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

What the tool promises, and where it is implemented
GuaranteeMechanismSource
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
The wiki_append pipeline, including its three early exits A call carrying a slug, content and optional links is slugified, then matched against existing pages. An exact match proceeds; otherwise pages are scored by character similarity and, unless create_new is set, the five best candidates are returned and nothing is written. The write branch acquires an exclusive lock file, scans existing bullets for a near-duplicate and exits if one is found, composes a dated bullet with wikilinks, scaffolds frontmatter and an Updates heading for a new page, writes the file, commits to git and appends a line to a JSONL journal. The lock is released in a finally block. An inset panel shows the similarity scoring ladder. input routing critical section after wiki_append({ slug, content, links?, create_new? }) content: ONE clean durable fact, no bullet prefix slugify(slug) lowercase · [^a-z0-9]+ → “-” · trimmed · max 60 chars exact slug match among existing pages? yes → existing page is the target score every page with slugSim(), keep ≥ 0.4 sort descending, take the best five create_new ? scaffold a new page : return the candidates EXIT — nothing written “No exact page … Did you mean: a, b, c? … or pass create_new=true” returned as success, not as an error critical section — guarded by <wiki>/.wiki-append.lock acquireLock — openSync(path, "wx") 25 attempts, 150 ms apart — about 3.75 s of patience EXIT — lock unavailable “another write in progress” duplicate scan over every “- ” bullet cleanBullet strips the dash, the [[links]] and the date stamp skip if jaccard ≥ 0.8 or either string contains the other EXIT — near-duplicate quotes the bullet it matched compose: - <content> [[link]] [[link]] (added YYYY-MM-DD) scaffold frontmatter + “## Updates” if absent · writeFileSync git add -A git commit -q -m "wiki_append: <slug>" failure caught — nothing-to-commit is fine journal append {t, slug, content, created, committed} one JSON object per line, best-effort finally — release the lock closeSync then unlinkSync, both swallowed runs on every path, including the exits slugSim(a, b) — the routing score, wiki_append.ts:42-54 strip everything but [a-z0-9] from both slugs, then: identical strings 1.00 one contains the other max(0.7, shorter ÷ longer) — catches subset names ≥ 0.70 otherwise, the greater of two Jaccard scores: · trigram sets, padded with spaces — catches typos · word sets, hyphens split as spaces 0.4 — the floor for appearing as a candidate below 0.4 with create_new unset — the page is created anyway, because the candidate list was empty. wiki_append.ts:135
Three ways to be told no. Two of the three early exits return successfully — the model receives prose telling it what to do next rather than an error it might retry blindly. Only the lock failure is flagged 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:

src/tools/wiki_append.ts:40-54slugSim()
// 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.

the subprocesses wiki_append runs, in order
# 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.

Two records, two different jobs

The SQLite audit ledger records only sha256(args) truncated to sixteen characters, so calls can be correlated but not read back (src/audit.ts:40). The wiki-write journal is the opposite kind of artefact: it keeps the fact text, because its job is to let a lost or corrupted page be reconstructed. One is a metadata trail; the other is a second copy of the knowledge base, and the two are handled accordingly.

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//etc and /tmp are refused by construction, and ~/ is expanded before resolution. Parent directories are created. Modes: overwrite (default) or append, 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._-]. A url argument 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 to a plain path before the test ever sees it. Ordering matters here: resolve first, compare second, is what makes a prefix test meaningful at all.

Policy lives in the description, not the schema

Keeping wiki pages out of files_write is a curation rule, and it is expressed as one — 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 steering mechanism than a policy engine encoding the same intent, and that the audit ledger is where the record of what actually happened lives.

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.