@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.
- package/AGENT_GUIDE.md +67 -5
- package/AGENT_QUICK_REFERENCE.md +32 -3
- package/README.md +13 -3
- package/dist/bin/cerefox.js +2694 -1968
- package/dist/server-assets/_shared/ef-meta/index.ts +2 -2
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +43 -1
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +5 -4
- package/dist/server-assets/_shared/mcp-tools/index.ts +7 -1
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +9 -2
- package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +489 -0
- package/dist/server-assets/_shared/partial-edits/index.ts +493 -0
- package/dist/server-assets/db/migrations/0019_partial_edit_audit_ops.sql +49 -0
- package/dist/server-assets/db/rpcs.sql +105 -20
- package/dist/server-assets/db/schema.sql +6 -2
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +4 -0
- package/docs/guides/cli.md +5 -3
- package/docs/guides/configuration.md +8 -0
- package/docs/guides/connect-agents.md +11 -9
- package/docs/guides/operational-cost.md +1 -1
- package/docs/guides/ops-scripts.md +15 -2
- package/package.json +1 -1
|
@@ -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 $$;
|