@cerefox/memory 1.3.0 → 1.5.0

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.
@@ -15,7 +15,7 @@
15
15
  href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"
16
16
  />
17
17
  <title>Cerefox</title>
18
- <script type="module" crossorigin src="/app/assets/index-B1pgikxA.js"></script>
18
+ <script type="module" crossorigin src="/app/assets/index-D9z5yV9u.js"></script>
19
19
  <link rel="stylesheet" crossorigin href="/app/assets/index-C1JXZA9m.css">
20
20
  </head>
21
21
  <body>
@@ -18,7 +18,25 @@
18
18
  * doesn't touch `supabase/functions/` leaves it alone).
19
19
  */
20
20
 
21
- export const EF_VERSION = "1.3.0";
21
+ export const EF_VERSION = "1.5.0";
22
+
23
+ /**
24
+ * The Cerefox RELEASE version — what `cerefox --version` reports and what npm
25
+ * published — bumped by `cut_release.ts` on every cut, pre-releases included.
26
+ *
27
+ * Distinct from `EF_VERSION`, which describes the deployed Edge Functions and
28
+ * bumps unconditionally only at *stable* cuts. The two are equal on a stable
29
+ * release and diverge during a beta, so reporting `EF_VERSION` to an agent
30
+ * would have told a beta tester the last stable number. Since the whole point
31
+ * of surfacing a version to agents (`cerefox_get_help(topic: "server")`) is
32
+ * letting them tell a stale client from a real capability gap, the number has
33
+ * to be the one the user would recognise.
34
+ *
35
+ * Lives here rather than in `packages/memory/src/meta.ts` because `_shared/`
36
+ * is imported by the Deno Edge Functions, which cannot reach into the npm
37
+ * package.
38
+ */
39
+ export const CEREFOX_VERSION = "1.5.0";
22
40
 
23
41
  /**
24
42
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -28,7 +46,7 @@ export const EF_VERSION = "1.3.0";
28
46
  * `cut_release.ts` ONLY when EF source changed since the last tag; doctor
29
47
  * uses it to stay silent on label-only drift.
30
48
  */
31
- export const EF_LAST_CHANGED = "1.3.0-beta.4";
49
+ export const EF_LAST_CHANGED = "1.5.0";
32
50
 
33
51
  /**
34
52
  * The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
@@ -8,6 +8,19 @@ import type { MCPSupabaseClient } from "./types.ts";
8
8
  import { logUsage } from "./_utils.ts";
9
9
  import type { ToolContext, ToolDefinition } from "./types.ts";
10
10
 
11
+ /**
12
+ * A timestamp an agent cannot mistake for local time (#199).
13
+ *
14
+ * `created_at` arrives as an ISO 8601 string in UTC. Truncating it to 19
15
+ * characters dropped the `Z`, and an agent reading `2026-08-11T06:32:13` while
16
+ * its own clock said 2026-08-10 concluded the server was a day ahead and dated
17
+ * its log entries accordingly. The instant was right; the label was missing.
18
+ */
19
+ function utcStamp(iso: string): string {
20
+ const trimmed = iso.slice(0, 19);
21
+ return trimmed.includes("T") ? `${trimmed}Z` : `${trimmed} UTC`;
22
+ }
23
+
11
24
  async function handler(
12
25
  supabase: MCPSupabaseClient,
13
26
  args: Record<string, unknown>,
@@ -56,7 +69,7 @@ async function handler(
56
69
  : e.size_after != null
57
70
  ? ` | ${e.size_after} chars`
58
71
  : "";
59
- return `${e.created_at.slice(0, 19)} | ${e.operation} | ${e.author} (${e.author_type}) | ${docLabel}${sizeInfo} | ${e.description}`;
72
+ return `${utcStamp(e.created_at)} | ${e.operation} | ${e.author} (${e.author_type}) | ${docLabel}${sizeInfo} | ${e.description}`;
60
73
  });
61
74
  return `Audit log (${entries.length} entries, newest first):\n\n${lines.join("\n")}`;
62
75
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { MCPSupabaseClient } from "./types.ts";
8
8
 
9
- import { parseOutline } from "../partial-edits/index.ts";
9
+ import { extractSection, parseOutline } from "../partial-edits/index.ts";
10
10
  import { logUsage } from "./_utils.ts";
11
11
  import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
12
12
 
@@ -18,8 +18,21 @@ async function handler(
18
18
  const document_id = args.document_id as string | undefined;
19
19
  const version_id = (args.version_id as string | null | undefined) ?? null;
20
20
  const outline = (args.outline as boolean | undefined) ?? false;
21
+ const section = ((args.section as string | undefined) ?? "").trim() || null;
22
+ const section_part = (args.section_part as "own_body" | "subtree" | undefined) ?? undefined;
21
23
 
22
24
  if (!document_id) throw new McpInvalidParams("document_id is required");
25
+ if (section && outline) {
26
+ // Both answer "what is in this document" at different zoom levels, and
27
+ // silently preferring one would make the other's absence look like an empty
28
+ // result. Refuse instead of choosing.
29
+ throw new McpInvalidParams(
30
+ "Pass either outline (the whole structure) or section (one section's text), not both.",
31
+ );
32
+ }
33
+ if (section_part && !section) {
34
+ throw new McpInvalidParams("section_part only applies together with section.");
35
+ }
23
36
 
24
37
  const { data, error } = await supabase.rpc("cerefox_get_document", {
25
38
  p_document_id: document_id,
@@ -83,6 +96,46 @@ async function handler(
83
96
  );
84
97
  }
85
98
 
99
+ // Section mode (#198): one section's text, so a replace_section is not a
100
+ // blind overwrite. The extent comes from the same resolver the write uses —
101
+ // what this returns is exactly what replace_section would destroy.
102
+ if (section) {
103
+ // Anchor failures are caller-recoverable, exactly as they are on the write
104
+ // path (`applyAndWrite` wraps the same three errors). Left unwrapped they
105
+ // surface as JSON-RPC -32603 "internal error" while the identical anchor
106
+ // through cerefox_edit surfaces as -32602 "invalid params", so a client
107
+ // keying on the code would classify the same mistake two ways depending on
108
+ // whether it read or wrote. The equivalence this feature promises has to
109
+ // cover refusals too, not just extents.
110
+ let extracted;
111
+ try {
112
+ extracted = extractSection(row.full_content ?? "", section, section_part);
113
+ } catch (err) {
114
+ throw new McpInvalidParams(err instanceof Error ? err.message : String(err));
115
+ }
116
+ // Same reasoning as outline mode: the RPC returns the CURRENT hash even when
117
+ // reconstructing an archived version, and pairing it with archived text
118
+ // would invite an edit based on content that is no longer there.
119
+ const archived = version_id !== null;
120
+ return JSON.stringify(
121
+ {
122
+ title: row.doc_title ?? "Untitled",
123
+ heading: extracted.heading,
124
+ path: extracted.path,
125
+ level: extracted.level,
126
+ section_part: extracted.section_part,
127
+ chars: extracted.chars,
128
+ content_hash: archived ? null : (row.content_hash ?? null),
129
+ text: extracted.text,
130
+ note: archived
131
+ ? "This is an ARCHIVED version's section, so no content_hash is returned: it must not be used to edit the current document."
132
+ : "This is exactly the text a replace_section on this anchor would overwrite (the heading itself is kept). content_hash is your expected_content_hash.",
133
+ },
134
+ null,
135
+ 2,
136
+ );
137
+ }
138
+
86
139
  const label = version_id !== null ? " (archived version)" : " (current)";
87
140
  // content_hash is the optimistic-concurrency token: pass it back as
88
141
  // expected_content_hash when updating this document via cerefox_ingest.
@@ -93,7 +146,7 @@ async function handler(
93
146
  export const getDocumentTool: ToolDefinition = {
94
147
  name: "cerefox_get_document",
95
148
  description:
96
- "Retrieve the full reconstructed content of a document — or, with outline=true, just its heading structure, sizes and content_hash (much cheaper, and the paths are the anchors the edit tools take). Pass version_id to retrieve an archived version; omit it (or pass null) for the current version. Version UUIDs are returned by cerefox_list_versions. The response header includes the document's current content_hash — pass it back as expected_content_hash when updating via cerefox_ingest (optimistic concurrency).",
149
+ "Retrieve a document at one of three zoom levels: the whole reconstructed content (default), its heading structure with outline=true (much cheaper, and the headings are the anchors the edit tools take), or one section's text with section=\"## Heading\" (what a replace_section on that anchor would overwrite — read it before replacing a section you did not write). Pass version_id to retrieve an archived version; omit it (or pass null) for the current version. Version UUIDs are returned by cerefox_list_versions. Every non-archived response carries the document's current content_hash — pass it back as expected_content_hash when updating (optimistic concurrency).",
97
150
  // Read-only: touches nothing. Safe for a client to run without prompting.
98
151
  annotations: {
99
152
  title: "Read document",
@@ -113,7 +166,18 @@ export const getDocumentTool: ToolDefinition = {
113
166
  outline: {
114
167
  type: "boolean",
115
168
  description:
116
- "Return the document's STRUCTURE instead of its content: heading paths, levels and per-section sizes, plus content_hash and total size. Far cheaper than a full read, and the paths are exactly what cerefox_insert / cerefox_edit take as anchor_heading. Use this before editing a document you have not read.",
169
+ "Return the document's STRUCTURE instead of its content: heading paths, levels and per-section sizes, plus content_hash and total size. Far cheaper than a full read. Every heading listed is addressable by cerefox_insert / cerefox_edit: pass the bare heading line (e.g. '## Daily Logs') when it occurs once in the document, and only the full ' > ' path shown here when the same heading text repeats. Use this before editing a document you have not read.",
170
+ },
171
+ section: {
172
+ type: "string",
173
+ description:
174
+ "Return ONE section's text instead of the whole document: the anchor heading, exactly as cerefox_insert / cerefox_edit take it (bare heading line when unique, ' > ' path when it repeats). What comes back is precisely the text a replace_section on this anchor would overwrite, so read it before replacing a section you did not write. The heading itself is returned separately, because replace_section keeps it. Cannot be combined with outline.",
175
+ },
176
+ section_part: {
177
+ type: "string",
178
+ enum: ["own_body", "subtree"],
179
+ description:
180
+ "Only when the target section HAS CHILD SECTIONS, and it means the same here as on the edit tools: own_body = up to the first child, subtree = everything nested underneath. The read refuses without it for exactly the cases the write refuses, so that what you read is what you would replace. Omit it otherwise; you will be told (with both options) whenever it is needed.",
117
181
  },
118
182
  requestor: {
119
183
  type: "string",
@@ -11,17 +11,19 @@
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**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";
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`/`rename_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, or with `section: \"## Heading\"` one section's text | `document_id` (required), `outline`, `section`, `section_part` |\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. **Look before you overwrite** — `cerefox_get_document(document_id,\n section: \"## Heading\")` returns exactly the text a `replace_section` on that\n anchor would destroy. The outline gives you a section's *size*, never its\n *text*, so on a document you did not write yourself this is the difference\n between a replace and a blind overwrite.\n4. **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. To fix a stale heading (`## OPEN TODOs (as of ...)`), use\n `rename_section`: it changes the heading text and leaves the body and\n position alone.\n5. All of them 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\n## Timestamps are UTC\n\nEvery timestamp Cerefox returns — `created_at` on audit entries, version\nhistory, document metadata — is **UTC**, and now carries its `Z` marker so it\ncannot be mistaken for local time.\n\n**When you write a date into a document's CONTENT, use your own clock, not a\nCerefox timestamp.** These are different things: a timestamp records when the\nserver stored something; a date in a log entry or a heading is authored content\nand belongs to your timezone. An agent working a Pacific afternoon read\n`2026-08-11` from version history, wrote \"8/11\" into its entries, and put a\nday's work in the future — the timestamp was correct, and copying it into\ncontent was not.\n\nCerefox deliberately does not convert to local time on the API or MCP paths.\n\"Local\" has no server-side meaning: the remote MCP server runs in a cloud\nfunction whose local time *is* UTC, while a local MCP server runs in yours, so\nthe same document would report two different times depending on transport. The\nweb UI converts because a browser knows the viewer's timezone; nothing\nserver-side does.\n\n## Mistakes that have actually happened\n\nEach of these comes from a real agent session, and each is easy to make.\n\n- **`cerefox_ingest` always replaces the ENTIRE document.** Never a section.\n Before sending, check that the tool name matches the intent: if the intent is\n \"change one section\", the call is `cerefox_edit` with `replace_section`. A\n section-sized edit sent as a full ingest truncated a 13,000-character index to\n a single word. It was recovered from version history within the minute, but\n only because it was noticed immediately.\n\n- **Do not include the anchor's own heading in your text.** `replace_section`\n keeps the heading and `insert` places your text inside the section, so\n including it produces two. This is now refused rather than silently applied,\n but the shape is worth knowing: it happened twice in one session, the second\n time while trying to repair the first. A *deeper* sub-heading inside your text\n is fine.\n\n- **Content between sections belongs to the section ABOVE it.** A section runs\n to the next heading of the same or higher level, so a `---` rule, a note, or\n any trailing text sitting just above the next heading is part of the section\n before it — even when it visually reads as belonging below. Replacing that\n section takes it too. An agent hit exactly this: a `---` that separated two\n major sections disappeared when the section above it was replaced. The write\n was correct by the addressing rules; the surprise is that \"the end of this\n section\" is further down the page than it looks. Note the loss warning will\n not catch it if your replacement text is longer than what it replaced, since\n there is then no net loss to report.\n\n- **Never partial-edit to fix a partial edit.** If a write leaves unexpected\n structure, stop. Use `cerefox_list_versions`, retrieve the last good version,\n and re-ingest cleanly. Repairing edits with more edits compounds the damage.\n\n- **A rejected batch is safe.** Operations in one `cerefox_edit` are\n all-or-nothing: if any is invalid, nothing is written. A refusal costs you a\n retry, not data — so prefer one call for changes that belong together, and do\n not split a batch to \"make it more likely to succeed\".\n\n- **Read before replacing.** `cerefox_get_document(section: \"## Heading\")`\n returns exactly what a `replace_section` on that anchor would overwrite. Use it\n for any section you did not write in this session. The outline gives a\n section's *size*, never its *text*.\n\n- **Verify after writing** — read the result back before reporting success, and\n report what the read actually shows.\n\n- **Partial edits cannot change a document's stored TITLE.** `rename_section`\n changes a heading inside the content; the title is a separate field and still\n needs `cerefox_ingest`.\n\n- **If a capability seems missing from one server, suspect your client first.**\n Local and remote run the same code. Call `cerefox_get_help(topic: \"server\")`:\n it reports the server's own version and the operations it registers. If that\n disagrees with your tool list, the client is holding a list it fetched before\n an upgrade — clients cache it at connect time. Ask the user to restart the\n client. Do not record a capability difference between servers as a fact; every\n such report so far has been a stale client.\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
- "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**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.",
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`/`rename_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, or with `section: \"## Heading\"` one section's text | `document_id` (required), `outline`, `section`, `section_part` |\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. **Look before you overwrite** — `cerefox_get_document(document_id,\n section: \"## Heading\")` returns exactly the text a `replace_section` on that\n anchor would destroy. The outline gives you a section's *size*, never its\n *text*, so on a document you did not write yourself this is the difference\n between a replace and a blind overwrite.\n4. **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. To fix a stale heading (`## OPEN TODOs (as of ...)`), use\n `rename_section`: it changes the heading text and leaves the body and\n position alone.\n5. All of them 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```",
23
23
  "Catch-Up Workflow": "## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```",
24
24
  "CLI fallback (when MCP is unavailable)": "## 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.",
25
+ "Timestamps are UTC": "## Timestamps are UTC\n\nEvery timestamp Cerefox returns — `created_at` on audit entries, version\nhistory, document metadata — is **UTC**, and now carries its `Z` marker so it\ncannot be mistaken for local time.\n\n**When you write a date into a document's CONTENT, use your own clock, not a\nCerefox timestamp.** These are different things: a timestamp records when the\nserver stored something; a date in a log entry or a heading is authored content\nand belongs to your timezone. An agent working a Pacific afternoon read\n`2026-08-11` from version history, wrote \"8/11\" into its entries, and put a\nday's work in the future — the timestamp was correct, and copying it into\ncontent was not.\n\nCerefox deliberately does not convert to local time on the API or MCP paths.\n\"Local\" has no server-side meaning: the remote MCP server runs in a cloud\nfunction whose local time *is* UTC, while a local MCP server runs in yours, so\nthe same document would report two different times depending on transport. The\nweb UI converts because a browser knows the viewer's timezone; nothing\nserver-side does.",
26
+ "Mistakes that have actually happened": "## Mistakes that have actually happened\n\nEach of these comes from a real agent session, and each is easy to make.\n\n- **`cerefox_ingest` always replaces the ENTIRE document.** Never a section.\n Before sending, check that the tool name matches the intent: if the intent is\n \"change one section\", the call is `cerefox_edit` with `replace_section`. A\n section-sized edit sent as a full ingest truncated a 13,000-character index to\n a single word. It was recovered from version history within the minute, but\n only because it was noticed immediately.\n\n- **Do not include the anchor's own heading in your text.** `replace_section`\n keeps the heading and `insert` places your text inside the section, so\n including it produces two. This is now refused rather than silently applied,\n but the shape is worth knowing: it happened twice in one session, the second\n time while trying to repair the first. A *deeper* sub-heading inside your text\n is fine.\n\n- **Content between sections belongs to the section ABOVE it.** A section runs\n to the next heading of the same or higher level, so a `---` rule, a note, or\n any trailing text sitting just above the next heading is part of the section\n before it — even when it visually reads as belonging below. Replacing that\n section takes it too. An agent hit exactly this: a `---` that separated two\n major sections disappeared when the section above it was replaced. The write\n was correct by the addressing rules; the surprise is that \"the end of this\n section\" is further down the page than it looks. Note the loss warning will\n not catch it if your replacement text is longer than what it replaced, since\n there is then no net loss to report.\n\n- **Never partial-edit to fix a partial edit.** If a write leaves unexpected\n structure, stop. Use `cerefox_list_versions`, retrieve the last good version,\n and re-ingest cleanly. Repairing edits with more edits compounds the damage.\n\n- **A rejected batch is safe.** Operations in one `cerefox_edit` are\n all-or-nothing: if any is invalid, nothing is written. A refusal costs you a\n retry, not data — so prefer one call for changes that belong together, and do\n not split a batch to \"make it more likely to succeed\".\n\n- **Read before replacing.** `cerefox_get_document(section: \"## Heading\")`\n returns exactly what a `replace_section` on that anchor would overwrite. Use it\n for any section you did not write in this session. The outline gives a\n section's *size*, never its *text*.\n\n- **Verify after writing** — read the result back before reporting success, and\n report what the read actually shows.\n\n- **Partial edits cannot change a document's stored TITLE.** `rename_section`\n changes a heading inside the content; the title is a separate field and still\n needs `cerefox_ingest`.\n\n- **If a capability seems missing from one server, suspect your client first.**\n Local and remote run the same code. Call `cerefox_get_help(topic: \"server\")`:\n it reports the server's own version and the operations it registers. If that\n disagrees with your tool list, the client is holding a list it fetched before\n an upgrade — clients cache it at connect time. Ask the user to restart the\n client. Do not record a capability difference between servers as a fact; every\n such report so far has been a stale client.",
25
27
  };
26
28
 
27
- export const HELP_SECTION_HEADINGS: string[] = ["Tools", "Editing part of a document (prefer this over re-sending)", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)"];
29
+ export const HELP_SECTION_HEADINGS: string[] = ["Tools", "Editing part of a document (prefer this over re-sending)", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)", "Timestamps are UTC", "Mistakes that have actually happened"];
@@ -17,6 +17,8 @@
17
17
 
18
18
  import type { MCPSupabaseClient } from "./types.ts";
19
19
 
20
+ import { CEREFOX_VERSION } from "../ef-meta/index.ts";
21
+ import { editTool } from "./partial-edits.ts";
20
22
  import { logUsage } from "./_utils.ts";
21
23
  import {
22
24
  HELP_FULL,
@@ -25,6 +27,54 @@ import {
25
27
  } from "./get-help-content.ts";
26
28
  import type { ToolContext, ToolDefinition } from "./types.ts";
27
29
 
30
+ /**
31
+ * The operations `cerefox_edit` actually registers, read from its own schema
32
+ * rather than restated — a hand-maintained list here would be the exact drift
33
+ * this block exists to expose.
34
+ *
35
+ * Imported from the tool module directly: `./index.ts` imports this file, so
36
+ * reading the registry would be a cycle.
37
+ */
38
+ function editOperations(): string[] {
39
+ const schema = editTool.inputSchema as {
40
+ properties?: {
41
+ operations?: { items?: { properties?: { op?: { enum?: string[] } } } };
42
+ };
43
+ };
44
+ return schema.properties?.operations?.items?.properties?.op?.enum ?? [];
45
+ }
46
+
47
+ /**
48
+ * What an MCP-only agent needs to tell "the server lacks this" from "my client
49
+ * is stale" — the single most-repeated misdiagnosis in reported sessions.
50
+ *
51
+ * Three separate reports have claimed a capability was missing from one server
52
+ * when both were correct and the CLIENT was holding a tool list fetched before
53
+ * an upgrade (clients fetch it once at connect). The advice for a human is
54
+ * "check `cerefox --version`", which is useless to an agent with no shell — and
55
+ * most agents have no shell.
56
+ *
57
+ * So the server states its own version and the operations it actually
58
+ * registers. An agent whose tool list disagrees with this block now knows the
59
+ * disagreement is client-side, without leaving the protocol.
60
+ */
61
+ function serverIdentity(): string {
62
+ return [
63
+ "## This server",
64
+ "",
65
+ `- **Version**: ${CEREFOX_VERSION}`,
66
+ `- **cerefox_edit operations**: ${editOperations().join(", ")}`,
67
+ "",
68
+ "**If your tool list disagrees with this block, your CLIENT is out of date, not the server.**",
69
+ "MCP clients fetch the tool list once when they connect and cache it, so a server",
70
+ "upgraded mid-session is invisible until the client reconnects. Ask the user to restart",
71
+ "the client — and if it stays missing after a restart, the client config may pin an old",
72
+ "version of the package. Do not record a capability difference between the local and",
73
+ "remote servers: they run the same code, and every such report so far has been a stale",
74
+ "client.",
75
+ ].join("\n");
76
+ }
77
+
28
78
  async function handler(
29
79
  supabase: MCPSupabaseClient,
30
80
  args: Record<string, unknown>,
@@ -43,6 +93,8 @@ async function handler(
43
93
  if (!topic) {
44
94
  const idx = HELP_SECTION_HEADINGS.map((h) => ` - ${h}`).join("\n");
45
95
  return (
96
+ serverIdentity() +
97
+ "\n\n---\n\n" +
46
98
  HELP_FULL +
47
99
  "\n\n---\n\n" +
48
100
  "## Available topics\n\n" +
@@ -52,6 +104,10 @@ async function handler(
52
104
  );
53
105
  }
54
106
 
107
+ // `topic: "server"` / `"version"` is the self-check, and must work even
108
+ // though this section is not part of the bundled markdown.
109
+ if (/^(server|version|stale|client)$/i.test(topic)) return serverIdentity();
110
+
55
111
  const t = topic.toLowerCase();
56
112
  const matched = HELP_SECTION_HEADINGS.filter((h) => h.toLowerCase().includes(t));
57
113
 
@@ -356,7 +356,8 @@ async function handler(
356
356
 
357
357
  export const ingestTool: ToolDefinition = {
358
358
  name: "cerefox_ingest",
359
- description: "Save a note or document to the Cerefox knowledge base.",
359
+ description:
360
+ "Save a note or document to the Cerefox knowledge base. Updating an existing document REPLACES its whole content, so every unchanged character has to be reproduced exactly. Prefer cerefox_insert / cerefox_edit for any change to part of a document, and especially where the untouched content cannot be checked by reading it — document IDs, hashes, numeric tables, indexes and registries. Drifting prose is obvious on review; one wrong character in a UUID is not, and it silently breaks the reference.",
360
361
  /** Destructive: `project_names` REPLACES the document's project memberships, and
361
362
  * memberships have no version history — a partial list silently drops the rest.
362
363
  * Content itself is version-snapshotted and guarded by expected_content_hash, so
@@ -9,6 +9,17 @@ import type { MCPSupabaseClient } from "./types.ts";
9
9
  import { logUsage } from "./_utils.ts";
10
10
  import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
11
11
 
12
+ /**
13
+ * Version timestamps carried only a DATE (`slice(0, 10)`), which is
14
+ * indistinguishable from a local date — and this is the tool an agent was
15
+ * reading when it dated a day's entries into the future (#199). Emit the
16
+ * instant with its zone.
17
+ */
18
+ function utcStamp(iso: string): string {
19
+ const trimmed = iso.slice(0, 19);
20
+ return trimmed.includes("T") ? `${trimmed}Z` : `${trimmed} UTC`;
21
+ }
22
+
12
23
  async function handler(
13
24
  supabase: MCPSupabaseClient,
14
25
  args: Record<string, unknown>,
@@ -44,7 +55,7 @@ async function handler(
44
55
 
45
56
  const lines = versions.map(
46
57
  (v) =>
47
- `v${v.version_number} | ${v.created_at.slice(0, 10)} | ${v.source} | ${v.chunk_count} chunks / ${v.total_chars.toLocaleString()} chars | id: ${v.version_id}`,
58
+ `v${v.version_number} | ${utcStamp(v.created_at)} | ${v.source} | ${v.chunk_count} chunks / ${v.total_chars.toLocaleString()} chars | id: ${v.version_id}`,
48
59
  );
49
60
  return `Archived versions (newest first):\n\n${lines.join("\n")}`;
50
61
  }
@@ -68,18 +68,75 @@ function defaultRequestor(ctx: ToolContext): string {
68
68
  * The full-document diff is deliberately not returned (§3.8), so the size delta
69
69
  * is the cheapest honest signal available.
70
70
  */
71
- function shrinkNote(before: number, after: number): string {
72
- const lost = before - after;
71
+ /**
72
+ * Did any destructive operation target a section that ran to the END of the
73
+ * document?
74
+ *
75
+ * This is the shape behind the whole warning. A section extends to the next
76
+ * heading of equal-or-higher level **or to EOF**, so the last section owns
77
+ * everything appended after it. An `end_of_document` insert lands inside that
78
+ * section, and a later `replace_section` on its heading removes it — correctly
79
+ * by the addressing rules, and silently.
80
+ *
81
+ * The answer is recorded by `applyOne` when the operation resolves, not
82
+ * recomputed here. The first version re-parsed the PRE-batch document and
83
+ * matched on `applied.path`, which breaks the moment a batch renames a heading
84
+ * before touching it: the later op's path names a heading the pre-batch
85
+ * outline has never seen, the lookup finds nothing, and the warning goes quiet
86
+ * on exactly the batch shape `rename_section` exists to enable. It also cost a
87
+ * second parse per destructive edit.
88
+ */
89
+ function touchedTrailingSection(applied: AppliedOperation[]): boolean {
90
+ return applied.some((a) => a.reachedEnd === true);
91
+ }
92
+
93
+ /**
94
+ * Report content loss (#196).
95
+ *
96
+ * The first version gated on a >25% ratio and so could not see the case it was
97
+ * built for. The loss that matters most is small *precisely because it was
98
+ * recently added*: append a 400-character entry to an 11,000-character
99
+ * document, then replace the last section, and 4% of the document silently
100
+ * takes the new entry with it. A percentage threshold is structurally blind to
101
+ * that — the more established the document, the smaller the fraction, the
102
+ * quieter the failure.
103
+ *
104
+ * So the ratio is no longer a gate on whether to speak, only on how loudly:
105
+ *
106
+ * - Any net loss is stated with its size. Cheap, and a caller who meant it
107
+ * reads one clause.
108
+ * - A destructive operation on a section that ran to EOF gets the full
109
+ * explanation at any size, because that is the sequence that surprises.
110
+ * - A large proportional loss gets it too, since that is worth a second look
111
+ * whichever section it hit.
112
+ */
113
+ function shrinkNote(before: string, afterChars: number, applied: AppliedOperation[]): string {
114
+ // Code points, NOT UTF-16 code units. `afterChars` comes from the RPC's
115
+ // SUM(char_count), and the chunker counts code points — so `before.length`
116
+ // over-counts by one per non-BMP character (emoji, most non-BMP CJK) and
117
+ // manufactures a phantom loss. Latent since v1.3.0, where the >25% gate hid
118
+ // it; removing that gate for #196 would have surfaced it on every edit to a
119
+ // document containing an emoji — including cerefox_insert, which is
120
+ // annotated destructiveHint: false precisely because it cannot lose content.
121
+ const beforeChars = [...before].length;
122
+ const lost = beforeChars - afterChars;
73
123
  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
- );
124
+ const pct = Math.round((lost / Math.max(beforeChars, 1)) * 100);
125
+ const trailing = touchedTrailingSection(applied);
126
+
127
+ if (pct < 25 && !trailing) {
128
+ return `This edit removed ${lost} characters. cerefox_list_versions has the previous content.\n`;
129
+ }
130
+
131
+ const why = trailing
132
+ ? `You replaced or deleted the LAST section, and a section runs to the next ` +
133
+ `heading of the same or higher level — or to the end of the document — so ` +
134
+ `anything appended after it was inside it. `
135
+ : `If you did not intend that, note that a section runs to the next heading ` +
136
+ `of the same or higher level — or to the end of the document — so replacing ` +
137
+ `or deleting the LAST section also removes anything appended after it. `;
138
+
139
+ return `⚠ This edit removed ${lost} characters (${pct}% smaller). ${why}cerefox_list_versions has the previous content.\n`;
83
140
  }
84
141
 
85
142
  /** Audit `operation` values, matching the CHECK constraint widened by migration 0019. */
@@ -87,6 +144,7 @@ const AUDIT_OP: Record<AppliedOperation["op"], string> = {
87
144
  insert: "insert",
88
145
  replace_section: "replace-section",
89
146
  delete_section: "delete-section",
147
+ rename_section: "rename-section",
90
148
  };
91
149
 
92
150
  /**
@@ -293,7 +351,7 @@ async function applyAndWrite(
293
351
  `Applied ${applied.length} operation(s) to "${doc.title}" (id: ${documentId}):\n${summary}\n\n` +
294
352
  `New content_hash: ${row?.content_hash ?? newHash}\n` +
295
353
  `Size: ${row?.total_chars ?? totalChars} chars (was ${doc.content.length}), ${chunks.length} chunk(s).\n` +
296
- shrinkNote(doc.content.length, row?.total_chars ?? totalChars) +
354
+ shrinkNote(doc.content, row?.total_chars ?? totalChars, applied) +
297
355
  `Pass the new content_hash as expected_content_hash on your next edit.${warning}`
298
356
  );
299
357
  }
@@ -445,7 +503,9 @@ export const editTool: ToolDefinition = {
445
503
  "Change parts of a document without resending the whole thing: one or many operations " +
446
504
  "applied ATOMICALLY in one write. Operations: insert (same positions as cerefox_insert), " +
447
505
  "replace_section (swap a section's body, heading kept), delete_section (remove a section, " +
448
- "scope body_only or heading_and_body). Use one call for changes that belong together — a " +
506
+ "scope body_only or heading_and_body), rename_section (change a heading's text, leaving " +
507
+ "its body and position untouched — for headings that go stale, like a dated one). " +
508
+ "Use one call for changes that belong together — a " +
449
509
  "half-applied edit is impossible, so a row and the total it feeds cannot disagree. " +
450
510
  "Operations apply in order and each sees the previous one's result. To change a single " +
451
511
  "line, replace_section on its smallest enclosing heading. Requires expected_content_hash; " +
@@ -472,7 +532,10 @@ export const editTool: ToolDefinition = {
472
532
  type: "object",
473
533
  required: ["op"],
474
534
  properties: {
475
- op: { type: "string", enum: ["insert", "replace_section", "delete_section"] },
535
+ op: {
536
+ type: "string",
537
+ enum: ["insert", "replace_section", "delete_section", "rename_section"],
538
+ },
476
539
  text: { type: "string", description: "Markdown. Required for insert and replace_section." },
477
540
  position: {
478
541
  type: "string",
@@ -495,6 +558,11 @@ export const editTool: ToolDefinition = {
495
558
  enum: ["body_only", "heading_and_body"],
496
559
  description: "delete_section only. Defaults to body_only, which keeps the heading.",
497
560
  },
561
+ new_heading: {
562
+ type: "string",
563
+ description:
564
+ "rename_section only: the replacement heading LINE, at the same level (## stays ##). Changes the heading text and nothing else — the body and the section's position are untouched, which is the point: renaming via delete + insert would risk both. Use it for headings that go stale, like '## OPEN TODOs (as of 2026-08-08)'. A rename changes the anchor, so a later operation in the same call must target the NEW heading.",
565
+ },
498
566
  },
499
567
  },
500
568
  },