@cerefox/memory 1.4.0 → 1.6.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.
- package/AGENT_GUIDE.md +90 -2
- package/AGENT_QUICK_REFERENCE.md +91 -1
- package/README.md +3 -2
- package/dist/bin/cerefox.js +843 -489
- package/dist/frontend/assets/{index-B1pgikxA.js → index-DcWOeGAh.js} +30 -30
- package/dist/frontend/assets/index-DcWOeGAh.js.map +1 -0
- package/dist/frontend/assets/{index-C1JXZA9m.css → index-DiDeaiM6.css} +1 -1
- package/dist/frontend/index.html +2 -2
- package/dist/server-assets/_shared/ef-meta/index.ts +20 -2
- package/dist/server-assets/_shared/mcp-tools/audit-log.ts +14 -1
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +6 -4
- package/dist/server-assets/_shared/mcp-tools/get-help.ts +71 -1
- package/dist/server-assets/_shared/mcp-tools/index.ts +3 -0
- package/dist/server-assets/_shared/mcp-tools/list-versions.ts +12 -1
- package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +133 -0
- package/dist/server-assets/_shared/partial-edits/index.ts +49 -0
- package/dist/server-assets/db/migrations/0022_rls_on_document_relations.sql +38 -0
- package/dist/server-assets/db/migrations/0023_set_document_metadata.sql +16 -0
- package/dist/server-assets/db/rpcs.sql +118 -1
- package/dist/server-assets/db/schema.sql +8 -1
- package/docs/guides/cli.md +54 -1
- package/docs/guides/connect-agents.md +11 -8
- package/docs/guides/operational-cost.md +1 -1
- package/package.json +1 -1
- package/dist/frontend/assets/index-B1pgikxA.js.map +0 -1
package/dist/frontend/index.html
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
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-
|
|
19
|
-
<link rel="stylesheet" crossorigin href="/app/assets/index-
|
|
18
|
+
<script type="module" crossorigin src="/app/assets/index-DcWOeGAh.js"></script>
|
|
19
|
+
<link rel="stylesheet" crossorigin href="/app/assets/index-DiDeaiM6.css">
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
22
22
|
<div id="root"></div>
|
|
@@ -18,7 +18,25 @@
|
|
|
18
18
|
* doesn't touch `supabase/functions/` leaves it alone).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
export const EF_VERSION = "1.
|
|
21
|
+
export const EF_VERSION = "1.6.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.6.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.4.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.
|
|
49
|
+
export const EF_LAST_CHANGED = "1.6.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
|
|
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
|
}
|
|
@@ -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`/`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";
|
|
14
|
+
export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **17 MCP tools** (16 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_metadata` | Change tags WITHOUT resending content. **Merges** by default; a `null` value removes a key | `document_id`, `metadata` (required), `replace` (rare: set exactly this object), `author` |\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_metadata` | `cerefox document set-metadata <id> --set key=value` (also `--remove key`, `--json '{...}'`, `--replace`) |\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- **To change only tags, use `cerefox_set_document_metadata`, never `cerefox_ingest`.**\n Ingest replaces the whole document, so re-sending it to set one tag carries the\n full transcription risk for no reason. The metadata tool merges: the keys you\n pass are set, everything else is left alone, so you do not need to read the\n document first and cannot drop a tag another agent set. Pass `null` as a value\n to remove a key.\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. **Every `cerefox_get_help()` response\n begins with the server's version and the operations it registers** — you do\n not need a special topic, and the *absence* of that block is itself an answer:\n a server that does not print it predates v1.5.0. 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`/`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.",
|
|
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_metadata` | Change tags WITHOUT resending content. **Merges** by default; a `null` value removes a key | `document_id`, `metadata` (required), `replace` (rare: set exactly this object), `author` |\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
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
|
-
"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.",
|
|
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_metadata` | `cerefox document set-metadata <id> --set key=value` (also `--remove key`, `--json '{...}'`, `--replace`) |\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- **To change only tags, use `cerefox_set_document_metadata`, never `cerefox_ingest`.**\n Ingest replaces the whole document, so re-sending it to set one tag carries the\n full transcription risk for no reason. The metadata tool merges: the keys you\n pass are set, everything else is left alone, so you do not need to read the\n document first and cannot drop a tag another agent set. Pass `null` as a value\n to remove a key.\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. **Every `cerefox_get_help()` response\n begins with the server's version and the operations it registers** — you do\n not need a special topic, and the *absence* of that block is itself an answer:\n a server that does not print it predates v1.5.0. 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>,
|
|
@@ -40,9 +90,23 @@ async function handler(
|
|
|
40
90
|
result_count: 1,
|
|
41
91
|
});
|
|
42
92
|
|
|
93
|
+
// The identity block leads EVERY response, not just `topic: "server"`.
|
|
94
|
+
//
|
|
95
|
+
// Behind a topic name it had a bootstrap problem an agent caught immediately:
|
|
96
|
+
// the remedy for a stale server requires a server new enough to contain the
|
|
97
|
+
// remedy. Asked for "server" on a pre-1.5.0 deployment, they got "no such
|
|
98
|
+
// topic" and reasonably concluded the documented check did not exist — inside
|
|
99
|
+
// the very section warning against unverified infrastructure claims.
|
|
100
|
+
//
|
|
101
|
+
// Nothing can retrofit old servers. What this fixes is the need to know a
|
|
102
|
+
// magic word: any `cerefox_get_help()` call now shows the version, and the
|
|
103
|
+
// ABSENCE of this block is itself diagnostic — a server that does not print
|
|
104
|
+
// it predates 1.5.0.
|
|
43
105
|
if (!topic) {
|
|
44
106
|
const idx = HELP_SECTION_HEADINGS.map((h) => ` - ${h}`).join("\n");
|
|
45
107
|
return (
|
|
108
|
+
serverIdentity() +
|
|
109
|
+
"\n\n---\n\n" +
|
|
46
110
|
HELP_FULL +
|
|
47
111
|
"\n\n---\n\n" +
|
|
48
112
|
"## Available topics\n\n" +
|
|
@@ -52,11 +116,17 @@ async function handler(
|
|
|
52
116
|
);
|
|
53
117
|
}
|
|
54
118
|
|
|
119
|
+
// `topic: "server"` / `"version"` is the self-check, and must work even
|
|
120
|
+
// though this section is not part of the bundled markdown.
|
|
121
|
+
if (/^(server|version|stale|client)$/i.test(topic)) return serverIdentity();
|
|
122
|
+
|
|
55
123
|
const t = topic.toLowerCase();
|
|
56
124
|
const matched = HELP_SECTION_HEADINGS.filter((h) => h.toLowerCase().includes(t));
|
|
57
125
|
|
|
58
126
|
if (matched.length === 0) {
|
|
59
127
|
return (
|
|
128
|
+
serverIdentity() +
|
|
129
|
+
"\n\n---\n\n" +
|
|
60
130
|
`No help topic matched "${topic}".\n\n` +
|
|
61
131
|
`Available topics:\n` +
|
|
62
132
|
HELP_SECTION_HEADINGS.map((h) => ` - ${h}`).join("\n") +
|
|
@@ -64,7 +134,7 @@ async function handler(
|
|
|
64
134
|
);
|
|
65
135
|
}
|
|
66
136
|
|
|
67
|
-
return matched.map((h) => HELP_SECTIONS[h]).join("\n\n---\n\n");
|
|
137
|
+
return serverIdentity() + "\n\n---\n\n" + matched.map((h) => HELP_SECTIONS[h]).join("\n\n---\n\n");
|
|
68
138
|
}
|
|
69
139
|
|
|
70
140
|
export const getHelpTool: ToolDefinition = {
|
|
@@ -32,6 +32,7 @@ import { listProjectsTool } from "./list-projects.ts";
|
|
|
32
32
|
import { listVersionsTool } from "./list-versions.ts";
|
|
33
33
|
import { metadataSearchTool } from "./metadata-search.ts";
|
|
34
34
|
import { searchTool } from "./search.ts";
|
|
35
|
+
import { setDocumentMetadataTool } from "./set-document-metadata.ts";
|
|
35
36
|
import { setDocumentProjectsTool } from "./set-document-projects.ts";
|
|
36
37
|
import { McpInvalidParams, type MCPSupabaseClient, type ToolDefinition } from "./types.ts";
|
|
37
38
|
|
|
@@ -49,6 +50,7 @@ export const ALL_TOOLS: ToolDefinition[] = [
|
|
|
49
50
|
metadataSearchTool,
|
|
50
51
|
listMetadataKeysTool,
|
|
51
52
|
listProjectsTool,
|
|
53
|
+
setDocumentMetadataTool,
|
|
52
54
|
setDocumentProjectsTool,
|
|
53
55
|
auditLogTool,
|
|
54
56
|
// Document relations (iteration 29): the graph surface.
|
|
@@ -110,5 +112,6 @@ export {
|
|
|
110
112
|
listVersionsTool,
|
|
111
113
|
metadataSearchTool,
|
|
112
114
|
searchTool,
|
|
115
|
+
setDocumentMetadataTool,
|
|
113
116
|
setDocumentProjectsTool,
|
|
114
117
|
};
|
|
@@ -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
|
|
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
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cerefox_set_document_metadata` — change a document's tags without touching
|
|
3
|
+
* its content (#204).
|
|
4
|
+
*
|
|
5
|
+
* Until this existed, `cerefox_ingest` was the only way to set a tag and it
|
|
6
|
+
* requires title + content, so changing one key meant resending the whole
|
|
7
|
+
* document — reproducing every untouched character, including IDs and tables.
|
|
8
|
+
* That is precisely the transcription risk the partial-edit tools were built to
|
|
9
|
+
* remove, still fully present for metadata. Project membership had a
|
|
10
|
+
* metadata-only writer (`cerefox_set_document_projects`) all along; tags never
|
|
11
|
+
* got one.
|
|
12
|
+
*
|
|
13
|
+
* The composition lives in the RPC rather than here, because the merge has to
|
|
14
|
+
* be atomic: several agent roles write to the same documents, and a read →
|
|
15
|
+
* merge → write done client-side would let two agents setting different keys
|
|
16
|
+
* clobber each other. `cerefox_set_document_metadata` merges inside one UPDATE
|
|
17
|
+
* against a locked row.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { MCPSupabaseClient } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
import { logUsage } from "./_utils.ts";
|
|
23
|
+
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
async function handler(
|
|
26
|
+
supabase: MCPSupabaseClient,
|
|
27
|
+
args: Record<string, unknown>,
|
|
28
|
+
ctx: ToolContext,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const document_id = args.document_id as string | undefined;
|
|
31
|
+
const metadata = args.metadata as Record<string, unknown> | undefined;
|
|
32
|
+
const replace = (args.replace as boolean | undefined) ?? false;
|
|
33
|
+
|
|
34
|
+
if (!document_id) throw new McpInvalidParams("document_id is required");
|
|
35
|
+
if (metadata === undefined || metadata === null) {
|
|
36
|
+
throw new McpInvalidParams(
|
|
37
|
+
"metadata is required: an object of keys to set. Use null as a value to REMOVE a key.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
41
|
+
throw new McpInvalidParams("metadata must be an object, not an array or scalar");
|
|
42
|
+
}
|
|
43
|
+
if (Object.keys(metadata).length === 0 && !replace) {
|
|
44
|
+
// An empty merge is a no-op, and a caller who wanted "clear everything"
|
|
45
|
+
// meant `replace: true`. Refusing beats silently doing nothing.
|
|
46
|
+
throw new McpInvalidParams(
|
|
47
|
+
"metadata is empty, which would change nothing. To clear all metadata pass replace: true with {}.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const author = (args.author as string | undefined) ?? (args.requestor as string | undefined);
|
|
52
|
+
// Derived from the transport, never taken from the caller: an agent must not
|
|
53
|
+
// be able to record itself as a user. Matches the partial-edit handlers.
|
|
54
|
+
const authorType = ctx.accessPath === "cli" ? "user" : "agent";
|
|
55
|
+
|
|
56
|
+
const { data, error } = await supabase.rpc("cerefox_set_document_metadata", {
|
|
57
|
+
p_document_id: document_id,
|
|
58
|
+
p_metadata: metadata,
|
|
59
|
+
p_replace: replace,
|
|
60
|
+
p_author: author ?? "unknown",
|
|
61
|
+
p_author_type: authorType,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
65
|
+
|
|
66
|
+
const row = data?.[0] as
|
|
67
|
+
| { document_id?: string; metadata?: Record<string, unknown>; keys_set?: number; keys_removed?: number }
|
|
68
|
+
| undefined;
|
|
69
|
+
if (!row) throw new Error("cerefox_set_document_metadata returned no data");
|
|
70
|
+
|
|
71
|
+
logUsage(supabase, {
|
|
72
|
+
operation: "update_metadata",
|
|
73
|
+
accessPath: ctx.accessPath,
|
|
74
|
+
requestor: args.requestor as string | undefined,
|
|
75
|
+
document_id,
|
|
76
|
+
result_count: 1,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const set = row.keys_set ?? 0;
|
|
80
|
+
const removed = row.keys_removed ?? 0;
|
|
81
|
+
// Report what CHANGED, not what was asked for. Setting a key to the value it
|
|
82
|
+
// already held is a no-op, and saying so beats implying work happened.
|
|
83
|
+
const summary =
|
|
84
|
+
set === 0 && removed === 0
|
|
85
|
+
? "No change: every key already held that value."
|
|
86
|
+
: `${set} key(s) set, ${removed} removed.`;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
`Metadata ${replace ? "replaced" : "merged"} on ${document_id}. ${summary}\n` +
|
|
90
|
+
`Now: ${JSON.stringify(row.metadata ?? {})}\n` +
|
|
91
|
+
`Content untouched — no new version, no re-embedding.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const setDocumentMetadataTool: ToolDefinition = {
|
|
96
|
+
name: "cerefox_set_document_metadata",
|
|
97
|
+
description:
|
|
98
|
+
"Change a document's metadata WITHOUT resending its content. MERGES by default: the keys you pass are set, every other key is left alone — so you do not need to read the document first, and you cannot accidentally drop tags another agent set. To REMOVE a key, pass it with a null value ({\"stale_key\": null}). Pass replace: true to set the metadata to exactly the object given, discarding everything else (rare; the same destructive contract as cerefox_set_document_projects). Content, chunks and embeddings are untouched and no new version is created. Use this instead of cerefox_ingest whenever only the tags are changing.",
|
|
99
|
+
annotations: {
|
|
100
|
+
title: "Set document metadata",
|
|
101
|
+
readOnlyHint: false,
|
|
102
|
+
// Merge cannot lose a key you did not name; `replace: true` can.
|
|
103
|
+
destructiveHint: true,
|
|
104
|
+
idempotentHint: true,
|
|
105
|
+
openWorldHint: false,
|
|
106
|
+
},
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object",
|
|
109
|
+
required: ["document_id", "metadata"],
|
|
110
|
+
properties: {
|
|
111
|
+
document_id: { type: "string", description: "UUID of the document to tag." },
|
|
112
|
+
metadata: {
|
|
113
|
+
type: "object",
|
|
114
|
+
description:
|
|
115
|
+
"Keys to set. Values are JSON strings by convention (a metadata_filter matches JSONB as strings, so a boolean true will not match \"true\"). A null value REMOVES that key. Keys you do not mention are left alone unless replace is true.",
|
|
116
|
+
},
|
|
117
|
+
replace: {
|
|
118
|
+
type: "boolean",
|
|
119
|
+
description:
|
|
120
|
+
"Set the metadata to EXACTLY this object, discarding any key not listed. Defaults to false (merge). Use only when you mean to reset a document's tags wholesale.",
|
|
121
|
+
},
|
|
122
|
+
author: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description: "Who is making this change. Recorded in the audit log.",
|
|
125
|
+
},
|
|
126
|
+
requestor: {
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "Name of the agent or user making this request. Recorded in the usage log.",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
handler,
|
|
133
|
+
};
|
|
@@ -184,6 +184,31 @@ export class HeadingLevelChangeError extends Error {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The supplied text begins with the very heading it is anchored to.
|
|
189
|
+
*
|
|
190
|
+
* `insert` splices text verbatim and `replace_section` preserves the heading,
|
|
191
|
+
* so a caller who includes the heading in their content gets two of them — and
|
|
192
|
+
* the tools said nothing. Two separate incidents in one agent's log came from
|
|
193
|
+
* this, the second while trying to repair the first, which is the shape that
|
|
194
|
+
* makes a silent trap expensive: the fix looks like more of the same call.
|
|
195
|
+
*
|
|
196
|
+
* There is no legitimate reading of "this section's body starts with this
|
|
197
|
+
* section's own heading", so this refuses rather than warns.
|
|
198
|
+
*/
|
|
199
|
+
export class DuplicateHeadingError extends Error {
|
|
200
|
+
constructor(heading: string, opName: string) {
|
|
201
|
+
super(
|
|
202
|
+
`The text you passed to ${opName} starts with the anchor heading itself ` +
|
|
203
|
+
`(${JSON.stringify(heading)}). No write was performed. The heading is kept ` +
|
|
204
|
+
`automatically — ${opName === "replace_section" ? "replace_section preserves it" : "insert places your text inside the section"}, ` +
|
|
205
|
+
`so including it would produce two. Send only the new content, without that ` +
|
|
206
|
+
`heading line. A DEEPER sub-heading inside your text is fine.`,
|
|
207
|
+
);
|
|
208
|
+
this.name = "DuplicateHeadingError";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
/** Structural validation failure of an operations array (before any parsing). */
|
|
188
213
|
export class InvalidOperationError extends Error {
|
|
189
214
|
constructor(index: number, message: string) {
|
|
@@ -374,6 +399,23 @@ export function extractSection(
|
|
|
374
399
|
};
|
|
375
400
|
}
|
|
376
401
|
|
|
402
|
+
/**
|
|
403
|
+
* First non-blank line of a block, trimmed — what a caller "starts with".
|
|
404
|
+
*/
|
|
405
|
+
function firstMeaningfulLine(text: string): string {
|
|
406
|
+
for (const line of text.split("\n")) {
|
|
407
|
+
if (line.trim() !== "") return canonicalHeading(line);
|
|
408
|
+
}
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Refuse text that repeats the heading it is being placed under. */
|
|
413
|
+
function assertNoDuplicateHeading(text: string, node: OutlineNode, opName: string): void {
|
|
414
|
+
if (firstMeaningfulLine(text) === node.heading) {
|
|
415
|
+
throw new DuplicateHeadingError(node.heading, opName);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
377
419
|
function firstChild(outline: OutlineNode[], node: OutlineNode): OutlineNode | null {
|
|
378
420
|
for (const n of outline) {
|
|
379
421
|
if (n.start >= node.bodyStart && n.start < node.subtreeEnd) return n;
|
|
@@ -468,9 +510,15 @@ function applyOne(
|
|
|
468
510
|
at = node.start;
|
|
469
511
|
detail = "insert before_heading";
|
|
470
512
|
} else if (position === "after_heading") {
|
|
513
|
+
// Lands INSIDE the section, immediately below its heading — the same
|
|
514
|
+
// duplication trap as end_of_section. `before_heading` is deliberately
|
|
515
|
+
// NOT guarded: text placed before a heading becomes a new sibling
|
|
516
|
+
// section, and one that repeats the name is a legitimate way to split.
|
|
517
|
+
assertNoDuplicateHeading(text, node, "insert");
|
|
471
518
|
at = node.bodyStart;
|
|
472
519
|
detail = "insert after_heading";
|
|
473
520
|
} else {
|
|
521
|
+
assertNoDuplicateHeading(text, node, "insert");
|
|
474
522
|
at = resolveSectionEnd(content, outline, node, operation.section_part, "end_of_section insert");
|
|
475
523
|
detail =
|
|
476
524
|
`insert at end_of_section` +
|
|
@@ -484,6 +532,7 @@ function applyOne(
|
|
|
484
532
|
|
|
485
533
|
if (operation.op === "replace_section") {
|
|
486
534
|
const node = resolveAnchor(outline, operation.anchor_heading);
|
|
535
|
+
assertNoDuplicateHeading(operation.text, node, "replace_section");
|
|
487
536
|
const to = resolveSectionEnd(content, outline, node, operation.section_part, "replace_section");
|
|
488
537
|
return {
|
|
489
538
|
content: spliceBlock(content, node.bodyStart, to, operation.text),
|