@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,489 @@
1
+ /**
2
+ * `cerefox_insert` and `cerefox_edit` — partial document edits (iteration 34).
3
+ *
4
+ * Spec: docs/specs/partial-document-edits-design.md (frozen).
5
+ * Technical design: docs/specs/partial-edits-technical-design.md §3.
6
+ *
7
+ * Both tools share one flow, so the additive tool cannot drift from the batch's
8
+ * insert semantics:
9
+ *
10
+ * read → apply (pure, _shared/partial-edits) → chunk → embed → ingest RPC
11
+ *
12
+ * The agent sends what changed; the server assembles the result. The document
13
+ * body never enters the agent's context on the way in *or* out — that is the
14
+ * whole point, and it is why the response carries the new hash and size rather
15
+ * than the document (spec §3.8).
16
+ *
17
+ * Two tools rather than one because MCP annotations are per-tool: `cerefox_edit`
18
+ * must declare itself destructive, and folding insert into it would make every
19
+ * additive write prompt like a delete (spec §3.2). `cerefox_insert` is
20
+ * structurally incapable of removing content, which is exactly the guarantee
21
+ * that prevents the scope-confusion data loss in spec §1.
22
+ */
23
+
24
+ import {
25
+ applyOperations,
26
+ validateOperations,
27
+ type AppliedOperation,
28
+ type EditOperation,
29
+ } from "../partial-edits/index.ts";
30
+ import {
31
+ chunkMarkdown,
32
+ embeddingInputFor,
33
+ CONTENT_FORMAT_BLIND_STITCH,
34
+ normalizeContent,
35
+ sha256hex,
36
+ } from "./_chunker.ts";
37
+ import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
38
+ import { logUsage } from "./_utils.ts";
39
+ import { McpInvalidParams, type MCPSupabaseClient, type ToolContext, type ToolDefinition } from "./types.ts";
40
+
41
+ /**
42
+ * Who to record as the author. Derived from the access path rather than taken
43
+ * on trust: an agent calling over MCP must not be able to claim `author_type:
44
+ * "user"` and route its writes around a governance filter. Only the CLI, where
45
+ * a human ran the command, may state it — and only to say `user`.
46
+ */
47
+ function resolveAuthorType(ctx: ToolContext, args: Record<string, unknown>): "user" | "agent" {
48
+ if (ctx.accessPath !== "cli") return "agent";
49
+ return args.author_type === "agent" ? "agent" : "user";
50
+ }
51
+
52
+ function defaultRequestor(ctx: ToolContext): string {
53
+ return ctx.accessPath === "cli" ? "cli-user" : "mcp-agent";
54
+ }
55
+
56
+ /** Audit `operation` values, matching the CHECK constraint widened by migration 0019. */
57
+ const AUDIT_OP: Record<AppliedOperation["op"], string> = {
58
+ insert: "insert",
59
+ replace_section: "replace-section",
60
+ delete_section: "delete-section",
61
+ };
62
+
63
+ /**
64
+ * Conflict text mirrors `ingest.ts`'s, but points at the partial-edit retry:
65
+ * re-read, re-decide, retry — deliberately NOT "overwrite anyway", because
66
+ * these tools have no last_write_wins (spec §5: a conflict is information the
67
+ * agent needs, and suppressing it is what destroys another writer's work).
68
+ */
69
+ function conflictError(documentId: string, expectedHash: string, currentHash: string): Error {
70
+ return new Error(
71
+ `Conflict: document ${documentId} changed since you read it ` +
72
+ `(your base hash: ${expectedHash}, current hash: ${currentHash}). No write was performed. ` +
73
+ `To resolve: (1) cerefox_get_document("${documentId}", outline=true) to see the current ` +
74
+ `structure and hash cheaply, or a full read if you need the text, (2) decide whether your ` +
75
+ `edit still applies — another writer may have already made it, or made something it ` +
76
+ `contradicts, (3) retry with expected_content_hash set to the current hash. ` +
77
+ `These tools have no last-write-wins: the other writer's work is not yours to discard.`,
78
+ );
79
+ }
80
+
81
+ interface DocRow {
82
+ doc_title?: string;
83
+ full_content?: string;
84
+ content_hash?: string;
85
+ }
86
+
87
+ async function readDocument(
88
+ supabase: MCPSupabaseClient,
89
+ documentId: string,
90
+ ): Promise<{ title: string; content: string; hash: string }> {
91
+ const { data, error } = await supabase.rpc("cerefox_get_document", {
92
+ p_document_id: documentId,
93
+ p_version_id: null,
94
+ });
95
+ if (error) throw new Error(`Could not read document: ${error.message}`);
96
+ const row = (data?.[0] as DocRow | undefined) ?? undefined;
97
+ if (!row || row.full_content === undefined) {
98
+ throw new McpInvalidParams(
99
+ `Document not found: ${documentId}. Partial edits apply to an existing document; ` +
100
+ `use cerefox_ingest to create one.`,
101
+ );
102
+ }
103
+ return {
104
+ title: row.doc_title ?? "Untitled",
105
+ content: row.full_content,
106
+ hash: row.content_hash ?? "",
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The shared write path. Everything above it is argument shape; everything
112
+ * below is the same code the whole-document ingest path uses.
113
+ */
114
+ async function applyAndWrite(
115
+ supabase: MCPSupabaseClient,
116
+ ctx: ToolContext,
117
+ args: {
118
+ documentId: string;
119
+ operations: EditOperation[];
120
+ expectedHash: string;
121
+ requestor: string;
122
+ toolLabel: string;
123
+ authorType: "user" | "agent";
124
+ },
125
+ ): Promise<string> {
126
+ const { documentId, operations, expectedHash, requestor, toolLabel, authorType } = args;
127
+
128
+ if (!ctx.openaiApiKey && resolveEmbedderKind() !== "local") {
129
+ throw new Error(
130
+ "OpenAI API key not configured. Set OPENAI_API_KEY (Edge Function) or CEREFOX_OPENAI_API_KEY (.env, local).",
131
+ );
132
+ }
133
+
134
+ const doc = await readDocument(supabase, documentId);
135
+
136
+ // Advisory fast-fail before the embedding spend. The authoritative, race-free
137
+ // check is the RPC's FOR UPDATE compare-and-swap.
138
+ if (doc.hash && expectedHash !== doc.hash) {
139
+ throw conflictError(documentId, expectedHash, doc.hash);
140
+ }
141
+
142
+ // Pure. Anchor/position problems raise here, before anything is written or
143
+ // paid for, carrying the candidates that resolve them.
144
+ let assembled: string;
145
+ let applied: AppliedOperation[];
146
+ try {
147
+ const result = applyOperations(doc.content, operations);
148
+ assembled = result.content;
149
+ applied = result.applied;
150
+ } catch (err) {
151
+ // These are agent-correctable: surface as invalid-params so the client
152
+ // reports them as a bad call rather than a server failure.
153
+ throw new McpInvalidParams(err instanceof Error ? err.message : String(err));
154
+ }
155
+
156
+ if (assembled === doc.content) {
157
+ return (
158
+ `No change: the ${toolLabel} produced content identical to the current document ` +
159
+ `"${doc.title}" (id: ${documentId}). content_hash: ${doc.hash} (unchanged).`
160
+ );
161
+ }
162
+
163
+ const newHash = await sha256hex(normalizeContent(assembled));
164
+ const chunks = chunkMarkdown(assembled);
165
+ if (chunks.length === 0) {
166
+ throw new McpInvalidParams(
167
+ "The result of this edit produced no chunks (the document would be empty). " +
168
+ "To remove a document use cerefox_ingest or the delete path, not a partial edit.",
169
+ );
170
+ }
171
+
172
+ const texts = chunks.map((c) => embeddingInputFor(doc.title, c));
173
+ const embeddings = await embedBatch(texts, ctx.openaiApiKey ?? "");
174
+ const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
175
+
176
+ const chunkData = chunks.map((chunk, i) => ({
177
+ chunk_index: i,
178
+ heading_path: chunk.heading_path,
179
+ heading_level: chunk.heading_level,
180
+ title: chunk.title,
181
+ content: chunk.content,
182
+ char_count: chunk.char_count,
183
+ embedding: embeddings[i],
184
+ embedder: activeEmbedderName(),
185
+ }));
186
+
187
+ const { data, error } = await supabase.rpc("cerefox_ingest_document", {
188
+ p_document_id: documentId,
189
+ p_title: doc.title, // partial edits never retitle
190
+ // NOT "agent": a partial edit changes a document's CONTENT, never where it
191
+ // came from. Passing a literal here would relabel provenance on every edit
192
+ // — the #191 defect, and an explicit value survives even that fix, so this
193
+ // has to be omitted rather than corrected server-side. NULL = keep existing
194
+ // NULL = keep existing. The RPC's default is now also NULL (#191), but this
195
+ // stays explicit: it makes intent legible at the call site and remains
196
+ // correct if that default is ever restored.
197
+ p_source: null,
198
+ p_content_hash: newHash,
199
+ p_metadata: null, // null = keep existing metadata
200
+ // Agent writes land in review; a human at the CLI is the reviewer.
201
+ p_review_status: authorType === "agent" ? "pending_review" : "approved",
202
+ p_chunks: chunkData,
203
+ p_author: requestor,
204
+ // Who actually made the write, not which module executed it. The CLI is a
205
+ // human at a shell (or their script); MCP is an agent. Recording everything
206
+ // as "agent" would mis-attribute every CLI edit in the audit trail, and
207
+ // governance filters keyed on author_type would silently miss them.
208
+ p_author_type: authorType,
209
+ p_source_label: authorType === "user" ? "manual" : "agent",
210
+ p_expected_content_hash: expectedHash,
211
+ p_last_write_wins: false,
212
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
213
+ p_operations: applied.map((a) => ({ op: AUDIT_OP[a.op], detail: a.detail })),
214
+ });
215
+
216
+ if (error) {
217
+ const message = error.message ?? "";
218
+ if (message.includes("CEREFOX_CONFLICT")) {
219
+ const current = message.match(/current hash ([0-9a-f]{64})/)?.[1] ?? "unknown";
220
+ throw conflictError(documentId, expectedHash, current);
221
+ }
222
+ if (message.includes("cerefox_documents_hash_unique")) {
223
+ // content_hash is UNIQUE store-wide, so an edit whose result matches
224
+ // ANOTHER document collides. Raw, this reads as a database fault; it is
225
+ // actually a meaningful statement about the store.
226
+ throw new Error(
227
+ `This edit would make "${doc.title}" (id: ${documentId}) byte-identical to another ` +
228
+ `document in the store, and content must be unique. No write was performed. ` +
229
+ `Usually this means the two documents have converged and one should be removed or ` +
230
+ `merged, or that this edit was already applied to the other one — ` +
231
+ `cerefox_search for the resulting content to find it.`,
232
+ );
233
+ }
234
+ if (message.includes("does not exist") && message.includes("cerefox_ingest_document")) {
235
+ throw new Error(
236
+ `This server is behind: partial edits need schema 0.11.0 or newer. ` +
237
+ `Run \`cerefox server deploy\`, then retry. (${message})`,
238
+ );
239
+ }
240
+ throw new Error(`Edit failed: ${message}`);
241
+ }
242
+
243
+ const row = (data?.[0] as
244
+ | { content_hash?: string; total_chars?: number; size_warning?: boolean }
245
+ | undefined) ?? undefined;
246
+
247
+ logUsage(supabase, {
248
+ operation: toolLabel,
249
+ accessPath: ctx.accessPath,
250
+ requestor,
251
+ document_id: documentId,
252
+ result_count: applied.length,
253
+ });
254
+
255
+ const summary = applied.map((a, i) => ` ${i + 1}. ${a.detail} → ${a.path}`).join("\n");
256
+ const warning = row?.size_warning
257
+ ? `\n\n⚠ This document has passed the configured size threshold ` +
258
+ `(document_size_warning_chars). Consider splitting it.`
259
+ : "";
260
+
261
+ // Deliberately no document body: returning it would spend exactly the tokens
262
+ // this feature saves, on the response side (spec §3.8).
263
+ return (
264
+ `Applied ${applied.length} operation(s) to "${doc.title}" (id: ${documentId}):\n${summary}\n\n` +
265
+ `New content_hash: ${row?.content_hash ?? newHash}\n` +
266
+ `Size: ${row?.total_chars ?? totalChars} chars, ${chunks.length} chunk(s).\n` +
267
+ `Pass the new content_hash as expected_content_hash on your next edit.${warning}`
268
+ );
269
+ }
270
+
271
+ // ── cerefox_insert ─────────────────────────────────────────────────────────
272
+
273
+ async function insertHandler(
274
+ supabase: MCPSupabaseClient,
275
+ args: Record<string, unknown>,
276
+ ctx: ToolContext,
277
+ ): Promise<string> {
278
+ const documentId = (args.document_id as string | undefined)?.trim();
279
+ const text = args.text as string | undefined;
280
+ const position = args.position as string | undefined;
281
+ const expectedHash = (args.expected_content_hash as string | undefined)?.trim();
282
+
283
+ if (!documentId) throw new McpInvalidParams("document_id is required");
284
+ if (!text?.trim()) throw new McpInvalidParams("text is required and cannot be empty");
285
+ if (!expectedHash) {
286
+ throw new McpInvalidParams(
287
+ "expected_content_hash is required. It is the content_hash of the version you are " +
288
+ "basing this insert on — returned by cerefox_get_document (including outline mode), " +
289
+ "cerefox_search, cerefox_metadata_search, and by every write. There is no " +
290
+ "last-write-wins here: knowing the document changed under you is the point.",
291
+ );
292
+ }
293
+
294
+ const operations = validateOperations([
295
+ {
296
+ op: "insert",
297
+ text,
298
+ position,
299
+ ...(args.anchor_heading !== undefined ? { anchor_heading: args.anchor_heading } : {}),
300
+ ...(args.section_part !== undefined ? { section_part: args.section_part } : {}),
301
+ },
302
+ ]);
303
+
304
+ return applyAndWrite(supabase, ctx, {
305
+ documentId,
306
+ operations,
307
+ expectedHash,
308
+ requestor: (args.requestor as string | undefined) ?? defaultRequestor(ctx),
309
+ toolLabel: "insert",
310
+ authorType: resolveAuthorType(ctx, args),
311
+ });
312
+ }
313
+
314
+ export const insertTool: ToolDefinition = {
315
+ name: "cerefox_insert",
316
+ description:
317
+ "Add text to a document without resending the whole thing. Purely additive: it cannot " +
318
+ "remove or overwrite existing content, so it is the safe way to append. Positions: " +
319
+ "end_of_document (a plain append), end_of_section (add to the end of a section's body — " +
320
+ "the most common mid-document add), after_heading (lead-in text), before_heading (a new " +
321
+ "block above a section). Anchors are the exact heading line ('## Intake') or a parent path " +
322
+ "('## Intake > ### Notes') when a heading appears more than once. Requires " +
323
+ "expected_content_hash; returns the new hash, not the document.",
324
+ annotations: {
325
+ title: "Insert into document",
326
+ readOnlyHint: false,
327
+ // Structurally incapable of destroying content — the distinction that lets a
328
+ // client grant this freely while still prompting on cerefox_edit.
329
+ destructiveHint: false,
330
+ idempotentHint: false,
331
+ openWorldHint: false,
332
+ },
333
+ inputSchema: {
334
+ type: "object",
335
+ required: ["document_id", "text", "position", "expected_content_hash"],
336
+ properties: {
337
+ document_id: { type: "string", description: "UUID of the document to add to" },
338
+ text: { type: "string", description: "Markdown to insert. Sent as-is; blank-line separation is handled for you." },
339
+ position: {
340
+ type: "string",
341
+ enum: ["end_of_document", "end_of_section", "after_heading", "before_heading"],
342
+ description:
343
+ "Where to insert. end_of_document needs no anchor; the other three require anchor_heading.",
344
+ },
345
+ anchor_heading: {
346
+ type: "string",
347
+ description:
348
+ "Exact heading line, or a ' > ' path for a heading that appears more than once. Required unless position is end_of_document.",
349
+ },
350
+ section_part: {
351
+ type: "string",
352
+ enum: ["own_body", "subtree"],
353
+ description:
354
+ "Only for end_of_section when the section has BOTH its own content and child sections: own_body = before the first child, subtree = after everything nested under it. Omit otherwise; you will be told (with both options) if it is needed.",
355
+ },
356
+ expected_content_hash: {
357
+ type: "string",
358
+ description:
359
+ "content_hash of the version you are basing this on. Required — no last-write-wins.",
360
+ },
361
+ requestor: {
362
+ type: "string",
363
+ description: 'Agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent".',
364
+ },
365
+ author_type: {
366
+ type: "string",
367
+ enum: ["user", "agent"],
368
+ description:
369
+ "Honoured on the CLI only, where a human ran the command; over MCP the write is always recorded as an agent write regardless of what is passed.",
370
+ },
371
+ },
372
+ },
373
+ handler: insertHandler,
374
+ };
375
+
376
+ // ── cerefox_edit ───────────────────────────────────────────────────────────
377
+
378
+ async function editHandler(
379
+ supabase: MCPSupabaseClient,
380
+ args: Record<string, unknown>,
381
+ ctx: ToolContext,
382
+ ): Promise<string> {
383
+ const documentId = (args.document_id as string | undefined)?.trim();
384
+ const expectedHash = (args.expected_content_hash as string | undefined)?.trim();
385
+
386
+ if (!documentId) throw new McpInvalidParams("document_id is required");
387
+ if (!expectedHash) {
388
+ throw new McpInvalidParams(
389
+ "expected_content_hash is required. It is the content_hash of the version you are " +
390
+ "basing these edits on — returned by cerefox_get_document (including outline mode), " +
391
+ "cerefox_search, cerefox_metadata_search, and by every write.",
392
+ );
393
+ }
394
+
395
+ let operations: EditOperation[];
396
+ try {
397
+ operations = validateOperations(args.operations);
398
+ } catch (err) {
399
+ throw new McpInvalidParams(err instanceof Error ? err.message : String(err));
400
+ }
401
+
402
+ return applyAndWrite(supabase, ctx, {
403
+ documentId,
404
+ operations,
405
+ expectedHash,
406
+ requestor: (args.requestor as string | undefined) ?? defaultRequestor(ctx),
407
+ toolLabel: "edit",
408
+ authorType: resolveAuthorType(ctx, args),
409
+ });
410
+ }
411
+
412
+ export const editTool: ToolDefinition = {
413
+ name: "cerefox_edit",
414
+ description:
415
+ "Change parts of a document without resending the whole thing: one or many operations " +
416
+ "applied ATOMICALLY in one write. Operations: insert (same positions as cerefox_insert), " +
417
+ "replace_section (swap a section's body, heading kept), delete_section (remove a section, " +
418
+ "scope body_only or heading_and_body). Use one call for changes that belong together — a " +
419
+ "half-applied edit is impossible, so a row and the total it feeds cannot disagree. " +
420
+ "Operations apply in order and each sees the previous one's result. To change a single " +
421
+ "line, replace_section on its smallest enclosing heading. Requires expected_content_hash; " +
422
+ "returns the new hash, not the document.",
423
+ annotations: {
424
+ title: "Edit document sections",
425
+ readOnlyHint: false,
426
+ // replace_section and delete_section overwrite/remove content.
427
+ destructiveHint: true,
428
+ idempotentHint: false,
429
+ openWorldHint: false,
430
+ },
431
+ inputSchema: {
432
+ type: "object",
433
+ required: ["document_id", "operations", "expected_content_hash"],
434
+ properties: {
435
+ document_id: { type: "string", description: "UUID of the document to edit" },
436
+ operations: {
437
+ type: "array",
438
+ minItems: 1,
439
+ description:
440
+ "Operations applied in order, all-or-nothing. If any fails (bad anchor, ambiguity), nothing is written.",
441
+ items: {
442
+ type: "object",
443
+ required: ["op"],
444
+ properties: {
445
+ op: { type: "string", enum: ["insert", "replace_section", "delete_section"] },
446
+ text: { type: "string", description: "Markdown. Required for insert and replace_section." },
447
+ position: {
448
+ type: "string",
449
+ enum: ["end_of_document", "end_of_section", "after_heading", "before_heading"],
450
+ description: "Required for insert.",
451
+ },
452
+ anchor_heading: {
453
+ type: "string",
454
+ description:
455
+ "Exact heading line, or a ' > ' path when the heading is not unique. Required for replace_section, delete_section, and any insert other than end_of_document.",
456
+ },
457
+ section_part: {
458
+ type: "string",
459
+ enum: ["own_body", "subtree"],
460
+ description:
461
+ "Only when the target section has BOTH its own content and child sections. You will be told (with both options) if it is needed.",
462
+ },
463
+ scope: {
464
+ type: "string",
465
+ enum: ["body_only", "heading_and_body"],
466
+ description: "delete_section only. Defaults to body_only, which keeps the heading.",
467
+ },
468
+ },
469
+ },
470
+ },
471
+ expected_content_hash: {
472
+ type: "string",
473
+ description:
474
+ "content_hash of the version you are basing these edits on. Required — no last-write-wins.",
475
+ },
476
+ requestor: {
477
+ type: "string",
478
+ description: 'Agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent".',
479
+ },
480
+ author_type: {
481
+ type: "string",
482
+ enum: ["user", "agent"],
483
+ description:
484
+ "Honoured on the CLI only, where a human ran the command; over MCP the write is always recorded as an agent write regardless of what is passed.",
485
+ },
486
+ },
487
+ },
488
+ handler: editHandler,
489
+ };