@cerefox/memory 1.3.0 → 1.5.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.
- package/AGENT_GUIDE.md +90 -8
- package/AGENT_QUICK_REFERENCE.md +91 -5
- package/dist/bin/cerefox.js +715 -387
- package/dist/frontend/assets/{index-B1pgikxA.js → index-D9z5yV9u.js} +30 -30
- package/dist/frontend/assets/index-D9z5yV9u.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +20 -2
- package/dist/server-assets/_shared/mcp-tools/audit-log.ts +14 -1
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +67 -3
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +6 -4
- package/dist/server-assets/_shared/mcp-tools/get-help.ts +56 -0
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +2 -1
- package/dist/server-assets/_shared/mcp-tools/list-versions.ts +12 -1
- package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +82 -14
- package/dist/server-assets/_shared/partial-edits/index.ts +222 -13
- package/dist/server-assets/db/migrations/0021_rename_section_audit_op.sql +36 -0
- package/dist/server-assets/db/migrations/0022_rls_on_document_relations.sql +38 -0
- package/dist/server-assets/db/rpcs.sql +4 -1
- package/dist/server-assets/db/schema.sql +12 -2
- package/docs/guides/configuration.md +1 -1
- package/docs/guides/connect-agents.md +14 -1
- package/package.json +1 -1
- package/dist/frontend/assets/index-B1pgikxA.js.map +0 -1
|
@@ -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
|
-
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,55 @@ 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
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The supplied text begins with the very heading it is anchored to.
|
|
189
|
+
*
|
|
190
|
+
* `insert` splices text verbatim and `replace_section` preserves the heading,
|
|
191
|
+
* so a caller who includes the heading in their content gets two of them — and
|
|
192
|
+
* the tools said nothing. Two separate incidents in one agent's log came from
|
|
193
|
+
* this, the second while trying to repair the first, which is the shape that
|
|
194
|
+
* makes a silent trap expensive: the fix looks like more of the same call.
|
|
195
|
+
*
|
|
196
|
+
* There is no legitimate reading of "this section's body starts with this
|
|
197
|
+
* section's own heading", so this refuses rather than warns.
|
|
198
|
+
*/
|
|
199
|
+
export class DuplicateHeadingError extends Error {
|
|
200
|
+
constructor(heading: string, opName: string) {
|
|
201
|
+
super(
|
|
202
|
+
`The text you passed to ${opName} starts with the anchor heading itself ` +
|
|
203
|
+
`(${JSON.stringify(heading)}). No write was performed. The heading is kept ` +
|
|
204
|
+
`automatically — ${opName === "replace_section" ? "replace_section preserves it" : "insert places your text inside the section"}, ` +
|
|
205
|
+
`so including it would produce two. Send only the new content, without that ` +
|
|
206
|
+
`heading line. A DEEPER sub-heading inside your text is fine.`,
|
|
207
|
+
);
|
|
208
|
+
this.name = "DuplicateHeadingError";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
137
212
|
/** Structural validation failure of an operations array (before any parsing). */
|
|
138
213
|
export class InvalidOperationError extends Error {
|
|
139
214
|
constructor(index: number, message: string) {
|
|
@@ -240,7 +315,11 @@ function canonicalHeading(text: string): string {
|
|
|
240
315
|
return m ? `${m[1]} ${m[2].trim()}`.trim() : text.trim();
|
|
241
316
|
}
|
|
242
317
|
|
|
243
|
-
export function resolveAnchor(
|
|
318
|
+
export function resolveAnchor(
|
|
319
|
+
outline: OutlineNode[],
|
|
320
|
+
anchorHeading: string,
|
|
321
|
+
reads = false,
|
|
322
|
+
): OutlineNode {
|
|
244
323
|
const anchor = canonicalHeading(anchorHeading);
|
|
245
324
|
|
|
246
325
|
// Try the LITERAL heading first, always — including when the anchor contains
|
|
@@ -253,7 +332,7 @@ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): Ou
|
|
|
253
332
|
const byHeading = outline.filter((n) => n.heading === anchor);
|
|
254
333
|
if (byHeading.length === 1) return byHeading[0];
|
|
255
334
|
if (byHeading.length > 1) {
|
|
256
|
-
throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path));
|
|
335
|
+
throw new AmbiguousAnchorError(anchor, byHeading.map((n) => n.path), reads);
|
|
257
336
|
}
|
|
258
337
|
|
|
259
338
|
// No heading matched literally: interpret it as a parent path.
|
|
@@ -262,11 +341,79 @@ export function resolveAnchor(outline: OutlineNode[], anchorHeading: string): Ou
|
|
|
262
341
|
const byPath = outline.filter((n) => n.path === normalizedPath);
|
|
263
342
|
if (byPath.length === 1) return byPath[0];
|
|
264
343
|
if (byPath.length > 1) {
|
|
265
|
-
throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path));
|
|
344
|
+
throw new AmbiguousAnchorError(anchor, byPath.map((n) => n.path), reads);
|
|
266
345
|
}
|
|
267
346
|
}
|
|
268
347
|
|
|
269
|
-
throw new AnchorNotFoundError(anchor, outline);
|
|
348
|
+
throw new AnchorNotFoundError(anchor, outline, reads);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The text a `replace_section` would overwrite — resolved through the SAME
|
|
353
|
+
* functions the write uses (#198).
|
|
354
|
+
*
|
|
355
|
+
* v1.3.0 shipped `replace_section` with no way to see what it was about to
|
|
356
|
+
* destroy: outline mode reports a section's *size*, never its *text*, so the
|
|
357
|
+
* only safe preparation was a full `get_document` — the cost partial edits
|
|
358
|
+
* exist to remove. `cerefox_insert` is guarded structurally (it cannot remove
|
|
359
|
+
* anything); the destructive operation was guarded only by
|
|
360
|
+
* `expected_content_hash`, which protects against a *concurrent* writer, not
|
|
361
|
+
* against a writer who does not know what it is deleting.
|
|
362
|
+
*
|
|
363
|
+
* The binding requirement is that `text` is EXACTLY the extent
|
|
364
|
+
* `replace_section` targets under the same `section_part`. If a read could
|
|
365
|
+
* differ from the write it feeds, the feature would be worse than its absence:
|
|
366
|
+
* absence at least announces itself. That is why this shares `resolveAnchor`
|
|
367
|
+
* and `resolveSectionEnd` rather than reproducing their rules — including the
|
|
368
|
+
* refusal on a section with children, which is the case most likely to diverge.
|
|
369
|
+
*
|
|
370
|
+
* `heading` is returned separately because it is context, not content:
|
|
371
|
+
* `replace_section` keeps the heading, so it is not part of what would be
|
|
372
|
+
* overwritten.
|
|
373
|
+
*/
|
|
374
|
+
export function extractSection(
|
|
375
|
+
content: string,
|
|
376
|
+
anchorHeading: string,
|
|
377
|
+
sectionPart?: SectionPart,
|
|
378
|
+
): {
|
|
379
|
+
heading: string;
|
|
380
|
+
path: string;
|
|
381
|
+
level: number;
|
|
382
|
+
text: string;
|
|
383
|
+
chars: number;
|
|
384
|
+
section_part: SectionPart | null;
|
|
385
|
+
} {
|
|
386
|
+
const outline = parseOutline(content);
|
|
387
|
+
const node = resolveAnchor(outline, anchorHeading, true);
|
|
388
|
+
// Same op label the write would raise under, so an ambiguity refusal reads
|
|
389
|
+
// the same whether the caller was reading or replacing.
|
|
390
|
+
const to = resolveSectionEnd(content, outline, node, sectionPart, "the section read", true);
|
|
391
|
+
const text = content.slice(node.bodyStart, to);
|
|
392
|
+
return {
|
|
393
|
+
heading: node.heading,
|
|
394
|
+
path: node.path,
|
|
395
|
+
level: node.level,
|
|
396
|
+
text,
|
|
397
|
+
chars: text.length,
|
|
398
|
+
section_part: sectionPart ?? null,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* First non-blank line of a block, trimmed — what a caller "starts with".
|
|
404
|
+
*/
|
|
405
|
+
function firstMeaningfulLine(text: string): string {
|
|
406
|
+
for (const line of text.split("\n")) {
|
|
407
|
+
if (line.trim() !== "") return canonicalHeading(line);
|
|
408
|
+
}
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Refuse text that repeats the heading it is being placed under. */
|
|
413
|
+
function assertNoDuplicateHeading(text: string, node: OutlineNode, opName: string): void {
|
|
414
|
+
if (firstMeaningfulLine(text) === node.heading) {
|
|
415
|
+
throw new DuplicateHeadingError(node.heading, opName);
|
|
416
|
+
}
|
|
270
417
|
}
|
|
271
418
|
|
|
272
419
|
function firstChild(outline: OutlineNode[], node: OutlineNode): OutlineNode | null {
|
|
@@ -288,6 +435,7 @@ function resolveSectionEnd(
|
|
|
288
435
|
node: OutlineNode,
|
|
289
436
|
sectionPart: SectionPart | undefined,
|
|
290
437
|
opName: string,
|
|
438
|
+
reads = false,
|
|
291
439
|
): number {
|
|
292
440
|
const child = firstChild(outline, node);
|
|
293
441
|
if (!child) return node.subtreeEnd; // leaf: unambiguous
|
|
@@ -315,7 +463,7 @@ function resolveSectionEnd(
|
|
|
315
463
|
//
|
|
316
464
|
// So: no exemption. `hasOwnBody` no longer gates anything, because the
|
|
317
465
|
// presence of children is what makes "the end of this section" ambiguous.
|
|
318
|
-
throw new AmbiguousPositionError(node, child.heading, opName);
|
|
466
|
+
throw new AmbiguousPositionError(node, child.heading, opName, reads);
|
|
319
467
|
}
|
|
320
468
|
|
|
321
469
|
/**
|
|
@@ -362,9 +510,15 @@ function applyOne(
|
|
|
362
510
|
at = node.start;
|
|
363
511
|
detail = "insert before_heading";
|
|
364
512
|
} else if (position === "after_heading") {
|
|
513
|
+
// Lands INSIDE the section, immediately below its heading — the same
|
|
514
|
+
// duplication trap as end_of_section. `before_heading` is deliberately
|
|
515
|
+
// NOT guarded: text placed before a heading becomes a new sibling
|
|
516
|
+
// section, and one that repeats the name is a legitimate way to split.
|
|
517
|
+
assertNoDuplicateHeading(text, node, "insert");
|
|
365
518
|
at = node.bodyStart;
|
|
366
519
|
detail = "insert after_heading";
|
|
367
520
|
} else {
|
|
521
|
+
assertNoDuplicateHeading(text, node, "insert");
|
|
368
522
|
at = resolveSectionEnd(content, outline, node, operation.section_part, "end_of_section insert");
|
|
369
523
|
detail =
|
|
370
524
|
`insert at end_of_section` +
|
|
@@ -378,6 +532,7 @@ function applyOne(
|
|
|
378
532
|
|
|
379
533
|
if (operation.op === "replace_section") {
|
|
380
534
|
const node = resolveAnchor(outline, operation.anchor_heading);
|
|
535
|
+
assertNoDuplicateHeading(operation.text, node, "replace_section");
|
|
381
536
|
const to = resolveSectionEnd(content, outline, node, operation.section_part, "replace_section");
|
|
382
537
|
return {
|
|
383
538
|
content: spliceBlock(content, node.bodyStart, to, operation.text),
|
|
@@ -386,6 +541,34 @@ function applyOne(
|
|
|
386
541
|
path: node.path,
|
|
387
542
|
detail:
|
|
388
543
|
"replace_section body" + (operation.section_part ? ` (${operation.section_part})` : ""),
|
|
544
|
+
reachedEnd: to >= content.trimEnd().length,
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (operation.op === "rename_section") {
|
|
550
|
+
const node = resolveAnchor(outline, operation.anchor_heading);
|
|
551
|
+
const next = canonicalHeading(operation.new_heading);
|
|
552
|
+
const m = next.match(ATX_HEADING);
|
|
553
|
+
if (!m) {
|
|
554
|
+
throw new InvalidOperationError(
|
|
555
|
+
0,
|
|
556
|
+
`new_heading must be a markdown heading line like "## Title", got ${JSON.stringify(operation.new_heading)}`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (m[1].length !== node.level) throw new HeadingLevelChangeError(node.heading, next);
|
|
560
|
+
|
|
561
|
+
// Replace the heading LINE only. Not spliceBlock: that normalises
|
|
562
|
+
// surrounding blank lines, which is right for a block of body text and
|
|
563
|
+
// wrong for a single line whose neighbours are the section's own spacing.
|
|
564
|
+
const hadNewline = content[node.bodyStart - 1] === "\n";
|
|
565
|
+
const replacement = next + (hadNewline ? "\n" : "");
|
|
566
|
+
return {
|
|
567
|
+
content: content.slice(0, node.start) + replacement + content.slice(node.bodyStart),
|
|
568
|
+
applied: {
|
|
569
|
+
op: "rename_section",
|
|
570
|
+
path: node.path,
|
|
571
|
+
detail: `rename_section to ${next}`,
|
|
389
572
|
},
|
|
390
573
|
};
|
|
391
574
|
}
|
|
@@ -402,6 +585,7 @@ function applyOne(
|
|
|
402
585
|
path: node.path,
|
|
403
586
|
detail:
|
|
404
587
|
`delete_section (${scope})` + (operation.section_part ? ` (${operation.section_part})` : ""),
|
|
588
|
+
reachedEnd: to >= content.trimEnd().length,
|
|
405
589
|
},
|
|
406
590
|
};
|
|
407
591
|
}
|
|
@@ -453,8 +637,33 @@ export function validateOperations(operations: unknown): EditOperation[] {
|
|
|
453
637
|
if (o.scope !== undefined && o.scope !== "body_only" && o.scope !== "heading_and_body") {
|
|
454
638
|
throw new InvalidOperationError(i, "scope must be body_only or heading_and_body");
|
|
455
639
|
}
|
|
640
|
+
} else if (op === "rename_section") {
|
|
641
|
+
if (typeof o.anchor_heading !== "string" || o.anchor_heading.trim() === "") {
|
|
642
|
+
throw new InvalidOperationError(i, "rename_section requires anchor_heading");
|
|
643
|
+
}
|
|
644
|
+
if (typeof o.new_heading !== "string" || o.new_heading.trim() === "") {
|
|
645
|
+
throw new InvalidOperationError(i, "rename_section requires non-empty new_heading");
|
|
646
|
+
}
|
|
647
|
+
// A rename touches the heading line and nothing else, so a caller that
|
|
648
|
+
// also passed body text has misunderstood which operation they want.
|
|
649
|
+
// Silently ignoring it would lose the text without saying so.
|
|
650
|
+
if (o.text !== undefined) {
|
|
651
|
+
throw new InvalidOperationError(
|
|
652
|
+
i,
|
|
653
|
+
"rename_section changes only the heading and takes no text — use replace_section for the body, or both operations in one call",
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (o.section_part !== undefined) {
|
|
657
|
+
throw new InvalidOperationError(
|
|
658
|
+
i,
|
|
659
|
+
"rename_section takes no section_part: it replaces the heading line, so no extent is involved",
|
|
660
|
+
);
|
|
661
|
+
}
|
|
456
662
|
} else {
|
|
457
|
-
throw new InvalidOperationError(
|
|
663
|
+
throw new InvalidOperationError(
|
|
664
|
+
i,
|
|
665
|
+
"op must be insert | replace_section | delete_section | rename_section",
|
|
666
|
+
);
|
|
458
667
|
}
|
|
459
668
|
if (
|
|
460
669
|
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 $$;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- 0022_rls_on_document_relations.sql — close the one-table RLS gap (iteration 36).
|
|
2
|
+
--
|
|
3
|
+
-- `cerefox_document_relations` was added in iteration 29 and never added to
|
|
4
|
+
-- schema.sql's RLS block, so it alone among the ten tables had row-level
|
|
5
|
+
-- security disabled. Cerefox's model is "RLS ON with NO policies" — the
|
|
6
|
+
-- service-role key bypasses RLS and everything else is denied — so a table
|
|
7
|
+
-- without RLS is reachable by any role holding a table grant.
|
|
8
|
+
--
|
|
9
|
+
-- On projects created before Supabase stopped granting `anon` blanket
|
|
10
|
+
-- privileges on `public` (the maintainer's production project is one), `anon`
|
|
11
|
+
-- holds SELECT/INSERT/UPDATE/DELETE here. The anon / publishable key is
|
|
12
|
+
-- designed to be public, so that means world read AND write on this table.
|
|
13
|
+
-- Supabase's advisor flagged it as `rls_disabled_in_public` on 2026-08-09.
|
|
14
|
+
--
|
|
15
|
+
-- Newer projects grant `anon` nothing, so they were never exposed — which is
|
|
16
|
+
-- why the maintainer's staging project showed no privileges while production
|
|
17
|
+
-- showed all four. Both get RLS regardless: relying on the absence of a grant
|
|
18
|
+
-- is not the same as denying access.
|
|
19
|
+
--
|
|
20
|
+
-- Impact of the gap: the relations feature is opt-in (`relations_enabled`,
|
|
21
|
+
-- default false), so the table is empty on a default install and no document
|
|
22
|
+
-- content was ever reachable through it. Content, chunks, versions, audit log
|
|
23
|
+
-- and config were correctly protected throughout.
|
|
24
|
+
--
|
|
25
|
+
-- Idempotent, and safe on a table that already has RLS.
|
|
26
|
+
|
|
27
|
+
ALTER TABLE cerefox_document_relations ENABLE ROW LEVEL SECURITY;
|
|
28
|
+
|
|
29
|
+
-- Defence in depth: revoke the legacy blanket grants so the table is denied by
|
|
30
|
+
-- privilege as well as by RLS. Harmless where the grants were never made.
|
|
31
|
+
REVOKE ALL ON TABLE cerefox_document_relations FROM anon;
|
|
32
|
+
|
|
33
|
+
DO $$
|
|
34
|
+
BEGIN
|
|
35
|
+
RAISE NOTICE
|
|
36
|
+
'Migration 0022: RLS enabled on cerefox_document_relations and anon '
|
|
37
|
+
'grants revoked (Supabase rls_disabled_in_public). Schema version 0.11.2.';
|
|
38
|
+
END $$;
|
|
@@ -2425,10 +2425,13 @@ 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.2 (iteration 36): RLS enabled on cerefox_document_relations,
|
|
2429
|
+
-- which iteration 29 left off the list (Supabase rls_disabled_in_public).
|
|
2430
|
+
-- 0.11.1 (iteration 35, #197): audit CHECK accepts 'rename-section'.
|
|
2428
2431
|
-- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
|
|
2429
2432
|
-- the partial-edit surface, and both migrations (0019, 0020) are in the
|
|
2430
2433
|
-- sequence, so a store deploying this gets everything from both lines.
|
|
2431
|
-
SELECT '0.11.
|
|
2434
|
+
SELECT '0.11.2'::TEXT;
|
|
2432
2435
|
$$;
|
|
2433
2436
|
|
|
2434
2437
|
-- ── 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.
|
|
8
|
+
-- @version: 0.11.2
|
|
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
|
);
|
|
@@ -428,6 +431,13 @@ ALTER TABLE cerefox_audit_log ENABLE ROW LEVEL SECURITY;
|
|
|
428
431
|
ALTER TABLE cerefox_migrations ENABLE ROW LEVEL SECURITY;
|
|
429
432
|
ALTER TABLE cerefox_config ENABLE ROW LEVEL SECURITY;
|
|
430
433
|
ALTER TABLE cerefox_usage_log ENABLE ROW LEVEL SECURITY;
|
|
434
|
+
-- iteration 29 added this table and did not add it here, so it stayed
|
|
435
|
+
-- world-accessible on any project whose `anon` role holds the legacy
|
|
436
|
+
-- GRANT ... ON ALL TABLES IN SCHEMA public. Supabase's linter flagged it as
|
|
437
|
+
-- `rls_disabled_in_public` (2026-08-09). Every other table on this list denies
|
|
438
|
+
-- anon by having RLS on with NO policies; this one did not, so the model had a
|
|
439
|
+
-- hole exactly one table wide. See the guard test in _shared/__tests__.
|
|
440
|
+
ALTER TABLE cerefox_document_relations ENABLE ROW LEVEL SECURITY;
|
|
431
441
|
|
|
432
442
|
-- ── Explicit Data API grants (issue #26; schema 0.8.2) ─────────────────────────
|
|
433
443
|
-- Supabase is removing the implicit privileges the Data API roles get on
|
|
@@ -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.
|
|
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
|
+
"version": "1.5.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",
|