@cerefox/memory 1.9.2 → 1.10.1
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 +16 -0
- package/AGENT_QUICK_REFERENCE.md +1 -0
- package/dist/bin/cerefox.js +924 -814
- package/dist/frontend/assets/index-i67RH1VY.js +121 -0
- package/dist/frontend/assets/index-i67RH1VY.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
- package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +377 -0
- package/dist/server-assets/_shared/mcp-tools/_projects.ts +13 -32
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +9 -2
- package/dist/server-assets/_shared/mcp-tools/escape-heuristic.ts +62 -0
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +2 -2
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +5 -3
- package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +9 -1
- package/dist/server-assets/_shared/mcp-tools/types.ts +6 -2
- package/dist/server-assets/db/migrations/0030_rename_document_rpc.sql +80 -0
- package/dist/server-assets/db/rpcs.sql +63 -1
- package/dist/server-assets/db/schema.sql +1 -1
- package/package.json +1 -1
- package/dist/frontend/assets/index-DWT7wZMR.js +0 -121
- package/dist/frontend/assets/index-DWT7wZMR.js.map +0 -1
package/dist/frontend/index.html
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"
|
|
16
16
|
/>
|
|
17
17
|
<title>Cerefox</title>
|
|
18
|
-
<script type="module" crossorigin src="/app/assets/index-
|
|
18
|
+
<script type="module" crossorigin src="/app/assets/index-i67RH1VY.js"></script>
|
|
19
19
|
<link rel="stylesheet" crossorigin href="/app/assets/index-Dm_zCch4.css">
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* doesn't touch `supabase/functions/` leaves it alone).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
export const EF_VERSION = "1.
|
|
21
|
+
export const EF_VERSION = "1.10.1";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The Cerefox RELEASE version — what `cerefox --version` reports and what npm
|
|
@@ -36,7 +36,7 @@ export const EF_VERSION = "1.9.2";
|
|
|
36
36
|
* is imported by the Deno Edge Functions, which cannot reach into the npm
|
|
37
37
|
* package.
|
|
38
38
|
*/
|
|
39
|
-
export const CEREFOX_VERSION = "1.
|
|
39
|
+
export const CEREFOX_VERSION = "1.10.1";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* The most recent version whose EF-side SOURCE actually changed (#127).
|
|
@@ -46,7 +46,7 @@ export const CEREFOX_VERSION = "1.9.2";
|
|
|
46
46
|
* `cut_release.ts` ONLY when EF source changed since the last tag; doctor
|
|
47
47
|
* uses it to stay silent on label-only drift.
|
|
48
48
|
*/
|
|
49
|
-
export const EF_LAST_CHANGED = "1.
|
|
49
|
+
export const EF_LAST_CHANGED = "1.10.1";
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document meta-facet cores + orchestrator (iteration 39, v1.10.0).
|
|
3
|
+
*
|
|
4
|
+
* The web document-save was the last multi-facet write not built on shared
|
|
5
|
+
* cores: it raw-updated title (silently skipping the FTS refresh that title
|
|
6
|
+
* boosting requires), raw-replaced metadata (bypassing the #212 merge
|
|
7
|
+
* guards), replaced memberships with its own helper (no audit, no usage
|
|
8
|
+
* log), and recorded one entry describing the REQUEST shape rather than
|
|
9
|
+
* what changed.
|
|
10
|
+
*
|
|
11
|
+
* These cores are the single implementation for each facet; the orchestrator
|
|
12
|
+
* sequences them for surfaces (web today) that edit several facets in one
|
|
13
|
+
* user action. Deliberately NO combined audit entry: per-facet entries are
|
|
14
|
+
* the house pattern (iteration 33 — the trail distinguishes what happened),
|
|
15
|
+
* and each core emits the same description regardless of interface. Every
|
|
16
|
+
* facet diffs against the STORED value first — a facet the request carried
|
|
17
|
+
* but did not change is skipped entirely, so the trail never records
|
|
18
|
+
* non-events.
|
|
19
|
+
*
|
|
20
|
+
* Errors are TYPED (review round 1): callers map FacetNotFoundError → 404 /
|
|
21
|
+
* notFound and FacetValidationError → 400 / userError without string-matching
|
|
22
|
+
* prose that another module owns.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { AccessPath, MCPSupabaseClient } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
import { logUsage, storeWriteRemediation } from "./_utils.ts";
|
|
28
|
+
|
|
29
|
+
export interface FacetActor {
|
|
30
|
+
author: string;
|
|
31
|
+
authorType: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The document (or a referenced project) does not exist or is in the trash. */
|
|
35
|
+
export class FacetNotFoundError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "FacetNotFoundError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The request is well-formed but fails a semantic check the caller can fix. */
|
|
43
|
+
export class FacetValidationError extends Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "FacetValidationError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Key-order-independent JSONB-style equality for metadata objects. */
|
|
51
|
+
export function stableStringify(value: unknown): string {
|
|
52
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
53
|
+
if (value !== null && typeof value === "object") {
|
|
54
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
55
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(",")}}`;
|
|
56
|
+
}
|
|
57
|
+
return JSON.stringify(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Replace-mode normalization mirroring the RPC: a null value means
|
|
61
|
+
* "remove this key", so null-valued keys vanish before comparison —
|
|
62
|
+
* otherwise a request that NORMALIZES to the stored value would fire the
|
|
63
|
+
* RPC and record a non-change (review round 1). */
|
|
64
|
+
export function normalizeMetadata(value: Record<string, unknown>): Record<string, unknown> {
|
|
65
|
+
const out: Record<string, unknown> = {};
|
|
66
|
+
for (const [k, v] of Object.entries(value)) {
|
|
67
|
+
if (v !== null) out[k] = v;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rename a document — a thin wrapper over `cerefox_rename_document`
|
|
74
|
+
* (0.15.0), which commits the row update, the chunk-FTS refresh (title
|
|
75
|
+
* boosting), and the audit entry in ONE transaction. The client-side
|
|
76
|
+
* sequencing this replaces could commit the rename and then fail the
|
|
77
|
+
* refresh, leaving the document ranking under its old title with no retry
|
|
78
|
+
* path. Unchanged title → RPC-side no-op, no entry.
|
|
79
|
+
*/
|
|
80
|
+
export async function changeDocumentTitle(
|
|
81
|
+
supabase: MCPSupabaseClient,
|
|
82
|
+
documentId: string,
|
|
83
|
+
newTitle: string,
|
|
84
|
+
who: FacetActor,
|
|
85
|
+
): Promise<{ changed: boolean; title: string }> {
|
|
86
|
+
const trimmed = (newTitle ?? "").trim();
|
|
87
|
+
if (!trimmed) throw new FacetValidationError("Title cannot be empty.");
|
|
88
|
+
|
|
89
|
+
const { data, error } = await supabase.rpc("cerefox_rename_document", {
|
|
90
|
+
p_document_id: documentId,
|
|
91
|
+
p_new_title: trimmed,
|
|
92
|
+
p_author: who.author,
|
|
93
|
+
p_author_type: who.authorType,
|
|
94
|
+
});
|
|
95
|
+
if (error) {
|
|
96
|
+
const msg = error.message ?? String(error);
|
|
97
|
+
if (/not found/i.test(msg)) throw new FacetNotFoundError(msg);
|
|
98
|
+
if (/cannot be empty/i.test(msg)) throw new FacetValidationError(msg);
|
|
99
|
+
// 0.15.0 grew the server surface; against a 0.14.x server the RPC is
|
|
100
|
+
// absent — say "redeploy", with the shared remediation prose.
|
|
101
|
+
const remediation = storeWriteRemediation(msg, "cerefox_rename_document", "0.15.0");
|
|
102
|
+
if (remediation) throw new Error(`Title update failed: ${remediation}`);
|
|
103
|
+
throw new Error(`Title update failed: ${msg}`);
|
|
104
|
+
}
|
|
105
|
+
const row = (data as Array<{ renamed: boolean; new_title: string }> | null)?.[0];
|
|
106
|
+
return { changed: row?.renamed ?? false, title: row?.new_title ?? trimmed };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The shared replace + audit + usage-log tail used by BOTH membership twins
|
|
111
|
+
* (ids here, names in `replaceDocumentProjects`) — review round 1 caught the
|
|
112
|
+
* two carrying drifting copies. Unified semantics: an unchanged set is a
|
|
113
|
+
* complete no-op (no entry — the trail never records non-events; this also
|
|
114
|
+
* changes the previously always-logging name path).
|
|
115
|
+
*/
|
|
116
|
+
export async function applyMembershipReplace(
|
|
117
|
+
supabase: MCPSupabaseClient,
|
|
118
|
+
opts: {
|
|
119
|
+
documentId: string;
|
|
120
|
+
projectIds: string[];
|
|
121
|
+
projectNames: string[];
|
|
122
|
+
accessPath: AccessPath;
|
|
123
|
+
} & FacetActor,
|
|
124
|
+
): Promise<{ changed: boolean }> {
|
|
125
|
+
const { data: current, error: curErr } = await supabase
|
|
126
|
+
.from("cerefox_document_projects")
|
|
127
|
+
.select("project_id")
|
|
128
|
+
.eq("document_id", opts.documentId);
|
|
129
|
+
if (curErr) throw new Error(`Membership read failed: ${curErr.message}`);
|
|
130
|
+
const currentSet = new Set((current ?? []).map((r: { project_id: string }) => r.project_id));
|
|
131
|
+
if (
|
|
132
|
+
opts.projectIds.length === currentSet.size &&
|
|
133
|
+
opts.projectIds.every((id) => currentSet.has(id))
|
|
134
|
+
) {
|
|
135
|
+
return { changed: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { error: delErr } = await supabase
|
|
139
|
+
.from("cerefox_document_projects")
|
|
140
|
+
.delete()
|
|
141
|
+
.eq("document_id", opts.documentId);
|
|
142
|
+
if (delErr) throw new Error(`Membership replace failed: ${delErr.message}`);
|
|
143
|
+
if (opts.projectIds.length > 0) {
|
|
144
|
+
const rows = opts.projectIds.map((pid) => ({
|
|
145
|
+
document_id: opts.documentId,
|
|
146
|
+
project_id: pid,
|
|
147
|
+
}));
|
|
148
|
+
const { error: insErr } = await supabase.from("cerefox_document_projects").insert(rows);
|
|
149
|
+
if (insErr) throw new Error(`Membership replace failed: ${insErr.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { error: auditErr } = await supabase.rpc("cerefox_create_audit_entry", {
|
|
153
|
+
p_document_id: opts.documentId,
|
|
154
|
+
p_version_id: null,
|
|
155
|
+
p_operation: "update-metadata",
|
|
156
|
+
p_author: opts.author,
|
|
157
|
+
p_author_type: opts.authorType,
|
|
158
|
+
p_size_before: null,
|
|
159
|
+
p_size_after: null,
|
|
160
|
+
p_description:
|
|
161
|
+
opts.projectNames.length > 0
|
|
162
|
+
? `Set document projects to [${opts.projectNames.join(", ")}]`
|
|
163
|
+
: "Cleared all project memberships",
|
|
164
|
+
});
|
|
165
|
+
if (auditErr) console.warn("applyMembershipReplace: audit entry failed", auditErr.message);
|
|
166
|
+
|
|
167
|
+
logUsage(supabase, {
|
|
168
|
+
operation: "set-document-projects",
|
|
169
|
+
accessPath: opts.accessPath,
|
|
170
|
+
requestor: opts.author,
|
|
171
|
+
document_id: opts.documentId,
|
|
172
|
+
result_count: opts.projectIds.length,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return { changed: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Guard shared by the facet writes: the document must exist and not be in
|
|
179
|
+
* the trash (a trashed document is immutable until restored — 0.12.0). */
|
|
180
|
+
async function assertDocumentLive(
|
|
181
|
+
supabase: MCPSupabaseClient,
|
|
182
|
+
documentId: string,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const { data, error } = await supabase
|
|
185
|
+
.from("cerefox_documents")
|
|
186
|
+
.select("id")
|
|
187
|
+
.eq("id", documentId)
|
|
188
|
+
.is("deleted_at", null)
|
|
189
|
+
.limit(1);
|
|
190
|
+
if (error) throw new Error(`Document read failed: ${error.message}`);
|
|
191
|
+
if (!data?.length) {
|
|
192
|
+
throw new FacetNotFoundError(`Document not found (or in the trash): ${documentId}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Replace a document's project memberships from project IDS (the web UI's
|
|
198
|
+
* currency; the name-based twin `replaceDocumentProjects` serves MCP/CLI and
|
|
199
|
+
* delegates to the same tail). Validates the document and every id BEFORE
|
|
200
|
+
* the destructive replace. No-op when the set is unchanged.
|
|
201
|
+
*/
|
|
202
|
+
export async function setDocumentProjectsByIds(
|
|
203
|
+
supabase: MCPSupabaseClient,
|
|
204
|
+
opts: {
|
|
205
|
+
documentId: string;
|
|
206
|
+
projectIds: string[];
|
|
207
|
+
accessPath: AccessPath;
|
|
208
|
+
} & FacetActor,
|
|
209
|
+
): Promise<{ changed: boolean; names: string[] }> {
|
|
210
|
+
const wanted = [...new Set(opts.projectIds)];
|
|
211
|
+
|
|
212
|
+
// Diff FIRST: an unchanged set is a complete no-op and needs no
|
|
213
|
+
// validation (its ids provably exist — they are current memberships).
|
|
214
|
+
const { data: current, error: curErr } = await supabase
|
|
215
|
+
.from("cerefox_document_projects")
|
|
216
|
+
.select("project_id")
|
|
217
|
+
.eq("document_id", opts.documentId);
|
|
218
|
+
if (curErr) throw new Error(`Membership read failed: ${curErr.message}`);
|
|
219
|
+
const currentSet = new Set((current ?? []).map((r: { project_id: string }) => r.project_id));
|
|
220
|
+
if (wanted.length === currentSet.size && wanted.every((id) => currentSet.has(id))) {
|
|
221
|
+
return { changed: false, names: [] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await assertDocumentLive(supabase, opts.documentId);
|
|
225
|
+
|
|
226
|
+
let names: string[] = [];
|
|
227
|
+
if (wanted.length > 0) {
|
|
228
|
+
const { data: found, error: valErr } = await supabase
|
|
229
|
+
.from("cerefox_projects")
|
|
230
|
+
.select("id, name")
|
|
231
|
+
.in("id", wanted);
|
|
232
|
+
if (valErr) throw new Error(`Project validation failed: ${valErr.message}`);
|
|
233
|
+
const byId = new Map<string, string>(
|
|
234
|
+
(found ?? []).map((r: { id: string; name: string }) => [r.id, r.name] as [string, string]),
|
|
235
|
+
);
|
|
236
|
+
const missing = wanted.filter((id) => !byId.has(id));
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
throw new FacetValidationError(
|
|
239
|
+
`Unknown project id(s): ${missing.join(", ")} — memberships left unchanged.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
names = wanted.map((id) => byId.get(id)!);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const r = await applyMembershipReplace(supabase, {
|
|
246
|
+
documentId: opts.documentId,
|
|
247
|
+
projectIds: wanted,
|
|
248
|
+
projectNames: names,
|
|
249
|
+
accessPath: opts.accessPath,
|
|
250
|
+
author: opts.author,
|
|
251
|
+
authorType: opts.authorType,
|
|
252
|
+
});
|
|
253
|
+
return { changed: r.changed, names };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface FacetUpdateResult {
|
|
257
|
+
titleChanged: boolean;
|
|
258
|
+
metadataChanged: boolean;
|
|
259
|
+
projectsChanged: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Thrown when a later facet fails after earlier ones committed: carries
|
|
263
|
+
* what DID apply so the surface can report the partial state honestly
|
|
264
|
+
* (the facets are separate transactions by design — full cross-facet
|
|
265
|
+
* atomicity would need one mega-RPC for three loosely related writes). */
|
|
266
|
+
export class FacetUpdateError extends Error {
|
|
267
|
+
applied: FacetUpdateResult;
|
|
268
|
+
constructor(applied: FacetUpdateResult, cause: Error) {
|
|
269
|
+
const done = [
|
|
270
|
+
applied.metadataChanged ? "metadata" : null,
|
|
271
|
+
applied.projectsChanged ? "projects" : null,
|
|
272
|
+
applied.titleChanged ? "title" : null,
|
|
273
|
+
].filter(Boolean);
|
|
274
|
+
super(
|
|
275
|
+
done.length > 0
|
|
276
|
+
? `${cause.message} (already applied before the failure: ${done.join(", ")})`
|
|
277
|
+
: cause.message,
|
|
278
|
+
);
|
|
279
|
+
this.name = "FacetUpdateError";
|
|
280
|
+
this.applied = applied;
|
|
281
|
+
this.cause = cause;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Orchestrate a multi-facet document meta update (metadata / memberships /
|
|
287
|
+
* title) for surfaces that edit them in one user action. Sequencing wrapper
|
|
288
|
+
* ONLY: each facet applies through its single implementation and writes its
|
|
289
|
+
* own audit entry — deliberately no combined entry. Order runs from the
|
|
290
|
+
* facet most likely to be rejected (metadata: free-form user input) to the
|
|
291
|
+
* least (title: one atomic RPC), minimizing partial application; a mid-way
|
|
292
|
+
* failure raises FacetUpdateError naming what already committed.
|
|
293
|
+
*/
|
|
294
|
+
export async function updateDocumentFacets(
|
|
295
|
+
supabase: MCPSupabaseClient,
|
|
296
|
+
opts: {
|
|
297
|
+
documentId: string;
|
|
298
|
+
title?: string;
|
|
299
|
+
metadata?: Record<string, unknown>;
|
|
300
|
+
projectIds?: string[];
|
|
301
|
+
accessPath: AccessPath;
|
|
302
|
+
} & FacetActor,
|
|
303
|
+
): Promise<FacetUpdateResult> {
|
|
304
|
+
const result: FacetUpdateResult = {
|
|
305
|
+
titleChanged: false,
|
|
306
|
+
metadataChanged: false,
|
|
307
|
+
projectsChanged: false,
|
|
308
|
+
};
|
|
309
|
+
const who: FacetActor = { author: opts.author, authorType: opts.authorType };
|
|
310
|
+
|
|
311
|
+
const step = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
312
|
+
try {
|
|
313
|
+
return await fn();
|
|
314
|
+
} catch (err) {
|
|
315
|
+
throw err instanceof FacetUpdateError
|
|
316
|
+
? err
|
|
317
|
+
: new FacetUpdateError(result, err instanceof Error ? err : new Error(String(err)));
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
if (opts.metadata !== undefined) {
|
|
322
|
+
await step(async () => {
|
|
323
|
+
const { data: doc, error } = await supabase
|
|
324
|
+
.from("cerefox_documents")
|
|
325
|
+
.select("metadata")
|
|
326
|
+
.eq("id", opts.documentId)
|
|
327
|
+
.is("deleted_at", null)
|
|
328
|
+
.limit(1);
|
|
329
|
+
if (error) throw new Error(`Metadata read failed: ${error.message}`);
|
|
330
|
+
if (!doc?.length) {
|
|
331
|
+
throw new FacetNotFoundError(`Document not found (or in the trash): ${opts.documentId}`);
|
|
332
|
+
}
|
|
333
|
+
const stored = (doc[0].metadata ?? {}) as Record<string, unknown>;
|
|
334
|
+
const wanted = normalizeMetadata(opts.metadata!);
|
|
335
|
+
if (stableStringify(stored) !== stableStringify(wanted)) {
|
|
336
|
+
// Replace mode: the save surface edits the whole object (an empty
|
|
337
|
+
// object CLEARS all keys — "remove the last key" must not be a
|
|
338
|
+
// silent no-op). The RPC carries the #212 guards and writes its own
|
|
339
|
+
// per-key audit report.
|
|
340
|
+
const { error: rpcErr } = await supabase.rpc("cerefox_set_document_metadata", {
|
|
341
|
+
p_document_id: opts.documentId,
|
|
342
|
+
p_metadata: wanted,
|
|
343
|
+
p_replace: true,
|
|
344
|
+
p_author: opts.author,
|
|
345
|
+
p_author_type: opts.authorType,
|
|
346
|
+
});
|
|
347
|
+
if (rpcErr) {
|
|
348
|
+
const msg = rpcErr.message ?? String(rpcErr);
|
|
349
|
+
if (/not found/i.test(msg)) throw new FacetNotFoundError(msg);
|
|
350
|
+
throw new Error(`Metadata update failed: ${msg}`);
|
|
351
|
+
}
|
|
352
|
+
result.metadataChanged = true;
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (opts.projectIds !== undefined) {
|
|
358
|
+
await step(async () => {
|
|
359
|
+
const r = await setDocumentProjectsByIds(supabase, {
|
|
360
|
+
documentId: opts.documentId,
|
|
361
|
+
projectIds: opts.projectIds!,
|
|
362
|
+
accessPath: opts.accessPath,
|
|
363
|
+
...who,
|
|
364
|
+
});
|
|
365
|
+
result.projectsChanged = r.changed;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (opts.title !== undefined) {
|
|
370
|
+
await step(async () => {
|
|
371
|
+
const r = await changeDocumentTitle(supabase, opts.documentId, opts.title!, who);
|
|
372
|
+
result.titleChanged = r.changed;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
import type { AccessPath, MCPSupabaseClient } from "./types.ts";
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { applyMembershipReplace } from "./_document-meta.ts";
|
|
27
|
+
import { storeWriteRemediation } from "./_utils.ts";
|
|
27
28
|
|
|
28
29
|
/** Who to attribute an implicit/explicit project write to in the audit log. */
|
|
29
30
|
export interface ProjectAuditContext {
|
|
@@ -208,38 +209,18 @@ export async function replaceDocumentProjects(
|
|
|
208
209
|
}
|
|
209
210
|
const projectIds = resolved.map((r) => r!.projectId);
|
|
210
211
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await supabase.rpc("cerefox_create_audit_entry", {
|
|
221
|
-
p_document_id: documentId,
|
|
222
|
-
p_version_id: null,
|
|
223
|
-
p_operation: "update-metadata",
|
|
224
|
-
p_author: author,
|
|
225
|
-
p_author_type: authorType,
|
|
226
|
-
p_size_before: null,
|
|
227
|
-
p_size_after: null,
|
|
228
|
-
p_description:
|
|
229
|
-
cleanNames.length > 0
|
|
230
|
-
? `Set document projects to [${cleanNames.join(", ")}]`
|
|
231
|
-
: "Cleared all project memberships",
|
|
232
|
-
});
|
|
233
|
-
} catch (err) {
|
|
234
|
-
console.warn("replaceDocumentProjects: audit entry failed", err);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
logUsage(supabase, {
|
|
238
|
-
operation: "set-document-projects",
|
|
212
|
+
// Delegate the replace + audit + usage-log tail to the shared core
|
|
213
|
+
// (review round 1: the id-based twin had a drifting copy). Unified
|
|
214
|
+
// semantics: an unchanged set is a complete no-op with NO audit entry —
|
|
215
|
+
// the trail never records non-events (this changes the previously
|
|
216
|
+
// always-logging behavior of this path, deliberately).
|
|
217
|
+
await applyMembershipReplace(supabase, {
|
|
218
|
+
documentId,
|
|
219
|
+
projectIds,
|
|
220
|
+
projectNames: cleanNames,
|
|
239
221
|
accessPath,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
result_count: projectIds.length,
|
|
222
|
+
author,
|
|
223
|
+
authorType,
|
|
243
224
|
});
|
|
244
225
|
|
|
245
226
|
return { documentTitle: doc[0].title as string, cleanNames, projectIds };
|
|
@@ -243,10 +243,17 @@ export function isDuplicateKeyError(message: string): boolean {
|
|
|
243
243
|
* deployment-state problem. Keeps the CLI and web surfaces in lockstep —
|
|
244
244
|
* round 4 found the two carrying hand-copied, already-diverging prose.
|
|
245
245
|
*/
|
|
246
|
-
export function storeWriteRemediation(
|
|
246
|
+
export function storeWriteRemediation(
|
|
247
|
+
message: string,
|
|
248
|
+
fnName: string,
|
|
249
|
+
// The schema floor the CALLING feature requires — round 2 caught the
|
|
250
|
+
// hardcoded "0.14.0" contradicting doctor on a 0.14.1 store missing a
|
|
251
|
+
// 0.15.0-only function.
|
|
252
|
+
requiredSchema = "0.14.0",
|
|
253
|
+
): string | null {
|
|
247
254
|
if (isMissingFunctionError(message, fnName)) {
|
|
248
255
|
return (
|
|
249
|
-
|
|
256
|
+
`The deployed server predates schema ${requiredSchema} — or PostgREST's schema cache ` +
|
|
250
257
|
"is stale right after a deploy. If you just deployed, retry in a few " +
|
|
251
258
|
"seconds; otherwise run `cerefox server deploy`."
|
|
252
259
|
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escaped-content heuristic (#222, v1.10.1) — a dependency-free leaf module.
|
|
3
|
+
*
|
|
4
|
+
* Agents authoring long multi-line bodies inline in a tool call occasionally
|
|
5
|
+
* JSON-escape a stretch one level too many, so the stored content carries the
|
|
6
|
+
* literal two-character sequences `\n` and `\"` where newlines and quotes
|
|
7
|
+
* were meant. Cerefox stores bytes faithfully (verified at the byte level),
|
|
8
|
+
* so the ONLY defensible response is a WARNING, never normalization:
|
|
9
|
+
* auto-converting would corrupt content that legitimately discusses escape
|
|
10
|
+
* sequences and would mask the emitting client's bug.
|
|
11
|
+
*
|
|
12
|
+
* The trigger is a RATIO, not an absolute count — calibrated on real data:
|
|
13
|
+
* legitimately-escaping documents (guides discussing `\n`) sit far below 1%
|
|
14
|
+
* literals-to-real-newlines, while observed corruption ran 50–70%. Threshold:
|
|
15
|
+
* at least 3 literal sequences AND literals ≥ 25% of real newlines.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface EscapeSuspicion {
|
|
19
|
+
literalNewlines: number;
|
|
20
|
+
literalQuotes: number;
|
|
21
|
+
realNewlines: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function measureEscapes(content: string): EscapeSuspicion {
|
|
25
|
+
return {
|
|
26
|
+
literalNewlines: (content.match(/\\n/g) ?? []).length,
|
|
27
|
+
literalQuotes: (content.match(/\\"/g) ?? []).length,
|
|
28
|
+
realNewlines: (content.match(/\n/g) ?? []).length,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The non-blocking note to append to a write response, or "" when the
|
|
34
|
+
* content looks fine — an empty string appends as nothing and stays falsy,
|
|
35
|
+
* so callers concatenate directly with no `?? ""` dance (review round 1:
|
|
36
|
+
* forgetting it would interpolate the literal string "null"). A signal in
|
|
37
|
+
* the write's response, never a refusal — the iteration-33 posture.
|
|
38
|
+
*
|
|
39
|
+
* The remedy clause is channel-aware: the MCP tail names MCP tools; the CLI
|
|
40
|
+
* tail must not tell a user who just ingested a file to "prefer ingesting
|
|
41
|
+
* from a file".
|
|
42
|
+
*/
|
|
43
|
+
export function escapedContentNote(content: string, channel: "mcp" | "cli" = "mcp"): string {
|
|
44
|
+
const m = measureEscapes(content);
|
|
45
|
+
const literals = m.literalNewlines + m.literalQuotes;
|
|
46
|
+
if (literals < 3 || literals * 4 < m.realNewlines) return "";
|
|
47
|
+
const parts = [
|
|
48
|
+
m.literalNewlines > 0 ? `${m.literalNewlines} literal \\n` : null,
|
|
49
|
+
m.literalQuotes > 0 ? `${m.literalQuotes} literal \\"` : null,
|
|
50
|
+
].filter(Boolean);
|
|
51
|
+
const remedy =
|
|
52
|
+
channel === "cli"
|
|
53
|
+
? `fix the escaping in the source and re-ingest.`
|
|
54
|
+
: `re-send with actual characters. For long content, prefer ` +
|
|
55
|
+
`ingesting from a file or building the document incrementally with ` +
|
|
56
|
+
`cerefox_insert/cerefox_edit.`;
|
|
57
|
+
return (
|
|
58
|
+
` Note: content contains ${parts.join(" and ")} sequence(s) against ` +
|
|
59
|
+
`${m.realNewlines} real newline(s) — if line breaks or quotes were ` +
|
|
60
|
+
`intended, ` + remedy
|
|
61
|
+
);
|
|
62
|
+
}
|