@cerefox/memory 0.10.4 → 0.11.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 +37 -14
- package/AGENT_QUICK_REFERENCE.md +13 -8
- package/dist/bin/cerefox.js +179 -59
- package/dist/frontend/assets/{index-DVXDQ7__.js → index-ojNhWSxm.js} +3 -3
- package/dist/frontend/assets/index-ojNhWSxm.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +6 -2
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +6 -6
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +71 -6
- package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
- package/dist/server-assets/_shared/mcp-tools/search.ts +4 -1
- package/dist/server-assets/db/rpcs.sql +77 -11
- package/dist/server-assets/db/schema.sql +1 -1
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +5 -1
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +65 -1
- package/docs/guides/agent-coordination.md +14 -1
- package/docs/guides/cli.md +20 -5
- package/docs/guides/connect-agents.md +40 -3
- package/package.json +1 -1
- package/dist/frontend/assets/index-DVXDQ7__.js.map +0 -1
package/dist/frontend/index.html
CHANGED
|
@@ -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-
|
|
18
|
+
<script type="module" crossorigin src="/app/assets/index-ojNhWSxm.js"></script>
|
|
19
19
|
<link rel="stylesheet" crossorigin href="/app/assets/index-Asx5wD7g.css">
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
@@ -32,6 +32,7 @@ async function handler(
|
|
|
32
32
|
full_content?: string;
|
|
33
33
|
chunk_count?: number;
|
|
34
34
|
total_chars?: number;
|
|
35
|
+
content_hash?: string;
|
|
35
36
|
}
|
|
36
37
|
| undefined;
|
|
37
38
|
|
|
@@ -46,13 +47,16 @@ async function handler(
|
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
const label = version_id !== null ? " (archived version)" : " (current)";
|
|
49
|
-
|
|
50
|
+
// content_hash is the optimistic-concurrency token: pass it back as
|
|
51
|
+
// expected_content_hash when updating this document via cerefox_ingest.
|
|
52
|
+
const hashLine = row.content_hash ? `content_hash: ${row.content_hash}\n\n` : "";
|
|
53
|
+
return `# ${row.doc_title ?? "Untitled"}${label}\n${hashLine}${row.full_content ?? ""}`;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export const getDocumentTool: ToolDefinition = {
|
|
53
57
|
name: "cerefox_get_document",
|
|
54
58
|
description:
|
|
55
|
-
"Retrieve the full reconstructed content of a document. 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.",
|
|
59
|
+
"Retrieve the full reconstructed content of a document. 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).",
|
|
56
60
|
inputSchema: {
|
|
57
61
|
type: "object",
|
|
58
62
|
required: ["document_id"],
|
|
@@ -11,16 +11,16 @@
|
|
|
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 **10 MCP tools** (9 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), `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata`, `author` |\n| `cerefox_get_document` | Get full document by ID | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\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## 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. **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) -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\", author=\"my-agent\")\n```\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true, 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`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\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>\" --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_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 **10 MCP tools** (9 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`, `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\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## 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 read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. 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. **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`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\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_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
|
|
15
15
|
|
|
16
16
|
/** Sections keyed by their H2 heading text (lower-cased for matching). */
|
|
17
17
|
export const HELP_SECTIONS: Record<string, string> = {
|
|
18
|
-
"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), `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata`, `author` |\n| `cerefox_get_document` | Get full document by ID | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\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) |",
|
|
19
|
-
"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. **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`.",
|
|
20
|
-
"Update Workflow (ID-based -- preferred)": "## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\", author=\"my-agent\")\n
|
|
21
|
-
"Update Workflow (title-based -- fallback)": "## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true, author=\"my-agent\")\n```",
|
|
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`, `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\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) |",
|
|
19
|
+
"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 read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. 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. **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`.",
|
|
20
|
+
"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.",
|
|
21
|
+
"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```",
|
|
22
22
|
"Catch-Up Workflow": "## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```",
|
|
23
|
-
"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`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\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>\" --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_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.",
|
|
23
|
+
"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`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\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_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
24
|
};
|
|
25
25
|
|
|
26
26
|
export const HELP_SECTION_HEADINGS: string[] = ["Tools", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)"];
|
|
@@ -23,6 +23,43 @@ import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.
|
|
|
23
23
|
import { logUsage } from "./_utils.ts";
|
|
24
24
|
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Agent-first instructions for an optimistic-concurrency conflict (iter-32).
|
|
28
|
+
* Raised either by the local fast-fail (before the embedding spend) or by the
|
|
29
|
+
* authoritative check inside the cerefox_ingest_document RPC.
|
|
30
|
+
*/
|
|
31
|
+
function conflictError(documentId: string, expectedHash: string, currentHash: string): Error {
|
|
32
|
+
return new Error(
|
|
33
|
+
`Conflict: document ${documentId} changed since you read it ` +
|
|
34
|
+
`(your base hash: ${expectedHash}, current hash: ${currentHash}). ` +
|
|
35
|
+
`To resolve: (1) cerefox_get_document("${documentId}") to fetch the latest content ` +
|
|
36
|
+
`and its content_hash, (2) merge your changes into it, (3) retry cerefox_ingest ` +
|
|
37
|
+
`with expected_content_hash set to the new hash. Do not overwrite blindly — ` +
|
|
38
|
+
`the current content may include another writer's work.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map RPC-side CEREFOX_CONFLICT / CEREFOX_TOKEN_REQUIRED errors to agent-first text. */
|
|
43
|
+
function mapIngestRpcError(message: string, documentId: string): Error {
|
|
44
|
+
if (message.includes("CEREFOX_CONFLICT")) {
|
|
45
|
+
const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
|
|
46
|
+
const expected = message.match(/expected hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
|
|
47
|
+
return conflictError(documentId, expected, current);
|
|
48
|
+
}
|
|
49
|
+
if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
|
|
50
|
+
const current = message.match(/Current hash: ([0-9a-f]{64})/)?.[1];
|
|
51
|
+
return new Error(
|
|
52
|
+
`Concurrency token required: content updates need expected_content_hash — ` +
|
|
53
|
+
`the content_hash of the version you based your edit on (returned by ` +
|
|
54
|
+
`cerefox_get_document, cerefox_search, and cerefox_metadata_search).` +
|
|
55
|
+
(current ? ` The document's current hash is ${current}; pass it ONLY if your edit was based on the current content.` : "") +
|
|
56
|
+
` If you have not read the document, read it first. To deliberately overwrite ` +
|
|
57
|
+
`regardless of concurrent changes, pass last_write_wins=true.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return new Error(`Ingest RPC failed: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
async function handler(
|
|
27
64
|
supabase: MCPSupabaseClient,
|
|
28
65
|
args: Record<string, unknown>,
|
|
@@ -38,6 +75,8 @@ async function handler(
|
|
|
38
75
|
const update_if_exists = (args.update_if_exists as boolean | undefined) ?? false;
|
|
39
76
|
const author = (args.author as string | undefined) ?? "mcp-agent";
|
|
40
77
|
const author_type = "agent"; // MCP path is always agent
|
|
78
|
+
const expected_content_hash = (args.expected_content_hash as string | undefined)?.trim() || null;
|
|
79
|
+
const last_write_wins = (args.last_write_wins as boolean | undefined) ?? false;
|
|
41
80
|
|
|
42
81
|
if (!title || !content?.trim()) {
|
|
43
82
|
throw new McpInvalidParams("title and content are required");
|
|
@@ -83,7 +122,13 @@ async function handler(
|
|
|
83
122
|
const note = update_if_exists
|
|
84
123
|
? ""
|
|
85
124
|
: " Note: update_if_exists flag was overridden by document_id.";
|
|
86
|
-
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.${note}`;
|
|
125
|
+
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash}).${note}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fast-fail on a stale token BEFORE paying the embedding cost. Advisory
|
|
129
|
+
// only — the authoritative, race-free check is inside the RPC (FOR UPDATE).
|
|
130
|
+
if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
|
|
131
|
+
throw conflictError(existingDoc.id, expected_content_hash, existingDoc.content_hash);
|
|
87
132
|
}
|
|
88
133
|
|
|
89
134
|
const chunks = chunkMarkdown(content);
|
|
@@ -115,9 +160,11 @@ async function handler(
|
|
|
115
160
|
p_author: author,
|
|
116
161
|
p_author_type: author_type,
|
|
117
162
|
p_source_label: source,
|
|
163
|
+
p_expected_content_hash: expected_content_hash,
|
|
164
|
+
p_last_write_wins: last_write_wins,
|
|
118
165
|
});
|
|
119
166
|
|
|
120
|
-
if (ingestErr) throw
|
|
167
|
+
if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
|
|
121
168
|
|
|
122
169
|
logUsage(supabase, {
|
|
123
170
|
operation: "ingest",
|
|
@@ -136,7 +183,7 @@ async function handler(
|
|
|
136
183
|
const note = update_if_exists
|
|
137
184
|
? ""
|
|
138
185
|
: " Note: update_if_exists flag was overridden by document_id.";
|
|
139
|
-
return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars.${note}`;
|
|
186
|
+
return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars. New content_hash: ${contentHash}.${note}`;
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
// ── Update-existing path ─────────────────────────────────────────────────
|
|
@@ -152,7 +199,13 @@ async function handler(
|
|
|
152
199
|
const existingDoc = existing[0];
|
|
153
200
|
|
|
154
201
|
if (existingDoc.content_hash === contentHash) {
|
|
155
|
-
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.`;
|
|
202
|
+
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash}).`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fast-fail on a stale token BEFORE the embedding cost (advisory; the
|
|
206
|
+
// authoritative check is in the RPC).
|
|
207
|
+
if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
|
|
208
|
+
throw conflictError(existingDoc.id, expected_content_hash, existingDoc.content_hash);
|
|
156
209
|
}
|
|
157
210
|
|
|
158
211
|
const chunks = chunkMarkdown(content);
|
|
@@ -184,9 +237,11 @@ async function handler(
|
|
|
184
237
|
p_author: author,
|
|
185
238
|
p_author_type: author_type,
|
|
186
239
|
p_source_label: source,
|
|
240
|
+
p_expected_content_hash: expected_content_hash,
|
|
241
|
+
p_last_write_wins: last_write_wins,
|
|
187
242
|
});
|
|
188
243
|
|
|
189
|
-
if (ingestErr) throw
|
|
244
|
+
if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
|
|
190
245
|
|
|
191
246
|
logUsage(supabase, {
|
|
192
247
|
operation: "ingest",
|
|
@@ -202,7 +257,7 @@ async function handler(
|
|
|
202
257
|
await ensureDocumentInProject(supabase, existingDoc.id, project_name);
|
|
203
258
|
}
|
|
204
259
|
|
|
205
|
-
return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars.`;
|
|
260
|
+
return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars. New content_hash: ${contentHash}.`;
|
|
206
261
|
}
|
|
207
262
|
// Fall through to create path
|
|
208
263
|
}
|
|
@@ -303,6 +358,16 @@ export const ingestTool: ToolDefinition = {
|
|
|
303
358
|
description:
|
|
304
359
|
"When true, update an existing document with the same title instead of creating a new one (default: false). Ignored when document_id is provided.",
|
|
305
360
|
},
|
|
361
|
+
expected_content_hash: {
|
|
362
|
+
type: "string",
|
|
363
|
+
description:
|
|
364
|
+
"REQUIRED on content updates (optimistic concurrency): the content_hash of the document version you based your edit on, as returned by cerefox_get_document / cerefox_search / cerefox_metadata_search. If the document changed since you read it, the update fails with a conflict — re-read, merge, retry with the new hash. Not needed when creating a new document.",
|
|
365
|
+
},
|
|
366
|
+
last_write_wins: {
|
|
367
|
+
type: "boolean",
|
|
368
|
+
description:
|
|
369
|
+
"Explicitly skip the concurrency check and overwrite regardless of concurrent changes (default: false). Use ONLY when an external source of truth makes conflicts meaningless (e.g. re-syncing from files). Recorded in the audit log.",
|
|
370
|
+
},
|
|
306
371
|
metadata: { type: "object", description: "Arbitrary JSON metadata (optional)" },
|
|
307
372
|
author: {
|
|
308
373
|
type: "string",
|
|
@@ -78,6 +78,7 @@ async function handler(
|
|
|
78
78
|
project_ids: string[];
|
|
79
79
|
project_names: string[];
|
|
80
80
|
version_count: number;
|
|
81
|
+
content_hash: string | null;
|
|
81
82
|
content: string | null;
|
|
82
83
|
}>;
|
|
83
84
|
|
|
@@ -105,9 +106,10 @@ async function handler(
|
|
|
105
106
|
const meta = Object.entries(row.doc_metadata ?? {})
|
|
106
107
|
.map(([k, v]) => `${k}=${v}`)
|
|
107
108
|
.join(", ");
|
|
109
|
+
const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
|
|
108
110
|
const header =
|
|
109
111
|
`## ${row.title} [id: ${row.document_id}]\n` +
|
|
110
|
-
`${meta}${projects} | ${row.total_chars} chars | ${row.review_status} | updated ${row.updated_at?.slice(0, 10) ?? "?"}`;
|
|
112
|
+
`${meta}${projects} | ${row.total_chars} chars | ${row.review_status} | updated ${row.updated_at?.slice(0, 10) ?? "?"}${hash}`;
|
|
111
113
|
|
|
112
114
|
if (include_content && row.content) {
|
|
113
115
|
return `${header}\n\n${row.content}`;
|
|
@@ -135,6 +135,7 @@ async function handler(
|
|
|
135
135
|
is_partial?: boolean;
|
|
136
136
|
chunk_count?: number;
|
|
137
137
|
total_chars?: number;
|
|
138
|
+
content_hash?: string;
|
|
138
139
|
}>;
|
|
139
140
|
|
|
140
141
|
const parts: string[] = rows.map((row) => {
|
|
@@ -144,7 +145,9 @@ async function handler(
|
|
|
144
145
|
const partial = row.is_partial
|
|
145
146
|
? ` -- partial (${row.chunk_count} of ${(row.total_chars ?? 0).toLocaleString()} chars)`
|
|
146
147
|
: "";
|
|
147
|
-
|
|
148
|
+
// content_hash = the concurrency token for cerefox_ingest updates (iter-32).
|
|
149
|
+
const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
|
|
150
|
+
return `## ${title}${docId}${score}${partial}${hash}\n\n${row.full_content ?? ""}`;
|
|
148
151
|
});
|
|
149
152
|
|
|
150
153
|
let output = parts.join("\n\n---\n\n");
|
|
@@ -38,6 +38,13 @@ DROP FUNCTION IF EXISTS cerefox_hybrid_search(TEXT, VECTOR(768), INT, FLOAT, BOO
|
|
|
38
38
|
DROP FUNCTION IF EXISTS cerefox_fts_search(TEXT, INT, UUID);
|
|
39
39
|
DROP FUNCTION IF EXISTS cerefox_semantic_search(VECTOR(768), INT, BOOLEAN, UUID, FLOAT);
|
|
40
40
|
DROP FUNCTION IF EXISTS cerefox_reconstruct_doc(UUID);
|
|
41
|
+
|
|
42
|
+
-- Iteration 32 (v0.11, optimistic concurrency): content_hash added to the return
|
|
43
|
+
-- types of all document-shaped reads — the writer's concurrency token must be
|
|
44
|
+
-- obtainable from every read surface. Drop the pre-change signatures first.
|
|
45
|
+
DROP FUNCTION IF EXISTS cerefox_get_document(UUID, UUID);
|
|
46
|
+
DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID, FLOAT, INT, INT, JSONB);
|
|
47
|
+
DROP FUNCTION IF EXISTS cerefox_metadata_search(JSONB, UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, BOOLEAN, INT);
|
|
41
48
|
DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID, FLOAT);
|
|
42
49
|
|
|
43
50
|
-- Iteration 13: Drop pre-metadata-filter signatures so we can add p_metadata_filter JSONB.
|
|
@@ -592,7 +599,10 @@ RETURNS TABLE (
|
|
|
592
599
|
total_chars INT,
|
|
593
600
|
doc_updated_at TIMESTAMPTZ,
|
|
594
601
|
version_count INT,
|
|
595
|
-
is_partial BOOL
|
|
602
|
+
is_partial BOOL,
|
|
603
|
+
-- Optimistic-concurrency token (iter-32): the document's current
|
|
604
|
+
-- content_hash, to pass back as expected_content_hash on update.
|
|
605
|
+
content_hash TEXT
|
|
596
606
|
)
|
|
597
607
|
LANGUAGE sql
|
|
598
608
|
SECURITY DEFINER
|
|
@@ -625,7 +635,8 @@ AS $$
|
|
|
625
635
|
cr.doc_project_ids,
|
|
626
636
|
cr.doc_project_names,
|
|
627
637
|
cr.version_count,
|
|
628
|
-
d.updated_at AS doc_updated_at
|
|
638
|
+
d.updated_at AS doc_updated_at,
|
|
639
|
+
d.content_hash
|
|
629
640
|
FROM chunk_results cr
|
|
630
641
|
JOIN cerefox_documents d ON d.id = cr.document_id
|
|
631
642
|
ORDER BY cr.document_id, cr.score DESC
|
|
@@ -707,7 +718,8 @@ AS $$
|
|
|
707
718
|
ds.total_chars, -- always full document size, even for partial results
|
|
708
719
|
td.doc_updated_at,
|
|
709
720
|
td.version_count,
|
|
710
|
-
ac.is_partial
|
|
721
|
+
ac.is_partial,
|
|
722
|
+
td.content_hash
|
|
711
723
|
FROM top_docs td
|
|
712
724
|
JOIN doc_sizes ds ON ds.document_id = td.document_id
|
|
713
725
|
JOIN all_content ac ON ac.document_id = td.document_id
|
|
@@ -832,7 +844,11 @@ RETURNS TABLE (
|
|
|
832
844
|
full_content TEXT,
|
|
833
845
|
chunk_count INT,
|
|
834
846
|
total_chars INT,
|
|
835
|
-
created_at TIMESTAMPTZ
|
|
847
|
+
created_at TIMESTAMPTZ,
|
|
848
|
+
-- Current content_hash of the document — the optimistic-concurrency token
|
|
849
|
+
-- to pass back as expected_content_hash on update (iter-32). Note: always
|
|
850
|
+
-- the CURRENT hash, even when an archived version is being retrieved.
|
|
851
|
+
content_hash TEXT
|
|
836
852
|
)
|
|
837
853
|
LANGUAGE sql
|
|
838
854
|
SECURITY DEFINER
|
|
@@ -853,7 +869,8 @@ AS $$
|
|
|
853
869
|
STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) AS full_content,
|
|
854
870
|
COUNT(*)::INT AS chunk_count,
|
|
855
871
|
SUM(c.char_count)::INT AS total_chars,
|
|
856
|
-
d.created_at
|
|
872
|
+
d.created_at,
|
|
873
|
+
d.content_hash
|
|
857
874
|
FROM cerefox_documents d
|
|
858
875
|
JOIN cerefox_chunks c ON c.document_id = d.id
|
|
859
876
|
WHERE d.id = p_document_id
|
|
@@ -861,7 +878,7 @@ AS $$
|
|
|
861
878
|
(p_version_id IS NULL AND c.version_id IS NULL) OR
|
|
862
879
|
(p_version_id IS NOT NULL AND c.version_id = p_version_id)
|
|
863
880
|
)
|
|
864
|
-
GROUP BY d.id, d.title, d.source, d.metadata, d.created_at;
|
|
881
|
+
GROUP BY d.id, d.title, d.source, d.metadata, d.created_at, d.content_hash;
|
|
865
882
|
$$;
|
|
866
883
|
|
|
867
884
|
-- ── cerefox_list_document_versions ────────────────────────────────────────────
|
|
@@ -1037,11 +1054,21 @@ $$;
|
|
|
1037
1054
|
-- p_source_label : version source label for snapshot ('file','paste','agent','manual')
|
|
1038
1055
|
-- p_retention_hours : for version cleanup (default 48)
|
|
1039
1056
|
-- p_cleanup_enabled : whether version cleanup runs (default true)
|
|
1057
|
+
-- p_expected_content_hash : optimistic-concurrency token (iter-32). On the UPDATE
|
|
1058
|
+
-- path this must equal the document's current content_hash —
|
|
1059
|
+
-- the caller proves they based their edit on the live version.
|
|
1060
|
+
-- Mismatch → CEREFOX_CONFLICT (SQLSTATE 40001). Absent (NULL)
|
|
1061
|
+
-- without p_last_write_wins → CEREFOX_TOKEN_REQUIRED (22023).
|
|
1062
|
+
-- Ignored on the CREATE path.
|
|
1063
|
+
-- p_last_write_wins : explicit opt-out of the concurrency check (filesystem-sync
|
|
1064
|
+
-- flows where an external source of truth makes conflicts
|
|
1065
|
+
-- meaningless). Recorded in the audit description when used.
|
|
1040
1066
|
--
|
|
1041
1067
|
-- Returns: document_id, chunk_count, total_chars, operation ('create' or 'update-content'),
|
|
1042
1068
|
-- version_id (UUID of snapshot, null on create)
|
|
1043
1069
|
|
|
1044
1070
|
DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN);
|
|
1071
|
+
DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN);
|
|
1045
1072
|
CREATE FUNCTION cerefox_ingest_document(
|
|
1046
1073
|
p_document_id UUID DEFAULT NULL,
|
|
1047
1074
|
p_title TEXT DEFAULT 'Untitled',
|
|
@@ -1055,7 +1082,9 @@ CREATE FUNCTION cerefox_ingest_document(
|
|
|
1055
1082
|
p_author_type TEXT DEFAULT 'user',
|
|
1056
1083
|
p_source_label TEXT DEFAULT 'manual',
|
|
1057
1084
|
p_retention_hours INT DEFAULT 48,
|
|
1058
|
-
p_cleanup_enabled BOOLEAN DEFAULT TRUE
|
|
1085
|
+
p_cleanup_enabled BOOLEAN DEFAULT TRUE,
|
|
1086
|
+
p_expected_content_hash TEXT DEFAULT NULL,
|
|
1087
|
+
p_last_write_wins BOOLEAN DEFAULT FALSE
|
|
1059
1088
|
)
|
|
1060
1089
|
RETURNS TABLE (
|
|
1061
1090
|
document_id UUID,
|
|
@@ -1075,6 +1104,7 @@ DECLARE
|
|
|
1075
1104
|
v_operation TEXT;
|
|
1076
1105
|
v_version_id UUID := NULL;
|
|
1077
1106
|
v_old_chars INT := 0;
|
|
1107
|
+
v_current_hash TEXT;
|
|
1078
1108
|
v_chunk JSONB;
|
|
1079
1109
|
v_snap RECORD;
|
|
1080
1110
|
v_status TEXT;
|
|
@@ -1114,9 +1144,38 @@ BEGIN
|
|
|
1114
1144
|
v_doc_id := p_document_id;
|
|
1115
1145
|
v_operation := 'update-content';
|
|
1116
1146
|
|
|
1117
|
-
--
|
|
1118
|
-
|
|
1119
|
-
|
|
1147
|
+
-- Lock the row and read its current state. FOR UPDATE makes the
|
|
1148
|
+
-- concurrency check below atomic with the write: two simultaneous
|
|
1149
|
+
-- updaters serialize here, and the second one sees the first one's
|
|
1150
|
+
-- hash — the race window (chunk + embed latency) is closed at the
|
|
1151
|
+
-- only place all transports share (iter-32).
|
|
1152
|
+
SELECT COALESCE(d.total_chars, 0), d.content_hash
|
|
1153
|
+
INTO v_old_chars, v_current_hash
|
|
1154
|
+
FROM cerefox_documents d WHERE d.id = v_doc_id
|
|
1155
|
+
FOR UPDATE;
|
|
1156
|
+
|
|
1157
|
+
IF NOT FOUND THEN
|
|
1158
|
+
RAISE EXCEPTION 'cerefox_ingest_document: document not found: %', v_doc_id
|
|
1159
|
+
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1160
|
+
END IF;
|
|
1161
|
+
|
|
1162
|
+
-- ── Optimistic concurrency check (iter-32) ───────────────────
|
|
1163
|
+
-- Content updates must prove freshness (expected hash) or explicitly
|
|
1164
|
+
-- choose last-write-wins. Message prefixes are machine-detectable:
|
|
1165
|
+
-- transport handlers map them to agent-first retry instructions.
|
|
1166
|
+
IF NOT p_last_write_wins THEN
|
|
1167
|
+
IF p_expected_content_hash IS NULL THEN
|
|
1168
|
+
RAISE EXCEPTION
|
|
1169
|
+
'CEREFOX_TOKEN_REQUIRED: content updates require expected_content_hash (the content_hash you read) or last_write_wins=true. Current hash: %',
|
|
1170
|
+
v_current_hash
|
|
1171
|
+
USING ERRCODE = '22023'; -- invalid_parameter_value
|
|
1172
|
+
ELSIF p_expected_content_hash <> v_current_hash THEN
|
|
1173
|
+
RAISE EXCEPTION
|
|
1174
|
+
'CEREFOX_CONFLICT: document % changed since it was read (expected hash %, current hash %). Re-read the document, merge your changes, and retry with the new hash.',
|
|
1175
|
+
v_doc_id, p_expected_content_hash, v_current_hash
|
|
1176
|
+
USING ERRCODE = '40001'; -- serialization_failure
|
|
1177
|
+
END IF;
|
|
1178
|
+
END IF;
|
|
1120
1179
|
|
|
1121
1180
|
-- Snapshot old version (archives current chunks, runs retention cleanup)
|
|
1122
1181
|
SELECT sv.version_id INTO v_version_id
|
|
@@ -1183,6 +1242,8 @@ BEGIN
|
|
|
1183
1242
|
p_size_before := CASE WHEN v_operation = 'create' THEN NULL ELSE v_old_chars END,
|
|
1184
1243
|
p_size_after := v_total_chars,
|
|
1185
1244
|
p_description := v_operation || ': ' || p_title || ' (' || v_chunk_count || ' chunks, ' || v_total_chars || ' chars)'
|
|
1245
|
+
|| CASE WHEN p_last_write_wins AND v_operation = 'update-content'
|
|
1246
|
+
THEN ' [last-write-wins]' ELSE '' END
|
|
1186
1247
|
);
|
|
1187
1248
|
|
|
1188
1249
|
RETURN QUERY SELECT v_doc_id, v_chunk_count, v_total_chars, v_operation, v_version_id;
|
|
@@ -1407,6 +1468,9 @@ RETURNS TABLE (
|
|
|
1407
1468
|
project_ids UUID[],
|
|
1408
1469
|
project_names TEXT[],
|
|
1409
1470
|
version_count INT,
|
|
1471
|
+
-- Optimistic-concurrency token (iter-32): pass back as
|
|
1472
|
+
-- expected_content_hash on update.
|
|
1473
|
+
content_hash TEXT,
|
|
1410
1474
|
content TEXT
|
|
1411
1475
|
)
|
|
1412
1476
|
LANGUAGE plpgsql
|
|
@@ -1436,6 +1500,7 @@ BEGIN
|
|
|
1436
1500
|
WHERE dp.document_id = d.id) AS project_names,
|
|
1437
1501
|
(SELECT COUNT(*)::INT FROM cerefox_document_versions dv
|
|
1438
1502
|
WHERE dv.document_id = d.id) AS version_count,
|
|
1503
|
+
d.content_hash,
|
|
1439
1504
|
CASE WHEN p_include_content THEN
|
|
1440
1505
|
(SELECT STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index)
|
|
1441
1506
|
FROM cerefox_chunks c
|
|
@@ -1474,6 +1539,7 @@ BEGIN
|
|
|
1474
1539
|
project_ids := v_row.project_ids;
|
|
1475
1540
|
project_names := v_row.project_names;
|
|
1476
1541
|
version_count := v_row.version_count;
|
|
1542
|
+
content_hash := v_row.content_hash;
|
|
1477
1543
|
content := v_row.content;
|
|
1478
1544
|
RETURN NEXT;
|
|
1479
1545
|
END LOOP;
|
|
@@ -1694,7 +1760,7 @@ SET search_path = public, pg_catalog
|
|
|
1694
1760
|
AS $$
|
|
1695
1761
|
-- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
|
|
1696
1762
|
-- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
|
|
1697
|
-
SELECT '0.
|
|
1763
|
+
SELECT '0.5.0'::TEXT;
|
|
1698
1764
|
$$;
|
|
1699
1765
|
|
|
1700
1766
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
-- Requires extensions: vector (pgvector), uuid-ossp
|
|
6
6
|
-- These are enabled at the top of db_deploy.py before this file is applied.
|
|
7
7
|
--
|
|
8
|
-
-- @version: 0.
|
|
8
|
+
-- @version: 0.5.0
|
|
9
9
|
-- The `@version` marker above is read by the schema-version-mismatch banner
|
|
10
10
|
-- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
|
|
11
11
|
-- changes in a way that requires `cerefox server deploy` to be re-run —
|
|
@@ -17,7 +17,7 @@ import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/inde
|
|
|
17
17
|
* { document_id: string, version_id?: string | null }
|
|
18
18
|
*
|
|
19
19
|
* Response (200):
|
|
20
|
-
* { document_id, doc_title, full_content, chunk_count, total_chars, is_archived, version_id }
|
|
20
|
+
* { document_id, doc_title, full_content, chunk_count, total_chars, is_archived, version_id, content_hash }
|
|
21
21
|
* Response (404):
|
|
22
22
|
* { error: "Document not found" }
|
|
23
23
|
* Response (400):
|
|
@@ -98,6 +98,7 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|
|
98
98
|
full_content?: string;
|
|
99
99
|
chunk_count?: number;
|
|
100
100
|
total_chars?: number;
|
|
101
|
+
content_hash?: string;
|
|
101
102
|
} | undefined;
|
|
102
103
|
|
|
103
104
|
if (!row) {
|
|
@@ -125,6 +126,9 @@ Deno.serve(async (req: Request): Promise<Response> => {
|
|
|
125
126
|
total_chars: row.total_chars ?? 0,
|
|
126
127
|
is_archived: version_id !== null,
|
|
127
128
|
version_id,
|
|
129
|
+
// Optimistic-concurrency token (iter-32): always the CURRENT hash —
|
|
130
|
+
// pass back as expected_content_hash when updating via ingest.
|
|
131
|
+
content_hash: row.content_hash ?? null,
|
|
128
132
|
}),
|
|
129
133
|
{ status: 200, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
|
|
130
134
|
);
|