@cerefox/memory 0.10.4 → 0.11.1

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.
@@ -7184,7 +7184,7 @@ var exports_meta = {};
7184
7184
  __export(exports_meta, {
7185
7185
  PKG_VERSION: () => PKG_VERSION
7186
7186
  });
7187
- var PKG_VERSION = "0.10.4";
7187
+ var PKG_VERSION = "0.11.1";
7188
7188
  var init_meta = () => {};
7189
7189
 
7190
7190
  // ../../node_modules/.bun/tslib@2.8.1/node_modules/tslib/tslib.js
@@ -54007,16 +54007,18 @@ async function handler2(supabase, args, ctx) {
54007
54007
  result_count: 1
54008
54008
  });
54009
54009
  const label = version_id !== null ? " (archived version)" : " (current)";
54010
- return `# ${row.doc_title ?? "Untitled"}${label}
54010
+ const hashLine = row.content_hash ? `content_hash: ${row.content_hash}
54011
54011
 
54012
- ${row.full_content ?? ""}`;
54012
+ ` : "";
54013
+ return `# ${row.doc_title ?? "Untitled"}${label}
54014
+ ${hashLine}${row.full_content ?? ""}`;
54013
54015
  }
54014
54016
  var getDocumentTool;
54015
54017
  var init_get_document = __esm(() => {
54016
54018
  init_types3();
54017
54019
  getDocumentTool = {
54018
54020
  name: "cerefox_get_document",
54019
- description: "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.",
54021
+ description: "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).",
54020
54022
  inputSchema: {
54021
54023
  type: "object",
54022
54024
  required: ["document_id"],
@@ -54037,15 +54039,23 @@ var init_get_document = __esm(() => {
54037
54039
  });
54038
54040
 
54039
54041
  // ../../_shared/mcp-tools/get-help-content.ts
54040
- var 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', HELP_SECTIONS, HELP_SECTION_HEADINGS;
54042
+ var 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` (omit on update to keep existing tags; `{}` clears), `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', HELP_SECTIONS, HELP_SECTION_HEADINGS;
54041
54043
  var init_get_help_content = __esm(() => {
54042
54044
  HELP_SECTIONS = {
54043
- 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) |",
54044
- "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`.',
54045
- "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```',
54046
- "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```',
54045
+ 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_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) |",
54046
+ "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`.',
54047
+ "Update Workflow (ID-based -- preferred)": `## Update Workflow (ID-based -- preferred)
54048
+
54049
+ \`\`\`
54050
+ search("topic") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->
54051
+ ingest(title="Same Title", content="...", document_id="abc123",
54052
+ expected_content_hash="<the hash you read>", author="my-agent")
54053
+ \`\`\`
54054
+
54055
+ On a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.`,
54056
+ "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```',
54047
54057
  "Catch-Up Workflow": '## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={"type": "decision-log"}, updated_since="2026-03-28T00:00:00Z")\n```',
54048
- "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.'
54058
+ "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.'
54049
54059
  };
54050
54060
  HELP_SECTION_HEADINGS = ["Tools", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)"];
54051
54061
  });
@@ -54265,6 +54275,21 @@ async function sha256hex(text) {
54265
54275
  var MAX_CHUNK_CHARS = 4000;
54266
54276
 
54267
54277
  // ../../_shared/mcp-tools/ingest.ts
54278
+ function conflictError(documentId, expectedHash, currentHash) {
54279
+ return new Error(`Conflict: document ${documentId} changed since you read it ` + `(your base hash: ${expectedHash}, current hash: ${currentHash}). ` + `To resolve: (1) cerefox_get_document("${documentId}") to fetch the latest content ` + `and its content_hash, (2) merge your changes into it, (3) retry cerefox_ingest ` + `with expected_content_hash set to the new hash. Do not overwrite blindly — ` + `the current content may include another writer's work.`);
54280
+ }
54281
+ function mapIngestRpcError(message, documentId) {
54282
+ if (message.includes("CEREFOX_CONFLICT")) {
54283
+ const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
54284
+ const expected = message.match(/expected hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
54285
+ return conflictError(documentId, expected, current);
54286
+ }
54287
+ if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
54288
+ const current = message.match(/Current hash: ([0-9a-f]{64})/)?.[1];
54289
+ return new Error(`Concurrency token required: content updates need expected_content_hash — ` + `the content_hash of the version you based your edit on (returned by ` + `cerefox_get_document, cerefox_search, and cerefox_metadata_search).` + (current ? ` The document's current hash is ${current}; pass it ONLY if your edit was based on the current content.` : "") + ` If you have not read the document, read it first. To deliberately overwrite ` + `regardless of concurrent changes, pass last_write_wins=true.`);
54290
+ }
54291
+ return new Error(`Ingest RPC failed: ${message}`);
54292
+ }
54268
54293
  async function handler4(supabase, args, ctx) {
54269
54294
  const title = args.title?.trim();
54270
54295
  const content = args.content;
@@ -54272,10 +54297,12 @@ async function handler4(supabase, args, ctx) {
54272
54297
  const project_name = args.project_name;
54273
54298
  const project_names_raw = args.project_names;
54274
54299
  const source = args.source ?? "agent";
54275
- const metadata = args.metadata ?? {};
54300
+ const metadata = args.metadata ?? null;
54276
54301
  const update_if_exists = args.update_if_exists ?? false;
54277
54302
  const author = args.author ?? "mcp-agent";
54278
54303
  const author_type = "agent";
54304
+ const expected_content_hash = args.expected_content_hash?.trim() || null;
54305
+ const last_write_wins = args.last_write_wins ?? false;
54279
54306
  if (!title || !content?.trim()) {
54280
54307
  throw new McpInvalidParams("title and content are required");
54281
54308
  }
@@ -54296,7 +54323,10 @@ async function handler4(supabase, args, ctx) {
54296
54323
  const existingDoc = existing[0];
54297
54324
  if (existingDoc.content_hash === contentHash2) {
54298
54325
  const note2 = update_if_exists ? "" : " Note: update_if_exists flag was overridden by document_id.";
54299
- return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.${note2}`;
54326
+ return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash2}).${note2}`;
54327
+ }
54328
+ if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
54329
+ throw conflictError(existingDoc.id, expected_content_hash, existingDoc.content_hash);
54300
54330
  }
54301
54331
  const chunks2 = chunkMarkdown2(content);
54302
54332
  if (chunks2.length === 0)
@@ -54325,10 +54355,12 @@ ${c2.content}`);
54325
54355
  p_chunks: chunkData2,
54326
54356
  p_author: author,
54327
54357
  p_author_type: author_type,
54328
- p_source_label: source
54358
+ p_source_label: source,
54359
+ p_expected_content_hash: expected_content_hash,
54360
+ p_last_write_wins: last_write_wins
54329
54361
  });
54330
54362
  if (ingestErr2)
54331
- throw new Error(`Ingest RPC failed: ${ingestErr2.message}`);
54363
+ throw mapIngestRpcError(ingestErr2.message, existingDoc.id);
54332
54364
  logUsage(supabase, {
54333
54365
  operation: "ingest",
54334
54366
  accessPath: ctx.accessPath,
@@ -54342,14 +54374,17 @@ ${c2.content}`);
54342
54374
  await ensureDocumentInProject(supabase, existingDoc.id, project_name);
54343
54375
  }
54344
54376
  const note = update_if_exists ? "" : " Note: update_if_exists flag was overridden by document_id.";
54345
- return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks2.length} chunk(s), ${totalChars2} chars.${note}`;
54377
+ return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks2.length} chunk(s), ${totalChars2} chars. New content_hash: ${contentHash2}.${note}`;
54346
54378
  }
54347
54379
  if (update_if_exists) {
54348
54380
  const { data: existing } = await supabase.from("cerefox_documents").select("id, title, content_hash").eq("title", title).order("updated_at", { ascending: false }).limit(1);
54349
54381
  if (existing?.length) {
54350
54382
  const existingDoc = existing[0];
54351
54383
  if (existingDoc.content_hash === contentHash2) {
54352
- return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.`;
54384
+ return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash2}).`;
54385
+ }
54386
+ if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
54387
+ throw conflictError(existingDoc.id, expected_content_hash, existingDoc.content_hash);
54353
54388
  }
54354
54389
  const chunks2 = chunkMarkdown2(content);
54355
54390
  if (chunks2.length === 0)
@@ -54378,10 +54413,12 @@ ${c2.content}`);
54378
54413
  p_chunks: chunkData2,
54379
54414
  p_author: author,
54380
54415
  p_author_type: author_type,
54381
- p_source_label: source
54416
+ p_source_label: source,
54417
+ p_expected_content_hash: expected_content_hash,
54418
+ p_last_write_wins: last_write_wins
54382
54419
  });
54383
54420
  if (ingestErr2)
54384
- throw new Error(`Ingest RPC failed: ${ingestErr2.message}`);
54421
+ throw mapIngestRpcError(ingestErr2.message, existingDoc.id);
54385
54422
  logUsage(supabase, {
54386
54423
  operation: "ingest",
54387
54424
  accessPath: ctx.accessPath,
@@ -54394,7 +54431,7 @@ ${c2.content}`);
54394
54431
  } else if (project_name) {
54395
54432
  await ensureDocumentInProject(supabase, existingDoc.id, project_name);
54396
54433
  }
54397
- return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks2.length} chunk(s), ${totalChars2} chars.`;
54434
+ return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks2.length} chunk(s), ${totalChars2} chars. New content_hash: ${contentHash2}.`;
54398
54435
  }
54399
54436
  }
54400
54437
  const { data: hashMatch } = await supabase.from("cerefox_documents").select("id, title").eq("content_hash", contentHash2).limit(1);
@@ -54479,6 +54516,14 @@ var init_ingest = __esm(() => {
54479
54516
  type: "boolean",
54480
54517
  description: "When true, update an existing document with the same title instead of creating a new one (default: false). Ignored when document_id is provided."
54481
54518
  },
54519
+ expected_content_hash: {
54520
+ type: "string",
54521
+ description: "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."
54522
+ },
54523
+ last_write_wins: {
54524
+ type: "boolean",
54525
+ description: "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."
54526
+ },
54482
54527
  metadata: { type: "object", description: "Arbitrary JSON metadata (optional)" },
54483
54528
  author: {
54484
54529
  type: "string",
@@ -54666,8 +54711,10 @@ async function handler8(supabase, args, ctx) {
54666
54711
  const parts = rows.map((row) => {
54667
54712
  const projects = row.project_names?.length ? ` | projects: ${row.project_names.join(", ")}` : "";
54668
54713
  const meta = Object.entries(row.doc_metadata ?? {}).map(([k, v]) => `${k}=${v}`).join(", ");
54714
+ const hash = row.content_hash ? `
54715
+ hash: ${row.content_hash}` : "";
54669
54716
  const header = `## ${row.title} [id: ${row.document_id}]
54670
- ` + `${meta}${projects} | ${row.total_chars} chars | ${row.review_status} | updated ${row.updated_at?.slice(0, 10) ?? "?"}`;
54717
+ ` + `${meta}${projects} | ${row.total_chars} chars | ${row.review_status} | updated ${row.updated_at?.slice(0, 10) ?? "?"}${hash}`;
54671
54718
  if (include_content && row.content) {
54672
54719
  return `${header}
54673
54720
 
@@ -54809,7 +54856,9 @@ async function handler9(supabase, args, ctx) {
54809
54856
  const docId = row.document_id ? ` [id: ${row.document_id}]` : "";
54810
54857
  const score = row.best_score != null ? ` (score: ${row.best_score.toFixed(3)})` : "";
54811
54858
  const partial = row.is_partial ? ` -- partial (${row.chunk_count} of ${(row.total_chars ?? 0).toLocaleString()} chars)` : "";
54812
- return `## ${title}${docId}${score}${partial}
54859
+ const hash = row.content_hash ? `
54860
+ hash: ${row.content_hash}` : "";
54861
+ return `## ${title}${docId}${score}${partial}${hash}
54813
54862
 
54814
54863
  ${row.full_content ?? ""}`;
54815
54864
  });
@@ -55001,6 +55050,7 @@ async function runSyncSelfDocs(options = {}) {
55001
55050
  topic: doc.topic
55002
55051
  },
55003
55052
  update_if_exists: true,
55053
+ last_write_wins: true,
55004
55054
  project_name: project,
55005
55055
  author,
55006
55056
  author_type: authorType
@@ -74047,7 +74097,7 @@ import { homedir as homedir5 } from "node:os";
74047
74097
  import { join as join8 } from "node:path";
74048
74098
 
74049
74099
  // ../../_shared/ef-meta/index.ts
74050
- var EF_VERSION = "0.10.4";
74100
+ var EF_VERSION = "0.11.1";
74051
74101
 
74052
74102
  // src/cli/util/checks.ts
74053
74103
  init_config();
@@ -74746,6 +74796,9 @@ async function action17(documentId, options) {
74746
74796
  }
74747
74797
  println(c.bold(`# ${doc.doc_title}`));
74748
74798
  println(c.dim(`[${doc.document_id}] · chunks: ${doc.chunk_count} · chars: ${doc.total_chars}` + (doc.is_archived ? " · archived" : "") + (doc.version_id ? ` · version: ${doc.version_id}` : "")));
74799
+ if (doc.content_hash) {
74800
+ println(c.dim(`content_hash: ${doc.content_hash}`));
74801
+ }
74749
74802
  println("");
74750
74803
  println(doc.full_content);
74751
74804
  }
@@ -75005,6 +75058,47 @@ async function resolveProjectIds(input, getOrCreateProject) {
75005
75058
  }
75006
75059
  return [];
75007
75060
  }
75061
+ // src/ingestion/types.ts
75062
+ class ConcurrencyConflictError extends Error {
75063
+ documentId;
75064
+ currentHash;
75065
+ constructor(documentId, currentHash, message) {
75066
+ super(message);
75067
+ this.name = "ConcurrencyConflictError";
75068
+ this.documentId = documentId;
75069
+ this.currentHash = currentHash;
75070
+ }
75071
+ }
75072
+
75073
+ class ConcurrencyTokenRequiredError extends Error {
75074
+ constructor(message) {
75075
+ super(message);
75076
+ this.name = "ConcurrencyTokenRequiredError";
75077
+ }
75078
+ }
75079
+ var DEFAULT_PIPELINE_SETTINGS = {
75080
+ maxChunkChars: 4000,
75081
+ minChunkChars: 100,
75082
+ versionRetentionHours: 48,
75083
+ versionCleanupEnabled: true
75084
+ };
75085
+ function loadPipelineSettings() {
75086
+ const env4 = globalThis.process?.env ?? {};
75087
+ const intMin = (raw, def, min) => {
75088
+ if (raw === undefined || raw === "")
75089
+ return def;
75090
+ const n = Number.parseInt(raw, 10);
75091
+ return Number.isNaN(n) || n < min ? def : n;
75092
+ };
75093
+ const bool = (raw, def) => raw === undefined || raw === "" ? def : !/^(false|0|no|off)$/i.test(raw.trim());
75094
+ return {
75095
+ maxChunkChars: intMin(env4.CEREFOX_MAX_CHUNK_CHARS, DEFAULT_PIPELINE_SETTINGS.maxChunkChars, 1),
75096
+ minChunkChars: intMin(env4.CEREFOX_MIN_CHUNK_CHARS, DEFAULT_PIPELINE_SETTINGS.minChunkChars, 0),
75097
+ versionRetentionHours: intMin(env4.CEREFOX_VERSION_RETENTION_HOURS, DEFAULT_PIPELINE_SETTINGS.versionRetentionHours, 0),
75098
+ versionCleanupEnabled: bool(env4.CEREFOX_VERSION_CLEANUP_ENABLED, DEFAULT_PIPELINE_SETTINGS.versionCleanupEnabled)
75099
+ };
75100
+ }
75101
+
75008
75102
  // src/ingestion/client-bridge.ts
75009
75103
  class IngestionDbBridge {
75010
75104
  supabase;
@@ -75057,9 +75151,22 @@ class IngestionDbBridge {
75057
75151
  params.p_retention_hours = args.retentionHours;
75058
75152
  if (args.cleanupEnabled !== undefined)
75059
75153
  params.p_cleanup_enabled = args.cleanupEnabled;
75154
+ if (args.documentId !== null) {
75155
+ params.p_expected_content_hash = args.expectedContentHash ?? null;
75156
+ params.p_last_write_wins = args.lastWriteWins ?? false;
75157
+ }
75060
75158
  const { data, error: error2 } = await this.supabase.rpc("cerefox_ingest_document", params);
75061
- if (error2)
75062
- throw new Error(error2.message ?? JSON.stringify(error2));
75159
+ if (error2) {
75160
+ const msg = error2.message ?? JSON.stringify(error2);
75161
+ if (msg.includes("CEREFOX_CONFLICT")) {
75162
+ const current = msg.match(/current hash ([0-9a-f]{64})/)?.[1] ?? null;
75163
+ throw new ConcurrencyConflictError(args.documentId ?? "", current, msg);
75164
+ }
75165
+ if (msg.includes("CEREFOX_TOKEN_REQUIRED")) {
75166
+ throw new ConcurrencyTokenRequiredError(msg);
75167
+ }
75168
+ throw new Error(msg);
75169
+ }
75063
75170
  if (Array.isArray(data) && data.length > 0) {
75064
75171
  return data[0];
75065
75172
  }
@@ -75164,30 +75271,6 @@ async function fileToMarkdown(filename, data) {
75164
75271
  return data.toString("utf8");
75165
75272
  }
75166
75273
 
75167
- // src/ingestion/types.ts
75168
- var DEFAULT_PIPELINE_SETTINGS = {
75169
- maxChunkChars: 4000,
75170
- minChunkChars: 100,
75171
- versionRetentionHours: 48,
75172
- versionCleanupEnabled: true
75173
- };
75174
- function loadPipelineSettings() {
75175
- const env4 = globalThis.process?.env ?? {};
75176
- const intMin = (raw, def, min) => {
75177
- if (raw === undefined || raw === "")
75178
- return def;
75179
- const n = Number.parseInt(raw, 10);
75180
- return Number.isNaN(n) || n < min ? def : n;
75181
- };
75182
- const bool = (raw, def) => raw === undefined || raw === "" ? def : !/^(false|0|no|off)$/i.test(raw.trim());
75183
- return {
75184
- maxChunkChars: intMin(env4.CEREFOX_MAX_CHUNK_CHARS, DEFAULT_PIPELINE_SETTINGS.maxChunkChars, 1),
75185
- minChunkChars: intMin(env4.CEREFOX_MIN_CHUNK_CHARS, DEFAULT_PIPELINE_SETTINGS.minChunkChars, 0),
75186
- versionRetentionHours: intMin(env4.CEREFOX_VERSION_RETENTION_HOURS, DEFAULT_PIPELINE_SETTINGS.versionRetentionHours, 0),
75187
- versionCleanupEnabled: bool(env4.CEREFOX_VERSION_CLEANUP_ENABLED, DEFAULT_PIPELINE_SETTINGS.versionCleanupEnabled)
75188
- };
75189
- }
75190
-
75191
75274
  // src/ingestion/pipeline.ts
75192
75275
  class IngestionPipeline {
75193
75276
  db;
@@ -75214,7 +75297,9 @@ class IngestionPipeline {
75214
75297
  updateExisting = false,
75215
75298
  documentId,
75216
75299
  author = "unknown",
75217
- authorType = "user"
75300
+ authorType = "user",
75301
+ expectedContentHash,
75302
+ lastWriteWins = false
75218
75303
  } = opts;
75219
75304
  const listFormProvided = projectIds !== undefined && projectIds !== null || projectNames !== undefined && projectNames !== null;
75220
75305
  const getOrCreate = (name) => this.db.getOrCreateProject(name);
@@ -75235,7 +75320,9 @@ class IngestionPipeline {
75235
75320
  projectIds: fullSetResolved,
75236
75321
  metadata,
75237
75322
  author,
75238
- authorType
75323
+ authorType,
75324
+ expectedContentHash,
75325
+ lastWriteWins
75239
75326
  });
75240
75327
  if (!listFormProvided && (projectId || projectName)) {
75241
75328
  const singular = await resolveProjectIds({ projectId, projectName }, getOrCreate);
@@ -75270,7 +75357,9 @@ class IngestionPipeline {
75270
75357
  projectIds: fullSetResolved,
75271
75358
  metadata,
75272
75359
  author,
75273
- authorType
75360
+ authorType,
75361
+ expectedContentHash,
75362
+ lastWriteWins
75274
75363
  });
75275
75364
  if (!listFormProvided && (projectId || projectName)) {
75276
75365
  const singular = await resolveProjectIds({ projectId, projectName }, getOrCreate);
@@ -75356,7 +75445,9 @@ ${c2.content}`);
75356
75445
  projectIds,
75357
75446
  metadata,
75358
75447
  author = "unknown",
75359
- authorType = "user"
75448
+ authorType = "user",
75449
+ expectedContentHash,
75450
+ lastWriteWins = false
75360
75451
  } = opts;
75361
75452
  const existing = await this.db.getDocumentById(documentId);
75362
75453
  if (!existing) {
@@ -75365,6 +75456,9 @@ ${c2.content}`);
75365
75456
  const newHash = contentHash(text);
75366
75457
  const contentUnchanged = newHash === existing.content_hash;
75367
75458
  if (!contentUnchanged) {
75459
+ if (!lastWriteWins && expectedContentHash && expectedContentHash !== existing.content_hash) {
75460
+ throw new ConcurrencyConflictError(documentId, existing.content_hash, `CEREFOX_CONFLICT: document ${documentId} changed since it was read ` + `(expected hash ${expectedContentHash}, current hash ${existing.content_hash}). ` + `Re-read the document, merge your changes, and retry with the new hash.`);
75461
+ }
75368
75462
  const collision = await this.db.getDocumentByHash(newHash);
75369
75463
  if (collision && collision.id !== documentId) {
75370
75464
  throw new Error(`Identical content already exists as document ${JSON.stringify(collision.title)}. ` + "Edit that document or change the content before saving.");
@@ -75465,7 +75559,9 @@ ${c2.content}`);
75465
75559
  authorType,
75466
75560
  sourceLabel: source,
75467
75561
  retentionHours: this.settings.versionRetentionHours,
75468
- cleanupEnabled: this.settings.versionCleanupEnabled
75562
+ cleanupEnabled: this.settings.versionCleanupEnabled,
75563
+ expectedContentHash: expectedContentHash ?? null,
75564
+ lastWriteWins
75469
75565
  });
75470
75566
  let finalProjectIds;
75471
75567
  if (newProjectIds !== null) {
@@ -75539,7 +75635,7 @@ async function action18(path, options) {
75539
75635
  if (author === "unknown") {
75540
75636
  warn("No --author / CEREFOX_AUTHOR_NAME set — audit log will record this write as 'unknown'.");
75541
75637
  }
75542
- const metadata = parseJsonObjectArg(options.metadata, "--metadata") ?? {};
75638
+ const metadata = parseJsonObjectArg(options.metadata, "--metadata");
75543
75639
  let projectNames;
75544
75640
  if (options.projectNames) {
75545
75641
  projectNames = options.projectNames.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -75566,22 +75662,26 @@ async function action18(path, options) {
75566
75662
  source: options.source ?? "cli",
75567
75663
  projectName: options.projectName ?? null,
75568
75664
  projectNames: projectNames ?? null,
75569
- metadata,
75665
+ metadata: metadata ?? null,
75570
75666
  updateExisting: Boolean(options.updateIfExists),
75571
75667
  documentId: options.documentId ?? null,
75572
75668
  author,
75573
- authorType
75669
+ authorType,
75670
+ expectedContentHash: options.expectedContentHash ?? null,
75671
+ lastWriteWins: Boolean(options.lastWriteWins)
75574
75672
  }) : await pipeline.ingestText({
75575
75673
  text: content,
75576
75674
  title,
75577
75675
  source: options.source ?? "cli",
75578
75676
  projectName: options.projectName ?? null,
75579
75677
  projectNames: projectNames ?? null,
75580
- metadata,
75678
+ metadata: metadata ?? null,
75581
75679
  updateExisting: Boolean(options.updateIfExists),
75582
75680
  documentId: options.documentId ?? null,
75583
75681
  author,
75584
- authorType
75682
+ authorType,
75683
+ expectedContentHash: options.expectedContentHash ?? null,
75684
+ lastWriteWins: Boolean(options.lastWriteWins)
75585
75685
  });
75586
75686
  let verb;
75587
75687
  if (result.action === "created") {
@@ -75600,7 +75700,7 @@ async function action18(path, options) {
75600
75700
  }
75601
75701
  }
75602
75702
  function registerIngest(program2) {
75603
- program2.command("ingest").description("Ingest a file (or stdin paste) into the knowledge base.").argument("[path]", "Path to the file to ingest. Omit when using --paste.").option("--paste", "Read content from stdin instead of a file.").option("-t, --title <title>", "Document title (required with --paste; defaults to filename without extension).").option("-p, --project-name <name>", "Single project membership (non-destructive on update).").option("-P, --project-names <names>", "Comma-separated full project membership set (destructive replace on update).").option("-m, --metadata <json>", "JSON metadata object.").option("--source <label>", "Origin label (default: cli).", "cli").option("-u, --update-if-exists", "Update an existing doc with the same title.").option("-i, --document-id <uuid>", "Update a specific document by UUID (overrides --update-if-exists).").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action18);
75703
+ program2.command("ingest").description("Ingest a file (or stdin paste) into the knowledge base.").argument("[path]", "Path to the file to ingest. Omit when using --paste.").option("--paste", "Read content from stdin instead of a file.").option("-t, --title <title>", "Document title (required with --paste; defaults to filename without extension).").option("-p, --project-name <name>", "Single project membership (non-destructive on update).").option("-P, --project-names <names>", "Comma-separated full project membership set (destructive replace on update).").option("-m, --metadata <json>", "JSON metadata object.").option("--source <label>", "Origin label (default: cli).", "cli").option("-u, --update-if-exists", "Update an existing doc with the same title.").option("-i, --document-id <uuid>", "Update a specific document by UUID (overrides --update-if-exists).").option("--expected-content-hash <sha256>", "Optimistic-concurrency token: the content_hash of the version this edit is based on (shown by `document get` / `search`). Required on content updates unless --last-write-wins.").option("--last-write-wins", "Skip the concurrency check and overwrite regardless of concurrent changes (recorded in the audit log).").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action18);
75604
75704
  }
75605
75705
 
75606
75706
  // src/cli/commands/ingest-dir.ts
@@ -75648,7 +75748,7 @@ async function action19(dir, options) {
75648
75748
  if (author === "unknown") {
75649
75749
  warn("No --author / CEREFOX_AUTHOR_NAME set — audit log will record these writes as 'unknown'.");
75650
75750
  }
75651
- const metadata = parseJsonObjectArg(options.metadata, "--metadata") ?? {};
75751
+ const metadata = parseJsonObjectArg(options.metadata, "--metadata");
75652
75752
  const settings = loadSettings();
75653
75753
  if (!settings.supabaseUrl || !settings.supabaseKey) {
75654
75754
  throw userError("Supabase credentials not configured — run `cerefox init` first.");
@@ -75679,10 +75779,11 @@ async function action19(dir, options) {
75679
75779
  title: basename3(file, extname4(file)),
75680
75780
  source: options.source ?? "cli",
75681
75781
  projectName: options.projectName ?? null,
75682
- metadata,
75782
+ metadata: metadata ?? null,
75683
75783
  updateExisting: Boolean(options.updateIfExists),
75684
75784
  author,
75685
- authorType
75785
+ authorType,
75786
+ lastWriteWins: true
75686
75787
  });
75687
75788
  outcomes.push({
75688
75789
  file,
@@ -76346,9 +76447,9 @@ function registerMcp(program2) {
76346
76447
  init_cli_core();
76347
76448
  init_client();
76348
76449
  async function action26(options) {
76349
- const metadataFilter = parseJsonObjectArg(options.metadataFilter, "--metadata-filter");
76350
- if (!metadataFilter || Object.keys(metadataFilter).length === 0) {
76351
- throw userError("--metadata-filter is required and must be a non-empty JSON object.", `Example: --metadata-filter '{"type":"decision-log"}'.`);
76450
+ const metadataFilter = parseJsonObjectArg(options.metadataFilter, "--metadata-filter") ?? {};
76451
+ if (Object.keys(metadataFilter).length === 0 && !options.projectName && !options.updatedSince && !options.createdSince) {
76452
+ throw userError("Provide at least one of: --metadata-filter, --project-name, --updated-since, or --created-since.", `Examples: --metadata-filter '{"type":"decision-log"}' · --project-name "research" (lists that project's docs).`);
76352
76453
  }
76353
76454
  const limit = parsePositiveInt(options.limit, "--limit", 10);
76354
76455
  const maxBytes = parseNonNegativeInt(options.maxBytes, "--max-bytes", 200000);
@@ -76408,7 +76509,7 @@ async function action26(options) {
76408
76509
  }
76409
76510
  }
76410
76511
  function registerMetadataSearch(program2) {
76411
- program2.command("metadata-search").description("Find documents by metadata criteria (no text query).").requiredOption("-f, --metadata-filter <json>", "JSON object; only docs whose metadata contains ALL pairs are returned.").option("-p, --project-name <name>", "Filter to a specific project.").option("--updated-since <iso>", "Only docs updated on/after this ISO timestamp.").option("--created-since <iso>", "Only docs created on/after this ISO timestamp.").option("--include-content", "Include full document text in results.").option("-l, --limit <n>", "Maximum docs to return.", "10").option("--max-bytes <n>", "Response size budget in bytes (with --include-content).", "200000").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action26);
76512
+ program2.command("metadata-search").description("Find or list documents by metadata, project, or time criteria (no text query).").option("-f, --metadata-filter <json>", "JSON object; only docs whose metadata contains ALL pairs are returned. Optional — omit to list by --project-name / time range alone (at least one criterion is required).").option("-p, --project-name <name>", "Filter to a specific project.").option("--updated-since <iso>", "Only docs updated on/after this ISO timestamp.").option("--created-since <iso>", "Only docs created on/after this ISO timestamp.").option("--include-content", "Include full document text in results.").option("-l, --limit <n>", "Maximum docs to return.", "10").option("--max-bytes <n>", "Response size budget in bytes (with --include-content).", "200000").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action26);
76412
76513
  }
76413
76514
 
76414
76515
  // src/cli/commands/reindex.ts
@@ -76728,10 +76829,12 @@ async function action29(query, options) {
76728
76829
  println(c.bold(`## ${title}${docId}${score}${counts}${kind}`));
76729
76830
  const bestMatch = doc2.best_chunk_heading_path?.length ? doc2.best_chunk_heading_path.join(" › ") : null;
76730
76831
  const updated = doc2.doc_updated_at ? doc2.doc_updated_at.slice(0, 10) : null;
76731
- if (bestMatch || updated) {
76832
+ const hash = doc2.content_hash ? `hash: ${doc2.content_hash}` : null;
76833
+ if (bestMatch || updated || hash) {
76732
76834
  const bits = [
76733
76835
  bestMatch ? `best match: ${bestMatch}` : null,
76734
- updated ? `updated ${updated}` : null
76836
+ updated ? `updated ${updated}` : null,
76837
+ hash
76735
76838
  ].filter(Boolean);
76736
76839
  println(c.dim(` ${bits.join(" · ")}`));
76737
76840
  }
@@ -80801,6 +80904,7 @@ function registerDocumentReadRoutes(app, ctx) {
80801
80904
  created_at: meta ? meta.created_at ?? null : null,
80802
80905
  updated_at: meta ? meta.updated_at ?? null : null,
80803
80906
  deleted_at: meta ? meta.deleted_at ?? null : null,
80907
+ content_hash: meta ? meta.content_hash ?? null : null,
80804
80908
  versions: versions3.map((v) => ({
80805
80909
  version_id: v.version_id,
80806
80910
  version_number: v.version_number,
@@ -80963,10 +81067,26 @@ function registerDocumentWriteRoutes(app, ctx) {
80963
81067
  projectIds: Array.isArray(body.project_ids) ? body.project_ids : undefined,
80964
81068
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
80965
81069
  author: "web-ui",
80966
- authorType: "user"
81070
+ authorType: "user",
81071
+ expectedContentHash: typeof body.expected_content_hash === "string" ? body.expected_content_hash : null
80967
81072
  });
80968
81073
  return c2.json({ success: true, reindexed: result.reindexed });
80969
81074
  } catch (err) {
81075
+ if (err instanceof ConcurrencyConflictError) {
81076
+ return c2.json({
81077
+ success: false,
81078
+ error: "conflict",
81079
+ message: "This document changed while you were editing it (another writer saved a newer version). Open it again in a new tab, merge your changes, and save from there.",
81080
+ current_hash: err.currentHash
81081
+ }, 409);
81082
+ }
81083
+ if (err instanceof ConcurrencyTokenRequiredError) {
81084
+ return c2.json({
81085
+ success: false,
81086
+ error: "expected_content_hash required",
81087
+ message: err.message
81088
+ }, 400);
81089
+ }
80970
81090
  return c2.json({
80971
81091
  success: false,
80972
81092
  error: err instanceof Error ? err.message : String(err)