@cerefox/memory 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT_GUIDE.md CHANGED
@@ -478,7 +478,7 @@ Call `cerefox_list_metadata_keys` for the current list -- conventions evolve.
478
478
 
479
479
  ## Writing linkable content
480
480
 
481
- Documents you ingest may contain markdown links to other Cerefox documents. The Cerefox web UI intercepts these links at click time and resolves them to the target document. The resolution happens entirely in the browser; the stored markdown is untouched.
481
+ Documents you ingest may contain markdown links to other Cerefox documents. The Cerefox web UI intercepts these links at click time and resolves them to the target document. The resolution happens entirely in the browser; the stored markdown is untouched. (User-facing overview of the whole linking system, including *why* long ids corrupt during regeneration: [`docs/guides/linking.md`](docs/guides/linking.md).)
482
482
 
483
483
  ### The rule for agents: use document UUIDs
484
484
 
@@ -502,9 +502,9 @@ same-turn-fixable error. Three things to know:
502
502
  mechanism, and it is just correct markdown authoring.
503
503
  - **Only links your write introduces are validated on updates.** A dead
504
504
  link the document already carried (its target purged after linking) does
505
- not block your unrelated edit — legacy dead links are found by the
506
- dead-link sweep (#214 phase 2), not by holding your edit hostage. New
507
- documents validate every link.
505
+ not block your unrelated edit — legacy dead links are found on demand by
506
+ the sweep (`cerefox document dead-links`, CLI), not by holding your edit
507
+ hostage. New documents validate every link.
508
508
 
509
509
  `[[Wikilinks]]` are NOT validated — they remain the sanctioned form for
510
510
  "flag a document to create later."
@@ -7438,7 +7438,7 @@ var exports_meta = {};
7438
7438
  __export(exports_meta, {
7439
7439
  PKG_VERSION: () => PKG_VERSION
7440
7440
  });
7441
- var PKG_VERSION = "1.7.0";
7441
+ var PKG_VERSION = "1.7.1";
7442
7442
  var init_meta = () => {};
7443
7443
 
7444
7444
  // ../../_shared/config/paths.ts
@@ -25743,7 +25743,7 @@ var init_bundled_docs = __esm(() => {
25743
25743
  });
25744
25744
 
25745
25745
  // ../../_shared/ef-meta/index.ts
25746
- var EF_VERSION = "1.7.0", CEREFOX_VERSION = "1.7.0", EF_LAST_CHANGED = "1.7.0";
25746
+ var EF_VERSION = "1.7.1", CEREFOX_VERSION = "1.7.1", EF_LAST_CHANGED = "1.7.0";
25747
25747
  var init_ef_meta = () => {};
25748
25748
 
25749
25749
  // ../../_shared/compatibility/index.ts
@@ -41592,10 +41592,10 @@ var require_flate = __commonJS((exports) => {
41592
41592
  var GenericWorker = require_GenericWorker();
41593
41593
  var ARRAY_TYPE = USE_TYPEDARRAY ? "uint8array" : "array";
41594
41594
  exports.magic = "\b\x00";
41595
- function FlateWorker(action19, options) {
41596
- GenericWorker.call(this, "FlateWorker/" + action19);
41595
+ function FlateWorker(action20, options) {
41596
+ GenericWorker.call(this, "FlateWorker/" + action20);
41597
41597
  this._pako = null;
41598
- this._pakoAction = action19;
41598
+ this._pakoAction = action20;
41599
41599
  this._pakoOptions = options;
41600
41600
  this.meta = {};
41601
41601
  }
@@ -57699,11 +57699,11 @@ async function runSyncSelfDocs(options = {}) {
57699
57699
  printTable(outcomes.filter((o) => o.status === "error").map((o) => ({ topic: o.topic, error: o.detail.slice(0, 100) })));
57700
57700
  }
57701
57701
  }
57702
- async function action21(options) {
57702
+ async function action22(options) {
57703
57703
  await runSyncSelfDocs(options);
57704
57704
  }
57705
57705
  function registerSyncSelfDocs(program2) {
57706
- program2.command("sync-self-docs").description("Ingest bundled Cerefox docs under the _cerefox-self-docs project.").option("--dry-run", "List what would be ingested without writing.").option("--project <name>", "Override the target project name.", "_cerefox-self-docs").action(action21);
57706
+ program2.command("sync-self-docs").description("Ingest bundled Cerefox docs under the _cerefox-self-docs project.").option("--dry-run", "List what would be ingested without writing.").option("--project <name>", "Override the target project name.", "_cerefox-self-docs").action(action22);
57707
57707
  }
57708
57708
  var init_sync_self_docs = __esm(() => {
57709
57709
  init_cli_core();
@@ -72797,11 +72797,48 @@ function registerDeleteDoc(program2) {
72797
72797
  program2.command("delete-doc").description("Soft-delete a document (recoverable via the web UI trash).").argument("<document-id>", "UUID of the document to delete.").option("--reason <text>", "Optional reason recorded in the audit log.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").option("--yes", "Skip the confirmation prompt.").action(action7);
72798
72798
  }
72799
72799
 
72800
+ // src/cli/commands/document-dead-links.ts
72801
+ init_cli_core();
72802
+ init_client();
72803
+ var SERVER_BEHIND = "The sweep did not run: this server has no cerefox_find_dead_links (needs schema 0.12.2). " + "Run `cerefox server deploy`, then retry.";
72804
+ async function action8(options) {
72805
+ const client = getClient();
72806
+ let rows;
72807
+ try {
72808
+ rows = await client.rpc("cerefox_find_dead_links", {});
72809
+ } catch (e) {
72810
+ const message = e instanceof Error ? e.message : String(e);
72811
+ if (isMissingFunctionError(message, "cerefox_find_dead_links")) {
72812
+ throw systemError(SERVER_BEHIND);
72813
+ }
72814
+ throw e;
72815
+ }
72816
+ if (rows === null)
72817
+ throw systemError(SERVER_BEHIND);
72818
+ if (options.json) {
72819
+ printJson(rows);
72820
+ return;
72821
+ }
72822
+ if (rows.length === 0) {
72823
+ println(c.green("✓ No dead document links found."));
72824
+ return;
72825
+ }
72826
+ println(c.yellow(`${rows.length} dead link(s) across ${new Set(rows.map((r) => r.document_id)).size} document(s):`));
72827
+ for (const r of rows) {
72828
+ println(` ${r.document_title} (${r.document_id})`);
72829
+ println(c.dim(` → [Text](${r.dead_link_id}) ×${r.occurrences} — target no longer exists`));
72830
+ }
72831
+ println(c.dim(`Fix each by editing the linking document (correct the id, remove the link, or backtick it as an example). ` + `The write-time guard prevents NEW dead links; these predate it or lost their target to a purge.`));
72832
+ }
72833
+ function registerDocumentDeadLinks(program2) {
72834
+ program2.command("dead-links").description("Find [Text](uuid) links whose target document no longer exists (#214 phase 2).").option("--json", "Machine-readable output.").action(action8);
72835
+ }
72836
+
72800
72837
  // src/cli/commands/delete-project.ts
72801
72838
  init_cli_core();
72802
72839
  init_client();
72803
72840
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
72804
- async function action8(target, options) {
72841
+ async function action9(target, options) {
72805
72842
  const client = getClient();
72806
72843
  const isUuid = UUID_RE.test(target);
72807
72844
  const lookup = isUuid ? client.raw.from("cerefox_projects").select("id, name, description").eq("id", target).maybeSingle() : client.raw.from("cerefox_projects").select("id, name, description").eq("name", target).maybeSingle();
@@ -72837,7 +72874,7 @@ async function action8(target, options) {
72837
72874
  println(c.green(`✓ Deleted project "${project.name}" (id: ${project.id}).`));
72838
72875
  }
72839
72876
  function registerDeleteProject(program2) {
72840
- program2.command("delete-project").description("Delete an empty project (use --force to remove a non-empty one).").argument("<name-or-id>", "Project name (exact match) or UUID.").option("--yes", "Skip the confirmation prompt.").option("--force", "Allow deletion when documents are still linked to the project.").action(action8);
72877
+ program2.command("delete-project").description("Delete an empty project (use --force to remove a non-empty one).").argument("<name-or-id>", "Project name (exact match) or UUID.").option("--yes", "Skip the confirmation prompt.").option("--force", "Allow deletion when documents are still linked to the project.").action(action9);
72841
72878
  }
72842
72879
 
72843
72880
  // src/cli/commands/deploy-server.ts
@@ -73043,7 +73080,7 @@ function listEdgeFunctions(functionsDir) {
73043
73080
  return [];
73044
73081
  return readdirSync2(functionsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("cerefox-")).map((e) => e.name).sort();
73045
73082
  }
73046
- async function action9(options) {
73083
+ async function action10(options) {
73047
73084
  const settings = loadSettings();
73048
73085
  const assets = resolveServerAssets();
73049
73086
  const doSchema = !options.functionsOnly;
@@ -73258,7 +73295,7 @@ Proceed with deployment to Supabase?`, true);
73258
73295
  println(c.dim("Verify with: cerefox doctor"));
73259
73296
  }
73260
73297
  function registerDeployServer(program2) {
73261
- program2.command("deploy-server").description("Deploy/update the Cerefox server side (schema + RPCs + Edge Functions) on Supabase.").option("--dry-run", "Print the plan + pre-flight without deploying.").option("--schema-only", "Deploy/update only the schema + RPCs (skip Edge Functions).").option("--functions-only", "Deploy only the Edge Functions (skip the schema/RPCs).").option("--project-ref <ref>", "Supabase project ref for Edge Function deploys (default: derived from CEREFOX_SUPABASE_URL).").option("--yes", "Non-interactive (skip the deployment confirmation).").action(action9);
73298
+ program2.command("deploy-server").description("Deploy/update the Cerefox server side (schema + RPCs + Edge Functions) on Supabase.").option("--dry-run", "Print the plan + pre-flight without deploying.").option("--schema-only", "Deploy/update only the schema + RPCs (skip Edge Functions).").option("--functions-only", "Deploy only the Edge Functions (skip the schema/RPCs).").option("--project-ref <ref>", "Supabase project ref for Edge Function deploys (default: derived from CEREFOX_SUPABASE_URL).").option("--yes", "Non-interactive (skip the deployment confirmation).").action(action10);
73262
73299
  }
73263
73300
 
73264
73301
  // src/cli/commands/document-edit.ts
@@ -73278,7 +73315,7 @@ function parseMetaPair(pair) {
73278
73315
  }
73279
73316
  return [key, value];
73280
73317
  }
73281
- async function action10(documentId, options) {
73318
+ async function action11(documentId, options) {
73282
73319
  const hasTitle = options.title !== undefined;
73283
73320
  const sets = options.setMeta ?? [];
73284
73321
  const unsets = options.unsetMeta ?? [];
@@ -73296,18 +73333,41 @@ async function action10(documentId, options) {
73296
73333
  if (doc.deleted_at) {
73297
73334
  throw userError(`Document ${documentId} is soft-deleted — restore it first (cerefox document restore).`);
73298
73335
  }
73299
- const metadata = { ...doc.metadata ?? {} };
73300
- for (const pair of sets) {
73301
- const [k, v] = parseMetaPair(pair);
73302
- metadata[k] = v;
73303
- }
73304
- for (const k of unsets)
73305
- delete metadata[k.trim()];
73336
+ const metaTouched = sets.length > 0 || unsets.length > 0;
73306
73337
  const newTitle = hasTitle ? options.title.trim() : doc.title;
73307
73338
  const titleChanged = newTitle !== doc.title;
73308
- const { error: updErr } = await client.raw.from("cerefox_documents").update({ title: newTitle, metadata, updated_at: new Date().toISOString() }).eq("id", documentId);
73309
- if (updErr)
73310
- throw systemError(`Update failed: ${updErr.message}`);
73339
+ const author = resolveAuthor(options.author);
73340
+ const authorType = resolveAuthorType(options.authorType);
73341
+ if (author === "unknown") {
73342
+ warn("No --author / CEREFOX_AUTHOR_NAME set — audit log will record this write as 'unknown'.");
73343
+ }
73344
+ if (metaTouched) {
73345
+ const patch = {};
73346
+ for (const pair of sets) {
73347
+ const [k, v] = parseMetaPair(pair);
73348
+ patch[k] = v;
73349
+ }
73350
+ for (const k of unsets)
73351
+ patch[k.trim()] = null;
73352
+ const { error: metaErr } = await client.raw.rpc("cerefox_set_document_metadata", {
73353
+ p_document_id: documentId,
73354
+ p_metadata: patch,
73355
+ p_replace: false,
73356
+ p_author: author,
73357
+ p_author_type: authorType
73358
+ });
73359
+ if (metaErr) {
73360
+ if (metaErr.message?.includes("CEREFOX_BAD_METADATA")) {
73361
+ throw userError(`Document ${documentId} has non-object metadata; a patch cannot repair it. ` + `Repair it first with: cerefox document set-metadata ${documentId} --replace --json '<the intended object>'`);
73362
+ }
73363
+ throw systemError(`Metadata update failed: ${metaErr.message}`);
73364
+ }
73365
+ }
73366
+ if (hasTitle) {
73367
+ const { error: updErr } = await client.raw.from("cerefox_documents").update({ title: newTitle, updated_at: new Date().toISOString() }).eq("id", documentId);
73368
+ if (updErr)
73369
+ throw systemError(`Update failed: ${updErr.message}`);
73370
+ }
73311
73371
  if (titleChanged) {
73312
73372
  const { error: ftsErr } = await client.raw.rpc("cerefox_update_chunk_fts", {
73313
73373
  p_document_id: documentId,
@@ -73316,18 +73376,15 @@ async function action10(documentId, options) {
73316
73376
  if (ftsErr)
73317
73377
  throw systemError(`Title updated but FTS refresh failed: ${ftsErr.message}`);
73318
73378
  }
73319
- const author = resolveAuthor(options.author);
73320
- const authorType = resolveAuthorType(options.authorType);
73321
- if (author === "unknown") {
73322
- warn("No --author / CEREFOX_AUTHOR_NAME set — audit log will record this write as 'unknown'.");
73379
+ if (titleChanged) {
73380
+ await client.raw.rpc("cerefox_create_audit_entry", {
73381
+ p_document_id: documentId,
73382
+ p_operation: "update-metadata",
73383
+ p_author: author,
73384
+ p_author_type: authorType,
73385
+ p_description: "Edited title"
73386
+ });
73323
73387
  }
73324
- await client.raw.rpc("cerefox_create_audit_entry", {
73325
- p_document_id: documentId,
73326
- p_operation: "update-metadata",
73327
- p_author: author,
73328
- p_author_type: authorType,
73329
- p_description: `Edited${titleChanged ? " title" : ""}` + (sets.length ? ` · set ${sets.length} meta key(s)` : "") + (unsets.length ? ` · unset ${unsets.length} meta key(s)` : "")
73330
- });
73331
73388
  println(c.green(`✓ Edited "${newTitle}" (id: ${documentId}).`));
73332
73389
  if (titleChanged) {
73333
73390
  println(c.dim(" Title changed: FTS refreshed; semantic embeddings update on next `cerefox server reindex`."));
@@ -73337,13 +73394,13 @@ function collect(value, prev) {
73337
73394
  return [...prev, value];
73338
73395
  }
73339
73396
  function registerDocumentEdit(parent) {
73340
- parent.command("edit").description("Edit a document's title and/or metadata (non-destructive patch). Content edits: `document ingest --document-id <id> --update`.").argument("<document-id>", "UUID of the document.").option("--title <title>", "New title (refreshes FTS; re-embed on next reindex).").option("--set-meta <key=value>", "Set/overwrite a metadata key (repeatable). Value is JSON-parsed when possible.", collect, []).option("--unset-meta <key>", "Remove a metadata key (repeatable).", collect, []).option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action10);
73397
+ parent.command("edit").description("Edit a document's title and/or metadata (non-destructive patch). Content edits: `document ingest --document-id <id> --update`.").argument("<document-id>", "UUID of the document.").option("--title <title>", "New title (refreshes FTS; re-embed on next reindex).").option("--set-meta <key=value>", "Set/overwrite a metadata key (repeatable). Value is JSON-parsed when possible.", collect, []).option("--unset-meta <key>", "Remove a metadata key (repeatable).", collect, []).option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action11);
73341
73398
  }
73342
73399
 
73343
73400
  // src/cli/commands/document-restore.ts
73344
73401
  init_cli_core();
73345
73402
  init_client();
73346
- async function action11(documentId, options) {
73403
+ async function action12(documentId, options) {
73347
73404
  const client = getClient();
73348
73405
  const { data: doc, error } = await client.raw.from("cerefox_documents").select("id, title, deleted_at").eq("id", documentId).maybeSingle();
73349
73406
  if (error)
@@ -73397,13 +73454,13 @@ async function action11(documentId, options) {
73397
73454
  }
73398
73455
  }
73399
73456
  function registerDocumentRestore(parent) {
73400
- parent.command("restore").description("Restore a soft-deleted document from the trash (inverse of `document delete`).").argument("<document-id>", "UUID of the soft-deleted document.").option("--reason <text>", "Optional reason recorded in the audit log.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action11);
73457
+ parent.command("restore").description("Restore a soft-deleted document from the trash (inverse of `document delete`).").argument("<document-id>", "UUID of the soft-deleted document.").option("--reason <text>", "Optional reason recorded in the audit log.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action12);
73401
73458
  }
73402
73459
 
73403
73460
  // src/cli/commands/document-set-metadata.ts
73404
73461
  init_cli_core();
73405
73462
  init_client();
73406
- async function action12(documentId, options) {
73463
+ async function action13(documentId, options) {
73407
73464
  const patch = {};
73408
73465
  if (options.json) {
73409
73466
  let parsed;
@@ -73475,14 +73532,14 @@ async function action12(documentId, options) {
73475
73532
  println(c.dim(" Content untouched — no new version, no re-embedding."));
73476
73533
  }
73477
73534
  function registerDocumentSetMetadata(parent) {
73478
- parent.command("set-metadata").description("Change a document's metadata without resending its content (merges by default).").argument("<document-id>", "UUID of the document.").option("-s, --set <key=value...>", "Set a key. Repeatable. Values are stored as JSON strings; quote to force JSON parsing.").option("-r, --remove <key...>", "Remove a key. Repeatable. (Sends a JSON null.)").option("--json <object>", "A JSON object of keys to set; a null value removes that key.").option("--replace", "Set the metadata to EXACTLY what was given, discarding every key not listed. Default is merge.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").option("--json-out", "Emit the result as JSON.").action(action12);
73535
+ parent.command("set-metadata").description("Change a document's metadata without resending its content (merges by default).").argument("<document-id>", "UUID of the document.").option("-s, --set <key=value...>", "Set a key. Repeatable. Values are stored as JSON strings; quote to force JSON parsing.").option("-r, --remove <key...>", "Remove a key. Repeatable. (Sends a JSON null.)").option("--json <object>", "A JSON object of keys to set; a null value removes that key.").option("--replace", "Set the metadata to EXACTLY what was given, discarding every key not listed. Default is merge.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").option("--json-out", "Emit the result as JSON.").action(action13);
73479
73536
  }
73480
73537
 
73481
73538
  // src/cli/commands/document-set-projects.ts
73482
73539
  init_cli_core();
73483
73540
  init__projects();
73484
73541
  init_client();
73485
- async function action13(documentId, projectNames, options) {
73542
+ async function action14(documentId, projectNames, options) {
73486
73543
  const names = projectNames ?? [];
73487
73544
  if (options.clear && names.length > 0) {
73488
73545
  throw userError("Pass either project names or --clear, not both.", "Use --clear on its own to remove the document from all projects.");
@@ -73512,7 +73569,7 @@ async function action13(documentId, projectNames, options) {
73512
73569
  println(c.dim(" This REPLACED the previous set — any project not listed is no longer associated."));
73513
73570
  }
73514
73571
  function registerDocumentSetProjects(parent) {
73515
- parent.command("set-projects").description("Replace a document's project memberships with exactly the given set (or --clear to remove all).").argument("<document-id>", "UUID of the document.").argument("[project-names...]", "Project names to set (created if missing). Omit and pass --clear to remove all.").option("--clear", "Remove the document from all projects.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action13);
73572
+ parent.command("set-projects").description("Replace a document's project memberships with exactly the given set (or --clear to remove all).").argument("<document-id>", "UUID of the document.").argument("[project-names...]", "Project names to set (created if missing). Omit and pass --clear to remove all.").option("--clear", "Remove the document from all projects.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action14);
73516
73573
  }
73517
73574
 
73518
73575
  // src/cli/commands/guides.ts
@@ -73562,7 +73619,7 @@ function registerGuides(parent) {
73562
73619
  // src/cli/commands/project-create.ts
73563
73620
  init_cli_core();
73564
73621
  init_client();
73565
- async function action14(name, options) {
73622
+ async function action15(name, options) {
73566
73623
  const trimmed = name.trim();
73567
73624
  if (!trimmed)
73568
73625
  throw userError("Project name is required.");
@@ -73574,14 +73631,14 @@ async function action14(name, options) {
73574
73631
  println(c.green(`✓ Created project "${data.name}" (id: ${data.id}).`));
73575
73632
  }
73576
73633
  function registerProjectCreate(parent) {
73577
- parent.command("create").description("Create a new (empty) project.").argument("<name>", "Project name (must be unique).").option("--description <text>", "Optional project description.").action(action14);
73634
+ parent.command("create").description("Create a new (empty) project.").argument("<name>", "Project name (must be unique).").option("--description <text>", "Optional project description.").action(action15);
73578
73635
  }
73579
73636
 
73580
73637
  // src/cli/commands/project-edit.ts
73581
73638
  init_cli_core();
73582
73639
  init_client();
73583
73640
  var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
73584
- async function action15(target, options) {
73641
+ async function action16(target, options) {
73585
73642
  const update = {};
73586
73643
  if (options.name !== undefined) {
73587
73644
  const n = options.name.trim();
@@ -73608,7 +73665,7 @@ async function action15(target, options) {
73608
73665
  println(c.green(`✓ Updated project "${data.name}" (id: ${data.id}).`));
73609
73666
  }
73610
73667
  function registerProjectEdit(parent) {
73611
- parent.command("edit").description("Rename a project and/or change its description.").argument("<name-or-id>", "Project name (exact match) or UUID.").option("--name <new-name>", "New project name.").option("--description <text>", "New project description.").action(action15);
73668
+ parent.command("edit").description("Rename a project and/or change its description.").argument("<name-or-id>", "Project name (exact match) or UUID.").option("--name <new-name>", "New project name.").option("--description <text>", "New project description.").action(action16);
73612
73669
  }
73613
73670
 
73614
73671
  // src/cli/commands/version-archive.ts
@@ -77430,14 +77487,9 @@ async function checkEmbedderMismatch() {
77430
77487
  };
77431
77488
  }
77432
77489
  }
77433
- var CONTENT_FORMAT_CHECK_NAME = "content format";
77434
- async function checkContentFormat() {
77435
- const settings = loadSettings();
77436
- if (!settings.supabaseUrl || !settings.supabaseKey) {
77437
- return { name: CONTENT_FORMAT_CHECK_NAME, status: "skipped", detail: "Supabase config missing; skipped." };
77438
- }
77490
+ async function probeRpcJson(settings, fn) {
77439
77491
  try {
77440
- const url = `${settings.supabaseUrl.replace(/\/$/, "")}/rest/v1/rpc/cerefox_content_format_stats`;
77492
+ const url = `${settings.supabaseUrl.replace(/\/$/, "")}/rest/v1/rpc/${fn}`;
77441
77493
  const resp = await fetch(url, {
77442
77494
  method: "POST",
77443
77495
  headers: {
@@ -77447,14 +77499,38 @@ async function checkContentFormat() {
77447
77499
  },
77448
77500
  body: "{}"
77449
77501
  });
77450
- if (!resp.ok) {
77502
+ if (resp.status === 404)
77503
+ return { kind: "absent" };
77504
+ if (!resp.ok)
77505
+ return { kind: "error", detail: `HTTP ${resp.status}` };
77506
+ return { kind: "ok", rows: await resp.json() };
77507
+ } catch (err) {
77508
+ return { kind: "error", detail: err instanceof Error ? err.message : String(err) };
77509
+ }
77510
+ }
77511
+ var CONTENT_FORMAT_CHECK_NAME = "content format";
77512
+ async function checkContentFormat() {
77513
+ const settings = loadSettings();
77514
+ if (!settings.supabaseUrl || !settings.supabaseKey) {
77515
+ return { name: CONTENT_FORMAT_CHECK_NAME, status: "skipped", detail: "Supabase config missing; skipped." };
77516
+ }
77517
+ {
77518
+ const probe = await probeRpcJson(settings, "cerefox_content_format_stats");
77519
+ if (probe.kind === "absent") {
77451
77520
  return {
77452
77521
  name: CONTENT_FORMAT_CHECK_NAME,
77453
77522
  status: "skipped",
77454
- detail: `format stats unavailable (${resp.status}); deploy schema 0.8.0 to enable.`
77523
+ detail: "format stats unavailable; deploy schema 0.8.0 to enable."
77455
77524
  };
77456
77525
  }
77457
- const rows = await resp.json();
77526
+ if (probe.kind === "error") {
77527
+ return {
77528
+ name: CONTENT_FORMAT_CHECK_NAME,
77529
+ status: "skipped",
77530
+ detail: `content-format check skipped (${probe.detail}).`
77531
+ };
77532
+ }
77533
+ const rows = probe.rows;
77458
77534
  const legacy = rows[0]?.legacy_docs ?? 0;
77459
77535
  const total = rows[0]?.total_docs ?? 0;
77460
77536
  if (legacy === 0) {
@@ -77470,13 +77546,39 @@ async function checkContentFormat() {
77470
77546
  detail: `${legacy} of ${total} document(s) use the legacy reconstruction format (format 1).`,
77471
77547
  hint: "Harmless — they auto-convert on next edit. To convert them all now: `cerefox server migrate-format` (re-embeds, so try `--dry-run` first). To read what chunk formats are: `cerefox guides show content-format`."
77472
77548
  };
77473
- } catch (err) {
77549
+ }
77550
+ }
77551
+ var METADATA_HEALTH_CHECK_NAME = "metadata health";
77552
+ async function checkMetadataHealth() {
77553
+ const settings = loadSettings();
77554
+ if (!settings.supabaseUrl || !settings.supabaseKey) {
77555
+ return { name: METADATA_HEALTH_CHECK_NAME, status: "skipped", detail: "Supabase config missing; skipped." };
77556
+ }
77557
+ const probe = await probeRpcJson(settings, "cerefox_metadata_health");
77558
+ if (probe.kind === "absent") {
77474
77559
  return {
77475
- name: CONTENT_FORMAT_CHECK_NAME,
77560
+ name: METADATA_HEALTH_CHECK_NAME,
77561
+ status: "skipped",
77562
+ detail: "metadata-health RPC not deployed; deploy schema 0.12.2 to enable."
77563
+ };
77564
+ }
77565
+ if (probe.kind === "error") {
77566
+ return {
77567
+ name: METADATA_HEALTH_CHECK_NAME,
77476
77568
  status: "skipped",
77477
- detail: `content-format check skipped: ${err instanceof Error ? err.message : String(err)}`
77569
+ detail: `metadata-health check skipped (${probe.detail}).`
77478
77570
  };
77479
77571
  }
77572
+ if (probe.rows.length === 0) {
77573
+ return { name: METADATA_HEALTH_CHECK_NAME, status: "ok", detail: "all document metadata is well-formed" };
77574
+ }
77575
+ const sample = probe.rows.slice(0, 3).map((r) => `"${r.document_title}" (${r.metadata_type})`).join(", ");
77576
+ return {
77577
+ name: METADATA_HEALTH_CHECK_NAME,
77578
+ status: "skipped",
77579
+ detail: `${probe.rows.length} document(s) hold non-object metadata: ${sample}${probe.rows.length > 3 ? ", …" : ""}.`,
77580
+ hint: "Writes that would merge onto these rows are refused (#212). Repair each with `cerefox document set-metadata <id> --replace --json '<the intended object>'`."
77581
+ };
77480
77582
  }
77481
77583
  function hasCerefoxInJsonFile(path) {
77482
77584
  if (!existsSync10(path))
@@ -77687,6 +77789,7 @@ async function runAllChecks(opts = {}) {
77687
77789
  { name: "schema + RPCs", phase: "Reading schema + RPC version", run: () => checkSchemaVersion() },
77688
77790
  { name: "embedder", phase: "Checking embedder consistency", run: () => checkEmbedderMismatch() },
77689
77791
  { name: "content format", phase: "Checking chunk reconstruction format", run: () => checkContentFormat() },
77792
+ { name: "metadata health", phase: "Checking metadata well-formedness", run: () => checkMetadataHealth() },
77690
77793
  { name: "edge functions", phase: "Probing Edge Function versions", run: () => checkEdgeFunctionsCompat() },
77691
77794
  { name: "postgres", phase: "Probing Postgres DDL endpoint", run: () => checkPostgres() },
77692
77795
  { name: "mcp clients", phase: "Scanning MCP client configs", run: () => checkMcpConfigs() }
@@ -77715,7 +77818,7 @@ function symbol(status) {
77715
77818
  return cErr.dim("ℹ");
77716
77819
  }
77717
77820
  }
77718
- async function action16(options) {
77821
+ async function action17(options) {
77719
77822
  const useSpinner = !options.json && process.stderr.isTTY;
77720
77823
  const spinner = useSpinner ? ora({ text: "Starting checks…", spinner: "dots", stream: process.stderr }).start() : null;
77721
77824
  const results = await runAllChecks({
@@ -77779,13 +77882,13 @@ async function action16(options) {
77779
77882
  process.exit(1);
77780
77883
  }
77781
77884
  function registerDoctor(program2) {
77782
- program2.command("doctor").description("Run diagnostic checks against the installed Cerefox.").option("--json", "Emit machine-readable JSON (no colours, structured output).").option("--strict", "Exit non-zero when any check warns (default: only errors fail).").action(action16);
77885
+ program2.command("doctor").description("Run diagnostic checks against the installed Cerefox.").option("--json", "Emit machine-readable JSON (no colours, structured output).").option("--strict", "Exit non-zero when any check warns (default: only errors fail).").action(action17);
77783
77886
  }
77784
77887
 
77785
77888
  // src/cli/commands/get-audit-log.ts
77786
77889
  init_cli_core();
77787
77890
  init_client();
77788
- async function action17(options) {
77891
+ async function action18(options) {
77789
77892
  const limit = parsePositiveInt(options.limit, "--limit", 50);
77790
77893
  const client = getClient();
77791
77894
  const data = await client.rpc("cerefox_list_audit_entries", {
@@ -77823,7 +77926,7 @@ async function action17(options) {
77823
77926
  })));
77824
77927
  }
77825
77928
  function registerGetAuditLog(program2) {
77826
- program2.command("get-audit-log").description("Query the audit log with optional filters.").option("-d, --document-id <uuid>", "Filter by document.").option("-a, --author <name>", "Filter by author.").option("-o, --operation <type>", "Filter by operation: create, update-content, update-metadata, delete, restore.").option("--since <iso>", "Lower-bound ISO timestamp.").option("--until <iso>", "Upper-bound ISO timestamp.").option("-l, --limit <n>", "Maximum entries (max 200).", "50").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action17);
77929
+ program2.command("get-audit-log").description("Query the audit log with optional filters.").option("-d, --document-id <uuid>", "Filter by document.").option("-a, --author <name>", "Filter by author.").option("-o, --operation <type>", "Filter by operation: create, update-content, update-metadata, delete, restore.").option("--since <iso>", "Lower-bound ISO timestamp.").option("--until <iso>", "Upper-bound ISO timestamp.").option("-l, --limit <n>", "Maximum entries (max 200).", "50").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action18);
77827
77930
  }
77828
77931
 
77829
77932
  // src/cli/commands/relation.ts
@@ -77959,7 +78062,7 @@ init_cli_core();
77959
78062
  init_cli_core();
77960
78063
  init_partial_edits();
77961
78064
  init_client();
77962
- async function action18(documentId, options) {
78065
+ async function action19(documentId, options) {
77963
78066
  const section = (options.section ?? "").trim() || null;
77964
78067
  if (section && options.outline) {
77965
78068
  throw userError("Pass either --outline (the whole structure) or --section (one section's text), not both.");
@@ -78056,7 +78159,7 @@ async function action18(documentId, options) {
78056
78159
  println(doc.full_content);
78057
78160
  }
78058
78161
  function registerGetDoc(program2) {
78059
- program2.command("get-doc").description("Retrieve the full content of a document by ID.").argument("<document-id>", "UUID of the document.").option("--version-id <uuid>", "Specific archived version (default: current).").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").option("--outline", "Show the heading structure, per-section sizes and content_hash instead of the content. Cheap, and the paths are the anchors the edit commands take.").option("--section <anchor>", "Show ONE section's text instead of the whole document: exactly what a replace_section on this anchor would overwrite. Pass the bare heading line when it is unique, or the full ' > ' path from --outline when it repeats.").option("--section-part <part>", "own_body | subtree — only when the target section has child sections, where 'the end' means two different places. You are told (with both options) whenever it is needed.").action(action18);
78162
+ program2.command("get-doc").description("Retrieve the full content of a document by ID.").argument("<document-id>", "UUID of the document.").option("--version-id <uuid>", "Specific archived version (default: current).").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").option("--outline", "Show the heading structure, per-section sizes and content_hash instead of the content. Cheap, and the paths are the anchors the edit commands take.").option("--section <anchor>", "Show ONE section's text instead of the whole document: exactly what a replace_section on this anchor would overwrite. Pass the bare heading line when it is unique, or the full ' > ' path from --outline when it repeats.").option("--section-part <part>", "own_body | subtree — only when the target section has child sections, where 'the end' means two different places. You are told (with both options) whenever it is needed.").action(action19);
78060
78163
  }
78061
78164
 
78062
78165
  // src/cli/commands/ingest.ts
@@ -78720,7 +78823,7 @@ async function readContent(path, paste) {
78720
78823
  const titleFromPath = basename2(path, extname3(path));
78721
78824
  return { content, titleFromPath };
78722
78825
  }
78723
- async function action19(path, options) {
78826
+ async function action20(path, options) {
78724
78827
  const { content, titleFromPath } = await readContent(path, Boolean(options.paste));
78725
78828
  const updatingById = Boolean(options.documentId);
78726
78829
  let title = options.title ?? (updatingById ? null : titleFromPath);
@@ -78811,7 +78914,7 @@ async function action19(path, options) {
78811
78914
  }
78812
78915
  }
78813
78916
  function registerIngest(program2) {
78814
- program2.command("ingest").description("Ingest a file (or stdin paste) into the knowledge base.").argument("[path]", "Path to the file to ingest. Omit when using --paste.").option("--paste", "Read content from stdin instead of a file.").option("-t, --title <title>", "Document title (required with --paste; defaults to filename without extension).").option("-p, --project-name <name>", "Single project membership (non-destructive on update).").option("-P, --project-names <names>", "Comma-separated full project membership set (destructive replace on update).").option("-m, --metadata <json>", "JSON metadata object.").option("--source <label>", 'Origin label. Omit it on an update and the document keeps the source it already has (#193); omit it on a create and it is recorded as "cli".').option("-u, --update-if-exists", "Update an existing doc with the same title.").option("-i, --document-id <uuid>", "Update a specific document by UUID (overrides --update-if-exists).").option("--expected-content-hash <sha256>", "Optimistic-concurrency token: the content_hash of the version this edit is based on (shown by `document get` / `search`). Required on content updates unless --last-write-wins.").option("--last-write-wins", "Skip the concurrency check and overwrite regardless of concurrent changes (recorded in the audit log).").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action19);
78917
+ program2.command("ingest").description("Ingest a file (or stdin paste) into the knowledge base.").argument("[path]", "Path to the file to ingest. Omit when using --paste.").option("--paste", "Read content from stdin instead of a file.").option("-t, --title <title>", "Document title (required with --paste; defaults to filename without extension).").option("-p, --project-name <name>", "Single project membership (non-destructive on update).").option("-P, --project-names <names>", "Comma-separated full project membership set (destructive replace on update).").option("-m, --metadata <json>", "JSON metadata object.").option("--source <label>", 'Origin label. Omit it on an update and the document keeps the source it already has (#193); omit it on a create and it is recorded as "cli".').option("-u, --update-if-exists", "Update an existing doc with the same title.").option("-i, --document-id <uuid>", "Update a specific document by UUID (overrides --update-if-exists).").option("--expected-content-hash <sha256>", "Optimistic-concurrency token: the content_hash of the version this edit is based on (shown by `document get` / `search`). Required on content updates unless --last-write-wins.").option("--last-write-wins", "Skip the concurrency check and overwrite regardless of concurrent changes (recorded in the audit log).").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").action(action20);
78815
78918
  }
78816
78919
 
78817
78920
  // src/cli/commands/document-partial-edit.ts
@@ -78914,7 +79017,7 @@ function walk(dir, extensions) {
78914
79017
  }
78915
79018
  return files;
78916
79019
  }
78917
- async function action20(dir, options) {
79020
+ async function action21(dir, options) {
78918
79021
  const extensions = new Set((options.extensions ?? ".md,.txt").split(",").map((e) => e.trim().toLowerCase()).map((e) => e.startsWith(".") ? e : "." + e).filter((e) => e.length > 0));
78919
79022
  const files = walk(dir, extensions);
78920
79023
  if (files.length === 0) {
@@ -78989,7 +79092,7 @@ async function action20(dir, options) {
78989
79092
  }
78990
79093
  }
78991
79094
  function registerIngestDir(program2) {
78992
- program2.command("ingest-dir").description("Recursively ingest a directory of markdown / text files.").argument("<dir>", "Root directory to walk.").option("-p, --project-name <name>", "Project membership for all ingested docs.").option("-m, --metadata <json>", "JSON metadata applied to every doc.").option("--source <label>", 'Origin label. Omit it and each matched document keeps the source it already has (#193); newly created ones are recorded as "cli".').option("-u, --update-if-exists", "Update an existing doc with the same title.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").option("-e, --extensions <list>", "Comma-separated file extensions to ingest.", ".md,.txt").action(action20);
79095
+ program2.command("ingest-dir").description("Recursively ingest a directory of markdown / text files.").argument("<dir>", "Root directory to walk.").option("-p, --project-name <name>", "Project membership for all ingested docs.").option("-m, --metadata <json>", "JSON metadata applied to every doc.").option("--source <label>", 'Origin label. Omit it and each matched document keeps the source it already has (#193); newly created ones are recorded as "cli".').option("-u, --update-if-exists", "Update an existing doc with the same title.").option("-a, --author <name>", "Caller identity (audit log).").option("--author-type <type>", "'user' or 'agent' (default: user).", "user").option("-e, --extensions <list>", "Comma-separated file extensions to ingest.", ".md,.txt").action(action21);
78993
79096
  }
78994
79097
 
78995
79098
  // src/cli/commands/init.ts
@@ -79320,7 +79423,7 @@ function writeAnswersTo(target, answers) {
79320
79423
  }
79321
79424
  }
79322
79425
  }
79323
- async function action22(options) {
79426
+ async function action23(options) {
79324
79427
  const homeEnv = join11(homedir7(), USER_STATE_DIR_NAME, ".env");
79325
79428
  const cwdEnv = join11(process.cwd(), ".env");
79326
79429
  const explicitDir = (process.env.CEREFOX_CONFIG_DIR ?? "").trim();
@@ -79429,13 +79532,13 @@ async function action22(options) {
79429
79532
  await postWriteLifecycle(target, options);
79430
79533
  }
79431
79534
  function registerInit(program2) {
79432
- program2.command("init").description("Interactive first-run setup (config, schema deploy stub, optional MCP wiring).").option("-c, --config <file>", "Non-interactive mode: read answers from a JSON file.").option("--force", "Overwrite existing configuration without prompting.").option("--skip-schema", "Skip the schema deploy step.").option("--skip-self-docs", "Skip the bundled self-doc ingest.").option("--skip-agent-config", "Skip the optional MCP agent wiring.").action(action22);
79535
+ program2.command("init").description("Interactive first-run setup (config, schema deploy stub, optional MCP wiring).").option("-c, --config <file>", "Non-interactive mode: read answers from a JSON file.").option("--force", "Overwrite existing configuration without prompting.").option("--skip-schema", "Skip the schema deploy step.").option("--skip-self-docs", "Skip the bundled self-doc ingest.").option("--skip-agent-config", "Skip the optional MCP agent wiring.").action(action23);
79433
79536
  }
79434
79537
 
79435
79538
  // src/cli/commands/list-docs.ts
79436
79539
  init_cli_core();
79437
79540
  init_client();
79438
- async function action23(options) {
79541
+ async function action24(options) {
79439
79542
  const deleted = !!options.deleted;
79440
79543
  const limit = parsePositiveInt(options.limit, "--limit", 100);
79441
79544
  const client = getClient();
@@ -79491,13 +79594,13 @@ async function action23(options) {
79491
79594
  }));
79492
79595
  }
79493
79596
  function registerListDocs(program2) {
79494
- program2.command("list-docs").description("List documents in the knowledge base.").option("-p, --project <name>", "Filter to a specific project.").option("-l, --limit <n>", "Maximum docs to return.", "100").option("--deleted", "List soft-deleted (trashed) documents instead of active ones.").option("--json", "Emit machine-readable JSON.").action(action23);
79597
+ program2.command("list-docs").description("List documents in the knowledge base.").option("-p, --project <name>", "Filter to a specific project.").option("-l, --limit <n>", "Maximum docs to return.", "100").option("--deleted", "List soft-deleted (trashed) documents instead of active ones.").option("--json", "Emit machine-readable JSON.").action(action24);
79495
79598
  }
79496
79599
 
79497
79600
  // src/cli/commands/list-metadata-keys.ts
79498
79601
  init_cli_core();
79499
79602
  init_client();
79500
- async function action24(options) {
79603
+ async function action25(options) {
79501
79604
  const client = getClient();
79502
79605
  const data = await client.rpc("cerefox_list_metadata_keys");
79503
79606
  if (data === null) {
@@ -79525,13 +79628,13 @@ async function action24(options) {
79525
79628
  })));
79526
79629
  }
79527
79630
  function registerListMetadataKeys(program2) {
79528
- program2.command("list-metadata-keys").description("List all metadata keys with document counts and example values.").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action24);
79631
+ program2.command("list-metadata-keys").description("List all metadata keys with document counts and example values.").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action25);
79529
79632
  }
79530
79633
 
79531
79634
  // src/cli/commands/list-projects.ts
79532
79635
  init_cli_core();
79533
79636
  init_client();
79534
- async function action25(options) {
79637
+ async function action26(options) {
79535
79638
  const client = getClient();
79536
79639
  const { data, error: error2 } = await client.raw.from("cerefox_projects").select("id, name, description, created_at").order("name", { ascending: true });
79537
79640
  if (error2) {
@@ -79560,13 +79663,13 @@ async function action25(options) {
79560
79663
  })), "(no projects)");
79561
79664
  }
79562
79665
  function registerListProjects(program2) {
79563
- program2.command("list-projects").description("List all projects in the knowledge base.").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action25);
79666
+ program2.command("list-projects").description("List all projects in the knowledge base.").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action26);
79564
79667
  }
79565
79668
 
79566
79669
  // src/cli/commands/list-versions.ts
79567
79670
  init_cli_core();
79568
79671
  init_client();
79569
- async function action26(documentId, options) {
79672
+ async function action27(documentId, options) {
79570
79673
  const client = getClient();
79571
79674
  const data = await client.rpc("cerefox_list_document_versions", {
79572
79675
  p_document_id: documentId
@@ -79607,7 +79710,7 @@ async function action26(documentId, options) {
79607
79710
  })));
79608
79711
  }
79609
79712
  function registerListVersions(program2) {
79610
- program2.command("list-versions").description("List archived versions of a document.").argument("<document-id>", "UUID of the document.").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action26);
79713
+ program2.command("list-versions").description("List archived versions of a document.").argument("<document-id>", "UUID of the document.").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action27);
79611
79714
  }
79612
79715
 
79613
79716
  // src/cli/commands/mcp.ts
@@ -79639,7 +79742,7 @@ function registerEmbedderWarmup(program2) {
79639
79742
  // src/cli/commands/metadata-search.ts
79640
79743
  init_cli_core();
79641
79744
  init_client();
79642
- async function action27(options) {
79745
+ async function action28(options) {
79643
79746
  const metadataFilter = parseJsonObjectArg(options.metadataFilter, "--metadata-filter") ?? {};
79644
79747
  if (Object.keys(metadataFilter).length === 0 && !options.projectName && !options.updatedSince && !options.createdSince) {
79645
79748
  throw userError("Provide at least one of: --metadata-filter, --project-name, --updated-since, or --created-since.", `Examples: --metadata-filter '{"type":"decision-log"}' · --project-name "research" (lists that project's docs).`);
@@ -79702,7 +79805,7 @@ async function action27(options) {
79702
79805
  }
79703
79806
  }
79704
79807
  function registerMetadataSearch(program2) {
79705
- program2.command("metadata-search").description("Find or list documents by metadata, project, or time criteria (no text query).").option("-f, --metadata-filter <json>", "JSON object; only docs whose metadata contains ALL pairs are returned. Optional — omit to list by --project-name / time range alone (at least one criterion is required).").option("-p, --project-name <name>", "Filter to a specific project.").option("--updated-since <iso>", "Only docs updated on/after this ISO timestamp.").option("--created-since <iso>", "Only docs created on/after this ISO timestamp.").option("--include-content", "Include full document text in results.").option("-l, --limit <n>", "Maximum docs to return.", "10").option("--max-bytes <n>", "Response size budget in bytes (with --include-content).", "200000").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action27);
79808
+ program2.command("metadata-search").description("Find or list documents by metadata, project, or time criteria (no text query).").option("-f, --metadata-filter <json>", "JSON object; only docs whose metadata contains ALL pairs are returned. Optional — omit to list by --project-name / time range alone (at least one criterion is required).").option("-p, --project-name <name>", "Filter to a specific project.").option("--updated-since <iso>", "Only docs updated on/after this ISO timestamp.").option("--created-since <iso>", "Only docs created on/after this ISO timestamp.").option("--include-content", "Include full document text in results.").option("-l, --limit <n>", "Maximum docs to return.", "10").option("--max-bytes <n>", "Response size budget in bytes (with --include-content).", "200000").option("-r, --requestor <name>", "Agent / user name (usage log).").option("--json", "Emit machine-readable JSON.").action(action28);
79706
79809
  }
79707
79810
 
79708
79811
  // src/cli/commands/reindex.ts
@@ -79728,7 +79831,7 @@ function warnLargeBulkWrite(opts) {
79728
79831
  }
79729
79832
 
79730
79833
  // src/cli/commands/reindex.ts
79731
- async function action28(options) {
79834
+ async function action29(options) {
79732
79835
  const settings = loadSettings();
79733
79836
  if (!settings.supabaseUrl || !settings.supabaseKey) {
79734
79837
  throw userError("Supabase credentials not configured — run `cerefox init` first.");
@@ -79818,7 +79921,7 @@ ${c2.content}`;
79818
79921
  }
79819
79922
  }
79820
79923
  function registerReindex(program2) {
79821
- program2.command("reindex").description("Re-embed existing document chunks (v0.7+).").option("--all", "Reindex every chunk regardless of embedder.").option("--batch <n>", "Chunks per OpenAI batch call. Capped at 96 internally.", "32").option("--dry-run", "Show counts without re-embedding.").option("-i, --document-id <uuid>", "Limit reindex to a single document.").action(action28);
79924
+ program2.command("reindex").description("Re-embed existing document chunks (v0.7+).").option("--all", "Reindex every chunk regardless of embedder.").option("--batch <n>", "Chunks per OpenAI batch call. Capped at 96 internally.", "32").option("--dry-run", "Show counts without re-embedding.").option("-i, --document-id <uuid>", "Limit reindex to a single document.").action(action29);
79822
79925
  }
79823
79926
 
79824
79927
  // src/cli/commands/migrate-format.ts
@@ -79826,7 +79929,7 @@ init_cli_core();
79826
79929
  init_config();
79827
79930
  init_client();
79828
79931
  var CURRENT_FORMAT = 2;
79829
- async function action29(options) {
79932
+ async function action30(options) {
79830
79933
  const settings = loadSettings();
79831
79934
  const client = getClient();
79832
79935
  const supabase = client.raw;
@@ -79953,7 +80056,7 @@ async function action29(options) {
79953
80056
  }
79954
80057
  }
79955
80058
  function registerMigrateFormat(program2) {
79956
- program2.command("migrate-format").description("Convert legacy-format documents to the current chunk format (re-chunks + re-embeds).").option("--dry-run", "Report how many documents would be converted; write nothing.").option("-l, --limit <n>", "Convert at most N documents (re-run to continue).").option("--document-id <uuid>", "Convert a single document.").option("--author <name>", "Recorded in the audit log for each conversion.").action(action29);
80059
+ program2.command("migrate-format").description("Convert legacy-format documents to the current chunk format (re-chunks + re-embeds).").option("--dry-run", "Report how many documents would be converted; write nothing.").option("-l, --limit <n>", "Convert at most N documents (re-run to continue).").option("--document-id <uuid>", "Convert a single document.").option("--author <name>", "Recorded in the audit log for each conversion.").action(action30);
79957
80060
  }
79958
80061
 
79959
80062
  // src/cli/commands/restore.ts
@@ -79983,7 +80086,7 @@ function resolveBackupFile(target) {
79983
80086
  }
79984
80087
  return join12(path, candidates[0].name);
79985
80088
  }
79986
- async function action30(target, options) {
80089
+ async function action31(target, options) {
79987
80090
  const file = resolveBackupFile(target);
79988
80091
  let payload;
79989
80092
  try {
@@ -80118,7 +80221,7 @@ async function action30(target, options) {
80118
80221
  }
80119
80222
  }
80120
80223
  function registerRestore(program2) {
80121
- program2.command("restore").description("Restore a JSON-snapshot backup into the knowledge base.").argument("<snapshot>", "Backup file (or directory; most recent is picked) produced by `cerefox backup`.").option("--dry-run", "Print what would be restored without writing.").option("-p, --project-name <name>", "Reserved for future use; currently ignored. Project memberships are restored from the backup itself (format 2+).").action(action30);
80224
+ program2.command("restore").description("Restore a JSON-snapshot backup into the knowledge base.").argument("<snapshot>", "Backup file (or directory; most recent is picked) produced by `cerefox backup`.").option("--dry-run", "Print what would be restored without writing.").option("-p, --project-name <name>", "Reserved for future use; currently ignored. Project memberships are restored from the backup itself (format 2+).").action(action31);
80122
80225
  }
80123
80226
 
80124
80227
  // src/cli/commands/search.ts
@@ -80143,7 +80246,7 @@ async function embedQuery(query) {
80143
80246
  }
80144
80247
 
80145
80248
  // src/cli/commands/search.ts
80146
- async function action31(query, options) {
80249
+ async function action32(query, options) {
80147
80250
  if (!query || query.trim() === "") {
80148
80251
  throw userError("Empty query.");
80149
80252
  }
@@ -80309,7 +80412,7 @@ async function action31(query, options) {
80309
80412
  }
80310
80413
  }
80311
80414
  function registerSearch(program2) {
80312
- program2.command("search").description("Search the knowledge base (hybrid FTS + semantic).").argument("<query>", "Natural-language search query.").option("-c, --match-count <n>", "Maximum number of documents to return.", "5").option("-p, --project-name <name>", "Filter results to a specific project.").option("-f, --metadata-filter <json>", "JSON containment filter; only docs whose metadata contains ALL pairs are returned.").option("--mode <mode>", "Search mode: docs (default), hybrid, fts.", "docs").option("--alpha <float>", "Semantic weight 0..1 (default: CEREFOX_SEARCH_ALPHA; else 0.7).").option("--min-score <float>", "Minimum cosine similarity threshold (default: CEREFOX_MIN_SEARCH_SCORE; else 0.5, or 0.6 with the local embedder).").option("--min-term-coverage <float>", "OR-fallback keyword matches must cover at least this fraction of the query's meaningful terms to count as confident hits (default: CEREFOX_MIN_TERM_COVERAGE; else the server default 0.5; needs schema ≥ 0.9.1).").option("--max-bytes <n>", "Response size budget in bytes (default: CEREFOX_MAX_RESPONSE_BYTES or 200000).").option("-r, --requestor <name>", "Agent / user name (recorded in usage log).").option("--json", "Emit machine-readable JSON instead of the default text.").option("--only-metadata", "List matching docs (id, score, chunks, chars, partial/full) WITHOUT their content — like the web UI's collapsed result list. Grab a [id:…] then `cerefox document get <id>`.").action(action31);
80415
+ program2.command("search").description("Search the knowledge base (hybrid FTS + semantic).").argument("<query>", "Natural-language search query.").option("-c, --match-count <n>", "Maximum number of documents to return.", "5").option("-p, --project-name <name>", "Filter results to a specific project.").option("-f, --metadata-filter <json>", "JSON containment filter; only docs whose metadata contains ALL pairs are returned.").option("--mode <mode>", "Search mode: docs (default), hybrid, fts.", "docs").option("--alpha <float>", "Semantic weight 0..1 (default: CEREFOX_SEARCH_ALPHA; else 0.7).").option("--min-score <float>", "Minimum cosine similarity threshold (default: CEREFOX_MIN_SEARCH_SCORE; else 0.5, or 0.6 with the local embedder).").option("--min-term-coverage <float>", "OR-fallback keyword matches must cover at least this fraction of the query's meaningful terms to count as confident hits (default: CEREFOX_MIN_TERM_COVERAGE; else the server default 0.5; needs schema ≥ 0.9.1).").option("--max-bytes <n>", "Response size budget in bytes (default: CEREFOX_MAX_RESPONSE_BYTES or 200000).").option("-r, --requestor <name>", "Agent / user name (recorded in usage log).").option("--json", "Emit machine-readable JSON instead of the default text.").option("--only-metadata", "List matching docs (id, score, chunks, chars, partial/full) WITHOUT their content — like the web UI's collapsed result list. Grab a [id:…] then `cerefox document get <id>`.").action(action32);
80313
80416
  }
80314
80417
 
80315
80418
  // src/cli/commands/self-update.ts
@@ -80356,7 +80459,7 @@ async function fetchLatestVersion() {
80356
80459
  }
80357
80460
  return body.version;
80358
80461
  }
80359
- async function action32(options) {
80462
+ async function action33(options) {
80360
80463
  let target;
80361
80464
  try {
80362
80465
  target = options.version ?? await fetchLatestVersion();
@@ -80409,7 +80512,7 @@ async function action32(options) {
80409
80512
  }
80410
80513
  function registerSelfUpdate(program2) {
80411
80514
  const desc = "Upgrade Cerefox in place. Alias: `cerefox upgrade`.";
80412
- const declaration = (cmd) => cmd.description(desc).option("--check", "Print current vs latest; do nothing.").option("--yes", "Non-interactive (skip confirmation).").option("--version <version>", "Pin a specific version (e.g. 0.5.1 or 0.6.0-rc.1).").action(action32);
80515
+ const declaration = (cmd) => cmd.description(desc).option("--check", "Print current vs latest; do nothing.").option("--yes", "Non-interactive (skip confirmation).").option("--version <version>", "Pin a specific version (e.g. 0.5.1 or 0.6.0-rc.1).").action(action33);
80413
80516
  declaration(program2.command("self-update"));
80414
80517
  declaration(program2.command("upgrade"));
80415
80518
  }
@@ -80428,7 +80531,7 @@ function symbol2(status) {
80428
80531
  return cErr.dim("ℹ");
80429
80532
  }
80430
80533
  }
80431
- async function action33(options) {
80534
+ async function action34(options) {
80432
80535
  const useSpinner = !options.json && process.stderr.isTTY;
80433
80536
  const spinner = useSpinner ? ora({ text: "Starting checks…", spinner: "dots", stream: process.stderr }).start() : null;
80434
80537
  const results = await runFastChecks({
@@ -80449,7 +80552,7 @@ async function action33(options) {
80449
80552
  }
80450
80553
  }
80451
80554
  function registerStatus(program2) {
80452
- program2.command("status").description("Quick sanity check (fast subset of `cerefox doctor`).").option("--json", "Emit machine-readable JSON.").action(action33);
80555
+ program2.command("status").description("Quick sanity check (fast subset of `cerefox doctor`).").option("--json", "Emit machine-readable JSON.").action(action34);
80453
80556
  }
80454
80557
 
80455
80558
  // src/cli/commands/token.ts
@@ -80482,10 +80585,10 @@ function upsertEnvVar(path, key, value, opts = {}) {
80482
80585
  }
80483
80586
  const re = new RegExp(`^(\\s*)${escapeRegExp(key)}=.*$`, "m");
80484
80587
  let next;
80485
- let action34;
80588
+ let action35;
80486
80589
  if (re.test(original)) {
80487
80590
  next = original.replace(re, `$1${line}`);
80488
- action34 = "updated";
80591
+ action35 = "updated";
80489
80592
  } else {
80490
80593
  const base = original.endsWith(`
80491
80594
  `) ? original : `${original}
@@ -80493,10 +80596,10 @@ function upsertEnvVar(path, key, value, opts = {}) {
80493
80596
  next = `${base}
80494
80597
  ${header}${line}
80495
80598
  `;
80496
- action34 = "added";
80599
+ action35 = "added";
80497
80600
  }
80498
80601
  writeFileSync5(path, next);
80499
- return { path, action: action34, backupPath };
80602
+ return { path, action: action35, backupPath };
80500
80603
  }
80501
80604
  function readEnvVar(path, key) {
80502
80605
  if (!existsSync14(path))
@@ -85069,6 +85172,9 @@ function registerDocumentWriteRoutes(app, ctx) {
85069
85172
  const title = String(body.title ?? "").trim();
85070
85173
  const content = String(body.content ?? "");
85071
85174
  const projectIds = Array.isArray(body.project_ids) ? body.project_ids : [];
85175
+ if (body.metadata !== undefined && body.metadata !== null && (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
85176
+ return c2.json({ success: false, error: "metadata must be a JSON object of key/value pairs" }, 400);
85177
+ }
85072
85178
  const metadata = body.metadata ?? {};
85073
85179
  const doc2 = await getCurrentDoc(ctx, documentId);
85074
85180
  if (!doc2) {
@@ -86334,6 +86440,7 @@ Learn more:
86334
86440
  moveInto(document2, registerGetDoc, "get");
86335
86441
  moveInto(document2, registerListDocs, "list");
86336
86442
  moveInto(document2, registerDeleteDoc, "delete");
86443
+ moveInto(document2, registerDocumentDeadLinks, "dead-links");
86337
86444
  registerDocumentRestore(document2);
86338
86445
  registerDocumentEdit(document2);
86339
86446
  registerDocumentSetProjects(document2);
@@ -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.0";
21
+ export const EF_VERSION = "1.7.1";
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.0";
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.0";
39
+ export const CEREFOX_VERSION = "1.7.1";
40
40
 
41
41
  /**
42
42
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -0,0 +1,15 @@
1
+ -- 0025_drop_orphaned_overloads.sql — remove pre-author-era 1-arg overloads.
2
+ --
3
+ -- cerefox_purge_document(UUID) and cerefox_restore_document(UUID) survived
4
+ -- every CREATE OR REPLACE since their signatures grew (OR REPLACE only
5
+ -- replaces the SAME signature), leaving long-lived databases with BOTH
6
+ -- overloads. A named 1-arg call is then ambiguous — PostgREST PGRST203
7
+ -- ("could not choose the best candidate") — which is how the first
8
+ -- production acceptance run failed to purge its fixtures (v1.7.0).
9
+ -- Fresh databases never had the old signatures and are unaffected.
10
+ --
11
+ -- Schema version 0.12.0 → 0.12.1. The DROPs also run from rpcs.sql on every
12
+ -- deploy; this migration makes the version advance signal the redeploy.
13
+
14
+ DROP FUNCTION IF EXISTS cerefox_purge_document(UUID);
15
+ DROP FUNCTION IF EXISTS cerefox_restore_document(UUID);
@@ -0,0 +1,46 @@
1
+ -- 0026_metadata_guard_and_dead_links.sql — #212 metadata type guards +
2
+ -- #214 phase-2 dead-link sweep.
3
+ --
4
+ -- RPC changes ship via rpcs.sql on this deploy: cerefox_ingest_document
5
+ -- rejects non-object p_metadata (the MCP layer always did; now every write
6
+ -- path agrees), cerefox_set_document_metadata refuses to MERGE onto a
7
+ -- non-object stored value (|| would produce an array; only replace=true
8
+ -- repairs), and two read-only RPCs arrive: cerefox_find_dead_links (whole-KB
9
+ -- [Text](uuid) sweep) and cerefox_metadata_health (rows with non-object
10
+ -- metadata, surfaced by doctor).
11
+ --
12
+ -- Schema version 0.12.1 → 0.12.2. This migration also REPORTS (not repairs)
13
+ -- any rows already in the non-object state, so the operator sees them at
14
+ -- upgrade time; repair is `cerefox document set-metadata <id> --replace`.
15
+
16
+ -- Table-level backstop (round-5 review): closes every current and future
17
+ -- direct writer at once, not only the RPC/CLI/web paths patched in code.
18
+ -- NOT VALID: legacy non-object rows survive (reported below) until repaired
19
+ -- with `document set-metadata --replace`, which the constraint then checks.
20
+ DO $$
21
+ BEGIN
22
+ IF NOT EXISTS (
23
+ SELECT 1 FROM pg_constraint
24
+ WHERE conname = 'cerefox_documents_metadata_object'
25
+ ) THEN
26
+ ALTER TABLE cerefox_documents
27
+ ADD CONSTRAINT cerefox_documents_metadata_object
28
+ CHECK (jsonb_typeof(metadata) = 'object') NOT VALID;
29
+ END IF;
30
+ END $$;
31
+
32
+ DO $$
33
+ DECLARE
34
+ v_count INT;
35
+ BEGIN
36
+ SELECT count(*) INTO v_count
37
+ FROM cerefox_documents
38
+ WHERE metadata IS NOT NULL AND jsonb_typeof(metadata) <> 'object';
39
+ IF v_count > 0 THEN
40
+ RAISE NOTICE
41
+ 'Migration 0026: % document(s) hold NON-OBJECT metadata (legacy #212 state). List them with cerefox doctor (or SELECT * FROM cerefox_metadata_health()); repair each with cerefox document set-metadata <id> --replace --json ''<object>''.',
42
+ v_count;
43
+ ELSE
44
+ RAISE NOTICE 'Migration 0026: metadata guards + dead-link sweep arrive with rpcs.sql. No non-object metadata rows found. Schema 0.12.2.';
45
+ END IF;
46
+ END $$;
@@ -1247,6 +1247,9 @@ $$;
1247
1247
 
1248
1248
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT, TEXT);
1249
1249
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT);
1250
+ -- 0.12.1: the pre-author 1-arg overload survived every CREATE OR REPLACE
1251
+ -- since the signature grew (same orphan class as purge; found live on prod).
1252
+ DROP FUNCTION IF EXISTS cerefox_restore_document(UUID);
1250
1253
  CREATE FUNCTION cerefox_restore_document(
1251
1254
  p_document_id UUID,
1252
1255
  p_author TEXT DEFAULT 'unknown',
@@ -1309,7 +1312,14 @@ $$;
1309
1312
  -- ── cerefox_purge_document ───────────────────────────────────────────────────
1310
1313
  -- Permanently deletes a soft-deleted document (CASCADE). Only works on
1311
1314
  -- documents that are already soft-deleted (deleted_at IS NOT NULL).
1315
+ --
1316
+ -- 0.12.1: drop the pre-author 1-arg overload. CREATE OR REPLACE never removed
1317
+ -- it when the signature grew, so long-lived databases carried BOTH — and a
1318
+ -- 1-arg named call was ambiguous there (PGRST203), which is how the first
1319
+ -- prod acceptance run failed to purge its fixtures. Same cleanup for
1320
+ -- cerefox_restore_document below.
1312
1321
 
1322
+ DROP FUNCTION IF EXISTS cerefox_purge_document(UUID);
1313
1323
  CREATE OR REPLACE FUNCTION cerefox_purge_document(
1314
1324
  p_document_id UUID,
1315
1325
  p_author TEXT DEFAULT 'unknown',
@@ -1347,6 +1357,43 @@ END;
1347
1357
  $$;
1348
1358
 
1349
1359
 
1360
+ -- ── cerefox_extract_doc_link_ids ─────────────────────────────────────────────
1361
+ -- The ONE implementation of [Text](uuid) link scanning (#214), shared by the
1362
+ -- write-time guard in cerefox_ingest_document and the cerefox_find_dead_links
1363
+ -- sweep — "same scanning rules" is enforced by this being the only copy.
1364
+ --
1365
+ -- Fences are LINE-ANCHORED and handled by SPLITTING, not by a paired-fence
1366
+ -- regex: Postgres AREs give the whole RE the greediness of their first
1367
+ -- quantified atom, which silently overrode a later .*? and made a closed
1368
+ -- fence strip everything to end-of-string (round-5 review, verified live) —
1369
+ -- blinding the scan to every link after any code block. Odd-numbered split
1370
+ -- segments are outside fences; an unterminated fence leaves its tail inside
1371
+ -- an even segment, dropped (under-validate, never false-reject). Inline code
1372
+ -- spans are stripped after (their regex has no greediness hazard).
1373
+
1374
+ CREATE OR REPLACE FUNCTION cerefox_extract_doc_link_ids(p_content TEXT)
1375
+ RETURNS TABLE (link_id TEXT)
1376
+ LANGUAGE sql
1377
+ IMMUTABLE
1378
+ SET search_path = public, pg_catalog
1379
+ AS $$
1380
+ WITH segments AS (
1381
+ SELECT seg, row_number() OVER () AS rn
1382
+ FROM regexp_split_to_table(COALESCE(p_content, ''), '^[ \t]*```.*$', 'n') AS seg
1383
+ ),
1384
+ outside AS (
1385
+ SELECT string_agg(regexp_replace(seg, '`[^`]*`', ' ', 'g'), ' ') AS s
1386
+ FROM segments
1387
+ WHERE rn % 2 = 1
1388
+ )
1389
+ SELECT lower(m[1])
1390
+ FROM outside,
1391
+ LATERAL regexp_matches(
1392
+ outside.s,
1393
+ '\]\(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)',
1394
+ 'g') AS m;
1395
+ $$;
1396
+
1350
1397
  -- ── cerefox_ingest_document ──────────────────────────────────────────────────
1351
1398
  -- Single RPC for ingesting a document (create or update). Handles:
1352
1399
  -- - Create: insert document row, insert chunks, set review_status, create audit entry
@@ -1494,6 +1541,19 @@ BEGIN
1494
1541
  USING ERRCODE = '22023'; -- invalid_parameter_value
1495
1542
  END IF;
1496
1543
 
1544
+ -- ── Metadata type guard (#212, 0.12.2) ───────────────────────────────
1545
+ -- metadata is jsonb and accepts ANY JSON value, but every reader assumes
1546
+ -- an object — and `document edit`'s JS spread DECOMPOSED a stored string
1547
+ -- into per-character keys (13 real documents were in the vulnerable
1548
+ -- state). The MCP layer always validated this; the RPC now does too, so
1549
+ -- every write path agrees (CLI, scripts, direct PostgREST included).
1550
+ IF p_metadata IS NOT NULL AND jsonb_typeof(p_metadata) <> 'object' THEN
1551
+ RAISE EXCEPTION
1552
+ 'cerefox_ingest_document: metadata must be a JSON object, got %. Wrap scalar values in a key ({"value": ...}).',
1553
+ jsonb_typeof(p_metadata)
1554
+ USING ERRCODE = '22023'; -- invalid_parameter_value
1555
+ END IF;
1556
+
1497
1557
  -- ── Link integrity (#214, 0.12.0) ────────────────────────────────────
1498
1558
  -- Validate [Text](uuid) document links against the store: agents mangle
1499
1559
  -- long random ids when regenerating text, and a mangled id silently
@@ -1506,23 +1566,14 @@ BEGIN
1506
1566
  -- See docs/specs/link-integrity-design.md.
1507
1567
  SELECT string_agg(c->>'content', E'\n') INTO v_scannable
1508
1568
  FROM jsonb_array_elements(p_chunks) c;
1509
- -- Fences are LINE-ANCHORED, matching markdown semantics: only ``` at a
1510
- -- line start opens/closes a block, so a stray backtick run mid-prose
1511
- -- cannot mis-pair the fences and un-escape a later real code block. An
1512
- -- unterminated fence strips to end-of-content (under-validates, never
1513
- -- false-rejects). Inline code is stripped after, so fence markers are
1514
- -- intact when pairing runs.
1515
- v_scannable := regexp_replace(
1516
- COALESCE(v_scannable, ''),
1517
- E'(^|\\n)[ \\t]*```.*?(\\n[ \\t]*```[^\\n]*|$)', ' ', 'g');
1518
- v_scannable := regexp_replace(v_scannable, '`[^`]*`', ' ', 'g');
1519
-
1520
- SELECT array_agg(DISTINCT m[1]) INTO v_missing
1521
- FROM regexp_matches(
1522
- v_scannable,
1523
- '\]\(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)',
1524
- 'g') m
1525
- WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents d WHERE d.id = m[1]::uuid);
1569
+
1570
+ -- Scanning delegated to cerefox_extract_doc_link_ids the one copy of
1571
+ -- the fence/inline-code/uuid rules, shared with the dead-link sweep
1572
+ -- (0.12.2; the previous inline regex was defeated by ARE whole-RE
1573
+ -- greediness see the helper's header).
1574
+ SELECT array_agg(DISTINCT l.link_id) INTO v_missing
1575
+ FROM cerefox_extract_doc_link_ids(v_scannable) l
1576
+ WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents d WHERE d.id = l.link_id::uuid);
1526
1577
 
1527
1578
  -- On UPDATE, tolerate dead links the document ALREADY carries: a target
1528
1579
  -- purged after linking must not make the document unwritable — sync
@@ -2662,6 +2713,30 @@ BEGIN
2662
2713
  USING ERRCODE = 'P0002';
2663
2714
  END IF;
2664
2715
 
2716
+ -- #212: a legacy row can hold NON-OBJECT metadata (the ingest RPC did not
2717
+ -- validate its input until 0.12.2). Merging onto it with || would produce
2718
+ -- an ARRAY — Postgres treats both sides as arrays — burying the stored
2719
+ -- value one level deeper and leaving the row still corrupt. Only replace
2720
+ -- actually repairs such a row, so refuse the merge and say so. jsonb
2721
+ -- 'null' is treated like SQL NULL (empty), matching the CLI.
2722
+ IF NOT p_replace AND v_before IS NOT NULL
2723
+ AND jsonb_typeof(v_before) NOT IN ('object', 'null') THEN
2724
+ RAISE EXCEPTION
2725
+ 'CEREFOX_BAD_METADATA: stored metadata on document % is not an object (%). A merge cannot repair it — retry with replace (CLI: --replace; MCP: replace: true), passing the full intended object.',
2726
+ p_document_id, jsonb_typeof(v_before)
2727
+ USING ERRCODE = '22023';
2728
+ END IF;
2729
+
2730
+ -- Normalized BEFORE value for merge and reporting: everything below must
2731
+ -- work when the stored value is a scalar/array/'null' and p_replace is
2732
+ -- true — that is the REPAIR path, and jsonb_object_keys on a scalar
2733
+ -- errors, which would roll back the repair itself (round-5 review,
2734
+ -- verified live).
2735
+ v_before := CASE
2736
+ WHEN v_before IS NULL OR jsonb_typeof(v_before) <> 'object' THEN '{}'::jsonb
2737
+ ELSE v_before
2738
+ END;
2739
+
2665
2740
  -- Keys explicitly set to null are removals, never stored values.
2666
2741
  SELECT COALESCE(array_agg(key), ARRAY[]::TEXT[]) INTO v_null_keys
2667
2742
  FROM jsonb_each(p_metadata)
@@ -2713,6 +2788,11 @@ SET search_path = public, pg_catalog
2713
2788
  AS $$
2714
2789
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
2715
2790
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
2791
+ -- 0.12.2 (#212, #214): metadata must be a JSON object (ingest input guard
2792
+ -- + set_document_metadata stored-state merge guard); cerefox_find_dead_links
2793
+ -- (link-integrity phase-2 sweep); cerefox_metadata_health (doctor check).
2794
+ -- 0.12.1: drop orphaned 1-arg overloads of purge/restore (pre-author era;
2795
+ -- CREATE OR REPLACE never removed them; ambiguous PGRST203 on 1-arg calls).
2716
2796
  -- 0.12.0 (#208, #210): cerefox_delete_document — CAS
2717
2797
  -- (p_expected_content_hash), p_reason in audit description, JSONB return,
2718
2798
  -- idempotent re-delete; cerefox_restore_document — same rework (JSONB,
@@ -2726,7 +2806,75 @@ AS $$
2726
2806
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
2727
2807
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
2728
2808
  -- sequence, so a store deploying this gets everything from both lines.
2729
- SELECT '0.12.0'::TEXT;
2809
+ SELECT '0.12.2'::TEXT;
2810
+ $$;
2811
+
2812
+ -- ── cerefox_find_dead_links ──────────────────────────────────────────────────
2813
+ -- Phase 2 of link integrity (#214): a read-only whole-KB sweep for
2814
+ -- [Text](uuid) links whose target document no longer EXISTS (purged after
2815
+ -- linking). Complements the write-time guard, which validates only
2816
+ -- newly-introduced links on updates. Same scanning rules as the guard:
2817
+ -- line-anchored fences and inline code spans are stripped (examples are not
2818
+ -- links); a trashed target still exists and is NOT a dead link.
2819
+ -- Full chunk scan — run on demand (CLI `cerefox document dead-links`), not on
2820
+ -- every doctor.
2821
+
2822
+ CREATE OR REPLACE FUNCTION cerefox_find_dead_links()
2823
+ RETURNS TABLE (
2824
+ document_id UUID,
2825
+ document_title TEXT,
2826
+ dead_link_id UUID,
2827
+ occurrences BIGINT
2828
+ )
2829
+ LANGUAGE sql
2830
+ STABLE
2831
+ SECURITY DEFINER
2832
+ SET search_path = public, pg_catalog
2833
+ AS $$
2834
+ -- Scanning delegated to cerefox_extract_doc_link_ids — the one copy of
2835
+ -- the fence/inline-code/uuid rules, shared with the write-time guard.
2836
+ -- Deliberate scope: trashed LINKER documents are excluded (they are
2837
+ -- inert until restored; restoring one re-enters it into the next sweep).
2838
+ WITH doc_content AS (
2839
+ SELECT d.id, d.title, string_agg(c.content, E'\n' ORDER BY c.chunk_index) AS content
2840
+ FROM cerefox_documents d
2841
+ JOIN cerefox_chunks c ON c.document_id = d.id AND c.version_id IS NULL
2842
+ WHERE d.deleted_at IS NULL
2843
+ GROUP BY d.id, d.title
2844
+ ),
2845
+ links AS (
2846
+ SELECT dc.id, dc.title, l.link_id AS target
2847
+ FROM doc_content dc,
2848
+ LATERAL cerefox_extract_doc_link_ids(dc.content) l
2849
+ )
2850
+ SELECT l.id, l.title, l.target::uuid, count(*)
2851
+ FROM links l
2852
+ WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents t WHERE t.id = l.target::uuid)
2853
+ GROUP BY l.id, l.title, l.target
2854
+ ORDER BY l.title, l.target;
2855
+ $$;
2856
+
2857
+ -- ── cerefox_metadata_health ──────────────────────────────────────────────────
2858
+ -- #212: rows whose stored metadata is not a JSON object — the state the
2859
+ -- 0.12.2 write guards now prevent, but which legacy rows may still be in.
2860
+ -- Cheap (documents table only); surfaced by `cerefox doctor`. Repair:
2861
+ -- `cerefox document set-metadata <id> --replace --json '<object>'`.
2862
+
2863
+ CREATE OR REPLACE FUNCTION cerefox_metadata_health()
2864
+ RETURNS TABLE (
2865
+ document_id UUID,
2866
+ document_title TEXT,
2867
+ metadata_type TEXT
2868
+ )
2869
+ LANGUAGE sql
2870
+ STABLE
2871
+ SECURITY DEFINER
2872
+ SET search_path = public, pg_catalog
2873
+ AS $$
2874
+ SELECT d.id, d.title, jsonb_typeof(d.metadata)
2875
+ FROM cerefox_documents d
2876
+ WHERE d.metadata IS NOT NULL AND jsonb_typeof(d.metadata) <> 'object'
2877
+ ORDER BY d.title;
2730
2878
  $$;
2731
2879
 
2732
2880
  -- ── cerefox_content_format_stats ─────────────────────────────────────────────
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.12.0
8
+ -- @version: 0.12.2
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -42,7 +42,15 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
42
42
  source_path TEXT,
43
43
  -- SHA-256 of raw markdown content; used for deduplication
44
44
  content_hash TEXT NOT NULL,
45
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
45
+ -- #212 (0.12.2): must be a JSON OBJECT — jsonb accepts any JSON value,
46
+ -- but every reader assumes an object, and non-object values were silently
47
+ -- destroyed by read-modify-write paths. The table-level CHECK closes all
48
+ -- current AND future direct writers at once; existing databases get it as
49
+ -- NOT VALID via migration 0026 (legacy rows survive until repaired with
50
+ -- `document set-metadata --replace`, which the constraint then validates).
51
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb
52
+ CONSTRAINT cerefox_documents_metadata_object
53
+ CHECK (jsonb_typeof(metadata) = 'object'),
46
54
  chunk_count INT NOT NULL DEFAULT 0,
47
55
  total_chars INT NOT NULL DEFAULT 0,
48
56
  -- review_status: human governance flag. 'approved' = validated by human,
@@ -226,6 +226,12 @@ cerefox document list [OPTIONS]
226
226
 
227
227
  **Purpose**: update a document's title and/or metadata in place, without re-ingesting content.
228
228
 
229
+ > **v1.7.1 (#212)**: a title-only edit no longer writes `metadata` at all, and
230
+ > the command refuses to patch a document whose stored metadata is not a JSON
231
+ > object (a legacy state older writes could create) instead of destroying it —
232
+ > repair such a row with `cerefox document set-metadata <id> --replace --json
233
+ > '<object>'`. `cerefox doctor` lists any rows in that state.
234
+
229
235
  **Synopsis**:
230
236
  ```
231
237
  cerefox document edit [OPTIONS] DOCUMENT_ID
@@ -341,6 +347,20 @@ cerefox document set-projects <doc-id> --clear
341
347
 
342
348
  ---
343
349
 
350
+ ### `cerefox document dead-links`
351
+
352
+ **Purpose**: whole-KB sweep for `[Text](uuid)` links whose target document no longer exists (phase 2 of link integrity, #214; v1.7.1, needs schema 0.12.2).
353
+
354
+ **Synopsis**: `cerefox document dead-links [--json]`
355
+
356
+ The write-time guard (v1.7.0) validates only links a write *introduces* — deliberately, so a target purged after linking cannot make its linkers unwritable. This command finds those legacy dead links on demand. A trashed target still exists and is **not** reported; trashed **linker** documents are also excluded (inert until restored — a restore re-enters them into the next sweep). Full chunk scan server-side (one RPC call); run on demand, not part of `doctor`.
357
+
358
+ **Fix each hit** by editing the linking document: correct the id, remove the link, or backtick it as an example. Full linking overview: [`linking.md`](linking.md).
359
+
360
+ **MCP equivalent**: none (maintenance verb).
361
+
362
+ ---
363
+
344
364
  ### `cerefox document restore`
345
365
 
346
366
  **Purpose**: restore a soft-deleted (trashed) document back to active.
@@ -806,6 +826,7 @@ surface).
806
826
  | `document get` | `cerefox_get_document` | ✅ |
807
827
  | `document list` | `cerefox_metadata_search` (scope by `project_name` / metadata / time) | ✅ as of this change. Unscoped whole-KB listing has no MCP path by design (scope it) |
808
828
  | `document edit` (title / metadata in place) | — | 🔒 intentional: a human/web-parity convenience. Agents update title+metadata deterministically via `cerefox_ingest` (with `document_id`); a metadata-only edit isn't a needed agent primitive |
829
+ | `document dead-links` | — | 🔒 intentional: a maintenance sweep for the operator (v1.7.1, #214 phase 2); agents encounter dead links through the write-time guard instead |
809
830
  | `document delete` (soft-delete) | `cerefox_delete_document` | ✅ v1.7.0 (#208). MCP requires the caller's read-hash; the CLI confirms interactively instead |
810
831
  | `document restore` | `cerefox_restore_document` | ✅ v1.7.0 (#210). Permanent purge remains web-UI-only |
811
832
  | `document version list` | `cerefox_list_versions` | ✅ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",