@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.
- package/dist/bin/cerefox.js +1077 -834
- package/dist/frontend/assets/index-CCkg5PXt.js +125 -0
- package/dist/frontend/assets/index-CCkg5PXt.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-auth/index.ts +134 -0
- package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
- package/dist/server-assets/_shared/embeddings/index.ts +42 -2
- package/dist/server-assets/_shared/ingest/chunker.ts +210 -0
- package/dist/server-assets/_shared/ingest/index.ts +32 -0
- package/dist/server-assets/_shared/ingest/pipeline-helpers.ts +135 -0
- package/dist/server-assets/_shared/mcp-auth/index.ts +352 -0
- package/dist/server-assets/_shared/mcp-tools/_chunker.ts +16 -170
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +13 -4
- package/dist/server-assets/db/migrations/0012_content_format.sql +20 -0
- package/dist/server-assets/db/rpcs.sql +76 -8
- package/dist/server-assets/db/schema.sql +7 -1
- package/dist/server-assets/supabase/functions/cerefox-get-audit-log/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +22 -171
- package/dist/server-assets/supabase/functions/cerefox-list-projects/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-list-versions/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-mcp/index.ts +54 -0
- package/dist/server-assets/supabase/functions/cerefox-mcp/oauth.ts +121 -0
- package/dist/server-assets/supabase/functions/cerefox-metadata/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-search/index.ts +11 -1
- package/docs/guides/access-paths.md +81 -30
- package/docs/guides/cli.md +29 -0
- package/docs/guides/configuration.md +4 -1
- package/docs/guides/connect-agents.md +98 -54
- package/docs/guides/content-format.md +55 -0
- package/docs/guides/migration-1.0.md +87 -0
- package/docs/guides/ops-scripts.md +1 -1
- package/docs/guides/quickstart.md +20 -0
- package/docs/guides/setup-supabase.md +154 -13
- package/docs/guides/upgrading.md +4 -3
- package/package.json +1 -1
- package/dist/frontend/assets/index-ojNhWSxm.js +0 -125
- 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
|
-
*
|
|
2
|
+
* Chunker + content-hash utilities for the MCP ingest tool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
/**
|
|
176
|
-
*
|
|
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 {
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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;
|