@cerefox/memory 1.6.1 → 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.
Files changed (28) hide show
  1. package/AGENT_GUIDE.md +80 -6
  2. package/AGENT_QUICK_REFERENCE.md +9 -7
  3. package/README.md +5 -3
  4. package/dist/bin/cerefox.js +790 -319
  5. package/dist/frontend/assets/index-D8E0mTnp.js +121 -0
  6. package/dist/frontend/assets/index-D8E0mTnp.js.map +1 -0
  7. package/dist/frontend/index.html +1 -1
  8. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  9. package/dist/server-assets/_shared/mcp-tools/_utils.ts +41 -0
  10. package/dist/server-assets/_shared/mcp-tools/delete-document.ts +181 -0
  11. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +4 -4
  12. package/dist/server-assets/_shared/mcp-tools/index.ts +8 -1
  13. package/dist/server-assets/_shared/mcp-tools/ingest.ts +75 -8
  14. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +43 -4
  15. package/dist/server-assets/_shared/mcp-tools/restore-document.ts +130 -0
  16. package/dist/server-assets/db/migrations/0024_mcp_delete_document.sql +26 -0
  17. package/dist/server-assets/db/migrations/0025_drop_orphaned_overloads.sql +15 -0
  18. package/dist/server-assets/db/migrations/0026_metadata_guard_and_dead_links.sql +46 -0
  19. package/dist/server-assets/db/rpcs.sql +355 -27
  20. package/dist/server-assets/db/schema.sql +10 -2
  21. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +26 -0
  22. package/docs/guides/access-paths.md +24 -15
  23. package/docs/guides/cli.md +34 -7
  24. package/docs/guides/connect-agents.md +41 -17
  25. package/docs/guides/operational-cost.md +1 -1
  26. package/package.json +1 -1
  27. package/dist/frontend/assets/index-OqloGFwv.js +0 -121
  28. package/dist/frontend/assets/index-OqloGFwv.js.map +0 -1
@@ -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.6.1";
7441
+ var PKG_VERSION = "1.7.1";
7442
7442
  var init_meta = () => {};
7443
7443
 
7444
7444
  // ../../_shared/config/paths.ts
@@ -23427,6 +23427,82 @@ var init_client = __esm(() => {
23427
23427
  init_cli_core();
23428
23428
  });
23429
23429
 
23430
+ // ../../_shared/mcp-tools/_utils.ts
23431
+ function getMaxResponseBytes() {
23432
+ const raw = globalThis.process?.env?.CEREFOX_MAX_RESPONSE_BYTES;
23433
+ if (raw === undefined || raw === "")
23434
+ return MAX_RESPONSE_BYTES;
23435
+ const n = Number.parseInt(raw, 10);
23436
+ return Number.isNaN(n) || n <= 0 ? MAX_RESPONSE_BYTES : n;
23437
+ }
23438
+ function readEnv(name) {
23439
+ const g = globalThis;
23440
+ const fromProcess = g.process?.env?.[name];
23441
+ if (fromProcess !== undefined && fromProcess !== "")
23442
+ return fromProcess;
23443
+ try {
23444
+ const fromDeno = g.Deno?.env?.get(name);
23445
+ return fromDeno === "" ? undefined : fromDeno;
23446
+ } catch {
23447
+ return;
23448
+ }
23449
+ }
23450
+ function getSearchAlpha() {
23451
+ return DEFAULT_SEARCH_ALPHA;
23452
+ }
23453
+ function getMinSearchScore() {
23454
+ return readEnv("CEREFOX_EMBEDDER") === "local" ? DEFAULT_MIN_SEARCH_SCORE_LOCAL : DEFAULT_MIN_SEARCH_SCORE;
23455
+ }
23456
+ function getConfiguredMinSearchScore() {
23457
+ return;
23458
+ }
23459
+ function getConfiguredSearchAlpha() {
23460
+ return;
23461
+ }
23462
+ function getMinTermCoverage() {
23463
+ return;
23464
+ }
23465
+ function applyByteBudget(rows, maxBytes) {
23466
+ const accepted = [];
23467
+ let usedBytes = 0;
23468
+ let truncated = false;
23469
+ for (const row of rows) {
23470
+ const rowBytes = new TextEncoder().encode(JSON.stringify(row)).length;
23471
+ if (usedBytes + rowBytes > maxBytes) {
23472
+ truncated = true;
23473
+ break;
23474
+ }
23475
+ accepted.push(row);
23476
+ usedBytes += rowBytes;
23477
+ }
23478
+ return { accepted, truncated, usedBytes };
23479
+ }
23480
+ function extractConflictHashes(message) {
23481
+ return {
23482
+ expected: message.match(/expected hash ([0-9a-f]{64})/)?.[1] ?? "unknown",
23483
+ current: message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown"
23484
+ };
23485
+ }
23486
+ function isDocumentNotFoundError(error) {
23487
+ return error.code === "22023" && /not found/i.test(error.message ?? "");
23488
+ }
23489
+ function isMissingFunctionError(message, fnName) {
23490
+ return message.includes("Could not find the function") && message.includes(fnName) || message.includes("does not exist") && message.includes(fnName);
23491
+ }
23492
+ function logUsage(supabase, params) {
23493
+ Promise.resolve(supabase.rpc("cerefox_log_usage", {
23494
+ p_operation: params.operation,
23495
+ p_access_path: params.accessPath,
23496
+ p_requestor: params.requestor ?? "mcp-agent",
23497
+ p_document_id: params.document_id ?? null,
23498
+ p_project_id: params.project_id ?? null,
23499
+ p_query_text: params.query_text ?? null,
23500
+ p_result_count: params.result_count ?? null,
23501
+ p_extra: params.extra ?? {}
23502
+ })).catch(() => {});
23503
+ }
23504
+ var MAX_RESPONSE_BYTES = 200000, DEFAULT_MIN_SEARCH_SCORE = 0.5, DEFAULT_MIN_SEARCH_SCORE_LOCAL = 0.6, DEFAULT_SEARCH_ALPHA = 0.7;
23505
+
23430
23506
  // ../../_shared/server-assets/index.ts
23431
23507
  import { existsSync as existsSync5 } from "node:fs";
23432
23508
  import { dirname as dirname2, join as join5 } from "node:path";
@@ -25466,70 +25542,6 @@ var init_src = __esm(() => {
25466
25542
  src_default = Postgres;
25467
25543
  });
25468
25544
 
25469
- // ../../_shared/mcp-tools/_utils.ts
25470
- function getMaxResponseBytes() {
25471
- const raw = globalThis.process?.env?.CEREFOX_MAX_RESPONSE_BYTES;
25472
- if (raw === undefined || raw === "")
25473
- return MAX_RESPONSE_BYTES;
25474
- const n = Number.parseInt(raw, 10);
25475
- return Number.isNaN(n) || n <= 0 ? MAX_RESPONSE_BYTES : n;
25476
- }
25477
- function readEnv(name) {
25478
- const g = globalThis;
25479
- const fromProcess = g.process?.env?.[name];
25480
- if (fromProcess !== undefined && fromProcess !== "")
25481
- return fromProcess;
25482
- try {
25483
- const fromDeno = g.Deno?.env?.get(name);
25484
- return fromDeno === "" ? undefined : fromDeno;
25485
- } catch {
25486
- return;
25487
- }
25488
- }
25489
- function getSearchAlpha() {
25490
- return DEFAULT_SEARCH_ALPHA;
25491
- }
25492
- function getMinSearchScore() {
25493
- return readEnv("CEREFOX_EMBEDDER") === "local" ? DEFAULT_MIN_SEARCH_SCORE_LOCAL : DEFAULT_MIN_SEARCH_SCORE;
25494
- }
25495
- function getConfiguredMinSearchScore() {
25496
- return;
25497
- }
25498
- function getConfiguredSearchAlpha() {
25499
- return;
25500
- }
25501
- function getMinTermCoverage() {
25502
- return;
25503
- }
25504
- function applyByteBudget(rows, maxBytes) {
25505
- const accepted = [];
25506
- let usedBytes = 0;
25507
- let truncated = false;
25508
- for (const row of rows) {
25509
- const rowBytes = new TextEncoder().encode(JSON.stringify(row)).length;
25510
- if (usedBytes + rowBytes > maxBytes) {
25511
- truncated = true;
25512
- break;
25513
- }
25514
- accepted.push(row);
25515
- usedBytes += rowBytes;
25516
- }
25517
- return { accepted, truncated, usedBytes };
25518
- }
25519
- function logUsage(supabase, params) {
25520
- Promise.resolve(supabase.rpc("cerefox_log_usage", {
25521
- p_operation: params.operation,
25522
- p_access_path: params.accessPath,
25523
- p_requestor: params.requestor ?? "mcp-agent",
25524
- p_document_id: params.document_id ?? null,
25525
- p_project_id: params.project_id ?? null,
25526
- p_query_text: params.query_text ?? null,
25527
- p_result_count: params.result_count ?? null,
25528
- p_extra: params.extra ?? {}
25529
- })).catch(() => {});
25530
- }
25531
- var MAX_RESPONSE_BYTES = 200000, DEFAULT_MIN_SEARCH_SCORE = 0.5, DEFAULT_MIN_SEARCH_SCORE_LOCAL = 0.6, DEFAULT_SEARCH_ALPHA = 0.7;
25532
-
25533
25545
  // ../../_shared/mcp-tools/_projects.ts
25534
25546
  async function ensureDocumentInProject(supabase, documentId, projectName) {
25535
25547
  let projectId = null;
@@ -25731,7 +25743,7 @@ var init_bundled_docs = __esm(() => {
25731
25743
  });
25732
25744
 
25733
25745
  // ../../_shared/ef-meta/index.ts
25734
- var EF_VERSION = "1.6.1", CEREFOX_VERSION = "1.6.1", EF_LAST_CHANGED = "1.6.0";
25746
+ var EF_VERSION = "1.7.1", CEREFOX_VERSION = "1.7.1", EF_LAST_CHANGED = "1.7.0";
25735
25747
  var init_ef_meta = () => {};
25736
25748
 
25737
25749
  // ../../_shared/compatibility/index.ts
@@ -41580,10 +41592,10 @@ var require_flate = __commonJS((exports) => {
41580
41592
  var GenericWorker = require_GenericWorker();
41581
41593
  var ARRAY_TYPE = USE_TYPEDARRAY ? "uint8array" : "array";
41582
41594
  exports.magic = "\b\x00";
41583
- function FlateWorker(action19, options) {
41584
- GenericWorker.call(this, "FlateWorker/" + action19);
41595
+ function FlateWorker(action20, options) {
41596
+ GenericWorker.call(this, "FlateWorker/" + action20);
41585
41597
  this._pako = null;
41586
- this._pakoAction = action19;
41598
+ this._pakoAction = action20;
41587
41599
  this._pakoOptions = options;
41588
41600
  this.meta = {};
41589
41601
  }
@@ -54803,6 +54815,186 @@ var init_audit_log = __esm(() => {
54803
54815
  };
54804
54816
  });
54805
54817
 
54818
+ // ../../_shared/mcp-tools/types.ts
54819
+ var McpInvalidParams;
54820
+ var init_types3 = __esm(() => {
54821
+ McpInvalidParams = class McpInvalidParams extends Error {
54822
+ constructor(message) {
54823
+ super(message);
54824
+ this.name = "McpInvalidParams";
54825
+ }
54826
+ };
54827
+ });
54828
+
54829
+ // ../../_shared/mcp-tools/delete-document.ts
54830
+ function conflictError(documentId, expectedHash, currentHash) {
54831
+ return new Error(`Conflict: document ${documentId} changed since you read it ` + `(your base hash: ${expectedHash}, current hash: ${currentHash}). ` + `Someone wrote to this document after you decided to delete it. ` + `To resolve: (1) cerefox_get_document("${documentId}") to see the current ` + `content, (2) check the deletion is still warranted — the new content may ` + `change your mind, (3) if it is, retry with expected_content_hash set to ` + `the new hash. Do not delete blindly — the change may be another writer's work.`);
54832
+ }
54833
+ async function handler2(supabase, args, ctx) {
54834
+ const document_id = args.document_id;
54835
+ const expected_content_hash = args.expected_content_hash?.trim();
54836
+ const reason = args.reason;
54837
+ if (!document_id)
54838
+ throw new McpInvalidParams("document_id is required");
54839
+ if (!expected_content_hash) {
54840
+ throw new McpInvalidParams("expected_content_hash is required: the content_hash of the document as you read it " + "(returned by cerefox_get_document, cerefox_search, and cerefox_metadata_search). " + "If you have not read the document, read it first — deletion requires knowing what " + "you are deleting.");
54841
+ }
54842
+ const author = args.author ?? args.requestor;
54843
+ const authorType = ctx.accessPath === "cli" ? "user" : "agent";
54844
+ const { data, error: error2 } = await supabase.rpc("cerefox_delete_document", {
54845
+ p_document_id: document_id,
54846
+ p_author: author ?? "unknown",
54847
+ p_author_type: authorType,
54848
+ p_expected_content_hash: expected_content_hash,
54849
+ p_reason: reason ?? null
54850
+ });
54851
+ if (error2) {
54852
+ const message = error2.message ?? String(error2);
54853
+ if (message.includes("CEREFOX_CONFLICT")) {
54854
+ const { expected, current } = extractConflictHashes(message);
54855
+ throw conflictError(document_id, expected === "unknown" ? expected_content_hash : expected, current);
54856
+ }
54857
+ if (isMissingFunctionError(message, "cerefox_delete_document")) {
54858
+ throw new Error(`This server is behind: cerefox_delete_document needs schema 0.12.0 or newer. ` + `Run \`cerefox server deploy\`, then retry. (${message})`);
54859
+ }
54860
+ if (isDocumentNotFoundError(error2)) {
54861
+ throw new McpInvalidParams(`Document ${document_id} not found.`);
54862
+ }
54863
+ throw new Error(`RPC error: ${message}`);
54864
+ }
54865
+ const row = data;
54866
+ if (!row)
54867
+ throw new Error("cerefox_delete_document returned no data");
54868
+ if (row.already_deleted) {
54869
+ return `Document ${document_id} ("${row.title ?? "untitled"}") was ALREADY soft-deleted ` + `at ${row.deleted_at}. No change was made and no audit entry was written.`;
54870
+ }
54871
+ logUsage(supabase, {
54872
+ operation: "delete",
54873
+ accessPath: ctx.accessPath,
54874
+ requestor: args.requestor,
54875
+ document_id,
54876
+ result_count: 1
54877
+ });
54878
+ return `Soft-deleted "${row.title ?? "untitled"}" (id: ${document_id}, ` + `${row.total_chars ?? "?"} chars) at ${row.deleted_at}.
54879
+ ` + `The document is excluded from search but fully recoverable: it sits in the ` + `trash until restored or purged. cerefox_restore_document undoes this if it ` + `was a mistake; permanent purge is human-only (web UI).
54880
+ ` + `Tell your user what you deleted and why, so they can review it.`;
54881
+ }
54882
+ var deleteDocumentTool;
54883
+ var init_delete_document = __esm(() => {
54884
+ init_types3();
54885
+ deleteDocumentTool = {
54886
+ name: "cerefox_delete_document",
54887
+ description: "SOFT-delete a document: it leaves search results and lands in the trash, recoverable until a human purges it. Requires expected_content_hash — the content_hash of the document AS YOU READ IT — so a delete always follows a read; if the document changed in between, the call fails with a conflict and you should re-read before deciding again. A mistaken delete can be undone with cerefox_restore_document; permanent purge is human-only (web UI). ALWAYS tell your user what you deleted and why. Pass a short reason — it is recorded in the audit log for the human reviewing the trash. Prefer this over ingesting empty/placeholder content when a document should go away.",
54888
+ annotations: {
54889
+ title: "Delete document (soft, recoverable)",
54890
+ readOnlyHint: false,
54891
+ destructiveHint: true,
54892
+ idempotentHint: true,
54893
+ openWorldHint: false
54894
+ },
54895
+ inputSchema: {
54896
+ type: "object",
54897
+ required: ["document_id", "expected_content_hash"],
54898
+ properties: {
54899
+ document_id: { type: "string", description: "UUID of the document to soft-delete." },
54900
+ expected_content_hash: {
54901
+ type: "string",
54902
+ description: "The content_hash of the document as you read it (returned by cerefox_get_document, cerefox_search, and cerefox_metadata_search). Required: a delete must follow a read. A stale hash fails with a conflict — re-read, reconsider, retry with the new hash."
54903
+ },
54904
+ reason: {
54905
+ type: "string",
54906
+ description: "Why this document is being deleted. Recorded in the audit log entry, where it is the main thing the human reviewing the trash has to go on. Short and specific beats long."
54907
+ },
54908
+ author: {
54909
+ type: "string",
54910
+ description: "Who is making this change. Recorded in the audit log."
54911
+ },
54912
+ requestor: {
54913
+ type: "string",
54914
+ description: "Name of the agent or user making this request. Recorded in the usage log."
54915
+ }
54916
+ }
54917
+ },
54918
+ handler: handler2
54919
+ };
54920
+ });
54921
+
54922
+ // ../../_shared/mcp-tools/restore-document.ts
54923
+ async function handler3(supabase, args, ctx) {
54924
+ const document_id = args.document_id;
54925
+ const reason = args.reason;
54926
+ if (!document_id)
54927
+ throw new McpInvalidParams("document_id is required");
54928
+ const author = args.author ?? args.requestor;
54929
+ const authorType = ctx.accessPath === "cli" ? "user" : "agent";
54930
+ const { data, error: error2 } = await supabase.rpc("cerefox_restore_document", {
54931
+ p_document_id: document_id,
54932
+ p_author: author ?? "unknown",
54933
+ p_author_type: authorType,
54934
+ p_reason: reason ?? null
54935
+ });
54936
+ if (error2) {
54937
+ const message = error2.message ?? String(error2);
54938
+ if (isMissingFunctionError(message, "cerefox_restore_document")) {
54939
+ throw new Error(`This server is behind: cerefox_restore_document needs schema 0.12.0 or newer. ` + `Run \`cerefox server deploy\`, then retry. (${message})`);
54940
+ }
54941
+ if (isDocumentNotFoundError(error2)) {
54942
+ throw new McpInvalidParams(`Document ${document_id} not found.`);
54943
+ }
54944
+ throw new Error(`RPC error: ${message}`);
54945
+ }
54946
+ const row = data;
54947
+ if (!row)
54948
+ throw new Error("cerefox_restore_document returned no data");
54949
+ if (!row.restored) {
54950
+ return `Document ${document_id} ("${row.title ?? "untitled"}") is NOT deleted — ` + `there is nothing to restore. No change was made.`;
54951
+ }
54952
+ logUsage(supabase, {
54953
+ operation: "restore",
54954
+ accessPath: ctx.accessPath,
54955
+ requestor: args.requestor,
54956
+ document_id,
54957
+ result_count: 1
54958
+ });
54959
+ return `Restored "${row.title ?? "untitled"}" (id: ${document_id}, ` + `${row.total_chars ?? "?"} chars) from the trash. It is searchable again ` + `and the restore is recorded in the audit log.
54960
+ ` + `Tell your user what you restored and why.`;
54961
+ }
54962
+ var restoreDocumentTool;
54963
+ var init_restore_document = __esm(() => {
54964
+ init_types3();
54965
+ restoreDocumentTool = {
54966
+ name: "cerefox_restore_document",
54967
+ description: "Restore a soft-deleted document from the trash — the inverse of cerefox_delete_document. The document becomes searchable again; the restore is recorded in the audit log with your identity. Restoring a document that is not deleted is a reported no-op. Pass a short reason — like a delete's reason, it is what the human reviewing the audit trail goes on. Permanent purge has no agent surface: once a human purges a document from the web UI, it is gone and cannot be restored.",
54968
+ annotations: {
54969
+ title: "Restore document from trash",
54970
+ readOnlyHint: false,
54971
+ destructiveHint: false,
54972
+ idempotentHint: true,
54973
+ openWorldHint: false
54974
+ },
54975
+ inputSchema: {
54976
+ type: "object",
54977
+ required: ["document_id"],
54978
+ properties: {
54979
+ document_id: { type: "string", description: "UUID of the soft-deleted document to restore." },
54980
+ reason: {
54981
+ type: "string",
54982
+ description: "Why this document is being restored. Recorded in the audit-log entry. Short and specific beats long."
54983
+ },
54984
+ author: {
54985
+ type: "string",
54986
+ description: "Who is making this change. Recorded in the audit log."
54987
+ },
54988
+ requestor: {
54989
+ type: "string",
54990
+ description: "Name of the agent or user making this request. Recorded in the usage log."
54991
+ }
54992
+ }
54993
+ },
54994
+ handler: handler3
54995
+ };
54996
+ });
54997
+
54806
54998
  // ../../_shared/mcp-tools/feature-flags.ts
54807
54999
  async function relationsEnabled(supabase) {
54808
55000
  if (cached && Date.now() - cached.at < CACHE_TTL_MS)
@@ -54833,17 +55025,6 @@ var init_feature_flags = __esm(() => {
54833
55025
  ]);
54834
55026
  });
54835
55027
 
54836
- // ../../_shared/mcp-tools/types.ts
54837
- var McpInvalidParams;
54838
- var init_types3 = __esm(() => {
54839
- McpInvalidParams = class McpInvalidParams extends Error {
54840
- constructor(message) {
54841
- super(message);
54842
- this.name = "McpInvalidParams";
54843
- }
54844
- };
54845
- });
54846
-
54847
55028
  // ../../_shared/mcp-tools/relations.ts
54848
55029
  function requireUuid2(value, field) {
54849
55030
  const s = typeof value === "string" ? value.trim() : "";
@@ -55069,7 +55250,7 @@ var init_relations = __esm(() => {
55069
55250
  });
55070
55251
 
55071
55252
  // ../../_shared/mcp-tools/get-document.ts
55072
- async function handler2(supabase, args, ctx) {
55253
+ async function handler4(supabase, args, ctx) {
55073
55254
  const document_id = args.document_id;
55074
55255
  const version_id = args.version_id ?? null;
55075
55256
  const outline = args.outline ?? false;
@@ -55182,7 +55363,7 @@ var init_get_document = __esm(() => {
55182
55363
  }
55183
55364
  }
55184
55365
  },
55185
- handler: handler2
55366
+ handler: handler4
55186
55367
  };
55187
55368
  });
55188
55369
 
@@ -55228,10 +55409,19 @@ function shrinkNote(before, afterChars, applied) {
55228
55409
  return `⚠ This edit removed ${lost} characters (${pct}% smaller). ${why}cerefox_list_versions has the previous content.
55229
55410
  `;
55230
55411
  }
55231
- function conflictError(documentId, expectedHash, currentHash) {
55412
+ function conflictError2(documentId, expectedHash, currentHash) {
55232
55413
  return new Error(`Conflict: document ${documentId} changed since you read it ` + `(your base hash: ${expectedHash}, current hash: ${currentHash}). No write was performed. ` + `To resolve: (1) cerefox_get_document("${documentId}", outline=true) to see the current ` + `structure and hash cheaply, or a full read if you need the text, (2) decide whether your ` + `edit still applies — another writer may have already made it, or made something it ` + `contradicts, (3) retry with expected_content_hash set to the current hash. ` + `These tools have no last-write-wins: the other writer's work is not yours to discard.`);
55233
55414
  }
55234
55415
  async function readDocument(supabase, documentId) {
55416
+ try {
55417
+ const { data: lifecycle } = await supabase.from("cerefox_documents").select("deleted_at").eq("id", documentId).maybeSingle();
55418
+ if (lifecycle?.deleted_at) {
55419
+ throw new McpInvalidParams(`Document ${documentId} is soft-deleted (in the trash). A trashed document ` + `cannot be edited — restore it first with cerefox_restore_document, then retry.`);
55420
+ }
55421
+ } catch (e) {
55422
+ if (e instanceof McpInvalidParams)
55423
+ throw e;
55424
+ }
55235
55425
  const { data, error: error2 } = await supabase.rpc("cerefox_get_document", {
55236
55426
  p_document_id: documentId,
55237
55427
  p_version_id: null
@@ -55255,7 +55445,7 @@ async function applyAndWrite(supabase, ctx, args) {
55255
55445
  }
55256
55446
  const doc = await readDocument(supabase, documentId);
55257
55447
  if (doc.hash && expectedHash !== doc.hash) {
55258
- throw conflictError(documentId, expectedHash, doc.hash);
55448
+ throw conflictError2(documentId, expectedHash, doc.hash);
55259
55449
  }
55260
55450
  let assembled;
55261
55451
  let applied;
@@ -55306,13 +55496,19 @@ async function applyAndWrite(supabase, ctx, args) {
55306
55496
  if (error2) {
55307
55497
  const message = error2.message ?? "";
55308
55498
  if (message.includes("CEREFOX_CONFLICT")) {
55309
- const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
55310
- throw conflictError(documentId, expectedHash, current);
55499
+ throw conflictError2(documentId, expectedHash, extractConflictHashes(message).current);
55311
55500
  }
55312
55501
  if (message.includes("cerefox_documents_hash_unique")) {
55313
55502
  throw new Error(`This edit would make "${doc.title}" (id: ${documentId}) byte-identical to another ` + `document in the store, and content must be unique. No write was performed. ` + `Usually this means the two documents have converged and one should be removed or ` + `merged, or that this edit was already applied to the other one — ` + `cerefox_search for the resulting content to find it.`);
55314
55503
  }
55315
- if (message.includes("does not exist") && message.includes("cerefox_ingest_document")) {
55504
+ if (message.includes("CEREFOX_UNRESOLVED_LINKS")) {
55505
+ const ids = message.match(/do not exist: ([^.]+)\./)?.[1] ?? "(unparsed)";
55506
+ throw new Error(`Edit rejected — this edit introduces link(s) to document id(s) that do not ` + `exist: ${ids}. The UUIDs are almost certainly mangled — re-read the source ` + `you copied each link from, correct the id(s), and retry. Do not retry ` + `unchanged. Deliberate examples belong in code formatting (backticks).`);
55507
+ }
55508
+ if (message.includes("CEREFOX_DELETED")) {
55509
+ throw new Error(`Document ${documentId} was soft-deleted while this edit was in flight. ` + `Restore it first with cerefox_restore_document, then retry.`);
55510
+ }
55511
+ if (isMissingFunctionError(message, "cerefox_ingest_document")) {
55316
55512
  throw new Error(`This server is behind: partial edits need schema 0.11.0 or newer. ` + `Run \`cerefox server deploy\`, then retry. (${message})`);
55317
55513
  }
55318
55514
  throw new Error(`Edit failed: ${message}`);
@@ -55522,12 +55718,12 @@ var init_partial_edits2 = __esm(() => {
55522
55718
  });
55523
55719
 
55524
55720
  // ../../_shared/mcp-tools/get-help-content.ts
55525
- var HELP_FULL = '# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **17 MCP tools** (16 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_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); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don\'t write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don\'t construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you 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_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` | `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', HELP_SECTIONS, HELP_SECTION_HEADINGS;
55721
+ var 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', HELP_SECTIONS, HELP_SECTION_HEADINGS;
55526
55722
  var init_get_help_content = __esm(() => {
55527
55723
  HELP_SECTIONS = {
55528
- Tools: "## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_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_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.",
55724
+ Tools: "## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_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.",
55529
55725
  "Editing part of a document (prefer this over re-sending)": '## 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.',
55530
- "Essential Rules": '## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., "Claude Code", "archiver"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user\'s `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` ("decision-log", "research", "design-doc") and `status` ("active", "draft").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don\'t write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don\'t construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you 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`.',
55726
+ "Essential Rules": '## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., "Claude Code", "archiver"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user\'s `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` ("decision-log", "research", "design-doc") and `status` ("active", "draft").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); 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`.',
55531
55727
  "Update Workflow (ID-based -- preferred)": `## Update Workflow (ID-based -- preferred)
55532
55728
 
55533
55729
  \`\`\`
@@ -55539,7 +55735,7 @@ ingest(title="Same Title", content="...", document_id="abc123",
55539
55735
  On a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.`,
55540
55736
  "Update Workflow (title-based -- fallback)": '## Update Workflow (title-based -- fallback)\n\n```\nsearch("topic") -> find doc (note its hash) -> modify ->\ningest(title="Same Title", content="...", update_if_exists=true,\n expected_content_hash="<the hash you read>", author="my-agent")\n```',
55541
55737
  "Catch-Up Workflow": '## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={"type": "decision-log"}, updated_since="2026-03-28T00:00:00Z")\n```',
55542
- "CLI fallback (when MCP is unavailable)": '## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\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_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` | `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.',
55738
+ "CLI fallback (when MCP is unavailable)": '## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\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.',
55543
55739
  "Timestamps are UTC": `## Timestamps are UTC
55544
55740
 
55545
55741
  Every timestamp Cerefox returns — \`created_at\` on audit entries, version
@@ -55652,7 +55848,7 @@ function serverIdentity() {
55652
55848
  ].join(`
55653
55849
  `);
55654
55850
  }
55655
- async function handler3(supabase, args, ctx) {
55851
+ async function handler5(supabase, args, ctx) {
55656
55852
  const topic = args.topic?.trim();
55657
55853
  logUsage(supabase, {
55658
55854
  operation: "get_help",
@@ -55730,19 +55926,26 @@ var init_get_help = __esm(() => {
55730
55926
  }
55731
55927
  }
55732
55928
  },
55733
- handler: handler3
55929
+ handler: handler5
55734
55930
  };
55735
55931
  });
55736
55932
 
55737
55933
  // ../../_shared/mcp-tools/ingest.ts
55738
- function conflictError2(documentId, expectedHash, currentHash) {
55934
+ function conflictError3(documentId, expectedHash, currentHash) {
55739
55935
  return new Error(`Conflict: document ${documentId} changed since you read it ` + `(your base hash: ${expectedHash}, current hash: ${currentHash}). ` + `To resolve: (1) cerefox_get_document("${documentId}") to fetch the latest content ` + `and its content_hash, (2) merge your changes into it, (3) retry cerefox_ingest ` + `with expected_content_hash set to the new hash. Do not overwrite blindly — ` + `the current content may include another writer's work.`);
55740
55936
  }
55741
55937
  function mapIngestRpcError(message, documentId) {
55742
55938
  if (message.includes("CEREFOX_CONFLICT")) {
55743
- const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
55744
- const expected = message.match(/expected hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
55745
- return conflictError2(documentId, expected, current);
55939
+ const { expected, current } = extractConflictHashes(message);
55940
+ return conflictError3(documentId, expected, current);
55941
+ }
55942
+ if (message.includes("CEREFOX_UNRESOLVED_LINKS")) {
55943
+ const ids = message.match(/do not exist: ([^.]+)\./)?.[1] ?? "(unparsed)";
55944
+ return new Error(`Write rejected — the content links document id(s) that do not exist: ${ids}. ` + `These are almost certainly mangled UUIDs (long random ids corrupt easily when ` + `regenerated). Do NOT retry unchanged: re-read the SOURCE you copied each link ` + `from, correct the id(s), and resend. If an id is a deliberate example rather ` + `than a real link, put it in code formatting (backticks or a fence).`);
55945
+ }
55946
+ if (message.includes("CEREFOX_DELETED")) {
55947
+ const id = message.match(/document ([0-9a-f-]{36})/)?.[1] ?? documentId;
55948
+ return new Error(`Document ${id} is soft-deleted (in the trash). A trashed document cannot be ` + `updated — restore it first with cerefox_restore_document, then retry, or ` + `create a new document instead.`);
55746
55949
  }
55747
55950
  if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
55748
55951
  const current = message.match(/Current hash: ([0-9a-f]{64})/)?.[1];
@@ -55750,7 +55953,7 @@ function mapIngestRpcError(message, documentId) {
55750
55953
  }
55751
55954
  return new Error(`Ingest RPC failed: ${message}`);
55752
55955
  }
55753
- async function handler4(supabase, args, ctx) {
55956
+ async function handler6(supabase, args, ctx) {
55754
55957
  const title = args.title?.trim();
55755
55958
  const content = args.content;
55756
55959
  const document_id = args.document_id ?? null;
@@ -55779,17 +55982,20 @@ async function handler4(supabase, args, ctx) {
55779
55982
  const contentHash2 = await sha256hex(normalizeContent(content));
55780
55983
  const reviewStatus = author_type === "agent" ? "pending_review" : "approved";
55781
55984
  if (document_id) {
55782
- const { data: existing } = await supabase.from("cerefox_documents").select("id, title, content_hash").eq("id", document_id).is("deleted_at", null).limit(1);
55985
+ const { data: existing } = await supabase.from("cerefox_documents").select("id, title, content_hash, deleted_at").eq("id", document_id).limit(1);
55783
55986
  if (!existing?.length) {
55784
55987
  throw new Error(`Document not found: ${document_id}`);
55785
55988
  }
55786
55989
  const existingDoc = existing[0];
55990
+ if (existingDoc.deleted_at) {
55991
+ throw new McpInvalidParams(`Document ${document_id} ("${existingDoc.title}") is soft-deleted (in the trash). ` + `A trashed document cannot be updated — restore it first with ` + `cerefox_restore_document, then retry, or create a new document instead.`);
55992
+ }
55787
55993
  if (existingDoc.content_hash === contentHash2) {
55788
55994
  const note2 = update_if_exists ? "" : " Note: update_if_exists flag was overridden by document_id.";
55789
55995
  return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash2}).${note2}`;
55790
55996
  }
55791
55997
  if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
55792
- throw conflictError2(existingDoc.id, expected_content_hash, existingDoc.content_hash);
55998
+ throw conflictError3(existingDoc.id, expected_content_hash, existingDoc.content_hash);
55793
55999
  }
55794
56000
  const chunks2 = chunkMarkdown(content);
55795
56001
  if (chunks2.length === 0)
@@ -55840,14 +56046,20 @@ async function handler4(supabase, args, ctx) {
55840
56046
  return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks2.length} chunk(s), ${totalChars2} chars. New content_hash: ${contentHash2}.${note}`;
55841
56047
  }
55842
56048
  if (update_if_exists) {
55843
- const { data: existing } = await supabase.from("cerefox_documents").select("id, title, content_hash").eq("title", title).order("updated_at", { ascending: false }).limit(1);
56049
+ let { data: existing } = await supabase.from("cerefox_documents").select("id, title, content_hash, deleted_at").eq("title", title).is("deleted_at", null).order("updated_at", { ascending: false }).limit(1);
56050
+ if (!existing?.length) {
56051
+ ({ data: existing } = await supabase.from("cerefox_documents").select("id, title, content_hash, deleted_at").eq("title", title).order("updated_at", { ascending: false }).limit(1));
56052
+ }
55844
56053
  if (existing?.length) {
55845
56054
  const existingDoc = existing[0];
56055
+ if (existingDoc.deleted_at) {
56056
+ throw new McpInvalidParams(`A document titled "${existingDoc.title}" is in the trash (soft-deleted, ` + `id: ${existingDoc.id}). Restore it first with cerefox_restore_document ` + `and re-ingest to update it — or have your user purge it from the web UI ` + `Trash to start fresh.`);
56057
+ }
55846
56058
  if (existingDoc.content_hash === contentHash2) {
55847
56059
  return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged (${contentHash2}).`;
55848
56060
  }
55849
56061
  if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
55850
- throw conflictError2(existingDoc.id, expected_content_hash, existingDoc.content_hash);
56062
+ throw conflictError3(existingDoc.id, expected_content_hash, existingDoc.content_hash);
55851
56063
  }
55852
56064
  const chunks2 = chunkMarkdown(content);
55853
56065
  if (chunks2.length === 0)
@@ -55897,8 +56109,11 @@ async function handler4(supabase, args, ctx) {
55897
56109
  return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks2.length} chunk(s), ${totalChars2} chars. New content_hash: ${contentHash2}.`;
55898
56110
  }
55899
56111
  }
55900
- const { data: hashMatch } = await supabase.from("cerefox_documents").select("id, title").eq("content_hash", contentHash2).limit(1);
56112
+ const { data: hashMatch } = await supabase.from("cerefox_documents").select("id, title, deleted_at").eq("content_hash", contentHash2).limit(1);
55901
56113
  if (hashMatch?.length) {
56114
+ if (hashMatch[0].deleted_at) {
56115
+ return `This exact content is already in the TRASH as "${hashMatch[0].title}" ` + `(soft-deleted, id: ${hashMatch[0].id}). Restore it with ` + `cerefox_restore_document instead of re-ingesting — or have your user ` + `purge it from the web UI Trash to start fresh.`;
56116
+ }
55902
56117
  return `Document already up-to-date: "${hashMatch[0].title}" (id: ${hashMatch[0].id}). Content hash unchanged (${contentHash2}) — pass it as expected_content_hash to edit it.`;
55903
56118
  }
55904
56119
  const chunks = chunkMarkdown(content);
@@ -56002,12 +56217,12 @@ var init_ingest = __esm(() => {
56002
56217
  }
56003
56218
  }
56004
56219
  },
56005
- handler: handler4
56220
+ handler: handler6
56006
56221
  };
56007
56222
  });
56008
56223
 
56009
56224
  // ../../_shared/mcp-tools/list-metadata-keys.ts
56010
- async function handler5(supabase, args, ctx) {
56225
+ async function handler7(supabase, args, ctx) {
56011
56226
  const { data, error: error2 } = await supabase.rpc("cerefox_list_metadata_keys");
56012
56227
  if (error2)
56013
56228
  throw new Error(`RPC error: ${error2.message}`);
@@ -56042,12 +56257,12 @@ var init_list_metadata_keys = __esm(() => {
56042
56257
  }
56043
56258
  }
56044
56259
  },
56045
- handler: handler5
56260
+ handler: handler7
56046
56261
  };
56047
56262
  });
56048
56263
 
56049
56264
  // ../../_shared/mcp-tools/list-projects.ts
56050
- async function handler6(supabase, args, ctx) {
56265
+ async function handler8(supabase, args, ctx) {
56051
56266
  const { data, error: error2 } = await supabase.rpc("cerefox_list_projects");
56052
56267
  if (error2)
56053
56268
  throw new Error(`RPC error: ${error2.message}`);
@@ -56089,7 +56304,7 @@ var init_list_projects = __esm(() => {
56089
56304
  }
56090
56305
  }
56091
56306
  },
56092
- handler: handler6
56307
+ handler: handler8
56093
56308
  };
56094
56309
  });
56095
56310
 
@@ -56098,7 +56313,7 @@ function utcStamp3(iso) {
56098
56313
  const trimmed = iso.slice(0, 19);
56099
56314
  return trimmed.includes("T") ? `${trimmed}Z` : `${trimmed} UTC`;
56100
56315
  }
56101
- async function handler7(supabase, args, ctx) {
56316
+ async function handler9(supabase, args, ctx) {
56102
56317
  const document_id = args.document_id;
56103
56318
  if (!document_id)
56104
56319
  throw new McpInvalidParams("document_id is required");
@@ -56149,12 +56364,12 @@ var init_list_versions = __esm(() => {
56149
56364
  }
56150
56365
  }
56151
56366
  },
56152
- handler: handler7
56367
+ handler: handler9
56153
56368
  };
56154
56369
  });
56155
56370
 
56156
56371
  // ../../_shared/mcp-tools/metadata-search.ts
56157
- async function handler8(supabase, args, ctx) {
56372
+ async function handler10(supabase, args, ctx) {
56158
56373
  const metadata_filter = args.metadata_filter;
56159
56374
  const project_name = args.project_name;
56160
56375
  const updated_since = args.updated_since;
@@ -56266,12 +56481,12 @@ var init_metadata_search = __esm(() => {
56266
56481
  }
56267
56482
  }
56268
56483
  },
56269
- handler: handler8
56484
+ handler: handler10
56270
56485
  };
56271
56486
  });
56272
56487
 
56273
56488
  // ../../_shared/mcp-tools/search.ts
56274
- async function handler9(supabase, args, ctx) {
56489
+ async function handler11(supabase, args, ctx) {
56275
56490
  const query = args.query;
56276
56491
  const project_name = args.project_name;
56277
56492
  const match_count = args.match_count ?? 5;
@@ -56445,12 +56660,12 @@ var init_search = __esm(() => {
56445
56660
  }
56446
56661
  }
56447
56662
  },
56448
- handler: handler9
56663
+ handler: handler11
56449
56664
  };
56450
56665
  });
56451
56666
 
56452
56667
  // ../../_shared/mcp-tools/set-document-metadata.ts
56453
- async function handler10(supabase, args, ctx) {
56668
+ async function handler12(supabase, args, ctx) {
56454
56669
  const document_id = args.document_id;
56455
56670
  const metadata = args.metadata;
56456
56671
  const replace = args.replace ?? false;
@@ -56529,12 +56744,12 @@ var init_set_document_metadata = __esm(() => {
56529
56744
  }
56530
56745
  }
56531
56746
  },
56532
- handler: handler10
56747
+ handler: handler12
56533
56748
  };
56534
56749
  });
56535
56750
 
56536
56751
  // ../../_shared/mcp-tools/set-document-projects.ts
56537
- async function handler11(supabase, args, ctx) {
56752
+ async function handler13(supabase, args, ctx) {
56538
56753
  const document_id = args.document_id?.trim();
56539
56754
  const project_names_raw = args.project_names;
56540
56755
  const author = args.author ?? "mcp-agent";
@@ -56595,7 +56810,7 @@ var init_set_document_projects = __esm(() => {
56595
56810
  }
56596
56811
  }
56597
56812
  },
56598
- handler: handler11
56813
+ handler: handler13
56599
56814
  };
56600
56815
  });
56601
56816
 
@@ -56614,6 +56829,8 @@ async function assertToolEnabled(supabase, name) {
56614
56829
  var ALL_TOOLS, TOOLS_BY_NAME;
56615
56830
  var init_mcp_tools = __esm(() => {
56616
56831
  init_audit_log();
56832
+ init_delete_document();
56833
+ init_restore_document();
56617
56834
  init_feature_flags();
56618
56835
  init_relations();
56619
56836
  init_get_document();
@@ -56634,6 +56851,8 @@ var init_mcp_tools = __esm(() => {
56634
56851
  ingestTool,
56635
56852
  insertTool,
56636
56853
  editTool,
56854
+ deleteDocumentTool,
56855
+ restoreDocumentTool,
56637
56856
  getDocumentTool,
56638
56857
  listVersionsTool,
56639
56858
  metadataSearchTool,
@@ -57480,11 +57699,11 @@ async function runSyncSelfDocs(options = {}) {
57480
57699
  printTable(outcomes.filter((o) => o.status === "error").map((o) => ({ topic: o.topic, error: o.detail.slice(0, 100) })));
57481
57700
  }
57482
57701
  }
57483
- async function action21(options) {
57702
+ async function action22(options) {
57484
57703
  await runSyncSelfDocs(options);
57485
57704
  }
57486
57705
  function registerSyncSelfDocs(program2) {
57487
- 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);
57488
57707
  }
57489
57708
  var init_sync_self_docs = __esm(() => {
57490
57709
  init_cli_core();
@@ -62713,25 +62932,25 @@ class Protocol {
62713
62932
  const error3 = McpError.fromError(ErrorCode.ConnectionClosed, "Connection closed");
62714
62933
  this._transport = undefined;
62715
62934
  this.onclose?.();
62716
- for (const handler12 of responseHandlers.values()) {
62717
- handler12(error3);
62935
+ for (const handler14 of responseHandlers.values()) {
62936
+ handler14(error3);
62718
62937
  }
62719
62938
  }
62720
62939
  _onerror(error3) {
62721
62940
  this.onerror?.(error3);
62722
62941
  }
62723
62942
  _onnotification(notification) {
62724
- const handler12 = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler;
62725
- if (handler12 === undefined) {
62943
+ const handler14 = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler;
62944
+ if (handler14 === undefined) {
62726
62945
  return;
62727
62946
  }
62728
- Promise.resolve().then(() => handler12(notification)).catch((error3) => this._onerror(new Error(`Uncaught error in notification handler: ${error3}`)));
62947
+ Promise.resolve().then(() => handler14(notification)).catch((error3) => this._onerror(new Error(`Uncaught error in notification handler: ${error3}`)));
62729
62948
  }
62730
62949
  _onrequest(request, extra) {
62731
- const handler12 = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
62950
+ const handler14 = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
62732
62951
  const capturedTransport = this._transport;
62733
62952
  const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
62734
- if (handler12 === undefined) {
62953
+ if (handler14 === undefined) {
62735
62954
  const errorResponse = {
62736
62955
  jsonrpc: "2.0",
62737
62956
  id: request.id,
@@ -62795,7 +63014,7 @@ class Protocol {
62795
63014
  if (taskCreationParams) {
62796
63015
  this.assertTaskHandlerCapability(request.method);
62797
63016
  }
62798
- }).then(() => handler12(request, fullExtra)).then(async (result) => {
63017
+ }).then(() => handler14(request, fullExtra)).then(async (result) => {
62799
63018
  if (abortController.signal.aborted) {
62800
63019
  return;
62801
63020
  }
@@ -62844,8 +63063,8 @@ class Protocol {
62844
63063
  _onprogress(notification) {
62845
63064
  const { progressToken, ...params } = notification.params;
62846
63065
  const messageId = Number(progressToken);
62847
- const handler12 = this._progressHandlers.get(messageId);
62848
- if (!handler12) {
63066
+ const handler14 = this._progressHandlers.get(messageId);
63067
+ if (!handler14) {
62849
63068
  this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`));
62850
63069
  return;
62851
63070
  }
@@ -62862,7 +63081,7 @@ class Protocol {
62862
63081
  return;
62863
63082
  }
62864
63083
  }
62865
- handler12(params);
63084
+ handler14(params);
62866
63085
  }
62867
63086
  _onresponse(response) {
62868
63087
  const messageId = Number(response.id);
@@ -62877,8 +63096,8 @@ class Protocol {
62877
63096
  }
62878
63097
  return;
62879
63098
  }
62880
- const handler12 = this._responseHandlers.get(messageId);
62881
- if (handler12 === undefined) {
63099
+ const handler14 = this._responseHandlers.get(messageId);
63100
+ if (handler14 === undefined) {
62882
63101
  this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`));
62883
63102
  return;
62884
63103
  }
@@ -62899,10 +63118,10 @@ class Protocol {
62899
63118
  this._progressHandlers.delete(messageId);
62900
63119
  }
62901
63120
  if (isJSONRPCResultResponse(response)) {
62902
- handler12(response);
63121
+ handler14(response);
62903
63122
  } else {
62904
63123
  const error3 = McpError.fromError(response.error.code, response.error.message, response.error.data);
62905
- handler12(error3);
63124
+ handler14(error3);
62906
63125
  }
62907
63126
  }
62908
63127
  get transport() {
@@ -63065,9 +63284,9 @@ class Protocol {
63065
63284
  const relatedTaskId = relatedTask?.taskId;
63066
63285
  if (relatedTaskId) {
63067
63286
  const responseResolver = (response) => {
63068
- const handler12 = this._responseHandlers.get(messageId);
63069
- if (handler12) {
63070
- handler12(response);
63287
+ const handler14 = this._responseHandlers.get(messageId);
63288
+ if (handler14) {
63289
+ handler14(response);
63071
63290
  } else {
63072
63291
  this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`));
63073
63292
  }
@@ -63176,12 +63395,12 @@ class Protocol {
63176
63395
  }
63177
63396
  await this._transport.send(jsonrpcNotification, options);
63178
63397
  }
63179
- setRequestHandler(requestSchema, handler12) {
63398
+ setRequestHandler(requestSchema, handler14) {
63180
63399
  const method = getMethodLiteral(requestSchema);
63181
63400
  this.assertRequestHandlerCapability(method);
63182
63401
  this._requestHandlers.set(method, (request, extra) => {
63183
63402
  const parsed = parseWithCompat(requestSchema, request);
63184
- return Promise.resolve(handler12(parsed, extra));
63403
+ return Promise.resolve(handler14(parsed, extra));
63185
63404
  });
63186
63405
  }
63187
63406
  removeRequestHandler(method) {
@@ -63192,11 +63411,11 @@ class Protocol {
63192
63411
  throw new Error(`A request handler for ${method} already exists, which would be overridden`);
63193
63412
  }
63194
63413
  }
63195
- setNotificationHandler(notificationSchema, handler12) {
63414
+ setNotificationHandler(notificationSchema, handler14) {
63196
63415
  const method = getMethodLiteral(notificationSchema);
63197
63416
  this._notificationHandlers.set(method, (notification) => {
63198
63417
  const parsed = parseWithCompat(notificationSchema, notification);
63199
- return Promise.resolve(handler12(parsed));
63418
+ return Promise.resolve(handler14(parsed));
63200
63419
  });
63201
63420
  }
63202
63421
  removeNotificationHandler(method) {
@@ -70138,7 +70357,7 @@ var init_server2 = __esm(() => {
70138
70357
  }
70139
70358
  this._capabilities = mergeCapabilities(this._capabilities, capabilities);
70140
70359
  }
70141
- setRequestHandler(requestSchema, handler12) {
70360
+ setRequestHandler(requestSchema, handler14) {
70142
70361
  const shape = getObjectShape(requestSchema);
70143
70362
  const methodSchema = shape?.method;
70144
70363
  if (!methodSchema) {
@@ -70157,7 +70376,7 @@ var init_server2 = __esm(() => {
70157
70376
  throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`);
70158
70377
  }
70159
70378
  const { params } = validatedRequest.data;
70160
- const result = await Promise.resolve(handler12(request, extra));
70379
+ const result = await Promise.resolve(handler14(request, extra));
70161
70380
  if (params.task) {
70162
70381
  const taskValidationResult = safeParse2(CreateTaskResultSchema, result);
70163
70382
  if (!taskValidationResult.success) {
@@ -70175,7 +70394,7 @@ var init_server2 = __esm(() => {
70175
70394
  };
70176
70395
  return super.setRequestHandler(requestSchema, wrappedHandler);
70177
70396
  }
70178
- return super.setRequestHandler(requestSchema, handler12);
70397
+ return super.setRequestHandler(requestSchema, handler14);
70179
70398
  }
70180
70399
  assertCapabilityForMethod(method) {
70181
70400
  switch (method) {
@@ -72540,25 +72759,86 @@ async function action7(documentId, options) {
72540
72759
  return;
72541
72760
  }
72542
72761
  }
72543
- await client.rpc("cerefox_delete_document", {
72544
- p_document_id: documentId,
72545
- p_author: author,
72546
- p_author_type: authorType
72547
- });
72762
+ let result;
72763
+ try {
72764
+ result = await client.rpc("cerefox_delete_document", {
72765
+ p_document_id: documentId,
72766
+ p_author: author,
72767
+ p_author_type: authorType,
72768
+ ...options.reason ? { p_reason: options.reason } : {}
72769
+ });
72770
+ } catch (e) {
72771
+ const message = e instanceof Error ? e.message : String(e);
72772
+ if (isMissingFunctionError(message, "cerefox_delete_document")) {
72773
+ throw systemError(options.reason ? `This server is behind: \`--reason\` needs schema 0.12.0 or newer. ` + `Run \`cerefox server deploy\` and retry, or retry without --reason.` : `The delete did not run: the server has no matching cerefox_delete_document ` + `(mid-deploy window, or an old schema). Run \`cerefox server deploy\` and retry.`);
72774
+ }
72775
+ throw e;
72776
+ }
72777
+ if (result === null) {
72778
+ const { data: check, error: checkError } = await client.raw.from("cerefox_documents").select("deleted_at").eq("id", documentId).maybeSingle();
72779
+ if (checkError) {
72780
+ warn(`Delete submitted, but the follow-up verification read failed (${checkError.message}). ` + `Confirm with: cerefox document get ${documentId}`);
72781
+ return;
72782
+ }
72783
+ if (!check?.deleted_at) {
72784
+ throw systemError(`The delete did not take effect — the server has no matching ` + `cerefox_delete_document (mid-deploy window, or an old schema). ` + `Run \`cerefox server deploy\` and retry.`);
72785
+ }
72786
+ }
72787
+ if (result?.already_deleted) {
72788
+ println(c.dim(`Document ${documentId} ("${doc.title}") was already soft-deleted at ${result.deleted_at} ` + `by the time the delete ran. No change was made and no reason was recorded.`));
72789
+ return;
72790
+ }
72548
72791
  println(c.green(`✓ Soft-deleted "${doc.title}" (id: ${documentId}). Recoverable from the Cerefox web UI trash.`));
72549
72792
  if (options.reason) {
72550
- println(c.dim(` Reason (informational only): ${options.reason}`));
72793
+ println(c.dim(` Reason (recorded in the audit log): ${options.reason}`));
72551
72794
  }
72552
72795
  }
72553
72796
  function registerDeleteDoc(program2) {
72554
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);
72555
72798
  }
72556
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
+
72557
72837
  // src/cli/commands/delete-project.ts
72558
72838
  init_cli_core();
72559
72839
  init_client();
72560
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;
72561
- async function action8(target, options) {
72841
+ async function action9(target, options) {
72562
72842
  const client = getClient();
72563
72843
  const isUuid = UUID_RE.test(target);
72564
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();
@@ -72594,7 +72874,7 @@ async function action8(target, options) {
72594
72874
  println(c.green(`✓ Deleted project "${project.name}" (id: ${project.id}).`));
72595
72875
  }
72596
72876
  function registerDeleteProject(program2) {
72597
- 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);
72598
72878
  }
72599
72879
 
72600
72880
  // src/cli/commands/deploy-server.ts
@@ -72800,7 +73080,7 @@ function listEdgeFunctions(functionsDir) {
72800
73080
  return [];
72801
73081
  return readdirSync2(functionsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("cerefox-")).map((e) => e.name).sort();
72802
73082
  }
72803
- async function action9(options) {
73083
+ async function action10(options) {
72804
73084
  const settings = loadSettings();
72805
73085
  const assets = resolveServerAssets();
72806
73086
  const doSchema = !options.functionsOnly;
@@ -73015,7 +73295,7 @@ Proceed with deployment to Supabase?`, true);
73015
73295
  println(c.dim("Verify with: cerefox doctor"));
73016
73296
  }
73017
73297
  function registerDeployServer(program2) {
73018
- 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);
73019
73299
  }
73020
73300
 
73021
73301
  // src/cli/commands/document-edit.ts
@@ -73035,7 +73315,7 @@ function parseMetaPair(pair) {
73035
73315
  }
73036
73316
  return [key, value];
73037
73317
  }
73038
- async function action10(documentId, options) {
73318
+ async function action11(documentId, options) {
73039
73319
  const hasTitle = options.title !== undefined;
73040
73320
  const sets = options.setMeta ?? [];
73041
73321
  const unsets = options.unsetMeta ?? [];
@@ -73053,18 +73333,41 @@ async function action10(documentId, options) {
73053
73333
  if (doc.deleted_at) {
73054
73334
  throw userError(`Document ${documentId} is soft-deleted — restore it first (cerefox document restore).`);
73055
73335
  }
73056
- const metadata = { ...doc.metadata ?? {} };
73057
- for (const pair of sets) {
73058
- const [k, v] = parseMetaPair(pair);
73059
- metadata[k] = v;
73060
- }
73061
- for (const k of unsets)
73062
- delete metadata[k.trim()];
73336
+ const metaTouched = sets.length > 0 || unsets.length > 0;
73063
73337
  const newTitle = hasTitle ? options.title.trim() : doc.title;
73064
73338
  const titleChanged = newTitle !== doc.title;
73065
- const { error: updErr } = await client.raw.from("cerefox_documents").update({ title: newTitle, metadata, updated_at: new Date().toISOString() }).eq("id", documentId);
73066
- if (updErr)
73067
- 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
+ }
73068
73371
  if (titleChanged) {
73069
73372
  const { error: ftsErr } = await client.raw.rpc("cerefox_update_chunk_fts", {
73070
73373
  p_document_id: documentId,
@@ -73073,18 +73376,15 @@ async function action10(documentId, options) {
73073
73376
  if (ftsErr)
73074
73377
  throw systemError(`Title updated but FTS refresh failed: ${ftsErr.message}`);
73075
73378
  }
73076
- const author = resolveAuthor(options.author);
73077
- const authorType = resolveAuthorType(options.authorType);
73078
- if (author === "unknown") {
73079
- 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
+ });
73080
73387
  }
73081
- await client.raw.rpc("cerefox_create_audit_entry", {
73082
- p_document_id: documentId,
73083
- p_operation: "update-metadata",
73084
- p_author: author,
73085
- p_author_type: authorType,
73086
- p_description: `Edited${titleChanged ? " title" : ""}` + (sets.length ? ` · set ${sets.length} meta key(s)` : "") + (unsets.length ? ` · unset ${unsets.length} meta key(s)` : "")
73087
- });
73088
73388
  println(c.green(`✓ Edited "${newTitle}" (id: ${documentId}).`));
73089
73389
  if (titleChanged) {
73090
73390
  println(c.dim(" Title changed: FTS refreshed; semantic embeddings update on next `cerefox server reindex`."));
@@ -73094,13 +73394,13 @@ function collect(value, prev) {
73094
73394
  return [...prev, value];
73095
73395
  }
73096
73396
  function registerDocumentEdit(parent) {
73097
- 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);
73098
73398
  }
73099
73399
 
73100
73400
  // src/cli/commands/document-restore.ts
73101
73401
  init_cli_core();
73102
73402
  init_client();
73103
- async function action11(documentId, options) {
73403
+ async function action12(documentId, options) {
73104
73404
  const client = getClient();
73105
73405
  const { data: doc, error } = await client.raw.from("cerefox_documents").select("id, title, deleted_at").eq("id", documentId).maybeSingle();
73106
73406
  if (error)
@@ -73116,21 +73416,51 @@ async function action11(documentId, options) {
73116
73416
  if (author === "unknown") {
73117
73417
  warn("No --author / CEREFOX_AUTHOR_NAME set — audit log will record this restore as 'unknown'.");
73118
73418
  }
73119
- await client.rpc("cerefox_restore_document", {
73120
- p_document_id: documentId,
73121
- p_author: author,
73122
- p_author_type: authorType
73123
- });
73419
+ let result;
73420
+ try {
73421
+ result = await client.rpc("cerefox_restore_document", {
73422
+ p_document_id: documentId,
73423
+ p_author: author,
73424
+ p_author_type: authorType,
73425
+ ...options.reason ? { p_reason: options.reason } : {}
73426
+ });
73427
+ } catch (e) {
73428
+ const message = e instanceof Error ? e.message : String(e);
73429
+ if (isMissingFunctionError(message, "cerefox_restore_document")) {
73430
+ throw systemError(options.reason ? `This server is behind: \`--reason\` needs schema 0.12.0 or newer. ` + `Run \`cerefox server deploy\` and retry, or retry without --reason.` : `The restore did not run: the server has no matching cerefox_restore_document ` + `(mid-deploy window, or an old schema). Run \`cerefox server deploy\` and retry.`);
73431
+ }
73432
+ throw e;
73433
+ }
73434
+ if (result === null) {
73435
+ const { data: check, error: checkError } = await client.raw.from("cerefox_documents").select("deleted_at").eq("id", documentId).maybeSingle();
73436
+ if (checkError) {
73437
+ warn(`Restore submitted, but the follow-up verification read failed (${checkError.message}). ` + `Confirm with: cerefox document get ${documentId}`);
73438
+ return;
73439
+ }
73440
+ if (!check) {
73441
+ throw systemError(`Document ${documentId} no longer exists — it may have been purged concurrently. Nothing was restored.`);
73442
+ }
73443
+ if (check.deleted_at) {
73444
+ throw systemError(`The restore did not take effect — the server has no matching ` + `cerefox_restore_document (mid-deploy window, or an old schema). ` + `Run \`cerefox server deploy\` and retry.`);
73445
+ }
73446
+ }
73447
+ if (result && result.restored === false) {
73448
+ println(c.dim(`Document ${documentId} ("${doc.title}") was already restored by the time this ran. No change was made.`));
73449
+ return;
73450
+ }
73124
73451
  println(c.green(`✓ Restored "${doc.title}" (id: ${documentId}) from the trash.`));
73452
+ if (options.reason) {
73453
+ println(c.dim(` Reason (recorded in the audit log): ${options.reason}`));
73454
+ }
73125
73455
  }
73126
73456
  function registerDocumentRestore(parent) {
73127
- 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("-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);
73128
73458
  }
73129
73459
 
73130
73460
  // src/cli/commands/document-set-metadata.ts
73131
73461
  init_cli_core();
73132
73462
  init_client();
73133
- async function action12(documentId, options) {
73463
+ async function action13(documentId, options) {
73134
73464
  const patch = {};
73135
73465
  if (options.json) {
73136
73466
  let parsed;
@@ -73202,14 +73532,14 @@ async function action12(documentId, options) {
73202
73532
  println(c.dim(" Content untouched — no new version, no re-embedding."));
73203
73533
  }
73204
73534
  function registerDocumentSetMetadata(parent) {
73205
- 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);
73206
73536
  }
73207
73537
 
73208
73538
  // src/cli/commands/document-set-projects.ts
73209
73539
  init_cli_core();
73210
73540
  init__projects();
73211
73541
  init_client();
73212
- async function action13(documentId, projectNames, options) {
73542
+ async function action14(documentId, projectNames, options) {
73213
73543
  const names = projectNames ?? [];
73214
73544
  if (options.clear && names.length > 0) {
73215
73545
  throw userError("Pass either project names or --clear, not both.", "Use --clear on its own to remove the document from all projects.");
@@ -73239,7 +73569,7 @@ async function action13(documentId, projectNames, options) {
73239
73569
  println(c.dim(" This REPLACED the previous set — any project not listed is no longer associated."));
73240
73570
  }
73241
73571
  function registerDocumentSetProjects(parent) {
73242
- 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);
73243
73573
  }
73244
73574
 
73245
73575
  // src/cli/commands/guides.ts
@@ -73289,7 +73619,7 @@ function registerGuides(parent) {
73289
73619
  // src/cli/commands/project-create.ts
73290
73620
  init_cli_core();
73291
73621
  init_client();
73292
- async function action14(name, options) {
73622
+ async function action15(name, options) {
73293
73623
  const trimmed = name.trim();
73294
73624
  if (!trimmed)
73295
73625
  throw userError("Project name is required.");
@@ -73301,14 +73631,14 @@ async function action14(name, options) {
73301
73631
  println(c.green(`✓ Created project "${data.name}" (id: ${data.id}).`));
73302
73632
  }
73303
73633
  function registerProjectCreate(parent) {
73304
- 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);
73305
73635
  }
73306
73636
 
73307
73637
  // src/cli/commands/project-edit.ts
73308
73638
  init_cli_core();
73309
73639
  init_client();
73310
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;
73311
- async function action15(target, options) {
73641
+ async function action16(target, options) {
73312
73642
  const update = {};
73313
73643
  if (options.name !== undefined) {
73314
73644
  const n = options.name.trim();
@@ -73335,7 +73665,7 @@ async function action15(target, options) {
73335
73665
  println(c.green(`✓ Updated project "${data.name}" (id: ${data.id}).`));
73336
73666
  }
73337
73667
  function registerProjectEdit(parent) {
73338
- 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);
73339
73669
  }
73340
73670
 
73341
73671
  // src/cli/commands/version-archive.ts
@@ -77157,14 +77487,9 @@ async function checkEmbedderMismatch() {
77157
77487
  };
77158
77488
  }
77159
77489
  }
77160
- var CONTENT_FORMAT_CHECK_NAME = "content format";
77161
- async function checkContentFormat() {
77162
- const settings = loadSettings();
77163
- if (!settings.supabaseUrl || !settings.supabaseKey) {
77164
- return { name: CONTENT_FORMAT_CHECK_NAME, status: "skipped", detail: "Supabase config missing; skipped." };
77165
- }
77490
+ async function probeRpcJson(settings, fn) {
77166
77491
  try {
77167
- const url = `${settings.supabaseUrl.replace(/\/$/, "")}/rest/v1/rpc/cerefox_content_format_stats`;
77492
+ const url = `${settings.supabaseUrl.replace(/\/$/, "")}/rest/v1/rpc/${fn}`;
77168
77493
  const resp = await fetch(url, {
77169
77494
  method: "POST",
77170
77495
  headers: {
@@ -77174,14 +77499,38 @@ async function checkContentFormat() {
77174
77499
  },
77175
77500
  body: "{}"
77176
77501
  });
77177
- 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") {
77178
77520
  return {
77179
77521
  name: CONTENT_FORMAT_CHECK_NAME,
77180
77522
  status: "skipped",
77181
- 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."
77182
77524
  };
77183
77525
  }
77184
- 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;
77185
77534
  const legacy = rows[0]?.legacy_docs ?? 0;
77186
77535
  const total = rows[0]?.total_docs ?? 0;
77187
77536
  if (legacy === 0) {
@@ -77197,13 +77546,39 @@ async function checkContentFormat() {
77197
77546
  detail: `${legacy} of ${total} document(s) use the legacy reconstruction format (format 1).`,
77198
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`."
77199
77548
  };
77200
- } 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") {
77201
77559
  return {
77202
- 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,
77203
77568
  status: "skipped",
77204
- detail: `content-format check skipped: ${err instanceof Error ? err.message : String(err)}`
77569
+ detail: `metadata-health check skipped (${probe.detail}).`
77205
77570
  };
77206
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
+ };
77207
77582
  }
77208
77583
  function hasCerefoxInJsonFile(path) {
77209
77584
  if (!existsSync10(path))
@@ -77414,6 +77789,7 @@ async function runAllChecks(opts = {}) {
77414
77789
  { name: "schema + RPCs", phase: "Reading schema + RPC version", run: () => checkSchemaVersion() },
77415
77790
  { name: "embedder", phase: "Checking embedder consistency", run: () => checkEmbedderMismatch() },
77416
77791
  { name: "content format", phase: "Checking chunk reconstruction format", run: () => checkContentFormat() },
77792
+ { name: "metadata health", phase: "Checking metadata well-formedness", run: () => checkMetadataHealth() },
77417
77793
  { name: "edge functions", phase: "Probing Edge Function versions", run: () => checkEdgeFunctionsCompat() },
77418
77794
  { name: "postgres", phase: "Probing Postgres DDL endpoint", run: () => checkPostgres() },
77419
77795
  { name: "mcp clients", phase: "Scanning MCP client configs", run: () => checkMcpConfigs() }
@@ -77442,7 +77818,7 @@ function symbol(status) {
77442
77818
  return cErr.dim("ℹ");
77443
77819
  }
77444
77820
  }
77445
- async function action16(options) {
77821
+ async function action17(options) {
77446
77822
  const useSpinner = !options.json && process.stderr.isTTY;
77447
77823
  const spinner = useSpinner ? ora({ text: "Starting checks…", spinner: "dots", stream: process.stderr }).start() : null;
77448
77824
  const results = await runAllChecks({
@@ -77506,13 +77882,13 @@ async function action16(options) {
77506
77882
  process.exit(1);
77507
77883
  }
77508
77884
  function registerDoctor(program2) {
77509
- 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);
77510
77886
  }
77511
77887
 
77512
77888
  // src/cli/commands/get-audit-log.ts
77513
77889
  init_cli_core();
77514
77890
  init_client();
77515
- async function action17(options) {
77891
+ async function action18(options) {
77516
77892
  const limit = parsePositiveInt(options.limit, "--limit", 50);
77517
77893
  const client = getClient();
77518
77894
  const data = await client.rpc("cerefox_list_audit_entries", {
@@ -77550,7 +77926,7 @@ async function action17(options) {
77550
77926
  })));
77551
77927
  }
77552
77928
  function registerGetAuditLog(program2) {
77553
- 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);
77554
77930
  }
77555
77931
 
77556
77932
  // src/cli/commands/relation.ts
@@ -77686,7 +78062,7 @@ init_cli_core();
77686
78062
  init_cli_core();
77687
78063
  init_partial_edits();
77688
78064
  init_client();
77689
- async function action18(documentId, options) {
78065
+ async function action19(documentId, options) {
77690
78066
  const section = (options.section ?? "").trim() || null;
77691
78067
  if (section && options.outline) {
77692
78068
  throw userError("Pass either --outline (the whole structure) or --section (one section's text), not both.");
@@ -77783,7 +78159,7 @@ async function action18(documentId, options) {
77783
78159
  println(doc.full_content);
77784
78160
  }
77785
78161
  function registerGetDoc(program2) {
77786
- 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);
77787
78163
  }
77788
78164
 
77789
78165
  // src/cli/commands/ingest.ts
@@ -77895,15 +78271,20 @@ class IngestionDbBridge {
77895
78271
  const rows = data ?? [];
77896
78272
  return rows.length > 0 ? rows[0] : null;
77897
78273
  }
78274
+ async findPreferLive(column, value) {
78275
+ const live = await this.supabase.from("cerefox_documents").select("*").eq(column, value).is("deleted_at", null).order("updated_at", { ascending: false }).limit(1);
78276
+ const liveRows = live.data ?? [];
78277
+ if (liveRows.length > 0)
78278
+ return liveRows[0];
78279
+ const any = await this.supabase.from("cerefox_documents").select("*").eq(column, value).order("updated_at", { ascending: false }).limit(1);
78280
+ const anyRows = any.data ?? [];
78281
+ return anyRows.length > 0 ? anyRows[0] : null;
78282
+ }
77898
78283
  async findDocumentByTitle(title) {
77899
- const { data } = await this.supabase.from("cerefox_documents").select("*").eq("title", title).order("updated_at", { ascending: false }).limit(1);
77900
- const rows = data ?? [];
77901
- return rows.length > 0 ? rows[0] : null;
78284
+ return this.findPreferLive("title", title);
77902
78285
  }
77903
78286
  async findDocumentBySourcePath(sourcePath) {
77904
- const { data } = await this.supabase.from("cerefox_documents").select("*").eq("source_path", sourcePath).order("updated_at", { ascending: false }).limit(1);
77905
- const rows = data ?? [];
77906
- return rows.length > 0 ? rows[0] : null;
78287
+ return this.findPreferLive("source_path", sourcePath);
77907
78288
  }
77908
78289
  async listChunksForDocument(documentId) {
77909
78290
  const data = await fetchAllPages((from, to) => this.supabase.from("cerefox_chunks").select("id, document_id, chunk_index, heading_path, heading_level, title, content, char_count").eq("document_id", documentId).is("version_id", null).order("chunk_index").range(from, to));
@@ -77942,8 +78323,8 @@ class IngestionDbBridge {
77942
78323
  if (error2) {
77943
78324
  const msg = error2.message ?? JSON.stringify(error2);
77944
78325
  if (msg.includes("CEREFOX_CONFLICT")) {
77945
- const current = msg.match(/current hash ([0-9a-f]{64})/)?.[1] ?? null;
77946
- throw new ConcurrencyConflictError(args.documentId ?? "", current, msg);
78326
+ const { current } = extractConflictHashes(msg);
78327
+ throw new ConcurrencyConflictError(args.documentId ?? "", current === "unknown" ? null : current, msg);
77947
78328
  }
77948
78329
  if (msg.includes("CEREFOX_TOKEN_REQUIRED")) {
77949
78330
  throw new ConcurrencyTokenRequiredError(msg);
@@ -78126,12 +78507,33 @@ class IngestionPipeline {
78126
78507
  }
78127
78508
  if (updateExisting) {
78128
78509
  let existingDoc = null;
78510
+ let matchedBySourcePath = false;
78129
78511
  if (sourcePathOpt) {
78130
78512
  existingDoc = await this.db.findDocumentBySourcePath(sourcePathOpt);
78513
+ matchedBySourcePath = existingDoc !== null;
78131
78514
  }
78132
78515
  if (!existingDoc) {
78133
78516
  existingDoc = await this.db.findDocumentByTitle(title);
78134
78517
  }
78518
+ if (existingDoc?.deleted_at) {
78519
+ if (matchedBySourcePath && lastWriteWins) {
78520
+ const projIds = await this.db.getDocumentProjectIds(existingDoc.id);
78521
+ return {
78522
+ documentId: existingDoc.id,
78523
+ title: existingDoc.title ?? title,
78524
+ chunkCount: existingDoc.chunk_count ?? 0,
78525
+ totalChars: existingDoc.total_chars ?? 0,
78526
+ action: "skipped",
78527
+ reindexed: false,
78528
+ projectIds: projIds,
78529
+ note: `"${existingDoc.title}" is in the trash (soft-deleted ` + `${existingDoc.deleted_at.slice(0, 10)}) — skipped, deletion respected. ` + `To resume syncing this file, restore the document ` + `(\`cerefox document restore ${existingDoc.id}\`) or purge it from the web UI Trash.`,
78530
+ contentHash: existingDoc.content_hash ?? ""
78531
+ };
78532
+ }
78533
+ if (!matchedBySourcePath) {
78534
+ existingDoc = null;
78535
+ }
78536
+ }
78135
78537
  if (existingDoc) {
78136
78538
  let fullSetResolved = null;
78137
78539
  if (listFormProvided) {
@@ -78174,7 +78576,7 @@ class IngestionPipeline {
78174
78576
  action: "skipped",
78175
78577
  reindexed: false,
78176
78578
  projectIds: existingProjectIds,
78177
- note: "",
78579
+ note: existingByHash.deleted_at ? `Identical content is in the TRASH as "${existingByHash.title}" ` + `(soft-deleted ${existingByHash.deleted_at.slice(0, 10)}). Restore it ` + `(\`cerefox document restore ${existingByHash.id}\` or the web UI Trash page) ` + `instead of re-ingesting, or purge it first to start fresh.` : "",
78178
78580
  contentHash: existingByHash.content_hash ?? hash
78179
78581
  };
78180
78582
  }
@@ -78246,11 +78648,15 @@ class IngestionPipeline {
78246
78648
  if (!existing) {
78247
78649
  throw new Error(`Document ${JSON.stringify(documentId)} not found`);
78248
78650
  }
78651
+ if (existing.deleted_at) {
78652
+ throw new Error(`Document ${documentId} ("${existing.title}") is soft-deleted (in the trash). ` + `A trashed document cannot be updated — restore it first ` + `(\`cerefox document restore ${documentId}\`, the web UI Trash page, or ` + `cerefox_restore_document over MCP), then re-ingest.`);
78653
+ }
78249
78654
  const newHash = contentHash(text);
78250
78655
  const contentUnchanged = newHash === existing.content_hash;
78656
+ const expectedHashTrimmed = expectedContentHash?.trim() || null;
78251
78657
  if (!contentUnchanged) {
78252
- if (!lastWriteWins && expectedContentHash && expectedContentHash !== existing.content_hash) {
78253
- throw new ConcurrencyConflictError(documentId, existing.content_hash, `CEREFOX_CONFLICT: document ${documentId} changed since it was read ` + `(expected hash ${expectedContentHash}, current hash ${existing.content_hash}). ` + `Re-read the document, merge your changes, and retry with the new hash.`);
78658
+ if (!lastWriteWins && expectedHashTrimmed && expectedHashTrimmed !== existing.content_hash) {
78659
+ throw new ConcurrencyConflictError(documentId, existing.content_hash, `CEREFOX_CONFLICT: document ${documentId} changed since it was read ` + `(expected hash ${expectedHashTrimmed}, current hash ${existing.content_hash}). ` + `Re-read the document, merge your changes, and retry with the new hash.`);
78254
78660
  }
78255
78661
  const collision = await this.db.getDocumentByHash(newHash);
78256
78662
  if (collision && collision.id !== documentId) {
@@ -78351,7 +78757,7 @@ class IngestionPipeline {
78351
78757
  author,
78352
78758
  authorType,
78353
78759
  sourceLabel: sourceLabel ?? source ?? "manual",
78354
- expectedContentHash: expectedContentHash ?? (forceRechunk && contentUnchanged ? existing.content_hash : null),
78760
+ expectedContentHash: expectedHashTrimmed ?? (forceRechunk && contentUnchanged ? existing.content_hash : null),
78355
78761
  lastWriteWins
78356
78762
  });
78357
78763
  let finalProjectIds;
@@ -78417,7 +78823,7 @@ async function readContent(path, paste) {
78417
78823
  const titleFromPath = basename2(path, extname3(path));
78418
78824
  return { content, titleFromPath };
78419
78825
  }
78420
- async function action19(path, options) {
78826
+ async function action20(path, options) {
78421
78827
  const { content, titleFromPath } = await readContent(path, Boolean(options.paste));
78422
78828
  const updatingById = Boolean(options.documentId);
78423
78829
  let title = options.title ?? (updatingById ? null : titleFromPath);
@@ -78508,7 +78914,7 @@ async function action19(path, options) {
78508
78914
  }
78509
78915
  }
78510
78916
  function registerIngest(program2) {
78511
- 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);
78512
78918
  }
78513
78919
 
78514
78920
  // src/cli/commands/document-partial-edit.ts
@@ -78611,7 +79017,7 @@ function walk(dir, extensions) {
78611
79017
  }
78612
79018
  return files;
78613
79019
  }
78614
- async function action20(dir, options) {
79020
+ async function action21(dir, options) {
78615
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));
78616
79022
  const files = walk(dir, extensions);
78617
79023
  if (files.length === 0) {
@@ -78664,7 +79070,7 @@ async function action20(dir, options) {
78664
79070
  outcomes.push({
78665
79071
  file,
78666
79072
  status: "ok",
78667
- detail: `${result.action}: ${result.chunkCount} chunks`
79073
+ detail: `${result.action}: ${result.chunkCount} chunks${result.note ? ` (${result.note})` : ""}`
78668
79074
  });
78669
79075
  } catch (err) {
78670
79076
  const msg = err instanceof Error ? err.message : String(err);
@@ -78686,7 +79092,7 @@ async function action20(dir, options) {
78686
79092
  }
78687
79093
  }
78688
79094
  function registerIngestDir(program2) {
78689
- 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);
78690
79096
  }
78691
79097
 
78692
79098
  // src/cli/commands/init.ts
@@ -79017,7 +79423,7 @@ function writeAnswersTo(target, answers) {
79017
79423
  }
79018
79424
  }
79019
79425
  }
79020
- async function action22(options) {
79426
+ async function action23(options) {
79021
79427
  const homeEnv = join11(homedir7(), USER_STATE_DIR_NAME, ".env");
79022
79428
  const cwdEnv = join11(process.cwd(), ".env");
79023
79429
  const explicitDir = (process.env.CEREFOX_CONFIG_DIR ?? "").trim();
@@ -79126,13 +79532,13 @@ async function action22(options) {
79126
79532
  await postWriteLifecycle(target, options);
79127
79533
  }
79128
79534
  function registerInit(program2) {
79129
- 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);
79130
79536
  }
79131
79537
 
79132
79538
  // src/cli/commands/list-docs.ts
79133
79539
  init_cli_core();
79134
79540
  init_client();
79135
- async function action23(options) {
79541
+ async function action24(options) {
79136
79542
  const deleted = !!options.deleted;
79137
79543
  const limit = parsePositiveInt(options.limit, "--limit", 100);
79138
79544
  const client = getClient();
@@ -79188,13 +79594,13 @@ async function action23(options) {
79188
79594
  }));
79189
79595
  }
79190
79596
  function registerListDocs(program2) {
79191
- 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);
79192
79598
  }
79193
79599
 
79194
79600
  // src/cli/commands/list-metadata-keys.ts
79195
79601
  init_cli_core();
79196
79602
  init_client();
79197
- async function action24(options) {
79603
+ async function action25(options) {
79198
79604
  const client = getClient();
79199
79605
  const data = await client.rpc("cerefox_list_metadata_keys");
79200
79606
  if (data === null) {
@@ -79222,13 +79628,13 @@ async function action24(options) {
79222
79628
  })));
79223
79629
  }
79224
79630
  function registerListMetadataKeys(program2) {
79225
- 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);
79226
79632
  }
79227
79633
 
79228
79634
  // src/cli/commands/list-projects.ts
79229
79635
  init_cli_core();
79230
79636
  init_client();
79231
- async function action25(options) {
79637
+ async function action26(options) {
79232
79638
  const client = getClient();
79233
79639
  const { data, error: error2 } = await client.raw.from("cerefox_projects").select("id, name, description, created_at").order("name", { ascending: true });
79234
79640
  if (error2) {
@@ -79257,13 +79663,13 @@ async function action25(options) {
79257
79663
  })), "(no projects)");
79258
79664
  }
79259
79665
  function registerListProjects(program2) {
79260
- 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);
79261
79667
  }
79262
79668
 
79263
79669
  // src/cli/commands/list-versions.ts
79264
79670
  init_cli_core();
79265
79671
  init_client();
79266
- async function action26(documentId, options) {
79672
+ async function action27(documentId, options) {
79267
79673
  const client = getClient();
79268
79674
  const data = await client.rpc("cerefox_list_document_versions", {
79269
79675
  p_document_id: documentId
@@ -79304,7 +79710,7 @@ async function action26(documentId, options) {
79304
79710
  })));
79305
79711
  }
79306
79712
  function registerListVersions(program2) {
79307
- 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);
79308
79714
  }
79309
79715
 
79310
79716
  // src/cli/commands/mcp.ts
@@ -79336,7 +79742,7 @@ function registerEmbedderWarmup(program2) {
79336
79742
  // src/cli/commands/metadata-search.ts
79337
79743
  init_cli_core();
79338
79744
  init_client();
79339
- async function action27(options) {
79745
+ async function action28(options) {
79340
79746
  const metadataFilter = parseJsonObjectArg(options.metadataFilter, "--metadata-filter") ?? {};
79341
79747
  if (Object.keys(metadataFilter).length === 0 && !options.projectName && !options.updatedSince && !options.createdSince) {
79342
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).`);
@@ -79399,7 +79805,7 @@ async function action27(options) {
79399
79805
  }
79400
79806
  }
79401
79807
  function registerMetadataSearch(program2) {
79402
- 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);
79403
79809
  }
79404
79810
 
79405
79811
  // src/cli/commands/reindex.ts
@@ -79425,7 +79831,7 @@ function warnLargeBulkWrite(opts) {
79425
79831
  }
79426
79832
 
79427
79833
  // src/cli/commands/reindex.ts
79428
- async function action28(options) {
79834
+ async function action29(options) {
79429
79835
  const settings = loadSettings();
79430
79836
  if (!settings.supabaseUrl || !settings.supabaseKey) {
79431
79837
  throw userError("Supabase credentials not configured — run `cerefox init` first.");
@@ -79515,7 +79921,7 @@ ${c2.content}`;
79515
79921
  }
79516
79922
  }
79517
79923
  function registerReindex(program2) {
79518
- 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);
79519
79925
  }
79520
79926
 
79521
79927
  // src/cli/commands/migrate-format.ts
@@ -79523,7 +79929,7 @@ init_cli_core();
79523
79929
  init_config();
79524
79930
  init_client();
79525
79931
  var CURRENT_FORMAT = 2;
79526
- async function action29(options) {
79932
+ async function action30(options) {
79527
79933
  const settings = loadSettings();
79528
79934
  const client = getClient();
79529
79935
  const supabase = client.raw;
@@ -79650,7 +80056,7 @@ async function action29(options) {
79650
80056
  }
79651
80057
  }
79652
80058
  function registerMigrateFormat(program2) {
79653
- 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);
79654
80060
  }
79655
80061
 
79656
80062
  // src/cli/commands/restore.ts
@@ -79680,7 +80086,7 @@ function resolveBackupFile(target) {
79680
80086
  }
79681
80087
  return join12(path, candidates[0].name);
79682
80088
  }
79683
- async function action30(target, options) {
80089
+ async function action31(target, options) {
79684
80090
  const file = resolveBackupFile(target);
79685
80091
  let payload;
79686
80092
  try {
@@ -79815,7 +80221,7 @@ async function action30(target, options) {
79815
80221
  }
79816
80222
  }
79817
80223
  function registerRestore(program2) {
79818
- 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);
79819
80225
  }
79820
80226
 
79821
80227
  // src/cli/commands/search.ts
@@ -79840,7 +80246,7 @@ async function embedQuery(query) {
79840
80246
  }
79841
80247
 
79842
80248
  // src/cli/commands/search.ts
79843
- async function action31(query, options) {
80249
+ async function action32(query, options) {
79844
80250
  if (!query || query.trim() === "") {
79845
80251
  throw userError("Empty query.");
79846
80252
  }
@@ -80006,7 +80412,7 @@ async function action31(query, options) {
80006
80412
  }
80007
80413
  }
80008
80414
  function registerSearch(program2) {
80009
- 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);
80010
80416
  }
80011
80417
 
80012
80418
  // src/cli/commands/self-update.ts
@@ -80053,7 +80459,7 @@ async function fetchLatestVersion() {
80053
80459
  }
80054
80460
  return body.version;
80055
80461
  }
80056
- async function action32(options) {
80462
+ async function action33(options) {
80057
80463
  let target;
80058
80464
  try {
80059
80465
  target = options.version ?? await fetchLatestVersion();
@@ -80106,7 +80512,7 @@ async function action32(options) {
80106
80512
  }
80107
80513
  function registerSelfUpdate(program2) {
80108
80514
  const desc = "Upgrade Cerefox in place. Alias: `cerefox upgrade`.";
80109
- 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);
80110
80516
  declaration(program2.command("self-update"));
80111
80517
  declaration(program2.command("upgrade"));
80112
80518
  }
@@ -80125,7 +80531,7 @@ function symbol2(status) {
80125
80531
  return cErr.dim("ℹ");
80126
80532
  }
80127
80533
  }
80128
- async function action33(options) {
80534
+ async function action34(options) {
80129
80535
  const useSpinner = !options.json && process.stderr.isTTY;
80130
80536
  const spinner = useSpinner ? ora({ text: "Starting checks…", spinner: "dots", stream: process.stderr }).start() : null;
80131
80537
  const results = await runFastChecks({
@@ -80146,7 +80552,7 @@ async function action33(options) {
80146
80552
  }
80147
80553
  }
80148
80554
  function registerStatus(program2) {
80149
- 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);
80150
80556
  }
80151
80557
 
80152
80558
  // src/cli/commands/token.ts
@@ -80179,10 +80585,10 @@ function upsertEnvVar(path, key, value, opts = {}) {
80179
80585
  }
80180
80586
  const re = new RegExp(`^(\\s*)${escapeRegExp(key)}=.*$`, "m");
80181
80587
  let next;
80182
- let action34;
80588
+ let action35;
80183
80589
  if (re.test(original)) {
80184
80590
  next = original.replace(re, `$1${line}`);
80185
- action34 = "updated";
80591
+ action35 = "updated";
80186
80592
  } else {
80187
80593
  const base = original.endsWith(`
80188
80594
  `) ? original : `${original}
@@ -80190,10 +80596,10 @@ function upsertEnvVar(path, key, value, opts = {}) {
80190
80596
  next = `${base}
80191
80597
  ${header}${line}
80192
80598
  `;
80193
- action34 = "added";
80599
+ action35 = "added";
80194
80600
  }
80195
80601
  writeFileSync5(path, next);
80196
- return { path, action: action34, backupPath };
80602
+ return { path, action: action35, backupPath };
80197
80603
  }
80198
80604
  function readEnvVar(path, key) {
80199
80605
  if (!existsSync14(path))
@@ -80371,13 +80777,13 @@ var WSContext = class {
80371
80777
  this.#init.close(code, reason);
80372
80778
  }
80373
80779
  };
80374
- var defineWebSocketHelper = (handler12) => {
80780
+ var defineWebSocketHelper = (handler14) => {
80375
80781
  return (...args) => {
80376
80782
  if (typeof args[0] === "function") {
80377
80783
  const [createEvents, options] = args;
80378
80784
  return async function upgradeWebSocket(c2, next) {
80379
80785
  const events = await createEvents(c2);
80380
- const result = await handler12(c2, events, options);
80786
+ const result = await handler14(c2, events, options);
80381
80787
  if (result) {
80382
80788
  return result;
80383
80789
  }
@@ -80386,7 +80792,7 @@ var defineWebSocketHelper = (handler12) => {
80386
80792
  } else {
80387
80793
  const [c2, events, options] = args;
80388
80794
  return (async () => {
80389
- const upgraded = await handler12(c2, events, options);
80795
+ const upgraded = await handler14(c2, events, options);
80390
80796
  if (!upgraded) {
80391
80797
  throw new Error("Failed to upgrade WebSocket");
80392
80798
  }
@@ -81979,16 +82385,16 @@ var compose = (middleware, onError, onNotFound) => {
81979
82385
  index = i;
81980
82386
  let res;
81981
82387
  let isError = false;
81982
- let handler12;
82388
+ let handler14;
81983
82389
  if (middleware[i]) {
81984
- handler12 = middleware[i][0][0];
82390
+ handler14 = middleware[i][0][0];
81985
82391
  context2.req.routeIndex = i;
81986
82392
  } else {
81987
- handler12 = i === middleware.length && next || undefined;
82393
+ handler14 = i === middleware.length && next || undefined;
81988
82394
  }
81989
- if (handler12) {
82395
+ if (handler14) {
81990
82396
  try {
81991
- res = await handler12(context2, () => dispatch(i + 1));
82397
+ res = await handler14(context2, () => dispatch(i + 1));
81992
82398
  } catch (err) {
81993
82399
  if (err instanceof Error && onError) {
81994
82400
  context2.error = err;
@@ -82672,8 +83078,8 @@ var Hono = class _Hono {
82672
83078
  } else {
82673
83079
  this.#addRoute(method, this.#path, args1);
82674
83080
  }
82675
- args.forEach((handler12) => {
82676
- this.#addRoute(method, this.#path, handler12);
83081
+ args.forEach((handler14) => {
83082
+ this.#addRoute(method, this.#path, handler14);
82677
83083
  });
82678
83084
  return this;
82679
83085
  };
@@ -82682,8 +83088,8 @@ var Hono = class _Hono {
82682
83088
  for (const p of [path].flat()) {
82683
83089
  this.#path = p;
82684
83090
  for (const m of [method].flat()) {
82685
- handlers.map((handler12) => {
82686
- this.#addRoute(m.toUpperCase(), this.#path, handler12);
83091
+ handlers.map((handler14) => {
83092
+ this.#addRoute(m.toUpperCase(), this.#path, handler14);
82687
83093
  });
82688
83094
  }
82689
83095
  }
@@ -82696,8 +83102,8 @@ var Hono = class _Hono {
82696
83102
  this.#path = "*";
82697
83103
  handlers.unshift(arg1);
82698
83104
  }
82699
- handlers.forEach((handler12) => {
82700
- this.#addRoute(METHOD_NAME_ALL, this.#path, handler12);
83105
+ handlers.forEach((handler14) => {
83106
+ this.#addRoute(METHOD_NAME_ALL, this.#path, handler14);
82701
83107
  });
82702
83108
  return this;
82703
83109
  };
@@ -82720,14 +83126,14 @@ var Hono = class _Hono {
82720
83126
  route(path, app) {
82721
83127
  const subApp = this.basePath(path);
82722
83128
  app.routes.map((r) => {
82723
- let handler12;
83129
+ let handler14;
82724
83130
  if (app.errorHandler === errorHandler) {
82725
- handler12 = r.handler;
83131
+ handler14 = r.handler;
82726
83132
  } else {
82727
- handler12 = async (c2, next) => (await compose([], app.errorHandler)(c2, () => r.handler(c2, next))).res;
82728
- handler12[COMPOSED_HANDLER] = r.handler;
83133
+ handler14 = async (c2, next) => (await compose([], app.errorHandler)(c2, () => r.handler(c2, next))).res;
83134
+ handler14[COMPOSED_HANDLER] = r.handler;
82729
83135
  }
82730
- subApp.#addRoute(r.method, r.path, handler12, r.basePath);
83136
+ subApp.#addRoute(r.method, r.path, handler14, r.basePath);
82731
83137
  });
82732
83138
  return this;
82733
83139
  }
@@ -82736,12 +83142,12 @@ var Hono = class _Hono {
82736
83142
  subApp._basePath = mergePath(this._basePath, path);
82737
83143
  return subApp;
82738
83144
  }
82739
- onError = (handler12) => {
82740
- this.errorHandler = handler12;
83145
+ onError = (handler14) => {
83146
+ this.errorHandler = handler14;
82741
83147
  return this;
82742
83148
  };
82743
- notFound = (handler12) => {
82744
- this.#notFoundHandler = handler12;
83149
+ notFound = (handler14) => {
83150
+ this.#notFoundHandler = handler14;
82745
83151
  return this;
82746
83152
  };
82747
83153
  mount(path, applicationHandler, options) {
@@ -82778,26 +83184,26 @@ var Hono = class _Hono {
82778
83184
  return new Request(url, request);
82779
83185
  };
82780
83186
  })();
82781
- const handler12 = async (c2, next) => {
83187
+ const handler14 = async (c2, next) => {
82782
83188
  const res = await applicationHandler(replaceRequest(c2.req.raw), ...getOptions(c2));
82783
83189
  if (res) {
82784
83190
  return res;
82785
83191
  }
82786
83192
  await next();
82787
83193
  };
82788
- this.#addRoute(METHOD_NAME_ALL, mergePath(path, "*"), handler12);
83194
+ this.#addRoute(METHOD_NAME_ALL, mergePath(path, "*"), handler14);
82789
83195
  return this;
82790
83196
  }
82791
- #addRoute(method, path, handler12, baseRoutePath) {
83197
+ #addRoute(method, path, handler14, baseRoutePath) {
82792
83198
  method = method.toUpperCase();
82793
83199
  path = mergePath(this._basePath, path);
82794
83200
  const r = {
82795
83201
  basePath: baseRoutePath !== undefined ? mergePath(this._basePath, baseRoutePath) : this._basePath,
82796
83202
  path,
82797
83203
  method,
82798
- handler: handler12
83204
+ handler: handler14
82799
83205
  };
82800
- this.router.add(method, path, [handler12, r]);
83206
+ this.router.add(method, path, [handler14, r]);
82801
83207
  this.routes.push(r);
82802
83208
  }
82803
83209
  #handleError(err, c2) {
@@ -83122,7 +83528,7 @@ var RegExpRouter = class {
83122
83528
  this.#middleware = { [METHOD_NAME_ALL]: /* @__PURE__ */ Object.create(null) };
83123
83529
  this.#routes = { [METHOD_NAME_ALL]: /* @__PURE__ */ Object.create(null) };
83124
83530
  }
83125
- add(method, path, handler12) {
83531
+ add(method, path, handler14) {
83126
83532
  const middleware = this.#middleware;
83127
83533
  const routes = this.#routes;
83128
83534
  if (!middleware || !routes) {
@@ -83152,13 +83558,13 @@ var RegExpRouter = class {
83152
83558
  Object.keys(middleware).forEach((m) => {
83153
83559
  if (method === METHOD_NAME_ALL || method === m) {
83154
83560
  Object.keys(middleware[m]).forEach((p) => {
83155
- re.test(p) && middleware[m][p].push([handler12, paramCount]);
83561
+ re.test(p) && middleware[m][p].push([handler14, paramCount]);
83156
83562
  });
83157
83563
  }
83158
83564
  });
83159
83565
  Object.keys(routes).forEach((m) => {
83160
83566
  if (method === METHOD_NAME_ALL || method === m) {
83161
- Object.keys(routes[m]).forEach((p) => re.test(p) && routes[m][p].push([handler12, paramCount]));
83567
+ Object.keys(routes[m]).forEach((p) => re.test(p) && routes[m][p].push([handler14, paramCount]));
83162
83568
  }
83163
83569
  });
83164
83570
  return;
@@ -83171,7 +83577,7 @@ var RegExpRouter = class {
83171
83577
  routes[m][path2] ||= [
83172
83578
  ...findMiddleware(middleware[m], path2) || findMiddleware(middleware[METHOD_NAME_ALL], path2) || []
83173
83579
  ];
83174
- routes[m][path2].push([handler12, paramCount - len + i + 1]);
83580
+ routes[m][path2].push([handler14, paramCount - len + i + 1]);
83175
83581
  }
83176
83582
  });
83177
83583
  }
@@ -83220,21 +83626,21 @@ var PreparedRegExpRouter = class {
83220
83626
  matcher[1].forEach((list) => list && list.push(handlerData));
83221
83627
  Object.values(matcher[2]).forEach((list) => list[0].push(handlerData));
83222
83628
  }
83223
- #addPath(method, path, handler12, indexes, map2) {
83629
+ #addPath(method, path, handler14, indexes, map2) {
83224
83630
  const matcher = this.#matchers[method];
83225
83631
  if (!map2) {
83226
- matcher[2][path][0].push([handler12, {}]);
83632
+ matcher[2][path][0].push([handler14, {}]);
83227
83633
  } else {
83228
83634
  indexes.forEach((index) => {
83229
83635
  if (typeof index === "number") {
83230
- matcher[1][index].push([handler12, map2]);
83636
+ matcher[1][index].push([handler14, map2]);
83231
83637
  } else {
83232
- matcher[2][index || path][0].push([handler12, map2]);
83638
+ matcher[2][index || path][0].push([handler14, map2]);
83233
83639
  }
83234
83640
  });
83235
83641
  }
83236
83642
  }
83237
- add(method, path, handler12) {
83643
+ add(method, path, handler14) {
83238
83644
  if (!this.#matchers[method]) {
83239
83645
  const all = this.#matchers[METHOD_NAME_ALL];
83240
83646
  const staticMap = {};
@@ -83248,7 +83654,7 @@ var PreparedRegExpRouter = class {
83248
83654
  ];
83249
83655
  }
83250
83656
  if (path === "/*" || path === "*") {
83251
- const handlerData = [handler12, {}];
83657
+ const handlerData = [handler14, {}];
83252
83658
  if (method === METHOD_NAME_ALL) {
83253
83659
  for (const m in this.#matchers) {
83254
83660
  this.#addWildcard(m, handlerData);
@@ -83265,10 +83671,10 @@ var PreparedRegExpRouter = class {
83265
83671
  for (const [indexes, map2] of data) {
83266
83672
  if (method === METHOD_NAME_ALL) {
83267
83673
  for (const m in this.#matchers) {
83268
- this.#addPath(m, path, handler12, indexes, map2);
83674
+ this.#addPath(m, path, handler14, indexes, map2);
83269
83675
  }
83270
83676
  } else {
83271
- this.#addPath(method, path, handler12, indexes, map2);
83677
+ this.#addPath(method, path, handler14, indexes, map2);
83272
83678
  }
83273
83679
  }
83274
83680
  }
@@ -83286,11 +83692,11 @@ var SmartRouter = class {
83286
83692
  constructor(init) {
83287
83693
  this.#routers = init.routers;
83288
83694
  }
83289
- add(method, path, handler12) {
83695
+ add(method, path, handler14) {
83290
83696
  if (!this.#routes) {
83291
83697
  throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
83292
83698
  }
83293
- this.#routes.push([method, path, handler12]);
83699
+ this.#routes.push([method, path, handler14]);
83294
83700
  }
83295
83701
  match(method, path) {
83296
83702
  if (!this.#routes) {
@@ -83347,17 +83753,17 @@ var Node2 = class _Node2 {
83347
83753
  #patterns;
83348
83754
  #order = 0;
83349
83755
  #params = emptyParams;
83350
- constructor(method, handler12, children) {
83756
+ constructor(method, handler14, children) {
83351
83757
  this.#children = children || /* @__PURE__ */ Object.create(null);
83352
83758
  this.#methods = [];
83353
- if (method && handler12) {
83759
+ if (method && handler14) {
83354
83760
  const m = /* @__PURE__ */ Object.create(null);
83355
- m[method] = { handler: handler12, possibleKeys: [], score: 0 };
83761
+ m[method] = { handler: handler14, possibleKeys: [], score: 0 };
83356
83762
  this.#methods = [m];
83357
83763
  }
83358
83764
  this.#patterns = [];
83359
83765
  }
83360
- insert(method, path, handler12) {
83766
+ insert(method, path, handler14) {
83361
83767
  this.#order = ++this.#order;
83362
83768
  let curNode = this;
83363
83769
  const parts = splitRoutingPath(path);
@@ -83383,7 +83789,7 @@ var Node2 = class _Node2 {
83383
83789
  }
83384
83790
  curNode.#methods.push({
83385
83791
  [method]: {
83386
- handler: handler12,
83792
+ handler: handler14,
83387
83793
  possibleKeys: possibleKeys.filter((v, i, a) => a.indexOf(v) === i),
83388
83794
  score: this.#order
83389
83795
  }
@@ -83501,7 +83907,7 @@ var Node2 = class _Node2 {
83501
83907
  return a.score - b2.score;
83502
83908
  });
83503
83909
  }
83504
- return [handlerSets.map(({ handler: handler12, params }) => [handler12, params])];
83910
+ return [handlerSets.map(({ handler: handler14, params }) => [handler14, params])];
83505
83911
  }
83506
83912
  };
83507
83913
 
@@ -83512,15 +83918,15 @@ var TrieRouter = class {
83512
83918
  constructor() {
83513
83919
  this.#node = new Node2;
83514
83920
  }
83515
- add(method, path, handler12) {
83921
+ add(method, path, handler14) {
83516
83922
  const results = checkOptionalParameter(path);
83517
83923
  if (results) {
83518
83924
  for (let i = 0, len = results.length;i < len; i++) {
83519
- this.#node.insert(method, results[i], handler12);
83925
+ this.#node.insert(method, results[i], handler14);
83520
83926
  }
83521
83927
  return;
83522
83928
  }
83523
- this.#node.insert(method, path, handler12);
83929
+ this.#node.insert(method, path, handler14);
83524
83930
  }
83525
83931
  match(method, path) {
83526
83932
  return this.#node.search(method, path);
@@ -84353,6 +84759,33 @@ function registerDiscoveryRoutes(app, ctx) {
84353
84759
  content: row.content ?? null
84354
84760
  })));
84355
84761
  });
84762
+ app.get("/api/v1/dashboard/recent-docs", async (c2) => {
84763
+ const projectId = c2.req.query("project_id") || null;
84764
+ if (projectId && !UUID_RE4.test(projectId)) {
84765
+ return c2.json({ detail: "project_id must be a UUID" }, 400);
84766
+ }
84767
+ const [recentDocs, projects] = await Promise.all([
84768
+ listDocuments(ctx, { projectId, limit: 10 }),
84769
+ listAllProjects(ctx)
84770
+ ]);
84771
+ const docIds = recentDocs.map((d) => String(d.id));
84772
+ const [docProjectsMap, authors] = await Promise.all([
84773
+ getProjectsForDocuments(ctx, docIds, projects),
84774
+ getRecentDocAuthors(ctx, docIds)
84775
+ ]);
84776
+ return c2.json({
84777
+ recent_docs: recentDocs.map((d) => {
84778
+ const id = String(d.id);
84779
+ const pids = (docProjectsMap[id] ?? []).map((p) => String(p.id));
84780
+ const a = authors[id];
84781
+ return {
84782
+ ...dashboardDocFromRow(d, pids),
84783
+ author: a?.author ?? null,
84784
+ author_type: a?.author_type ?? null
84785
+ };
84786
+ })
84787
+ });
84788
+ });
84356
84789
  app.get("/api/v1/dashboard", async (c2) => {
84357
84790
  const [recentDocs, projects, docCount, totals] = await Promise.all([
84358
84791
  listDocuments(ctx, { limit: 10 }),
@@ -84739,11 +85172,21 @@ function registerDocumentWriteRoutes(app, ctx) {
84739
85172
  const title = String(body.title ?? "").trim();
84740
85173
  const content = String(body.content ?? "");
84741
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
+ }
84742
85178
  const metadata = body.metadata ?? {};
84743
85179
  const doc2 = await getCurrentDoc(ctx, documentId);
84744
85180
  if (!doc2) {
84745
85181
  return c2.json({ success: false, error: "Document not found" }, 404);
84746
85182
  }
85183
+ if (doc2.deleted_at) {
85184
+ return c2.json({
85185
+ success: false,
85186
+ error: "document is in the trash",
85187
+ message: "This document was moved to the trash while you were editing. " + "Restore it from the Trash page first, then save again."
85188
+ }, 409);
85189
+ }
84747
85190
  const currentHash = doc2.content_hash;
84748
85191
  const proposedHash = content.trim() ? contentHash(content) : null;
84749
85192
  const contentChanged = proposedHash !== null && currentHash !== null && proposedHash !== currentHash;
@@ -84787,10 +85230,23 @@ function registerDocumentWriteRoutes(app, ctx) {
84787
85230
  message: err.message
84788
85231
  }, 400);
84789
85232
  }
84790
- return c2.json({
84791
- success: false,
84792
- error: err instanceof Error ? err.message : String(err)
84793
- }, 500);
85233
+ const msg = err instanceof Error ? err.message : String(err);
85234
+ if (msg.includes("CEREFOX_UNRESOLVED_LINKS")) {
85235
+ const ids = msg.match(/do not exist: ([^.]+)\./)?.[1] ?? "";
85236
+ return c2.json({
85237
+ success: false,
85238
+ error: "unresolved document links",
85239
+ message: `This content links document id(s) that don't exist${ids ? `: ${ids}` : ""}. ` + `Fix or remove the broken link(s), or wrap example ids in backticks.`
85240
+ }, 422);
85241
+ }
85242
+ if (msg.includes("soft-deleted")) {
85243
+ return c2.json({
85244
+ success: false,
85245
+ error: "document is in the trash",
85246
+ message: "This document was moved to the trash while you were editing. " + "Restore it from the Trash page first, then save again."
85247
+ }, 409);
85248
+ }
85249
+ return c2.json({ success: false, error: msg }, 500);
84794
85250
  }
84795
85251
  }
84796
85252
  const updates = {};
@@ -84823,25 +85279,35 @@ function registerDocumentWriteRoutes(app, ctx) {
84823
85279
  });
84824
85280
  app.delete("/api/v1/documents/:document_id", async (c2) => {
84825
85281
  const documentId = c2.req.param("document_id");
84826
- const { error: error3 } = await ctx.supabase.rpc("cerefox_delete_document", {
85282
+ const { data, error: error3 } = await ctx.supabase.rpc("cerefox_delete_document", {
84827
85283
  p_document_id: documentId,
84828
85284
  p_author: "web-ui",
84829
85285
  p_author_type: "user"
84830
85286
  });
84831
- if (error3)
85287
+ if (error3) {
85288
+ if (isDocumentNotFoundError(error3)) {
85289
+ return c2.json({ detail: `Document ${documentId} not found` }, 404);
85290
+ }
84832
85291
  return c2.json({ detail: error3.message }, 500);
84833
- return c2.json({ success: true });
85292
+ }
85293
+ const row = data;
85294
+ return c2.json({ success: true, ...row ? { already_deleted: row.already_deleted ?? false } : {} });
84834
85295
  });
84835
85296
  app.post("/api/v1/documents/:document_id/restore", async (c2) => {
84836
85297
  const documentId = c2.req.param("document_id");
84837
- const { error: error3 } = await ctx.supabase.rpc("cerefox_restore_document", {
85298
+ const { data, error: error3 } = await ctx.supabase.rpc("cerefox_restore_document", {
84838
85299
  p_document_id: documentId,
84839
85300
  p_author: "web-ui",
84840
85301
  p_author_type: "user"
84841
85302
  });
84842
- if (error3)
85303
+ if (error3) {
85304
+ if (isDocumentNotFoundError(error3)) {
85305
+ return c2.json({ detail: `Document ${documentId} not found` }, 404);
85306
+ }
84843
85307
  return c2.json({ detail: error3.message }, 500);
84844
- return c2.json({ success: true });
85308
+ }
85309
+ const row = data;
85310
+ return c2.json({ success: true, ...row ? { restored: row.restored ?? false } : {} });
84845
85311
  });
84846
85312
  app.delete("/api/v1/documents/:document_id/purge", async (c2) => {
84847
85313
  const documentId = c2.req.param("document_id");
@@ -84867,6 +85333,9 @@ function registerDocumentWriteRoutes(app, ctx) {
84867
85333
  return c2.json({ detail: `Invalid status: ${JSON.stringify(status)}` }, 400);
84868
85334
  }
84869
85335
  const old = await getCurrentDoc(ctx, documentId);
85336
+ if (old?.deleted_at) {
85337
+ return c2.json({ detail: "This document is in the trash — restore it before changing its review status." }, 409);
85338
+ }
84870
85339
  const oldStatus = old?.review_status ?? "unknown";
84871
85340
  const { error: error3 } = await ctx.supabase.from("cerefox_documents").update({ review_status: status, updated_at: new Date().toISOString() }).eq("id", documentId);
84872
85341
  if (error3)
@@ -85013,7 +85482,8 @@ function registerIngestRoutes(app, ctx) {
85013
85482
  document_id: result.documentId,
85014
85483
  title: result.title,
85015
85484
  skipped,
85016
- updated: result.reindexed
85485
+ updated: result.reindexed,
85486
+ ...result.note ? { note: result.note } : {}
85017
85487
  }, 200);
85018
85488
  } catch (err) {
85019
85489
  return c2.json(notReady(err instanceof Error ? err.message : String(err)), 200);
@@ -85970,6 +86440,7 @@ Learn more:
85970
86440
  moveInto(document2, registerGetDoc, "get");
85971
86441
  moveInto(document2, registerListDocs, "list");
85972
86442
  moveInto(document2, registerDeleteDoc, "delete");
86443
+ moveInto(document2, registerDocumentDeadLinks, "dead-links");
85973
86444
  registerDocumentRestore(document2);
85974
86445
  registerDocumentEdit(document2);
85975
86446
  registerDocumentSetProjects(document2);