@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.
@@ -62,25 +62,44 @@ export type EditOperation =
62
62
  anchor_heading: string;
63
63
  scope?: "body_only" | "heading_and_body";
64
64
  section_part?: SectionPart;
65
+ }
66
+ | {
67
+ op: "rename_section";
68
+ anchor_heading: string;
69
+ new_heading: string;
65
70
  };
66
71
 
67
72
  /** Echo of one applied operation, for audit labels and response text. */
68
73
  export interface AppliedOperation {
69
- op: "insert" | "replace_section" | "delete_section";
74
+ op: "insert" | "replace_section" | "delete_section" | "rename_section";
70
75
  /** Resolved anchor path (or "(document)" for end_of_document). */
71
76
  path: string;
72
77
  /** Human summary: position / scope / section_part actually used. */
73
78
  detail: string;
79
+ /**
80
+ * For destructive ops: did the targeted section run to the END of the
81
+ * document it was applied to? (#196 — the last section owns anything
82
+ * appended after it, which is the loss that surprises people.)
83
+ *
84
+ * Decided HERE, at resolution time, rather than by re-parsing the pre-batch
85
+ * document later. In a batch, each operation resolves against the content
86
+ * left by the previous one, so a `rename_section` earlier in the same call
87
+ * means a later op's `path` names a heading the pre-batch outline has never
88
+ * heard of — and a path lookup against that outline silently finds nothing.
89
+ * That is precisely the batch shape `rename_section` was added to enable.
90
+ */
91
+ reachedEnd?: boolean;
74
92
  }
75
93
 
76
94
  /** Anchor matched nothing. The write must never fall back to appending. */
77
95
  export class AnchorNotFoundError extends Error {
78
- constructor(anchor: string, outline: OutlineNode[]) {
96
+ /** `reads` — see AmbiguousPositionError: a read never attempted a write. */
97
+ constructor(anchor: string, outline: OutlineNode[], reads = false) {
79
98
  const known = outline.length
80
99
  ? ` Known headings:\n${outline.map((n) => ` ${n.path}`).join("\n")}`
81
100
  : " The document has no headings.";
82
101
  super(
83
- `Anchor not found: "${anchor}". No write was performed.${known}\n` +
102
+ `Anchor not found: "${anchor}".${reads ? "" : " No write was performed."}${known}\n` +
84
103
  `Anchors match a heading line exactly ("## Title") or a parent path ` +
85
104
  `("## Parent > ### Child").`,
86
105
  );
@@ -91,10 +110,11 @@ export class AnchorNotFoundError extends Error {
91
110
  /** Anchor matched more than one section. Candidates resolve the retry. */
92
111
  export class AmbiguousAnchorError extends Error {
93
112
  readonly candidates: string[];
94
- constructor(anchor: string, candidates: string[]) {
113
+ /** `reads` — see AmbiguousPositionError: a read never attempted a write. */
114
+ constructor(anchor: string, candidates: string[], reads = false) {
95
115
  super(
96
116
  `Ambiguous anchor: "${anchor}" matches ${candidates.length} sections. ` +
97
- `No write was performed. Disambiguate by passing one of these paths as anchor_heading:\n` +
117
+ `${reads ? "" : "No write was performed. "}Disambiguate by passing one of these paths as anchor_heading:\n` +
98
118
  candidates.map((c) => ` ${c}`).join("\n"),
99
119
  );
100
120
  this.name = "AmbiguousAnchorError";
@@ -108,7 +128,13 @@ export class AmbiguousAnchorError extends Error {
108
128
  */
109
129
  export class AmbiguousPositionError extends Error {
110
130
  readonly candidates: { section_part: SectionPart; description: string }[];
111
- constructor(node: OutlineNode, firstChildHeading: string, opName: string) {
131
+ /**
132
+ * `reads` marks a caller that only inspects content (#198's section read).
133
+ * The reassurance "No write was performed" is meant to stop an agent
134
+ * retrying a half-applied batch — on a read it is noise at best, and at
135
+ * worst it implies a write was attempted when none ever could be.
136
+ */
137
+ constructor(node: OutlineNode, firstChildHeading: string, opName: string, reads = false) {
112
138
  const candidates = [
113
139
  {
114
140
  section_part: "own_body" as const,
@@ -126,7 +152,7 @@ export class AmbiguousPositionError extends Error {
126
152
  super(
127
153
  `Ambiguous position: "${node.path}" has child sections, so "the end of ` +
128
154
  `the section" could mean two different places and ${opName} will not guess. ` +
129
- `No write was performed. Pass section_part to choose:\n` +
155
+ `${reads ? "" : "No write was performed. "}Pass section_part to choose:\n` +
130
156
  candidates.map((c) => ` section_part: "${c.section_part}" — ${c.description}`).join("\n"),
131
157
  );
132
158
  this.name = "AmbiguousPositionError";
@@ -134,6 +160,30 @@ export class AmbiguousPositionError extends Error {
134
160
  }
135
161
  }
136
162
 
163
+ /**
164
+ * A rename asked to change the heading's LEVEL, not just its text.
165
+ *
166
+ * `## Q3 plan` → `### Q3 plan` re-parents everything under it: the section
167
+ * stops being a sibling of its neighbours and becomes a child of whichever
168
+ * section precedes it. That is a restructure with a different blast radius
169
+ * from a rename, and doing it silently under the name "rename" is exactly the
170
+ * kind of surprise this contract refuses (#197).
171
+ */
172
+ export class HeadingLevelChangeError extends Error {
173
+ constructor(fromHeading: string, toHeading: string) {
174
+ const from = (fromHeading.match(/^#+/) ?? [""])[0].length;
175
+ const to = (toHeading.match(/^#+/) ?? [""])[0].length;
176
+ super(
177
+ `rename_section changes heading TEXT, not depth: "${fromHeading}" is level ` +
178
+ `${from} and "${toHeading}" is level ${to}. No write was performed. ` +
179
+ `Changing the level would re-parent every section nested under it. ` +
180
+ `Pass a level-${from} heading (${"#".repeat(from)} ...), or restructure ` +
181
+ `explicitly with delete_section + insert if that is what you meant.`,
182
+ );
183
+ this.name = "HeadingLevelChangeError";
184
+ }
185
+ }
186
+
137
187
  /** Structural validation failure of an operations array (before any parsing). */
138
188
  export class InvalidOperationError extends Error {
139
189
  constructor(index: number, message: string) {
@@ -240,7 +290,11 @@ function canonicalHeading(text: string): string {
240
290
  return m ? `${m[1]} ${m[2].trim()}`.trim() : text.trim();
241
291
  }
242
292
 
243
- export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): OutlineNode {
293
+ export function resolveAnchor(
294
+ outline: OutlineNode[],
295
+ anchorHeading: string,
296
+ reads = false,
297
+ ): OutlineNode {
244
298
  const anchor = canonicalHeading(anchorHeading);
245
299
 
246
300
  // Try the LITERAL heading first, always — including when the anchor contains
@@ -253,7 +307,7 @@ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): Ou
253
307
  const byHeading = outline.filter((n) => n.heading === anchor);
254
308
  if (byHeading.length === 1) return byHeading[0];
255
309
  if (byHeading.length > 1) {
256
- throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path));
310
+ throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path), reads);
257
311
  }
258
312
 
259
313
  // No heading matched literally: interpret it as a parent path.
@@ -262,11 +316,62 @@ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): Ou
262
316
  const byPath = outline.filter((n) => n.path === normalizedPath);
263
317
  if (byPath.length === 1) return byPath[0];
264
318
  if (byPath.length > 1) {
265
- throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path));
319
+ throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path), reads);
266
320
  }
267
321
  }
268
322
 
269
- throw new AnchorNotFoundError(anchor, outline);
323
+ throw new AnchorNotFoundError(anchor, outline, reads);
324
+ }
325
+
326
+ /**
327
+ * The text a `replace_section` would overwrite — resolved through the SAME
328
+ * functions the write uses (#198).
329
+ *
330
+ * v1.3.0 shipped `replace_section` with no way to see what it was about to
331
+ * destroy: outline mode reports a section's *size*, never its *text*, so the
332
+ * only safe preparation was a full `get_document` — the cost partial edits
333
+ * exist to remove. `cerefox_insert` is guarded structurally (it cannot remove
334
+ * anything); the destructive operation was guarded only by
335
+ * `expected_content_hash`, which protects against a *concurrent* writer, not
336
+ * against a writer who does not know what it is deleting.
337
+ *
338
+ * The binding requirement is that `text` is EXACTLY the extent
339
+ * `replace_section` targets under the same `section_part`. If a read could
340
+ * differ from the write it feeds, the feature would be worse than its absence:
341
+ * absence at least announces itself. That is why this shares `resolveAnchor`
342
+ * and `resolveSectionEnd` rather than reproducing their rules — including the
343
+ * refusal on a section with children, which is the case most likely to diverge.
344
+ *
345
+ * `heading` is returned separately because it is context, not content:
346
+ * `replace_section` keeps the heading, so it is not part of what would be
347
+ * overwritten.
348
+ */
349
+ export function extractSection(
350
+ content: string,
351
+ anchorHeading: string,
352
+ sectionPart?: SectionPart,
353
+ ): {
354
+ heading: string;
355
+ path: string;
356
+ level: number;
357
+ text: string;
358
+ chars: number;
359
+ section_part: SectionPart | null;
360
+ } {
361
+ const outline = parseOutline(content);
362
+ const node = resolveAnchor(outline, anchorHeading, true);
363
+ // Same op label the write would raise under, so an ambiguity refusal reads
364
+ // the same whether the caller was reading or replacing.
365
+ const to = resolveSectionEnd(content, outline, node, sectionPart, "the section read", true);
366
+ const text = content.slice(node.bodyStart, to);
367
+ return {
368
+ heading: node.heading,
369
+ path: node.path,
370
+ level: node.level,
371
+ text,
372
+ chars: text.length,
373
+ section_part: sectionPart ?? null,
374
+ };
270
375
  }
271
376
 
272
377
  function firstChild(outline: OutlineNode[], node: OutlineNode): OutlineNode | null {
@@ -288,6 +393,7 @@ function resolveSectionEnd(
288
393
  node: OutlineNode,
289
394
  sectionPart: SectionPart | undefined,
290
395
  opName: string,
396
+ reads = false,
291
397
  ): number {
292
398
  const child = firstChild(outline, node);
293
399
  if (!child) return node.subtreeEnd; // leaf: unambiguous
@@ -315,7 +421,7 @@ function resolveSectionEnd(
315
421
  //
316
422
  // So: no exemption. `hasOwnBody` no longer gates anything, because the
317
423
  // presence of children is what makes "the end of this section" ambiguous.
318
- throw new AmbiguousPositionError(node, child.heading, opName);
424
+ throw new AmbiguousPositionError(node, child.heading, opName, reads);
319
425
  }
320
426
 
321
427
  /**
@@ -386,6 +492,34 @@ function applyOne(
386
492
  path: node.path,
387
493
  detail:
388
494
  "replace_section body" + (operation.section_part ? ` (${operation.section_part})` : ""),
495
+ reachedEnd: to >= content.trimEnd().length,
496
+ },
497
+ };
498
+ }
499
+
500
+ if (operation.op === "rename_section") {
501
+ const node = resolveAnchor(outline, operation.anchor_heading);
502
+ const next = canonicalHeading(operation.new_heading);
503
+ const m = next.match(ATX_HEADING);
504
+ if (!m) {
505
+ throw new InvalidOperationError(
506
+ 0,
507
+ `new_heading must be a markdown heading line like "## Title", got ${JSON.stringify(operation.new_heading)}`,
508
+ );
509
+ }
510
+ if (m[1].length !== node.level) throw new HeadingLevelChangeError(node.heading, next);
511
+
512
+ // Replace the heading LINE only. Not spliceBlock: that normalises
513
+ // surrounding blank lines, which is right for a block of body text and
514
+ // wrong for a single line whose neighbours are the section's own spacing.
515
+ const hadNewline = content[node.bodyStart - 1] === "\n";
516
+ const replacement = next + (hadNewline ? "\n" : "");
517
+ return {
518
+ content: content.slice(0, node.start) + replacement + content.slice(node.bodyStart),
519
+ applied: {
520
+ op: "rename_section",
521
+ path: node.path,
522
+ detail: `rename_section to ${next}`,
389
523
  },
390
524
  };
391
525
  }
@@ -402,6 +536,7 @@ function applyOne(
402
536
  path: node.path,
403
537
  detail:
404
538
  `delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : ""),
539
+ reachedEnd: to >= content.trimEnd().length,
405
540
  },
406
541
  };
407
542
  }
@@ -453,8 +588,33 @@ export function validateOperations(operations: unknown): EditOperation[] {
453
588
  if (o.scope !== undefined && o.scope !== "body_only" && o.scope !== "heading_and_body") {
454
589
  throw new InvalidOperationError(i, "scope must be body_only or heading_and_body");
455
590
  }
591
+ } else if (op === "rename_section") {
592
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
593
+ throw new InvalidOperationError(i, "rename_section requires anchor_heading");
594
+ }
595
+ if (typeof o.new_heading !== "string" || o.new_heading.trim() === "") {
596
+ throw new InvalidOperationError(i, "rename_section requires non-empty new_heading");
597
+ }
598
+ // A rename touches the heading line and nothing else, so a caller that
599
+ // also passed body text has misunderstood which operation they want.
600
+ // Silently ignoring it would lose the text without saying so.
601
+ if (o.text !== undefined) {
602
+ throw new InvalidOperationError(
603
+ i,
604
+ "rename_section changes only the heading and takes no text — use replace_section for the body, or both operations in one call",
605
+ );
606
+ }
607
+ if (o.section_part !== undefined) {
608
+ throw new InvalidOperationError(
609
+ i,
610
+ "rename_section takes no section_part: it replaces the heading line, so no extent is involved",
611
+ );
612
+ }
456
613
  } else {
457
- throw new InvalidOperationError(i, "op must be insert | replace_section | delete_section");
614
+ throw new InvalidOperationError(
615
+ i,
616
+ "op must be insert | replace_section | delete_section | rename_section",
617
+ );
458
618
  }
459
619
  if (
460
620
  o.section_part !== undefined &&
@@ -0,0 +1,36 @@
1
+ -- 0021_rename_section_audit_op.sql — audit the rename operation (iteration 35, #197).
2
+ --
3
+ -- `cerefox_audit_log.operation` is CHECK-constrained, so a new partial-edit
4
+ -- operation cannot be recorded until the constraint knows about it. Migration
5
+ -- 0019 widened it for insert / replace-section / delete-section; this adds
6
+ -- rename-section.
7
+ --
8
+ -- Why the operation exists: there was no way to change a heading's own text.
9
+ -- `replace_section` preserves the heading by design, and delete + re-insert
10
+ -- sacrifices the section's body and position to fix the heading — an agent hit
11
+ -- this on real work (a heading whose date had gone stale), judged that trade
12
+ -- wrong, and left the document stale rather than risk the body. Renaming is
13
+ -- therefore its own operation, and one that structurally cannot touch a body.
14
+ --
15
+ -- Schema version 0.11.0 → 0.11.1. Additive: the constraint only widens, so an
16
+ -- older client against this database is unaffected, and this database against
17
+ -- an older client simply never sees the new value.
18
+
19
+ ALTER TABLE cerefox_audit_log
20
+ DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
21
+
22
+ ALTER TABLE cerefox_audit_log
23
+ ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
24
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
25
+ 'status-change', 'archive', 'unarchive', 'restore',
26
+ 'relation-set', 'relation-delete',
27
+ 'insert', 'replace-section', 'delete-section',
28
+ 'rename-section')
29
+ );
30
+
31
+ DO $$
32
+ BEGIN
33
+ RAISE NOTICE
34
+ 'Migration 0021: audit log accepts rename-section (iteration 35, #197). '
35
+ 'Schema version 0.11.1 — re-apply rpcs.sql on this deploy.';
36
+ END $$;
@@ -2425,10 +2425,11 @@ SET search_path = public, pg_catalog
2425
2425
  AS $$
2426
2426
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
2427
2427
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
2428
+ -- 0.11.1 (iteration 35, #197): audit CHECK accepts 'rename-section'.
2428
2429
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
2429
2430
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
2430
2431
  -- sequence, so a store deploying this gets everything from both lines.
2431
- SELECT '0.11.0'::TEXT;
2432
+ SELECT '0.11.1'::TEXT;
2432
2433
  $$;
2433
2434
 
2434
2435
  -- ── cerefox_content_format_stats ─────────────────────────────────────────────
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.11.0
8
+ -- @version: 0.11.1
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -129,7 +129,10 @@ CREATE TABLE IF NOT EXISTS cerefox_audit_log (
129
129
  -- iteration 33: partial edits. Distinct from 'update-content'
130
130
  -- so the trail separates "added to" from "rewrote" from
131
131
  -- "removed" — one entry per operation in a cerefox_edit batch.
132
- 'insert', 'replace-section', 'delete-section')
132
+ 'insert', 'replace-section', 'delete-section',
133
+ -- iteration 35 (#197): renaming a heading is neither a
134
+ -- rewrite nor a removal, and the trail should say so.
135
+ 'rename-section')
133
136
  ),
134
137
  CONSTRAINT cerefox_audit_log_author_type_check CHECK (author_type IN ('user', 'agent'))
135
138
  );
@@ -273,7 +273,7 @@ cerefox document get <document-id> --version-id <version-id>
273
273
  | Variable | Default | Description |
274
274
  |----------|---------|-------------|
275
275
  | `CEREFOX_BACKUP_DIR` | `~/.cerefox/backups` | Local directory where file system backups are stored. Created automatically if it doesn't exist. **Use an absolute path** — a relative value (such as the pre-v0.3.0 `./backups`) resolves against the current working directory, so snapshots scatter depending on where you run the command; `backup create` warns when it sees one. Does **not** follow `CEREFOX_CONFIG_DIR`, so a second environment must set it explicitly. |
276
- | `CEREFOX_ENV_LABEL` | _(unset)_ | Names a non-production environment (e.g. `staging`). Purely cosmetic and inert when unset. When set: the web UI shows a banner on every page, `doctor` shows `[LABEL]` on its title line, `backup create` puts the label in the snapshot filename and payload, and `backup restore` warns when a snapshot's environment differs from the target's. See [`staging-env.md`](staging-env.md). |
276
+ | `CEREFOX_ENV_LABEL` | _(unset)_ | Names a non-production environment (e.g. `staging`). Purely cosmetic and inert when unset. When set: the web UI shows a banner on every page, `doctor` shows `[LABEL]` on its title line, `backup create` puts the label in the snapshot filename and payload, and `backup restore` warns when a snapshot's environment differs from the target's, and `configure-agent` registers the MCP server as `cerefox-<label>` so a labelled environment sits alongside production instead of overwriting it (v1.4.0, #168). See [`staging-env.md`](staging-env.md). |
277
277
  | `CEREFOX_VERSION_RETENTION_HOURS` | **Retired in v1.1.0 — no longer read.** | Version retention is now a property of the store: `cerefox config set version_retention_hours <hours>`, or the **Settings** page. It moved because it used to be passed per-call from each client's environment, so the surviving history depended on which client wrote last. `cerefox doctor` reports the variable if it is still set. |
278
278
 
279
279
  ---
@@ -166,7 +166,20 @@ cerefox configure-agent --tool gemini # ~/.gemini/settings.json
166
166
  Useful flags: `--dry-run` (print the planned write without touching any file), `--json`
167
167
  (machine-readable result), `--config-path <path>` (override the target file), `--no-backup`
168
168
  (skip the `.pre-cerefox.bak` backup). The command is idempotent and backs up any existing
169
- config before writing. The per-client sections below document the same entries for anyone
169
+ config before writing.
170
+
171
+ The entry is registered under the server name `cerefox`. If `CEREFOX_ENV_LABEL` is
172
+ set — as it is for a [staging environment](staging-env.md) — the name becomes
173
+ `cerefox-<label>` instead, so a second environment sits **alongside** your production
174
+ entry rather than replacing it, and an agent can hold both at once (v1.4.0, #168).
175
+ The command prints the name it used.
176
+
177
+ A labelled entry additionally carries `CEREFOX_CONFIG_DIR` and `CEREFOX_ENV_LABEL`
178
+ in its `env` block. That is what makes it actually reach the environment it is named
179
+ after: MCP clients spawn a stdio server with the **client's** environment, not the
180
+ shell you ran `configure-agent` in, and a desktop client launched from the dock has
181
+ no shell environment at all. A production entry carries no `env` — unchanged from
182
+ earlier releases. The per-client sections below document the same entries for anyone
170
183
  who prefers to edit by hand or needs the remote (`Path A-Remote`) HTTP transport instead.
171
184
 
172
185
  ### Path A MCP tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",