writes
One door, and it is narrow
A general file-writing tool exists in the same server. For wiki pages it is off limits. Every durable fact enters through a single 207-line module that would rather refuse than create a near-duplicate page.
The problem wiki_append solves is not writing — that is one call to
writeFileSync. It is entropy. An agent that can create pages will create
project-x, projectx and project-x-notes over three
sessions, and restate the same fact in each of them. Six months later the wiki retrieves badly
because it has become three shallow copies of itself.
So the module inverts the default. Creating a page is opt-in and requires an explicit flag; appending to a page that already exists is the fast path; and a fact that resembles one already on the page is silently declined.
This is the ONLY safe way to write to the wiki — all agents must use it instead of files_write for wiki pages.
src/tools/wiki_append.ts:93 — tool description
Five guarantees, in order
| Guarantee | Mechanism | Source |
|---|---|---|
| Routes to the right existing page | Exact slug match, then a character-level similarity score over every page; candidates are returned instead of a new page being created | :121-145 |
| Never writes the same fact twice | Token Jaccard ≥ 0.8 against every existing bullet, plus substring containment in either direction | :153-163 |
| Adds explicit graph edges | Each entry in links is slugified and rendered as [[slug]], empty ones dropped |
:165 |
| Safe under concurrency | Exclusive open(…, 'wx') lock file, 25 attempts at 150 ms, released in finally |
:77-86, :199-204 |
| Every change is recoverable | git add -A and git commit per append; failure is caught and reported, not fatal |
:181-186 |
isError.
Why character similarity and not embeddings
Routing a slug is a spelling problem, not a semantic one. The comment in the source names the two failure modes it is built for, and both are lexical:
// Character-level slug similarity — catches typos ("wikiappnd"~"wikiappend") and // subsets ("<name>"~"<name>-<surname>") that token-jaccard misses. function slugSim(a: string, b: string): number { const ca = a.replace(/[^a-z0-9]/g, ''); const cb = b.replace(/[^a-z0-9]/g, ''); if (!ca || !cb) return 0; if (ca === cb) return 1; if (ca.includes(cb) || cb.includes(ca)) { return Math.max(0.7, Math.min(ca.length, cb.length) / Math.max(ca.length, cb.length)); } return Math.max( jaccard(trigrams(a), trigrams(b)), jaccard(tokens(a.replace(/-/g, ' ')), tokens(b.replace(/-/g, ' '))), ); }
The substring rule has a deliberate floor of 0.7. Without it, a three-character slug inside a thirteen-character one would score 0.23 and never surface as a candidate — exactly the “first name versus full name” case the comment calls out. (The comment's second example names a real person; it is replaced with placeholders above.)
De-duplication is textual too
The same Jaccard machinery, at a much higher threshold, decides whether a fact is already on the
page. Before comparing, each existing bullet is stripped back to its bare claim — the leading
dash, any [[wikilinks]], and the trailing (added …) stamp all come off,
so formatting never counts as content.
Declines the write
- Word-set Jaccard against an existing bullet is ≥ 0.8.
- Or the normalised new fact contains an existing bullet.
- Or an existing bullet contains the normalised new fact.
- Response quotes the first 80 characters of what it matched, so the agent can see the collision.
Lets it through
- Same subject, different claim — shared words rarely reach 0.8 across a full sentence.
- A restatement in genuinely different vocabulary. There is no semantic check here; a paraphrase gets a second bullet.
- Anything on a different page. De-duplication is per page, never corpus-wide.
What the file ends up looking like
For a page that does not exist yet and is being created deliberately, the module writes a minimal
scaffold: YAML frontmatter with a title de-slugified into title case, an <h1>
of the same, and an ## Updates heading. Then the bullet.
Appends land at end of file, not under the heading
The module ensures an ## Updates section exists, then appends the bullet to the end
of the file (wiki_append.ts:174-177). On a page whose ## Updates
heading is followed by further sections, new bullets accumulate after the last section
rather than under Updates. On pages the module itself created, the two are the same place.
Durability
Two records survive every successful append: a git commit whose subject names the page, and a line in an append-only JSONL journal. Neither is load-bearing for the tool's response — both are wrapped in try/catch and their failure is reported rather than raised.
# after writeFileSync succeeds — wiki_append.ts:182-185 $ git -C <wiki> add -A $ git -C <wiki> commit -q -m "wiki_append: <slug>" # both awaited; a rejection here sets committed = false and is otherwise ignored $ git -C <wiki> log --oneline -3 a1b2c3d wiki_append: <slug> 9f8e7d6 wiki_append: <other-slug> 4c5b6a7 wiki_append: <other-slug> # the journal — one JSON object per line, appended by read-then-rewrite $ tail -n 1 <state>/memory-librarian/wiki-writes.jsonl {"t":"2026-07-28T00:00:00.000Z","slug":"<slug>","content":"<the durable fact>","created":false,"committed":true}
Commit hashes, slugs and fact text are placeholders. The JSONL shape is exactly the object
literal at src/tools/wiki_append.ts:191.
The journal stores content in the clear
The SQLite audit ledger is careful — it records only sha256(args) truncated to
sixteen characters, so arguments can be correlated but not read back
(src/audit.ts:40). The wiki-write journal takes the opposite approach and stores
the full fact text. Both are on the same disk, but they warrant different handling: one is a
metadata trail, the other is a second copy of the knowledge base.
The journal is also rewritten whole on every append — read the file, concatenate one line, write
it back (wiki_append.ts:190-191). Correct, and O(n) in journal size per write.
The other write tools
Three tools can put bytes on disk. Only one of them is allowed near the wiki, and the boundaries of the other two are worth stating precisely.
- wiki_append write
- Curated pages only. Routes, de-duplicates, links, locks, commits. The sanctioned path.
- files_write write
-
Gateway box only, no remote form. The resolved path must start with
$HOME/—/etcand/tmpare refused by construction, and~/is expanded before resolution. Parent directories are created. Modes:overwrite(default) orappend, the latter implemented as read-then-write. - files_wiki_ingest write
-
Writes only into
<wiki>/raw/, the directory the page walker ignores. The filename is sanitised to[a-zA-Z0-9._-]. Aurlargument is parsed as a URL and rejected unless the protocol is http or https.
The $HOME check in files_write is a prefix test on the resolved absolute
path, performed after path.resolve, so ../ traversal collapses before it
is evaluated. It is a containment boundary rather than a permission model: anything the gateway
user can write inside their home directory is writable.
Policy lives in the description, not the schema
Nothing in the code prevents an agent from calling files_write with a path inside the
wiki. The prohibition is enforced by text — in the tool description, and again in the instruction
block every client receives on connect. This is the same bet the retrieval router makes: that a
capable model reading clear rules is a cheaper enforcement mechanism than a policy engine, and
that the audit ledger catches what persuasion misses.
Ask before creating NEW pages or merges; simple appends to an obvious existing page can be made then shown.
src/server.ts:97 — client instructions
Slugs, page names, commit hashes and fact text shown here are placeholders. No content from the live wiki or its git history appears on this site.