@cerefox/memory 0.11.1 → 1.0.0-beta.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.
Files changed (39) hide show
  1. package/dist/bin/cerefox.js +1077 -834
  2. package/dist/frontend/assets/index-CCkg5PXt.js +125 -0
  3. package/dist/frontend/assets/index-CCkg5PXt.js.map +1 -0
  4. package/dist/frontend/index.html +1 -1
  5. package/dist/server-assets/_shared/ef-auth/index.ts +134 -0
  6. package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
  7. package/dist/server-assets/_shared/embeddings/index.ts +42 -2
  8. package/dist/server-assets/_shared/ingest/chunker.ts +210 -0
  9. package/dist/server-assets/_shared/ingest/index.ts +32 -0
  10. package/dist/server-assets/_shared/ingest/pipeline-helpers.ts +135 -0
  11. package/dist/server-assets/_shared/mcp-auth/index.ts +352 -0
  12. package/dist/server-assets/_shared/mcp-tools/_chunker.ts +16 -170
  13. package/dist/server-assets/_shared/mcp-tools/ingest.ts +13 -4
  14. package/dist/server-assets/db/migrations/0012_content_format.sql +20 -0
  15. package/dist/server-assets/db/rpcs.sql +76 -8
  16. package/dist/server-assets/db/schema.sql +7 -1
  17. package/dist/server-assets/supabase/functions/cerefox-get-audit-log/index.ts +8 -0
  18. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -0
  19. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +22 -171
  20. package/dist/server-assets/supabase/functions/cerefox-list-projects/index.ts +8 -0
  21. package/dist/server-assets/supabase/functions/cerefox-list-versions/index.ts +8 -0
  22. package/dist/server-assets/supabase/functions/cerefox-mcp/index.ts +54 -0
  23. package/dist/server-assets/supabase/functions/cerefox-mcp/oauth.ts +121 -0
  24. package/dist/server-assets/supabase/functions/cerefox-metadata/index.ts +8 -0
  25. package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +8 -0
  26. package/dist/server-assets/supabase/functions/cerefox-search/index.ts +11 -1
  27. package/docs/guides/access-paths.md +81 -30
  28. package/docs/guides/cli.md +29 -0
  29. package/docs/guides/configuration.md +4 -1
  30. package/docs/guides/connect-agents.md +98 -54
  31. package/docs/guides/content-format.md +55 -0
  32. package/docs/guides/migration-1.0.md +87 -0
  33. package/docs/guides/ops-scripts.md +1 -1
  34. package/docs/guides/quickstart.md +20 -0
  35. package/docs/guides/setup-supabase.md +154 -13
  36. package/docs/guides/upgrading.md +4 -3
  37. package/package.json +1 -1
  38. package/dist/frontend/assets/index-ojNhWSxm.js +0 -125
  39. 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
  }
@@ -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) {
@@ -0,0 +1,20 @@
1
+ -- Migration 0012: content_format on cerefox_chunks (iter-28D)
2
+ --
3
+ -- Records how each chunk's content reconstructs into full document text:
4
+ -- 1 = legacy — chunk contents were trimmed sections; reconstruction re-joins
5
+ -- them with E'\n\n' (the pre-28D behaviour). All existing chunks.
6
+ -- 2 = blind-stitch — chunk contents are an exact, gapless partition of the
7
+ -- document; reconstruction is a plain concatenation (no separator
8
+ -- synthesized on read). Written by the exact-partition chunker.
9
+ --
10
+ -- Placed on the CHUNK (not the document) so an archived version reconstructs with
11
+ -- its OWN format, since Cerefox uses chunks-anchored versioning
12
+ -- (cerefox_chunks.version_id). The reconstruction RPCs branch on
13
+ -- MAX(content_format) >= 2 per aggregated group.
14
+ --
15
+ -- Adding a NOT NULL column with a constant default is a metadata-only change in
16
+ -- PostgreSQL 11+ (no rewrite of the chunks table). Existing rows read back as 1.
17
+ -- Explanation for users: docs/guides/content-format.md.
18
+
19
+ ALTER TABLE cerefox_chunks
20
+ ADD COLUMN IF NOT EXISTS content_format SMALLINT NOT NULL DEFAULT 1;