@cerefox/memory 0.11.0 → 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/AGENT_GUIDE.md +1 -1
- package/AGENT_QUICK_REFERENCE.md +1 -1
- package/dist/bin/cerefox.js +1089 -846
- 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/get-help-content.ts +2 -2
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +17 -5
- package/dist/server-assets/db/migrations/0012_content_format.sql +20 -0
- package/dist/server-assets/db/rpcs.sql +88 -13
- 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 +29 -173
- 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 +32 -3
- package/docs/guides/configuration.md +4 -1
- package/docs/guides/connect-agents.md +102 -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
|
}
|
|
@@ -11,11 +11,11 @@
|
|
|
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
|
|
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";
|
|
15
15
|
|
|
16
16
|
/** Sections keyed by their H2 heading text (lower-cased for matching). */
|
|
17
17
|
export const HELP_SECTIONS: Record<string, string> = {
|
|
18
|
-
"Tools": "## 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
|
|
18
|
+
"Tools": "## 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) |",
|
|
19
19
|
"Essential Rules": "## 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`.",
|
|
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```",
|