@cerefox/memory 0.10.3 → 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.
@@ -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-DVXDQ7__.js"></script>
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>
@@ -18,7 +18,7 @@
18
18
  * doesn't touch `supabase/functions/` leaves it alone).
19
19
  */
20
20
 
21
- export const EF_VERSION = "0.10.1";
21
+ export const EF_VERSION = "0.11.0";
22
22
 
23
23
  /**
24
24
  * The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
@@ -21,7 +21,9 @@
21
21
  * prevents drift.
22
22
  */
23
23
 
24
- import type { MCPSupabaseClient } from "./types.ts";
24
+ import type { AccessPath, MCPSupabaseClient } from "./types.ts";
25
+
26
+ import { logUsage } from "./_utils.ts";
25
27
 
26
28
  /** Ensure `(documentId, project)` exists. Resolves project by name
27
29
  * (case-insensitive); creates the project if missing. Idempotent.
@@ -104,6 +106,120 @@ export async function setDocumentProjectsByName(
104
106
  return projectIds;
105
107
  }
106
108
 
109
+ export interface ReplaceDocumentProjectsResult {
110
+ documentTitle: string;
111
+ /** Names after stripping blanks + case-insensitive dedup, in input order. */
112
+ cleanNames: string[];
113
+ projectIds: string[];
114
+ }
115
+
116
+ /**
117
+ * Full-set replace of a document's project memberships, with audit + usage
118
+ * logging. The shared core behind both the `cerefox_set_document_projects`
119
+ * MCP tool and the `cerefox document set-projects` CLI command, so the two
120
+ * behave identically.
121
+ *
122
+ * Cleans the incoming names (strip blanks, preserve order, case-insensitive
123
+ * dedup), verifies the document exists and isn't soft-deleted, resolves each
124
+ * name → project_id (creating the project if absent), then DELETE-then-INSERT
125
+ * replaces the membership set. An empty (or all-blank) list clears all
126
+ * memberships. Writes an `update-metadata` audit entry (content is untouched)
127
+ * and a usage-log entry.
128
+ *
129
+ * Throws if the document is missing or soft-deleted. Callers validate
130
+ * argument *shape* (e.g. that `projectNames` is an array of strings).
131
+ */
132
+ export async function replaceDocumentProjects(
133
+ supabase: MCPSupabaseClient,
134
+ opts: {
135
+ documentId: string;
136
+ projectNames: string[];
137
+ author: string;
138
+ authorType: string;
139
+ accessPath: AccessPath;
140
+ },
141
+ ): Promise<ReplaceDocumentProjectsResult> {
142
+ const { documentId, projectNames, author, authorType, accessPath } = opts;
143
+
144
+ // Strip empties; preserve order; dedup case-insensitively.
145
+ const seenLower = new Set<string>();
146
+ const cleanNames: string[] = [];
147
+ for (const n of projectNames) {
148
+ const stripped = (n ?? "").trim();
149
+ if (!stripped) continue;
150
+ const key = stripped.toLowerCase();
151
+ if (seenLower.has(key)) continue;
152
+ seenLower.add(key);
153
+ cleanNames.push(stripped);
154
+ }
155
+
156
+ // Verify the document exists and isn't soft-deleted.
157
+ const { data: doc } = await supabase
158
+ .from("cerefox_documents")
159
+ .select("id, title")
160
+ .eq("id", documentId)
161
+ .is("deleted_at", null)
162
+ .limit(1);
163
+ if (!doc?.length) {
164
+ throw new Error(`Document not found (or soft-deleted): ${documentId}`);
165
+ }
166
+
167
+ // Resolve each name → project_id (create if absent). Preserve order.
168
+ const projectIds: string[] = [];
169
+ for (const name of cleanNames) {
170
+ const { data: proj } = await supabase
171
+ .from("cerefox_projects")
172
+ .select("id")
173
+ .ilike("name", name)
174
+ .limit(1);
175
+ if (proj?.length) {
176
+ projectIds.push(proj[0].id);
177
+ } else {
178
+ const { data: newProj } = await supabase
179
+ .from("cerefox_projects")
180
+ .insert({ name })
181
+ .select("id");
182
+ if (newProj?.[0]?.id) projectIds.push(newProj[0].id);
183
+ }
184
+ }
185
+
186
+ // DELETE-then-INSERT replace (matches Python assign_document_projects).
187
+ await supabase.from("cerefox_document_projects").delete().eq("document_id", documentId);
188
+ if (projectIds.length > 0) {
189
+ const rows = projectIds.map((pid) => ({ document_id: documentId, project_id: pid }));
190
+ await supabase.from("cerefox_document_projects").insert(rows);
191
+ }
192
+
193
+ // Audit entry — project membership is metadata, not content.
194
+ try {
195
+ await supabase.rpc("cerefox_create_audit_entry", {
196
+ p_document_id: documentId,
197
+ p_version_id: null,
198
+ p_operation: "update-metadata",
199
+ p_author: author,
200
+ p_author_type: authorType,
201
+ p_size_before: null,
202
+ p_size_after: null,
203
+ p_description:
204
+ cleanNames.length > 0
205
+ ? `Set document projects to [${cleanNames.join(", ")}]`
206
+ : "Cleared all project memberships",
207
+ });
208
+ } catch (err) {
209
+ console.warn("replaceDocumentProjects: audit entry failed", err);
210
+ }
211
+
212
+ logUsage(supabase, {
213
+ operation: "set-document-projects",
214
+ accessPath,
215
+ requestor: author,
216
+ document_id: documentId,
217
+ result_count: projectIds.length,
218
+ });
219
+
220
+ return { documentTitle: doc[0].title as string, cleanNames, projectIds };
221
+ }
222
+
107
223
  /** Resolve a project name → project_id (case-insensitive), or `null` if
108
224
  * not found. Does NOT create. Used by search / metadata-search to translate
109
225
  * `project_name` parameters to UUIDs. */
@@ -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
- return `# ${row.doc_title ?? "Untitled"}${label}\n\n${row.full_content ?? ""}`;
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 docs by metadata (no text query) | `metadata_filter` (required), `include_content`, `updated_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>\"` |\n| `cerefox_set_document_projects` | _MCP-only; a CLI command will be added in a future release. Until then, run via MCP if available._ |\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 docs by metadata (no text query) | `metadata_filter` (required), `include_content`, `updated_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>\"` |\n| `cerefox_set_document_projects` | _MCP-only; a CLI command will be added in a future release. Until then, run via MCP if available._ |\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 new Error(`Ingest RPC failed: ${ingestErr.message}`);
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 new Error(`Ingest RPC failed: ${ingestErr.message}`);
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",
@@ -24,11 +24,18 @@ async function handler(
24
24
  const include_content = (args.include_content as boolean | undefined) ?? false;
25
25
  const requested_max_bytes = args.max_bytes as number | undefined;
26
26
 
27
- if (!metadata_filter || typeof metadata_filter !== "object" || Array.isArray(metadata_filter)) {
28
- throw new McpInvalidParams("metadata_filter is required and must be a JSON object");
27
+ if (metadata_filter !== undefined && (typeof metadata_filter !== "object" || Array.isArray(metadata_filter))) {
28
+ throw new McpInvalidParams("metadata_filter must be a JSON object when provided");
29
29
  }
30
- if (Object.keys(metadata_filter).length === 0) {
31
- throw new McpInvalidParams("metadata_filter must contain at least one key-value pair");
30
+ const has_metadata = !!metadata_filter && Object.keys(metadata_filter).length > 0;
31
+ // metadata_filter is optional, but at least one narrowing criterion is
32
+ // required so this can never become an unbounded whole-KB dump. An empty
33
+ // filter + project_name lists a project's documents: the RPC's
34
+ // `metadata @> '{}'` matches every row and the project predicate narrows it.
35
+ if (!has_metadata && !project_name && !updated_since && !created_since) {
36
+ throw new McpInvalidParams(
37
+ "Provide at least one of: metadata_filter, project_name, updated_since, or created_since.",
38
+ );
32
39
  }
33
40
 
34
41
  // Resolve project name to UUID if provided
@@ -45,7 +52,7 @@ async function handler(
45
52
  : null;
46
53
 
47
54
  const params: Record<string, unknown> = {
48
- p_metadata_filter: metadata_filter,
55
+ p_metadata_filter: metadata_filter ?? {},
49
56
  p_project_id: projectId,
50
57
  p_updated_since: updated_since ?? null,
51
58
  p_created_since: created_since ?? null,
@@ -71,6 +78,7 @@ async function handler(
71
78
  project_ids: string[];
72
79
  project_names: string[];
73
80
  version_count: number;
81
+ content_hash: string | null;
74
82
  content: string | null;
75
83
  }>;
76
84
 
@@ -78,12 +86,12 @@ async function handler(
78
86
  operation: "metadata_search",
79
87
  accessPath: ctx.accessPath,
80
88
  requestor: args.requestor as string | undefined,
81
- query_text: JSON.stringify(metadata_filter),
89
+ query_text: JSON.stringify(metadata_filter ?? {}),
82
90
  project_id: projectId,
83
91
  result_count: rows.length,
84
92
  });
85
93
 
86
- if (rows.length === 0) return "No documents match the metadata filter.";
94
+ if (rows.length === 0) return "No documents match the given criteria.";
87
95
 
88
96
  // Note: when include_content is true the RPC already respects p_max_bytes
89
97
  // server-side. The applyByteBudget helper is retained here only for
@@ -98,9 +106,10 @@ async function handler(
98
106
  const meta = Object.entries(row.doc_metadata ?? {})
99
107
  .map(([k, v]) => `${k}=${v}`)
100
108
  .join(", ");
109
+ const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
101
110
  const header =
102
111
  `## ${row.title} [id: ${row.document_id}]\n` +
103
- `${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}`;
104
113
 
105
114
  if (include_content && row.content) {
106
115
  return `${header}\n\n${row.content}`;
@@ -114,18 +123,17 @@ async function handler(
114
123
  export const metadataSearchTool: ToolDefinition = {
115
124
  name: "cerefox_metadata_search",
116
125
  description:
117
- "Find documents by metadata key-value criteria without a text search term. Use to discover documents tagged with specific attributes, browse by taxonomy, or retrieve messages/tasks by type and status.",
126
+ "Find or list documents by metadata key-value criteria without a text search term. Use to discover documents tagged with specific attributes, browse by taxonomy, retrieve messages/tasks by type and status, or list all documents in a project (pass project_name alone). At least one of metadata_filter, project_name, updated_since, or created_since must be supplied; results are ordered newest-updated first.",
118
127
  inputSchema: {
119
128
  type: "object",
120
- required: ["metadata_filter"],
121
129
  properties: {
122
130
  metadata_filter: {
123
131
  type: "object",
124
132
  description:
125
- 'Key-value pairs; ALL must match (AND semantics). Example: {"type": "decision", "status": "active"}. Call cerefox_list_metadata_keys first to discover available keys.',
133
+ 'Key-value pairs; ALL must match (AND semantics). Example: {"type": "decision", "status": "active"}. Call cerefox_list_metadata_keys first to discover available keys. Optional — omit (or pass {}) to list by project_name / time range alone.',
126
134
  additionalProperties: { type: "string" },
127
135
  },
128
- project_name: { type: "string", description: "Restrict to a project by name (optional)" },
136
+ project_name: { type: "string", description: "Restrict to a project by name. Sufficient on its own to list that project's documents (optional)." },
129
137
  updated_since: {
130
138
  type: "string",
131
139
  description: "ISO-8601 timestamp; only docs updated on/after (optional)",
@@ -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
- return `## ${title}${docId}${score}${partial}\n\n${row.full_content ?? ""}`;
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");
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { MCPSupabaseClient } from "./types.ts";
13
13
 
14
- import { logUsage } from "./_utils.ts";
14
+ import { replaceDocumentProjects } from "./_projects.ts";
15
15
  import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
16
16
 
17
17
  async function handler(
@@ -42,80 +42,15 @@ async function handler(
42
42
  throw new McpInvalidParams("project_names must contain only strings.");
43
43
  }
44
44
 
45
- // Strip empties; preserve order; dedup case-insensitively.
46
- const seenLower = new Set<string>();
47
- const cleanNames: string[] = [];
48
- for (const n of project_names_raw as string[]) {
49
- const stripped = n.trim();
50
- if (!stripped) continue;
51
- const key = stripped.toLowerCase();
52
- if (seenLower.has(key)) continue;
53
- seenLower.add(key);
54
- cleanNames.push(stripped);
55
- }
56
-
57
- // Verify the document exists and isn't soft-deleted.
58
- const { data: doc } = await supabase
59
- .from("cerefox_documents")
60
- .select("id, title")
61
- .eq("id", document_id)
62
- .is("deleted_at", null)
63
- .limit(1);
64
- if (!doc?.length) {
65
- throw new Error(`Document not found (or soft-deleted): ${document_id}`);
66
- }
67
-
68
- // Resolve each name → project_id (create if absent). Preserve order.
69
- const projectIds: string[] = [];
70
- for (const name of cleanNames) {
71
- const { data: proj } = await supabase
72
- .from("cerefox_projects")
73
- .select("id")
74
- .ilike("name", name)
75
- .limit(1);
76
- if (proj?.length) {
77
- projectIds.push(proj[0].id);
78
- } else {
79
- const { data: newProj } = await supabase
80
- .from("cerefox_projects")
81
- .insert({ name })
82
- .select("id");
83
- if (newProj?.[0]?.id) projectIds.push(newProj[0].id);
84
- }
85
- }
86
-
87
- // DELETE-then-INSERT replace (matches Python assign_document_projects).
88
- await supabase.from("cerefox_document_projects").delete().eq("document_id", document_id);
89
- if (projectIds.length > 0) {
90
- const rows = projectIds.map((pid) => ({ document_id, project_id: pid }));
91
- await supabase.from("cerefox_document_projects").insert(rows);
92
- }
93
-
94
- // Audit entry — project membership is metadata, not content.
95
- try {
96
- await supabase.rpc("cerefox_create_audit_entry", {
97
- p_document_id: document_id,
98
- p_version_id: null,
99
- p_operation: "update-metadata",
100
- p_author: author,
101
- p_author_type: "agent",
102
- p_size_before: null,
103
- p_size_after: null,
104
- p_description:
105
- cleanNames.length > 0
106
- ? `Set document projects to [${cleanNames.join(", ")}]`
107
- : "Cleared all project memberships",
108
- });
109
- } catch (err) {
110
- console.warn("set-document-projects: audit entry failed", err);
111
- }
112
-
113
- logUsage(supabase, {
114
- operation: "set-document-projects",
45
+ // The clean/dedup, existence check, name→id resolution, DELETE-then-INSERT
46
+ // replace, audit entry, and usage log all live in the shared core so the
47
+ // `cerefox document set-projects` CLI command behaves identically.
48
+ const { cleanNames, projectIds } = await replaceDocumentProjects(supabase, {
49
+ documentId: document_id,
50
+ projectNames: project_names_raw as string[],
51
+ author,
52
+ authorType: "agent",
115
53
  accessPath: ctx.accessPath,
116
- requestor: author,
117
- document_id,
118
- result_count: projectIds.length,
119
54
  });
120
55
 
121
56
  if (cleanNames.length === 0) {