@cerefox/memory 1.3.0-beta.1 → 1.3.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT_GUIDE.md CHANGED
@@ -140,7 +140,7 @@ Add text to a document **without resending it**. Purely additive: this tool cann
140
140
  | `text` | Yes | Markdown to insert. Blank-line separation from surrounding content is handled for you. |
141
141
  | `position` | Yes | `end_of_document` (plain append) · `end_of_section` (add to a section's body — the most common mid-document add) · `after_heading` (lead-in text) · `before_heading` (a new block above a section). |
142
142
  | `anchor_heading` | Unless `end_of_document` | The exact heading line (`## Intake`) or a ` > ` parent path (`## Intake > ### Notes`) when a heading appears more than once. |
143
- | `section_part` | Sometimes | Only when the target section has BOTH its own content and child sections: `own_body` (before the first child) or `subtree` (after everything nested under it). If it is needed, the error tells you and lists both options. |
143
+ | `section_part` | Sometimes | Required when the target section **has child sections** (whether or not it also has its own body): `own_body` (before the first child) or `subtree` (after everything nested under it). These can be far apart, so the tool refuses rather than choosing; the error lists both options. |
144
144
  | `expected_content_hash` | **Yes** | The hash of the version you are basing this on. There is **no `last_write_wins` on this tool**. |
145
145
  | `requestor` | No | Your agent name. |
146
146
 
@@ -163,6 +163,15 @@ Change parts of a document: **one to many operations applied atomically in a sin
163
163
 
164
164
  **Put changes that belong together in ONE call.** Operations apply in order against the evolving document (op 2 sees op 1's result), and a half-applied state is impossible — so a table row and the running total it feeds cannot end up disagreeing. If any operation fails (bad anchor, ambiguity), nothing at all is written and the error names the failing operation.
165
165
 
166
+ **One sharp edge worth knowing.** A section runs to the next heading of the same
167
+ or higher level — **or to the end of the document**. So the last section owns
168
+ everything appended after it: an `end_of_document` insert becomes part of that
169
+ section's body, and a later `replace_section` or `delete_section` on that heading
170
+ removes it along with the rest. This is correct addressing, not a bug, but it is
171
+ silent. If a write reports a large shrink, that is the warning; the previous
172
+ content is in `cerefox_list_versions`. To append somewhere a later section edit
173
+ cannot swallow, give the appended material its own heading.
174
+
166
175
  **To change a single line**, `replace_section` on its smallest enclosing heading and resend just that section. That is the intended granularity — line-level anchors were deliberately excluded because they silently edit the wrong place.
167
176
 
168
177
  The audit trail records each operation distinctly (`insert` / `replace-section` / `delete-section`), so *added to*, *rewrote* and *removed* stay distinguishable from a full rewrite.
@@ -50,6 +50,11 @@ diff. Use the partial-edit tools instead:
50
50
  conflict means someone else changed the document; re-read and decide, do not
51
51
  force it.
52
52
 
53
+ **A section runs to the next same-or-higher heading, or to the end of the
54
+ document.** So `end_of_document` inserts land inside the *last* section, and
55
+ replacing or deleting that section removes them too. A large shrink in the
56
+ response is your warning; `cerefox_list_versions` has the previous content.
57
+
53
58
  **When an anchor is ambiguous the tool refuses and hands you the options** — a
54
59
  repeated heading returns the qualifying paths, and a section with both its own
55
60
  content and sub-sections returns both `section_part` choices. That is a
@@ -7438,7 +7438,7 @@ var exports_meta = {};
7438
7438
  __export(exports_meta, {
7439
7439
  PKG_VERSION: () => PKG_VERSION
7440
7440
  });
7441
- var PKG_VERSION = "1.3.0-beta.1";
7441
+ var PKG_VERSION = "1.3.0-beta.3";
7442
7442
  var init_meta = () => {};
7443
7443
 
7444
7444
  // ../../_shared/config/paths.ts
@@ -26304,9 +26304,6 @@ function resolveAnchor(outline, anchorHeading) {
26304
26304
  }
26305
26305
  throw new AnchorNotFoundError(anchor, outline);
26306
26306
  }
26307
- function hasOwnBody(content, node) {
26308
- return content.slice(node.bodyStart, node.ownBodyEnd).trim().length > 0;
26309
- }
26310
26307
  function firstChild(outline, node) {
26311
26308
  for (const n of outline) {
26312
26309
  if (n.start >= node.bodyStart && n.start < node.subtreeEnd)
@@ -26314,7 +26311,7 @@ function firstChild(outline, node) {
26314
26311
  }
26315
26312
  return null;
26316
26313
  }
26317
- function resolveSectionEnd(content, outline, node, sectionPart, opName, destructive) {
26314
+ function resolveSectionEnd(content, outline, node, sectionPart, opName) {
26318
26315
  const child = firstChild(outline, node);
26319
26316
  if (!child)
26320
26317
  return node.subtreeEnd;
@@ -26322,11 +26319,6 @@ function resolveSectionEnd(content, outline, node, sectionPart, opName, destruct
26322
26319
  return node.ownBodyEnd;
26323
26320
  if (sectionPart === "subtree")
26324
26321
  return node.subtreeEnd;
26325
- if (!hasOwnBody(content, node)) {
26326
- if (!destructive)
26327
- return node.subtreeEnd;
26328
- throw new AmbiguousPositionError(node, child.heading, opName);
26329
- }
26330
26322
  throw new AmbiguousPositionError(node, child.heading, opName);
26331
26323
  }
26332
26324
  function spliceBlock(content, from, to, text) {
@@ -26370,7 +26362,7 @@ function applyOne(content, operation) {
26370
26362
  at = node2.bodyStart;
26371
26363
  detail = "insert after_heading";
26372
26364
  } else {
26373
- at = resolveSectionEnd(content, outline, node2, operation.section_part, "end_of_section insert", false);
26365
+ at = resolveSectionEnd(content, outline, node2, operation.section_part, "end_of_section insert");
26374
26366
  detail = `insert at end_of_section` + (operation.section_part ? ` (${operation.section_part})` : "");
26375
26367
  }
26376
26368
  return {
@@ -26380,7 +26372,7 @@ function applyOne(content, operation) {
26380
26372
  }
26381
26373
  if (operation.op === "replace_section") {
26382
26374
  const node2 = resolveAnchor(outline, operation.anchor_heading);
26383
- const to2 = resolveSectionEnd(content, outline, node2, operation.section_part, "replace_section", true);
26375
+ const to2 = resolveSectionEnd(content, outline, node2, operation.section_part, "replace_section");
26384
26376
  return {
26385
26377
  content: spliceBlock(content, node2.bodyStart, to2, operation.text),
26386
26378
  applied: {
@@ -26392,7 +26384,7 @@ function applyOne(content, operation) {
26392
26384
  }
26393
26385
  const node = resolveAnchor(outline, operation.anchor_heading);
26394
26386
  const scope = operation.scope ?? "body_only";
26395
- const to = resolveSectionEnd(content, outline, node, operation.section_part, "delete_section", true);
26387
+ const to = resolveSectionEnd(content, outline, node, operation.section_part, "delete_section");
26396
26388
  const from = scope === "heading_and_body" ? node.start : node.bodyStart;
26397
26389
  return {
26398
26390
  content: spliceBlock(content, from, to, ""),
@@ -26497,14 +26489,14 @@ ${outline.map((n) => ` ${n.path}`).join(`
26497
26489
  const candidates = [
26498
26490
  {
26499
26491
  section_part: "own_body",
26500
- description: `the section's own content, before its first child (${firstChildHeading})`
26492
+ description: `just this section's own content, stopping before its first child ` + `(${firstChildHeading}) — often only a line or two below the heading`
26501
26493
  },
26502
26494
  {
26503
26495
  section_part: "subtree",
26504
- description: `the whole subtree, including everything nested under ${node.heading}`
26496
+ description: `the whole subtree, past everything nested under ${node.heading}` + `which can be a long way down`
26505
26497
  }
26506
26498
  ];
26507
- super(`Ambiguous position: "${node.path}" has both its own content and child sections, ` + `so ${opName} could target two different ranges. No write was performed. ` + `Pass section_part to choose:
26499
+ super(`Ambiguous position: "${node.path}" has child sections, so "the end of ` + `the section" could mean two different places and ${opName} will not guess. ` + `No write was performed. Pass section_part to choose:
26508
26500
  ` + candidates.map((c2) => ` section_part: "${c2.section_part}" — ${c2.description}`).join(`
26509
26501
  `));
26510
26502
  this.name = "AmbiguousPositionError";
@@ -55069,11 +55061,11 @@ var init_get_document = __esm(() => {
55069
55061
  });
55070
55062
 
55071
55063
  // ../../_shared/mcp-tools/get-help-content.ts
55072
- var HELP_FULL = '# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **16 MCP tools** (15 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for "How AI Agents Use Cerefox" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`), `expected_content_hash` (required) |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash | `document_id` (required), `outline` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project\'s docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc\'s project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.\n\n## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so "I meant to append" cannot become "I replaced the file".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., "Claude Code", "archiver"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user\'s `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` ("decision-log", "research", "design-doc") and `status` ("active", "draft").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don\'t write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don\'t construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it\'s stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer\'s work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates\' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc\'s full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean "add" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch("topic") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title="Same Title", content="...", document_id="abc123",\n expected_content_hash="<the hash you read>", author="my-agent")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch("topic") -> find doc (note its hash) -> modify ->\ningest(title="Same Title", content="...", update_if_exists=true,\n expected_content_hash="<the hash you read>", author="my-agent")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={"type": "decision-log"}, updated_since="2026-03-28T00:00:00Z")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search "<q>" --requestor "<your-name>"` |\n| `cerefox_ingest` (paste) | `printf \'...\' \\| cerefox document ingest --paste --title "<t>" --author "<your-name>" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf \'...\' \\| cerefox document ingest --paste --title "<t>" --document-id "<uuid>" --expected-content-hash "<hash>" --author "<your-name>" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor "<your-name>"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor "<your-name>"` |\n| `cerefox_list_projects` | `cerefox project list --requestor "<your-name>"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter \'<json>\' --requestor "<your-name>"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author "<your-name>" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor "<your-name>"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author "<your-name>" --author-type agent`\n- Reads: `--requestor "<your-name>"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n', HELP_SECTIONS, HELP_SECTION_HEADINGS;
55064
+ var HELP_FULL = '# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **16 MCP tools** (15 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for "How AI Agents Use Cerefox" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`), `expected_content_hash` (required) |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash | `document_id` (required), `outline` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project\'s docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc\'s project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.\n\n## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so "I meant to append" cannot become "I replaced the file".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**A section runs to the next same-or-higher heading, or to the end of the\ndocument.** So `end_of_document` inserts land inside the *last* section, and\nreplacing or deleting that section removes them too. A large shrink in the\nresponse is your warning; `cerefox_list_versions` has the previous content.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., "Claude Code", "archiver"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user\'s `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` ("decision-log", "research", "design-doc") and `status` ("active", "draft").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don\'t write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don\'t construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it\'s stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer\'s work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates\' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc\'s full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean "add" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch("topic") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title="Same Title", content="...", document_id="abc123",\n expected_content_hash="<the hash you read>", author="my-agent")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch("topic") -> find doc (note its hash) -> modify ->\ningest(title="Same Title", content="...", update_if_exists=true,\n expected_content_hash="<the hash you read>", author="my-agent")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={"type": "decision-log"}, updated_since="2026-03-28T00:00:00Z")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search "<q>" --requestor "<your-name>"` |\n| `cerefox_ingest` (paste) | `printf \'...\' \\| cerefox document ingest --paste --title "<t>" --author "<your-name>" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf \'...\' \\| cerefox document ingest --paste --title "<t>" --document-id "<uuid>" --expected-content-hash "<hash>" --author "<your-name>" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor "<your-name>"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor "<your-name>"` |\n| `cerefox_list_projects` | `cerefox project list --requestor "<your-name>"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter \'<json>\' --requestor "<your-name>"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author "<your-name>" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor "<your-name>"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author "<your-name>" --author-type agent`\n- Reads: `--requestor "<your-name>"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n', HELP_SECTIONS, HELP_SECTION_HEADINGS;
55073
55065
  var init_get_help_content = __esm(() => {
55074
55066
  HELP_SECTIONS = {
55075
55067
  Tools: "## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`), `expected_content_hash` (required) |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash | `document_id` (required), `outline` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.",
55076
- "Editing part of a document (prefer this over re-sending)": '## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so "I meant to append" cannot become "I replaced the file".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.',
55068
+ "Editing part of a document (prefer this over re-sending)": '## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so "I meant to append" cannot become "I replaced the file".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**A section runs to the next same-or-higher heading, or to the end of the\ndocument.** So `end_of_document` inserts land inside the *last* section, and\nreplacing or deleting that section removes them too. A large shrink in the\nresponse is your warning; `cerefox_list_versions` has the previous content.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.',
55077
55069
  "Essential Rules": '## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., "Claude Code", "archiver"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user\'s `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` ("decision-log", "research", "design-doc") and `status` ("active", "draft").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don\'t write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don\'t construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it\'s stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer\'s work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates\' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc\'s full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean "add" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.',
55078
55070
  "Update Workflow (ID-based -- preferred)": `## Update Workflow (ID-based -- preferred)
55079
55071
 
@@ -55454,6 +55446,16 @@ function resolveAuthorType2(ctx, args) {
55454
55446
  function defaultRequestor(ctx) {
55455
55447
  return ctx.accessPath === "cli" ? "cli-user" : "mcp-agent";
55456
55448
  }
55449
+ function shrinkNote(before, after) {
55450
+ const lost = before - after;
55451
+ if (lost <= 0)
55452
+ return "";
55453
+ const pct = Math.round(lost / Math.max(before, 1) * 100);
55454
+ if (pct < 25)
55455
+ return "";
55456
+ return `⚠ This edit removed ${lost} characters (${pct}% smaller). If you did not ` + `intend that, note that a section runs to the next heading of the same or ` + `higher level — or to the end of the document — so replacing or deleting ` + `the LAST section also removes anything appended after it. ` + `cerefox_list_versions has the previous content.
55457
+ `;
55458
+ }
55457
55459
  function conflictError2(documentId, expectedHash, currentHash) {
55458
55460
  return new Error(`Conflict: document ${documentId} changed since you read it ` + `(your base hash: ${expectedHash}, current hash: ${currentHash}). No write was performed. ` + `To resolve: (1) cerefox_get_document("${documentId}", outline=true) to see the current ` + `structure and hash cheaply, or a full read if you need the text, (2) decide whether your ` + `edit still applies — another writer may have already made it, or made something it ` + `contradicts, (3) retry with expected_content_hash set to the current hash. ` + `These tools have no last-write-wins: the other writer's work is not yours to discard.`);
55459
55461
  }
@@ -55560,8 +55562,8 @@ async function applyAndWrite(supabase, ctx, args) {
55560
55562
  ${summary}
55561
55563
 
55562
55564
  ` + `New content_hash: ${row?.content_hash ?? newHash}
55563
- ` + `Size: ${row?.total_chars ?? totalChars} chars, ${chunks.length} chunk(s).
55564
- ` + `Pass the new content_hash as expected_content_hash on your next edit.${warning2}`;
55565
+ ` + `Size: ${row?.total_chars ?? totalChars} chars (was ${doc.content.length}), ${chunks.length} chunk(s).
55566
+ ` + shrinkNote(doc.content.length, row?.total_chars ?? totalChars) + `Pass the new content_hash as expected_content_hash on your next edit.${warning2}`;
55565
55567
  }
55566
55568
  async function insertHandler(supabase, args, ctx) {
55567
55569
  const documentId = args.document_id?.trim();
@@ -55654,7 +55656,7 @@ var init_partial_edits2 = __esm(() => {
55654
55656
  section_part: {
55655
55657
  type: "string",
55656
55658
  enum: ["own_body", "subtree"],
55657
- description: "Only for end_of_section when the section has BOTH its own content and child sections: own_body = before the first child, subtree = after everything nested under it. Omit otherwise; you will be told (with both options) if it is needed."
55659
+ description: "Only for end_of_section when the target section HAS CHILD SECTIONS: own_body = before the first child, subtree = after everything nested under it. These can be far apart, so the tool refuses rather than choosing. Omit it otherwise; you will be told (with both options) whenever it is needed."
55658
55660
  },
55659
55661
  expected_content_hash: {
55660
55662
  type: "string",
@@ -55710,7 +55712,7 @@ var init_partial_edits2 = __esm(() => {
55710
55712
  section_part: {
55711
55713
  type: "string",
55712
55714
  enum: ["own_body", "subtree"],
55713
- description: "Only when the target section has BOTH its own content and child sections. You will be told (with both options) if it is needed."
55715
+ description: "Only when the target section has child sections. You will be told (with both options) whenever it is needed."
55714
55716
  },
55715
55717
  scope: {
55716
55718
  type: "string",
@@ -76319,8 +76321,8 @@ import { homedir as homedir6 } from "node:os";
76319
76321
  import { join as join9 } from "node:path";
76320
76322
 
76321
76323
  // ../../_shared/ef-meta/index.ts
76322
- var EF_VERSION = "1.3.0-beta.1";
76323
- var EF_LAST_CHANGED = "1.3.0-beta.1";
76324
+ var EF_VERSION = "1.3.0-beta.3";
76325
+ var EF_LAST_CHANGED = "1.3.0-beta.3";
76324
76326
 
76325
76327
  // src/cli/util/checks.ts
76326
76328
  init_config();
@@ -78038,7 +78040,7 @@ async function runTool(toolName, args) {
78038
78040
  }
78039
78041
  }
78040
78042
  function registerDocumentInsert(program2) {
78041
- program2.command("insert <document-id>").description("Add text to a document without resending it (purely additive)").requiredOption("-t, --text <text>", "Markdown to insert. Use '-' for stdin or '@path' for a file.").option("-p, --position <position>", "end_of_document | end_of_section | after_heading | before_heading", "end_of_document").option("-a, --anchor-heading <heading>", "Heading line, or a ' > ' path. Required unless end_of_document.").option("--section-part <part>", "own_body | subtree — only when a section has both content and children").requiredOption("-e, --expected-content-hash <hash>", "content_hash you are basing this on (cerefox document get --outline shows it)").option("--requestor <name>", "Recorded in the usage log", "cli-user").option("--author-type <type>", "user (default for the CLI) or agent, when scripting on an agent's behalf", "user").action(async (documentId, options) => {
78043
+ program2.command("insert <document-id>").description("Add text to a document without resending it (purely additive)").requiredOption("-t, --text <text>", "Markdown to insert. Use '-' for stdin or '@path' for a file.").option("-p, --position <position>", "end_of_document | end_of_section | after_heading | before_heading", "end_of_document").option("-a, --anchor-heading <heading>", "Heading line, or a ' > ' path. Required unless end_of_document.").option("--section-part <part>", "own_body | subtree — required when the target section has child sections").requiredOption("-e, --expected-content-hash <hash>", "content_hash you are basing this on (cerefox document get --outline shows it)").option("--requestor <name>", "Recorded in the usage log", "cli-user").option("--author-type <type>", "user (default for the CLI) or agent, when scripting on an agent's behalf", "user").action(async (documentId, options) => {
78042
78044
  await runTool("cerefox_insert", {
78043
78045
  document_id: documentId,
78044
78046
  text: resolveText(options.text, "--text"),
@@ -18,7 +18,7 @@
18
18
  * doesn't touch `supabase/functions/` leaves it alone).
19
19
  */
20
20
 
21
- export const EF_VERSION = "1.3.0-beta.1";
21
+ export const EF_VERSION = "1.3.0-beta.3";
22
22
 
23
23
  /**
24
24
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -28,7 +28,7 @@ export const EF_VERSION = "1.3.0-beta.1";
28
28
  * `cut_release.ts` ONLY when EF source changed since the last tag; doctor
29
29
  * uses it to stay silent on label-only drift.
30
30
  */
31
- export const EF_LAST_CHANGED = "1.3.0-beta.1";
31
+ export const EF_LAST_CHANGED = "1.3.0-beta.3";
32
32
 
33
33
  /**
34
34
  * The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
@@ -11,12 +11,12 @@
11
11
  * docs/specs/polish-and-distribution-design.md §10d.
12
12
  */
13
13
 
14
- export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **16 MCP tools** (15 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`), `expected_content_hash` (required) |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash | `document_id` (required), `outline` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.\n\n## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so \"I meant to append\" cannot become \"I replaced the file\".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
14
+ export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **16 MCP tools** (15 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`), `expected_content_hash` (required) |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash | `document_id` (required), `outline` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.\n\n## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so \"I meant to append\" cannot become \"I replaced the file\".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**A section runs to the next same-or-higher heading, or to the end of the\ndocument.** So `end_of_document` inserts land inside the *last* section, and\nreplacing or deleting that section removes them too. A large shrink in the\nresponse is your warning; `cerefox_list_versions` has the previous content.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
15
15
 
16
16
  /** Sections keyed by their H2 heading text (lower-cased for matching). */
17
17
  export const HELP_SECTIONS: Record<string, string> = {
18
18
  "Tools": "## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`), `expected_content_hash` (required) |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash | `document_id` (required), `outline` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.",
19
- "Editing part of a document (prefer this over re-sending)": "## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so \"I meant to append\" cannot become \"I replaced the file\".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.",
19
+ "Editing part of a document (prefer this over re-sending)": "## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so \"I meant to append\" cannot become \"I replaced the file\".\n3. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround.\n4. Both require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**A section runs to the next same-or-higher heading, or to the end of the\ndocument.** So `end_of_document` inserts land inside the *last* section, and\nreplacing or deleting that section removes them too. A large shrink in the\nresponse is your warning; `cerefox_list_versions` has the previous content.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.",
20
20
  "Essential Rules": "## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.",
21
21
  "Update Workflow (ID-based -- preferred)": "## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.",
22
22
  "Update Workflow (title-based -- fallback)": "## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```",
@@ -53,6 +53,35 @@ function defaultRequestor(ctx: ToolContext): string {
53
53
  return ctx.accessPath === "cli" ? "cli-user" : "mcp-agent";
54
54
  }
55
55
 
56
+
57
+ /**
58
+ * Surface a large shrink in the response the agent already reads.
59
+ *
60
+ * A section extends to the next heading of equal-or-higher level **or to the
61
+ * end of the document**, so the last section owns everything appended after it.
62
+ * That means an `end_of_document` insert becomes part of the last section's
63
+ * body, and a later `replace_section` on that heading removes it — correctly by
64
+ * the addressing rules, and invisibly, because the response otherwise just says
65
+ * the operation applied. Observed on a real store during the v1.3.0 beta: an
66
+ * appended entry vanished under a routine-looking section replace.
67
+ *
68
+ * The full-document diff is deliberately not returned (§3.8), so the size delta
69
+ * is the cheapest honest signal available.
70
+ */
71
+ function shrinkNote(before: number, after: number): string {
72
+ const lost = before - after;
73
+ if (lost <= 0) return "";
74
+ const pct = Math.round((lost / Math.max(before, 1)) * 100);
75
+ if (pct < 25) return "";
76
+ return (
77
+ `⚠ This edit removed ${lost} characters (${pct}% smaller). If you did not ` +
78
+ `intend that, note that a section runs to the next heading of the same or ` +
79
+ `higher level — or to the end of the document — so replacing or deleting ` +
80
+ `the LAST section also removes anything appended after it. ` +
81
+ `cerefox_list_versions has the previous content.\n`
82
+ );
83
+ }
84
+
56
85
  /** Audit `operation` values, matching the CHECK constraint widened by migration 0019. */
57
86
  const AUDIT_OP: Record<AppliedOperation["op"], string> = {
58
87
  insert: "insert",
@@ -263,7 +292,8 @@ async function applyAndWrite(
263
292
  return (
264
293
  `Applied ${applied.length} operation(s) to "${doc.title}" (id: ${documentId}):\n${summary}\n\n` +
265
294
  `New content_hash: ${row?.content_hash ?? newHash}\n` +
266
- `Size: ${row?.total_chars ?? totalChars} chars, ${chunks.length} chunk(s).\n` +
295
+ `Size: ${row?.total_chars ?? totalChars} chars (was ${doc.content.length}), ${chunks.length} chunk(s).\n` +
296
+ shrinkNote(doc.content.length, row?.total_chars ?? totalChars) +
267
297
  `Pass the new content_hash as expected_content_hash on your next edit.${warning}`
268
298
  );
269
299
  }
@@ -351,7 +381,7 @@ export const insertTool: ToolDefinition = {
351
381
  type: "string",
352
382
  enum: ["own_body", "subtree"],
353
383
  description:
354
- "Only for end_of_section when the section has BOTH its own content and child sections: own_body = before the first child, subtree = after everything nested under it. Omit otherwise; you will be told (with both options) if it is needed.",
384
+ "Only for end_of_section when the target section HAS CHILD SECTIONS: own_body = before the first child, subtree = after everything nested under it. These can be far apart, so the tool refuses rather than choosing. Omit it otherwise; you will be told (with both options) whenever it is needed.",
355
385
  },
356
386
  expected_content_hash: {
357
387
  type: "string",
@@ -458,7 +488,7 @@ export const editTool: ToolDefinition = {
458
488
  type: "string",
459
489
  enum: ["own_body", "subtree"],
460
490
  description:
461
- "Only when the target section has BOTH its own content and child sections. You will be told (with both options) if it is needed.",
491
+ "Only when the target section has child sections. You will be told (with both options) whenever it is needed.",
462
492
  },
463
493
  scope: {
464
494
  type: "string",
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Partial document edits — pure string layer (iteration 34).
3
+ *
4
+ * Implements the position/anchor semantics of
5
+ * `docs/specs/partial-document-edits-design.md` §3 exactly. No I/O, no client,
6
+ * no runtime dependencies: everything here is testable against strings alone
7
+ * and runs identically under Deno (Edge Function) and Node/Bun (local MCP,
8
+ * CLI). The handlers in `_shared/mcp-tools/{insert,edit}.ts` compose this with
9
+ * read → chunk → embed → `cerefox_ingest_document`.
10
+ *
11
+ * The one rule that governs every function: **never guess**. An absent anchor
12
+ * is an error, an ambiguous anchor is an error carrying the candidates that
13
+ * resolve it, and an ambiguous position (a section with both its own body and
14
+ * child headings) is an error carrying both concrete insertion points. A
15
+ * silent wrong-location write is strictly worse than a refusal (spec §3.7).
16
+ */
17
+
18
+ /** One section in document order. Offsets are into the parsed string. */
19
+ export interface OutlineNode {
20
+ /** Full heading line, trimmed — e.g. `### Notes`. */
21
+ heading: string;
22
+ /** 1–6. */
23
+ level: number;
24
+ /** ` > `-joined ancestor headings + own — the §3.7 anchor path form. */
25
+ path: string;
26
+ /** Offset of the heading line's first character. */
27
+ start: number;
28
+ /** Offset just past the heading line (start of the section's own body). */
29
+ bodyStart: number;
30
+ /** End of the section's own body: before its first child heading (== subtreeEnd for leaves). */
31
+ ownBodyEnd: number;
32
+ /** Before the next heading of equal-or-higher level (end of the whole subtree). */
33
+ subtreeEnd: number;
34
+ /** subtreeEnd - start: the per-section size reported by outline mode. */
35
+ chars: number;
36
+ }
37
+
38
+ export type InsertPosition =
39
+ | "end_of_document"
40
+ | "end_of_section"
41
+ | "after_heading"
42
+ | "before_heading";
43
+
44
+ export type SectionPart = "own_body" | "subtree";
45
+
46
+ export type EditOperation =
47
+ | {
48
+ op: "insert";
49
+ text: string;
50
+ position: InsertPosition;
51
+ anchor_heading?: string;
52
+ section_part?: SectionPart;
53
+ }
54
+ | {
55
+ op: "replace_section";
56
+ text: string;
57
+ anchor_heading: string;
58
+ section_part?: SectionPart;
59
+ }
60
+ | {
61
+ op: "delete_section";
62
+ anchor_heading: string;
63
+ scope?: "body_only" | "heading_and_body";
64
+ section_part?: SectionPart;
65
+ };
66
+
67
+ /** Echo of one applied operation, for audit labels and response text. */
68
+ export interface AppliedOperation {
69
+ op: "insert" | "replace_section" | "delete_section";
70
+ /** Resolved anchor path (or "(document)" for end_of_document). */
71
+ path: string;
72
+ /** Human summary: position / scope / section_part actually used. */
73
+ detail: string;
74
+ }
75
+
76
+ /** Anchor matched nothing. The write must never fall back to appending. */
77
+ export class AnchorNotFoundError extends Error {
78
+ constructor(anchor: string, outline: OutlineNode[]) {
79
+ const known = outline.length
80
+ ? ` Known headings:\n${outline.map((n) => ` ${n.path}`).join("\n")}`
81
+ : " The document has no headings.";
82
+ super(
83
+ `Anchor not found: "${anchor}". No write was performed.${known}\n` +
84
+ `Anchors match a heading line exactly ("## Title") or a parent path ` +
85
+ `("## Parent > ### Child").`,
86
+ );
87
+ this.name = "AnchorNotFoundError";
88
+ }
89
+ }
90
+
91
+ /** Anchor matched more than one section. Candidates resolve the retry. */
92
+ export class AmbiguousAnchorError extends Error {
93
+ readonly candidates: string[];
94
+ constructor(anchor: string, candidates: string[]) {
95
+ super(
96
+ `Ambiguous anchor: "${anchor}" matches ${candidates.length} sections. ` +
97
+ `No write was performed. Disambiguate by passing one of these paths as anchor_heading:\n` +
98
+ candidates.map((c) => ` ${c}`).join("\n"),
99
+ );
100
+ this.name = "AmbiguousAnchorError";
101
+ this.candidates = candidates;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The anchored section has both its own body and child headings, so "the
107
+ * section" has two defensible readings (spec §3.3). Never guessed.
108
+ */
109
+ export class AmbiguousPositionError extends Error {
110
+ readonly candidates: { section_part: SectionPart; description: string }[];
111
+ constructor(node: OutlineNode, firstChildHeading: string, opName: string) {
112
+ const candidates = [
113
+ {
114
+ section_part: "own_body" as const,
115
+ description:
116
+ `just this section's own content, stopping before its first child ` +
117
+ `(${firstChildHeading}) — often only a line or two below the heading`,
118
+ },
119
+ {
120
+ section_part: "subtree" as const,
121
+ description:
122
+ `the whole subtree, past everything nested under ${node.heading} — ` +
123
+ `which can be a long way down`,
124
+ },
125
+ ];
126
+ super(
127
+ `Ambiguous position: "${node.path}" has child sections, so "the end of ` +
128
+ `the section" could mean two different places and ${opName} will not guess. ` +
129
+ `No write was performed. Pass section_part to choose:\n` +
130
+ candidates.map((c) => ` section_part: "${c.section_part}" — ${c.description}`).join("\n"),
131
+ );
132
+ this.name = "AmbiguousPositionError";
133
+ this.candidates = candidates;
134
+ }
135
+ }
136
+
137
+ /** Structural validation failure of an operations array (before any parsing). */
138
+ export class InvalidOperationError extends Error {
139
+ constructor(index: number, message: string) {
140
+ super(`Invalid operation at index ${index}: ${message}. No write was performed.`);
141
+ this.name = "InvalidOperationError";
142
+ }
143
+ }
144
+
145
+ const ATX_HEADING = /^(#{1,6})[ \t]+(.*[^ \t#]|)[ \t]*#*[ \t]*$/;
146
+ const FENCE_OPEN = /^([ \t]{0,3})(`{3,}|~{3,})(.*)$/;
147
+
148
+ /**
149
+ * Parse the outline of a markdown document in one pass.
150
+ *
151
+ * ATX headings only (the chunker's convention). Headings inside fenced code
152
+ * blocks are content, not structure: the scanner tracks the open fence's
153
+ * marker character and length, and only a closing fence at least as long, of
154
+ * the same character, closes it (CommonMark). A decision log quoting markdown
155
+ * WILL contain `#` lines inside fences; treating those as headings would
156
+ * corrupt every anchor computed after them.
157
+ */
158
+ export function parseOutline(content: string): OutlineNode[] {
159
+ const nodes: OutlineNode[] = [];
160
+ const stack: OutlineNode[] = []; // open ancestors, strictly increasing level
161
+ let fence: { char: string; len: number } | null = null;
162
+
163
+ let offset = 0;
164
+ const lines = content.split("\n");
165
+ for (let i = 0; i < lines.length; i++) {
166
+ const line = lines[i];
167
+ const lineStart = offset;
168
+ offset += line.length + (i < lines.length - 1 ? 1 : 0);
169
+
170
+ const fenceMatch = line.match(FENCE_OPEN);
171
+ if (fence) {
172
+ if (
173
+ fenceMatch &&
174
+ fenceMatch[2][0] === fence.char &&
175
+ fenceMatch[2].length >= fence.len &&
176
+ fenceMatch[3].trim() === ""
177
+ ) {
178
+ fence = null; // closing fence
179
+ }
180
+ continue;
181
+ }
182
+ if (fenceMatch) {
183
+ fence = { char: fenceMatch[2][0], len: fenceMatch[2].length };
184
+ continue;
185
+ }
186
+
187
+ const m = line.match(ATX_HEADING);
188
+ if (!m) continue;
189
+
190
+ const level = m[1].length;
191
+ const bodyStart = lineStart + line.length + (i < lines.length - 1 ? 1 : 0);
192
+
193
+ // Close every open section at >= this level.
194
+ while (stack.length && stack[stack.length - 1].level >= level) {
195
+ const closed = stack.pop()!;
196
+ closed.subtreeEnd = lineStart;
197
+ if (closed.ownBodyEnd === -1) closed.ownBodyEnd = lineStart;
198
+ }
199
+ // This heading is the first child of the innermost still-open ancestor.
200
+ if (stack.length && stack[stack.length - 1].ownBodyEnd === -1) {
201
+ stack[stack.length - 1].ownBodyEnd = lineStart;
202
+ }
203
+
204
+ // CommonMark treats a trailing run of #s as decoration, so `## Title ##`
205
+ // and `## Title` name the same section. Store the canonical form: an agent
206
+ // that read the rendered document addresses it as `## Title`, and one that
207
+ // pasted an outline path gets the same string back (resolveAnchor
208
+ // canonicalises the incoming anchor too).
209
+ const heading = `${m[1]} ${m[2].trim()}`.trim();
210
+ const node: OutlineNode = {
211
+ heading,
212
+ level,
213
+ path: [...stack.map((a) => a.heading), heading].join(" > "),
214
+ start: lineStart,
215
+ bodyStart,
216
+ ownBodyEnd: -1, // resolved when the first child or the subtree end is seen
217
+ subtreeEnd: -1,
218
+ chars: 0,
219
+ };
220
+ nodes.push(node);
221
+ stack.push(node);
222
+ }
223
+
224
+ const end = content.length;
225
+ for (const open of stack) {
226
+ open.subtreeEnd = end;
227
+ if (open.ownBodyEnd === -1) open.ownBodyEnd = end;
228
+ }
229
+ for (const n of nodes) n.chars = n.subtreeEnd - n.start;
230
+ return nodes;
231
+ }
232
+
233
+ /**
234
+ * Resolve an anchor per spec §3.7: exact heading text, or a ` > ` parent path.
235
+ * 0 matches → AnchorNotFoundError; 2+ → AmbiguousAnchorError with the paths.
236
+ */
237
+ /** `## Title ##` and `## Title` name the same section (CommonMark decoration). */
238
+ function canonicalHeading(text: string): string {
239
+ const m = text.trim().match(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/);
240
+ return m ? `${m[1]} ${m[2].trim()}`.trim() : text.trim();
241
+ }
242
+
243
+ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): OutlineNode {
244
+ const anchor = canonicalHeading(anchorHeading);
245
+
246
+ // Try the LITERAL heading first, always — including when the anchor contains
247
+ // the path separator. Headings really do contain " > " (`## Draft > Review`,
248
+ // `## A > B`), and treating any such anchor as a path made those sections
249
+ // unaddressable by their own text: the agent would read `## A > B` from the
250
+ // outline, pass it back verbatim, and be told the anchor does not exist while
251
+ // the error listed it. Literal-first also keeps the outline's promise that
252
+ // what it prints can be pasted straight back.
253
+ const byHeading = outline.filter((n) => n.heading === anchor);
254
+ if (byHeading.length === 1) return byHeading[0];
255
+ if (byHeading.length > 1) {
256
+ throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path));
257
+ }
258
+
259
+ // No heading matched literally: interpret it as a parent path.
260
+ if (anchor.includes(" > ")) {
261
+ const normalizedPath = anchor.split(" > ").map((seg) => canonicalHeading(seg)).join(" > ");
262
+ const byPath = outline.filter((n) => n.path === normalizedPath);
263
+ if (byPath.length === 1) return byPath[0];
264
+ if (byPath.length > 1) {
265
+ throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path));
266
+ }
267
+ }
268
+
269
+ throw new AnchorNotFoundError(anchor, outline);
270
+ }
271
+
272
+ function firstChild(outline: OutlineNode[], node: OutlineNode): OutlineNode | null {
273
+ for (const n of outline) {
274
+ if (n.start >= node.bodyStart && n.start < node.subtreeEnd) return n;
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /**
280
+ * Resolve the [from, to) range a replace/delete targets, and the end offset an
281
+ * end_of_section insert lands at. Spec §3.3 + the freeze-pass rule: leaf →
282
+ * unambiguous; body+children → require section_part; children-without-body →
283
+ * subtree (the two readings coincide in intent: "everything under it").
284
+ */
285
+ function resolveSectionEnd(
286
+ content: string,
287
+ outline: OutlineNode[],
288
+ node: OutlineNode,
289
+ sectionPart: SectionPart | undefined,
290
+ opName: string,
291
+ ): number {
292
+ const child = firstChild(outline, node);
293
+ if (!child) return node.subtreeEnd; // leaf: unambiguous
294
+ if (sectionPart === "own_body") return node.ownBodyEnd;
295
+ if (sectionPart === "subtree") return node.subtreeEnd;
296
+
297
+ // A section with children is ambiguous, full stop — whether or not it has
298
+ // any body of its own, and whichever operation is asking.
299
+ //
300
+ // Two earlier versions of this function were wrong here, in opposite ways.
301
+ // The first returned subtreeEnd for every children-only section, so
302
+ // `delete_section` on a grouping heading silently removed every sub-section
303
+ // under it — with `scope: "body_only"`, whose whole promise is to keep the
304
+ // structure. A review caught that and the destructive path started refusing.
305
+ //
306
+ // The insert path kept the exemption, on the reasoning that "for an insert
307
+ // the two readings coincide, both landing at the section's terminus". That
308
+ // reasoning was simply false, and an agent editing a real document found it:
309
+ // for `## Parent` with children and no body of its own, `own_body` lands
310
+ // BEFORE the first child and `subtree` lands AFTER the last one. Those are
311
+ // different places — potentially pages apart — and the code was choosing
312
+ // silently. The agent's text went to the end of a long section, and it only
313
+ // discovered that by re-reading the document, which is the cost this feature
314
+ // exists to remove.
315
+ //
316
+ // So: no exemption. `hasOwnBody` no longer gates anything, because the
317
+ // presence of children is what makes "the end of this section" ambiguous.
318
+ throw new AmbiguousPositionError(node, child.heading, opName);
319
+ }
320
+
321
+ /**
322
+ * Splice `text` into `content` as a markdown block: exactly one blank line
323
+ * separates it from any non-empty neighbour, existing surrounding blank runs
324
+ * collapse rather than stack. Agents send content; the join owns whitespace.
325
+ */
326
+ function spliceBlock(content: string, from: number, to: number, text: string): string {
327
+ const before = content.slice(0, from).replace(/\n+$/, "");
328
+ const after = content.slice(to).replace(/^\n+/, "").replace(/\n+$/, "");
329
+ const block = text.replace(/^\n+/, "").replace(/\n+$/, "");
330
+
331
+ const parts: string[] = [];
332
+ if (before.length) parts.push(before);
333
+ if (block.length) parts.push(block);
334
+ if (after.length) parts.push(after);
335
+ const joined = parts.join("\n\n");
336
+ // Preserve a single trailing newline if the original ended with one.
337
+ return content.endsWith("\n") && joined.length ? joined + "\n" : joined;
338
+ }
339
+
340
+ function applyOne(
341
+ content: string,
342
+ operation: EditOperation,
343
+ ): { content: string; applied: AppliedOperation } {
344
+ const outline = parseOutline(content);
345
+
346
+ if (operation.op === "insert") {
347
+ const { position, text } = operation;
348
+ if (position === "end_of_document") {
349
+ return {
350
+ content: spliceBlock(content, content.length, content.length, text),
351
+ applied: { op: "insert", path: "(document)", detail: "insert at end_of_document" },
352
+ };
353
+ }
354
+ if (!operation.anchor_heading) {
355
+ // Validated earlier for real callers; guarded here for direct users.
356
+ throw new InvalidOperationError(0, `insert with position "${position}" requires anchor_heading`);
357
+ }
358
+ const node = resolveAnchor(outline, operation.anchor_heading);
359
+ let at: number;
360
+ let detail: string;
361
+ if (position === "before_heading") {
362
+ at = node.start;
363
+ detail = "insert before_heading";
364
+ } else if (position === "after_heading") {
365
+ at = node.bodyStart;
366
+ detail = "insert after_heading";
367
+ } else {
368
+ at = resolveSectionEnd(content, outline, node, operation.section_part, "end_of_section insert");
369
+ detail =
370
+ `insert at end_of_section` +
371
+ (operation.section_part ? ` (${operation.section_part})` : "");
372
+ }
373
+ return {
374
+ content: spliceBlock(content, at, at, text),
375
+ applied: { op: "insert", path: node.path, detail },
376
+ };
377
+ }
378
+
379
+ if (operation.op === "replace_section") {
380
+ const node = resolveAnchor(outline, operation.anchor_heading);
381
+ const to = resolveSectionEnd(content, outline, node, operation.section_part, "replace_section");
382
+ return {
383
+ content: spliceBlock(content, node.bodyStart, to, operation.text),
384
+ applied: {
385
+ op: "replace_section",
386
+ path: node.path,
387
+ detail:
388
+ "replace_section body" + (operation.section_part ? ` (${operation.section_part})` : ""),
389
+ },
390
+ };
391
+ }
392
+
393
+ // delete_section
394
+ const node = resolveAnchor(outline, operation.anchor_heading);
395
+ const scope = operation.scope ?? "body_only";
396
+ const to = resolveSectionEnd(content, outline, node, operation.section_part, "delete_section");
397
+ const from = scope === "heading_and_body" ? node.start : node.bodyStart;
398
+ return {
399
+ content: spliceBlock(content, from, to, ""),
400
+ applied: {
401
+ op: "delete_section",
402
+ path: node.path,
403
+ detail:
404
+ `delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : ""),
405
+ },
406
+ };
407
+ }
408
+
409
+ /** Structural validation of an operations array — index-precise, before any work. */
410
+ export function validateOperations(operations: unknown): EditOperation[] {
411
+ if (!Array.isArray(operations) || operations.length === 0) {
412
+ throw new InvalidOperationError(0, "operations must be a non-empty array");
413
+ }
414
+ return operations.map((raw, i) => {
415
+ if (typeof raw !== "object" || raw === null) {
416
+ throw new InvalidOperationError(i, "each operation must be an object");
417
+ }
418
+ const o = raw as Record<string, unknown>;
419
+ const op = o.op;
420
+ if (op === "insert") {
421
+ const position = o.position;
422
+ if (
423
+ position !== "end_of_document" &&
424
+ position !== "end_of_section" &&
425
+ position !== "after_heading" &&
426
+ position !== "before_heading"
427
+ ) {
428
+ throw new InvalidOperationError(
429
+ i,
430
+ "insert requires position: end_of_document | end_of_section | after_heading | before_heading",
431
+ );
432
+ }
433
+ if (typeof o.text !== "string" || o.text.trim() === "") {
434
+ throw new InvalidOperationError(i, "insert requires non-empty text");
435
+ }
436
+ if (position !== "end_of_document" && typeof o.anchor_heading !== "string") {
437
+ throw new InvalidOperationError(i, `insert at ${position} requires anchor_heading`);
438
+ }
439
+ if (position === "end_of_document" && o.anchor_heading !== undefined) {
440
+ throw new InvalidOperationError(i, "end_of_document takes no anchor_heading");
441
+ }
442
+ } else if (op === "replace_section") {
443
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
444
+ throw new InvalidOperationError(i, "replace_section requires anchor_heading");
445
+ }
446
+ if (typeof o.text !== "string" || o.text.trim() === "") {
447
+ throw new InvalidOperationError(i, "replace_section requires non-empty text");
448
+ }
449
+ } else if (op === "delete_section") {
450
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
451
+ throw new InvalidOperationError(i, "delete_section requires anchor_heading");
452
+ }
453
+ if (o.scope !== undefined && o.scope !== "body_only" && o.scope !== "heading_and_body") {
454
+ throw new InvalidOperationError(i, "scope must be body_only or heading_and_body");
455
+ }
456
+ } else {
457
+ throw new InvalidOperationError(i, "op must be insert | replace_section | delete_section");
458
+ }
459
+ if (
460
+ o.section_part !== undefined &&
461
+ o.section_part !== "own_body" &&
462
+ o.section_part !== "subtree"
463
+ ) {
464
+ throw new InvalidOperationError(i, "section_part must be own_body or subtree");
465
+ }
466
+ return raw as EditOperation;
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Apply operations in order against the evolving text (spec §3.4). All or
472
+ * nothing is upheld by construction: this function either returns the fully
473
+ * assembled result or throws before the caller writes anything — the write
474
+ * itself is a single ingest call downstream. Errors are re-thrown with the
475
+ * failing index prefixed so a batch caller can report it.
476
+ */
477
+ export function applyOperations(
478
+ content: string,
479
+ operations: EditOperation[],
480
+ ): { content: string; applied: AppliedOperation[] } {
481
+ let current = content;
482
+ const applied: AppliedOperation[] = [];
483
+ for (let i = 0; i < operations.length; i++) {
484
+ try {
485
+ const result = applyOne(current, operations[i]);
486
+ current = result.content;
487
+ applied.push(result.applied);
488
+ } catch (err) {
489
+ if (err instanceof InvalidOperationError) throw err;
490
+ const msg = err instanceof Error ? err.message : String(err);
491
+ const wrapped = new Error(`Operation ${i + 1} of ${operations.length} failed: ${msg}`);
492
+ wrapped.name = err instanceof Error ? err.name : "Error";
493
+ throw wrapped;
494
+ }
495
+ }
496
+ return { content: current, applied };
497
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.3.0-beta.1",
3
+ "version": "1.3.0-beta.3",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",