@cerefox/memory 1.7.1 → 1.9.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-D8E0mTnp.js"></script>
18
+ <script type="module" crossorigin src="/app/assets/index-DWT7wZMR.js"></script>
19
19
  <link rel="stylesheet" crossorigin href="/app/assets/index-Dm_zCch4.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 = "1.7.1";
21
+ export const EF_VERSION = "1.9.0";
22
22
 
23
23
  /**
24
24
  * The Cerefox RELEASE version — what `cerefox --version` reports and what npm
@@ -36,7 +36,7 @@ export const EF_VERSION = "1.7.1";
36
36
  * is imported by the Deno Edge Functions, which cannot reach into the npm
37
37
  * package.
38
38
  */
39
- export const CEREFOX_VERSION = "1.7.1";
39
+ export const CEREFOX_VERSION = "1.9.0";
40
40
 
41
41
  /**
42
42
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -46,7 +46,7 @@ export const CEREFOX_VERSION = "1.7.1";
46
46
  * `cut_release.ts` ONLY when EF source changed since the last tag; doctor
47
47
  * uses it to stay silent on label-only drift.
48
48
  */
49
- export const EF_LAST_CHANGED = "1.7.0";
49
+ export const EF_LAST_CHANGED = "1.9.0";
50
50
 
51
51
  /**
52
52
  * The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
@@ -23,31 +23,66 @@
23
23
 
24
24
  import type { AccessPath, MCPSupabaseClient } from "./types.ts";
25
25
 
26
- import { logUsage } from "./_utils.ts";
26
+ import { logUsage, storeWriteRemediation } from "./_utils.ts";
27
+
28
+ /** Who to attribute an implicit/explicit project write to in the audit log. */
29
+ export interface ProjectAuditContext {
30
+ author: string;
31
+ authorType: string;
32
+ }
33
+
34
+ /**
35
+ * Resolve (or create) a project by name via cerefox_create_project with
36
+ * p_if_exists='return' — the single implementation (0.14.0, #219): the RPC
37
+ * audits an actual creation in the SAME transaction as the insert, and an
38
+ * existing project is returned untouched with no audit entry. Returns the
39
+ * project id, or null on failure (best-effort, matching the historical
40
+ * posture of the assignment paths).
41
+ */
42
+ export interface ResolvedProject {
43
+ projectId: string;
44
+ projectName: string;
45
+ }
46
+
47
+ export async function resolveOrCreateProject(
48
+ supabase: MCPSupabaseClient,
49
+ projectName: string,
50
+ audit?: ProjectAuditContext,
51
+ ): Promise<ResolvedProject | null> {
52
+ const { data, error } = await supabase.rpc("cerefox_create_project", {
53
+ p_name: projectName,
54
+ p_description: "",
55
+ p_author: audit?.author ?? "unknown",
56
+ p_author_type: audit?.authorType ?? "agent",
57
+ p_if_exists: "return",
58
+ });
59
+ if (error) {
60
+ // Deployment-state failures must be LOUD: swallowing them here is what
61
+ // turned a version skew into a membership wipe downstream (round-4
62
+ // verifier — the destructive replace ran after every resolution
63
+ // silently nulled out). Anything else stays best-effort.
64
+ const remediation = storeWriteRemediation(error.message ?? "", "cerefox_create_project");
65
+ if (remediation) throw new Error(`Project resolution failed for '${projectName}': ${remediation}`);
66
+ console.warn("resolveOrCreateProject: RPC failed", error);
67
+ return null;
68
+ }
69
+ const row = (data as Array<{ project_id?: string; project_name?: string }> | null)?.[0];
70
+ return row?.project_id ? { projectId: row.project_id, projectName: row.project_name ?? projectName } : null;
71
+ }
27
72
 
28
73
  /** Ensure `(documentId, project)` exists. Resolves project by name
29
74
  * (case-insensitive); creates the project if missing. Idempotent.
30
- * Returns the resolved project_id, or `null` if creation failed. */
75
+ * Returns the resolved project_id, or `null` if creation failed.
76
+ * Pass `audit` so an implicit creation lands in the audit trail
77
+ * attributed to the write that caused it (0.14.0). */
31
78
  export async function ensureDocumentInProject(
32
79
  supabase: MCPSupabaseClient,
33
80
  documentId: string,
34
81
  projectName: string,
82
+ audit?: ProjectAuditContext,
35
83
  ): Promise<string | null> {
36
- let projectId: string | null = null;
37
- const { data: proj } = await supabase
38
- .from("cerefox_projects")
39
- .select("id")
40
- .ilike("name", projectName)
41
- .limit(1);
42
- if (proj?.length) {
43
- projectId = proj[0].id;
44
- } else {
45
- const { data: newProj } = await supabase
46
- .from("cerefox_projects")
47
- .insert({ name: projectName })
48
- .select("id");
49
- projectId = newProj?.[0]?.id ?? null;
50
- }
84
+ const resolved = await resolveOrCreateProject(supabase, projectName, audit);
85
+ const projectId = resolved?.projectId ?? null;
51
86
  if (!projectId) return null;
52
87
 
53
88
  const { data: existing } = await supabase
@@ -75,25 +110,20 @@ export async function setDocumentProjectsByName(
75
110
  supabase: MCPSupabaseClient,
76
111
  documentId: string,
77
112
  projectNames: string[],
113
+ audit?: ProjectAuditContext,
78
114
  ): Promise<string[]> {
79
- const projectIds: string[] = [];
80
- for (const name of projectNames) {
81
- if (!name) continue;
82
- const { data: proj } = await supabase
83
- .from("cerefox_projects")
84
- .select("id")
85
- .ilike("name", name)
86
- .limit(1);
87
- if (proj?.length) {
88
- projectIds.push(proj[0].id);
89
- } else {
90
- const { data: newProj } = await supabase
91
- .from("cerefox_projects")
92
- .insert({ name })
93
- .select("id");
94
- if (newProj?.[0]?.id) projectIds.push(newProj[0].id);
95
- }
115
+ // Resolve EVERYTHING before touching memberships (concurrently — the
116
+ // names are independent), and abort if any requested name failed to
117
+ // resolve: proceeding would wipe memberships the caller asked to keep.
118
+ const wanted = projectNames.filter((n) => !!n);
119
+ const resolved = await Promise.all(wanted.map((n) => resolveOrCreateProject(supabase, n, audit)));
120
+ const failed = wanted.filter((_, i) => !resolved[i]);
121
+ if (failed.length > 0) {
122
+ throw new Error(
123
+ `Could not resolve project(s): ${failed.join(", ")} — memberships left unchanged.`,
124
+ );
96
125
  }
126
+ const projectIds = resolved.map((r) => r!.projectId);
97
127
 
98
128
  await supabase
99
129
  .from("cerefox_document_projects")
@@ -164,24 +194,19 @@ export async function replaceDocumentProjects(
164
194
  throw new Error(`Document not found (or soft-deleted): ${documentId}`);
165
195
  }
166
196
 
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
- }
197
+ // Resolve each name → project_id (create if absent) CONCURRENTLY, and
198
+ // abort before the destructive replace if any name failed to resolve —
199
+ // wiping memberships because resolution errored is the round-4 wipe bug.
200
+ const resolved = await Promise.all(
201
+ cleanNames.map((name) => resolveOrCreateProject(supabase, name, { author, authorType })),
202
+ );
203
+ const failed = cleanNames.filter((_, i) => !resolved[i]);
204
+ if (failed.length > 0) {
205
+ throw new Error(
206
+ `Could not resolve project(s): ${failed.join(", ")} — memberships left unchanged.`,
207
+ );
184
208
  }
209
+ const projectIds = resolved.map((r) => r!.projectId);
185
210
 
186
211
  // DELETE-then-INSERT replace (matches Python assign_document_projects).
187
212
  await supabase.from("cerefox_document_projects").delete().eq("document_id", documentId);
@@ -215,6 +215,51 @@ export function isDocumentNotFoundError(error: { code?: string; message?: string
215
215
  return error.code === "22023" && /not found/i.test(error.message ?? "");
216
216
  }
217
217
 
218
+ export {
219
+ AUDIT_OPERATIONS,
220
+ auditDocLabel,
221
+ isStoreLevelAuditOp,
222
+ STORE_LEVEL_AUDIT_OPS,
223
+ } from "./audit-ops.ts";
224
+
225
+ /**
226
+ * A store whose RPCs are 0.14.0+ but whose cerefox_audit_log operation CHECK
227
+ * was never widened (migration 0028 unapplied — e.g. a partial deploy) rejects
228
+ * every in-transaction audit insert with 23514. The write itself rolls back,
229
+ * so the remediation is "apply the migration", never "fix your input".
230
+ */
231
+ export function isAuditCheckError(message: string): boolean {
232
+ return /cerefox_audit_log_operation_check/.test(message);
233
+ }
234
+
235
+ /** Postgres 23505 through PostgREST — one predicate, not N copied regexes. */
236
+ export function isDuplicateKeyError(message: string): boolean {
237
+ return /duplicate key|unique constraint|23505/i.test(message);
238
+ }
239
+
240
+ /**
241
+ * One classifier for a failed store-level write RPC (set_config, the project
242
+ * writes): returns the remediation text, or null when the failure is not a
243
+ * deployment-state problem. Keeps the CLI and web surfaces in lockstep —
244
+ * round 4 found the two carrying hand-copied, already-diverging prose.
245
+ */
246
+ export function storeWriteRemediation(message: string, fnName: string): string | null {
247
+ if (isMissingFunctionError(message, fnName)) {
248
+ return (
249
+ "The deployed server predates schema 0.14.0 — or PostgREST's schema cache " +
250
+ "is stale right after a deploy. If you just deployed, retry in a few " +
251
+ "seconds; otherwise run `cerefox server deploy`."
252
+ );
253
+ }
254
+ if (isAuditCheckError(message)) {
255
+ return (
256
+ "The server's audit-log constraint predates migration 0028 (partial " +
257
+ "deploy). Run `cerefox server deploy` to apply pending migrations, then retry."
258
+ );
259
+ }
260
+ return null;
261
+ }
262
+
218
263
  export function isMissingFunctionError(message: string, fnName: string): boolean {
219
264
  return (
220
265
  (message.includes("Could not find the function") && message.includes(fnName)) ||
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { MCPSupabaseClient } from "./types.ts";
7
7
 
8
- import { logUsage } from "./_utils.ts";
8
+ import { logUsage, auditDocLabel, AUDIT_OPERATIONS } from "./_utils.ts";
9
9
  import type { ToolContext, ToolDefinition } from "./types.ts";
10
10
 
11
11
  /**
@@ -61,8 +61,7 @@ async function handler(
61
61
  if (!entries.length) return "No audit log entries found.";
62
62
 
63
63
  const lines = entries.map((e) => {
64
- const docLabel =
65
- e.doc_title ?? (e.document_id ? e.document_id.slice(0, 8) + "..." : "(deleted)");
64
+ const docLabel = auditDocLabel(e.doc_title, e.document_id, e.operation);
66
65
  const sizeInfo =
67
66
  e.size_before != null && e.size_after != null
68
67
  ? ` | ${e.size_before} -> ${e.size_after} chars`
@@ -93,8 +92,7 @@ export const auditLogTool: ToolDefinition = {
93
92
  author: { type: "string", description: "Filter by author name (optional)" },
94
93
  operation: {
95
94
  type: "string",
96
- description:
97
- "Filter by operation type: create, update-content, update-metadata, delete, status-change, archive, unarchive (optional)",
95
+ description: `Filter by operation type: ${AUDIT_OPERATIONS.join(", ")} (optional)`,
98
96
  },
99
97
  since: {
100
98
  type: "string",
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Audit operation vocabulary — a dependency-free leaf module so the frontend
3
+ * can import it through the `@cerefox/audit-ops` vite alias (same pattern as
4
+ * `@cerefox/schemas`) without dragging server-side helpers into the bundle.
5
+ * `_shared/mcp-tools/_utils.ts` re-exports everything here for Node/Deno
6
+ * consumers; there is exactly ONE definition of each.
7
+ */
8
+
9
+ /**
10
+ * Store-level audit operations (0.14.0, #147) carry document_id NULL by
11
+ * design. A NULL-document row that is NOT store-level means the document was
12
+ * purged.
13
+ */
14
+ export const STORE_LEVEL_AUDIT_OPS = [
15
+ "config-change",
16
+ "project-create",
17
+ "project-edit",
18
+ "project-delete",
19
+ ] as const;
20
+
21
+ export function isStoreLevelAuditOp(operation: string | null | undefined): boolean {
22
+ return (STORE_LEVEL_AUDIT_OPS as readonly string[]).includes(operation ?? "");
23
+ }
24
+
25
+ /** All audit operation values, in CHECK order — drives the CLI help, the MCP
26
+ * tool description, and the web filter so a new operation cannot miss a
27
+ * surface. */
28
+ export const AUDIT_OPERATIONS = [
29
+ "create", "update-content", "update-metadata", "insert", "replace-section",
30
+ "delete-section", "rename-section", "delete", "restore", "status-change",
31
+ "archive", "unarchive", "relation-set", "relation-delete",
32
+ ...STORE_LEVEL_AUDIT_OPS,
33
+ ] as const;
34
+
35
+ /** The document-column label for an audit row — title, short id, or the
36
+ * NULL-document story: "(store)" for store-level ops, "(deleted)" for rows
37
+ * whose document was purged. One implementation across MCP, CLI, and web. */
38
+ export function auditDocLabel(
39
+ docTitle: string | null | undefined,
40
+ documentId: string | null | undefined,
41
+ operation: string | null | undefined,
42
+ ): string {
43
+ if (docTitle) return docTitle;
44
+ if (documentId) return documentId.slice(0, 8) + "…";
45
+ return isStoreLevelAuditOp(operation) ? "(store)" : "(deleted)";
46
+ }
@@ -11,7 +11,7 @@
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 **19 MCP tools** (18 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`/`rename_section`), `expected_content_hash` (required) |\n| `cerefox_delete_document` | **Soft**-delete a document (to trash; excluded from search; permanent purge is human-only) | `document_id`, `expected_content_hash` (**required** — a delete must follow a read), `reason` (recorded in the audit log — give one), `author` |\n| `cerefox_restore_document` | Restore a soft-deleted document from the trash (audited inverse of delete; no-op if not deleted) | `document_id` (required), `reason` (recorded in the audit log), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash, or with `section: \"## Heading\"` one section's text | `document_id` (required), `outline`, `section`, `section_part` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_metadata` | Change tags WITHOUT resending content. **Merges** by default; a `null` value removes a key | `document_id`, `metadata` (required), `replace` (rare: set exactly this object), `author` |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.\n\n## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so \"I meant to append\" cannot become \"I replaced the file\".\n3. **Look before you overwrite** — `cerefox_get_document(document_id,\n section: \"## Heading\")` returns exactly the text a `replace_section` on that\n anchor would destroy. The outline gives you a section's *size*, never its\n *text*, so on a document you did not write yourself this is the difference\n between a replace and a blind overwrite.\n4. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround. To fix a stale heading (`## OPEN TODOs (as of ...)`), use\n `rename_section`: it changes the heading text and leaves the body and\n position alone.\n5. All of them require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**A section runs to the next same-or-higher heading, or to the end of the\ndocument.** So `end_of_document` inserts land inside the *last* section, and\nreplacing or deleting that section removes them too. A large shrink in the\nresponse is your warning; `cerefox_list_versions` has the previous content.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); permanent purge is web-UI-only.** `cerefox_delete_document` requires the document's `content_hash` as you read it (read before you delete) and takes a `reason` — give one; it is what the human reviewing the trash sees. `cerefox_restore_document` undoes a mistaken delete (also audited, also takes a `reason`). Always surface deletes AND restores to the user. Once a human purges from the web UI, the document is gone for good.\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. **The server validates `](uuid)` links on every write** (v1.7.0): a link to a nonexistent id rejects the write, naming the offender — that means you mangled the UUID; re-read the source and correct it, do not retry unchanged. Example ids go in backticks (code is not validated). `[[Wikilinks]]` may dangle. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_insert` | `cerefox document insert <id> -t \"<text>\" -p <position> -a \"<anchor-heading>\" -e \"<hash>\" --requestor \"<your-name>\" --author-type agent` |\n| `cerefox_edit` | `cerefox document edit-parts <id> --operations '<json>' -e \"<hash>\" --requestor \"<your-name>\" --author-type agent` |\n| `cerefox_delete_document` | `cerefox document delete <id> --reason \"<why>\" --author \"<your-name>\" --author-type agent --yes` (confirms interactively instead of requiring the hash) |\n| `cerefox_restore_document` | `cerefox document restore <id> --reason \"<why>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_metadata` | `cerefox document set-metadata <id> --set key=value` (also `--remove key`, `--json '{...}'`, `--replace`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n\n## Timestamps are UTC\n\nEvery timestamp Cerefox returns — `created_at` on audit entries, version\nhistory, document metadata — is **UTC**, and now carries its `Z` marker so it\ncannot be mistaken for local time.\n\n**When you write a date into a document's CONTENT, use your own clock, not a\nCerefox timestamp.** These are different things: a timestamp records when the\nserver stored something; a date in a log entry or a heading is authored content\nand belongs to your timezone. An agent working a Pacific afternoon read\n`2026-08-11` from version history, wrote \"8/11\" into its entries, and put a\nday's work in the future — the timestamp was correct, and copying it into\ncontent was not.\n\nCerefox deliberately does not convert to local time on the API or MCP paths.\n\"Local\" has no server-side meaning: the remote MCP server runs in a cloud\nfunction whose local time *is* UTC, while a local MCP server runs in yours, so\nthe same document would report two different times depending on transport. The\nweb UI converts because a browser knows the viewer's timezone; nothing\nserver-side does.\n\n## Mistakes that have actually happened\n\nEach of these comes from a real agent session, and each is easy to make.\n\n- **`cerefox_ingest` always replaces the ENTIRE document.** Never a section.\n Before sending, check that the tool name matches the intent: if the intent is\n \"change one section\", the call is `cerefox_edit` with `replace_section`. A\n section-sized edit sent as a full ingest truncated a 13,000-character index to\n a single word. It was recovered from version history within the minute, but\n only because it was noticed immediately.\n\n- **Do not include the anchor's own heading in your text.** `replace_section`\n keeps the heading and `insert` places your text inside the section, so\n including it produces two. This is now refused rather than silently applied,\n but the shape is worth knowing: it happened twice in one session, the second\n time while trying to repair the first. A *deeper* sub-heading inside your text\n is fine.\n\n- **Content between sections belongs to the section ABOVE it.** A section runs\n to the next heading of the same or higher level, so a `---` rule, a note, or\n any trailing text sitting just above the next heading is part of the section\n before it — even when it visually reads as belonging below. Replacing that\n section takes it too. An agent hit exactly this: a `---` that separated two\n major sections disappeared when the section above it was replaced. The write\n was correct by the addressing rules; the surprise is that \"the end of this\n section\" is further down the page than it looks. Note the loss warning will\n not catch it if your replacement text is longer than what it replaced, since\n there is then no net loss to report.\n\n- **To change only tags, use `cerefox_set_document_metadata`, never `cerefox_ingest`.**\n Ingest replaces the whole document, so re-sending it to set one tag carries the\n full transcription risk for no reason. The metadata tool merges: the keys you\n pass are set, everything else is left alone, so you do not need to read the\n document first and cannot drop a tag another agent set. Pass `null` as a value\n to remove a key.\n\n- **Never partial-edit to fix a partial edit.** If a write leaves unexpected\n structure, stop. Use `cerefox_list_versions`, retrieve the last good version,\n and re-ingest cleanly. Repairing edits with more edits compounds the damage.\n\n- **A rejected batch is safe.** Operations in one `cerefox_edit` are\n all-or-nothing: if any is invalid, nothing is written. A refusal costs you a\n retry, not data — so prefer one call for changes that belong together, and do\n not split a batch to \"make it more likely to succeed\".\n\n- **Read before replacing.** `cerefox_get_document(section: \"## Heading\")`\n returns exactly what a `replace_section` on that anchor would overwrite. Use it\n for any section you did not write in this session. The outline gives a\n section's *size*, never its *text*.\n\n- **Verify after writing** — read the result back before reporting success, and\n report what the read actually shows.\n\n- **Partial edits cannot change a document's stored TITLE.** `rename_section`\n changes a heading inside the content; the title is a separate field and still\n needs `cerefox_ingest`.\n\n- **If a capability seems missing from one server, suspect your client first.**\n Local and remote run the same code. **Every `cerefox_get_help()` response\n begins with the server's version and the operations it registers** — you do\n not need a special topic, and the *absence* of that block is itself an answer:\n a server that does not print it predates v1.5.0. If that\n disagrees with your tool list, the client is holding a list it fetched before\n an upgrade — clients cache it at connect time. Ask the user to restart the\n client. Do not record a capability difference between servers as a fact; every\n such report so far has been a stale client.\n";
14
+ export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **15 core MCP tools** (14 with CLI equivalents — `cerefox_get_help` is MCP-only), plus 4 dormant relation tools that appear only when `relations_enabled` is on. For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_insert` | **Add** to a document without resending it. Cannot destroy content. | `document_id`, `text`, `position` (`end_of_document`/`end_of_section`/`after_heading`/`before_heading`), `expected_content_hash` (required), `anchor_heading` (unless `end_of_document`), `section_part` |\n| `cerefox_edit` | **Change** parts of a document: 1..n operations applied atomically | `document_id`, `operations` (`insert`/`replace_section`/`delete_section`/`rename_section`), `expected_content_hash` (required) |\n| `cerefox_delete_document` | **Soft**-delete a document (to trash; excluded from search; permanent purge is human-only) | `document_id`, `expected_content_hash` (**required** — a delete must follow a read), `reason` (recorded in the audit log — give one), `author` |\n| `cerefox_restore_document` | Restore a soft-deleted document from the trash (audited inverse of delete; no-op if not deleted) | `document_id` (required), `reason` (recorded in the audit log), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token), or with `outline: true` just its heading paths, sizes and hash, or with `section: \"## Heading\"` one section's text | `document_id` (required), `outline`, `section`, `section_part` |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_set_relation` ⚑ | Link two documents (`source --rel_type--> target`) | `source_id`, `target_id`, `rel_type` (required), `metadata`, `author` |\n| `cerefox_delete_relation` ⚑ | Remove a relation | `source_id`, `target_id`, `rel_type` |\n| `cerefox_get_relations` ⚑ | All relations touching a document, both directions | `document_id` |\n| `cerefox_get_neighbors` ⚑ | Walk the graph along ONE relation type | `document_id`, `rel_type` (required), `depth`, `from_time`, `to_time`, `limit` |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_metadata` | Change tags WITHOUT resending content. **Merges** by default; a `null` value removes a key | `document_id`, `metadata` (required), `replace` (rare: set exactly this object), `author` |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n⚑ **Opt-in — usually absent.** The four relation tools are hidden unless the\noperator enables them (`relations_enabled`). **Trust your own tool list**: if\nthey are not in it, the feature is switched off for this deployment. That is\nnormal, not an error, and not something to work around.\n\n## Editing part of a document (prefer this over re-sending)\n\n**Re-sending a whole document to change part of it is the main way agents lose\ndata.** You have to reproduce the untouched remainder verbatim, and any drift\nsilently rewrites content nobody asked you to touch — which the caller cannot\ndiff. Use the partial-edit tools instead:\n\n1. **Learn the anchors** — `cerefox_get_document(document_id, outline: true)`.\n Returns heading paths, per-section sizes and the `content_hash`, without the\n body. The paths it returns are exactly what `anchor_heading` accepts.\n2. **Add** → `cerefox_insert`. `end_of_document` is a plain append;\n `end_of_section` adds inside a named section. It is structurally incapable of\n removing anything, so \"I meant to append\" cannot become \"I replaced the file\".\n3. **Look before you overwrite** — `cerefox_get_document(document_id,\n section: \"## Heading\")` returns exactly the text a `replace_section` on that\n anchor would destroy. The outline gives you a section's *size*, never its\n *text*, so on a document you did not write yourself this is the difference\n between a replace and a blind overwrite.\n4. **Change or remove** → `cerefox_edit`. Put changes that belong together in\n ONE call: they apply atomically, so a table row and the total it feeds cannot\n end up disagreeing. To change a single line, `replace_section` on its\n smallest enclosing heading — that is the intended granularity, not a\n workaround. To fix a stale heading (`## OPEN TODOs (as of ...)`), use\n `rename_section`: it changes the heading text and leaves the body and\n position alone.\n5. All of them require `expected_content_hash` and **have no last-write-wins**. A\n conflict means someone else changed the document; re-read and decide, do not\n force it.\n\n**A section runs to the next same-or-higher heading, or to the end of the\ndocument.** So `end_of_document` inserts land inside the *last* section, and\nreplacing or deleting that section removes them too. A large shrink in the\nresponse is your warning; `cerefox_list_versions` has the previous content.\n\n**When an anchor is ambiguous the tool refuses and hands you the options** — a\nrepeated heading returns the qualifying paths, and a section with both its own\ncontent and sub-sections returns both `section_part` choices. That is a\nrecoverable answer, not a failure: retry with what it gave you.\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); permanent purge is web-UI-only.** `cerefox_delete_document` requires the document's `content_hash` as you read it (read before you delete) and takes a `reason` — give one; it is what the human reviewing the trash sees. `cerefox_restore_document` undoes a mistaken delete (also audited, also takes a `reason`). Always surface deletes AND restores to the user. Once a human purges from the web UI, the document is gone for good.\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. **The server validates `](uuid)` links on every write** (v1.7.0): a link to a nonexistent id rejects the write, naming the offender — that means you mangled the UUID; re-read the source and correct it, do not retry unchanged. Example ids go in backticks (code is not validated). `[[Wikilinks]]` may dangle. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you last saw — every read shows one (`cerefox_get_document` incl. outline mode, `cerefox_search`, `cerefox_metadata_search`) and **every write returns the new one, including create** (v1.3.0, #189), so after writing you already hold the token for your next edit; no re-read needed. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Search: prefer a few distinctive terms; heed `below confidence`.** When nothing clears the relevance threshold, `cerefox_search` returns the closest candidates prefixed with a `below confidence` warning instead of an empty set — that flag means **weak signal, not absent knowledge**: check the candidates' scores and titles before concluding the KB lacks the content. A truly empty response means nothing even weakly related exists.\n11. **Relations express how documents relate; lifecycle tells you if knowledge is still good.** Use `cerefox_set_relation` when one document supersedes, contradicts, references, or continues another. `supersedes` marks the target **superseded**; `contradicts` marks **both** stale; `related_to`/`duplicates`/`contradicts` are symmetric (both directions written). Any other type string is accepted without special behaviour. When a search result or `cerefox_get_relations` shows a neighbour marked `[superseded]` or `[stale]`, say so rather than presenting it as current.\n12. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_insert` | `cerefox document insert <id> -t \"<text>\" -p <position> -a \"<anchor-heading>\" -e \"<hash>\" --requestor \"<your-name>\" --author-type agent` |\n| `cerefox_edit` | `cerefox document edit-parts <id> --operations '<json>' -e \"<hash>\" --requestor \"<your-name>\" --author-type agent` |\n| `cerefox_delete_document` | `cerefox document delete <id> --reason \"<why>\" --author \"<your-name>\" --author-type agent --yes` (confirms interactively instead of requiring the hash) |\n| `cerefox_restore_document` | `cerefox document restore <id> --reason \"<why>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_metadata` | `cerefox document set-metadata <id> --set key=value` (also `--remove key`, `--json '{...}'`, `--replace`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n\n## Timestamps are UTC\n\nEvery timestamp Cerefox returns — `created_at` on audit entries, version\nhistory, document metadata — is **UTC**, and now carries its `Z` marker so it\ncannot be mistaken for local time.\n\n**When you write a date into a document's CONTENT, use your own clock, not a\nCerefox timestamp.** These are different things: a timestamp records when the\nserver stored something; a date in a log entry or a heading is authored content\nand belongs to your timezone. An agent working a Pacific afternoon read\n`2026-08-11` from version history, wrote \"8/11\" into its entries, and put a\nday's work in the future — the timestamp was correct, and copying it into\ncontent was not.\n\nCerefox deliberately does not convert to local time on the API or MCP paths.\n\"Local\" has no server-side meaning: the remote MCP server runs in a cloud\nfunction whose local time *is* UTC, while a local MCP server runs in yours, so\nthe same document would report two different times depending on transport. The\nweb UI converts because a browser knows the viewer's timezone; nothing\nserver-side does.\n\n## Mistakes that have actually happened\n\nEach of these comes from a real agent session, and each is easy to make.\n\n- **`cerefox_ingest` always replaces the ENTIRE document.** Never a section.\n Before sending, check that the tool name matches the intent: if the intent is\n \"change one section\", the call is `cerefox_edit` with `replace_section`. A\n section-sized edit sent as a full ingest truncated a 13,000-character index to\n a single word. It was recovered from version history within the minute, but\n only because it was noticed immediately.\n\n- **Do not include the anchor's own heading in your text.** `replace_section`\n keeps the heading and `insert` places your text inside the section, so\n including it produces two. This is now refused rather than silently applied,\n but the shape is worth knowing: it happened twice in one session, the second\n time while trying to repair the first. A *deeper* sub-heading inside your text\n is fine.\n\n- **Content between sections belongs to the section ABOVE it.** A section runs\n to the next heading of the same or higher level, so a `---` rule, a note, or\n any trailing text sitting just above the next heading is part of the section\n before it — even when it visually reads as belonging below. Replacing that\n section takes it too. An agent hit exactly this: a `---` that separated two\n major sections disappeared when the section above it was replaced. The write\n was correct by the addressing rules; the surprise is that \"the end of this\n section\" is further down the page than it looks. Note the loss warning will\n not catch it if your replacement text is longer than what it replaced, since\n there is then no net loss to report.\n\n- **To change only tags, use `cerefox_set_document_metadata`, never `cerefox_ingest`.**\n Ingest replaces the whole document, so re-sending it to set one tag carries the\n full transcription risk for no reason. The metadata tool merges: the keys you\n pass are set, everything else is left alone, so you do not need to read the\n document first and cannot drop a tag another agent set. Pass `null` as a value\n to remove a key.\n\n- **Never partial-edit to fix a partial edit.** If a write leaves unexpected\n structure, stop. Use `cerefox_list_versions`, retrieve the last good version,\n and re-ingest cleanly. Repairing edits with more edits compounds the damage.\n\n- **A rejected batch is safe.** Operations in one `cerefox_edit` are\n all-or-nothing: if any is invalid, nothing is written. A refusal costs you a\n retry, not data — so prefer one call for changes that belong together, and do\n not split a batch to \"make it more likely to succeed\".\n\n- **Read before replacing.** `cerefox_get_document(section: \"## Heading\")`\n returns exactly what a `replace_section` on that anchor would overwrite. Use it\n for any section you did not write in this session. The outline gives a\n section's *size*, never its *text*.\n\n- **Verify after writing** — read the result back before reporting success, and\n report what the read actually shows.\n\n- **Partial edits cannot change a document's stored TITLE.** `rename_section`\n changes a heading inside the content; the title is a separate field and still\n needs `cerefox_ingest`.\n\n- **If a capability seems missing from one server, suspect your client first.**\n Local and remote run the same code. **Every `cerefox_get_help()` response\n begins with the server's version and the operations it registers** — you do\n not need a special topic, and the *absence* of that block is itself an answer:\n a server that does not print it predates v1.5.0. If that\n disagrees with your tool list, the client is holding a list it fetched before\n an upgrade — clients cache it at connect time. Ask the user to restart the\n client. Do not record a capability difference between servers as a fact; every\n such report so far has been a stale client.\n";
15
15
 
16
16
  /** Sections keyed by their H2 heading text (lower-cased for matching). */
17
17
  export const HELP_SECTIONS: Record<string, string> = {
@@ -222,9 +222,9 @@ async function handler(
222
222
  });
223
223
 
224
224
  if (project_names !== null) {
225
- await setDocumentProjectsByName(supabase, existingDoc.id, project_names);
225
+ await setDocumentProjectsByName(supabase, existingDoc.id, project_names, { author, authorType: author_type });
226
226
  } else if (project_name) {
227
- await ensureDocumentInProject(supabase, existingDoc.id, project_name);
227
+ await ensureDocumentInProject(supabase, existingDoc.id, project_name, { author, authorType: author_type });
228
228
  }
229
229
 
230
230
  const note = update_if_exists
@@ -326,9 +326,9 @@ async function handler(
326
326
  });
327
327
 
328
328
  if (project_names !== null) {
329
- await setDocumentProjectsByName(supabase, existingDoc.id, project_names);
329
+ await setDocumentProjectsByName(supabase, existingDoc.id, project_names, { author, authorType: author_type });
330
330
  } else if (project_name) {
331
- await ensureDocumentInProject(supabase, existingDoc.id, project_name);
331
+ await ensureDocumentInProject(supabase, existingDoc.id, project_name, { author, authorType: author_type });
332
332
  }
333
333
 
334
334
  return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars. New content_hash: ${contentHash}.`;
@@ -397,9 +397,9 @@ async function handler(
397
397
  const documentId = ingestResult[0].document_id;
398
398
 
399
399
  if (project_names !== null && project_names.length > 0) {
400
- await setDocumentProjectsByName(supabase, documentId, project_names);
400
+ await setDocumentProjectsByName(supabase, documentId, project_names, { author, authorType: author_type });
401
401
  } else if (project_name) {
402
- await ensureDocumentInProject(supabase, documentId, project_name);
402
+ await ensureDocumentInProject(supabase, documentId, project_name, { author, authorType: author_type });
403
403
  }
404
404
 
405
405
  logUsage(supabase, {
@@ -5,7 +5,8 @@
5
5
  -- replaces the SAME signature), leaving long-lived databases with BOTH
6
6
  -- overloads. A named 1-arg call is then ambiguous — PostgREST PGRST203
7
7
  -- ("could not choose the best candidate") — which is how the first
8
- -- production acceptance run failed to purge its fixtures (v1.7.0).
8
+ -- acceptance run against a long-lived database failed to purge its
9
+ -- fixtures (v1.7.0).
9
10
  -- Fresh databases never had the old signatures and are unaffected.
10
11
  --
11
12
  -- Schema version 0.12.0 → 0.12.1. The DROPs also run from rpcs.sql on every
@@ -0,0 +1,172 @@
1
+ -- 0027_drop_archived_search_artifacts.sql — archived chunks carry no search
2
+ -- artifacts (#216). THE CANONICAL RATIONALE LIVES HERE; code comments
3
+ -- reference it.
4
+ --
5
+ -- Search is current-chunks-only by design (every search index is partial on
6
+ -- version_id IS NULL), version reconstruction and diffs read `content`, and
7
+ -- restoring an old version is deliberately manual re-ingest (which
8
+ -- re-embeds). Embeddings and fts on archived chunks were therefore never
9
+ -- readable by anything — pure storage cost, measured at ~30-45% of the chunk
10
+ -- relation on long-lived stores — and after a reindex they are stale for the
11
+ -- current embedder besides. There is no config and no maintenance command:
12
+ -- with nothing able to read the artifacts, this is an invariant, not a
13
+ -- policy. The archived content — the actual safety copy — is untouched.
14
+ --
15
+ -- Four parts:
16
+ -- 1. embedding_primary becomes nullable, WITH a replacement guard: a CHECK
17
+ -- that a CURRENT chunk always carries an embedding. Without it, a short
18
+ -- embedding-API response could insert a silently search-invisible chunk
19
+ -- where the old NOT NULL failed loudly (review round 6).
20
+ -- 2. The NEW cerefox_snapshot_version ships INSIDE this migration (repo
21
+ -- precedent: 0005-0008, 0011, 0025 carry function bodies). This closes
22
+ -- two windows where the OLD snapshot could keep archiving WITH
23
+ -- artifacts after 0027 was stamped: `bun scripts/db_migrate.ts` (which
24
+ -- never refreshes rpcs.sql), and a `server deploy` that failed between
25
+ -- the migration step and the RPC refresh.
26
+ -- 3. Back-fill: strip artifacts from existing archived rows, reporting
27
+ -- rows and bytes. embedder_upgrade is nulled with its vector;
28
+ -- embedder_primary is deliberately kept (NOT NULL, harmless provenance).
29
+ -- 4. Space note: Postgres frees the bytes for REUSE via autovacuum rather
30
+ -- than shrinking files immediately — growth stops even if the reported
31
+ -- database size does not drop the same day.
32
+ --
33
+ -- Schema version 0.12.2 → 0.13.0.
34
+
35
+ ALTER TABLE cerefox_chunks ALTER COLUMN embedding_primary DROP NOT NULL;
36
+
37
+ DO $$
38
+ BEGIN
39
+ IF NOT EXISTS (
40
+ SELECT 1 FROM pg_constraint WHERE conname = 'cerefox_chunks_current_has_embedding'
41
+ ) THEN
42
+ ALTER TABLE cerefox_chunks
43
+ ADD CONSTRAINT cerefox_chunks_current_has_embedding
44
+ CHECK (version_id IS NOT NULL OR embedding_primary IS NOT NULL);
45
+ END IF;
46
+ END $$;
47
+
48
+ -- The new snapshot (identical to the rpcs.sql copy this release ships):
49
+
50
+ DROP FUNCTION IF EXISTS cerefox_snapshot_version(UUID, TEXT, INT);
51
+ DROP FUNCTION IF EXISTS cerefox_snapshot_version(UUID, TEXT, INT, BOOLEAN);
52
+ CREATE FUNCTION cerefox_snapshot_version(
53
+ p_document_id UUID,
54
+ p_source TEXT DEFAULT 'manual',
55
+ -- NULL (the new default) means "use the store's policy from
56
+ -- cerefox_config". Passing a value still overrides, for deliberate one-off
57
+ -- admin operations — but callers no longer supply one by accident.
58
+ p_retention_hours INT DEFAULT NULL,
59
+ p_cleanup_enabled BOOLEAN DEFAULT NULL
60
+ )
61
+ RETURNS TABLE (
62
+ version_id UUID,
63
+ version_number INT,
64
+ chunk_count INT,
65
+ total_chars INT
66
+ )
67
+ LANGUAGE plpgsql
68
+ SECURITY DEFINER
69
+ SET search_path = public, pg_catalog
70
+ AS $$
71
+ DECLARE
72
+ v_version_id UUID;
73
+ v_version_number INT;
74
+ v_chunk_count INT;
75
+ v_total_chars INT;
76
+ -- Resolve the retention policy from the STORE, not the caller.
77
+ --
78
+ -- These used to arrive as parameters filled from each client's own env, so
79
+ -- the surviving version history depended on which client wrote last: an
80
+ -- agent running defaults would prune versions that an operator had
81
+ -- configured to keep. Retention describes the data, so it belongs to the
82
+ -- data. Same COALESCE(param, config, default) shape the retrieval tunables
83
+ -- already use.
84
+ v_retention INT := COALESCE(p_retention_hours,
85
+ cerefox_config_int('version_retention_hours', 120));
86
+ v_cleanup BOOLEAN := COALESCE(p_cleanup_enabled,
87
+ cerefox_config_bool('version_cleanup_enabled', TRUE));
88
+ BEGIN
89
+ -- Count current chunks to record in the version metadata
90
+ SELECT COUNT(*), COALESCE(SUM(char_count), 0)
91
+ INTO v_chunk_count, v_total_chars
92
+ FROM cerefox_chunks c
93
+ WHERE c.document_id = p_document_id
94
+ AND c.version_id IS NULL;
95
+
96
+ -- Compute the next version number (sequential per document)
97
+ SELECT COALESCE(MAX(dv.version_number), 0) + 1
98
+ INTO v_version_number
99
+ FROM cerefox_document_versions dv
100
+ WHERE dv.document_id = p_document_id;
101
+
102
+ -- Create the version row
103
+ INSERT INTO cerefox_document_versions (
104
+ document_id, version_number, source, chunk_count, total_chars
105
+ ) VALUES (
106
+ p_document_id, v_version_number, p_source, v_chunk_count, v_total_chars
107
+ )
108
+ RETURNING id INTO v_version_id;
109
+
110
+ -- Archive all current chunks by pointing them at the new version, and
111
+ -- NULL their search artifacts in the same write (0.13.0, #216 — full
112
+ -- rationale in migration 0027). The content — the actual safety copy —
113
+ -- is untouched. embedder_upgrade is nulled with its vector;
114
+ -- embedder_primary is deliberately KEPT (it is NOT NULL, and the label
115
+ -- is harmless provenance for a vector that no longer exists — nothing
116
+ -- reads embedder columns without a version_id IS NULL filter).
117
+ UPDATE cerefox_chunks c
118
+ SET version_id = v_version_id,
119
+ embedding_primary = NULL,
120
+ embedding_upgrade = NULL,
121
+ embedder_upgrade = NULL,
122
+ fts = NULL
123
+ WHERE c.document_id = p_document_id
124
+ AND c.version_id IS NULL;
125
+
126
+ -- Lazy retention: delete versions outside the retention window,
127
+ -- but always keep the most recently created version (the one we just made).
128
+ -- Skip archived versions (archived=true) -- they are protected from cleanup.
129
+ -- Skip cleanup entirely if p_cleanup_enabled is false (immutable mode).
130
+ IF v_cleanup THEN
131
+ DELETE FROM cerefox_document_versions dv
132
+ WHERE dv.document_id = p_document_id
133
+ AND dv.archived IS NOT TRUE
134
+ AND dv.created_at < NOW() - (v_retention || ' hours')::INTERVAL
135
+ AND dv.id != (
136
+ SELECT id FROM cerefox_document_versions
137
+ WHERE document_id = p_document_id
138
+ ORDER BY created_at DESC
139
+ LIMIT 1
140
+ );
141
+ END IF;
142
+
143
+ RETURN QUERY SELECT v_version_id, v_version_number, v_chunk_count, v_total_chars;
144
+ END;
145
+ $$;
146
+
147
+ DO $$
148
+ DECLARE
149
+ v_rows INT;
150
+ v_bytes BIGINT;
151
+ BEGIN
152
+ SELECT count(*),
153
+ COALESCE(SUM(COALESCE(pg_column_size(embedding_primary), 0))
154
+ + SUM(COALESCE(pg_column_size(embedding_upgrade), 0))
155
+ + SUM(COALESCE(pg_column_size(fts), 0)), 0)
156
+ INTO v_rows, v_bytes
157
+ FROM cerefox_chunks
158
+ WHERE version_id IS NOT NULL
159
+ AND (embedding_primary IS NOT NULL OR embedding_upgrade IS NOT NULL OR fts IS NOT NULL);
160
+
161
+ UPDATE cerefox_chunks
162
+ SET embedding_primary = NULL,
163
+ embedding_upgrade = NULL,
164
+ embedder_upgrade = NULL,
165
+ fts = NULL
166
+ WHERE version_id IS NOT NULL
167
+ AND (embedding_primary IS NOT NULL OR embedding_upgrade IS NOT NULL OR fts IS NOT NULL);
168
+
169
+ RAISE NOTICE
170
+ 'Migration 0027: stripped search artifacts from % archived chunk row(s), freeing ~% for reuse. Archived content is untouched; current chunks keep their embeddings.',
171
+ v_rows, pg_size_pretty(v_bytes);
172
+ END $$;