@cerefox/memory 1.2.1 → 1.3.0-beta.2

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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Partial document edits — pure string layer (iteration 34).
3
+ *
4
+ * Implements the position/anchor semantics of
5
+ * `docs/specs/partial-document-edits-design.md` §3 exactly. No I/O, no client,
6
+ * no runtime dependencies: everything here is testable against strings alone
7
+ * and runs identically under Deno (Edge Function) and Node/Bun (local MCP,
8
+ * CLI). The handlers in `_shared/mcp-tools/{insert,edit}.ts` compose this with
9
+ * read → chunk → embed → `cerefox_ingest_document`.
10
+ *
11
+ * The one rule that governs every function: **never guess**. An absent anchor
12
+ * is an error, an ambiguous anchor is an error carrying the candidates that
13
+ * resolve it, and an ambiguous position (a section with both its own body and
14
+ * child headings) is an error carrying both concrete insertion points. A
15
+ * silent wrong-location write is strictly worse than a refusal (spec §3.7).
16
+ */
17
+
18
+ /** One section in document order. Offsets are into the parsed string. */
19
+ export interface OutlineNode {
20
+ /** Full heading line, trimmed — e.g. `### Notes`. */
21
+ heading: string;
22
+ /** 1–6. */
23
+ level: number;
24
+ /** ` > `-joined ancestor headings + own — the §3.7 anchor path form. */
25
+ path: string;
26
+ /** Offset of the heading line's first character. */
27
+ start: number;
28
+ /** Offset just past the heading line (start of the section's own body). */
29
+ bodyStart: number;
30
+ /** End of the section's own body: before its first child heading (== subtreeEnd for leaves). */
31
+ ownBodyEnd: number;
32
+ /** Before the next heading of equal-or-higher level (end of the whole subtree). */
33
+ subtreeEnd: number;
34
+ /** subtreeEnd - start: the per-section size reported by outline mode. */
35
+ chars: number;
36
+ }
37
+
38
+ export type InsertPosition =
39
+ | "end_of_document"
40
+ | "end_of_section"
41
+ | "after_heading"
42
+ | "before_heading";
43
+
44
+ export type SectionPart = "own_body" | "subtree";
45
+
46
+ export type EditOperation =
47
+ | {
48
+ op: "insert";
49
+ text: string;
50
+ position: InsertPosition;
51
+ anchor_heading?: string;
52
+ section_part?: SectionPart;
53
+ }
54
+ | {
55
+ op: "replace_section";
56
+ text: string;
57
+ anchor_heading: string;
58
+ section_part?: SectionPart;
59
+ }
60
+ | {
61
+ op: "delete_section";
62
+ anchor_heading: string;
63
+ scope?: "body_only" | "heading_and_body";
64
+ section_part?: SectionPart;
65
+ };
66
+
67
+ /** Echo of one applied operation, for audit labels and response text. */
68
+ export interface AppliedOperation {
69
+ op: "insert" | "replace_section" | "delete_section";
70
+ /** Resolved anchor path (or "(document)" for end_of_document). */
71
+ path: string;
72
+ /** Human summary: position / scope / section_part actually used. */
73
+ detail: string;
74
+ }
75
+
76
+ /** Anchor matched nothing. The write must never fall back to appending. */
77
+ export class AnchorNotFoundError extends Error {
78
+ constructor(anchor: string, outline: OutlineNode[]) {
79
+ const known = outline.length
80
+ ? ` Known headings:\n${outline.map((n) => ` ${n.path}`).join("\n")}`
81
+ : " The document has no headings.";
82
+ super(
83
+ `Anchor not found: "${anchor}". No write was performed.${known}\n` +
84
+ `Anchors match a heading line exactly ("## Title") or a parent path ` +
85
+ `("## Parent > ### Child").`,
86
+ );
87
+ this.name = "AnchorNotFoundError";
88
+ }
89
+ }
90
+
91
+ /** Anchor matched more than one section. Candidates resolve the retry. */
92
+ export class AmbiguousAnchorError extends Error {
93
+ readonly candidates: string[];
94
+ constructor(anchor: string, candidates: string[]) {
95
+ super(
96
+ `Ambiguous anchor: "${anchor}" matches ${candidates.length} sections. ` +
97
+ `No write was performed. Disambiguate by passing one of these paths as anchor_heading:\n` +
98
+ candidates.map((c) => ` ${c}`).join("\n"),
99
+ );
100
+ this.name = "AmbiguousAnchorError";
101
+ this.candidates = candidates;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The anchored section has both its own body and child headings, so "the
107
+ * section" has two defensible readings (spec §3.3). Never guessed.
108
+ */
109
+ export class AmbiguousPositionError extends Error {
110
+ readonly candidates: { section_part: SectionPart; description: string }[];
111
+ constructor(node: OutlineNode, firstChildHeading: string, opName: string) {
112
+ const candidates = [
113
+ {
114
+ section_part: "own_body" as const,
115
+ description: `the section's own content, before its first child (${firstChildHeading})`,
116
+ },
117
+ {
118
+ section_part: "subtree" as const,
119
+ description: `the whole subtree, including everything nested under ${node.heading}`,
120
+ },
121
+ ];
122
+ super(
123
+ `Ambiguous position: "${node.path}" has both its own content and child sections, ` +
124
+ `so ${opName} could target two different ranges. No write was performed. ` +
125
+ `Pass section_part to choose:\n` +
126
+ candidates.map((c) => ` section_part: "${c.section_part}" — ${c.description}`).join("\n"),
127
+ );
128
+ this.name = "AmbiguousPositionError";
129
+ this.candidates = candidates;
130
+ }
131
+ }
132
+
133
+ /** Structural validation failure of an operations array (before any parsing). */
134
+ export class InvalidOperationError extends Error {
135
+ constructor(index: number, message: string) {
136
+ super(`Invalid operation at index ${index}: ${message}. No write was performed.`);
137
+ this.name = "InvalidOperationError";
138
+ }
139
+ }
140
+
141
+ const ATX_HEADING = /^(#{1,6})[ \t]+(.*[^ \t#]|)[ \t]*#*[ \t]*$/;
142
+ const FENCE_OPEN = /^([ \t]{0,3})(`{3,}|~{3,})(.*)$/;
143
+
144
+ /**
145
+ * Parse the outline of a markdown document in one pass.
146
+ *
147
+ * ATX headings only (the chunker's convention). Headings inside fenced code
148
+ * blocks are content, not structure: the scanner tracks the open fence's
149
+ * marker character and length, and only a closing fence at least as long, of
150
+ * the same character, closes it (CommonMark). A decision log quoting markdown
151
+ * WILL contain `#` lines inside fences; treating those as headings would
152
+ * corrupt every anchor computed after them.
153
+ */
154
+ export function parseOutline(content: string): OutlineNode[] {
155
+ const nodes: OutlineNode[] = [];
156
+ const stack: OutlineNode[] = []; // open ancestors, strictly increasing level
157
+ let fence: { char: string; len: number } | null = null;
158
+
159
+ let offset = 0;
160
+ const lines = content.split("\n");
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const line = lines[i];
163
+ const lineStart = offset;
164
+ offset += line.length + (i < lines.length - 1 ? 1 : 0);
165
+
166
+ const fenceMatch = line.match(FENCE_OPEN);
167
+ if (fence) {
168
+ if (
169
+ fenceMatch &&
170
+ fenceMatch[2][0] === fence.char &&
171
+ fenceMatch[2].length >= fence.len &&
172
+ fenceMatch[3].trim() === ""
173
+ ) {
174
+ fence = null; // closing fence
175
+ }
176
+ continue;
177
+ }
178
+ if (fenceMatch) {
179
+ fence = { char: fenceMatch[2][0], len: fenceMatch[2].length };
180
+ continue;
181
+ }
182
+
183
+ const m = line.match(ATX_HEADING);
184
+ if (!m) continue;
185
+
186
+ const level = m[1].length;
187
+ const bodyStart = lineStart + line.length + (i < lines.length - 1 ? 1 : 0);
188
+
189
+ // Close every open section at >= this level.
190
+ while (stack.length && stack[stack.length - 1].level >= level) {
191
+ const closed = stack.pop()!;
192
+ closed.subtreeEnd = lineStart;
193
+ if (closed.ownBodyEnd === -1) closed.ownBodyEnd = lineStart;
194
+ }
195
+ // This heading is the first child of the innermost still-open ancestor.
196
+ if (stack.length && stack[stack.length - 1].ownBodyEnd === -1) {
197
+ stack[stack.length - 1].ownBodyEnd = lineStart;
198
+ }
199
+
200
+ // CommonMark treats a trailing run of #s as decoration, so `## Title ##`
201
+ // and `## Title` name the same section. Store the canonical form: an agent
202
+ // that read the rendered document addresses it as `## Title`, and one that
203
+ // pasted an outline path gets the same string back (resolveAnchor
204
+ // canonicalises the incoming anchor too).
205
+ const heading = `${m[1]} ${m[2].trim()}`.trim();
206
+ const node: OutlineNode = {
207
+ heading,
208
+ level,
209
+ path: [...stack.map((a) => a.heading), heading].join(" > "),
210
+ start: lineStart,
211
+ bodyStart,
212
+ ownBodyEnd: -1, // resolved when the first child or the subtree end is seen
213
+ subtreeEnd: -1,
214
+ chars: 0,
215
+ };
216
+ nodes.push(node);
217
+ stack.push(node);
218
+ }
219
+
220
+ const end = content.length;
221
+ for (const open of stack) {
222
+ open.subtreeEnd = end;
223
+ if (open.ownBodyEnd === -1) open.ownBodyEnd = end;
224
+ }
225
+ for (const n of nodes) n.chars = n.subtreeEnd - n.start;
226
+ return nodes;
227
+ }
228
+
229
+ /**
230
+ * Resolve an anchor per spec §3.7: exact heading text, or a ` > ` parent path.
231
+ * 0 matches → AnchorNotFoundError; 2+ → AmbiguousAnchorError with the paths.
232
+ */
233
+ /** `## Title ##` and `## Title` name the same section (CommonMark decoration). */
234
+ function canonicalHeading(text: string): string {
235
+ const m = text.trim().match(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/);
236
+ return m ? `${m[1]} ${m[2].trim()}`.trim() : text.trim();
237
+ }
238
+
239
+ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): OutlineNode {
240
+ const anchor = canonicalHeading(anchorHeading);
241
+
242
+ // Try the LITERAL heading first, always — including when the anchor contains
243
+ // the path separator. Headings really do contain " > " (`## Draft > Review`,
244
+ // `## A > B`), and treating any such anchor as a path made those sections
245
+ // unaddressable by their own text: the agent would read `## A > B` from the
246
+ // outline, pass it back verbatim, and be told the anchor does not exist while
247
+ // the error listed it. Literal-first also keeps the outline's promise that
248
+ // what it prints can be pasted straight back.
249
+ const byHeading = outline.filter((n) => n.heading === anchor);
250
+ if (byHeading.length === 1) return byHeading[0];
251
+ if (byHeading.length > 1) {
252
+ throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path));
253
+ }
254
+
255
+ // No heading matched literally: interpret it as a parent path.
256
+ if (anchor.includes(" > ")) {
257
+ const normalizedPath = anchor.split(" > ").map((seg) => canonicalHeading(seg)).join(" > ");
258
+ const byPath = outline.filter((n) => n.path === normalizedPath);
259
+ if (byPath.length === 1) return byPath[0];
260
+ if (byPath.length > 1) {
261
+ throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path));
262
+ }
263
+ }
264
+
265
+ throw new AnchorNotFoundError(anchor, outline);
266
+ }
267
+
268
+ /** A section is position-ambiguous iff it has BOTH own body content and children. */
269
+ function hasOwnBody(content: string, node: OutlineNode): boolean {
270
+ return content.slice(node.bodyStart, node.ownBodyEnd).trim().length > 0;
271
+ }
272
+ function firstChild(outline: OutlineNode[], node: OutlineNode): OutlineNode | null {
273
+ for (const n of outline) {
274
+ if (n.start >= node.bodyStart && n.start < node.subtreeEnd) return n;
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /**
280
+ * Resolve the [from, to) range a replace/delete targets, and the end offset an
281
+ * end_of_section insert lands at. Spec §3.3 + the freeze-pass rule: leaf →
282
+ * unambiguous; body+children → require section_part; children-without-body →
283
+ * subtree (the two readings coincide in intent: "everything under it").
284
+ */
285
+ function resolveSectionEnd(
286
+ content: string,
287
+ outline: OutlineNode[],
288
+ node: OutlineNode,
289
+ sectionPart: SectionPart | undefined,
290
+ opName: string,
291
+ destructive: boolean,
292
+ ): number {
293
+ const child = firstChild(outline, node);
294
+ if (!child) return node.subtreeEnd; // leaf: unambiguous
295
+ if (sectionPart === "own_body") return node.ownBodyEnd;
296
+ if (sectionPart === "subtree") return node.subtreeEnd;
297
+
298
+ // Children but no own body. For an INSERT the two readings genuinely
299
+ // coincide — both put the new text at the section's terminus — so there is
300
+ // nothing to ask about.
301
+ //
302
+ // For a DESTRUCTIVE operation they are opposites, and an earlier version of
303
+ // this function got that wrong: `own_body` targets an empty range and would
304
+ // preserve every child, while `subtree` removes all of them. Returning
305
+ // subtreeEnd here meant `delete_section` on a grouping heading silently
306
+ // deleted every sub-section under it — with `scope: "body_only"`, whose
307
+ // whole promise is to keep the structure. Guessing the maximally
308
+ // destructive reading is exactly what §3.6 says none of these operations may
309
+ // do. Found by review, not by the tests, which only covered the insert side.
310
+ if (!hasOwnBody(content, node)) {
311
+ if (!destructive) return node.subtreeEnd;
312
+ throw new AmbiguousPositionError(node, child.heading, opName);
313
+ }
314
+ throw new AmbiguousPositionError(node, child.heading, opName);
315
+ }
316
+
317
+ /**
318
+ * Splice `text` into `content` as a markdown block: exactly one blank line
319
+ * separates it from any non-empty neighbour, existing surrounding blank runs
320
+ * collapse rather than stack. Agents send content; the join owns whitespace.
321
+ */
322
+ function spliceBlock(content: string, from: number, to: number, text: string): string {
323
+ const before = content.slice(0, from).replace(/\n+$/, "");
324
+ const after = content.slice(to).replace(/^\n+/, "").replace(/\n+$/, "");
325
+ const block = text.replace(/^\n+/, "").replace(/\n+$/, "");
326
+
327
+ const parts: string[] = [];
328
+ if (before.length) parts.push(before);
329
+ if (block.length) parts.push(block);
330
+ if (after.length) parts.push(after);
331
+ const joined = parts.join("\n\n");
332
+ // Preserve a single trailing newline if the original ended with one.
333
+ return content.endsWith("\n") && joined.length ? joined + "\n" : joined;
334
+ }
335
+
336
+ function applyOne(
337
+ content: string,
338
+ operation: EditOperation,
339
+ ): { content: string; applied: AppliedOperation } {
340
+ const outline = parseOutline(content);
341
+
342
+ if (operation.op === "insert") {
343
+ const { position, text } = operation;
344
+ if (position === "end_of_document") {
345
+ return {
346
+ content: spliceBlock(content, content.length, content.length, text),
347
+ applied: { op: "insert", path: "(document)", detail: "insert at end_of_document" },
348
+ };
349
+ }
350
+ if (!operation.anchor_heading) {
351
+ // Validated earlier for real callers; guarded here for direct users.
352
+ throw new InvalidOperationError(0, `insert with position "${position}" requires anchor_heading`);
353
+ }
354
+ const node = resolveAnchor(outline, operation.anchor_heading);
355
+ let at: number;
356
+ let detail: string;
357
+ if (position === "before_heading") {
358
+ at = node.start;
359
+ detail = "insert before_heading";
360
+ } else if (position === "after_heading") {
361
+ at = node.bodyStart;
362
+ detail = "insert after_heading";
363
+ } else {
364
+ at = resolveSectionEnd(content, outline, node, operation.section_part, "end_of_section insert", false);
365
+ detail =
366
+ `insert at end_of_section` +
367
+ (operation.section_part ? ` (${operation.section_part})` : "");
368
+ }
369
+ return {
370
+ content: spliceBlock(content, at, at, text),
371
+ applied: { op: "insert", path: node.path, detail },
372
+ };
373
+ }
374
+
375
+ if (operation.op === "replace_section") {
376
+ const node = resolveAnchor(outline, operation.anchor_heading);
377
+ const to = resolveSectionEnd(content, outline, node, operation.section_part, "replace_section", true);
378
+ return {
379
+ content: spliceBlock(content, node.bodyStart, to, operation.text),
380
+ applied: {
381
+ op: "replace_section",
382
+ path: node.path,
383
+ detail:
384
+ "replace_section body" + (operation.section_part ? ` (${operation.section_part})` : ""),
385
+ },
386
+ };
387
+ }
388
+
389
+ // delete_section
390
+ const node = resolveAnchor(outline, operation.anchor_heading);
391
+ const scope = operation.scope ?? "body_only";
392
+ const to = resolveSectionEnd(content, outline, node, operation.section_part, "delete_section", true);
393
+ const from = scope === "heading_and_body" ? node.start : node.bodyStart;
394
+ return {
395
+ content: spliceBlock(content, from, to, ""),
396
+ applied: {
397
+ op: "delete_section",
398
+ path: node.path,
399
+ detail:
400
+ `delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : ""),
401
+ },
402
+ };
403
+ }
404
+
405
+ /** Structural validation of an operations array — index-precise, before any work. */
406
+ export function validateOperations(operations: unknown): EditOperation[] {
407
+ if (!Array.isArray(operations) || operations.length === 0) {
408
+ throw new InvalidOperationError(0, "operations must be a non-empty array");
409
+ }
410
+ return operations.map((raw, i) => {
411
+ if (typeof raw !== "object" || raw === null) {
412
+ throw new InvalidOperationError(i, "each operation must be an object");
413
+ }
414
+ const o = raw as Record<string, unknown>;
415
+ const op = o.op;
416
+ if (op === "insert") {
417
+ const position = o.position;
418
+ if (
419
+ position !== "end_of_document" &&
420
+ position !== "end_of_section" &&
421
+ position !== "after_heading" &&
422
+ position !== "before_heading"
423
+ ) {
424
+ throw new InvalidOperationError(
425
+ i,
426
+ "insert requires position: end_of_document | end_of_section | after_heading | before_heading",
427
+ );
428
+ }
429
+ if (typeof o.text !== "string" || o.text.trim() === "") {
430
+ throw new InvalidOperationError(i, "insert requires non-empty text");
431
+ }
432
+ if (position !== "end_of_document" && typeof o.anchor_heading !== "string") {
433
+ throw new InvalidOperationError(i, `insert at ${position} requires anchor_heading`);
434
+ }
435
+ if (position === "end_of_document" && o.anchor_heading !== undefined) {
436
+ throw new InvalidOperationError(i, "end_of_document takes no anchor_heading");
437
+ }
438
+ } else if (op === "replace_section") {
439
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
440
+ throw new InvalidOperationError(i, "replace_section requires anchor_heading");
441
+ }
442
+ if (typeof o.text !== "string" || o.text.trim() === "") {
443
+ throw new InvalidOperationError(i, "replace_section requires non-empty text");
444
+ }
445
+ } else if (op === "delete_section") {
446
+ if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
447
+ throw new InvalidOperationError(i, "delete_section requires anchor_heading");
448
+ }
449
+ if (o.scope !== undefined && o.scope !== "body_only" && o.scope !== "heading_and_body") {
450
+ throw new InvalidOperationError(i, "scope must be body_only or heading_and_body");
451
+ }
452
+ } else {
453
+ throw new InvalidOperationError(i, "op must be insert | replace_section | delete_section");
454
+ }
455
+ if (
456
+ o.section_part !== undefined &&
457
+ o.section_part !== "own_body" &&
458
+ o.section_part !== "subtree"
459
+ ) {
460
+ throw new InvalidOperationError(i, "section_part must be own_body or subtree");
461
+ }
462
+ return raw as EditOperation;
463
+ });
464
+ }
465
+
466
+ /**
467
+ * Apply operations in order against the evolving text (spec §3.4). All or
468
+ * nothing is upheld by construction: this function either returns the fully
469
+ * assembled result or throws before the caller writes anything — the write
470
+ * itself is a single ingest call downstream. Errors are re-thrown with the
471
+ * failing index prefixed so a batch caller can report it.
472
+ */
473
+ export function applyOperations(
474
+ content: string,
475
+ operations: EditOperation[],
476
+ ): { content: string; applied: AppliedOperation[] } {
477
+ let current = content;
478
+ const applied: AppliedOperation[] = [];
479
+ for (let i = 0; i < operations.length; i++) {
480
+ try {
481
+ const result = applyOne(current, operations[i]);
482
+ current = result.content;
483
+ applied.push(result.applied);
484
+ } catch (err) {
485
+ if (err instanceof InvalidOperationError) throw err;
486
+ const msg = err instanceof Error ? err.message : String(err);
487
+ const wrapped = new Error(`Operation ${i + 1} of ${operations.length} failed: ${msg}`);
488
+ wrapped.name = err instanceof Error ? err.name : "Error";
489
+ throw wrapped;
490
+ }
491
+ }
492
+ return { content: current, applied };
493
+ }
@@ -0,0 +1,49 @@
1
+ -- 0019_partial_edit_audit_ops.sql — make partial edits auditable (iteration 33).
2
+ --
3
+ -- `cerefox_audit_log.operation` is CHECK-constrained, so the partial-edit
4
+ -- operations need to be admitted before `cerefox_ingest_document` can record
5
+ -- them. Three new values:
6
+ --
7
+ -- insert -- cerefox_insert, and insert operations inside cerefox_edit
8
+ -- replace-section -- cerefox_edit
9
+ -- delete-section -- cerefox_edit
10
+ --
11
+ -- Why distinct values rather than logging everything as 'update-content': the
12
+ -- audit trail exists to answer "what did someone do to this document", and
13
+ -- *added a paragraph*, *rewrote a section* and *removed a section* are
14
+ -- different answers even though all three are implemented with the same ingest
15
+ -- primitive underneath. A trail that flattens them cannot distinguish an agent
16
+ -- that appended from one that re-sent the whole document and dropped half of
17
+ -- it, which is much of what the trail is for. Design:
18
+ -- docs/specs/partial-document-edits-design.md §6.1.
19
+ --
20
+ -- Three values, not one per position: whether an insert landed at
21
+ -- `end_of_document` or `end_of_section` is detail about the same intent and
22
+ -- lives in the entry's description, so adding a position later never needs a
23
+ -- schema change.
24
+ --
25
+ -- The constraint stays the allow-list. A handler label that drifts from this
26
+ -- set aborts its transaction rather than silently recording an operation the
27
+ -- readers of the trail cannot interpret.
28
+ --
29
+ -- Idempotent: drops and re-adds the constraint.
30
+
31
+ ALTER TABLE cerefox_audit_log
32
+ DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
33
+
34
+ ALTER TABLE cerefox_audit_log
35
+ ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
36
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
37
+ 'status-change', 'archive', 'unarchive', 'restore',
38
+ 'relation-set', 'relation-delete',
39
+ 'insert', 'replace-section', 'delete-section')
40
+ );
41
+
42
+ DO $$
43
+ BEGIN
44
+ RAISE NOTICE
45
+ 'Migration 0019: audit log accepts insert / replace-section / '
46
+ 'delete-section (iteration 33, partial document edits). '
47
+ 'cerefox_ingest_document also now returns content_hash on create (#189) '
48
+ 'and a size_warning flag; both arrive with rpcs.sql on this deploy.';
49
+ END $$;