@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.
- package/AGENT_GUIDE.md +47 -20
- package/AGENT_QUICK_REFERENCE.md +16 -11
- package/dist/bin/cerefox.js +525 -331
- package/dist/frontend/assets/{index-DVXDQ7__.js → index-ojNhWSxm.js} +3 -3
- package/dist/frontend/assets/index-ojNhWSxm.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
- package/dist/server-assets/_shared/mcp-tools/_projects.ts +117 -1
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +6 -2
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +6 -6
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +71 -6
- package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +20 -12
- package/dist/server-assets/_shared/mcp-tools/search.ts +4 -1
- package/dist/server-assets/_shared/mcp-tools/set-document-projects.ts +9 -74
- package/dist/server-assets/db/rpcs.sql +77 -11
- package/dist/server-assets/db/schema.sql +1 -1
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +5 -1
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +65 -1
- package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +25 -7
- package/docs/guides/access-paths.md +1 -1
- package/docs/guides/agent-coordination.md +14 -1
- package/docs/guides/cli.md +109 -6
- package/docs/guides/connect-agents.md +51 -7
- package/package.json +1 -1
- package/dist/frontend/assets/index-DVXDQ7__.js.map +0 -1
package/dist/frontend/index.html
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"
|
|
16
16
|
/>
|
|
17
17
|
<title>Cerefox</title>
|
|
18
|
-
<script type="module" crossorigin src="/app/assets/index-
|
|
18
|
+
<script type="module" crossorigin src="/app/assets/index-ojNhWSxm.js"></script>
|
|
19
19
|
<link rel="stylesheet" crossorigin href="/app/assets/index-Asx5wD7g.css">
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
@@ -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
|
-
|
|
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` (
|
|
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` (
|
|
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` |
|
|
23
|
+
"CLI fallback (when MCP is unavailable)": "## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.",
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
export const HELP_SECTION_HEADINGS: string[] = ["Tools", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)"];
|
|
@@ -23,6 +23,43 @@ import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.
|
|
|
23
23
|
import { logUsage } from "./_utils.ts";
|
|
24
24
|
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Agent-first instructions for an optimistic-concurrency conflict (iter-32).
|
|
28
|
+
* Raised either by the local fast-fail (before the embedding spend) or by the
|
|
29
|
+
* authoritative check inside the cerefox_ingest_document RPC.
|
|
30
|
+
*/
|
|
31
|
+
function conflictError(documentId: string, expectedHash: string, currentHash: string): Error {
|
|
32
|
+
return new Error(
|
|
33
|
+
`Conflict: document ${documentId} changed since you read it ` +
|
|
34
|
+
`(your base hash: ${expectedHash}, current hash: ${currentHash}). ` +
|
|
35
|
+
`To resolve: (1) cerefox_get_document("${documentId}") to fetch the latest content ` +
|
|
36
|
+
`and its content_hash, (2) merge your changes into it, (3) retry cerefox_ingest ` +
|
|
37
|
+
`with expected_content_hash set to the new hash. Do not overwrite blindly — ` +
|
|
38
|
+
`the current content may include another writer's work.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map RPC-side CEREFOX_CONFLICT / CEREFOX_TOKEN_REQUIRED errors to agent-first text. */
|
|
43
|
+
function mapIngestRpcError(message: string, documentId: string): Error {
|
|
44
|
+
if (message.includes("CEREFOX_CONFLICT")) {
|
|
45
|
+
const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
|
|
46
|
+
const expected = message.match(/expected hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
|
|
47
|
+
return conflictError(documentId, expected, current);
|
|
48
|
+
}
|
|
49
|
+
if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
|
|
50
|
+
const current = message.match(/Current hash: ([0-9a-f]{64})/)?.[1];
|
|
51
|
+
return new Error(
|
|
52
|
+
`Concurrency token required: content updates need expected_content_hash — ` +
|
|
53
|
+
`the content_hash of the version you based your edit on (returned by ` +
|
|
54
|
+
`cerefox_get_document, cerefox_search, and cerefox_metadata_search).` +
|
|
55
|
+
(current ? ` The document's current hash is ${current}; pass it ONLY if your edit was based on the current content.` : "") +
|
|
56
|
+
` If you have not read the document, read it first. To deliberately overwrite ` +
|
|
57
|
+
`regardless of concurrent changes, pass last_write_wins=true.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return new Error(`Ingest RPC failed: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
async function handler(
|
|
27
64
|
supabase: MCPSupabaseClient,
|
|
28
65
|
args: Record<string, unknown>,
|
|
@@ -38,6 +75,8 @@ async function handler(
|
|
|
38
75
|
const update_if_exists = (args.update_if_exists as boolean | undefined) ?? false;
|
|
39
76
|
const author = (args.author as string | undefined) ?? "mcp-agent";
|
|
40
77
|
const author_type = "agent"; // MCP path is always agent
|
|
78
|
+
const expected_content_hash = (args.expected_content_hash as string | undefined)?.trim() || null;
|
|
79
|
+
const last_write_wins = (args.last_write_wins as boolean | undefined) ?? false;
|
|
41
80
|
|
|
42
81
|
if (!title || !content?.trim()) {
|
|
43
82
|
throw new McpInvalidParams("title and content are required");
|
|
@@ -83,7 +122,13 @@ async function handler(
|
|
|
83
122
|
const note = update_if_exists
|
|
84
123
|
? ""
|
|
85
124
|
: " Note: update_if_exists flag was overridden by document_id.";
|
|
86
|
-
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.${note}`;
|
|
125
|
+
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash}).${note}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fast-fail on a stale token BEFORE paying the embedding cost. Advisory
|
|
129
|
+
// only — the authoritative, race-free check is inside the RPC (FOR UPDATE).
|
|
130
|
+
if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
|
|
131
|
+
throw conflictError(existingDoc.id, expected_content_hash, existingDoc.content_hash);
|
|
87
132
|
}
|
|
88
133
|
|
|
89
134
|
const chunks = chunkMarkdown(content);
|
|
@@ -115,9 +160,11 @@ async function handler(
|
|
|
115
160
|
p_author: author,
|
|
116
161
|
p_author_type: author_type,
|
|
117
162
|
p_source_label: source,
|
|
163
|
+
p_expected_content_hash: expected_content_hash,
|
|
164
|
+
p_last_write_wins: last_write_wins,
|
|
118
165
|
});
|
|
119
166
|
|
|
120
|
-
if (ingestErr) throw
|
|
167
|
+
if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
|
|
121
168
|
|
|
122
169
|
logUsage(supabase, {
|
|
123
170
|
operation: "ingest",
|
|
@@ -136,7 +183,7 @@ async function handler(
|
|
|
136
183
|
const note = update_if_exists
|
|
137
184
|
? ""
|
|
138
185
|
: " Note: update_if_exists flag was overridden by document_id.";
|
|
139
|
-
return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars.${note}`;
|
|
186
|
+
return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars. New content_hash: ${contentHash}.${note}`;
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
// ── Update-existing path ─────────────────────────────────────────────────
|
|
@@ -152,7 +199,13 @@ async function handler(
|
|
|
152
199
|
const existingDoc = existing[0];
|
|
153
200
|
|
|
154
201
|
if (existingDoc.content_hash === contentHash) {
|
|
155
|
-
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.`;
|
|
202
|
+
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash}).`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fast-fail on a stale token BEFORE the embedding cost (advisory; the
|
|
206
|
+
// authoritative check is in the RPC).
|
|
207
|
+
if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
|
|
208
|
+
throw conflictError(existingDoc.id, expected_content_hash, existingDoc.content_hash);
|
|
156
209
|
}
|
|
157
210
|
|
|
158
211
|
const chunks = chunkMarkdown(content);
|
|
@@ -184,9 +237,11 @@ async function handler(
|
|
|
184
237
|
p_author: author,
|
|
185
238
|
p_author_type: author_type,
|
|
186
239
|
p_source_label: source,
|
|
240
|
+
p_expected_content_hash: expected_content_hash,
|
|
241
|
+
p_last_write_wins: last_write_wins,
|
|
187
242
|
});
|
|
188
243
|
|
|
189
|
-
if (ingestErr) throw
|
|
244
|
+
if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
|
|
190
245
|
|
|
191
246
|
logUsage(supabase, {
|
|
192
247
|
operation: "ingest",
|
|
@@ -202,7 +257,7 @@ async function handler(
|
|
|
202
257
|
await ensureDocumentInProject(supabase, existingDoc.id, project_name);
|
|
203
258
|
}
|
|
204
259
|
|
|
205
|
-
return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars.`;
|
|
260
|
+
return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars. New content_hash: ${contentHash}.`;
|
|
206
261
|
}
|
|
207
262
|
// Fall through to create path
|
|
208
263
|
}
|
|
@@ -303,6 +358,16 @@ export const ingestTool: ToolDefinition = {
|
|
|
303
358
|
description:
|
|
304
359
|
"When true, update an existing document with the same title instead of creating a new one (default: false). Ignored when document_id is provided.",
|
|
305
360
|
},
|
|
361
|
+
expected_content_hash: {
|
|
362
|
+
type: "string",
|
|
363
|
+
description:
|
|
364
|
+
"REQUIRED on content updates (optimistic concurrency): the content_hash of the document version you based your edit on, as returned by cerefox_get_document / cerefox_search / cerefox_metadata_search. If the document changed since you read it, the update fails with a conflict — re-read, merge, retry with the new hash. Not needed when creating a new document.",
|
|
365
|
+
},
|
|
366
|
+
last_write_wins: {
|
|
367
|
+
type: "boolean",
|
|
368
|
+
description:
|
|
369
|
+
"Explicitly skip the concurrency check and overwrite regardless of concurrent changes (default: false). Use ONLY when an external source of truth makes conflicts meaningless (e.g. re-syncing from files). Recorded in the audit log.",
|
|
370
|
+
},
|
|
306
371
|
metadata: { type: "object", description: "Arbitrary JSON metadata (optional)" },
|
|
307
372
|
author: {
|
|
308
373
|
type: "string",
|
|
@@ -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 (
|
|
28
|
-
throw new McpInvalidParams("metadata_filter
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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) {
|