@cerefox/memory 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3.0";
7441
+ var PKG_VERSION = "1.4.0";
7442
7442
  var init_meta = () => {};
7443
7443
 
7444
7444
  // ../../_shared/config/paths.ts
@@ -26285,13 +26285,13 @@ function canonicalHeading(text) {
26285
26285
  const m = text.trim().match(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/);
26286
26286
  return m ? `${m[1]} ${m[2].trim()}`.trim() : text.trim();
26287
26287
  }
26288
- function resolveAnchor(outline, anchorHeading) {
26288
+ function resolveAnchor(outline, anchorHeading, reads = false) {
26289
26289
  const anchor = canonicalHeading(anchorHeading);
26290
26290
  const byHeading = outline.filter((n) => n.heading === anchor);
26291
26291
  if (byHeading.length === 1)
26292
26292
  return byHeading[0];
26293
26293
  if (byHeading.length > 1) {
26294
- throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path));
26294
+ throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path), reads);
26295
26295
  }
26296
26296
  if (anchor.includes(" > ")) {
26297
26297
  const normalizedPath = anchor.split(" > ").map((seg) => canonicalHeading(seg)).join(" > ");
@@ -26299,10 +26299,24 @@ function resolveAnchor(outline, anchorHeading) {
26299
26299
  if (byPath.length === 1)
26300
26300
  return byPath[0];
26301
26301
  if (byPath.length > 1) {
26302
- throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path));
26302
+ throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path), reads);
26303
26303
  }
26304
26304
  }
26305
- throw new AnchorNotFoundError(anchor, outline);
26305
+ throw new AnchorNotFoundError(anchor, outline, reads);
26306
+ }
26307
+ function extractSection(content, anchorHeading, sectionPart) {
26308
+ const outline = parseOutline(content);
26309
+ const node = resolveAnchor(outline, anchorHeading, true);
26310
+ const to = resolveSectionEnd(content, outline, node, sectionPart, "the section read", true);
26311
+ const text = content.slice(node.bodyStart, to);
26312
+ return {
26313
+ heading: node.heading,
26314
+ path: node.path,
26315
+ level: node.level,
26316
+ text,
26317
+ chars: text.length,
26318
+ section_part: sectionPart ?? null
26319
+ };
26306
26320
  }
26307
26321
  function firstChild(outline, node) {
26308
26322
  for (const n of outline) {
@@ -26311,7 +26325,7 @@ function firstChild(outline, node) {
26311
26325
  }
26312
26326
  return null;
26313
26327
  }
26314
- function resolveSectionEnd(content, outline, node, sectionPart, opName) {
26328
+ function resolveSectionEnd(content, outline, node, sectionPart, opName, reads = false) {
26315
26329
  const child = firstChild(outline, node);
26316
26330
  if (!child)
26317
26331
  return node.subtreeEnd;
@@ -26319,7 +26333,7 @@ function resolveSectionEnd(content, outline, node, sectionPart, opName) {
26319
26333
  return node.ownBodyEnd;
26320
26334
  if (sectionPart === "subtree")
26321
26335
  return node.subtreeEnd;
26322
- throw new AmbiguousPositionError(node, child.heading, opName);
26336
+ throw new AmbiguousPositionError(node, child.heading, opName, reads);
26323
26337
  }
26324
26338
  function spliceBlock(content, from, to, text) {
26325
26339
  const before = content.slice(0, from).replace(/\n+$/, "");
@@ -26378,7 +26392,30 @@ function applyOne(content, operation) {
26378
26392
  applied: {
26379
26393
  op: "replace_section",
26380
26394
  path: node2.path,
26381
- detail: "replace_section body" + (operation.section_part ? ` (${operation.section_part})` : "")
26395
+ detail: "replace_section body" + (operation.section_part ? ` (${operation.section_part})` : ""),
26396
+ reachedEnd: to2 >= content.trimEnd().length
26397
+ }
26398
+ };
26399
+ }
26400
+ if (operation.op === "rename_section") {
26401
+ const node2 = resolveAnchor(outline, operation.anchor_heading);
26402
+ const next = canonicalHeading(operation.new_heading);
26403
+ const m = next.match(ATX_HEADING);
26404
+ if (!m) {
26405
+ throw new InvalidOperationError(0, `new_heading must be a markdown heading line like "## Title", got ${JSON.stringify(operation.new_heading)}`);
26406
+ }
26407
+ if (m[1].length !== node2.level)
26408
+ throw new HeadingLevelChangeError(node2.heading, next);
26409
+ const hadNewline = content[node2.bodyStart - 1] === `
26410
+ `;
26411
+ const replacement = next + (hadNewline ? `
26412
+ ` : "");
26413
+ return {
26414
+ content: content.slice(0, node2.start) + replacement + content.slice(node2.bodyStart),
26415
+ applied: {
26416
+ op: "rename_section",
26417
+ path: node2.path,
26418
+ detail: `rename_section to ${next}`
26382
26419
  }
26383
26420
  };
26384
26421
  }
@@ -26391,7 +26428,8 @@ function applyOne(content, operation) {
26391
26428
  applied: {
26392
26429
  op: "delete_section",
26393
26430
  path: node.path,
26394
- detail: `delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : "")
26431
+ detail: `delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : ""),
26432
+ reachedEnd: to >= content.trimEnd().length
26395
26433
  }
26396
26434
  };
26397
26435
  }
@@ -26433,8 +26471,21 @@ function validateOperations(operations) {
26433
26471
  if (o.scope !== undefined && o.scope !== "body_only" && o.scope !== "heading_and_body") {
26434
26472
  throw new InvalidOperationError(i, "scope must be body_only or heading_and_body");
26435
26473
  }
26474
+ } else if (op === "rename_section") {
26475
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
26476
+ throw new InvalidOperationError(i, "rename_section requires anchor_heading");
26477
+ }
26478
+ if (typeof o.new_heading !== "string" || o.new_heading.trim() === "") {
26479
+ throw new InvalidOperationError(i, "rename_section requires non-empty new_heading");
26480
+ }
26481
+ if (o.text !== undefined) {
26482
+ throw new InvalidOperationError(i, "rename_section changes only the heading and takes no text — use replace_section for the body, or both operations in one call");
26483
+ }
26484
+ if (o.section_part !== undefined) {
26485
+ throw new InvalidOperationError(i, "rename_section takes no section_part: it replaces the heading line, so no extent is involved");
26486
+ }
26436
26487
  } else {
26437
- throw new InvalidOperationError(i, "op must be insert | replace_section | delete_section");
26488
+ throw new InvalidOperationError(i, "op must be insert | replace_section | delete_section | rename_section");
26438
26489
  }
26439
26490
  if (o.section_part !== undefined && o.section_part !== "own_body" && o.section_part !== "subtree") {
26440
26491
  throw new InvalidOperationError(i, "section_part must be own_body or subtree");
@@ -26461,22 +26512,22 @@ function applyOperations(content, operations) {
26461
26512
  }
26462
26513
  return { content: current, applied };
26463
26514
  }
26464
- var AnchorNotFoundError, AmbiguousAnchorError, AmbiguousPositionError, InvalidOperationError, ATX_HEADING, FENCE_OPEN;
26515
+ var AnchorNotFoundError, AmbiguousAnchorError, AmbiguousPositionError, HeadingLevelChangeError, InvalidOperationError, ATX_HEADING, FENCE_OPEN;
26465
26516
  var init_partial_edits = __esm(() => {
26466
26517
  AnchorNotFoundError = class AnchorNotFoundError extends Error {
26467
- constructor(anchor, outline) {
26518
+ constructor(anchor, outline, reads = false) {
26468
26519
  const known = outline.length ? ` Known headings:
26469
26520
  ${outline.map((n) => ` ${n.path}`).join(`
26470
26521
  `)}` : " The document has no headings.";
26471
- super(`Anchor not found: "${anchor}". No write was performed.${known}
26522
+ super(`Anchor not found: "${anchor}".${reads ? "" : " No write was performed."}${known}
26472
26523
  ` + `Anchors match a heading line exactly ("## Title") or a parent path ` + `("## Parent > ### Child").`);
26473
26524
  this.name = "AnchorNotFoundError";
26474
26525
  }
26475
26526
  };
26476
26527
  AmbiguousAnchorError = class AmbiguousAnchorError extends Error {
26477
26528
  candidates;
26478
- constructor(anchor, candidates) {
26479
- super(`Ambiguous anchor: "${anchor}" matches ${candidates.length} sections. ` + `No write was performed. Disambiguate by passing one of these paths as anchor_heading:
26529
+ constructor(anchor, candidates, reads = false) {
26530
+ super(`Ambiguous anchor: "${anchor}" matches ${candidates.length} sections. ` + `${reads ? "" : "No write was performed. "}Disambiguate by passing one of these paths as anchor_heading:
26480
26531
  ` + candidates.map((c2) => ` ${c2}`).join(`
26481
26532
  `));
26482
26533
  this.name = "AmbiguousAnchorError";
@@ -26485,7 +26536,7 @@ ${outline.map((n) => ` ${n.path}`).join(`
26485
26536
  };
26486
26537
  AmbiguousPositionError = class AmbiguousPositionError extends Error {
26487
26538
  candidates;
26488
- constructor(node, firstChildHeading, opName) {
26539
+ constructor(node, firstChildHeading, opName, reads = false) {
26489
26540
  const candidates = [
26490
26541
  {
26491
26542
  section_part: "own_body",
@@ -26496,13 +26547,21 @@ ${outline.map((n) => ` ${n.path}`).join(`
26496
26547
  description: `the whole subtree, past everything nested under ${node.heading} — ` + `which can be a long way down`
26497
26548
  }
26498
26549
  ];
26499
- super(`Ambiguous position: "${node.path}" has child sections, so "the end of ` + `the section" could mean two different places and ${opName} will not guess. ` + `No write was performed. Pass section_part to choose:
26550
+ super(`Ambiguous position: "${node.path}" has child sections, so "the end of ` + `the section" could mean two different places and ${opName} will not guess. ` + `${reads ? "" : "No write was performed. "}Pass section_part to choose:
26500
26551
  ` + candidates.map((c2) => ` section_part: "${c2.section_part}" — ${c2.description}`).join(`
26501
26552
  `));
26502
26553
  this.name = "AmbiguousPositionError";
26503
26554
  this.candidates = candidates;
26504
26555
  }
26505
26556
  };
26557
+ HeadingLevelChangeError = class HeadingLevelChangeError extends Error {
26558
+ constructor(fromHeading, toHeading) {
26559
+ const from = (fromHeading.match(/^#+/) ?? [""])[0].length;
26560
+ const to = (toHeading.match(/^#+/) ?? [""])[0].length;
26561
+ super(`rename_section changes heading TEXT, not depth: "${fromHeading}" is level ` + `${from} and "${toHeading}" is level ${to}. No write was performed. ` + `Changing the level would re-parent every section nested under it. ` + `Pass a level-${from} heading (${"#".repeat(from)} ...), or restructure ` + `explicitly with delete_section + insert if that is what you meant.`);
26562
+ this.name = "HeadingLevelChangeError";
26563
+ }
26564
+ };
26506
26565
  InvalidOperationError = class InvalidOperationError extends Error {
26507
26566
  constructor(index, message) {
26508
26567
  super(`Invalid operation at index ${index}: ${message}. No write was performed.`);
@@ -54984,8 +55043,16 @@ async function handler2(supabase, args, ctx) {
54984
55043
  const document_id = args.document_id;
54985
55044
  const version_id = args.version_id ?? null;
54986
55045
  const outline = args.outline ?? false;
55046
+ const section = (args.section ?? "").trim() || null;
55047
+ const section_part = args.section_part ?? undefined;
54987
55048
  if (!document_id)
54988
55049
  throw new McpInvalidParams("document_id is required");
55050
+ if (section && outline) {
55051
+ throw new McpInvalidParams("Pass either outline (the whole structure) or section (one section's text), not both.");
55052
+ }
55053
+ if (section_part && !section) {
55054
+ throw new McpInvalidParams("section_part only applies together with section.");
55055
+ }
54989
55056
  const { data, error: error2 } = await supabase.rpc("cerefox_get_document", {
54990
55057
  p_document_id: document_id,
54991
55058
  p_version_id: version_id
@@ -55017,6 +55084,26 @@ async function handler2(supabase, args, ctx) {
55017
55084
  note: archived ? "This is an ARCHIVED version's structure, so no content_hash is returned: these anchors describe the old version and must not be used to edit the current one. Re-read without version_id to edit." : nodes.length === 0 ? "This document has no headings, so it has no anchors: only end_of_document inserts apply." : "Use a path as anchor_heading in cerefox_insert / cerefox_edit; content_hash is your expected_content_hash."
55018
55085
  }, null, 2);
55019
55086
  }
55087
+ if (section) {
55088
+ let extracted;
55089
+ try {
55090
+ extracted = extractSection(row.full_content ?? "", section, section_part);
55091
+ } catch (err) {
55092
+ throw new McpInvalidParams(err instanceof Error ? err.message : String(err));
55093
+ }
55094
+ const archived = version_id !== null;
55095
+ return JSON.stringify({
55096
+ title: row.doc_title ?? "Untitled",
55097
+ heading: extracted.heading,
55098
+ path: extracted.path,
55099
+ level: extracted.level,
55100
+ section_part: extracted.section_part,
55101
+ chars: extracted.chars,
55102
+ content_hash: archived ? null : row.content_hash ?? null,
55103
+ text: extracted.text,
55104
+ note: archived ? "This is an ARCHIVED version's section, so no content_hash is returned: it must not be used to edit the current document." : "This is exactly the text a replace_section on this anchor would overwrite (the heading itself is kept). content_hash is your expected_content_hash."
55105
+ }, null, 2);
55106
+ }
55020
55107
  const label = version_id !== null ? " (archived version)" : " (current)";
55021
55108
  const hashLine = row.content_hash ? `content_hash: ${row.content_hash}
55022
55109
 
@@ -55030,7 +55117,7 @@ var init_get_document = __esm(() => {
55030
55117
  init_types3();
55031
55118
  getDocumentTool = {
55032
55119
  name: "cerefox_get_document",
55033
- description: "Retrieve the full reconstructed content of a document or, with outline=true, just its heading structure, sizes and content_hash (much cheaper, and the paths are the anchors the edit tools take). Pass version_id to retrieve an archived version; omit it (or pass null) for the current version. Version UUIDs are returned by cerefox_list_versions. The response header includes the document's current content_hash — pass it back as expected_content_hash when updating via cerefox_ingest (optimistic concurrency).",
55120
+ description: `Retrieve a document at one of three zoom levels: the whole reconstructed content (default), its heading structure with outline=true (much cheaper, and the headings are the anchors the edit tools take), or one section's text with section="## Heading" (what a replace_section on that anchor would overwrite — read it before replacing a section you did not write). Pass version_id to retrieve an archived version; omit it (or pass null) for the current version. Version UUIDs are returned by cerefox_list_versions. Every non-archived response carries the document's current content_hash — pass it back as expected_content_hash when updating (optimistic concurrency).`,
55034
55121
  annotations: {
55035
55122
  title: "Read document",
55036
55123
  readOnlyHint: true,
@@ -55048,7 +55135,16 @@ var init_get_document = __esm(() => {
55048
55135
  },
55049
55136
  outline: {
55050
55137
  type: "boolean",
55051
- description: "Return the document's STRUCTURE instead of its content: heading paths, levels and per-section sizes, plus content_hash and total size. Far cheaper than a full read, and the paths are exactly what cerefox_insert / cerefox_edit take as anchor_heading. Use this before editing a document you have not read."
55138
+ description: "Return the document's STRUCTURE instead of its content: heading paths, levels and per-section sizes, plus content_hash and total size. Far cheaper than a full read. Every heading listed is addressable by cerefox_insert / cerefox_edit: pass the bare heading line (e.g. '## Daily Logs') when it occurs once in the document, and only the full ' > ' path shown here when the same heading text repeats. Use this before editing a document you have not read."
55139
+ },
55140
+ section: {
55141
+ type: "string",
55142
+ description: "Return ONE section's text instead of the whole document: the anchor heading, exactly as cerefox_insert / cerefox_edit take it (bare heading line when unique, ' > ' path when it repeats). What comes back is precisely the text a replace_section on this anchor would overwrite, so read it before replacing a section you did not write. The heading itself is returned separately, because replace_section keeps it. Cannot be combined with outline."
55143
+ },
55144
+ section_part: {
55145
+ type: "string",
55146
+ enum: ["own_body", "subtree"],
55147
+ description: "Only when the target section HAS CHILD SECTIONS, and it means the same here as on the edit tools: own_body = up to the first child, subtree = everything nested underneath. The read refuses without it for exactly the cases the write refuses, so that what you read is what you would replace. Omit it otherwise; you will be told (with both options) whenever it is needed."
55052
55148
  },
55053
55149
  requestor: {
55054
55150
  type: "string",
@@ -55061,11 +55157,11 @@ var init_get_document = __esm(() => {
55061
55157
  });
55062
55158
 
55063
55159
  // ../../_shared/mcp-tools/get-help-content.ts
55064
- var HELP_FULL = '# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **16 MCP tools** (15 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`), `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 | `document_id` (required), `outline` |\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_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. **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.\n4. Both 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_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', HELP_SECTIONS, HELP_SECTION_HEADINGS;
55160
+ var HELP_FULL = '# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **16 MCP tools** (15 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_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_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', HELP_SECTIONS, HELP_SECTION_HEADINGS;
55065
55161
  var init_get_help_content = __esm(() => {
55066
55162
  HELP_SECTIONS = {
55067
- 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`), `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 | `document_id` (required), `outline` |\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_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.",
55068
- "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. **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.\n4. Both 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.',
55163
+ 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_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.",
55164
+ "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.',
55069
55165
  "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`.',
55070
55166
  "Update Workflow (ID-based -- preferred)": `## Update Workflow (ID-based -- preferred)
55071
55167
 
@@ -55386,7 +55482,7 @@ var init_ingest = __esm(() => {
55386
55482
  init_types3();
55387
55483
  ingestTool = {
55388
55484
  name: "cerefox_ingest",
55389
- description: "Save a note or document to the Cerefox knowledge base.",
55485
+ description: "Save a note or document to the Cerefox knowledge base. Updating an existing document REPLACES its whole content, so every unchanged character has to be reproduced exactly. Prefer cerefox_insert / cerefox_edit for any change to part of a document, and especially where the untouched content cannot be checked by reading it — document IDs, hashes, numeric tables, indexes and registries. Drifting prose is obvious on review; one wrong character in a UUID is not, and it silently breaks the reference.",
55390
55486
  annotations: {
55391
55487
  title: "Save or update a document",
55392
55488
  readOnlyHint: false,
@@ -55446,14 +55542,22 @@ function resolveAuthorType2(ctx, args) {
55446
55542
  function defaultRequestor(ctx) {
55447
55543
  return ctx.accessPath === "cli" ? "cli-user" : "mcp-agent";
55448
55544
  }
55449
- function shrinkNote(before, after) {
55450
- const lost = before - after;
55545
+ function touchedTrailingSection(applied) {
55546
+ return applied.some((a) => a.reachedEnd === true);
55547
+ }
55548
+ function shrinkNote(before, afterChars, applied) {
55549
+ const beforeChars = [...before].length;
55550
+ const lost = beforeChars - afterChars;
55451
55551
  if (lost <= 0)
55452
55552
  return "";
55453
- const pct = Math.round(lost / Math.max(before, 1) * 100);
55454
- if (pct < 25)
55455
- return "";
55456
- return `⚠ This edit removed ${lost} characters (${pct}% smaller). If you did not ` + `intend that, note that a section runs to the next heading of the same or ` + `higher level — or to the end of the document — so replacing or deleting ` + `the LAST section also removes anything appended after it. ` + `cerefox_list_versions has the previous content.
55553
+ const pct = Math.round(lost / Math.max(beforeChars, 1) * 100);
55554
+ const trailing = touchedTrailingSection(applied);
55555
+ if (pct < 25 && !trailing) {
55556
+ return `This edit removed ${lost} characters. cerefox_list_versions has the previous content.
55557
+ `;
55558
+ }
55559
+ const why = trailing ? `You replaced or deleted the LAST section, and a section runs to the next ` + `heading of the same or higher level — or to the end of the document — so ` + `anything appended after it was inside it. ` : `If you did not intend that, note that a section runs to the next heading ` + `of the same or higher level — or to the end of the document — so replacing ` + `or deleting the LAST section also removes anything appended after it. `;
55560
+ return `⚠ This edit removed ${lost} characters (${pct}% smaller). ${why}cerefox_list_versions has the previous content.
55457
55561
  `;
55458
55562
  }
55459
55563
  function conflictError2(documentId, expectedHash, currentHash) {
@@ -55563,7 +55667,7 @@ ${summary}
55563
55667
 
55564
55668
  ` + `New content_hash: ${row?.content_hash ?? newHash}
55565
55669
  ` + `Size: ${row?.total_chars ?? totalChars} chars (was ${doc.content.length}), ${chunks.length} chunk(s).
55566
- ` + shrinkNote(doc.content.length, row?.total_chars ?? totalChars) + `Pass the new content_hash as expected_content_hash on your next edit.${warning2}`;
55670
+ ` + shrinkNote(doc.content, row?.total_chars ?? totalChars, applied) + `Pass the new content_hash as expected_content_hash on your next edit.${warning2}`;
55567
55671
  }
55568
55672
  async function insertHandler(supabase, args, ctx) {
55569
55673
  const documentId = args.document_id?.trim();
@@ -55626,7 +55730,8 @@ var init_partial_edits2 = __esm(() => {
55626
55730
  AUDIT_OP = {
55627
55731
  insert: "insert",
55628
55732
  replace_section: "replace-section",
55629
- delete_section: "delete-section"
55733
+ delete_section: "delete-section",
55734
+ rename_section: "rename-section"
55630
55735
  };
55631
55736
  insertTool = {
55632
55737
  name: "cerefox_insert",
@@ -55677,7 +55782,7 @@ var init_partial_edits2 = __esm(() => {
55677
55782
  };
55678
55783
  editTool = {
55679
55784
  name: "cerefox_edit",
55680
- description: "Change parts of a document without resending the whole thing: one or many operations " + "applied ATOMICALLY in one write. Operations: insert (same positions as cerefox_insert), " + "replace_section (swap a section's body, heading kept), delete_section (remove a section, " + "scope body_only or heading_and_body). Use one call for changes that belong together — a " + "half-applied edit is impossible, so a row and the total it feeds cannot disagree. " + "Operations apply in order and each sees the previous one's result. To change a single " + "line, replace_section on its smallest enclosing heading. Requires expected_content_hash; " + "returns the new hash, not the document.",
55785
+ description: "Change parts of a document without resending the whole thing: one or many operations " + "applied ATOMICALLY in one write. Operations: insert (same positions as cerefox_insert), " + "replace_section (swap a section's body, heading kept), delete_section (remove a section, " + "scope body_only or heading_and_body), rename_section (change a heading's text, leaving " + "its body and position untouched — for headings that go stale, like a dated one). " + "Use one call for changes that belong together — a " + "half-applied edit is impossible, so a row and the total it feeds cannot disagree. " + "Operations apply in order and each sees the previous one's result. To change a single " + "line, replace_section on its smallest enclosing heading. Requires expected_content_hash; " + "returns the new hash, not the document.",
55681
55786
  annotations: {
55682
55787
  title: "Edit document sections",
55683
55788
  readOnlyHint: false,
@@ -55698,7 +55803,10 @@ var init_partial_edits2 = __esm(() => {
55698
55803
  type: "object",
55699
55804
  required: ["op"],
55700
55805
  properties: {
55701
- op: { type: "string", enum: ["insert", "replace_section", "delete_section"] },
55806
+ op: {
55807
+ type: "string",
55808
+ enum: ["insert", "replace_section", "delete_section", "rename_section"]
55809
+ },
55702
55810
  text: { type: "string", description: "Markdown. Required for insert and replace_section." },
55703
55811
  position: {
55704
55812
  type: "string",
@@ -55718,6 +55826,10 @@ var init_partial_edits2 = __esm(() => {
55718
55826
  type: "string",
55719
55827
  enum: ["body_only", "heading_and_body"],
55720
55828
  description: "delete_section only. Defaults to body_only, which keeps the heading."
55829
+ },
55830
+ new_heading: {
55831
+ type: "string",
55832
+ description: "rename_section only: the replacement heading LINE, at the same level (## stays ##). Changes the heading text and nothing else — the body and the section's position are untouched, which is the point: renaming via delete + insert would risk both. Use it for headings that go stale, like '## OPEN TODOs (as of 2026-08-08)'. A rename changes the anchor, so a later operation in the same call must target the NEW heading."
55721
55833
  }
55722
55834
  }
55723
55835
  }
@@ -71898,16 +72010,27 @@ function stringify(obj2, { maxDepth = 1000, numbersAsFloat = false } = {}) {
71898
72010
  */
71899
72011
 
71900
72012
  // src/cli/util/mcp-config-writers.ts
72013
+ init_config();
72014
+ function envForEntry() {
72015
+ const label = (process.env.CEREFOX_ENV_LABEL ?? "").trim();
72016
+ if (!label || mcpServerName() === "cerefox")
72017
+ return;
72018
+ return { CEREFOX_CONFIG_DIR: resolveConfigDir(), CEREFOX_ENV_LABEL: label };
72019
+ }
71901
72020
  function defaultCerefoxEntry() {
72021
+ const env3 = envForEntry();
71902
72022
  return {
71903
72023
  command: "npx",
71904
- args: ["-y", "--package=@cerefox/memory", "cerefox", "mcp"]
72024
+ args: ["-y", "--package=@cerefox/memory", "cerefox", "mcp"],
72025
+ ...env3 ? { env: env3 } : {}
71905
72026
  };
71906
72027
  }
71907
72028
  function localCerefoxEntry() {
72029
+ const env3 = envForEntry();
71908
72030
  return {
71909
72031
  command: process.env.CEREFOX_LOCAL_CMD || "cerefox-local",
71910
- args: ["mcp"]
72032
+ args: ["mcp"],
72033
+ ...env3 ? { env: env3 } : {}
71911
72034
  };
71912
72035
  }
71913
72036
  function claudeCodeUserConfigPath() {
@@ -71924,9 +72047,20 @@ function claudeDesktopConfigPath() {
71924
72047
  return join4(home, ".config", "Claude", "claude_desktop_config.json");
71925
72048
  }
71926
72049
  function claudeCodeDelegated(entry) {
72050
+ const envFlags = Object.entries(entry.env ?? {}).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
71927
72051
  return {
71928
72052
  cmd: "claude",
71929
- args: ["mcp", "add", "cerefox", "--scope", "user", "--", entry.command, ...entry.args]
72053
+ args: [
72054
+ "mcp",
72055
+ "add",
72056
+ mcpServerName(),
72057
+ "--scope",
72058
+ "user",
72059
+ ...envFlags,
72060
+ "--",
72061
+ entry.command,
72062
+ ...entry.args
72063
+ ]
71930
72064
  };
71931
72065
  }
71932
72066
  function cursorConfigPath() {
@@ -72011,7 +72145,7 @@ function directWrite(writer, configPath, opts) {
72011
72145
  }
72012
72146
  const serversKey = format === "toml" ? "mcp_servers" : "mcpServers";
72013
72147
  const servers = (existing[serversKey] && typeof existing[serversKey] === "object" ? existing[serversKey] : {}) ?? {};
72014
- servers.cerefox = entry;
72148
+ servers[mcpServerName()] = entry;
72015
72149
  existing[serversKey] = servers;
72016
72150
  if (!opts.dryRun) {
72017
72151
  const body = format === "toml" ? stringify(existing) + `
@@ -72021,10 +72155,17 @@ function directWrite(writer, configPath, opts) {
72021
72155
  }
72022
72156
  return { configPath, backupPath, action: action6, serverEntry: entry };
72023
72157
  }
72158
+ function mcpServerName() {
72159
+ const label = (process.env.CEREFOX_ENV_LABEL ?? "").trim();
72160
+ if (!label)
72161
+ return "cerefox";
72162
+ const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
72163
+ return slug ? `cerefox-${slug}` : "cerefox";
72164
+ }
72024
72165
  function hasCerefoxEntry(existing, format) {
72025
72166
  const key = format === "toml" ? "mcp_servers" : "mcpServers";
72026
72167
  const servers = existing[key];
72027
- return typeof servers === "object" && servers !== null && servers.cerefox !== undefined;
72168
+ return typeof servers === "object" && servers !== null && servers[mcpServerName()] !== undefined;
72028
72169
  }
72029
72170
  function delegatedWrite(writer, opts) {
72030
72171
  if (!writer.delegated) {
@@ -72093,6 +72234,7 @@ function action6(options) {
72093
72234
  println(c.dim(` backup: ${result.backupPath}`));
72094
72235
  }
72095
72236
  println(c.dim(` action: ${result.action}`));
72237
+ println(c.dim(` server name: ${mcpServerName()}`));
72096
72238
  if (result.delegatedCommand) {
72097
72239
  println(c.dim(` invoked: ${result.delegatedCommand}`));
72098
72240
  }
@@ -76319,8 +76461,8 @@ import { homedir as homedir6 } from "node:os";
76319
76461
  import { join as join9 } from "node:path";
76320
76462
 
76321
76463
  // ../../_shared/ef-meta/index.ts
76322
- var EF_VERSION = "1.3.0";
76323
- var EF_LAST_CHANGED = "1.3.0-beta.4";
76464
+ var EF_VERSION = "1.4.0";
76465
+ var EF_LAST_CHANGED = "1.4.0";
76324
76466
 
76325
76467
  // src/cli/util/checks.ts
76326
76468
  init_config();
@@ -76812,7 +76954,8 @@ async function checkPostgres() {
76812
76954
  }
76813
76955
  let postgres2;
76814
76956
  try {
76815
- postgres2 = (await Promise.resolve().then(() => (init_src(), exports_src))).default;
76957
+ const mod = await Promise.resolve().then(() => (init_src(), exports_src));
76958
+ postgres2 = mod.default;
76816
76959
  } catch (err) {
76817
76960
  return {
76818
76961
  name: "postgres",
@@ -77576,6 +77719,7 @@ class IngestionPipeline {
77576
77719
  text,
77577
77720
  title,
77578
77721
  source = "paste",
77722
+ sourceOnCreate,
77579
77723
  sourceLabel,
77580
77724
  sourcePath: sourcePathOpt,
77581
77725
  projectName,
@@ -77678,7 +77822,8 @@ class IngestionPipeline {
77678
77822
  action: "skipped",
77679
77823
  reindexed: false,
77680
77824
  projectIds: existingProjectIds,
77681
- note: ""
77825
+ note: "",
77826
+ contentHash: existingByHash.content_hash ?? hash
77682
77827
  };
77683
77828
  }
77684
77829
  const chunks = chunkMarkdown(text, this.settings.maxChunkChars, this.settings.minChunkChars);
@@ -77703,7 +77848,7 @@ class IngestionPipeline {
77703
77848
  const rpcResult = await this.db.ingestDocumentRpc({
77704
77849
  documentId: null,
77705
77850
  title,
77706
- source,
77851
+ source: source ?? sourceOnCreate ?? null,
77707
77852
  sourcePath,
77708
77853
  contentHash: hash,
77709
77854
  metadata: validatedMeta,
@@ -77725,7 +77870,8 @@ class IngestionPipeline {
77725
77870
  action: "created",
77726
77871
  reindexed: false,
77727
77872
  projectIds: resolvedIds,
77728
- note: ""
77873
+ note: "",
77874
+ contentHash: hash
77729
77875
  };
77730
77876
  }
77731
77877
  async updateDocument(opts) {
@@ -77817,7 +77963,8 @@ class IngestionPipeline {
77817
77963
  action: "updated",
77818
77964
  reindexed: false,
77819
77965
  projectIds: finalProjectIds2,
77820
- note: ""
77966
+ note: "",
77967
+ contentHash: existing.content_hash ?? newHash
77821
77968
  };
77822
77969
  }
77823
77970
  const chunks = chunkMarkdown(text, this.settings.maxChunkChars, this.settings.minChunkChars);
@@ -77851,7 +77998,7 @@ class IngestionPipeline {
77851
77998
  contentFormat: CONTENT_FORMAT_BLIND_STITCH,
77852
77999
  author,
77853
78000
  authorType,
77854
- sourceLabel: sourceLabel ?? source,
78001
+ sourceLabel: sourceLabel ?? source ?? "manual",
77855
78002
  expectedContentHash: expectedContentHash ?? (forceRechunk && contentUnchanged ? existing.content_hash : null),
77856
78003
  lastWriteWins
77857
78004
  });
@@ -77870,7 +78017,8 @@ class IngestionPipeline {
77870
78017
  action: "updated",
77871
78018
  reindexed: true,
77872
78019
  projectIds: finalProjectIds,
77873
- note: ""
78020
+ note: "",
78021
+ contentHash: newHash
77874
78022
  };
77875
78023
  }
77876
78024
  async ingestFile(path, opts = {}) {
@@ -77881,7 +78029,8 @@ class IngestionPipeline {
77881
78029
  ...opts,
77882
78030
  text,
77883
78031
  title: opts.title ?? stem,
77884
- source: opts.source ?? "file",
78032
+ source: opts.source === undefined ? "file" : opts.source,
78033
+ sourceOnCreate: opts.sourceOnCreate ?? "file",
77885
78034
  sourcePath: absPath
77886
78035
  });
77887
78036
  }
@@ -77953,6 +78102,7 @@ async function action18(path, options) {
77953
78102
  title = data.title;
77954
78103
  println(c.dim(` (keeping existing title: ${JSON.stringify(title)})`));
77955
78104
  }
78105
+ const resolvedSource = options.source ?? null;
77956
78106
  const pipeline = new IngestionPipeline({
77957
78107
  supabase,
77958
78108
  openAiApiKey: settings.openaiApiKey
@@ -77960,7 +78110,8 @@ async function action18(path, options) {
77960
78110
  try {
77961
78111
  const result = path && !options.paste ? await pipeline.ingestFile(path, {
77962
78112
  title,
77963
- source: options.source ?? "cli",
78113
+ source: resolvedSource,
78114
+ sourceOnCreate: "cli",
77964
78115
  projectName: options.projectName ?? null,
77965
78116
  projectNames: projectNames ?? null,
77966
78117
  metadata: metadata ?? null,
@@ -77973,7 +78124,8 @@ async function action18(path, options) {
77973
78124
  }) : await pipeline.ingestText({
77974
78125
  text: content,
77975
78126
  title,
77976
- source: options.source ?? "cli",
78127
+ source: resolvedSource,
78128
+ sourceOnCreate: "cli",
77977
78129
  projectName: options.projectName ?? null,
77978
78130
  projectNames: projectNames ?? null,
77979
78131
  metadata: metadata ?? null,
@@ -77995,13 +78147,16 @@ async function action18(path, options) {
77995
78147
  const projects = result.projectIds.length > 0 ? ` [projects: ${result.projectIds.length}]` : "";
77996
78148
  const note = result.note ? ` (${result.note})` : "";
77997
78149
  println(c.green("✓ ") + `${verb}: ${JSON.stringify(result.title)} (id: ${result.documentId}), ` + `${result.chunkCount} chunk(s), ${result.totalChars} chars.${projects}${note}`);
78150
+ if (result.contentHash) {
78151
+ println(c.dim(` content_hash: ${result.contentHash}`));
78152
+ }
77998
78153
  } catch (err) {
77999
78154
  const msg = err instanceof Error ? err.message : String(err);
78000
78155
  throw systemError(`Ingest failed: ${msg}`);
78001
78156
  }
78002
78157
  }
78003
78158
  function registerIngest(program2) {
78004
- 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 (default: cli).", "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(action18);
78159
+ 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(action18);
78005
78160
  }
78006
78161
 
78007
78162
  // src/cli/commands/document-partial-edit.ts
@@ -78145,7 +78300,8 @@ async function action19(dir, options) {
78145
78300
  try {
78146
78301
  const result = await pipeline.ingestFile(file, {
78147
78302
  title: basename3(file, extname4(file)),
78148
- source: options.source ?? "cli",
78303
+ source: options.source ?? null,
78304
+ sourceOnCreate: "cli",
78149
78305
  projectName: options.projectName ?? null,
78150
78306
  metadata: metadata ?? null,
78151
78307
  updateExisting: Boolean(options.updateIfExists),
@@ -78178,7 +78334,7 @@ async function action19(dir, options) {
78178
78334
  }
78179
78335
  }
78180
78336
  function registerIngestDir(program2) {
78181
- 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 (default: cli).", "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(action19);
78337
+ 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(action19);
78182
78338
  }
78183
78339
 
78184
78340
  // src/cli/commands/init.ts
@@ -83652,7 +83808,7 @@ async function runSearch(ctx, opts) {
83652
83808
  if (!ctx.openAiApiKey && resolveEmbedderKind() !== "local") {
83653
83809
  throw new HttpError(503, "Embedder not available");
83654
83810
  }
83655
- const embedding = await getEmbedding(query, ctx.openAiApiKey);
83811
+ const embedding = await getEmbedding(query, ctx.openAiApiKey ?? "");
83656
83812
  if (mode === "semantic") {
83657
83813
  const params2 = {
83658
83814
  p_query_embedding: embedding,
@@ -84249,7 +84405,7 @@ function registerDocumentWriteRoutes(app, ctx) {
84249
84405
  try {
84250
84406
  const pipeline2 = new IngestionPipeline({
84251
84407
  supabase: ctx.supabase,
84252
- openAiApiKey: ctx.openAiApiKey
84408
+ openAiApiKey: ctx.openAiApiKey ?? ""
84253
84409
  });
84254
84410
  const result = await pipeline2.updateDocument({
84255
84411
  documentId,
@@ -84420,7 +84576,7 @@ function registerIngestRoutes(app, ctx) {
84420
84576
  try {
84421
84577
  const pipeline2 = new IngestionPipeline({
84422
84578
  supabase: ctx.supabase,
84423
- openAiApiKey: ctx.openAiApiKey
84579
+ openAiApiKey: ctx.openAiApiKey ?? ""
84424
84580
  });
84425
84581
  const result = await pipeline2.ingestText({
84426
84582
  text: content.trim(),
@@ -84485,7 +84641,7 @@ function registerIngestRoutes(app, ctx) {
84485
84641
  try {
84486
84642
  const pipeline2 = new IngestionPipeline({
84487
84643
  supabase: ctx.supabase,
84488
- openAiApiKey: ctx.openAiApiKey
84644
+ openAiApiKey: ctx.openAiApiKey ?? ""
84489
84645
  });
84490
84646
  const result = await pipeline2.ingestText({
84491
84647
  text,
@@ -84538,7 +84694,7 @@ function registerIngestRoutes(app, ctx) {
84538
84694
  try {
84539
84695
  const pipeline2 = new IngestionPipeline({
84540
84696
  supabase: ctx.supabase,
84541
- openAiApiKey: ctx.openAiApiKey
84697
+ openAiApiKey: ctx.openAiApiKey ?? ""
84542
84698
  });
84543
84699
  const result = await pipeline2.updateDocument({
84544
84700
  documentId,