@cerefox/memory 0.11.1 → 1.0.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.
Files changed (43) hide show
  1. package/AGENT_GUIDE.md +2 -2
  2. package/AGENT_QUICK_REFERENCE.md +1 -1
  3. package/dist/bin/cerefox.js +1079 -836
  4. package/dist/frontend/assets/index-D3FshoP3.js +125 -0
  5. package/dist/frontend/assets/index-D3FshoP3.js.map +1 -0
  6. package/dist/frontend/index.html +1 -1
  7. package/dist/server-assets/_shared/ef-auth/index.ts +134 -0
  8. package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
  9. package/dist/server-assets/_shared/embeddings/index.ts +42 -2
  10. package/dist/server-assets/_shared/ingest/chunker.ts +210 -0
  11. package/dist/server-assets/_shared/ingest/index.ts +32 -0
  12. package/dist/server-assets/_shared/ingest/pipeline-helpers.ts +135 -0
  13. package/dist/server-assets/_shared/mcp-auth/index.ts +352 -0
  14. package/dist/server-assets/_shared/mcp-tools/_chunker.ts +16 -170
  15. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +2 -2
  16. package/dist/server-assets/_shared/mcp-tools/ingest.ts +13 -4
  17. package/dist/server-assets/db/migrations/0012_content_format.sql +20 -0
  18. package/dist/server-assets/db/rpcs.sql +76 -8
  19. package/dist/server-assets/db/schema.sql +7 -1
  20. package/dist/server-assets/supabase/functions/cerefox-get-audit-log/index.ts +8 -0
  21. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -0
  22. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +39 -173
  23. package/dist/server-assets/supabase/functions/cerefox-list-projects/index.ts +8 -0
  24. package/dist/server-assets/supabase/functions/cerefox-list-versions/index.ts +8 -0
  25. package/dist/server-assets/supabase/functions/cerefox-mcp/index.ts +54 -0
  26. package/dist/server-assets/supabase/functions/cerefox-mcp/oauth.ts +121 -0
  27. package/dist/server-assets/supabase/functions/cerefox-metadata/index.ts +8 -0
  28. package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +8 -0
  29. package/dist/server-assets/supabase/functions/cerefox-search/index.ts +11 -1
  30. package/docs/guides/access-paths.md +84 -35
  31. package/docs/guides/cli.md +29 -0
  32. package/docs/guides/configuration.md +10 -8
  33. package/docs/guides/connect-agents.md +99 -101
  34. package/docs/guides/content-format.md +55 -0
  35. package/docs/guides/migration-1.0.md +96 -0
  36. package/docs/guides/ops-scripts.md +5 -7
  37. package/docs/guides/quickstart.md +22 -2
  38. package/docs/guides/setup-cloud-run.md +5 -9
  39. package/docs/guides/setup-supabase.md +157 -16
  40. package/docs/guides/upgrading.md +7 -8
  41. package/package.json +1 -1
  42. package/dist/frontend/assets/index-ojNhWSxm.js +0 -125
  43. package/dist/frontend/assets/index-ojNhWSxm.js.map +0 -1
@@ -0,0 +1,352 @@
1
+ /**
2
+ * mcp-auth — in-function authentication for the `cerefox-mcp` Edge Function.
3
+ *
4
+ * Context: to be an OAuth 2.1 protected resource server, `cerefox-mcp` must serve
5
+ * unauthenticated discovery routes and issue its own 401 challenges. That requires
6
+ * deploying it with `--no-verify-jwt` (the Supabase gateway otherwise rejects the
7
+ * request before the function runs). With the gateway gate removed, **this module
8
+ * becomes the only auth gate** — see the invariants in
9
+ * `docs/specs/oauth-mcp-server-design.md` §5–6.
10
+ *
11
+ * Two accepted credentials (design §5):
12
+ * Path 1 — an optional static Bearer, constant-time compared against an
13
+ * explicitly-set value. Left unset by cerefox-mcp since iter-28E (its
14
+ * static path is the Cerefox access token, checked in the EF via
15
+ * _shared/ef-auth); kept here as a generic, fail-closed capability.
16
+ * Path 2 — an OAuth 2.1 access token: a project-signed JWT validated against the
17
+ * project JWKS (asymmetric alg allowlist, iss/aud/exp/nbf, owner `sub`).
18
+ *
19
+ * Portability: this file uses ONLY Web Platform globals (`crypto.subtle`, `fetch`,
20
+ * `atob`, `TextEncoder`) — no `node:`, `jsr:`, or `npm:` imports — so the identical
21
+ * source runs under Deno (the Edge Function) and Bun (`bun test`). JWT verification
22
+ * is implemented directly on SubtleCrypto rather than pulling in `jose`; ES256/RS256
23
+ * JWS signatures are already in the raw formats SubtleCrypto's `verify` expects.
24
+ */
25
+
26
+ // ── Types ────────────────────────────────────────────────────────────────────
27
+
28
+ export type AuthPath = "static" | "oauth";
29
+
30
+ export interface AuthSuccess {
31
+ ok: true;
32
+ path: AuthPath;
33
+ /** The authenticated subject (owner user id) for OAuth; undefined for static. */
34
+ sub?: string;
35
+ }
36
+
37
+ export interface AuthFailure {
38
+ ok: false;
39
+ /** Machine-readable reason, also used to shape the WWW-Authenticate challenge. */
40
+ reason:
41
+ | "no_token"
42
+ | "malformed_token"
43
+ | "bad_signature"
44
+ | "bad_claims"
45
+ | "not_owner"
46
+ | "no_verifier";
47
+ /** Human detail for logs (never returned to the client). */
48
+ detail?: string;
49
+ }
50
+
51
+ export type AuthResult = AuthSuccess | AuthFailure;
52
+
53
+ interface Jwk {
54
+ kty: string;
55
+ kid?: string;
56
+ alg?: string;
57
+ crv?: string;
58
+ n?: string;
59
+ e?: string;
60
+ x?: string;
61
+ y?: string;
62
+ use?: string;
63
+ }
64
+
65
+ interface Jwks {
66
+ keys: Jwk[];
67
+ }
68
+
69
+ export interface McpAuthConfig {
70
+ /** Expected token issuer, e.g. `https://<ref>.supabase.co/auth/v1`. */
71
+ issuer: string;
72
+ /** JWKS URL, e.g. `<issuer>/.well-known/jwks.json`. */
73
+ jwksUri: string;
74
+ /** Expected `aud` claim. Supabase issues `"authenticated"`. */
75
+ expectedAudience: string;
76
+ /**
77
+ * Pinned owner user id. When set, an OAuth token's `sub` MUST equal it. When
78
+ * null/undefined the OAuth path FAILS CLOSED (rejects) unless `allowAnyUser` is
79
+ * true — because with Supabase's default email sign-ups on, an unpinned server
80
+ * would accept any self-registered user's token (design §6 / Finding 3).
81
+ */
82
+ ownerUserId?: string | null;
83
+ /**
84
+ * Explicit opt-out of the owner pin: accept any validly-signed `authenticated`
85
+ * token when `ownerUserId` is unset. For deliberate multi-user / sign-ups-disabled
86
+ * setups only. Default false (fail closed when unpinned).
87
+ */
88
+ allowAnyUser?: boolean;
89
+ /**
90
+ * Expected value for the optional static-Bearer path. When null/undefined the
91
+ * static path is disabled and rejects everything (fail-closed — never
92
+ * accept-all). cerefox-mcp leaves this null since iter-28E (its static path is
93
+ * the Cerefox access token, validated in the EF via `_shared/ef-auth`).
94
+ */
95
+ staticBearer?: string | null;
96
+ /** Accepted JWS algorithms. Default `["ES256", "RS256"]`. Never HS256/none. */
97
+ allowedAlgs?: string[];
98
+ /** Clock skew tolerance in seconds. Default 60. */
99
+ clockSkewSec?: number;
100
+ /** JWKS cache TTL in seconds. Default 600. */
101
+ jwksCacheTtlSec?: number;
102
+ /** Injectable clock (ms since epoch). Default `Date.now`. */
103
+ now?: () => number;
104
+ /** Injectable fetch (for tests). Default global `fetch`. */
105
+ fetchImpl?: typeof fetch;
106
+ }
107
+
108
+ export interface McpAuthenticator {
109
+ authenticate(authorizationHeader: string | null): Promise<AuthResult>;
110
+ }
111
+
112
+ // ── Base64url / encoding helpers ─────────────────────────────────────────────
113
+
114
+ function base64UrlToBytes(input: string): Uint8Array {
115
+ const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
116
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
117
+ const bin = atob(b64 + pad);
118
+ const out = new Uint8Array(bin.length);
119
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Copy a view's bytes into a fresh, plain `ArrayBuffer`. SubtleCrypto expects a
125
+ * `BufferSource`; under strict typed-array generics (TS 5.7) a `Uint8Array` may be
126
+ * inferred as `ArrayBufferLike`-backed, so we normalize to `ArrayBuffer` here.
127
+ */
128
+ function toArrayBuffer(view: Uint8Array): ArrayBuffer {
129
+ return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer;
130
+ }
131
+
132
+ function base64UrlToString(input: string): string {
133
+ return new TextDecoder().decode(base64UrlToBytes(input));
134
+ }
135
+
136
+ /**
137
+ * Length-independent constant-time string comparison. Iterates over the longer
138
+ * length so the loop count doesn't leak which operand is shorter, and folds a
139
+ * length mismatch into the result rather than short-circuiting.
140
+ */
141
+ export function constantTimeEqual(a: string, b: string): boolean {
142
+ const ab = new TextEncoder().encode(a);
143
+ const bb = new TextEncoder().encode(b);
144
+ const len = Math.max(ab.length, bb.length);
145
+ let diff = ab.length ^ bb.length;
146
+ for (let i = 0; i < len; i++) {
147
+ diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
148
+ }
149
+ return diff === 0;
150
+ }
151
+
152
+ // ── JWT verification (SubtleCrypto) ──────────────────────────────────────────
153
+
154
+ interface JwtHeader {
155
+ alg: string;
156
+ kid?: string;
157
+ typ?: string;
158
+ }
159
+
160
+ interface JwtPayload {
161
+ iss?: string;
162
+ sub?: string;
163
+ aud?: string | string[];
164
+ exp?: number;
165
+ nbf?: number;
166
+ iat?: number;
167
+ role?: string;
168
+ client_id?: string;
169
+ [k: string]: unknown;
170
+ }
171
+
172
+ function importParamsFor(alg: string, jwk: Jwk): {
173
+ importAlgo: EcKeyImportParams | RsaHashedImportParams;
174
+ verifyAlgo: EcdsaParams | AlgorithmIdentifier;
175
+ } | null {
176
+ if (alg === "ES256" && jwk.kty === "EC") {
177
+ return {
178
+ importAlgo: { name: "ECDSA", namedCurve: "P-256" },
179
+ verifyAlgo: { name: "ECDSA", hash: { name: "SHA-256" } },
180
+ };
181
+ }
182
+ if (alg === "RS256" && jwk.kty === "RSA") {
183
+ return {
184
+ importAlgo: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
185
+ verifyAlgo: { name: "RSASSA-PKCS1-v1_5" },
186
+ };
187
+ }
188
+ return null;
189
+ }
190
+
191
+ async function verifyJwtSignature(
192
+ token: string,
193
+ header: JwtHeader,
194
+ jwks: Jwks,
195
+ ): Promise<boolean> {
196
+ const parts = token.split(".");
197
+ if (parts.length !== 3) return false;
198
+ const [headerB64, payloadB64, sigB64] = parts;
199
+
200
+ // Select the key by kid; if the token has no kid, accept a lone matching key.
201
+ const candidates = jwks.keys.filter((k) => {
202
+ if (header.kid) return k.kid === header.kid;
203
+ return true;
204
+ });
205
+ if (candidates.length === 0) return false;
206
+
207
+ const signature = toArrayBuffer(base64UrlToBytes(sigB64));
208
+ const signed = toArrayBuffer(new TextEncoder().encode(`${headerB64}.${payloadB64}`));
209
+
210
+ for (const jwk of candidates) {
211
+ const params = importParamsFor(header.alg, jwk);
212
+ if (!params) continue;
213
+ try {
214
+ const key = await crypto.subtle.importKey(
215
+ "jwk",
216
+ jwk as JsonWebKey,
217
+ params.importAlgo,
218
+ false,
219
+ ["verify"],
220
+ );
221
+ const ok = await crypto.subtle.verify(params.verifyAlgo, key, signature, signed);
222
+ if (ok) return true;
223
+ } catch {
224
+ // try the next candidate key
225
+ }
226
+ }
227
+ return false;
228
+ }
229
+
230
+ // ── Authenticator factory ────────────────────────────────────────────────────
231
+
232
+ const BEARER_PREFIX = "Bearer ";
233
+
234
+ export function createMcpAuthenticator(config: McpAuthConfig): McpAuthenticator {
235
+ const allowedAlgs = config.allowedAlgs ?? ["ES256", "RS256"];
236
+ const clockSkewSec = config.clockSkewSec ?? 60;
237
+ const jwksCacheTtlSec = config.jwksCacheTtlSec ?? 600;
238
+ const now = config.now ?? (() => Date.now());
239
+ const fetchImpl = config.fetchImpl ?? fetch;
240
+
241
+ // Isolate-lifetime JWKS cache (closure state). Key rotation is picked up on
242
+ // TTL expiry or isolate recycle.
243
+ let cache: { jwks: Jwks; fetchedAt: number } | null = null;
244
+
245
+ async function getJwks(): Promise<Jwks | null> {
246
+ if (cache && now() - cache.fetchedAt < jwksCacheTtlSec * 1000) {
247
+ return cache.jwks;
248
+ }
249
+ try {
250
+ const resp = await fetchImpl(config.jwksUri);
251
+ if (!resp.ok) return cache?.jwks ?? null;
252
+ const jwks = (await resp.json()) as Jwks;
253
+ if (!jwks || !Array.isArray(jwks.keys)) return cache?.jwks ?? null;
254
+ cache = { jwks, fetchedAt: now() };
255
+ return jwks;
256
+ } catch {
257
+ // On a transient fetch failure, fall back to a still-cached set if any;
258
+ // otherwise fail closed (caller rejects).
259
+ return cache?.jwks ?? null;
260
+ }
261
+ }
262
+
263
+ function validateClaims(payload: JwtPayload): AuthResult {
264
+ const nowSec = Math.floor(now() / 1000);
265
+
266
+ if (payload.iss !== config.issuer) {
267
+ return { ok: false, reason: "bad_claims", detail: `iss=${payload.iss} want=${config.issuer}` };
268
+ }
269
+ const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
270
+ if (!auds.includes(config.expectedAudience)) {
271
+ return {
272
+ ok: false,
273
+ reason: "bad_claims",
274
+ detail: `aud=${JSON.stringify(payload.aud)} want=${config.expectedAudience}`,
275
+ };
276
+ }
277
+ if (typeof payload.exp !== "number" || payload.exp + clockSkewSec < nowSec) {
278
+ return { ok: false, reason: "bad_claims", detail: `expired (exp=${payload.exp})` };
279
+ }
280
+ if (typeof payload.nbf === "number" && payload.nbf - clockSkewSec > nowSec) {
281
+ return { ok: false, reason: "bad_claims", detail: "not yet valid" };
282
+ }
283
+ if (!payload.sub) {
284
+ return { ok: false, reason: "bad_claims", detail: "missing sub" };
285
+ }
286
+ if (config.ownerUserId) {
287
+ if (payload.sub !== config.ownerUserId) {
288
+ return { ok: false, reason: "not_owner", detail: `sub=${payload.sub} owner=${config.ownerUserId}` };
289
+ }
290
+ } else if (!config.allowAnyUser) {
291
+ // Fail closed: no owner pinned and no explicit opt-out. Accepting here would
292
+ // let any self-registered user in (Supabase email sign-ups default on).
293
+ return {
294
+ ok: false,
295
+ reason: "not_owner",
296
+ detail: "owner not pinned; set CEREFOX_OAUTH_OWNER_ID (or CEREFOX_OAUTH_ALLOW_ANY_USER=true to opt out)",
297
+ };
298
+ }
299
+ return { ok: true, path: "oauth", sub: payload.sub };
300
+ }
301
+
302
+ async function authenticate(authorizationHeader: string | null): Promise<AuthResult> {
303
+ if (!authorizationHeader || !authorizationHeader.startsWith(BEARER_PREFIX)) {
304
+ return { ok: false, reason: "no_token" };
305
+ }
306
+ const token = authorizationHeader.slice(BEARER_PREFIX.length).trim();
307
+ if (!token) return { ok: false, reason: "no_token" };
308
+
309
+ // Path 1 — legacy static Bearer (constant-time). Fail-closed when unset.
310
+ if (config.staticBearer && constantTimeEqual(token, config.staticBearer)) {
311
+ return { ok: true, path: "static" };
312
+ }
313
+
314
+ // Path 2 — OAuth access token (JWT against JWKS).
315
+ const parts = token.split(".");
316
+ if (parts.length !== 3) {
317
+ return { ok: false, reason: "malformed_token", detail: "not a JWT" };
318
+ }
319
+ let header: JwtHeader;
320
+ let payload: JwtPayload;
321
+ try {
322
+ header = JSON.parse(base64UrlToString(parts[0])) as JwtHeader;
323
+ payload = JSON.parse(base64UrlToString(parts[1])) as JwtPayload;
324
+ } catch {
325
+ return { ok: false, reason: "malformed_token", detail: "bad JSON" };
326
+ }
327
+
328
+ // Algorithm allowlist BEFORE any crypto — reject none/HS256 outright, which
329
+ // is what defends against alg-confusion with the HS256 legacy anon JWT.
330
+ if (!allowedAlgs.includes(header.alg)) {
331
+ return { ok: false, reason: "bad_signature", detail: `alg ${header.alg} not allowed` };
332
+ }
333
+
334
+ const jwks = await getJwks();
335
+ if (!jwks) return { ok: false, reason: "no_verifier", detail: "JWKS unavailable" };
336
+
337
+ const verified = await verifyJwtSignature(token, header, jwks);
338
+ if (!verified) {
339
+ return {
340
+ ok: false,
341
+ reason: "bad_signature",
342
+ detail: `signature invalid (alg=${header.alg} kid=${header.kid} jwks_kids=${
343
+ jwks.keys.map((k) => k.kid).join(",")
344
+ })`,
345
+ };
346
+ }
347
+
348
+ return validateClaims(payload);
349
+ }
350
+
351
+ return { authenticate };
352
+ }
@@ -1,179 +1,25 @@
1
1
  /**
2
- * Heading-aware markdown chunker.
2
+ * Chunker + content-hash utilities for the MCP ingest tool.
3
3
  *
4
- * Mirrors:
5
- * - `src/cerefox/chunking/markdown.py` (Python pipeline)
6
- * - `supabase/functions/cerefox-ingest/index.ts` (standalone ingest EF)
7
- *
8
- * Greedy section accumulation: H1/H2/H3 sections are joined into a buffer
9
- * until adding the next would exceed `MAX_CHUNK_CHARS`. Oversized sections
10
- * are paragraph-split. Short documents collapse to a single chunk.
11
- *
12
- * The hash of the chunked output (via `_hash.ts:sha256hex(normalizeContent(...))`)
13
- * must match the Python pipeline byte-for-byte so dedup works across access
14
- * paths. Don't change chunk boundaries without updating both.
4
+ * iter-28D Phase 1: the chunker is now the single exact-partition implementation
5
+ * in `_shared/ingest/chunker.ts` — this module re-exports it (the previous copy
6
+ * here was removed) and keeps the content-hash helpers used for dedup.
15
7
  */
16
8
 
17
- export const MAX_CHUNK_CHARS = 4000;
18
-
19
- interface Section {
20
- level: number;
21
- headings: string[];
22
- heading: string;
23
- content: string;
24
- body: string;
25
- }
26
-
27
- export interface Chunk {
28
- heading_path: string[];
29
- heading_level: number;
30
- title: string;
31
- content: string;
32
- char_count: number;
33
- }
34
-
35
- function parseSections(text: string): Section[] {
36
- const lines = text.split("\n");
37
- const sections: Section[] = [];
38
- let currentHeadings: string[] = [];
39
- let currentLevel = 0;
40
- let bodyLines: string[] = [];
41
-
42
- function collectSection() {
43
- const body = bodyLines.join("\n").trim();
44
- bodyLines = [];
45
- let content: string;
46
- if (currentLevel > 0) {
47
- const headerLine = "#".repeat(currentLevel) + " " +
48
- (currentHeadings[currentHeadings.length - 1] ?? "");
49
- content = body ? headerLine + "\n\n" + body : headerLine;
50
- } else {
51
- content = body;
52
- }
53
- if (!content.trim()) return;
54
- sections.push({
55
- level: currentLevel,
56
- headings: [...currentHeadings],
57
- heading: currentHeadings[currentHeadings.length - 1] ?? "",
58
- content,
59
- body,
60
- });
61
- }
62
-
63
- for (const line of lines) {
64
- const h1 = line.match(/^# (.+)/);
65
- const h2 = line.match(/^## (.+)/);
66
- const h3 = line.match(/^### (.+)/);
67
-
68
- if (h1) {
69
- collectSection();
70
- currentHeadings = [h1[1].trim()];
71
- currentLevel = 1;
72
- } else if (h2) {
73
- collectSection();
74
- currentHeadings = [currentHeadings[0] ?? "", h2[1].trim()].filter(Boolean);
75
- currentLevel = 2;
76
- } else if (h3) {
77
- collectSection();
78
- currentHeadings = [
79
- currentHeadings[0] ?? "",
80
- currentHeadings[1] ?? "",
81
- h3[1].trim(),
82
- ].filter(Boolean);
83
- currentLevel = 3;
84
- } else {
85
- bodyLines.push(line);
86
- }
87
- }
88
- collectSection();
89
- return sections;
90
- }
91
-
92
- function makeChunk(headings: string[], level: number, content: string): Chunk {
93
- const title = headings[headings.length - 1] ?? "";
94
- return {
95
- heading_path: [...headings],
96
- heading_level: level,
97
- title,
98
- content,
99
- char_count: content.length,
100
- };
101
- }
9
+ export {
10
+ chunkMarkdown,
11
+ embeddingInputFor,
12
+ CONTENT_FORMAT_BLIND_STITCH,
13
+ type ChunkData,
14
+ type ChunkData as Chunk,
15
+ } from "../ingest/chunker.ts";
102
16
 
103
- export function chunkMarkdown(text: string): Chunk[] {
104
- const trimmed = text.trim();
105
- if (!trimmed) return [];
106
-
107
- if (trimmed.length <= MAX_CHUNK_CHARS) {
108
- return [makeChunk([], 0, trimmed)];
109
- }
110
-
111
- const sections = parseSections(trimmed);
112
- const chunks: Chunk[] = [];
113
-
114
- let bufParts: string[] = [];
115
- let bufHeadings: string[] = [];
116
- let bufLevel = 0;
117
- let bufChars = 0;
118
-
119
- function flushBuf() {
120
- if (bufParts.length === 0) return;
121
- chunks.push(makeChunk(bufHeadings, bufLevel, bufParts.join("\n\n")));
122
- bufParts = [];
123
- bufHeadings = [];
124
- bufLevel = 0;
125
- bufChars = 0;
126
- }
127
-
128
- for (const section of sections) {
129
- const { level, headings, heading, content, body } = section;
130
-
131
- if (content.length > MAX_CHUNK_CHARS) {
132
- flushBuf();
133
- const headerPrefix = level > 0 ? "#".repeat(level) + " " + heading + "\n\n" : "";
134
- const bodyToSplit = body || content;
135
- const paragraphs = bodyToSplit.split(/\n\n+/);
136
- let sub = "";
137
- let isFirst = true;
138
- for (const para of paragraphs) {
139
- const prefix = isFirst ? headerPrefix : "";
140
- if (sub.length + prefix.length + para.length + 2 > MAX_CHUNK_CHARS && sub.length > 0) {
141
- chunks.push(makeChunk(headings, level, sub.trim()));
142
- sub = para;
143
- isFirst = false;
144
- } else {
145
- sub = sub ? sub + "\n\n" + para : prefix + para;
146
- isFirst = false;
147
- }
148
- }
149
- if (sub.trim()) chunks.push(makeChunk(headings, level, sub.trim()));
150
- continue;
151
- }
152
-
153
- const addition = content.length + (bufParts.length > 0 ? 2 : 0);
154
-
155
- if (bufChars + addition <= MAX_CHUNK_CHARS) {
156
- if (bufParts.length === 0) {
157
- bufHeadings = headings;
158
- bufLevel = level;
159
- }
160
- bufParts.push(content);
161
- bufChars += addition;
162
- } else {
163
- flushBuf();
164
- bufParts = [content];
165
- bufHeadings = headings;
166
- bufLevel = level;
167
- bufChars = content.length;
168
- }
169
- }
170
-
171
- flushBuf();
172
- return chunks;
173
- }
17
+ export const MAX_CHUNK_CHARS = 4000;
174
18
 
175
- /** Content-hash normalization. Must match `pipeline.py::_normalize`
176
- * byte-for-byte so cross-runtime dedup works. */
19
+ /**
20
+ * Content-hash normalization. Kept stable so the content_hash dedup key is
21
+ * consistent across access paths and over time.
22
+ */
177
23
  export function normalizeContent(text: string): string {
178
24
  return text.trim().replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n{3,}/g, "\n\n");
179
25
  }
@@ -11,7 +11,7 @@
11
11
  * docs/specs/polish-and-distribution-design.md §10d.
12
12
  */
13
13
 
14
- export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **10 MCP tools** (9 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
14
+ export const HELP_FULL = "# Cerefox Knowledge Base -- Agent Quick Reference\n\nCerefox is a persistent, shared knowledge base. You have **10 MCP tools** (9 of them have CLI equivalents — `cerefox_get_help` is MCP-only). For the full guide, search Cerefox for \"How AI Agents Use Cerefox\" or call `cerefox_get_help` to retrieve this content over MCP.\n\n## Tools\n\n| Tool | Purpose | Key params |\n|------|---------|------------|\n| `cerefox_search` | Find documents (hybrid FTS + semantic) | `query` (required), `project_name`, `metadata_filter`, `requestor` |\n| `cerefox_ingest` | Save or update a document | `title`, `content` (required), `document_id` (update by ID), `expected_content_hash` (**required on content updates** — see rule 9), `last_write_wins`, `update_if_exists`, `project_name` (single, non-destructive add on update), `project_names` (list, destructive replace on update), `metadata` (omit on update to keep existing tags; `{}` clears), `author` |\n| `cerefox_get_document` | Get full document by ID (header includes `content_hash` — the update token) | `document_id` (required) |\n| `cerefox_list_versions` | Version history of a document | `document_id` (required) |\n| `cerefox_metadata_search` | Find or list docs by metadata, project, or time (no text query) | `metadata_filter`, `project_name` (list a project's docs), `updated_since`, `include_content` — **at least one** of metadata_filter/project_name/updated_since/created_since |\n| `cerefox_list_metadata_keys` | Discover available metadata keys | (none required) |\n| `cerefox_list_projects` | List all projects | (none required) |\n| `cerefox_set_document_projects` | Set doc's project memberships to exactly the given list (destructive replace; metadata-only, no content change) | `document_id`, `project_names` (required) |\n| `cerefox_get_audit_log` | Query write operation history | `document_id`, `author`, `operation`, `since` |\n| `cerefox_get_help` | Retrieve Cerefox conventions (this reference) over MCP. **Call this whenever uncertain.** | `topic` (optional, case-insensitive H2 substring match) |\n\n## Essential Rules\n\n1. **Search before ingesting** -- check if the document exists first.\n2. **Prefer ID-based updates** -- pass `document_id` from search results for deterministic updates. Falls back to title-matching with `update_if_exists: true`.\n3. **Set `author`/`requestor`** to your name on every call (e.g., \"Claude Code\", \"archiver\"). On MCP, pass as parameters. On CLI, pass `--author`/`--author-type`/`--requestor` flags, or rely on `CEREFOX_AUTHOR_NAME`/`CEREFOX_AUTHOR_TYPE`/`CEREFOX_REQUESTOR_NAME` env vars set in the user's `.env`.\n4. **Use `document_id` from search results** `[id: uuid]` for get_document and list_versions.\n5. **Add metadata** -- at minimum `type` (\"decision-log\", \"research\", \"design-doc\") and `status` (\"active\", \"draft\").\n6. **Write structured Markdown** with H1/H2/H3 headings for good chunking and search.\n7. **Deletes are soft (recoverable); purge is web-UI-only.** If you decide to delete, surface it to the user (`I soft-deleted X — recoverable from the Cerefox web UI trash`). You cannot un-do your own delete from agent code by design.\n8. **Cross-doc links inside content**: **always use `[Text](document-uuid)`.** UUIDs are the only fully reliable link form — stable across title changes, never ambiguous, no encoding gotchas. Every `cerefox_search` result shows `[id: <uuid>]` after the title; grab it and use it. Title-based linking (`[Text](<Title With Spaces>)`) is fragile (breaks on colons, parens, ampersands, brackets — silently navigates to wrong page) — **don't write title-based links**; do an extra search to get the UUID instead. Repo-path forms (`[Text](docs/path.md)`) exist for repo-ingested files; don't construct manually. See `AGENT_GUIDE.md → Writing linkable content` for the full rule.\n9. **Concurrency: content updates require `expected_content_hash`.** Pass the `content_hash` you read (shown by `cerefox_get_document`, `cerefox_search`, and `cerefox_metadata_search`) when updating a document. If it's stale you get a **conflict** — re-read the document, merge your changes into the latest content, retry with the new hash. **Never resolve a conflict by overwriting blindly** — the current content includes another writer's work. `last_write_wins: true` skips the check; use it ONLY when an external source of truth makes conflicts meaningless (file re-sync), never to silence a conflict.\n10. **Project memberships — non-destructive by default**: on `cerefox_ingest` updates, **`project_name` (singular) is a non-destructive add** (ensures membership, preserves others). Use **`project_names` (list)** when you want to set the doc's full project set in one call (destructive replace). For metadata-only project changes without writing content, use **`cerefox_set_document_projects(document_id, project_names)`** — that tool is the destructive-replace contract made explicit. Never call `cerefox_set_document_projects` with a single name when you mean \"add\" — that would REMOVE the doc from all other projects. When in doubt, use `cerefox_ingest` with singular `project_name`.\n\n## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.\n\n## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\n## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```\n\n## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.\n";
15
15
 
16
16
  /** Sections keyed by their H2 heading text (lower-cased for matching). */
17
17
  export const HELP_SECTIONS: Record<string, string> = {
@@ -20,7 +20,7 @@ export const HELP_SECTIONS: Record<string, string> = {
20
20
  "Update Workflow (ID-based -- preferred)": "## Update Workflow (ID-based -- preferred)\n\n```\nsearch(\"topic\") -> find doc [id: abc123] -> get_document(abc123) -> note its content_hash -> modify ->\ningest(title=\"Same Title\", content=\"...\", document_id=\"abc123\",\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```\n\nOn a **conflict** error: get_document again (fresh content + fresh hash) -> merge your changes -> retry with the new hash.",
21
21
  "Update Workflow (title-based -- fallback)": "## Update Workflow (title-based -- fallback)\n\n```\nsearch(\"topic\") -> find doc (note its hash) -> modify ->\ningest(title=\"Same Title\", content=\"...\", update_if_exists=true,\n expected_content_hash=\"<the hash you read>\", author=\"my-agent\")\n```",
22
22
  "Catch-Up Workflow": "## Catch-Up Workflow\n\n```\nmetadata_search(metadata_filter={\"type\": \"decision-log\"}, updated_since=\"2026-03-28T00:00:00Z\")\n```",
23
- "CLI fallback (when MCP is unavailable)": "## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …). The legacy Python `uv run cerefox` is now a frozen husk as of v0.9 — only `uv run cerefox mcp` still works.\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.",
23
+ "CLI fallback (when MCP is unavailable)": "## CLI fallback (when MCP is unavailable)\n\nIf `cerefox_search` is not in your tool list, your user has likely installed the Cerefox CLI. The canonical invocation is plain **`cerefox <subcommand>`** (the TypeScript CLI, installed via `npm install -g @cerefox/memory`). It uses a resource-verb shape (`cerefox document get`, `cerefox project list`, …).\n\nSame operations, same conventions. Full reference: [`docs/guides/cli.md`](docs/guides/cli.md). CLI flag names match MCP parameter names exactly (e.g. `metadata_filter` ↔ `--metadata-filter`); common flags also have single-letter short forms (`-f`, `-p`, `-c`, `-m`, `-u`, `-a`, `-r`). Use the canonical long name (what `--help` shows) or its short form — there are no long-form aliases like `--filter` or `--count`.\n\n| MCP tool | CLI |\n|---|---|\n| `cerefox_search` | `cerefox search \"<q>\" --requestor \"<your-name>\"` |\n| `cerefox_ingest` (paste) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_ingest` (update by ID) | `printf '...' \\| cerefox document ingest --paste --title \"<t>\" --document-id \"<uuid>\" --expected-content-hash \"<hash>\" --author \"<your-name>\" --author-type agent` |\n| `cerefox_get_document` | `cerefox document get <id> --version-id <vid> --requestor \"<your-name>\"` |\n| `cerefox_list_versions` | `cerefox document version list <id> --requestor \"<your-name>\"` |\n| `cerefox_list_projects` | `cerefox project list --requestor \"<your-name>\"` |\n| `cerefox_list_metadata_keys` | `cerefox metadata keys` |\n| `cerefox_metadata_search` | `cerefox metadata search --metadata-filter '<json>' --requestor \"<your-name>\"` (list a project: `cerefox document list --project <name>`) |\n| `cerefox_set_document_projects` | `cerefox document set-projects <id> <name...> --author \"<your-name>\" --author-type agent` (or `--clear` to remove all) |\n| `cerefox_get_audit_log` | `cerefox audit list --requestor \"<your-name>\"` (add `--json` for scripted access) |\n| `cerefox_get_help` | `cerefox guides show agent-quick-reference` (or `cerefox guides list` for the full bundled-docs index) |\n\n**Set identity on every call**, exactly as you would on MCP:\n- Writes (`document ingest`, `document ingest-dir`): `--author \"<your-name>\" --author-type agent`\n- Reads: `--requestor \"<your-name>\"`\n\nOr have your user set `CEREFOX_AUTHOR_NAME` / `CEREFOX_AUTHOR_TYPE` / `CEREFOX_REQUESTOR_NAME` in their `.env` to apply defaults once.",
24
24
  };
25
25
 
26
26
  export const HELP_SECTION_HEADINGS: string[] = ["Tools", "Essential Rules", "Update Workflow (ID-based -- preferred)", "Update Workflow (title-based -- fallback)", "Catch-Up Workflow", "CLI fallback (when MCP is unavailable)"];
@@ -17,7 +17,13 @@
17
17
 
18
18
  import type { MCPSupabaseClient } from "./types.ts";
19
19
 
20
- import { chunkMarkdown, normalizeContent, sha256hex } from "./_chunker.ts";
20
+ import {
21
+ chunkMarkdown,
22
+ embeddingInputFor,
23
+ CONTENT_FORMAT_BLIND_STITCH,
24
+ normalizeContent,
25
+ sha256hex,
26
+ } from "./_chunker.ts";
21
27
  import { embedBatch, OPENAI_MODEL } from "../embeddings/index.ts";
22
28
  import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.ts";
23
29
  import { logUsage } from "./_utils.ts";
@@ -137,7 +143,7 @@ async function handler(
137
143
  const chunks = chunkMarkdown(content);
138
144
  if (chunks.length === 0) throw new Error("Content produced no chunks");
139
145
 
140
- const texts = chunks.map((c) => `# ${title}\n${c.content}`);
146
+ const texts = chunks.map((c) => embeddingInputFor(title, c));
141
147
  const embeddings = await embedBatch(texts, ctx.openaiApiKey);
142
148
  const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
143
149
 
@@ -165,6 +171,7 @@ async function handler(
165
171
  p_source_label: source,
166
172
  p_expected_content_hash: expected_content_hash,
167
173
  p_last_write_wins: last_write_wins,
174
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
168
175
  });
169
176
 
170
177
  if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
@@ -214,7 +221,7 @@ async function handler(
214
221
  const chunks = chunkMarkdown(content);
215
222
  if (chunks.length === 0) throw new Error("Content produced no chunks");
216
223
 
217
- const texts = chunks.map((c) => `# ${title}\n${c.content}`);
224
+ const texts = chunks.map((c) => embeddingInputFor(title, c));
218
225
  const embeddings = await embedBatch(texts, ctx.openaiApiKey);
219
226
  const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
220
227
 
@@ -242,6 +249,7 @@ async function handler(
242
249
  p_source_label: source,
243
250
  p_expected_content_hash: expected_content_hash,
244
251
  p_last_write_wins: last_write_wins,
252
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
245
253
  });
246
254
 
247
255
  if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
@@ -279,7 +287,7 @@ async function handler(
279
287
  const chunks = chunkMarkdown(content);
280
288
  if (chunks.length === 0) throw new Error("Content produced no chunks");
281
289
 
282
- const texts = chunks.map((c) => `# ${title}\n${c.content}`);
290
+ const texts = chunks.map((c) => embeddingInputFor(title, c));
283
291
  const embeddings = await embedBatch(texts, ctx.openaiApiKey);
284
292
  const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
285
293
 
@@ -304,6 +312,7 @@ async function handler(
304
312
  p_chunks: chunkData,
305
313
  p_author: author,
306
314
  p_author_type: author_type,
315
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
307
316
  });
308
317
 
309
318
  if (ingestErr || !ingestResult?.length) {