@cerefox/memory 1.17.4 → 1.19.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 +8 -3
- package/AGENT_QUICK_REFERENCE.md +3 -3
- package/README.md +1 -1
- package/dist/bin/cerefox.js +1542 -1123
- package/dist/frontend/assets/index-01k5C3sG.css +1 -0
- package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
- package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
- package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
- package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
- package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
- package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
- package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
- package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
- package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
- package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
- package/dist/server-assets/db/migrations/0012_content_format.sql +1 -1
- package/dist/server-assets/db/migrations/0014_document_relations.sql +1 -1
- package/dist/server-assets/db/migrations/0020_ingest_preserves_source.sql +1 -1
- package/dist/server-assets/db/migrations/0022_rls_on_document_relations.sql +1 -1
- package/dist/server-assets/db/migrations/0032_derive_grants.sql +1 -1
- package/dist/server-assets/db/migrations/0034_drop_vector_indexes.sql +22 -0
- package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
- package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
- package/dist/server-assets/db/rpcs.sql +260 -58
- package/dist/server-assets/db/schema.sql +49 -18
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
- package/docs/api/openapi.json +77 -3
- package/docs/guides/access-paths.md +7 -3
- package/docs/guides/api.md +1 -1
- package/docs/guides/cli.md +5 -3
- package/docs/guides/configuration.md +23 -2
- package/docs/guides/connect-agents.md +60 -25
- package/docs/guides/ops-scripts.md +1 -1
- package/docs/guides/quickstart.md +7 -3
- package/docs/guides/setup-local.md +30 -6
- package/docs/guides/setup-supabase.md +5 -0
- package/docs/guides/upgrading.md +16 -1
- package/package.json +7 -7
- package/dist/frontend/assets/index-B0H_N4if.css +0 -1
- package/dist/frontend/assets/index-D1kdLLr8.js +0 -121
- package/dist/frontend/assets/index-D1kdLLr8.js.map +0 -1
|
@@ -27,7 +27,14 @@ import {
|
|
|
27
27
|
} from "./_chunker.ts";
|
|
28
28
|
import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
|
|
29
29
|
import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.ts";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
duplicateContentDocumentId,
|
|
32
|
+
extractConflictHashes,
|
|
33
|
+
legacyContentUniqueError,
|
|
34
|
+
logUsage,
|
|
35
|
+
metadataTokenError,
|
|
36
|
+
metadataTokenParams,
|
|
37
|
+
} from "./_utils.ts";
|
|
31
38
|
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
32
39
|
import { AUTHOR_PARAM_WRITE, DEFAULT_IDENTITY, callerIdentity } from "./identity.ts";
|
|
33
40
|
|
|
@@ -49,6 +56,8 @@ function conflictError(documentId: string, expectedHash: string, currentHash: st
|
|
|
49
56
|
|
|
50
57
|
/** Map RPC-side CEREFOX_CONFLICT / CEREFOX_TOKEN_REQUIRED errors to agent-first text. */
|
|
51
58
|
function mapIngestRpcError(message: string, documentId: string): Error {
|
|
59
|
+
const metaErr = metadataTokenError(message);
|
|
60
|
+
if (metaErr) return metaErr;
|
|
52
61
|
if (message.includes("CEREFOX_CONFLICT")) {
|
|
53
62
|
const { expected, current } = extractConflictHashes(message);
|
|
54
63
|
return conflictError(documentId, expected, current);
|
|
@@ -82,7 +91,7 @@ function mapIngestRpcError(message: string, documentId: string): Error {
|
|
|
82
91
|
`regardless of concurrent changes, pass last_write_wins=true.`,
|
|
83
92
|
);
|
|
84
93
|
}
|
|
85
|
-
return new Error(`Ingest RPC failed: ${message}`);
|
|
94
|
+
return legacyContentUniqueError(message) ?? new Error(`Ingest RPC failed: ${message}`);
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
async function handler(
|
|
@@ -118,6 +127,14 @@ async function handler(
|
|
|
118
127
|
const author_type = "agent"; // MCP path is always agent
|
|
119
128
|
const expected_content_hash = (args.expected_content_hash as string | undefined)?.trim() || null;
|
|
120
129
|
const last_write_wins = (args.last_write_wins as boolean | undefined) ?? false;
|
|
130
|
+
const expectedMetaRaw = args.expected_metadata_revision;
|
|
131
|
+
if (
|
|
132
|
+
expectedMetaRaw !== undefined && expectedMetaRaw !== null &&
|
|
133
|
+
!(typeof expectedMetaRaw === "number" && Number.isInteger(expectedMetaRaw))
|
|
134
|
+
) {
|
|
135
|
+
throw new McpInvalidParams("expected_metadata_revision must be an integer (the metadata_revision you read)");
|
|
136
|
+
}
|
|
137
|
+
const expected_metadata_revision = (expectedMetaRaw as number | undefined) ?? null;
|
|
121
138
|
|
|
122
139
|
if (!title || !content?.trim()) {
|
|
123
140
|
throw new McpInvalidParams("title and content are required");
|
|
@@ -214,6 +231,7 @@ async function handler(
|
|
|
214
231
|
p_expected_content_hash: expected_content_hash,
|
|
215
232
|
p_last_write_wins: last_write_wins,
|
|
216
233
|
p_content_format: CONTENT_FORMAT_BLIND_STITCH,
|
|
234
|
+
...metadataTokenParams(expected_metadata_revision, false),
|
|
217
235
|
});
|
|
218
236
|
|
|
219
237
|
if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
|
|
@@ -315,6 +333,7 @@ async function handler(
|
|
|
315
333
|
p_expected_content_hash: expected_content_hash,
|
|
316
334
|
p_last_write_wins: last_write_wins,
|
|
317
335
|
p_content_format: CONTENT_FORMAT_BLIND_STITCH,
|
|
336
|
+
...metadataTokenParams(expected_metadata_revision, false),
|
|
318
337
|
});
|
|
319
338
|
|
|
320
339
|
if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
|
|
@@ -339,26 +358,19 @@ async function handler(
|
|
|
339
358
|
}
|
|
340
359
|
|
|
341
360
|
// ── Hash deduplication (create path) ─────────────────────────────────────
|
|
342
|
-
//
|
|
343
|
-
//
|
|
361
|
+
// Creates only, against LIVE documents (#332): content is not unique across
|
|
362
|
+
// the store, and a trashed document no longer blocks a new one. This saves
|
|
363
|
+
// the embedding spend; the RPC repeats the check authoritatively.
|
|
344
364
|
const { data: hashMatch } = await supabase
|
|
345
365
|
.from("cerefox_documents")
|
|
346
|
-
.select("id, title
|
|
366
|
+
.select("id, title")
|
|
347
367
|
.eq("content_hash", contentHash)
|
|
368
|
+
.is("deleted_at", null)
|
|
369
|
+
.order("created_at", { ascending: true })
|
|
348
370
|
.limit(1);
|
|
349
371
|
|
|
350
372
|
if (hashMatch?.length) {
|
|
351
|
-
|
|
352
|
-
// "Already up-to-date" while search shows nothing is the classic
|
|
353
|
-
// re-upload-after-delete confusion. Say where the content actually is.
|
|
354
|
-
return (
|
|
355
|
-
`This exact content is already in the TRASH as "${hashMatch[0].title}" ` +
|
|
356
|
-
`(soft-deleted, id: ${hashMatch[0].id}). Restore it with ` +
|
|
357
|
-
`cerefox_restore_document instead of re-ingesting — or have your user ` +
|
|
358
|
-
`purge it from the web UI Trash to start fresh.`
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
return `Document already up-to-date: "${hashMatch[0].title}" (id: ${hashMatch[0].id}). Content hash unchanged (${contentHash}) — pass it as expected_content_hash to edit it.`;
|
|
373
|
+
return alreadyExistsMessage(hashMatch[0].title, hashMatch[0].id, contentHash);
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
const chunks = chunkMarkdown(content);
|
|
@@ -391,7 +403,20 @@ async function handler(
|
|
|
391
403
|
p_content_format: CONTENT_FORMAT_BLIND_STITCH,
|
|
392
404
|
});
|
|
393
405
|
|
|
406
|
+
// The RPC's duplicate check caught an identical create that landed after
|
|
407
|
+
// the pre-check above. Nothing was written.
|
|
408
|
+
const duplicateId = duplicateContentDocumentId(ingestErr?.message ?? "");
|
|
409
|
+
if (duplicateId) {
|
|
410
|
+
const { data: existing } = await supabase
|
|
411
|
+
.from("cerefox_documents")
|
|
412
|
+
.select("title")
|
|
413
|
+
.eq("id", duplicateId)
|
|
414
|
+
.maybeSingle();
|
|
415
|
+
return alreadyExistsMessage(existing?.title ?? title, duplicateId, contentHash);
|
|
416
|
+
}
|
|
394
417
|
if (ingestErr || !ingestResult?.length) {
|
|
418
|
+
const legacy = legacyContentUniqueError(ingestErr?.message ?? "");
|
|
419
|
+
if (legacy) throw legacy;
|
|
395
420
|
throw new Error(`Ingest RPC failed: ${ingestErr?.message ?? "no data returned"}`);
|
|
396
421
|
}
|
|
397
422
|
|
|
@@ -424,6 +449,10 @@ async function handler(
|
|
|
424
449
|
);
|
|
425
450
|
}
|
|
426
451
|
|
|
452
|
+
function alreadyExistsMessage(title: string, id: string, contentHash: string): string {
|
|
453
|
+
return `Document already up-to-date: "${title}" (id: ${id}). Content hash unchanged (${contentHash}) — pass it as expected_content_hash to edit it.`;
|
|
454
|
+
}
|
|
455
|
+
|
|
427
456
|
export const ingestTool: ToolDefinition = {
|
|
428
457
|
name: "cerefox_ingest",
|
|
429
458
|
description:
|
|
@@ -477,7 +506,16 @@ export const ingestTool: ToolDefinition = {
|
|
|
477
506
|
description:
|
|
478
507
|
"Explicitly skip the concurrency check and overwrite regardless of concurrent changes (default: false). Use ONLY when an external source of truth makes conflicts meaningless (e.g. re-syncing from files). Recorded in the audit log.",
|
|
479
508
|
},
|
|
480
|
-
metadata: {
|
|
509
|
+
metadata: {
|
|
510
|
+
type: "object",
|
|
511
|
+
description:
|
|
512
|
+
"JSON metadata (optional). On an UPDATE it is MERGED into the existing metadata: keys you pass are set, a null value removes that key, keys you omit are kept. To change only metadata, use cerefox_set_document_metadata instead.",
|
|
513
|
+
},
|
|
514
|
+
expected_metadata_revision: {
|
|
515
|
+
type: "integer",
|
|
516
|
+
description:
|
|
517
|
+
"Optional, with metadata on an update: the metadata_revision you read (cerefox_get_document / cerefox_metadata_search). The write fails if the metadata changed since.",
|
|
518
|
+
},
|
|
481
519
|
author: AUTHOR_PARAM_WRITE,
|
|
482
520
|
},
|
|
483
521
|
},
|
|
@@ -89,6 +89,7 @@ async function handler(
|
|
|
89
89
|
project_names: string[];
|
|
90
90
|
version_count: number;
|
|
91
91
|
content_hash: string | null;
|
|
92
|
+
metadata_revision?: number | null;
|
|
92
93
|
content: string | null;
|
|
93
94
|
}>;
|
|
94
95
|
|
|
@@ -203,7 +204,8 @@ async function handler(
|
|
|
203
204
|
const meta = Object.entries(row.doc_metadata ?? {})
|
|
204
205
|
.map(([k, v]) => `${k}=${v}`)
|
|
205
206
|
.join(", ");
|
|
206
|
-
const
|
|
207
|
+
const rev = row.metadata_revision != null ? ` · metadata_revision: ${row.metadata_revision}` : "";
|
|
208
|
+
const hash = row.content_hash ? `\nhash: ${row.content_hash}${rev}` : "";
|
|
207
209
|
const header =
|
|
208
210
|
`## ${row.title} [id: ${row.document_id}]\n` +
|
|
209
211
|
`${meta}${projects} | ${row.total_chars} chars | ${showReview ? `${row.review_status} | ` : ""}updated ${row.updated_at?.slice(0, 10) ?? "?"}${hash}`;
|
|
@@ -36,7 +36,12 @@ import {
|
|
|
36
36
|
sha256hex,
|
|
37
37
|
} from "./_chunker.ts";
|
|
38
38
|
import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
extractConflictHashes,
|
|
41
|
+
isMissingFunctionError,
|
|
42
|
+
legacyContentUniqueError,
|
|
43
|
+
logUsage,
|
|
44
|
+
} from "./_utils.ts";
|
|
40
45
|
import { McpInvalidParams, type MCPSupabaseClient, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
41
46
|
import { AUTHOR_PARAM_WRITE, DEFAULT_IDENTITY, callerIdentity } from "./identity.ts";
|
|
42
47
|
|
|
@@ -329,18 +334,6 @@ async function applyAndWrite(
|
|
|
329
334
|
if (message.includes("CEREFOX_CONFLICT")) {
|
|
330
335
|
throw conflictError(documentId, expectedHash, extractConflictHashes(message).current);
|
|
331
336
|
}
|
|
332
|
-
if (message.includes("cerefox_documents_hash_unique")) {
|
|
333
|
-
// content_hash is UNIQUE store-wide, so an edit whose result matches
|
|
334
|
-
// ANOTHER document collides. Raw, this reads as a database fault; it is
|
|
335
|
-
// actually a meaningful statement about the store.
|
|
336
|
-
throw new Error(
|
|
337
|
-
`This edit would make "${doc.title}" (id: ${documentId}) byte-identical to another ` +
|
|
338
|
-
`document in the store, and content must be unique. No write was performed. ` +
|
|
339
|
-
`Usually this means the two documents have converged and one should be removed or ` +
|
|
340
|
-
`merged, or that this edit was already applied to the other one — ` +
|
|
341
|
-
`cerefox_search for the resulting content to find it.`,
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
337
|
if (message.includes("CEREFOX_UNRESOLVED_LINKS")) {
|
|
345
338
|
// Only ids INTRODUCED by this write reject (the RPC tolerates dead
|
|
346
339
|
// links the document already carried), so the diagnosis is specific.
|
|
@@ -365,6 +358,8 @@ async function applyAndWrite(
|
|
|
365
358
|
`Run \`cerefox server deploy\`, then retry. (${message})`,
|
|
366
359
|
);
|
|
367
360
|
}
|
|
361
|
+
const legacy = legacyContentUniqueError(message);
|
|
362
|
+
if (legacy) throw legacy;
|
|
368
363
|
throw new Error(`Edit failed: ${message}`);
|
|
369
364
|
}
|
|
370
365
|
|
|
@@ -43,6 +43,7 @@ interface SearchRow {
|
|
|
43
43
|
chunk_count?: number;
|
|
44
44
|
total_chars?: number;
|
|
45
45
|
content_hash?: string;
|
|
46
|
+
metadata_revision?: number;
|
|
46
47
|
below_confidence?: boolean;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -104,6 +105,14 @@ function shortLabel(row: SearchRow): string {
|
|
|
104
105
|
return `${leaf ? `${doc} › ${leaf}` : doc}${chunk}${id}`;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** The concurrency tokens: content_hash for cerefox_ingest updates (iter-32),
|
|
109
|
+
* metadata_revision for metadata writes (#333). */
|
|
110
|
+
function hashLine(row: SearchRow): string {
|
|
111
|
+
if (!row.content_hash) return "";
|
|
112
|
+
const rev = row.metadata_revision != null ? ` · metadata_revision: ${row.metadata_revision}` : "";
|
|
113
|
+
return `\nhash: ${row.content_hash}${rev}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
/** One rendered result: heading, score, then the row's text. */
|
|
108
117
|
function renderRow(row: SearchRow): string {
|
|
109
118
|
const raw = row.best_score ?? row.score;
|
|
@@ -112,7 +121,7 @@ function renderRow(row: SearchRow): string {
|
|
|
112
121
|
? ` -- partial (${row.chunk_count} of ${(row.total_chars ?? 0).toLocaleString()} chars)`
|
|
113
122
|
: "";
|
|
114
123
|
// content_hash = the concurrency token for cerefox_ingest updates (iter-32).
|
|
115
|
-
const hash = row
|
|
124
|
+
const hash = hashLine(row);
|
|
116
125
|
// Whichever column this mode returns (#259): `cerefox_search_docs` gives
|
|
117
126
|
// `full_content`, the chunk RPCs give `content`, and reading only the first
|
|
118
127
|
// rendered every hybrid/fts result as a title with an empty body.
|
|
@@ -124,7 +133,7 @@ function headerLine(row: SearchRow): string {
|
|
|
124
133
|
const raw = row.best_score ?? row.score;
|
|
125
134
|
const score = raw != null ? ` (score: ${raw.toFixed(3)})` : "";
|
|
126
135
|
const size = row.total_chars != null ? ` -- ${row.total_chars.toLocaleString()} chars` : "";
|
|
127
|
-
const hash = row
|
|
136
|
+
const hash = hashLine(row);
|
|
128
137
|
return `## ${rowHeading(row)}${score}${size}${hash}`;
|
|
129
138
|
}
|
|
130
139
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { MCPSupabaseClient } from "./types.ts";
|
|
21
21
|
|
|
22
|
-
import { logUsage } from "./_utils.ts";
|
|
22
|
+
import { logUsage, metadataTokenError, metadataTokenParams } from "./_utils.ts";
|
|
23
23
|
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
24
24
|
import { AUTHOR_PARAM_WRITE, DEFAULT_IDENTITY, callerIdentity } from "./identity.ts";
|
|
25
25
|
|
|
@@ -31,6 +31,14 @@ async function handler(
|
|
|
31
31
|
const document_id = args.document_id as string | undefined;
|
|
32
32
|
const metadata = args.metadata as Record<string, unknown> | undefined;
|
|
33
33
|
const replace = (args.replace as boolean | undefined) ?? false;
|
|
34
|
+
const expectedRevision = args.expected_metadata_revision;
|
|
35
|
+
if (
|
|
36
|
+
expectedRevision !== undefined && expectedRevision !== null &&
|
|
37
|
+
!(typeof expectedRevision === "number" && Number.isInteger(expectedRevision))
|
|
38
|
+
) {
|
|
39
|
+
throw new McpInvalidParams("expected_metadata_revision must be an integer (the metadata_revision you read)");
|
|
40
|
+
}
|
|
41
|
+
const lastWriteWins = (args.last_write_wins as boolean | undefined) ?? false;
|
|
34
42
|
|
|
35
43
|
if (!document_id) throw new McpInvalidParams("document_id is required");
|
|
36
44
|
if (metadata === undefined || metadata === null) {
|
|
@@ -60,12 +68,19 @@ async function handler(
|
|
|
60
68
|
p_replace: replace,
|
|
61
69
|
p_author: author ?? "unknown",
|
|
62
70
|
p_author_type: authorType,
|
|
71
|
+
...metadataTokenParams(expectedRevision as number | null | undefined, lastWriteWins),
|
|
63
72
|
});
|
|
64
73
|
|
|
65
|
-
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
74
|
+
if (error) throw metadataTokenError(error.message) ?? new Error(`RPC error: ${error.message}`);
|
|
66
75
|
|
|
67
76
|
const row = data?.[0] as
|
|
68
|
-
| {
|
|
77
|
+
| {
|
|
78
|
+
document_id?: string;
|
|
79
|
+
metadata?: Record<string, unknown>;
|
|
80
|
+
keys_set?: number;
|
|
81
|
+
keys_removed?: number;
|
|
82
|
+
metadata_revision?: number;
|
|
83
|
+
}
|
|
69
84
|
| undefined;
|
|
70
85
|
if (!row) throw new Error("cerefox_set_document_metadata returned no data");
|
|
71
86
|
|
|
@@ -89,6 +104,7 @@ async function handler(
|
|
|
89
104
|
return (
|
|
90
105
|
`Metadata ${replace ? "replaced" : "merged"} on ${document_id}. ${summary}\n` +
|
|
91
106
|
`Now: ${JSON.stringify(row.metadata ?? {})}\n` +
|
|
107
|
+
(row.metadata_revision != null ? `metadata_revision: ${row.metadata_revision}\n` : "") +
|
|
92
108
|
`Content untouched — no new version, no re-embedding.`
|
|
93
109
|
);
|
|
94
110
|
}
|
|
@@ -96,7 +112,7 @@ async function handler(
|
|
|
96
112
|
export const setDocumentMetadataTool: ToolDefinition = {
|
|
97
113
|
name: "cerefox_set_document_metadata",
|
|
98
114
|
description:
|
|
99
|
-
"Change a document's metadata WITHOUT resending its content. MERGES by default: the keys you pass are set, every other key is left alone — so you do not need to read the document first, and you cannot accidentally drop tags another agent set. To REMOVE a key, pass it with a null value ({\"stale_key\": null}). Pass replace: true to set the metadata to exactly the object given, discarding everything else (rare; the
|
|
115
|
+
"Change a document's metadata WITHOUT resending its content. MERGES by default: the keys you pass are set, every other key is left alone — so you do not need to read the document first, and you cannot accidentally drop tags another agent set. To REMOVE a key, pass it with a null value ({\"stale_key\": null}). Pass replace: true to set the metadata to exactly the object given, discarding everything else (rare; it requires expected_metadata_revision or last_write_wins). To make a merge fail if anyone changed the metadata since you read it, pass expected_metadata_revision. Content, chunks and embeddings are untouched and no new version is created. Use this instead of cerefox_ingest whenever only the tags are changing.",
|
|
100
116
|
annotations: {
|
|
101
117
|
title: "Set document metadata",
|
|
102
118
|
readOnlyHint: false,
|
|
@@ -118,7 +134,17 @@ export const setDocumentMetadataTool: ToolDefinition = {
|
|
|
118
134
|
replace: {
|
|
119
135
|
type: "boolean",
|
|
120
136
|
description:
|
|
121
|
-
"Set the metadata to EXACTLY this object, discarding any key not listed. Defaults to false (merge). Use only when you mean to reset a document's tags wholesale.",
|
|
137
|
+
"Set the metadata to EXACTLY this object, discarding any key not listed. Defaults to false (merge). Use only when you mean to reset a document's tags wholesale. Requires expected_metadata_revision (or last_write_wins).",
|
|
138
|
+
},
|
|
139
|
+
expected_metadata_revision: {
|
|
140
|
+
type: "integer",
|
|
141
|
+
description:
|
|
142
|
+
"The metadata_revision you read (from cerefox_get_document or cerefox_metadata_search). Optional on a merge: when given, the write fails if the metadata changed since. Required with replace: true.",
|
|
143
|
+
},
|
|
144
|
+
last_write_wins: {
|
|
145
|
+
type: "boolean",
|
|
146
|
+
description:
|
|
147
|
+
"Replace without a metadata_revision, regardless of concurrent changes (default false). Recorded in the audit log. Use only when an external source of truth makes conflicts meaningless.",
|
|
122
148
|
},
|
|
123
149
|
author: AUTHOR_PARAM_WRITE,
|
|
124
150
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
--
|
|
3
3
|
-- Records how each chunk's content reconstructs into full document text:
|
|
4
4
|
-- 1 = legacy — chunk contents were trimmed sections; reconstruction re-joins
|
|
5
|
-
-- them with E'\n\n' (the pre-28D
|
|
5
|
+
-- them with E'\n\n' (the pre-28D behavior). All existing chunks.
|
|
6
6
|
-- 2 = blind-stitch — chunk contents are an exact, gapless partition of the
|
|
7
7
|
-- document; reconstruction is a plain concatenation (no separator
|
|
8
8
|
-- synthesized on read). Written by the exact-partition chunker.
|
|
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS cerefox_document_relations (
|
|
|
12
12
|
source_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
|
|
13
13
|
target_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
|
|
14
14
|
-- Free-text by design: agents define new types without a migration. The
|
|
15
|
-
-- type dictionary (in the RPCs) gives known types
|
|
15
|
+
-- type dictionary (in the RPCs) gives known types behavior; unknown types
|
|
16
16
|
-- are stored and returned, just without special handling.
|
|
17
17
|
rel_type TEXT NOT NULL,
|
|
18
18
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
@@ -55,5 +55,5 @@ BEGIN
|
|
|
55
55
|
'Migration 0020: cerefox_ingest_document now preserves a document''s '
|
|
56
56
|
'source when p_source is omitted. Before this, any content update '
|
|
57
57
|
'without an explicit source silently rewrote provenance to ''agent'', '
|
|
58
|
-
'and migrate-format
|
|
58
|
+
'and migrate-format relabeled every document it converted (#191).';
|
|
59
59
|
END $$;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
ALTER TABLE cerefox_document_relations ENABLE ROW LEVEL SECURITY;
|
|
28
28
|
|
|
29
|
-
--
|
|
29
|
+
-- Defense in depth: revoke the legacy blanket grants so the table is denied by
|
|
30
30
|
-- privilege as well as by RLS. Harmless where the grants were never made.
|
|
31
31
|
REVOKE ALL ON TABLE cerefox_document_relations FROM anon;
|
|
32
32
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- 0034_drop_vector_indexes.sql — drop the unused HNSW vector indexes (schema
|
|
2
|
+
-- 0.19.0, release 1.18.0).
|
|
3
|
+
--
|
|
4
|
+
-- Since 0.18.1 the hybrid search RPCs rank vector candidates by an exact scan
|
|
5
|
+
-- (`ORDER BY vec_score DESC`, see cerefox_hybrid_search in rpcs.sql), because
|
|
6
|
+
-- the HNSW index answered the bare-distance ordering with at most
|
|
7
|
+
-- `hnsw.ef_search` (40) rows, and on a store with heavy version churn those
|
|
8
|
+
-- were mostly not the nearest ones. The two indexes have served no query since,
|
|
9
|
+
-- while every chunk write still paid to maintain them and their quality kept
|
|
10
|
+
-- degrading.
|
|
11
|
+
--
|
|
12
|
+
-- The exact scan is fast at knowledge-base scale: measured on a 1-CPU / 1 GB
|
|
13
|
+
-- Postgres with 768-dim vectors, top-500 candidates took 2 ms at 2k chunks,
|
|
14
|
+
-- 56 ms at 50k and 181 ms at 100k. A default-configured HNSW index was slower
|
|
15
|
+
-- than that from 50k chunks and recalled 27% of the true top-250 at 100k.
|
|
16
|
+
-- `cerefox doctor` reports the current chunk count and warns past 100k; a
|
|
17
|
+
-- tuned index path for stores that large is tracked separately.
|
|
18
|
+
--
|
|
19
|
+
-- Idempotent: safe to re-run.
|
|
20
|
+
|
|
21
|
+
DROP INDEX IF EXISTS idx_cerefox_chunks_emb_primary;
|
|
22
|
+
DROP INDEX IF EXISTS idx_cerefox_chunks_emb_upgrade;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- 0035_drop_content_hash_unique.sql — content no longer has to be unique
|
|
2
|
+
-- across the store (schema 0.20.0, #332).
|
|
3
|
+
--
|
|
4
|
+
-- `cerefox_documents_hash_unique` made every document's body unique store-wide,
|
|
5
|
+
-- trashed documents included. It was meant to stop the same file being
|
|
6
|
+
-- ingested twice, but as an unconditional constraint it also refused
|
|
7
|
+
-- legitimate writes: an update whose new body matched any OTHER document (two
|
|
8
|
+
-- unrelated documents converging on a short or templated body, a revision
|
|
9
|
+
-- back to an earlier state), and any write matching a document in the trash,
|
|
10
|
+
-- which the caller cannot see.
|
|
11
|
+
--
|
|
12
|
+
-- Duplicate detection now happens only where it is useful, when CREATING a
|
|
13
|
+
-- document, and only against live documents: `cerefox_ingest_document`
|
|
14
|
+
-- returns the existing document with operation 'skipped' instead of inserting
|
|
15
|
+
-- a copy. Updates are never refused for matching another document.
|
|
16
|
+
--
|
|
17
|
+
-- The plain index idx_cerefox_docs_hash keeps hash lookups fast.
|
|
18
|
+
--
|
|
19
|
+
-- Idempotent: safe to re-run.
|
|
20
|
+
|
|
21
|
+
ALTER TABLE cerefox_documents DROP CONSTRAINT IF EXISTS cerefox_documents_hash_unique;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- 0036_metadata_revision.sql — a concurrency token and a history for metadata
|
|
2
|
+
-- (schema 0.21.0, #333).
|
|
3
|
+
--
|
|
4
|
+
-- Optimistic concurrency covered the body only: content_hash does not move on
|
|
5
|
+
-- a metadata change, and no metadata write checked a token, so two writers
|
|
6
|
+
-- setting the same key, a `replace`, or a content update carrying metadata
|
|
7
|
+
-- could silently overwrite another writer's change. Metadata also had no
|
|
8
|
+
-- history: content versions never captured it and audit entries recorded only
|
|
9
|
+
-- key counts.
|
|
10
|
+
--
|
|
11
|
+
-- 1. cerefox_documents.metadata_revision: the metadata's own token, bumped by a
|
|
12
|
+
-- trigger whenever metadata actually changes (every writer is covered).
|
|
13
|
+
-- 2. cerefox_audit_log.metadata_before / metadata_after: written on
|
|
14
|
+
-- 'update-metadata' entries, so any change can be reconstructed.
|
|
15
|
+
--
|
|
16
|
+
-- The RPCs that check the token and write the history ship in rpcs.sql.
|
|
17
|
+
-- Idempotent: safe to re-run.
|
|
18
|
+
|
|
19
|
+
ALTER TABLE cerefox_documents
|
|
20
|
+
ADD COLUMN IF NOT EXISTS metadata_revision INT NOT NULL DEFAULT 1;
|
|
21
|
+
|
|
22
|
+
ALTER TABLE cerefox_audit_log
|
|
23
|
+
ADD COLUMN IF NOT EXISTS metadata_before JSONB,
|
|
24
|
+
ADD COLUMN IF NOT EXISTS metadata_after JSONB;
|
|
25
|
+
|
|
26
|
+
CREATE OR REPLACE FUNCTION cerefox_bump_metadata_revision()
|
|
27
|
+
RETURNS TRIGGER
|
|
28
|
+
LANGUAGE plpgsql
|
|
29
|
+
SET search_path = public, pg_catalog
|
|
30
|
+
AS $$
|
|
31
|
+
BEGIN
|
|
32
|
+
IF NEW.metadata IS DISTINCT FROM OLD.metadata THEN
|
|
33
|
+
NEW.metadata_revision := OLD.metadata_revision + 1;
|
|
34
|
+
ELSE
|
|
35
|
+
NEW.metadata_revision := OLD.metadata_revision;
|
|
36
|
+
END IF;
|
|
37
|
+
RETURN NEW;
|
|
38
|
+
END;
|
|
39
|
+
$$;
|
|
40
|
+
|
|
41
|
+
CREATE OR REPLACE TRIGGER trig_cerefox_documents_metadata_revision
|
|
42
|
+
BEFORE UPDATE ON cerefox_documents
|
|
43
|
+
FOR EACH ROW EXECUTE FUNCTION cerefox_bump_metadata_revision();
|