scream-code 0.16.7 → 0.16.9

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.
@@ -0,0 +1,4141 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';
3
+ import { dirname as __cjsShimDirname } from 'node:path';
4
+ const __filename = __cjsShimFileURLToPath(import.meta.url);
5
+ const __dirname = __cjsShimDirname(__filename);
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
+ import { createReadStream, existsSync, mkdirSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { DatabaseSync } from "node:sqlite";
11
+ //#region ../../node_modules/.pnpm/pathe@2.0.3/node_modules/pathe/dist/shared/pathe.M-eThtNZ.mjs
12
+ const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
13
+ function normalizeWindowsPath(input = "") {
14
+ if (!input) return input;
15
+ return input.replace(/\\/g, "/").replace(_DRIVE_LETTER_START_RE, (r) => r.toUpperCase());
16
+ }
17
+ const _UNC_REGEX = /^[/\\]{2}/;
18
+ const _IS_ABSOLUTE_RE = /^[/\\](?![/\\])|^[/\\]{2}(?!\.)|^[A-Za-z]:[/\\]/;
19
+ const _DRIVE_LETTER_RE = /^[A-Za-z]:$/;
20
+ const _ROOT_FOLDER_RE = /^\/([A-Za-z]:)?$/;
21
+ const _EXTNAME_RE = /.(\.[^./]+|\.)$/;
22
+ const _PATH_ROOT_RE = /^[/\\]|^[a-zA-Z]:[/\\]/;
23
+ const normalize = function(path) {
24
+ if (path.length === 0) return ".";
25
+ path = normalizeWindowsPath(path);
26
+ const isUNCPath = path.match(_UNC_REGEX);
27
+ const isPathAbsolute = isAbsolute$1(path);
28
+ const trailingSeparator = path[path.length - 1] === "/";
29
+ path = normalizeString(path, !isPathAbsolute);
30
+ if (path.length === 0) {
31
+ if (isPathAbsolute) return "/";
32
+ return trailingSeparator ? "./" : ".";
33
+ }
34
+ if (trailingSeparator) path += "/";
35
+ if (_DRIVE_LETTER_RE.test(path)) path += "/";
36
+ if (isUNCPath) {
37
+ if (!isPathAbsolute) return `//./${path}`;
38
+ return `//${path}`;
39
+ }
40
+ return isPathAbsolute && !isAbsolute$1(path) ? `/${path}` : path;
41
+ };
42
+ const join$1 = function(...segments) {
43
+ let path = "";
44
+ for (const seg of segments) {
45
+ if (!seg) continue;
46
+ if (path.length > 0) {
47
+ const pathTrailing = path[path.length - 1] === "/";
48
+ const segLeading = seg[0] === "/";
49
+ if (pathTrailing && segLeading) path += seg.slice(1);
50
+ else path += pathTrailing || segLeading ? seg : `/${seg}`;
51
+ } else path += seg;
52
+ }
53
+ return normalize(path);
54
+ };
55
+ function cwd() {
56
+ if (typeof process !== "undefined" && typeof process.cwd === "function") return process.cwd().replace(/\\/g, "/");
57
+ return "/";
58
+ }
59
+ const resolve$1 = function(...arguments_) {
60
+ arguments_ = arguments_.map((argument) => normalizeWindowsPath(argument));
61
+ let resolvedPath = "";
62
+ let resolvedAbsolute = false;
63
+ for (let index = arguments_.length - 1; index >= -1 && !resolvedAbsolute; index--) {
64
+ const path = index >= 0 ? arguments_[index] : cwd();
65
+ if (!path || path.length === 0) continue;
66
+ resolvedPath = `${path}/${resolvedPath}`;
67
+ resolvedAbsolute = isAbsolute$1(path);
68
+ }
69
+ resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute);
70
+ if (resolvedAbsolute && !isAbsolute$1(resolvedPath)) return `/${resolvedPath}`;
71
+ return resolvedPath.length > 0 ? resolvedPath : ".";
72
+ };
73
+ function normalizeString(path, allowAboveRoot) {
74
+ let res = "";
75
+ let lastSegmentLength = 0;
76
+ let lastSlash = -1;
77
+ let dots = 0;
78
+ let char = null;
79
+ for (let index = 0; index <= path.length; ++index) {
80
+ if (index < path.length) char = path[index];
81
+ else if (char === "/") break;
82
+ else char = "/";
83
+ if (char === "/") {
84
+ if (lastSlash === index - 1 || dots === 1);
85
+ else if (dots === 2) {
86
+ if (res.length < 2 || lastSegmentLength !== 2 || res[res.length - 1] !== "." || res[res.length - 2] !== ".") {
87
+ if (res.length > 2) {
88
+ const lastSlashIndex = res.lastIndexOf("/");
89
+ if (lastSlashIndex === -1) {
90
+ res = "";
91
+ lastSegmentLength = 0;
92
+ } else {
93
+ res = res.slice(0, lastSlashIndex);
94
+ lastSegmentLength = res.length - 1 - res.lastIndexOf("/");
95
+ }
96
+ lastSlash = index;
97
+ dots = 0;
98
+ continue;
99
+ } else if (res.length > 0) {
100
+ res = "";
101
+ lastSegmentLength = 0;
102
+ lastSlash = index;
103
+ dots = 0;
104
+ continue;
105
+ }
106
+ }
107
+ if (allowAboveRoot) {
108
+ res += res.length > 0 ? "/.." : "..";
109
+ lastSegmentLength = 2;
110
+ }
111
+ } else {
112
+ if (res.length > 0) res += `/${path.slice(lastSlash + 1, index)}`;
113
+ else res = path.slice(lastSlash + 1, index);
114
+ lastSegmentLength = index - lastSlash - 1;
115
+ }
116
+ lastSlash = index;
117
+ dots = 0;
118
+ } else if (char === "." && dots !== -1) ++dots;
119
+ else dots = -1;
120
+ }
121
+ return res;
122
+ }
123
+ const isAbsolute$1 = function(p) {
124
+ return _IS_ABSOLUTE_RE.test(p);
125
+ };
126
+ const extname$1 = function(p) {
127
+ if (p === "..") return "";
128
+ const match = _EXTNAME_RE.exec(normalizeWindowsPath(p));
129
+ return match && match[1] || "";
130
+ };
131
+ const relative$1 = function(from, to) {
132
+ const _from = resolve$1(from).replace(_ROOT_FOLDER_RE, "$1").split("/");
133
+ const _to = resolve$1(to).replace(_ROOT_FOLDER_RE, "$1").split("/");
134
+ if (_to[0][1] === ":" && _from[0][1] === ":" && _from[0] !== _to[0]) return _to.join("/");
135
+ const _fromCopy = [..._from];
136
+ for (const segment of _fromCopy) {
137
+ if (_to[0] !== segment) break;
138
+ _from.shift();
139
+ _to.shift();
140
+ }
141
+ return [..._from.map(() => ".."), ..._to].join("/");
142
+ };
143
+ const dirname$1 = function(p) {
144
+ const segments = normalizeWindowsPath(p).replace(/\/$/, "").split("/").slice(0, -1);
145
+ if (segments.length === 1 && _DRIVE_LETTER_RE.test(segments[0])) segments[0] += "/";
146
+ return segments.join("/") || (isAbsolute$1(p) ? "/" : ".");
147
+ };
148
+ const basename$1 = function(p, extension) {
149
+ const segments = normalizeWindowsPath(p).split("/");
150
+ let lastSegment = "";
151
+ for (let i = segments.length - 1; i >= 0; i--) {
152
+ const val = segments[i];
153
+ if (val) {
154
+ lastSegment = val;
155
+ break;
156
+ }
157
+ }
158
+ return extension && lastSegment.endsWith(extension) ? lastSegment.slice(0, -extension.length) : lastSegment;
159
+ };
160
+ const parse = function(p) {
161
+ const root = _PATH_ROOT_RE.exec(p)?.[0]?.replace(/\\/g, "/") || "";
162
+ const base = basename$1(p);
163
+ const extension = extname$1(base);
164
+ return {
165
+ root,
166
+ dir: dirname$1(p),
167
+ base,
168
+ ext: extension,
169
+ name: base.slice(0, base.length - extension.length)
170
+ };
171
+ };
172
+ //#endregion
173
+ //#region ../../packages/memory/src/scoring.ts
174
+ const TEMPORAL_STRONG = /昨天|今天|上周|本周|本月|最近|之前|以前|上次|刚刚|刚刚才|刚才|前天|大前天|前几天|这几天|这几天来|近.*[天周月年]/;
175
+ const TEMPORAL_WEAK = /什么时候|何时|多久|几时|哪天|哪一天|多长.*时间/;
176
+ const TEMPORAL_ACTION = /做了|干了|处理了|解决了|修复了|完成了|改了|写了|加了|删了|移除了|部署了|发布了/;
177
+ const CODE_SIGNAL = /[`'"#]|[{}()[\]]|\.(ts|js|tsx|jsx|py|rs|go|java|cpp|c|h)\b|function |const |import |export |class |async |await |type |interface /;
178
+ /**
179
+ * Lightweight regex-based query intent detection.
180
+ * Zero LLM cost — classifies into temporal vs factual bias to adjust scoring weights.
181
+ */
182
+ function detectQueryIntent(query) {
183
+ const q = query.trim();
184
+ if (q.length === 0) return {
185
+ temporalBias: .3,
186
+ factualBias: .6
187
+ };
188
+ let temporalBias = .3;
189
+ let factualBias = .6;
190
+ if (TEMPORAL_STRONG.test(q)) temporalBias = 1;
191
+ else if (TEMPORAL_WEAK.test(q)) temporalBias = .8;
192
+ else if (TEMPORAL_ACTION.test(q)) temporalBias = .6;
193
+ if (CODE_SIGNAL.test(q) && !TEMPORAL_STRONG.test(q) && !TEMPORAL_WEAK.test(q)) factualBias = 1;
194
+ return {
195
+ temporalBias,
196
+ factualBias
197
+ };
198
+ }
199
+ /**
200
+ * Multi-factor relevance score for a memory memo against a query.
201
+ * Pure deterministic scoring — no LLM call, no network.
202
+ *
203
+ * When `intent` is provided, keyword weight increases for factual queries
204
+ * and recency weight increases for temporal queries.
205
+ */
206
+ function computeRelevanceScore(memo, query, usageCount = 0, currentProjectDir, projectTagCloud, intent) {
207
+ const ti = intent?.temporalBias ?? .3;
208
+ const fi = intent?.factualBias ?? .6;
209
+ const factors = {
210
+ keywordOverlap: computeKeywordSimilarity(memo, query),
211
+ recency: computeRecency(memo.recordedAt),
212
+ usageBoost: Math.min(.3, usageCount * .1),
213
+ projectBoost: computeProjectBoost(memo.projectDir, currentProjectDir),
214
+ tagOverlap: computeTagOverlap(memo.tags, projectTagCloud)
215
+ };
216
+ const kwWeight = .45 * (1 + (fi - .6) * .5);
217
+ const recencyWeight = .25 * (1 + (ti - .3) * .571);
218
+ const usageWeight = .15;
219
+ const projectWeight = .1;
220
+ const tagWeight = .05;
221
+ const total = kwWeight + recencyWeight + usageWeight + projectWeight + tagWeight;
222
+ return factors.keywordOverlap * (kwWeight / total) + factors.recency * (recencyWeight / total) + factors.usageBoost * (usageWeight / total) + factors.projectBoost * (projectWeight / total) + factors.tagOverlap * (tagWeight / total);
223
+ }
224
+ /**
225
+ * Score multiple memos against a query, returning sorted results.
226
+ */
227
+ function rankMemos(memos, query, options = {}) {
228
+ const { minScore = .3, maxResults = 3, currentProjectDir, projectTagCloud, vectorScores } = options;
229
+ const intent = detectQueryIntent(query);
230
+ const hasVectorScores = vectorScores !== void 0 && vectorScores.size > 0;
231
+ return memos.map((memo) => {
232
+ const keywordScore = computeRelevanceScore(memo, query, 0, currentProjectDir, projectTagCloud, intent);
233
+ const vectorScore = vectorScores?.get(memo.id) ?? 0;
234
+ return {
235
+ memo,
236
+ score: hasVectorScores ? keywordScore * .6 + vectorScore * .4 : keywordScore
237
+ };
238
+ }).filter((s) => s.score >= minScore).toSorted((a, b) => b.score - a.score).slice(0, maxResults);
239
+ }
240
+ /**
241
+ * Build a tag cloud from memos that belong to the current project.
242
+ */
243
+ function buildProjectTagCloud(memos, projectDir) {
244
+ const cloud = /* @__PURE__ */ new Set();
245
+ for (const memo of memos) if (memo.projectDir === projectDir && memo.tags !== void 0) for (const tag of memo.tags) cloud.add(tag);
246
+ return cloud;
247
+ }
248
+ function computeProjectBoost(memoProjectDir, currentProjectDir) {
249
+ if (currentProjectDir === void 0 || currentProjectDir.length === 0) return 0;
250
+ if (memoProjectDir === currentProjectDir) return 1;
251
+ if (memoProjectDir.length === 0) return 0;
252
+ const memoParent = parentDir(memoProjectDir);
253
+ const currentParent = parentDir(currentProjectDir);
254
+ if (memoParent.length > 0 && memoParent === currentParent) return .3;
255
+ return 0;
256
+ }
257
+ function parentDir(path) {
258
+ const trimmed = path.replace(/\/+$/, "");
259
+ const lastSep = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
260
+ return lastSep > 0 ? trimmed.slice(0, lastSep) : "";
261
+ }
262
+ function computeTagOverlap(memoTags, projectTagCloud) {
263
+ if (projectTagCloud === void 0 || projectTagCloud.size === 0) return 0;
264
+ if (memoTags === void 0 || memoTags.length === 0) return 0;
265
+ const memoSet = new Set(memoTags);
266
+ let intersection = 0;
267
+ for (const tag of memoSet) if (projectTagCloud.has(tag)) intersection++;
268
+ const union = new Set([...memoSet, ...projectTagCloud]).size;
269
+ return union === 0 ? 0 : intersection / union;
270
+ }
271
+ const STOP_WORDS = new Set([
272
+ "的",
273
+ "了",
274
+ "在",
275
+ "是",
276
+ "我",
277
+ "有",
278
+ "和",
279
+ "就",
280
+ "不",
281
+ "人",
282
+ "都",
283
+ "一",
284
+ "一个",
285
+ "上",
286
+ "也",
287
+ "很",
288
+ "到",
289
+ "说",
290
+ "要",
291
+ "去",
292
+ "你",
293
+ "会",
294
+ "着",
295
+ "没有",
296
+ "看",
297
+ "好",
298
+ "自己",
299
+ "这",
300
+ "他",
301
+ "她",
302
+ "它",
303
+ "们",
304
+ "那",
305
+ "些",
306
+ "the",
307
+ "a",
308
+ "an",
309
+ "is",
310
+ "are",
311
+ "was",
312
+ "were",
313
+ "be",
314
+ "been",
315
+ "being",
316
+ "have",
317
+ "has",
318
+ "had",
319
+ "do",
320
+ "does",
321
+ "did",
322
+ "will",
323
+ "would",
324
+ "could",
325
+ "should",
326
+ "may",
327
+ "might",
328
+ "can",
329
+ "shall",
330
+ "to",
331
+ "of",
332
+ "in",
333
+ "for",
334
+ "on",
335
+ "with",
336
+ "at",
337
+ "by",
338
+ "from",
339
+ "as",
340
+ "into",
341
+ "through",
342
+ "during",
343
+ "before",
344
+ "after",
345
+ "above",
346
+ "below",
347
+ "between",
348
+ "and",
349
+ "but",
350
+ "or",
351
+ "nor",
352
+ "not",
353
+ "so",
354
+ "yet",
355
+ "both",
356
+ "either",
357
+ "neither",
358
+ "each",
359
+ "every",
360
+ "all",
361
+ "any",
362
+ "few",
363
+ "more",
364
+ "most",
365
+ "other",
366
+ "some",
367
+ "such",
368
+ "no",
369
+ "only",
370
+ "own",
371
+ "same",
372
+ "than",
373
+ "too",
374
+ "very",
375
+ "just",
376
+ "because",
377
+ "about",
378
+ "what",
379
+ "which",
380
+ "who",
381
+ "whom",
382
+ "this",
383
+ "that",
384
+ "these",
385
+ "those",
386
+ "it",
387
+ "its",
388
+ "he",
389
+ "she",
390
+ "they",
391
+ "we",
392
+ "you",
393
+ "how"
394
+ ]);
395
+ function extractKeywords(text) {
396
+ const parts = text.toLowerCase().replaceAll(/([一-鿿㐀-䶿])([a-z0-9])/g, "$1 $2").replaceAll(/([a-z0-9])([一-鿿㐀-䶿])/g, "$1 $2").split(/[^a-z0-9一-鿿㐀-䶿]+/);
397
+ const tokens = [];
398
+ for (const part of parts) {
399
+ if (part.length === 0) continue;
400
+ if (/[一-鿿㐀-䶿]/.test(part)) {
401
+ for (const ch of part) if (ch.length > 0 && !STOP_WORDS.has(ch)) tokens.push(ch);
402
+ }
403
+ if (/^[a-z0-9]+$/.test(part) && part.length >= 2 && !STOP_WORDS.has(part)) tokens.push(part);
404
+ }
405
+ return [...new Set(tokens)];
406
+ }
407
+ function computeKeywordSimilarity(memo, query) {
408
+ const memoWords = extractKeywords(`${memo.userNeed} ${memo.approach} ${memo.whatFailed} ${memo.whatWorked}`);
409
+ const queryWords = extractKeywords(query);
410
+ if (memoWords.length === 0 || queryWords.length === 0) return 0;
411
+ const intersection = memoWords.filter((w) => queryWords.includes(w)).length;
412
+ const union = new Set([...memoWords, ...queryWords]).size;
413
+ return union === 0 ? 0 : intersection / union;
414
+ }
415
+ function computeRecency(recordedAt) {
416
+ const daysSince = (Date.now() - recordedAt) / (1e3 * 60 * 60 * 24);
417
+ return Math.max(0, 1 - daysSince / 90);
418
+ }
419
+ //#endregion
420
+ //#region ../../packages/memory/src/tags.ts
421
+ /**
422
+ * Normalize a tag list: lowercase, trim, deduplicate, drop empties,
423
+ * and cap at `max` entries.
424
+ */
425
+ function normalizeTags(tags, max = 5) {
426
+ if (!Array.isArray(tags)) return [];
427
+ const seen = /* @__PURE__ */ new Set();
428
+ const result = [];
429
+ for (const raw of tags) {
430
+ if (typeof raw !== "string") continue;
431
+ const tag = raw.trim().toLowerCase();
432
+ if (tag.length === 0 || seen.has(tag)) continue;
433
+ seen.add(tag);
434
+ result.push(tag);
435
+ if (result.length >= max) break;
436
+ }
437
+ return result;
438
+ }
439
+ /**
440
+ * Generate a small set of semantic tags from free-form text.
441
+ * Falls back to keyword extraction when the caller does not supply tags.
442
+ */
443
+ function generateTags(text, max = 5) {
444
+ return normalizeTags(extractKeywords(text), max);
445
+ }
446
+ //#endregion
447
+ //#region ../../packages/memory/src/models.ts
448
+ function generateId$1() {
449
+ return `memo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
450
+ }
451
+ function createMemoryMemo(partial) {
452
+ return {
453
+ id: partial.id ?? generateId$1(),
454
+ sourceSessionId: partial.sourceSessionId,
455
+ sourceSessionTitle: partial.sourceSessionTitle,
456
+ userNeed: partial.userNeed,
457
+ approach: partial.approach,
458
+ outcome: partial.outcome,
459
+ whatFailed: partial.whatFailed,
460
+ whatWorked: partial.whatWorked,
461
+ extractionSource: partial.extractionSource,
462
+ recordedAt: partial.recordedAt ?? Date.now(),
463
+ projectDir: partial.projectDir ?? "",
464
+ tags: normalizedTags(partial.tags),
465
+ note: partial.note
466
+ };
467
+ }
468
+ function normalizedTags(value) {
469
+ const tags = normalizeTags(value);
470
+ return tags.length > 0 ? tags : void 0;
471
+ }
472
+ function toSummary(memo) {
473
+ return {
474
+ id: memo.id,
475
+ sourceSessionTitle: memo.sourceSessionTitle,
476
+ sourceSessionId: memo.sourceSessionId,
477
+ userNeed: memo.userNeed,
478
+ approach: memo.approach,
479
+ outcome: memo.outcome,
480
+ whatFailed: memo.whatFailed,
481
+ whatWorked: memo.whatWorked,
482
+ extractionSource: memo.extractionSource,
483
+ recordedAt: memo.recordedAt,
484
+ projectDir: memo.projectDir,
485
+ tags: memo.tags,
486
+ note: memo.note
487
+ };
488
+ }
489
+ //#endregion
490
+ //#region ../../packages/memory/src/embeddings.ts
491
+ /**
492
+ * Text used to generate embeddings for a memo.
493
+ * Combines the most semantically meaningful fields.
494
+ */
495
+ function buildEmbeddingText(memo) {
496
+ return `${memo.userNeed} ${memo.approach} ${memo.whatWorked}`;
497
+ }
498
+ /**
499
+ * Cache of created engines keyed by cacheDir.
500
+ * Guarantees that all callers sharing the same cacheDir reuse the same engine
501
+ * instance and the same in-flight model download/load — avoiding duplicate
502
+ * downloads and file-corruption races when /memory and /knowledge both start
503
+ * before the model is cached.
504
+ */
505
+ const engineCache = /* @__PURE__ */ new Map();
506
+ /**
507
+ * Create an embedding engine backed by fastembed.
508
+ * Lazily loads the model on first use so startup is not blocked.
509
+ * Engines are cached by cacheDir so repeated calls with the same cacheDir
510
+ * return the same instance, sharing model state and download progress.
511
+ * @param cacheDir Absolute path for model cache (e.g. ~/.scream-code/cache/fastembed).
512
+ * Defaults to "local_cache" (CWD-relative) if not provided — prefer
513
+ * passing an explicit path so the cache doesn't duplicate across CWDs.
514
+ */
515
+ function createFastEmbedEngine(cacheDir) {
516
+ const key = cacheDir ?? "";
517
+ const cached = engineCache.get(key);
518
+ if (cached !== void 0) return cached;
519
+ const engine = createFastEmbedEngineImpl(cacheDir);
520
+ engineCache.set(key, engine);
521
+ return engine;
522
+ }
523
+ /** Stable identity of the model used by every engine created here. */
524
+ const EMBEDDING_MODEL_NAME = "bge-small-zh-v1.5";
525
+ function createFastEmbedEngineImpl(cacheDir) {
526
+ let embedder = null;
527
+ let initPromise = null;
528
+ let loadFailed = false;
529
+ let lastError;
530
+ return {
531
+ modelName: EMBEDDING_MODEL_NAME,
532
+ get available() {
533
+ return embedder !== null && !loadFailed;
534
+ },
535
+ get lastError() {
536
+ return lastError;
537
+ },
538
+ async embedBatch(texts) {
539
+ if (!this.available) return null;
540
+ if (texts.length === 0) return [];
541
+ try {
542
+ const generator = embedder.embed(texts);
543
+ const vectors = [];
544
+ for await (const batch of generator) for (const vec of batch) vectors.push(new Float32Array(vec));
545
+ return vectors.length > 0 ? vectors : null;
546
+ } catch (error) {
547
+ lastError = error instanceof Error ? error.message : String(error);
548
+ return null;
549
+ }
550
+ },
551
+ cosineSimilarity(a, b) {
552
+ if (a.length !== b.length || a.length === 0) return 0;
553
+ let dot = 0;
554
+ let normA = 0;
555
+ let normB = 0;
556
+ for (let i = 0; i < a.length; i++) {
557
+ dot += a[i] * b[i];
558
+ normA += a[i] * a[i];
559
+ normB += b[i] * b[i];
560
+ }
561
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
562
+ return denom === 0 ? 0 : dot / denom;
563
+ },
564
+ async ensureReady() {
565
+ if (embedder !== null) {
566
+ loadFailed = false;
567
+ lastError = void 0;
568
+ return true;
569
+ }
570
+ try {
571
+ if (loadFailed || initPromise === null) {
572
+ loadFailed = false;
573
+ lastError = void 0;
574
+ initPromise = loadEmbedder(cacheDir);
575
+ }
576
+ embedder = await initPromise;
577
+ if (embedder === null) {
578
+ initPromise = null;
579
+ lastError = "fastembed returned an empty model";
580
+ return false;
581
+ }
582
+ loadFailed = false;
583
+ lastError = void 0;
584
+ return true;
585
+ } catch (error) {
586
+ initPromise = null;
587
+ loadFailed = true;
588
+ lastError = error instanceof Error ? error.message : String(error);
589
+ return false;
590
+ }
591
+ }
592
+ };
593
+ }
594
+ async function loadEmbedder(cacheDir) {
595
+ const { FlagEmbedding, EmbeddingModel } = await import("fastembed");
596
+ if (cacheDir !== void 0) mkdirSync(cacheDir, { recursive: true });
597
+ const model = EmbeddingModel.BGESmallZH;
598
+ const initOpts = cacheDir !== void 0 ? {
599
+ model,
600
+ cacheDir
601
+ } : { model };
602
+ try {
603
+ return await FlagEmbedding.init(initOpts);
604
+ } catch (initError) {
605
+ const msg = initError instanceof Error ? initError.message : "";
606
+ if (!/Config file not found|Tokenizer file not found|Tokens map file not found/iu.test(msg)) throw initError;
607
+ await ensureFastembedModelSidecars(model, cacheDir);
608
+ return FlagEmbedding.init(initOpts);
609
+ }
610
+ }
611
+ /**
612
+ * Cache entry names fastembed uses for the fixed BGESmallZH model: it extracts
613
+ * into `<cacheDir>/<name>/` from the staged archive `<cacheDir>/<name>.tar.gz`.
614
+ */
615
+ const BGESMALLZH_CACHE_NAME = "fast-bge-small-zh-v1.5";
616
+ const PLATFORM_FAILURE = /@anush008\/tokenizers|Failed to load native binding|Unsupported (?:OS|platform|architecture)|ERR_DLOPEN_FAILED|invalid ELF header|Exec format error|is not a valid Win32 application|GLIBC_\d+[^"\n]*not found|Error loading shared library|cannot open shared object file/iu;
617
+ const ARCHIVE_FAILURE = /\bTAR_[A-Z_]+|incorrect header check|unexpected end of file|invalid tar|zlib/iu;
618
+ const NETWORK_FAILURE = /\b(?:ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|socket hang up|fetch failed)\b|network error|HTTP \d{3}/iu;
619
+ /**
620
+ * Classify a load failure so callers can say something true about it: the four
621
+ * causes need four different answers (unsupported platform, corrupt cache,
622
+ * network, unknown), and they used to be reported as one network problem.
623
+ */
624
+ function classifyEmbeddingFailure(message) {
625
+ if (message === void 0) return "other";
626
+ if (PLATFORM_FAILURE.test(message)) return "platform";
627
+ if (ARCHIVE_FAILURE.test(message)) return "archive";
628
+ if (NETWORK_FAILURE.test(message)) return "network";
629
+ return "other";
630
+ }
631
+ /**
632
+ * Loads only the module entry point — no model download — so callers can tell
633
+ * "this machine cannot run the local model at all" apart from "the download
634
+ * failed". fastembed imports its native tokenizer binding at module scope, which
635
+ * is exactly the step a platform without a published binary cannot pass.
636
+ */
637
+ async function probeLocalEmbeddingSupport() {
638
+ try {
639
+ await import("fastembed");
640
+ return { supported: true };
641
+ } catch (error) {
642
+ return {
643
+ supported: false,
644
+ error: error instanceof Error ? error.message : String(error)
645
+ };
646
+ }
647
+ }
648
+ /**
649
+ * Remove every trace of a previous BGESmallZH download: the extracted model
650
+ * directory *and* the staged archive it came from.
651
+ *
652
+ * The archive has to go with it. fastembed skips the download whenever
653
+ * `<cacheDir>/<name>.tar.gz` exists, and it only deletes that file after a
654
+ * *successful* extract — its download error paths (response error, file stream
655
+ * error) reject and leave whatever was written behind. So an archive sitting
656
+ * next to a failed attempt is usually a partial leftover, and keeping it makes
657
+ * every retry extract the same junk locally: no network traffic, a few
658
+ * milliseconds, the same failure forever.
659
+ *
660
+ * Never throws: a file locked by another process (on Windows a mapped ONNX file
661
+ * stays locked) must not replace the download error the caller is reporting.
662
+ */
663
+ function clearEmbeddingModelCache(cacheDir) {
664
+ for (const name of [BGESMALLZH_CACHE_NAME, `${BGESMALLZH_CACHE_NAME}.tar.gz`]) {
665
+ const target = join(cacheDir, name);
666
+ if (!existsSync(target)) continue;
667
+ try {
668
+ rmSync(target, {
669
+ recursive: true,
670
+ force: true
671
+ });
672
+ } catch {}
673
+ }
674
+ }
675
+ /**
676
+ * True when the extracted model weights are already on disk. Loading then
677
+ * never downloads (fastembed skips the fetch whenever the model dir exists),
678
+ * so callers can safely trigger a local load without risking a surprise
679
+ * mid-query download.
680
+ */
681
+ function hasEmbeddingModelCache(cacheDir) {
682
+ const modelDir = join(cacheDir, BGESMALLZH_CACHE_NAME);
683
+ return existsSync(modelDir) && existsSync(join(modelDir, "model_optimized.onnx"));
684
+ }
685
+ /**
686
+ * Small config/tokenizer files that fastembed expects alongside model.onnx.
687
+ * If these are missing (e.g. GCS download partially failed), fastembed throws.
688
+ * We download them from HuggingFace so the model can load — this covers the
689
+ * case where the GCS tarball was incomplete but the HF repo is reachable.
690
+ */
691
+ const FASTEMBED_SIDECARS = [
692
+ "config.json",
693
+ "tokenizer.json",
694
+ "tokenizer_config.json",
695
+ "special_tokens_map.json"
696
+ ];
697
+ const FASTEMBED_HF_REPOS = { "fast-bge-small-zh-v1.5": "BAAI/bge-small-zh-v1.5" };
698
+ async function ensureFastembedModelSidecars(model, cacheDir) {
699
+ const repo = FASTEMBED_HF_REPOS[model];
700
+ if (repo === void 0) return;
701
+ const modelDir = join(cacheDir ?? "local_cache", model);
702
+ mkdirSync(modelDir, { recursive: true });
703
+ for (const fileName of FASTEMBED_SIDECARS) {
704
+ const target = join(modelDir, fileName);
705
+ try {
706
+ const { access } = await import("node:fs/promises");
707
+ await access(target);
708
+ continue;
709
+ } catch {}
710
+ const hfUrl = `https://huggingface.co/${repo}/resolve/main/${fileName}`;
711
+ try {
712
+ const response = await fetch(hfUrl);
713
+ if (!response.ok) continue;
714
+ const { writeFile } = await import("node:fs/promises");
715
+ await writeFile(target, Buffer.from(await response.arrayBuffer()));
716
+ } catch {}
717
+ }
718
+ }
719
+ //#endregion
720
+ //#region ../../packages/memory/src/store.ts
721
+ const FILE_NAME = "entries.jsonl";
722
+ const MIGRATION_MARKER = ".migrated";
723
+ const SQLITE_MIGRATION_MARKER = ".migrated-to-sqlite";
724
+ var MemoryMemoStore = class MemoryMemoStore {
725
+ projectDir;
726
+ jsonlPath;
727
+ dbPath;
728
+ db;
729
+ initialized = false;
730
+ writeLock = Promise.resolve();
731
+ embeddingEngine;
732
+ embeddingQueue = /* @__PURE__ */ new Set();
733
+ embeddingTimer;
734
+ embeddingFlushing = false;
735
+ embeddingDegraded = false;
736
+ embeddingConsecutiveFailures = 0;
737
+ lastEmbeddingError;
738
+ log;
739
+ constructor(projectDir, log) {
740
+ this.projectDir = projectDir;
741
+ this.jsonlPath = join$1(projectDir, "memory", FILE_NAME);
742
+ this.dbPath = join$1(projectDir, "memory", "memos.sqlite");
743
+ this.log = log ?? {};
744
+ }
745
+ /**
746
+ * Open the SQLite database and run schema migrations. Call this once after
747
+ * construction before relying on reads/writes.
748
+ *
749
+ * Note on async SQLite: Node.js added the asynchronous `Database` class to
750
+ * `node:sqlite` in v23.4.0 (experimental). This package currently supports
751
+ * Node >=22.0.0 and uses `DatabaseSync` because the v22 type definitions do
752
+ * not yet include `Database`. Once the project baseline moves to Node 23+ and
753
+ * the types catch up, the synchronous calls here should be migrated to the
754
+ * async API to avoid blocking the event loop on large operations.
755
+ */
756
+ initPromise = null;
757
+ async init() {
758
+ if (this.initPromise) return this.initPromise;
759
+ this.initPromise = this._doInit().catch((error) => {
760
+ this.initPromise = null;
761
+ throw error;
762
+ });
763
+ return this.initPromise;
764
+ }
765
+ async _doInit() {
766
+ if (this.initialized) return;
767
+ await this.ensureDir();
768
+ this.db = new DatabaseSync(this.dbPath);
769
+ this.db.exec("PRAGMA journal_mode = WAL;");
770
+ this.db.exec("PRAGMA foreign_keys = ON;");
771
+ this.db.exec("PRAGMA busy_timeout = 5000;");
772
+ this.createSchema();
773
+ await this.migrateFromJsonl();
774
+ this.initialized = true;
775
+ }
776
+ /** Close the database handle and checkpoint WAL. */
777
+ close() {
778
+ if (this.embeddingTimer !== void 0) {
779
+ clearTimeout(this.embeddingTimer);
780
+ this.embeddingTimer = void 0;
781
+ }
782
+ this.embeddingQueue.clear();
783
+ if (this.db !== void 0) {
784
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
785
+ this.db.close();
786
+ this.db = void 0;
787
+ }
788
+ this.initialized = false;
789
+ this.initPromise = null;
790
+ }
791
+ /** Iterate all memos from the database, newest first. Optionally filter by project directory. */
792
+ async *read(options) {
793
+ await this.init();
794
+ if (this.db === void 0) return;
795
+ const projectDir = options?.projectDir;
796
+ const stmt = projectDir === void 0 ? this.db.prepare("SELECT * FROM memos ORDER BY recorded_at DESC") : this.db.prepare("SELECT * FROM memos WHERE project_dir = ? OR project_dir = '' ORDER BY recorded_at DESC");
797
+ const rows = projectDir === void 0 ? stmt.all() : stmt.all(projectDir);
798
+ for (const row of rows) yield rowToMemo(row);
799
+ }
800
+ /** Append a memo. */
801
+ async append(entry) {
802
+ return this.withWriteLock(() => this.appendInternal(entry));
803
+ }
804
+ /**
805
+ * True when a memo with the same source session + user need + approach
806
+ * already exists. Used by compaction-summary recovery to re-store memos
807
+ * idempotently after a crash between compaction apply and extraction.
808
+ * The approach field keeps two distinct memos that happen to share the
809
+ * same user need from being collapsed into one.
810
+ */
811
+ existsBySourceAndNeed(sourceSessionId, userNeed, approach) {
812
+ if (this.db === void 0) return false;
813
+ return this.db.prepare("SELECT 1 FROM memos WHERE source_session_id = ? AND user_need = ? AND approach = ? LIMIT 1").get(sourceSessionId, userNeed, approach) !== void 0;
814
+ }
815
+ /** Delete a memo by id. */
816
+ async delete(id) {
817
+ return this.withWriteLock(() => this.deleteInternal(id));
818
+ }
819
+ /** Get a single memo by ID. */
820
+ async get(id) {
821
+ await this.init();
822
+ if (this.db === void 0) return void 0;
823
+ const row = this.db.prepare("SELECT * FROM memos WHERE id = ?").get(id);
824
+ return row !== void 0 ? rowToMemo(row) : void 0;
825
+ }
826
+ /**
827
+ * Full-text search over memos using the FTS5 index.
828
+ *
829
+ * Returns raw candidates newest first. Callers that need ranking should pass
830
+ * the results to `rankMemos`. An empty or whitespace-only query returns an
831
+ * empty array.
832
+ */
833
+ async search(query, options) {
834
+ await this.init();
835
+ if (this.db === void 0) return [];
836
+ const ftsQuery = buildFtsQuery$1(query);
837
+ if (ftsQuery === void 0) return [];
838
+ const limit = options?.candidateLimit ?? 200;
839
+ const projectDir = options?.projectDir;
840
+ const stmt = projectDir === void 0 ? this.db.prepare(`SELECT m.* FROM memos m
841
+ JOIN memos_fts f ON m.rowid = f.rowid
842
+ WHERE f.memos_fts MATCH ?
843
+ ORDER BY m.recorded_at DESC LIMIT ?`) : this.db.prepare(`SELECT m.* FROM memos m
844
+ JOIN memos_fts f ON m.rowid = f.rowid
845
+ WHERE f.memos_fts MATCH ? AND (m.project_dir = ? OR m.project_dir = '')
846
+ ORDER BY m.recorded_at DESC LIMIT ?`);
847
+ return (projectDir === void 0 ? stmt.all(ftsQuery, limit) : stmt.all(ftsQuery, projectDir, limit)).map(rowToMemo);
848
+ }
849
+ /** List memos with optional full-text search and pagination. */
850
+ async list(options) {
851
+ await this.init();
852
+ if (this.db === void 0) return {
853
+ memos: [],
854
+ total: 0
855
+ };
856
+ const search = options?.search?.toLowerCase().trim();
857
+ const limit = options?.limit ?? 50;
858
+ const offset = options?.offset ?? 0;
859
+ const projectDir = options?.projectDir;
860
+ if (search !== void 0 && search.length > 0) {
861
+ let candidates = await this.search(search, { projectDir });
862
+ if (candidates.length === 0) for await (const memo of this.read({ projectDir })) candidates.push(memo);
863
+ const filtered = candidates.filter((memo) => memoMatchesSearch(memo, search));
864
+ const total = filtered.length;
865
+ return {
866
+ memos: filtered.slice(offset, offset + limit).map(toSummary),
867
+ total
868
+ };
869
+ }
870
+ const { rows, total } = this.listAll(limit, offset, projectDir);
871
+ return {
872
+ memos: rows.map(toSummary),
873
+ total
874
+ };
875
+ }
876
+ /**
877
+ * One-time migration from per-workDir memory stores to a global store.
878
+ * Reads `<screamHomeDir>/sessions/<workDirKey>/memory/entries.jsonl`
879
+ * and appends valid entries to the global SQLite store.
880
+ * Deletes the legacy per-session memory files afterwards and writes a marker
881
+ * file so the migration only runs once.
882
+ */
883
+ static async migrateLegacyStores(screamHomeDir) {
884
+ const target = new MemoryMemoStore(screamHomeDir);
885
+ const markerPath = join$1(screamHomeDir, "memory", MIGRATION_MARKER);
886
+ try {
887
+ await stat(markerPath);
888
+ return;
889
+ } catch {}
890
+ const sessionsDir = join$1(screamHomeDir, "sessions");
891
+ let sessionEntries;
892
+ try {
893
+ sessionEntries = await readdir(sessionsDir, { withFileTypes: true }).then((entries) => entries.filter((e) => e.isDirectory()).map((e) => e.name));
894
+ } catch {
895
+ await writeFile(markerPath, "", "utf8").catch(() => {});
896
+ return;
897
+ }
898
+ const migratedIds = /* @__PURE__ */ new Set();
899
+ for await (const memo of target.read()) migratedIds.add(memo.id);
900
+ let migratedCount = 0;
901
+ const legacyPaths = [];
902
+ for (const sessionKey of sessionEntries) {
903
+ const legacyPath = join$1(sessionsDir, sessionKey, "memory", FILE_NAME);
904
+ let stream;
905
+ try {
906
+ stream = createReadStream(legacyPath, { encoding: "utf8" });
907
+ } catch {}
908
+ stream.on("error", () => {});
909
+ let line = "";
910
+ try {
911
+ for await (const chunk of stream) {
912
+ line += chunk;
913
+ let newlineIndex = line.indexOf("\n");
914
+ while (newlineIndex !== -1) {
915
+ const rawLine = line.slice(0, newlineIndex).replace(/\r$/, "");
916
+ line = line.slice(newlineIndex + 1);
917
+ newlineIndex = line.indexOf("\n");
918
+ const memo = target.parseLine(rawLine, 0);
919
+ if (memo === void 0 || migratedIds.has(memo.id)) continue;
920
+ await target.append(memo);
921
+ migratedIds.add(memo.id);
922
+ migratedCount++;
923
+ }
924
+ }
925
+ } catch {
926
+ continue;
927
+ }
928
+ legacyPaths.push(legacyPath);
929
+ }
930
+ for (const legacyPath of legacyPaths) {
931
+ await unlink(legacyPath).catch(() => {});
932
+ await rmdir(dirname$1(legacyPath)).catch(() => {});
933
+ }
934
+ await writeFile(markerPath, `${migratedCount}\n`, "utf8").catch(() => {});
935
+ }
936
+ /** @internal */
937
+ parseLine(rawLine, _lineNumber) {
938
+ if (rawLine.length === 0) return void 0;
939
+ try {
940
+ const record = JSON.parse(rawLine);
941
+ if (record["type"] !== "memory_memo" || !record["entry"]) return void 0;
942
+ const entry = record["entry"];
943
+ if (record["version"] === 1 || entry["userRequirement"] !== void 0 && entry["userNeed"] === void 0) {
944
+ const str = (v, fallback = "") => typeof v === "string" ? v : fallback;
945
+ return {
946
+ id: str(entry["id"]),
947
+ sourceSessionId: str(entry["sourceSessionId"]),
948
+ sourceSessionTitle: str(entry["sourceSessionTitle"], void 0),
949
+ userNeed: str(entry["userRequirement"]),
950
+ approach: str(entry["solution"]),
951
+ outcome: str(entry["completionStatus"]),
952
+ whatFailed: str(entry["problemsEncountered"], "none"),
953
+ whatWorked: "none",
954
+ extractionSource: entry["extractionSource"] === "exit" ? "exit" : "compaction",
955
+ recordedAt: typeof entry["recordedAt"] === "number" ? entry["recordedAt"] : 0,
956
+ projectDir: str(entry["projectDir"])
957
+ };
958
+ }
959
+ const src = entry["extractionSource"];
960
+ if (typeof src === "string" && ![
961
+ "compaction",
962
+ "exit",
963
+ "manual"
964
+ ].includes(src)) return;
965
+ return entry;
966
+ } catch {
967
+ return;
968
+ }
969
+ }
970
+ createSchema() {
971
+ if (this.db === void 0) return;
972
+ this.db.exec(`
973
+ CREATE TABLE IF NOT EXISTS memos (
974
+ id TEXT PRIMARY KEY,
975
+ source_session_id TEXT NOT NULL,
976
+ source_session_title TEXT,
977
+ user_need TEXT NOT NULL,
978
+ approach TEXT NOT NULL,
979
+ outcome TEXT NOT NULL,
980
+ what_failed TEXT NOT NULL DEFAULT 'none',
981
+ what_worked TEXT NOT NULL DEFAULT 'none',
982
+ extraction_source TEXT NOT NULL CHECK(extraction_source IN ('compaction', 'exit', 'manual')),
983
+ recorded_at INTEGER NOT NULL,
984
+ project_dir TEXT NOT NULL DEFAULT '',
985
+ tags TEXT NOT NULL DEFAULT '[]'
986
+ );
987
+
988
+ CREATE INDEX IF NOT EXISTS idx_memos_project_dir ON memos(project_dir);
989
+
990
+ CREATE VIRTUAL TABLE IF NOT EXISTS memos_fts USING fts5(
991
+ user_need,
992
+ approach,
993
+ what_failed,
994
+ what_worked,
995
+ source_session_title,
996
+ content=''
997
+ );
998
+
999
+ CREATE TABLE IF NOT EXISTS memory_embeddings (
1000
+ memory_id TEXT PRIMARY KEY REFERENCES memos(id) ON DELETE CASCADE,
1001
+ embedding_json TEXT NOT NULL,
1002
+ model TEXT NOT NULL DEFAULT '${EMBEDDING_MODEL_NAME}',
1003
+ created_at INTEGER NOT NULL
1004
+ );
1005
+
1006
+ CREATE INDEX IF NOT EXISTS idx_memory_embeddings_created_at ON memory_embeddings(created_at DESC);
1007
+ `);
1008
+ this.migrateSchema();
1009
+ }
1010
+ migrateSchema() {
1011
+ if (this.db === void 0) return;
1012
+ const info = this.db.prepare("PRAGMA table_info(memos)").all();
1013
+ if (!info.some((col) => col.name === "project_dir")) this.db.exec("ALTER TABLE memos ADD COLUMN project_dir TEXT NOT NULL DEFAULT ''");
1014
+ if (!info.some((col) => col.name === "tags")) this.db.exec("ALTER TABLE memos ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'");
1015
+ this.db.exec(`
1016
+ CREATE INDEX IF NOT EXISTS idx_memos_project_dir ON memos(project_dir);
1017
+ CREATE INDEX IF NOT EXISTS idx_memory_embeddings_created_at ON memory_embeddings(created_at DESC);
1018
+ `);
1019
+ }
1020
+ async migrateFromJsonl() {
1021
+ const markerPath = join$1(this.projectDir, "memory", SQLITE_MIGRATION_MARKER);
1022
+ try {
1023
+ await stat(markerPath);
1024
+ return;
1025
+ } catch {}
1026
+ const memos = [];
1027
+ let stream;
1028
+ try {
1029
+ stream = createReadStream(this.jsonlPath, { encoding: "utf8" });
1030
+ } catch {
1031
+ await writeFile(markerPath, "", "utf8").catch(() => {});
1032
+ return;
1033
+ }
1034
+ stream.on("error", () => {});
1035
+ let line = "";
1036
+ try {
1037
+ for await (const chunk of stream) {
1038
+ line += chunk;
1039
+ let newlineIndex = line.indexOf("\n");
1040
+ while (newlineIndex !== -1) {
1041
+ const rawLine = line.slice(0, newlineIndex).replace(/\r$/, "");
1042
+ line = line.slice(newlineIndex + 1);
1043
+ newlineIndex = line.indexOf("\n");
1044
+ const memo = this.parseLine(rawLine, 0);
1045
+ if (memo !== void 0) memos.push(memo);
1046
+ }
1047
+ }
1048
+ } catch {}
1049
+ if (memos.length > 0) this.insertMany(memos);
1050
+ await writeFile(markerPath, "", "utf8").catch(() => {});
1051
+ await rename(this.jsonlPath, `${this.jsonlPath}.bak`).catch(() => {});
1052
+ await unlink(join$1(this.projectDir, "memory", "index.json")).catch(() => {});
1053
+ }
1054
+ insertMany(memos) {
1055
+ if (this.db === void 0 || memos.length === 0) return;
1056
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO memos (
1057
+ id, source_session_id, source_session_title, user_need, approach,
1058
+ outcome, what_failed, what_worked, extraction_source, recorded_at, project_dir, tags
1059
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1060
+ RETURNING rowid`);
1061
+ const insertFts = this.db.prepare(`INSERT INTO memos_fts(rowid, user_need, approach, what_failed, what_worked, source_session_title)
1062
+ VALUES (?, ?, ?, ?, ?, ?)`);
1063
+ this.db.exec("BEGIN TRANSACTION");
1064
+ try {
1065
+ for (const memo of memos) {
1066
+ const row = insert.get(memo.id, memo.sourceSessionId, memo.sourceSessionTitle ?? null, memo.userNeed, memo.approach, memo.outcome, memo.whatFailed, memo.whatWorked, memo.extractionSource, memo.recordedAt, memo.projectDir ?? "", JSON.stringify(memo.tags ?? []));
1067
+ if (row === void 0) continue;
1068
+ insertFts.run(row.rowid, toFtsText$1(memo.userNeed), toFtsText$1(memo.approach), toFtsText$1(memo.whatFailed), toFtsText$1(memo.whatWorked), toFtsText$1(memo.sourceSessionTitle ?? ""));
1069
+ }
1070
+ this.db.exec("COMMIT");
1071
+ } catch (error) {
1072
+ this.db.exec("ROLLBACK");
1073
+ throw new Error(`Failed to migrate memos to SQLite: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1074
+ }
1075
+ }
1076
+ async appendInternal(entry) {
1077
+ await this.init();
1078
+ if (this.db === void 0) return;
1079
+ const insert = this.db.prepare(`INSERT INTO memos (
1080
+ id, source_session_id, source_session_title, user_need, approach,
1081
+ outcome, what_failed, what_worked, extraction_source, recorded_at, project_dir, tags
1082
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1083
+ RETURNING rowid`);
1084
+ const insertFts = this.db.prepare(`INSERT INTO memos_fts(rowid, user_need, approach, what_failed, what_worked, source_session_title)
1085
+ VALUES (?, ?, ?, ?, ?, ?)`);
1086
+ this.db.exec("BEGIN TRANSACTION");
1087
+ try {
1088
+ const row = insert.get(entry.id, entry.sourceSessionId, entry.sourceSessionTitle ?? null, entry.userNeed, entry.approach, entry.outcome, entry.whatFailed, entry.whatWorked, entry.extractionSource, entry.recordedAt, entry.projectDir ?? "", JSON.stringify(entry.tags ?? []));
1089
+ insertFts.run(row.rowid, toFtsText$1(entry.userNeed), toFtsText$1(entry.approach), toFtsText$1(entry.whatFailed), toFtsText$1(entry.whatWorked), toFtsText$1(entry.sourceSessionTitle ?? ""));
1090
+ this.db.exec("COMMIT");
1091
+ this.scheduleEmbedding(entry);
1092
+ } catch {
1093
+ this.db.exec("ROLLBACK");
1094
+ throw new Error("Failed to append memo");
1095
+ }
1096
+ }
1097
+ /** Update a memo by id. Returns true if the memo existed and was updated. */
1098
+ async update(id, patch) {
1099
+ return this.withWriteLock(() => this.updateInternal(id, patch));
1100
+ }
1101
+ /** @internal */
1102
+ async updateInternal(id, patch) {
1103
+ await this.init();
1104
+ if (this.db === void 0) return false;
1105
+ const existing = await this.get(id);
1106
+ if (existing === void 0) return false;
1107
+ const updated = {
1108
+ ...existing,
1109
+ ...patch
1110
+ };
1111
+ const selectRow = this.db.prepare("SELECT rowid FROM memos WHERE id = ?");
1112
+ const update = this.db.prepare(`UPDATE memos SET
1113
+ rowid = (SELECT COALESCE(MAX(rowid), 0) + 1 FROM memos),
1114
+ source_session_id = ?,
1115
+ source_session_title = ?,
1116
+ user_need = ?,
1117
+ approach = ?,
1118
+ outcome = ?,
1119
+ what_failed = ?,
1120
+ what_worked = ?,
1121
+ extraction_source = ?,
1122
+ recorded_at = ?,
1123
+ project_dir = ?,
1124
+ tags = ?
1125
+ WHERE id = ?
1126
+ RETURNING rowid`);
1127
+ const updateFts = this.db.prepare(`INSERT INTO memos_fts(rowid, user_need, approach, what_failed, what_worked, source_session_title)
1128
+ VALUES (?, ?, ?, ?, ?, ?)`);
1129
+ const deleteFts = this.db.prepare("INSERT INTO memos_fts(memos_fts, rowid) VALUES ('delete', ?)");
1130
+ const deleteEmbedding = this.db.prepare("DELETE FROM memory_embeddings WHERE memory_id = ?");
1131
+ this.db.exec("BEGIN TRANSACTION");
1132
+ try {
1133
+ const oldRow = selectRow.get(id);
1134
+ if (oldRow === void 0) {
1135
+ this.db.exec("ROLLBACK");
1136
+ return false;
1137
+ }
1138
+ const row = update.get(updated.sourceSessionId, updated.sourceSessionTitle ?? null, updated.userNeed, updated.approach, updated.outcome, updated.whatFailed, updated.whatWorked, updated.extractionSource, updated.recordedAt, updated.projectDir ?? "", JSON.stringify(updated.tags ?? []), id);
1139
+ if (row === void 0) {
1140
+ this.db.exec("ROLLBACK");
1141
+ return false;
1142
+ }
1143
+ deleteFts.run(oldRow.rowid);
1144
+ updateFts.run(row.rowid, toFtsText$1(updated.userNeed), toFtsText$1(updated.approach), toFtsText$1(updated.whatFailed), toFtsText$1(updated.whatWorked), toFtsText$1(updated.sourceSessionTitle ?? ""));
1145
+ deleteEmbedding.run(id);
1146
+ this.db.exec("COMMIT");
1147
+ this.scheduleEmbedding(updated);
1148
+ return true;
1149
+ } catch {
1150
+ this.db.exec("ROLLBACK");
1151
+ throw new Error("Failed to update memo");
1152
+ }
1153
+ }
1154
+ async deleteInternal(id) {
1155
+ await this.init();
1156
+ if (this.db === void 0) return false;
1157
+ const row = this.db.prepare("SELECT rowid FROM memos WHERE id = ?").get(id);
1158
+ if (row === void 0) return true;
1159
+ const deleteFts = this.db.prepare("INSERT INTO memos_fts(memos_fts, rowid) VALUES ('delete', ?)");
1160
+ const deleteMemo = this.db.prepare("DELETE FROM memos WHERE id = ?");
1161
+ this.db.exec("BEGIN TRANSACTION");
1162
+ try {
1163
+ deleteFts.run(row.rowid);
1164
+ deleteMemo.run(id);
1165
+ this.db.exec("COMMIT");
1166
+ return true;
1167
+ } catch {
1168
+ this.db.exec("ROLLBACK");
1169
+ throw new Error("Failed to delete memo");
1170
+ }
1171
+ }
1172
+ /** Set the embedding engine. Call once after construction, before any writes. */
1173
+ setEmbeddingEngine(engine) {
1174
+ this.embeddingEngine = engine;
1175
+ }
1176
+ /** Check whether the store has any vector embeddings. */
1177
+ hasEmbeddings() {
1178
+ if (this.db === void 0) return false;
1179
+ return (this.db.prepare("SELECT COUNT(*) as count FROM memory_embeddings").get()?.count ?? 0) > 0;
1180
+ }
1181
+ /** Access the embedding engine (may be undefined if not configured). */
1182
+ getEmbeddingEngine() {
1183
+ return this.embeddingEngine;
1184
+ }
1185
+ /**
1186
+ * Search memos by vector similarity. Returns memos sorted by cosine
1187
+ * similarity (highest first). Falls back to empty if no embeddings exist.
1188
+ *
1189
+ * NOTE: candidates are ordered by created_at DESC, so only the newest
1190
+ * N embeddings are considered. Older but highly relevant memos may be
1191
+ * omitted when the store has more than candidateLimit embeddings.
1192
+ * This is a known limitation — see M-M5 in project review notes.
1193
+ * Performance notes:
1194
+ * - candidateLimit bounds the SQL query so we never load every embedding.
1195
+ * - recencyCutoffDays lets callers ignore very old memos.
1196
+ * - projectDir is pushed into the SQL JOIN so unrelated projects are not
1197
+ * considered at all.
1198
+ */
1199
+ async searchByVector(queryEmbedding, options) {
1200
+ await this.init();
1201
+ if (this.db === void 0) return [];
1202
+ const limit = options?.candidateLimit ?? 200;
1203
+ const projectDir = options?.projectDir;
1204
+ const recencyCutoffDays = options?.recencyCutoffDays;
1205
+ const cutoffMs = recencyCutoffDays !== void 0 && recencyCutoffDays > 0 ? Date.now() - recencyCutoffDays * 24 * 60 * 60 * 1e3 : void 0;
1206
+ let rows;
1207
+ if (projectDir !== void 0) {
1208
+ const stmt = cutoffMs === void 0 ? this.db.prepare(`
1209
+ SELECT e.memory_id, e.embedding_json
1210
+ FROM memory_embeddings e
1211
+ JOIN memos m ON m.id = e.memory_id
1212
+ WHERE (m.project_dir = ? OR m.project_dir = '')
1213
+ ORDER BY e.created_at DESC
1214
+ LIMIT ?
1215
+ `) : this.db.prepare(`
1216
+ SELECT e.memory_id, e.embedding_json
1217
+ FROM memory_embeddings e
1218
+ JOIN memos m ON m.id = e.memory_id
1219
+ WHERE (m.project_dir = ? OR m.project_dir = '')
1220
+ AND e.created_at > ?
1221
+ ORDER BY e.created_at DESC
1222
+ LIMIT ?
1223
+ `);
1224
+ rows = cutoffMs === void 0 ? stmt.all(projectDir, limit) : stmt.all(projectDir, cutoffMs, limit);
1225
+ } else {
1226
+ const stmt = cutoffMs === void 0 ? this.db.prepare("SELECT memory_id, embedding_json FROM memory_embeddings ORDER BY created_at DESC LIMIT ?") : this.db.prepare("SELECT memory_id, embedding_json FROM memory_embeddings WHERE created_at > ? ORDER BY created_at DESC LIMIT ?");
1227
+ rows = cutoffMs === void 0 ? stmt.all(limit) : stmt.all(cutoffMs, limit);
1228
+ }
1229
+ if (rows.length === 0) return [];
1230
+ const scored = [];
1231
+ for (const row of rows) try {
1232
+ const vec = new Float32Array(JSON.parse(row.embedding_json));
1233
+ const similarity = this.embeddingEngine?.cosineSimilarity(queryEmbedding, vec) ?? 0;
1234
+ if (similarity > 0) scored.push({
1235
+ id: row.memory_id,
1236
+ score: similarity
1237
+ });
1238
+ } catch {}
1239
+ scored.sort((a, b) => b.score - a.score);
1240
+ const topScored = scored.slice(0, limit);
1241
+ const results = [];
1242
+ for (const { id, score } of topScored) {
1243
+ const row = this.db.prepare("SELECT * FROM memos WHERE id = ?").get(id);
1244
+ if (row !== void 0) results.push({
1245
+ memo: rowToMemo(row),
1246
+ score
1247
+ });
1248
+ }
1249
+ return results;
1250
+ }
1251
+ /**
1252
+ * Schedule async embedding generation for a memo. Debounced — the actual
1253
+ * batch flush runs after a short quiet period. Never blocks the caller.
1254
+ */
1255
+ scheduleEmbedding(memo) {
1256
+ this.embeddingQueue.add(memo.id);
1257
+ if (this.embeddingEngine === void 0) return;
1258
+ if (!this.embeddingEngine.available) {
1259
+ this.embeddingEngine.ensureReady().then((ready) => {
1260
+ if (ready && this.embeddingQueue.size > 0 && this.embeddingTimer === void 0) this.embeddingTimer = setTimeout(() => {
1261
+ this.flushEmbeddings();
1262
+ }, 2e3);
1263
+ }).catch(() => {});
1264
+ return;
1265
+ }
1266
+ if (this.embeddingTimer !== void 0) clearTimeout(this.embeddingTimer);
1267
+ this.embeddingTimer = setTimeout(() => {
1268
+ this.flushEmbeddings();
1269
+ }, 2e3);
1270
+ }
1271
+ /**
1272
+ * Flush queued embedding generation. Retries once on failure to tolerate
1273
+ * transient model-load contention, then marks embeddings as degraded and
1274
+ * logs the problem. Callers can still retrieve memos through keyword search.
1275
+ */
1276
+ async flushEmbeddings() {
1277
+ if (this.embeddingFlushing || this.embeddingEngine === void 0 || !this.embeddingEngine.available) return;
1278
+ this.embeddingFlushing = true;
1279
+ const ids = [...this.embeddingQueue];
1280
+ this.embeddingQueue.clear();
1281
+ const reenqueue = () => {
1282
+ for (const id of ids) this.embeddingQueue.add(id);
1283
+ };
1284
+ try {
1285
+ await this.init();
1286
+ if (this.db === void 0) return;
1287
+ const pending = [];
1288
+ for (const id of ids) {
1289
+ if (this.db.prepare("SELECT id FROM memory_embeddings WHERE memory_id = ?").get(id) !== void 0) continue;
1290
+ const memo = await this.get(id);
1291
+ if (memo !== void 0) pending.push({
1292
+ id,
1293
+ text: buildEmbeddingText(memo)
1294
+ });
1295
+ }
1296
+ if (pending.length === 0) return;
1297
+ const vectors = await this.tryEmbedBatch(pending.map((p) => p.text));
1298
+ if (vectors === null || vectors.length !== pending.length) {
1299
+ this.markEmbeddingFailure(/* @__PURE__ */ new Error(vectors === null ? "embedBatch returned null" : "embedding count mismatch"));
1300
+ reenqueue();
1301
+ return;
1302
+ }
1303
+ this.clearEmbeddingFailure();
1304
+ const insert = this.db.prepare("INSERT OR REPLACE INTO memory_embeddings (memory_id, embedding_json, model, created_at) VALUES (?, ?, ?, ?)");
1305
+ const now = Date.now();
1306
+ this.db.exec("BEGIN TRANSACTION");
1307
+ try {
1308
+ for (let i = 0; i < pending.length; i++) insert.run(pending[i].id, JSON.stringify([...vectors[i]]), EMBEDDING_MODEL_NAME, now);
1309
+ this.db.exec("COMMIT");
1310
+ } catch (error) {
1311
+ this.db.exec("ROLLBACK");
1312
+ this.markEmbeddingFailure(error instanceof Error ? error : new Error(String(error)));
1313
+ reenqueue();
1314
+ }
1315
+ } catch (error) {
1316
+ this.markEmbeddingFailure(error instanceof Error ? error : new Error(String(error)));
1317
+ reenqueue();
1318
+ } finally {
1319
+ this.embeddingFlushing = false;
1320
+ this.embeddingTimer = void 0;
1321
+ }
1322
+ }
1323
+ async tryEmbedBatch(texts) {
1324
+ if (this.embeddingEngine === void 0) return null;
1325
+ try {
1326
+ const first = await this.embeddingEngine.embedBatch(texts);
1327
+ if (first !== null) return first;
1328
+ } catch {}
1329
+ try {
1330
+ return await this.embeddingEngine.embedBatch(texts);
1331
+ } catch {
1332
+ return null;
1333
+ }
1334
+ }
1335
+ markEmbeddingFailure(error) {
1336
+ this.embeddingDegraded = true;
1337
+ this.embeddingConsecutiveFailures += 1;
1338
+ this.lastEmbeddingError = error;
1339
+ this.log.warn?.("embedding flush failed", {
1340
+ error: error.message,
1341
+ consecutiveFailures: this.embeddingConsecutiveFailures
1342
+ });
1343
+ }
1344
+ clearEmbeddingFailure() {
1345
+ this.embeddingDegraded = false;
1346
+ this.embeddingConsecutiveFailures = 0;
1347
+ this.lastEmbeddingError = void 0;
1348
+ }
1349
+ /**
1350
+ * Runtime health of the embedding subsystem. When `degraded` is true, the
1351
+ * store still serves keyword search; only vector similarity is unavailable.
1352
+ */
1353
+ embeddingStatus() {
1354
+ return {
1355
+ available: this.embeddingEngine !== void 0 && this.embeddingEngine.available,
1356
+ degraded: this.embeddingDegraded,
1357
+ consecutiveFailures: this.embeddingConsecutiveFailures,
1358
+ lastError: this.lastEmbeddingError?.message
1359
+ };
1360
+ }
1361
+ listAll(limit, offset, projectDir) {
1362
+ if (this.db === void 0) return {
1363
+ rows: [],
1364
+ total: 0
1365
+ };
1366
+ const countStmt = projectDir === void 0 ? this.db.prepare("SELECT COUNT(*) as total FROM memos") : this.db.prepare("SELECT COUNT(*) as total FROM memos WHERE project_dir = ? OR project_dir = ''");
1367
+ const total = (projectDir === void 0 ? countStmt.get() : countStmt.get(projectDir))?.total ?? 0;
1368
+ const stmt = projectDir === void 0 ? this.db.prepare("SELECT * FROM memos ORDER BY recorded_at DESC LIMIT ? OFFSET ?") : this.db.prepare("SELECT * FROM memos WHERE project_dir = ? OR project_dir = '' ORDER BY recorded_at DESC LIMIT ? OFFSET ?");
1369
+ return {
1370
+ rows: (projectDir === void 0 ? stmt.all(limit, offset) : stmt.all(projectDir, limit, offset)).map(rowToMemo),
1371
+ total
1372
+ };
1373
+ }
1374
+ async ensureDir() {
1375
+ await mkdir(dirname$1(this.dbPath), { recursive: true });
1376
+ }
1377
+ async withWriteLock(fn) {
1378
+ const next = this.writeLock.then(fn, fn);
1379
+ this.writeLock = next;
1380
+ return next;
1381
+ }
1382
+ };
1383
+ function rowToMemo(row) {
1384
+ const sourceSessionTitle = row["source_session_title"];
1385
+ const projectDir = row["project_dir"];
1386
+ return {
1387
+ id: String(row["id"]),
1388
+ sourceSessionId: String(row["source_session_id"]),
1389
+ sourceSessionTitle: typeof sourceSessionTitle === "string" ? sourceSessionTitle : void 0,
1390
+ userNeed: String(row["user_need"]),
1391
+ approach: String(row["approach"]),
1392
+ outcome: String(row["outcome"]),
1393
+ whatFailed: String(row["what_failed"]),
1394
+ whatWorked: String(row["what_worked"]),
1395
+ extractionSource: row["extraction_source"],
1396
+ recordedAt: Number(row["recorded_at"]),
1397
+ projectDir: typeof projectDir === "string" ? projectDir : "",
1398
+ tags: parseTags(row["tags"])
1399
+ };
1400
+ }
1401
+ function parseTags(value) {
1402
+ if (typeof value !== "string" || value.length === 0) return void 0;
1403
+ try {
1404
+ const parsed = JSON.parse(value);
1405
+ if (!Array.isArray(parsed)) return void 0;
1406
+ const tags = parsed.filter((t) => typeof t === "string");
1407
+ return tags.length > 0 ? tags : void 0;
1408
+ } catch {
1409
+ return;
1410
+ }
1411
+ }
1412
+ function memoMatchesSearch(memo, search) {
1413
+ return [
1414
+ memo.userNeed,
1415
+ memo.approach,
1416
+ memo.whatFailed,
1417
+ memo.whatWorked,
1418
+ memo.sourceSessionTitle ?? "",
1419
+ ...memo.tags ?? []
1420
+ ].join(" ").toLowerCase().replaceAll(/\s+/g, "").includes(search.replaceAll(/\s+/g, ""));
1421
+ }
1422
+ /**
1423
+ * Tokenize text so FTS5's unicode61 tokenizer can index mixed CJK/ASCII text.
1424
+ * CJK characters are split into individual characters separated by spaces, and
1425
+ * CJK/ASCII boundaries are also separated so "使用redis缓存" becomes searchable
1426
+ * by "redis" as well as by individual CJK characters.
1427
+ */
1428
+ function toFtsText$1(text) {
1429
+ const parts = text.toLowerCase().replaceAll(/([一-鿿㐀-䶿])([a-z0-9])/g, "$1 $2").replaceAll(/([a-z0-9])([一-鿿㐀-䶿])/g, "$1 $2").split(/[^a-z0-9一-鿿㐀-䶿]+/);
1430
+ const tokens = [];
1431
+ for (const part of parts) {
1432
+ if (part.length === 0) continue;
1433
+ if (/^[a-z0-9]+$/.test(part)) tokens.push(part);
1434
+ else for (const ch of part) if (ch.length > 0) tokens.push(ch);
1435
+ }
1436
+ return tokens.join(" ");
1437
+ }
1438
+ function buildFtsQuery$1(search) {
1439
+ const tokens = toFtsText$1(search).split(/\s+/).filter((t) => t.length > 0);
1440
+ if (tokens.length === 0) return void 0;
1441
+ return tokens.map((t) => `"${t.replaceAll("\"", "\"\"")}"`).join(" AND ");
1442
+ }
1443
+ //#endregion
1444
+ //#region ../../packages/memory/src/extractor.ts
1445
+ /**
1446
+ * Parse memory-memo blocks from LLM compaction output.
1447
+ */
1448
+ function parseMemoryMemos(text) {
1449
+ const memos = [];
1450
+ const regex = /```memory-memo[\s\S]*?\n([\s\S]*?)```/g;
1451
+ let match;
1452
+ while ((match = regex.exec(text)) !== null) {
1453
+ const jsonStr = match[1]?.trim();
1454
+ if (!jsonStr) continue;
1455
+ try {
1456
+ const parsed = JSON.parse(jsonStr);
1457
+ if (parsed["none"] === true) continue;
1458
+ const userNeed = typeof parsed["userNeed"] === "string" ? parsed["userNeed"].trim() : "";
1459
+ if (userNeed.length === 0) continue;
1460
+ const rawTags = parsed["tags"];
1461
+ const tags = Array.isArray(rawTags) ? normalizeTags(rawTags) : void 0;
1462
+ const note = typeof parsed["note"] === "string" ? parsed["note"].trim() : void 0;
1463
+ memos.push(createMemoryMemo({
1464
+ userNeed,
1465
+ approach: typeof parsed["approach"] === "string" ? parsed["approach"].trim() : "",
1466
+ outcome: typeof parsed["outcome"] === "string" ? parsed["outcome"].trim() : "",
1467
+ whatFailed: typeof parsed["whatFailed"] === "string" ? parsed["whatFailed"].trim() : "none",
1468
+ whatWorked: typeof parsed["whatWorked"] === "string" ? parsed["whatWorked"].trim() : "none",
1469
+ tags,
1470
+ note: note && note.length > 0 ? note : void 0,
1471
+ extractionSource: "compaction",
1472
+ sourceSessionId: "",
1473
+ sourceSessionTitle: ""
1474
+ }));
1475
+ } catch {}
1476
+ }
1477
+ return memos;
1478
+ }
1479
+ /**
1480
+ * Strip injected memory-memo content from text before writing it back to the
1481
+ * store, preventing feedback loops where recalled/compacted content gets
1482
+ * re-stored. Removes fenced ```memory-memo blocks (the format emitted by
1483
+ * compaction and exit-time extraction) and any <memories>...</memories>
1484
+ * wrapper an injector might use.
1485
+ */
1486
+ function stripMemoryTags(text) {
1487
+ return text.replaceAll(/```memory-memo[\s\S]*?```/gi, "").replaceAll(/<memories>[\s\S]*?<\/memories>/gi, "").trim();
1488
+ }
1489
+ /** System prompt for exit-time extraction — instructs the LLM how to extract. */
1490
+ const EXIT_EXTRACTION_SYSTEM_PROMPT = "你是一个任务经验提取助手。任务是从对话记录中识别已完成的任务闭环,提炼出任务经验记录。用对话的主要语言输出(中文对话用中文,英文对话用英文)。只输出指定的 JSON 格式,不要调用任何工具。";
1491
+ /** Build the user prompt for exit-time extraction, including a conversation sample. */
1492
+ function buildExitExtractionPrompt(sessionId, messageCount, sampleText) {
1493
+ return `以下是会话 "${sessionId}"(共 ${messageCount} 条消息)的对话记录。请提取其中所有**已完成的任务闭环**:
1494
+
1495
+ 判断标准:
1496
+ - 用户提出了明确的需求或问题
1497
+ - 给出了解决方案或回答
1498
+ - 结果明确(成功、部分完成、失败)
1499
+
1500
+ 对每个已完成的任务闭环,输出一个结构化经验记录。**必须用对话的主要语言书写**:
1501
+
1502
+ \`\`\`memory-memo
1503
+ {
1504
+ "userNeed": "<用户需求/目标,一句话概括>",
1505
+ "approach": "<执行方案,做了什么,2-4 句话>",
1506
+ "outcome": "<最终结果,如'完成'、'部分完成'、'失败:原因'>",
1507
+ "whatFailed": "<踩坑记录:试了但不行的路,无则填 'none'>",
1508
+ "whatWorked": "<成功经验:最终奏效的关键动作,无则填 'none'>",
1509
+ "tags": ["<标签1>", "<标签2>", "<标签3>"],
1510
+ "note": "<可选:给 AI 的补充说明/建议——让未来理解这条经验更容易,如'这类任务先查接口可用性再写代码'。无则省略>"
1511
+ }
1512
+ \`\`\`
1513
+
1514
+ 注意:
1515
+ - tags 是 3-5 个语义标签,概括任务领域/技术栈/动作类型,例如 ["react", "auth", "部署"]
1516
+ - whatFailed 记录重要的错误尝试,帮助未来避免重蹈覆辙
1517
+ - whatWorked 记录最终成功的关键动作,帮助未来复用经验
1518
+ - note 是可选字段,写一句能让未来 AI 更快理解/复用这条经验的说明或建议(软性提示,非强制规则)
1519
+ - 跳过未完成的工作,除非其中包含有价值的踩坑经验
1520
+ - 将紧密相关的子任务合并为一条记录
1521
+ - 严格遵守字段名和 JSON 格式(除 note 外不要添加额外字段)
1522
+
1523
+ 如果没有已完成的任务闭环,输出:
1524
+ \`\`\`memory-memo
1525
+ {"none": true}
1526
+ \`\`\`
1527
+
1528
+ --- 对话记录(最近 30 条消息)---
1529
+
1530
+ ${sampleText}
1531
+
1532
+ --- 对话记录结束 ---`;
1533
+ }
1534
+ //#endregion
1535
+ //#region ../../packages/memory/src/consolidator.ts
1536
+ const SIMILARITY_THRESHOLD = .45;
1537
+ const STALE_DAYS = 30;
1538
+ /**
1539
+ * Analyze all memos and produce a consolidation plan.
1540
+ *
1541
+ * Pure logic — no LLM call. Uses keyword similarity to find near-duplicate
1542
+ * memos, flags resolved/stale entries.
1543
+ */
1544
+ async function buildConsolidationPlan(store, options) {
1545
+ const allMemos = [];
1546
+ for await (const memo of store.read(options)) allMemos.push(memo);
1547
+ const summaries = allMemos.map(toSummary);
1548
+ const duplicateGroups = findDuplicateGroups(summaries);
1549
+ const relatedGroups = findRelatedGroups(summaries, duplicateGroups);
1550
+ const resolved = findResolved(summaries);
1551
+ const stale = findStale(summaries, STALE_DAYS);
1552
+ const dedupedCount = duplicateGroups.reduce((acc, g) => acc + g.memos.length - 1, 0);
1553
+ return {
1554
+ duplicateGroups,
1555
+ relatedGroups,
1556
+ resolved,
1557
+ stale,
1558
+ summary: {
1559
+ totalMemos: allMemos.length,
1560
+ duplicatesFound: dedupedCount,
1561
+ relatedGroupsFound: relatedGroups.length,
1562
+ resolvedFound: resolved.length,
1563
+ staleFound: stale.length,
1564
+ memosAfterConsolidation: allMemos.length - dedupedCount - resolved.length - stale.length
1565
+ }
1566
+ };
1567
+ }
1568
+ /**
1569
+ * Apply a consolidation plan: delete duplicates, resolved, and stale memos,
1570
+ * appending merged replacements for duplicates.
1571
+ */
1572
+ async function applyConsolidation(store, plan) {
1573
+ let deleted = 0;
1574
+ let created = 0;
1575
+ if (plan.resolved.length > 0) {
1576
+ const worked = plan.resolved.filter((m) => m.whatWorked).map((m) => m.whatWorked).join("; ");
1577
+ const failed = plan.resolved.filter((m) => m.whatFailed).map((m) => m.whatFailed).join("; ");
1578
+ if (worked || failed) {
1579
+ const archive = createMemoryMemo({
1580
+ sourceSessionId: plan.resolved[0].sourceSessionId,
1581
+ userNeed: "已解决任务的经验归档(dream 整理)",
1582
+ approach: "多次尝试后解决",
1583
+ outcome: "已完成",
1584
+ whatWorked: worked || "none",
1585
+ whatFailed: failed || "none",
1586
+ tags: normalizeTags(plan.resolved.flatMap((m) => m.tags ?? [])),
1587
+ projectDir: plan.resolved[0].projectDir,
1588
+ extractionSource: "compaction"
1589
+ });
1590
+ await store.append(archive);
1591
+ created++;
1592
+ }
1593
+ }
1594
+ if (plan.stale.length > 0) {
1595
+ const worked = plan.stale.filter((m) => m.whatWorked).map((m) => m.whatWorked).join("; ");
1596
+ const failed = plan.stale.filter((m) => m.whatFailed).map((m) => m.whatFailed).join("; ");
1597
+ if (worked || failed) {
1598
+ const archive = createMemoryMemo({
1599
+ sourceSessionId: plan.stale[0].sourceSessionId,
1600
+ userNeed: "过期记忆的经验归档(dream 整理)",
1601
+ approach: "距今超过30天未使用",
1602
+ outcome: "已归档",
1603
+ whatWorked: worked || "none",
1604
+ whatFailed: failed || "none",
1605
+ tags: normalizeTags(plan.stale.flatMap((m) => m.tags ?? [])),
1606
+ projectDir: plan.stale[0].projectDir,
1607
+ extractionSource: "compaction"
1608
+ });
1609
+ await store.append(archive);
1610
+ created++;
1611
+ }
1612
+ }
1613
+ for (const group of plan.duplicateGroups) {
1614
+ const newest = group.memos.reduce((a, b) => a.recordedAt > b.recordedAt ? a : b);
1615
+ const mergedTags = normalizeTags(group.memos.flatMap((m) => m.tags ?? []));
1616
+ const merged = createMemoryMemo({
1617
+ sourceSessionId: newest.sourceSessionId,
1618
+ sourceSessionTitle: newest.sourceSessionTitle,
1619
+ userNeed: group.merged.userNeed,
1620
+ approach: group.merged.approach,
1621
+ outcome: group.merged.outcome,
1622
+ whatFailed: group.merged.whatFailed,
1623
+ whatWorked: group.merged.whatWorked,
1624
+ tags: group.merged.tags ?? mergedTags,
1625
+ projectDir: newest.projectDir,
1626
+ extractionSource: "compaction"
1627
+ });
1628
+ await store.append(merged);
1629
+ created++;
1630
+ }
1631
+ for (const group of plan.duplicateGroups) for (const memo of group.memos) {
1632
+ await store.delete(memo.id);
1633
+ deleted++;
1634
+ }
1635
+ for (const memo of plan.resolved) {
1636
+ await store.delete(memo.id);
1637
+ deleted++;
1638
+ }
1639
+ for (const memo of plan.stale) {
1640
+ await store.delete(memo.id);
1641
+ deleted++;
1642
+ }
1643
+ return {
1644
+ deleted,
1645
+ created
1646
+ };
1647
+ }
1648
+ function findDuplicateGroups(memos) {
1649
+ const groups = [];
1650
+ const used = /* @__PURE__ */ new Set();
1651
+ for (let i = 0; i < memos.length; i++) {
1652
+ const first = memos[i];
1653
+ if (!first || used.has(first.id)) continue;
1654
+ const cluster = [first];
1655
+ for (let j = i + 1; j < memos.length; j++) {
1656
+ const candidate = memos[j];
1657
+ if (!candidate || used.has(candidate.id)) continue;
1658
+ if (cluster.some((m) => {
1659
+ return computeKeywordSimilarity(candidate, `${m.userNeed} ${m.approach}`) >= SIMILARITY_THRESHOLD;
1660
+ })) cluster.push(candidate);
1661
+ }
1662
+ if (cluster.length > 1) {
1663
+ for (const m of cluster) used.add(m.id);
1664
+ groups.push(buildDuplicateGroup(cluster));
1665
+ }
1666
+ }
1667
+ return groups;
1668
+ }
1669
+ /**
1670
+ * Split a whatFailed / whatWorked field into individual claims.
1671
+ * Handles both `;` and `;` separators.
1672
+ */
1673
+ function splitClaims(text) {
1674
+ if (!text || text === "none" || text === "无") return [];
1675
+ return text.split(/[;;]/).map((s) => s.trim()).filter((s) => s.length > 0);
1676
+ }
1677
+ /**
1678
+ * Extract significant words for contradiction detection:
1679
+ * ASCII words >= 3 chars + CJK 2-grams.
1680
+ */
1681
+ function extractSignificantWords(text) {
1682
+ const words = [];
1683
+ const lower = text.toLowerCase();
1684
+ for (const m of lower.matchAll(/[a-z0-9]+/g)) if (m[0].length >= 3) words.push(m[0]);
1685
+ for (const m of lower.matchAll(/[一-鿿]+/g)) {
1686
+ const run = m[0];
1687
+ for (let i = 0; i < run.length - 1; i++) words.push(run.slice(i, i + 2));
1688
+ }
1689
+ return words;
1690
+ }
1691
+ /**
1692
+ * Check whether `claim` overlaps with any claim in `against`.
1693
+ * 2+ shared significant words = overlap; 1 word is enough for single-word claims.
1694
+ */
1695
+ function claimsOverlap(claim, against) {
1696
+ const words = extractSignificantWords(claim);
1697
+ if (words.length === 0) return false;
1698
+ for (const other of against) {
1699
+ const otherLower = other.toLowerCase();
1700
+ const matched = words.filter((w) => otherLower.includes(w)).length;
1701
+ if (matched >= 2 || matched >= 1 && words.length === 1) return true;
1702
+ }
1703
+ return false;
1704
+ }
1705
+ function buildDuplicateGroup(cluster) {
1706
+ const sorted = [...cluster].toSorted((a, b) => b.recordedAt - a.recordedAt);
1707
+ const newest = sorted[0];
1708
+ const mid = Math.ceil(sorted.length / 2);
1709
+ const newer = sorted.slice(0, mid);
1710
+ const newerFailedClaims = new Set(newer.flatMap((m) => splitClaims(m.whatFailed)));
1711
+ const newerWorkedClaims = new Set(newer.flatMap((m) => splitClaims(m.whatWorked)));
1712
+ const failures = /* @__PURE__ */ new Set();
1713
+ const successes = /* @__PURE__ */ new Set();
1714
+ for (const memo of cluster) {
1715
+ for (const claim of splitClaims(memo.whatFailed)) if (!claimsOverlap(claim, newerWorkedClaims)) failures.add(claim);
1716
+ for (const claim of splitClaims(memo.whatWorked)) if (!claimsOverlap(claim, newerFailedClaims)) successes.add(claim);
1717
+ }
1718
+ const bestOutcome = cluster.map((m) => m.outcome).some((o) => o.includes("完成") || o.toLowerCase().includes("done")) ? "完成" : newest.outcome;
1719
+ return {
1720
+ memos: cluster,
1721
+ merged: {
1722
+ userNeed: newest.userNeed,
1723
+ approach: `合并 ${cluster.length} 条相关记录。最新方案: ${newest.approach}`,
1724
+ outcome: bestOutcome,
1725
+ whatFailed: failures.size > 0 ? [...failures].join("; ") : "none",
1726
+ whatWorked: successes.size > 0 ? [...successes].join("; ") : "none"
1727
+ },
1728
+ reason: `发现 ${cluster.length} 条相似记录(关键词重叠 > ${Math.round(SIMILARITY_THRESHOLD * 100)}%)`
1729
+ };
1730
+ }
1731
+ /**
1732
+ * Extract topic anchors from memo text.
1733
+ *
1734
+ * Compound identifiers like `sample-project` are strong signals and kept whole.
1735
+ * ASCII words >= 3 chars are kept when not stopwords. CJK runs are tokenized
1736
+ * into 2-grams so that pure-Chinese themes like "用户认证" can still form
1737
+ * groups without relying on noisy single-character Jaccard overlap.
1738
+ */
1739
+ function extractTopicAnchors(text) {
1740
+ const lower = text.toLowerCase();
1741
+ const anchors = /* @__PURE__ */ new Set();
1742
+ for (const match of lower.matchAll(/[a-z0-9]+[-_][a-z0-9]+(?:[-_][a-z0-9]+)*/g)) {
1743
+ const token = match[0];
1744
+ if (token.length >= 5) anchors.add(token);
1745
+ }
1746
+ for (const match of lower.matchAll(/[a-z0-9]+/g)) {
1747
+ const token = match[0];
1748
+ if (token.length >= 3 && !STOP_WORDS.has(token)) anchors.add(token);
1749
+ }
1750
+ for (const match of lower.matchAll(/[一-鿿㐀-䶿]+/g)) {
1751
+ const run = match[0];
1752
+ for (let i = 0; i < run.length - 1; i++) anchors.add(run.slice(i, i + 2));
1753
+ }
1754
+ return [...anchors];
1755
+ }
1756
+ function findRelatedGroups(memos, duplicateGroups) {
1757
+ const used = /* @__PURE__ */ new Set();
1758
+ for (const group of duplicateGroups) for (const memo of group.memos) used.add(memo.id);
1759
+ const available = memos.filter((m) => !used.has(m.id));
1760
+ const anchorIndex = /* @__PURE__ */ new Map();
1761
+ for (const memo of available) {
1762
+ const anchors = extractTopicAnchors(`${memo.userNeed} ${memo.approach} ${memo.whatFailed} ${memo.whatWorked}`);
1763
+ for (const anchor of anchors) {
1764
+ const list = anchorIndex.get(anchor) ?? [];
1765
+ if (!list.some((m) => m.id === memo.id)) list.push(memo);
1766
+ anchorIndex.set(anchor, list);
1767
+ }
1768
+ }
1769
+ const groups = [];
1770
+ const assigned = /* @__PURE__ */ new Set();
1771
+ const sortedAnchors = [...anchorIndex.entries()].filter(([, list]) => list.length >= 2).toSorted((a, b) => b[1].length - a[1].length);
1772
+ for (const [anchor, candidates] of sortedAnchors) {
1773
+ const groupMemos = candidates.filter((m) => !assigned.has(m.id));
1774
+ if (groupMemos.length >= 2) {
1775
+ groups.push({
1776
+ memos: groupMemos,
1777
+ topic: anchor,
1778
+ reason: `发现 ${groupMemos.length} 条围绕 ${anchor} 的记录`
1779
+ });
1780
+ for (const m of groupMemos) assigned.add(m.id);
1781
+ }
1782
+ }
1783
+ return groups;
1784
+ }
1785
+ function isOutcomeCompleted(outcome) {
1786
+ const lower = outcome.toLowerCase();
1787
+ return lower.includes("完成") || lower.includes("done") || lower.includes("completed") || lower.includes("成功") || lower.includes("success");
1788
+ }
1789
+ function findResolved(memos) {
1790
+ return memos.filter((m) => isOutcomeCompleted(m.outcome) && Date.now() - m.recordedAt > 10080 * 60 * 1e3);
1791
+ }
1792
+ function findStale(memos, staleDays) {
1793
+ const threshold = Date.now() - staleDays * 24 * 60 * 60 * 1e3;
1794
+ return memos.filter((m) => m.recordedAt < threshold && !isOutcomeCompleted(m.outcome) && !m.outcome.includes("blocked"));
1795
+ }
1796
+ //#endregion
1797
+ //#region ../../packages/memory/src/dream.ts
1798
+ const LOCK_FILE = "dream-lock.json";
1799
+ const MIN_HOURS_BETWEEN_DREAMS = 24;
1800
+ const MIN_SESSIONS_BETWEEN_DREAMS = 5;
1801
+ let atomicWriteSequence = 0;
1802
+ /**
1803
+ * Write `data` to `path` through a temp file + rename, so a crash mid-write
1804
+ * leaves the previous state on disk instead of a truncated file. The temp file
1805
+ * sits next to the target, which keeps the rename inside one filesystem.
1806
+ *
1807
+ * The swap replaces the target inode rather than writing through it: a
1808
+ * rewritten file's permissions follow the process umask, and a symlink at the
1809
+ * target is replaced by a regular file. That is the same trade-off every other
1810
+ * state-file swap in this repository makes.
1811
+ *
1812
+ * Implemented here rather than imported because this package declares `pathe`
1813
+ * as its only dependency: the atomic-write helpers in `@scream-code/jian` and
1814
+ * `@scream-code/agent-core` are not reachable from it, and pulling either
1815
+ * package in for ten lines would widen the dependency graph.
1816
+ */
1817
+ async function writeFileAtomic(path, data) {
1818
+ const tmpPath = `${path}.${process.pid.toString(36)}.${(atomicWriteSequence++).toString(36)}.tmp`;
1819
+ try {
1820
+ await writeFile(tmpPath, data, "utf8");
1821
+ await rename(tmpPath, path);
1822
+ } catch (error) {
1823
+ await unlink(tmpPath).catch(() => {});
1824
+ throw error;
1825
+ }
1826
+ }
1827
+ /**
1828
+ * Tracks dream consolidation state and decides when to suggest running
1829
+ * another dream. Persisted to `<screamHomeDir>/dream-lock.json`.
1830
+ */
1831
+ var DreamTracker = class {
1832
+ state;
1833
+ lockPath;
1834
+ screamHomeDir;
1835
+ initialized = false;
1836
+ constructor(screamHomeDir) {
1837
+ this.screamHomeDir = screamHomeDir;
1838
+ this.lockPath = join$1(screamHomeDir, LOCK_FILE);
1839
+ this.state = {
1840
+ lastDreamAt: (/* @__PURE__ */ new Date()).toISOString(),
1841
+ sessionsSinceLastDream: 0
1842
+ };
1843
+ }
1844
+ /** Load persisted state (call once at startup). */
1845
+ async init() {
1846
+ if (this.initialized) return;
1847
+ this.initialized = true;
1848
+ try {
1849
+ const raw = await readFile(this.lockPath, "utf8");
1850
+ const parsed = JSON.parse(raw);
1851
+ if (parsed.version === 1 && parsed.state) {
1852
+ this.state = parsed.state;
1853
+ return;
1854
+ }
1855
+ } catch {}
1856
+ await this.migrateLegacyLockFiles();
1857
+ }
1858
+ /** Record that a dream completed successfully. */
1859
+ async recordDream() {
1860
+ this.state = {
1861
+ lastDreamAt: (/* @__PURE__ */ new Date()).toISOString(),
1862
+ sessionsSinceLastDream: 0
1863
+ };
1864
+ await this.persist();
1865
+ }
1866
+ /** Call on each new session to bump the session counter. */
1867
+ async recordNewSession() {
1868
+ if (!this.initialized) await this.init();
1869
+ this.state.sessionsSinceLastDream += 1;
1870
+ await this.persist();
1871
+ }
1872
+ /** Check whether it's time to suggest another dream. */
1873
+ shouldSuggest() {
1874
+ return (Date.now() - new Date(this.state.lastDreamAt).getTime()) / (1e3 * 60 * 60) >= MIN_HOURS_BETWEEN_DREAMS && this.state.sessionsSinceLastDream >= MIN_SESSIONS_BETWEEN_DREAMS;
1875
+ }
1876
+ /** Get a human-readable suggestion message when conditions are met. */
1877
+ getSuggestionMessage() {
1878
+ const hoursSince = (Date.now() - new Date(this.state.lastDreamAt).getTime()) / (1e3 * 60 * 60);
1879
+ return `距离上次记忆整理已过去 ${Math.floor(hoursSince / 24)} 天、${this.state.sessionsSinceLastDream} 个会话。建议运行 /dream 来合并重复记忆、清理过期条目、解决矛盾信息。`;
1880
+ }
1881
+ async persist() {
1882
+ const data = {
1883
+ version: 1,
1884
+ state: this.state
1885
+ };
1886
+ try {
1887
+ await mkdir(dirname$1(this.lockPath), { recursive: true });
1888
+ await writeFileAtomic(this.lockPath, JSON.stringify(data, null, 2));
1889
+ } catch {}
1890
+ }
1891
+ /**
1892
+ * One-time migration for legacy dream-lock.json locations:
1893
+ * - <screamHomeDir>/.scream-code/dream-lock.json (buggy double directory)
1894
+ * - <screamHomeDir>/sessions/<sessionKey>/.scream-code/dream-lock.json (old per-session)
1895
+ *
1896
+ * Picks the most recent state, writes it to the new global location, and
1897
+ * deletes the legacy files/directories.
1898
+ */
1899
+ async migrateLegacyLockFiles() {
1900
+ const candidates = [join$1(this.screamHomeDir, ".scream-code", LOCK_FILE)];
1901
+ const sessionsDir = join$1(this.screamHomeDir, "sessions");
1902
+ try {
1903
+ const entries = await readdir(sessionsDir, { withFileTypes: true });
1904
+ for (const entry of entries) if (entry.isDirectory()) candidates.push(join$1(sessionsDir, entry.name, ".scream-code", LOCK_FILE));
1905
+ } catch {}
1906
+ let bestState;
1907
+ for (const candidate of candidates) try {
1908
+ await stat(candidate);
1909
+ const raw = await readFile(candidate, "utf8");
1910
+ const parsed = JSON.parse(raw);
1911
+ if (parsed.version === 1 && parsed.state) {
1912
+ if (bestState === void 0 || new Date(parsed.state.lastDreamAt).getTime() > new Date(bestState.lastDreamAt).getTime()) bestState = parsed.state;
1913
+ }
1914
+ } catch {}
1915
+ if (bestState !== void 0) {
1916
+ this.state = bestState;
1917
+ await this.persist();
1918
+ }
1919
+ for (const candidate of candidates) try {
1920
+ await rm(candidate);
1921
+ await rmdir(dirname$1(candidate)).catch(() => {});
1922
+ } catch {}
1923
+ }
1924
+ };
1925
+ //#endregion
1926
+ //#region ../../packages/knowledge/src/store.ts
1927
+ const ENTITY_TYPES$1 = new Set([
1928
+ "person",
1929
+ "organization",
1930
+ "location",
1931
+ "time",
1932
+ "product",
1933
+ "metric",
1934
+ "action",
1935
+ "work",
1936
+ "group",
1937
+ "subject",
1938
+ "tags"
1939
+ ]);
1940
+ function generateId(prefix) {
1941
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1942
+ }
1943
+ function normalizeName(name) {
1944
+ return name.trim().toLowerCase().replaceAll(/\s+/g, " ");
1945
+ }
1946
+ /** Serialize a vector as a raw Float32 BLOB — half the text of JSON, no parse on scan. */
1947
+ function vectorToBlob(vec) {
1948
+ if (vec === null) return null;
1949
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
1950
+ }
1951
+ /**
1952
+ * Decode a stored vector: Float32 BLOB, or legacy JSON text. Both formats are
1953
+ * accepted forever so a half-migrated (or not-yet-migrated) library stays
1954
+ * correct — the migration only shrinks storage, it is not a correctness gate.
1955
+ */
1956
+ function blobToVector(value) {
1957
+ if (value === null || value === void 0) return null;
1958
+ if (typeof value === "string") try {
1959
+ const arr = JSON.parse(value);
1960
+ return new Float32Array(arr);
1961
+ } catch {
1962
+ return null;
1963
+ }
1964
+ if (!(value instanceof Uint8Array)) return null;
1965
+ if (value.byteLength % 4 !== 0) return null;
1966
+ const copy = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
1967
+ return new Float32Array(copy);
1968
+ }
1969
+ function isEntityType(value) {
1970
+ return ENTITY_TYPES$1.has(value);
1971
+ }
1972
+ /** Meta key recording which embedding model the stored vectors came from. */
1973
+ const EMBEDDING_MODEL_META_KEY = "embedding_model";
1974
+ var KnowledgeStore = class {
1975
+ dbPath;
1976
+ db;
1977
+ initialized = false;
1978
+ initPromise = null;
1979
+ embeddingEngine;
1980
+ constructor(projectDir) {
1981
+ this.dbPath = join$1(projectDir, "knowledge", "knowledge.db");
1982
+ }
1983
+ async init() {
1984
+ if (this.initPromise) return this.initPromise;
1985
+ this.initPromise = this._doInit().catch((error) => {
1986
+ this.initPromise = null;
1987
+ throw error;
1988
+ });
1989
+ return this.initPromise;
1990
+ }
1991
+ async _doInit() {
1992
+ if (this.initialized) return;
1993
+ await mkdir(dirname$1(this.dbPath), { recursive: true });
1994
+ this.db = new DatabaseSync(this.dbPath);
1995
+ this.db.exec("PRAGMA journal_mode = WAL;");
1996
+ this.db.exec("PRAGMA foreign_keys = ON;");
1997
+ this.db.exec("PRAGMA busy_timeout = 5000;");
1998
+ this.createSchema();
1999
+ this.migrateFtsTokenization();
2000
+ this.migrateVectorsToBlob();
2001
+ this.initialized = true;
2002
+ }
2003
+ /**
2004
+ * One-time migration: FTS rows written before toFtsText pre-tokenization
2005
+ * indexed raw text (CJK runs became single tokens and are unsearchable).
2006
+ * Rebuild all three FTS tables from the content tables with toFtsText.
2007
+ * Tracked via PRAGMA user_version so it runs exactly once.
2008
+ */
2009
+ migrateFtsTokenization() {
2010
+ if (this.db === void 0) return;
2011
+ if (this.db.prepare("PRAGMA user_version").get().user_version >= 1) return;
2012
+ this.db.exec("DELETE FROM knowledge_chunks_fts");
2013
+ this.db.exec("DELETE FROM knowledge_events_fts");
2014
+ this.db.exec("DELETE FROM knowledge_entities_fts");
2015
+ const chunks = this.db.prepare("SELECT rowid, heading, content FROM knowledge_chunks").all();
2016
+ const insChunk = this.db.prepare("INSERT INTO knowledge_chunks_fts (rowid, heading, content) VALUES (?, ?, ?)");
2017
+ for (const c of chunks) insChunk.run(c.rowid, toFtsText(c.heading ?? ""), toFtsText(c.content));
2018
+ const events = this.db.prepare("SELECT rowid, title, summary, content FROM knowledge_events").all();
2019
+ const insEvent = this.db.prepare("INSERT INTO knowledge_events_fts (rowid, title, summary, content) VALUES (?, ?, ?, ?)");
2020
+ for (const e of events) insEvent.run(e.rowid, toFtsText(e.title), toFtsText(e.summary ?? ""), toFtsText(e.content));
2021
+ const entities = this.db.prepare("SELECT rowid, name, description FROM knowledge_entities").all();
2022
+ const insEntity = this.db.prepare("INSERT INTO knowledge_entities_fts (rowid, name, description) VALUES (?, ?, ?)");
2023
+ for (const e of entities) insEntity.run(e.rowid, toFtsText(e.name), toFtsText(e.description ?? ""));
2024
+ this.db.exec("PRAGMA user_version = 1");
2025
+ }
2026
+ /**
2027
+ * One-time migration (user_version 2): rewrite legacy JSON vector text to
2028
+ * raw Float32 BLOBs. Reads accept both formats forever, so a half-migrated
2029
+ * or failed run stays correct — this only shrinks storage and removes
2030
+ * JSON.parse from the O(n) scan paths. typeof() = 'text' makes the pass
2031
+ * idempotent: converted rows come back as 'blob' and are skipped.
2032
+ */
2033
+ migrateVectorsToBlob() {
2034
+ if (this.db === void 0) return;
2035
+ if (this.db.prepare("PRAGMA user_version").get().user_version >= 2) return;
2036
+ const columns = [
2037
+ {
2038
+ table: "knowledge_chunks",
2039
+ column: "embedding_json"
2040
+ },
2041
+ {
2042
+ table: "knowledge_events",
2043
+ column: "title_embedding_json"
2044
+ },
2045
+ {
2046
+ table: "knowledge_events",
2047
+ column: "content_embedding_json"
2048
+ },
2049
+ {
2050
+ table: "knowledge_entities",
2051
+ column: "embedding_json"
2052
+ },
2053
+ {
2054
+ table: "knowledge_event_entities",
2055
+ column: "embedding_json"
2056
+ }
2057
+ ];
2058
+ this.db.exec("BEGIN");
2059
+ try {
2060
+ for (const { table, column } of columns) {
2061
+ const rows = this.db.prepare(`SELECT id AS pk, ${column} AS v FROM ${table}
2062
+ WHERE ${column} IS NOT NULL AND typeof(${column}) = 'text'`).all();
2063
+ if (rows.length === 0) continue;
2064
+ const update = this.db.prepare(`UPDATE ${table} SET ${column} = ? WHERE id = ?`);
2065
+ for (const row of rows) {
2066
+ const vec = blobToVector(row.v);
2067
+ if (vec === null) continue;
2068
+ update.run(vectorToBlob(vec), row.pk);
2069
+ }
2070
+ }
2071
+ this.db.exec("PRAGMA user_version = 2");
2072
+ this.db.exec("COMMIT");
2073
+ } catch (error) {
2074
+ this.db.exec("ROLLBACK");
2075
+ throw error;
2076
+ }
2077
+ }
2078
+ close() {
2079
+ if (this.db !== void 0) {
2080
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2081
+ this.db.close();
2082
+ this.db = void 0;
2083
+ }
2084
+ this.initialized = false;
2085
+ this.initPromise = null;
2086
+ }
2087
+ setEmbeddingEngine(engine) {
2088
+ this.embeddingEngine = engine;
2089
+ }
2090
+ getEmbeddingEngine() {
2091
+ return this.embeddingEngine;
2092
+ }
2093
+ /**
2094
+ * Begin a write transaction. Use commitTransaction / rollbackTransaction
2095
+ * to close it. Used by ingest to keep chunks/events/entities/edges atomic —
2096
+ * a mid-ingest failure leaves no partial rows.
2097
+ */
2098
+ beginTransaction() {
2099
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2100
+ this.db.exec("BEGIN");
2101
+ }
2102
+ commitTransaction() {
2103
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2104
+ this.db.exec("COMMIT");
2105
+ }
2106
+ rollbackTransaction() {
2107
+ if (this.db === void 0) return;
2108
+ try {
2109
+ this.db.exec("ROLLBACK");
2110
+ } catch {}
2111
+ }
2112
+ /**
2113
+ * Read a metadata value (e.g. the embedding model identity the library was
2114
+ * built with). Returns null when the key is absent.
2115
+ */
2116
+ async getMeta(key) {
2117
+ await this.init();
2118
+ if (this.db === void 0) return null;
2119
+ const row = this.db.prepare("SELECT value FROM knowledge_meta WHERE key = ?").get(key);
2120
+ return row === void 0 ? null : asString(row["value"]);
2121
+ }
2122
+ /** Write a metadata value (upsert). */
2123
+ async setMeta(key, value) {
2124
+ await this.init();
2125
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2126
+ this.db.prepare("INSERT INTO knowledge_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
2127
+ }
2128
+ createSchema() {
2129
+ if (this.db === void 0) return;
2130
+ this.db.exec(`
2131
+ CREATE TABLE IF NOT EXISTS knowledge_sources (
2132
+ id TEXT PRIMARY KEY,
2133
+ name TEXT NOT NULL,
2134
+ file_path TEXT,
2135
+ description TEXT,
2136
+ created_at INTEGER NOT NULL
2137
+ );
2138
+
2139
+ CREATE TABLE IF NOT EXISTS knowledge_documents (
2140
+ id TEXT PRIMARY KEY,
2141
+ source_id TEXT NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE,
2142
+ title TEXT NOT NULL,
2143
+ content TEXT,
2144
+ status TEXT NOT NULL DEFAULT 'pending',
2145
+ chunk_count INTEGER DEFAULT 0,
2146
+ created_at INTEGER NOT NULL
2147
+ );
2148
+
2149
+ CREATE INDEX IF NOT EXISTS idx_knowledge_documents_source ON knowledge_documents(source_id);
2150
+
2151
+ CREATE TABLE IF NOT EXISTS knowledge_chunks (
2152
+ id TEXT PRIMARY KEY,
2153
+ source_id TEXT NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE,
2154
+ document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
2155
+ rank INTEGER NOT NULL,
2156
+ heading TEXT,
2157
+ content TEXT NOT NULL,
2158
+ raw_content TEXT,
2159
+ embedding_json BLOB,
2160
+ created_at INTEGER NOT NULL
2161
+ );
2162
+
2163
+ CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_doc ON knowledge_chunks(document_id);
2164
+ CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_source ON knowledge_chunks(source_id);
2165
+
2166
+ CREATE TABLE IF NOT EXISTS knowledge_events (
2167
+ id TEXT PRIMARY KEY,
2168
+ source_id TEXT NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE,
2169
+ document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
2170
+ chunk_id TEXT NOT NULL REFERENCES knowledge_chunks(id) ON DELETE CASCADE,
2171
+ rank INTEGER NOT NULL,
2172
+ title TEXT NOT NULL,
2173
+ summary TEXT,
2174
+ content TEXT NOT NULL,
2175
+ category TEXT,
2176
+ keywords TEXT,
2177
+ title_embedding_json BLOB,
2178
+ content_embedding_json BLOB,
2179
+ created_at INTEGER NOT NULL
2180
+ );
2181
+
2182
+ CREATE INDEX IF NOT EXISTS idx_knowledge_events_chunk ON knowledge_events(chunk_id);
2183
+ CREATE INDEX IF NOT EXISTS idx_knowledge_events_doc ON knowledge_events(document_id);
2184
+ CREATE INDEX IF NOT EXISTS idx_knowledge_events_source ON knowledge_events(source_id);
2185
+
2186
+ CREATE TABLE IF NOT EXISTS knowledge_entities (
2187
+ id TEXT PRIMARY KEY,
2188
+ source_id TEXT NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE,
2189
+ type TEXT NOT NULL,
2190
+ name TEXT NOT NULL,
2191
+ normalized_name TEXT NOT NULL,
2192
+ description TEXT,
2193
+ embedding_json BLOB,
2194
+ created_at INTEGER NOT NULL,
2195
+ UNIQUE(source_id, type, normalized_name)
2196
+ );
2197
+
2198
+ CREATE INDEX IF NOT EXISTS idx_knowledge_entities_source ON knowledge_entities(source_id);
2199
+ CREATE INDEX IF NOT EXISTS idx_knowledge_entities_name ON knowledge_entities(normalized_name);
2200
+
2201
+ CREATE TABLE IF NOT EXISTS knowledge_event_entities (
2202
+ id TEXT PRIMARY KEY,
2203
+ event_id TEXT NOT NULL REFERENCES knowledge_events(id) ON DELETE CASCADE,
2204
+ entity_id TEXT NOT NULL REFERENCES knowledge_entities(id) ON DELETE CASCADE,
2205
+ weight REAL DEFAULT 1.0,
2206
+ description TEXT,
2207
+ embedding_json BLOB,
2208
+ UNIQUE(event_id, entity_id)
2209
+ );
2210
+
2211
+ CREATE INDEX IF NOT EXISTS idx_knowledge_event_entities_event ON knowledge_event_entities(event_id);
2212
+ CREATE INDEX IF NOT EXISTS idx_knowledge_event_entities_entity ON knowledge_event_entities(entity_id);
2213
+
2214
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_chunks_fts USING fts5(
2215
+ heading, content, content='knowledge_chunks', content_rowid='rowid'
2216
+ );
2217
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_events_fts USING fts5(
2218
+ title, summary, content, content='knowledge_events', content_rowid='rowid'
2219
+ );
2220
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_entities_fts USING fts5(
2221
+ name, description, content='knowledge_entities', content_rowid='rowid'
2222
+ );
2223
+
2224
+ CREATE TABLE IF NOT EXISTS knowledge_meta (
2225
+ key TEXT PRIMARY KEY,
2226
+ value TEXT NOT NULL
2227
+ );
2228
+ `);
2229
+ try {
2230
+ this.db.exec("ALTER TABLE knowledge_sources ADD COLUMN content_hash TEXT");
2231
+ } catch (error) {
2232
+ if (!(error instanceof Error ? error.message : String(error)).includes("duplicate column")) throw error;
2233
+ }
2234
+ }
2235
+ async createSource(params) {
2236
+ await this.init();
2237
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2238
+ const id = generateId("src");
2239
+ const createdAt = Date.now();
2240
+ this.db.prepare("INSERT INTO knowledge_sources (id, name, file_path, description, content_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(id, params.name, params.filePath ?? null, params.description ?? null, params.contentHash ?? null, createdAt);
2241
+ return {
2242
+ id,
2243
+ name: params.name,
2244
+ filePath: params.filePath ?? null,
2245
+ description: params.description ?? null,
2246
+ contentHash: params.contentHash ?? null,
2247
+ createdAt
2248
+ };
2249
+ }
2250
+ /** Backfill or refresh a source's content fingerprint (legacy rows / replacements). */
2251
+ async updateSourceContentHash(id, contentHash) {
2252
+ await this.init();
2253
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2254
+ this.db.prepare("UPDATE knowledge_sources SET content_hash = ? WHERE id = ?").run(contentHash, id);
2255
+ }
2256
+ async findSourceByFilePath(filePath) {
2257
+ await this.init();
2258
+ if (this.db === void 0) return void 0;
2259
+ const row = this.db.prepare("SELECT * FROM knowledge_sources WHERE file_path = ?").get(filePath);
2260
+ return row === void 0 ? void 0 : rowToSource(row);
2261
+ }
2262
+ async listSources() {
2263
+ await this.init();
2264
+ if (this.db === void 0) return [];
2265
+ return this.db.prepare("SELECT * FROM knowledge_sources ORDER BY created_at DESC").all().map(rowToSource);
2266
+ }
2267
+ async getSource(id) {
2268
+ await this.init();
2269
+ if (this.db === void 0) return void 0;
2270
+ const row = this.db.prepare("SELECT * FROM knowledge_sources WHERE id = ?").get(id);
2271
+ return row === void 0 ? void 0 : rowToSource(row);
2272
+ }
2273
+ async deleteSource(id) {
2274
+ await this.init();
2275
+ if (this.db === void 0) return false;
2276
+ const chunkRows = this.db.prepare("SELECT rowid, heading, content FROM knowledge_chunks WHERE source_id = ?").all(id);
2277
+ const eventRows = this.db.prepare("SELECT rowid, title, summary, content FROM knowledge_events WHERE source_id = ?").all(id);
2278
+ const entityRows = this.db.prepare("SELECT rowid, name, description FROM knowledge_entities WHERE source_id = ?").all(id);
2279
+ const result = this.db.prepare("DELETE FROM knowledge_sources WHERE id = ?").run(id);
2280
+ if (result.changes > 0) {
2281
+ const delChunk = this.db.prepare("INSERT INTO knowledge_chunks_fts (knowledge_chunks_fts, rowid, heading, content) VALUES ('delete', ?, ?, ?)");
2282
+ for (const row of chunkRows) delChunk.run(row.rowid, toFtsText(row.heading ?? ""), toFtsText(row.content));
2283
+ const delEvent = this.db.prepare("INSERT INTO knowledge_events_fts (knowledge_events_fts, rowid, title, summary, content) VALUES ('delete', ?, ?, ?, ?)");
2284
+ for (const row of eventRows) delEvent.run(row.rowid, toFtsText(row.title), toFtsText(row.summary ?? ""), toFtsText(row.content));
2285
+ const delEntity = this.db.prepare("INSERT INTO knowledge_entities_fts (knowledge_entities_fts, rowid, name, description) VALUES ('delete', ?, ?, ?)");
2286
+ for (const row of entityRows) delEntity.run(row.rowid, toFtsText(row.name), toFtsText(row.description ?? ""));
2287
+ }
2288
+ return result.changes > 0;
2289
+ }
2290
+ async createDocument(params) {
2291
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2292
+ const id = generateId("doc");
2293
+ const createdAt = Date.now();
2294
+ this.db.prepare("INSERT INTO knowledge_documents (id, source_id, title, content, status, chunk_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(id, params.sourceId, params.title, params.content ?? null, "pending", 0, createdAt);
2295
+ return {
2296
+ id,
2297
+ sourceId: params.sourceId,
2298
+ title: params.title,
2299
+ content: params.content ?? null,
2300
+ status: "pending",
2301
+ chunkCount: 0,
2302
+ createdAt
2303
+ };
2304
+ }
2305
+ async updateDocumentStatus(documentId, status, chunkCount) {
2306
+ if (this.db === void 0) return;
2307
+ if (chunkCount !== void 0) this.db.prepare("UPDATE knowledge_documents SET status = ?, chunk_count = ? WHERE id = ?").run(status, chunkCount, documentId);
2308
+ else this.db.prepare("UPDATE knowledge_documents SET status = ? WHERE id = ?").run(status, documentId);
2309
+ }
2310
+ async listDocuments() {
2311
+ await this.init();
2312
+ if (this.db === void 0) return [];
2313
+ return this.db.prepare(`SELECT d.*, s.name AS source_name
2314
+ FROM knowledge_documents d
2315
+ JOIN knowledge_sources s ON s.id = d.source_id
2316
+ ORDER BY d.created_at DESC`).all().map((row) => ({
2317
+ ...rowToDocument(row),
2318
+ sourceName: String(row["source_name"])
2319
+ }));
2320
+ }
2321
+ async getDocument(id) {
2322
+ await this.init();
2323
+ if (this.db === void 0) return void 0;
2324
+ const row = this.db.prepare("SELECT * FROM knowledge_documents WHERE id = ?").get(id);
2325
+ return row === void 0 ? void 0 : rowToDocument(row);
2326
+ }
2327
+ async insertChunk(params) {
2328
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2329
+ const id = generateId("chk");
2330
+ const createdAt = Date.now();
2331
+ const embeddingBlob = vectorToBlob(params.embedding);
2332
+ this.db.prepare(`INSERT INTO knowledge_chunks
2333
+ (id, source_id, document_id, rank, heading, content, raw_content, embedding_json, created_at)
2334
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.sourceId, params.documentId, params.rank, params.heading, params.content, params.rawContent, embeddingBlob, createdAt);
2335
+ this.db.prepare(`INSERT INTO knowledge_chunks_fts (rowid, heading, content)
2336
+ VALUES ((SELECT rowid FROM knowledge_chunks WHERE id = ?), ?, ?)`).run(id, toFtsText(params.heading ?? ""), toFtsText(params.content));
2337
+ return {
2338
+ id,
2339
+ sourceId: params.sourceId,
2340
+ documentId: params.documentId,
2341
+ rank: params.rank,
2342
+ heading: params.heading,
2343
+ content: params.content,
2344
+ rawContent: params.rawContent,
2345
+ embedding: params.embedding,
2346
+ createdAt
2347
+ };
2348
+ }
2349
+ async getChunk(id) {
2350
+ await this.init();
2351
+ if (this.db === void 0) return void 0;
2352
+ const row = this.db.prepare("SELECT * FROM knowledge_chunks WHERE id = ?").get(id);
2353
+ return row === void 0 ? void 0 : rowToChunk(row);
2354
+ }
2355
+ async listChunksByDocument(documentId) {
2356
+ await this.init();
2357
+ if (this.db === void 0) return [];
2358
+ return this.db.prepare("SELECT * FROM knowledge_chunks WHERE document_id = ? ORDER BY rank ASC").all(documentId).map(rowToChunk);
2359
+ }
2360
+ /** Load all chunk embeddings + ids for vector search. */
2361
+ async loadAllChunkEmbeddings() {
2362
+ await this.init();
2363
+ if (this.db === void 0) return [];
2364
+ const rows = this.db.prepare("SELECT id, embedding_json FROM knowledge_chunks WHERE embedding_json IS NOT NULL").all();
2365
+ const out = [];
2366
+ for (const row of rows) {
2367
+ const vec = blobToVector(row.embedding_json);
2368
+ if (vec !== null) out.push({
2369
+ id: row.id,
2370
+ embedding: vec
2371
+ });
2372
+ }
2373
+ return out;
2374
+ }
2375
+ async insertEvent(params) {
2376
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2377
+ const id = generateId("evt");
2378
+ const createdAt = Date.now();
2379
+ this.db.prepare(`INSERT INTO knowledge_events
2380
+ (id, source_id, document_id, chunk_id, rank, title, summary, content, category, keywords,
2381
+ title_embedding_json, content_embedding_json, created_at)
2382
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.sourceId, params.documentId, params.chunkId, params.rank, params.title, params.summary, params.content, params.category, JSON.stringify(params.keywords), vectorToBlob(params.titleEmbedding), vectorToBlob(params.contentEmbedding), createdAt);
2383
+ this.db.prepare(`INSERT INTO knowledge_events_fts (rowid, title, summary, content)
2384
+ VALUES ((SELECT rowid FROM knowledge_events WHERE id = ?), ?, ?, ?)`).run(id, toFtsText(params.title), toFtsText(params.summary ?? ""), toFtsText(params.content));
2385
+ return {
2386
+ id,
2387
+ sourceId: params.sourceId,
2388
+ documentId: params.documentId,
2389
+ chunkId: params.chunkId,
2390
+ rank: params.rank,
2391
+ title: params.title,
2392
+ summary: params.summary,
2393
+ content: params.content,
2394
+ category: params.category,
2395
+ keywords: params.keywords,
2396
+ titleEmbedding: params.titleEmbedding,
2397
+ contentEmbedding: params.contentEmbedding,
2398
+ createdAt
2399
+ };
2400
+ }
2401
+ async getEvent(id) {
2402
+ await this.init();
2403
+ if (this.db === void 0) return void 0;
2404
+ const row = this.db.prepare("SELECT * FROM knowledge_events WHERE id = ?").get(id);
2405
+ return row === void 0 ? void 0 : rowToEvent(row);
2406
+ }
2407
+ async listEventsByDocument(documentId) {
2408
+ await this.init();
2409
+ if (this.db === void 0) return [];
2410
+ return this.db.prepare("SELECT * FROM knowledge_events WHERE document_id = ? ORDER BY rank ASC").all(documentId).map(rowToEvent);
2411
+ }
2412
+ /** Find events by title vector similarity. */
2413
+ async findEventsByTitleVector(queryVec, options = {}) {
2414
+ await this.init();
2415
+ if (this.db === void 0) return [];
2416
+ const limit = options.limit ?? 20;
2417
+ const threshold = options.threshold ?? 0;
2418
+ const rows = this.db.prepare("SELECT id, title_embedding_json FROM knowledge_events WHERE title_embedding_json IS NOT NULL").all();
2419
+ const scored = [];
2420
+ for (const row of rows) {
2421
+ const vec = blobToVector(row.title_embedding_json);
2422
+ if (vec === null) continue;
2423
+ const score = this.embeddingEngine?.cosineSimilarity(queryVec, vec) ?? 0;
2424
+ if (score >= threshold) scored.push({
2425
+ id: row.id,
2426
+ score
2427
+ });
2428
+ }
2429
+ scored.sort((a, b) => b.score - a.score);
2430
+ const top = scored.slice(0, limit);
2431
+ const out = [];
2432
+ for (const item of top) {
2433
+ const event = await this.getEvent(item.id);
2434
+ if (event !== void 0) out.push({
2435
+ event,
2436
+ score: item.score
2437
+ });
2438
+ }
2439
+ return out;
2440
+ }
2441
+ async upsertEntity(params) {
2442
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2443
+ if (!isEntityType(params.type)) throw new Error(`invalid entity type: ${params.type}`);
2444
+ const normalizedName = normalizeName(params.name);
2445
+ const existing = this.db.prepare("SELECT * FROM knowledge_entities WHERE source_id = ? AND type = ? AND normalized_name = ?").get(params.sourceId, params.type, normalizedName);
2446
+ if (existing !== void 0) {
2447
+ const existingId = String(existing["id"]);
2448
+ let descriptionChanged = false;
2449
+ if (params.description !== null && existing["description"] === null) {
2450
+ this.db.prepare("UPDATE knowledge_entities SET description = ? WHERE id = ?").run(params.description, existingId);
2451
+ existing["description"] = params.description;
2452
+ descriptionChanged = true;
2453
+ }
2454
+ if (params.embedding !== null && existing["embedding_json"] === null) {
2455
+ this.db.prepare("UPDATE knowledge_entities SET embedding_json = ? WHERE id = ?").run(vectorToBlob(params.embedding), existingId);
2456
+ existing["embedding_json"] = vectorToBlob(params.embedding);
2457
+ }
2458
+ if (descriptionChanged) {
2459
+ const ftsRow = this.db.prepare("SELECT rowid, name, description FROM knowledge_entities WHERE id = ?").get(existingId);
2460
+ if (ftsRow !== void 0) {
2461
+ this.db.prepare("INSERT INTO knowledge_entities_fts (knowledge_entities_fts, rowid, name, description) VALUES ('delete', ?, ?, ?)").run(ftsRow.rowid, toFtsText(ftsRow.name), "");
2462
+ this.db.prepare(`INSERT INTO knowledge_entities_fts (rowid, name, description) VALUES (?, ?, ?)`).run(ftsRow.rowid, toFtsText(ftsRow.name), toFtsText(ftsRow.description ?? ""));
2463
+ }
2464
+ }
2465
+ return rowToEntity(existing);
2466
+ }
2467
+ const id = generateId("ent");
2468
+ const createdAt = Date.now();
2469
+ this.db.prepare(`INSERT INTO knowledge_entities
2470
+ (id, source_id, type, name, normalized_name, description, embedding_json, created_at)
2471
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.sourceId, params.type, params.name, normalizedName, params.description, vectorToBlob(params.embedding), createdAt);
2472
+ this.db.prepare(`INSERT INTO knowledge_entities_fts (rowid, name, description)
2473
+ VALUES ((SELECT rowid FROM knowledge_entities WHERE id = ?), ?, ?)`).run(id, toFtsText(params.name), toFtsText(params.description ?? ""));
2474
+ return {
2475
+ id,
2476
+ sourceId: params.sourceId,
2477
+ type: params.type,
2478
+ name: params.name,
2479
+ normalizedName,
2480
+ description: params.description,
2481
+ embedding: params.embedding,
2482
+ createdAt
2483
+ };
2484
+ }
2485
+ async findEntitiesByName(name, sourceId) {
2486
+ await this.init();
2487
+ if (this.db === void 0) return [];
2488
+ const normalized = normalizeName(name);
2489
+ return (sourceId === void 0 ? this.db.prepare("SELECT * FROM knowledge_entities WHERE normalized_name = ?").all(normalized) : this.db.prepare("SELECT * FROM knowledge_entities WHERE normalized_name = ? AND source_id = ?").all(normalized, sourceId)).map(rowToEntity);
2490
+ }
2491
+ /** Find entities by name vector similarity. */
2492
+ async findEntitiesByVector(queryVec, options = {}) {
2493
+ await this.init();
2494
+ if (this.db === void 0) return [];
2495
+ const limit = options.limit ?? 20;
2496
+ const threshold = options.threshold ?? 0;
2497
+ const rows = this.db.prepare("SELECT id, embedding_json FROM knowledge_entities WHERE embedding_json IS NOT NULL").all();
2498
+ const scored = [];
2499
+ for (const row of rows) {
2500
+ const vec = blobToVector(row.embedding_json);
2501
+ if (vec === null) continue;
2502
+ const score = this.embeddingEngine?.cosineSimilarity(queryVec, vec) ?? 0;
2503
+ if (score >= threshold) scored.push({
2504
+ id: row.id,
2505
+ score
2506
+ });
2507
+ }
2508
+ scored.sort((a, b) => b.score - a.score);
2509
+ const top = scored.slice(0, limit);
2510
+ const out = [];
2511
+ for (const item of top) {
2512
+ const row = this.db.prepare("SELECT * FROM knowledge_entities WHERE id = ?").get(item.id);
2513
+ if (row !== void 0) out.push({
2514
+ entity: rowToEntity(row),
2515
+ score: item.score
2516
+ });
2517
+ }
2518
+ return out;
2519
+ }
2520
+ async insertEventEntity(params) {
2521
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2522
+ const id = generateId("ee");
2523
+ this.db.prepare(`INSERT OR IGNORE INTO knowledge_event_entities
2524
+ (id, event_id, entity_id, weight, description, embedding_json)
2525
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, params.eventId, params.entityId, params.weight ?? 1, params.description, vectorToBlob(params.embedding));
2526
+ return {
2527
+ id,
2528
+ eventId: params.eventId,
2529
+ entityId: params.entityId,
2530
+ weight: params.weight ?? 1,
2531
+ description: params.description,
2532
+ embedding: params.embedding
2533
+ };
2534
+ }
2535
+ async findEventsByEntity(entityId) {
2536
+ await this.init();
2537
+ if (this.db === void 0) return [];
2538
+ return this.db.prepare(`SELECT e.* FROM knowledge_events e
2539
+ JOIN knowledge_event_entities ee ON ee.event_id = e.id
2540
+ WHERE ee.entity_id = ?`).all(entityId).map(rowToEvent);
2541
+ }
2542
+ async findEntitiesByEvent(eventId) {
2543
+ await this.init();
2544
+ if (this.db === void 0) return [];
2545
+ return this.db.prepare(`SELECT en.* FROM knowledge_entities en
2546
+ JOIN knowledge_event_entities ee ON ee.entity_id = en.id
2547
+ WHERE ee.event_id = ?`).all(eventId).map(rowToEntity);
2548
+ }
2549
+ async listEntities(sourceId) {
2550
+ await this.init();
2551
+ if (this.db === void 0) return [];
2552
+ const sql = sourceId ? `SELECT e.*, COUNT(ee.id) AS event_count
2553
+ FROM knowledge_entities e
2554
+ LEFT JOIN knowledge_event_entities ee ON ee.entity_id = e.id
2555
+ WHERE e.source_id = ?
2556
+ GROUP BY e.id
2557
+ ORDER BY event_count DESC` : `SELECT e.*, COUNT(ee.id) AS event_count
2558
+ FROM knowledge_entities e
2559
+ LEFT JOIN knowledge_event_entities ee ON ee.entity_id = e.id
2560
+ GROUP BY e.id
2561
+ ORDER BY event_count DESC`;
2562
+ return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map((row) => ({
2563
+ ...rowToEntity(row),
2564
+ eventCount: Number(row["event_count"] ?? 0)
2565
+ }));
2566
+ }
2567
+ async listEvents(sourceId) {
2568
+ await this.init();
2569
+ if (this.db === void 0) return [];
2570
+ const sql = sourceId ? "SELECT * FROM knowledge_events WHERE source_id = ? ORDER BY rank ASC" : "SELECT * FROM knowledge_events ORDER BY created_at DESC";
2571
+ return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map(rowToEvent);
2572
+ }
2573
+ async listEventEntities(sourceId) {
2574
+ await this.init();
2575
+ if (this.db === void 0) return [];
2576
+ const sql = sourceId ? `SELECT ee.event_id, ee.entity_id
2577
+ FROM knowledge_event_entities ee
2578
+ JOIN knowledge_events e ON e.id = ee.event_id
2579
+ WHERE e.source_id = ?` : "SELECT event_id, entity_id FROM knowledge_event_entities";
2580
+ return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map((row) => ({
2581
+ eventId: asString(row["event_id"]),
2582
+ entityId: asString(row["entity_id"])
2583
+ }));
2584
+ }
2585
+ /** FTS hits ranked by bm25 relevance — score = -bm25 (larger is better). */
2586
+ async ftsSearchChunks(query, limit = 50) {
2587
+ await this.init();
2588
+ if (this.db === void 0) return [];
2589
+ const ftsQuery = buildFtsQuery(query);
2590
+ if (ftsQuery === void 0) return [];
2591
+ return this.db.prepare(`SELECT c.*, bm25(knowledge_chunks_fts) AS bm
2592
+ FROM knowledge_chunks c
2593
+ JOIN knowledge_chunks_fts f ON c.rowid = f.rowid
2594
+ WHERE f.knowledge_chunks_fts MATCH ?
2595
+ ORDER BY bm ASC
2596
+ LIMIT ?`).all(ftsQuery, limit).map((row) => ({
2597
+ chunk: rowToChunk(row),
2598
+ score: -Number(row["bm"] ?? 0)
2599
+ }));
2600
+ }
2601
+ async searchChunksByVector(queryVec, options = {}) {
2602
+ await this.init();
2603
+ if (this.db === void 0) return [];
2604
+ const limit = options.limit ?? 50;
2605
+ const threshold = options.threshold ?? 0;
2606
+ const rows = this.db.prepare("SELECT id, embedding_json FROM knowledge_chunks WHERE embedding_json IS NOT NULL").all();
2607
+ const scored = [];
2608
+ for (const row of rows) {
2609
+ const vec = blobToVector(row.embedding_json);
2610
+ if (vec === null) continue;
2611
+ const score = this.embeddingEngine?.cosineSimilarity(queryVec, vec) ?? 0;
2612
+ if (score >= threshold) scored.push({
2613
+ id: row.id,
2614
+ score
2615
+ });
2616
+ }
2617
+ scored.sort((a, b) => b.score - a.score);
2618
+ const top = scored.slice(0, limit);
2619
+ const out = [];
2620
+ for (const item of top) {
2621
+ const chunk = await this.getChunk(item.id);
2622
+ if (chunk !== void 0) out.push({
2623
+ chunk,
2624
+ score: item.score
2625
+ });
2626
+ }
2627
+ return out;
2628
+ }
2629
+ /**
2630
+ * Recompute every embedding for one source from the already-stored texts
2631
+ * (no LLM extraction). Repairs libraries whose vectors are missing
2632
+ * (ingested while the embedding model was unavailable) or stale (produced
2633
+ * by a different model). All texts are embedded up front — any batch
2634
+ * failure aborts before the first write, so the library is never left
2635
+ * half-updated. On success the embedding-model meta is stamped with the
2636
+ * engine's identity, unblocking future ingests after a model change.
2637
+ */
2638
+ async reembedSource(sourceId, engine) {
2639
+ await this.init();
2640
+ if (this.db === void 0) throw new Error("knowledge store not initialized");
2641
+ if (!engine.available) throw new Error("embedding engine unavailable");
2642
+ const chunkRows = this.db.prepare("SELECT id, heading, content FROM knowledge_chunks WHERE source_id = ? ORDER BY rank ASC").all(sourceId);
2643
+ const chunkTexts = chunkRows.map((c) => c.heading !== null ? `${c.heading}\n${c.content}` : c.content);
2644
+ const eventRows = this.db.prepare("SELECT id, title, content FROM knowledge_events WHERE source_id = ? ORDER BY rank ASC").all(sourceId);
2645
+ const eventTitleTexts = eventRows.map((e) => e.title);
2646
+ const eventContentTexts = eventRows.map((e) => `${e.title}\n\n${e.content}`);
2647
+ const entityRows = this.db.prepare("SELECT id, name FROM knowledge_entities WHERE source_id = ?").all(sourceId);
2648
+ const relationRows = this.db.prepare(`SELECT ee.id, ee.description, e.title, en.name
2649
+ FROM knowledge_event_entities ee
2650
+ JOIN knowledge_events e ON e.id = ee.event_id
2651
+ JOIN knowledge_entities en ON en.id = ee.entity_id
2652
+ WHERE e.source_id = ?`).all(sourceId);
2653
+ const relationTexts = relationRows.map((r) => r.description !== null && r.description.length > 0 ? r.description : `${r.title} ${r.name}`);
2654
+ const embedAll = async (texts) => {
2655
+ if (texts.length === 0) return [];
2656
+ const vectors = await engine.embedBatch(texts);
2657
+ if (vectors === null || vectors.length !== texts.length) throw new Error("向量嵌入失败,已取消本次重新嵌入");
2658
+ return vectors;
2659
+ };
2660
+ const chunkVecs = await embedAll(chunkTexts);
2661
+ const eventTitleVecs = await embedAll(eventTitleTexts);
2662
+ const eventContentVecs = await embedAll(eventContentTexts);
2663
+ const entityVecs = await embedAll(entityRows.map((e) => e.name));
2664
+ const relationVecs = await embedAll(relationTexts);
2665
+ this.beginTransaction();
2666
+ try {
2667
+ const updChunk = this.db.prepare("UPDATE knowledge_chunks SET embedding_json = ? WHERE id = ?");
2668
+ for (let i = 0; i < chunkRows.length; i++) updChunk.run(vectorToBlob(chunkVecs[i] ?? null), chunkRows[i].id);
2669
+ const updEvent = this.db.prepare("UPDATE knowledge_events SET title_embedding_json = ?, content_embedding_json = ? WHERE id = ?");
2670
+ for (let i = 0; i < eventRows.length; i++) updEvent.run(vectorToBlob(eventTitleVecs[i] ?? null), vectorToBlob(eventContentVecs[i] ?? null), eventRows[i].id);
2671
+ const updEntity = this.db.prepare("UPDATE knowledge_entities SET embedding_json = ? WHERE id = ?");
2672
+ for (let i = 0; i < entityRows.length; i++) updEntity.run(vectorToBlob(entityVecs[i] ?? null), entityRows[i].id);
2673
+ const updRelation = this.db.prepare("UPDATE knowledge_event_entities SET embedding_json = ? WHERE id = ?");
2674
+ for (let i = 0; i < relationRows.length; i++) updRelation.run(vectorToBlob(relationVecs[i] ?? null), relationRows[i].id);
2675
+ await this.setMeta(EMBEDDING_MODEL_META_KEY, engine.modelName);
2676
+ this.commitTransaction();
2677
+ } catch (error) {
2678
+ this.rollbackTransaction();
2679
+ throw error;
2680
+ }
2681
+ return {
2682
+ chunks: chunkRows.length,
2683
+ events: eventRows.length,
2684
+ entities: entityRows.length,
2685
+ relations: relationRows.length
2686
+ };
2687
+ }
2688
+ /**
2689
+ * Chunk vector coverage — how many of the stored chunks carry an embedding.
2690
+ * Used by the TUI to surface partial/skipped embedding after an ingest.
2691
+ */
2692
+ async embeddingCoverage() {
2693
+ await this.init();
2694
+ if (this.db === void 0) return {
2695
+ total: 0,
2696
+ embedded: 0
2697
+ };
2698
+ const row = this.db.prepare(`SELECT COUNT(*) AS total,
2699
+ SUM(CASE WHEN embedding_json IS NOT NULL THEN 1 ELSE 0 END) AS embedded
2700
+ FROM knowledge_chunks`).get();
2701
+ if (row === void 0) return {
2702
+ total: 0,
2703
+ embedded: 0
2704
+ };
2705
+ return {
2706
+ total: Number(row["total"] ?? 0),
2707
+ embedded: Number(row["embedded"] ?? 0)
2708
+ };
2709
+ }
2710
+ async stats() {
2711
+ await this.init();
2712
+ if (this.db === void 0) return {
2713
+ sources: 0,
2714
+ documents: 0,
2715
+ chunks: 0,
2716
+ events: 0,
2717
+ entities: 0
2718
+ };
2719
+ const count = (table) => {
2720
+ return this.db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get()?.c ?? 0;
2721
+ };
2722
+ return {
2723
+ sources: count("knowledge_sources"),
2724
+ documents: count("knowledge_documents"),
2725
+ chunks: count("knowledge_chunks"),
2726
+ events: count("knowledge_events"),
2727
+ entities: count("knowledge_entities")
2728
+ };
2729
+ }
2730
+ async buildSearchResult(chunkId, score, eventId = null) {
2731
+ if (this.db === void 0) return void 0;
2732
+ const chunk = await this.getChunk(chunkId);
2733
+ if (chunk === void 0) return void 0;
2734
+ if (this.db.prepare("SELECT * FROM knowledge_documents WHERE id = ?").get(chunk.documentId) === void 0) return void 0;
2735
+ const sourceRow = this.db.prepare("SELECT * FROM knowledge_sources WHERE id = ?").get(chunk.sourceId);
2736
+ if (sourceRow === void 0) return void 0;
2737
+ let eventTitle = null;
2738
+ if (eventId !== null) eventTitle = (await this.getEvent(eventId))?.title ?? null;
2739
+ return {
2740
+ chunkId: chunk.id,
2741
+ documentId: chunk.documentId,
2742
+ sourceId: chunk.sourceId,
2743
+ sourceName: String(sourceRow["name"]),
2744
+ heading: chunk.heading,
2745
+ content: chunk.content,
2746
+ score,
2747
+ eventId,
2748
+ eventTitle
2749
+ };
2750
+ }
2751
+ };
2752
+ function asString(value) {
2753
+ if (typeof value === "string") return value;
2754
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value);
2755
+ return "";
2756
+ }
2757
+ function asNullableString(value) {
2758
+ if (value === null || value === void 0) return null;
2759
+ if (typeof value === "string") return value;
2760
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value);
2761
+ return null;
2762
+ }
2763
+ function rowToSource(row) {
2764
+ return {
2765
+ id: asString(row["id"]),
2766
+ name: asString(row["name"]),
2767
+ filePath: asNullableString(row["file_path"]),
2768
+ description: asNullableString(row["description"]),
2769
+ contentHash: asNullableString(row["content_hash"]),
2770
+ createdAt: Number(row["created_at"])
2771
+ };
2772
+ }
2773
+ function rowToDocument(row) {
2774
+ return {
2775
+ id: asString(row["id"]),
2776
+ sourceId: asString(row["source_id"]),
2777
+ title: asString(row["title"]),
2778
+ content: asNullableString(row["content"]),
2779
+ status: asString(row["status"]),
2780
+ chunkCount: Number(row["chunk_count"] ?? 0),
2781
+ createdAt: Number(row["created_at"])
2782
+ };
2783
+ }
2784
+ function rowToChunk(row) {
2785
+ return {
2786
+ id: asString(row["id"]),
2787
+ sourceId: asString(row["source_id"]),
2788
+ documentId: asString(row["document_id"]),
2789
+ rank: Number(row["rank"]),
2790
+ heading: asNullableString(row["heading"]),
2791
+ content: asString(row["content"]),
2792
+ rawContent: asNullableString(row["raw_content"]),
2793
+ embedding: blobToVector(row["embedding_json"]),
2794
+ createdAt: Number(row["created_at"])
2795
+ };
2796
+ }
2797
+ function rowToEvent(row) {
2798
+ let keywords = [];
2799
+ try {
2800
+ const parsed = JSON.parse(asString(row["keywords"] ?? "[]"));
2801
+ if (Array.isArray(parsed)) keywords = parsed.filter((k) => typeof k === "string");
2802
+ } catch {}
2803
+ return {
2804
+ id: asString(row["id"]),
2805
+ sourceId: asString(row["source_id"]),
2806
+ documentId: asString(row["document_id"]),
2807
+ chunkId: asString(row["chunk_id"]),
2808
+ rank: Number(row["rank"]),
2809
+ title: asString(row["title"]),
2810
+ summary: asNullableString(row["summary"]),
2811
+ content: asString(row["content"]),
2812
+ category: asNullableString(row["category"]),
2813
+ keywords,
2814
+ titleEmbedding: blobToVector(row["title_embedding_json"]),
2815
+ contentEmbedding: blobToVector(row["content_embedding_json"]),
2816
+ createdAt: Number(row["created_at"])
2817
+ };
2818
+ }
2819
+ function rowToEntity(row) {
2820
+ return {
2821
+ id: asString(row["id"]),
2822
+ sourceId: asString(row["source_id"]),
2823
+ type: asString(row["type"]),
2824
+ name: asString(row["name"]),
2825
+ normalizedName: asString(row["normalized_name"]),
2826
+ description: asNullableString(row["description"]),
2827
+ embedding: blobToVector(row["embedding_json"]),
2828
+ createdAt: Number(row["created_at"])
2829
+ };
2830
+ }
2831
+ /**
2832
+ * Tokenize text so FTS5's unicode61 tokenizer can index mixed CJK/ASCII text.
2833
+ * Mirrors the memory package's `toFtsText`.
2834
+ */
2835
+ function toFtsText(text) {
2836
+ const parts = text.toLowerCase().replaceAll(/([一-鿿㐀-䶿])([a-z0-9])/g, "$1 $2").replaceAll(/([a-z0-9])([一-鿿㐀-䶿])/g, "$1 $2").split(/[^a-z0-9一-鿿㐀-䶿]+/);
2837
+ const tokens = [];
2838
+ for (const part of parts) {
2839
+ if (part.length === 0) continue;
2840
+ if (/^[a-z0-9]+$/.test(part)) tokens.push(part);
2841
+ else for (const ch of part) if (ch.length > 0) tokens.push(ch);
2842
+ }
2843
+ return tokens.join(" ");
2844
+ }
2845
+ function buildFtsQuery(search) {
2846
+ const tokens = toFtsText(search).split(/\s+/).filter((t) => t.length > 0);
2847
+ if (tokens.length === 0) return void 0;
2848
+ return tokens.map((t) => `"${t.replaceAll("\"", "\"\"")}"`).join(" AND ");
2849
+ }
2850
+ //#endregion
2851
+ //#region ../../packages/knowledge/src/shared-store.ts
2852
+ const entries = /* @__PURE__ */ new Map();
2853
+ function entryFor(homeDir) {
2854
+ const existing = entries.get(homeDir);
2855
+ if (existing !== void 0) return existing;
2856
+ const store = new KnowledgeStore(homeDir);
2857
+ const entry = {
2858
+ store,
2859
+ ready: (async () => {
2860
+ try {
2861
+ await store.init();
2862
+ const cacheDir = join$1(homeDir, "cache", "fastembed");
2863
+ mkdirSync(cacheDir, { recursive: true });
2864
+ store.setEmbeddingEngine(createFastEmbedEngine(cacheDir));
2865
+ } catch (error) {
2866
+ entries.delete(homeDir);
2867
+ throw error;
2868
+ }
2869
+ })()
2870
+ };
2871
+ entries.set(homeDir, entry);
2872
+ return entry;
2873
+ }
2874
+ /**
2875
+ * Process-level KnowledgeStore per homeDir, plus the embedding engine wired
2876
+ * onto it — one handle and one status source instead of one per host (TUI
2877
+ * command vs agent session).
2878
+ *
2879
+ * The map key is the raw homeDir string so the engine cache key
2880
+ * join(homeDir, 'cache', 'fastembed') stays byte-identical with every other
2881
+ * createFastEmbedEngine call site: same string → same engine instance.
2882
+ */
2883
+ function sharedKnowledgeStore(homeDir) {
2884
+ return entryFor(homeDir).store;
2885
+ }
2886
+ /** Resolves once init + engine wiring finished; rejects once per failed build. */
2887
+ function sharedKnowledgeStoreReady(homeDir) {
2888
+ return entryFor(homeDir).ready;
2889
+ }
2890
+ //#endregion
2891
+ //#region ../../packages/knowledge/src/chunking.ts
2892
+ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
2893
+ const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
2894
+ /**
2895
+ * Soft cap on chunk token count. BGE-small-zh-v1.5 (the fastembed model used
2896
+ * here) has a 512-token sequence limit; chunks longer than that get truncated
2897
+ * at the embedding layer, hurting retrieval quality. We split at ~480 to leave
2898
+ * headroom for the heading line that gets prepended at embed time.
2899
+ */
2900
+ const MAX_CHUNK_TOKENS = 480;
2901
+ /**
2902
+ * Split a markdown document into sections using heading_strict strategy:
2903
+ * each section begins at a heading and contains all body content until the
2904
+ * next heading. Consecutive headings collapse to the lowest-level one as the
2905
+ * main heading. Content before the first heading is grouped as "Introduction".
2906
+ *
2907
+ * Mirrors SAG's `buildHeadingStrictSections`.
2908
+ */
2909
+ function chunkMarkdown(content) {
2910
+ const lines = content.split("\n");
2911
+ const sections = [];
2912
+ let buffer = [];
2913
+ let currentHeading = null;
2914
+ let currentLevel = null;
2915
+ let rank = 0;
2916
+ let inFence = false;
2917
+ const flush = () => {
2918
+ const raw = buffer.join("\n").trim();
2919
+ const heading = currentHeading;
2920
+ const level = currentLevel;
2921
+ buffer = [];
2922
+ if (raw.length === 0) return;
2923
+ const body = stripMarkdown(raw);
2924
+ if (body.length === 0 && heading === null) return;
2925
+ const subSections = splitLargeSection({
2926
+ heading,
2927
+ headingLevel: level,
2928
+ content: body,
2929
+ rawContent: raw,
2930
+ rank
2931
+ });
2932
+ for (const section of subSections) {
2933
+ sections.push({
2934
+ ...section,
2935
+ rank
2936
+ });
2937
+ rank += 1;
2938
+ }
2939
+ };
2940
+ for (const line of lines) {
2941
+ if (CODE_FENCE_RE.exec(line)) {
2942
+ inFence = !inFence;
2943
+ buffer.push(line);
2944
+ continue;
2945
+ }
2946
+ if (inFence) {
2947
+ buffer.push(line);
2948
+ continue;
2949
+ }
2950
+ const headingMatch = HEADING_RE.exec(line);
2951
+ if (headingMatch) {
2952
+ flush();
2953
+ currentHeading = headingMatch[2].trim();
2954
+ currentLevel = headingMatch[1].length;
2955
+ } else buffer.push(line);
2956
+ }
2957
+ flush();
2958
+ return sections;
2959
+ }
2960
+ /**
2961
+ * Strip markdown decorations that hurt embedding quality:
2962
+ * code fences, inline code, images, links, emphasis markers.
2963
+ *
2964
+ * Mirrors SAG's `stripMarkdown`.
2965
+ */
2966
+ function stripMarkdown(text) {
2967
+ return text.replaceAll(/^(\s*)```[^\n]*$\n?/gm, "$1").replaceAll(/^(\s*)```[^\n]*$/gm, "").replaceAll(/`([^`]+)`/g, "$1").replaceAll(/!\[([^\]]*)\]\([^)]+\)/g, "$1").replaceAll(/\[([^\]]+)\]\([^)]+\)/g, "$1").replaceAll(/\*\*([^*]+)\*\*/g, "$1").replaceAll(/__([^_]+)__/g, "$1").replaceAll(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "$1").replaceAll(/(?<!\w)_([^_\n]+)_(?!\w)/g, "$1").replaceAll(/^#{1,6}\s+/gm, "").replaceAll(/\n{3,}/g, "\n\n").trim();
2968
+ }
2969
+ /**
2970
+ * Split a plain text file into chunks by blank-line paragraphs.
2971
+ * Each non-empty paragraph becomes one chunk with no heading.
2972
+ * Long paragraphs are split at sentence boundaries to stay under the
2973
+ * embedding model's token cap (same MAX_CHUNK_TOKENS as markdown).
2974
+ */
2975
+ function chunkText(content) {
2976
+ const MAX_PARA_TOKENS = MAX_CHUNK_TOKENS;
2977
+ const MAX_PARA_CHARS = MAX_PARA_TOKENS * 4;
2978
+ const paragraphs = content.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0);
2979
+ const sections = [];
2980
+ let rank = 0;
2981
+ for (const para of paragraphs) {
2982
+ if (estimateTokens(para) <= MAX_PARA_TOKENS) {
2983
+ sections.push({
2984
+ heading: null,
2985
+ headingLevel: null,
2986
+ content: para,
2987
+ rawContent: para,
2988
+ rank: rank++
2989
+ });
2990
+ continue;
2991
+ }
2992
+ const sentences = para.match(/[^.!?。!?\n]+[.!?。!?\n]+|[^.!?。!?\n]+$/g) ?? [para];
2993
+ let buffer = "";
2994
+ const flushBuffer = () => {
2995
+ const text = buffer.trim();
2996
+ if (text.length === 0) return;
2997
+ sections.push({
2998
+ heading: null,
2999
+ headingLevel: null,
3000
+ content: text,
3001
+ rawContent: text,
3002
+ rank: rank++
3003
+ });
3004
+ buffer = "";
3005
+ };
3006
+ for (const sentence of sentences) {
3007
+ const candidate = (buffer + " " + sentence).trim();
3008
+ if (buffer.length > 0 && candidate.length > MAX_PARA_CHARS) flushBuffer();
3009
+ buffer = buffer.length === 0 ? sentence : `${buffer} ${sentence}`;
3010
+ }
3011
+ flushBuffer();
3012
+ }
3013
+ return sections;
3014
+ }
3015
+ /** Rough token estimate — chars / 4 (English-leaning; CJK underestimates). */
3016
+ function estimateTokens(text) {
3017
+ return Math.ceil(text.length / 4);
3018
+ }
3019
+ /**
3020
+ * Split a section that exceeds MAX_CHUNK_TOKENS into smaller sub-sections.
3021
+ * Strategy (mirrors SAG's `splitLargeSection`):
3022
+ * 1. Split into paragraphs by blank lines.
3023
+ * 2. Greedily pack paragraphs into chunks under the token cap.
3024
+ * 3. A paragraph that alone exceeds the cap is split at sentence boundaries;
3025
+ * a sentence that itself exceeds the cap is split by character limit.
3026
+ *
3027
+ * Sections under the cap are returned unchanged (single-element array).
3028
+ * The heading is preserved across all sub-sections; rank is reassigned by the
3029
+ * caller. rawContent is rebuilt from the sub-chunk's content paragraphs.
3030
+ */
3031
+ function splitLargeSection(section) {
3032
+ if (estimateTokens(section.content) <= MAX_CHUNK_TOKENS) return [section];
3033
+ const paragraphs = section.rawContent.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
3034
+ if (paragraphs.length <= 1) return splitBySentences(section, section.rawContent);
3035
+ const result = [];
3036
+ let buffer = [];
3037
+ let bufferTokens = 0;
3038
+ const flushBuffer = () => {
3039
+ if (buffer.length === 0) return;
3040
+ const raw = buffer.join("\n\n").trim();
3041
+ if (raw.length === 0) {
3042
+ buffer = [];
3043
+ bufferTokens = 0;
3044
+ return;
3045
+ }
3046
+ result.push({
3047
+ heading: section.heading,
3048
+ headingLevel: section.headingLevel,
3049
+ content: stripMarkdown(raw),
3050
+ rawContent: raw,
3051
+ rank: 0
3052
+ });
3053
+ buffer = [];
3054
+ bufferTokens = 0;
3055
+ };
3056
+ for (const para of paragraphs) {
3057
+ const paraTokens = estimateTokens(para);
3058
+ if (paraTokens > MAX_CHUNK_TOKENS) {
3059
+ flushBuffer();
3060
+ for (const frag of splitBySentences(section, para)) result.push(frag);
3061
+ continue;
3062
+ }
3063
+ if (buffer.length > 0 && bufferTokens + paraTokens > MAX_CHUNK_TOKENS) flushBuffer();
3064
+ buffer.push(para);
3065
+ bufferTokens += paraTokens;
3066
+ }
3067
+ flushBuffer();
3068
+ return result.length > 0 ? result : [section];
3069
+ }
3070
+ /**
3071
+ * Split a long text into sub-sections at sentence boundaries.
3072
+ * Falls back to a hard character cut when a single sentence exceeds the cap.
3073
+ */
3074
+ function splitBySentences(section, text) {
3075
+ const sentences = splitSentences(text);
3076
+ const result = [];
3077
+ let buffer = "";
3078
+ let bufferTokens = 0;
3079
+ const flush = () => {
3080
+ const trimmed = buffer.trim();
3081
+ if (trimmed.length === 0) {
3082
+ buffer = "";
3083
+ bufferTokens = 0;
3084
+ return;
3085
+ }
3086
+ result.push({
3087
+ heading: section.heading,
3088
+ headingLevel: section.headingLevel,
3089
+ content: stripMarkdown(trimmed),
3090
+ rawContent: trimmed,
3091
+ rank: 0
3092
+ });
3093
+ buffer = "";
3094
+ bufferTokens = 0;
3095
+ };
3096
+ for (const sentence of sentences) {
3097
+ const sentTokens = estimateTokens(sentence);
3098
+ if (sentTokens > MAX_CHUNK_TOKENS) {
3099
+ flush();
3100
+ for (const frag of splitByCharLimit(sentence)) result.push({
3101
+ heading: section.heading,
3102
+ headingLevel: section.headingLevel,
3103
+ content: stripMarkdown(frag),
3104
+ rawContent: frag,
3105
+ rank: 0
3106
+ });
3107
+ continue;
3108
+ }
3109
+ if (buffer.length > 0 && bufferTokens + sentTokens > MAX_CHUNK_TOKENS) flush();
3110
+ buffer = buffer.length === 0 ? sentence : `${buffer} ${sentence}`;
3111
+ bufferTokens += sentTokens;
3112
+ }
3113
+ flush();
3114
+ return result.length > 0 ? result : [{
3115
+ heading: section.heading,
3116
+ headingLevel: section.headingLevel,
3117
+ content: section.content,
3118
+ rawContent: section.rawContent,
3119
+ rank: 0
3120
+ }];
3121
+ }
3122
+ /** Split text into sentences using CJK + ASCII punctuation. */
3123
+ function splitSentences(text) {
3124
+ return text.match(/[^。!?!?.\n]+[。!?!?.\n]+|[^。!?!?.\n]+$/g) ?? [text];
3125
+ }
3126
+ /** Hard character cut for pathological single sentences longer than the cap. */
3127
+ function splitByCharLimit(text) {
3128
+ const maxChars = MAX_CHUNK_TOKENS * 4;
3129
+ const chunks = [];
3130
+ let remaining = text.trim();
3131
+ while (remaining.length > maxChars) {
3132
+ chunks.push(remaining.slice(0, maxChars));
3133
+ remaining = remaining.slice(maxChars).trimStart();
3134
+ }
3135
+ if (remaining.length > 0) chunks.push(remaining);
3136
+ return chunks;
3137
+ }
3138
+ //#endregion
3139
+ //#region ../../packages/knowledge/src/extractor.ts
3140
+ /** Max LLM call attempts for extraction — fails fall back to a heading-derived event. */
3141
+ const EXTRACTION_MAX_ATTEMPTS = 3;
3142
+ /** Base delay (ms) for exponential backoff: 100, 200, 400. */
3143
+ const EXTRACTION_BACKOFF_BASE_MS = 100;
3144
+ function backoffDelay(attempt) {
3145
+ return EXTRACTION_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1);
3146
+ }
3147
+ async function callLlmWithBackoff(llm, systemPrompt, userPrompt) {
3148
+ let lastError;
3149
+ for (let attempt = 1; attempt <= EXTRACTION_MAX_ATTEMPTS; attempt += 1) try {
3150
+ return await llm.generate(systemPrompt, userPrompt);
3151
+ } catch (error) {
3152
+ lastError = error;
3153
+ if (attempt < EXTRACTION_MAX_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt)));
3154
+ }
3155
+ throw lastError;
3156
+ }
3157
+ const ENTITY_TYPES = [
3158
+ "person",
3159
+ "organization",
3160
+ "location",
3161
+ "time",
3162
+ "product",
3163
+ "metric",
3164
+ "action",
3165
+ "work",
3166
+ "group",
3167
+ "subject",
3168
+ "tags"
3169
+ ];
3170
+ const EXTRACTION_SYSTEM_PROMPT = `You are a professional knowledge content extractor for a SAG-style event-centric knowledge graph.
3171
+
3172
+ Your task: given a markdown section (one chunk), extract exactly ONE fused event that captures the section's core informational content, plus the entities mentioned.
3173
+
3174
+ # Output Format
3175
+
3176
+ Return a JSON object with this exact shape:
3177
+
3178
+ {
3179
+ "items": [
3180
+ {
3181
+ "title": "short event title (≤80 chars, noun phrase or short sentence)",
3182
+ "summary": "one-sentence summary of what this section is about",
3183
+ "content": "the full informational content of this section, rephrased as a self-contained event description (1-4 sentences)",
3184
+ "category": "one of: definition, process, comparison, decision, history, reference, other",
3185
+ "keywords": ["3-5 short keywords or phrases"],
3186
+ "entities": [
3187
+ { "type": "one of ENTITY_TYPES below", "name": "entity name", "description": "one-line context for the entity" }
3188
+ ]
3189
+ }
3190
+ ]
3191
+ }
3192
+
3193
+ # Entity Types
3194
+
3195
+ Use only these types:
3196
+ - person — named individuals
3197
+ - organization — companies, teams, institutions
3198
+ - location — places, geographic regions
3199
+ - time — dates, time periods, eras
3200
+ - product — products, services, software, tools
3201
+ - metric — numbers, statistics, measurements
3202
+ - action — processes, operations, activities
3203
+ - work — books, papers, projects, artworks
3204
+ - group — categories, classes, groups of things
3205
+ - subject — abstract topics, concepts, fields
3206
+ - tags — use sparingly for cross-cutting labels
3207
+
3208
+ # Rules
3209
+
3210
+ 1. Produce EXACTLY ONE item in the "items" array.
3211
+ 2. The event title should be specific enough to disambiguate from sibling sections.
3212
+ 3. The content must be self-contained — a reader should understand it without the surrounding context.
3213
+ 4. Extract 2-6 entities per chunk. Only include entities that are clearly referenced in the text.
3214
+ 5. Use the entity's most specific name (e.g. "Anthropic" rather than "the company").
3215
+ 6. Output language must follow the main input language. Chinese input requires Chinese title, summary, content, category, keywords, and entity descriptions. English input requires English fields.
3216
+ 7. Output ONLY the JSON object — no prose, no markdown fences, no commentary.
3217
+
3218
+ # Example
3219
+
3220
+ Input section:
3221
+ """
3222
+ ## Model Context Protocol
3223
+
3224
+ The Model Context Protocol (MCP) is an open standard introduced by Anthropic in 2024. It defines a JSON-RPC interface between AI assistants and external tools. MCP servers expose resources, prompts, and tools that clients like Claude Desktop can consume.
3225
+ """
3226
+
3227
+ Output:
3228
+ {
3229
+ "items": [
3230
+ {
3231
+ "title": "Model Context Protocol introduction",
3232
+ "summary": "MCP is an open standard for AI assistant / tool integration introduced by Anthropic in 2024.",
3233
+ "content": "The Model Context Protocol (MCP) is an open standard introduced by Anthropic in 2024 that defines a JSON-RPC interface between AI assistants and external tools. MCP servers expose resources, prompts, and tools that clients like Claude Desktop can consume.",
3234
+ "category": "definition",
3235
+ "keywords": ["MCP", "JSON-RPC", "Anthropic", "AI tools"],
3236
+ "entities": [
3237
+ { "type": "product", "name": "Model Context Protocol", "description": "Open standard for AI assistant / tool integration" },
3238
+ { "type": "organization", "name": "Anthropic", "description": "Company that introduced MCP in 2024" },
3239
+ { "type": "time", "name": "2024", "description": "Year MCP was introduced" },
3240
+ { "type": "product", "name": "Claude Desktop", "description": "An MCP client application" }
3241
+ ]
3242
+ }
3243
+ ]
3244
+ }`;
3245
+ /**
3246
+ * Build the user prompt for extracting events/entities from a chunk.
3247
+ * Includes a one-shot example to anchor the expected output shape.
3248
+ */
3249
+ function buildExtractionUserPrompt(chunk) {
3250
+ return [
3251
+ "Extract the fused event and entities from the following markdown section.",
3252
+ "",
3253
+ `Section heading: ${chunk.heading ?? "(no heading)"}`,
3254
+ "",
3255
+ "Section content:",
3256
+ "\"\"\"",
3257
+ chunk.content,
3258
+ "\"\"\"",
3259
+ "",
3260
+ "Return only the JSON object per the schema."
3261
+ ].join("\n");
3262
+ }
3263
+ /** Extract the first {...} JSON block from a text response. */
3264
+ function extractJsonFromText(text) {
3265
+ const match = text.match(/\{[\s\S]*\}/);
3266
+ return match === null ? null : match[0];
3267
+ }
3268
+ /** Parse LLM extraction output into structured events. Returns at most 1 event. */
3269
+ function parseExtractionResponse(text, fallbackChunk) {
3270
+ const jsonText = extractJsonFromText(text);
3271
+ if (jsonText === null) return fallbackEvent(fallbackChunk);
3272
+ try {
3273
+ const items = JSON.parse(jsonText)["items"];
3274
+ if (!Array.isArray(items) || items.length === 0) return fallbackEvent(fallbackChunk);
3275
+ const first = items[0];
3276
+ if (first === null || typeof first !== "object") return fallbackEvent(fallbackChunk);
3277
+ const obj = first;
3278
+ return {
3279
+ title: typeof obj["title"] === "string" && obj["title"].trim().length > 0 ? obj["title"].trim() : fallbackChunk.heading ?? "Untitled",
3280
+ summary: typeof obj["summary"] === "string" ? obj["summary"].trim() : "",
3281
+ content: typeof obj["content"] === "string" && obj["content"].trim().length > 0 ? obj["content"].trim() : fallbackChunk.content,
3282
+ category: typeof obj["category"] === "string" ? obj["category"].trim() : "other",
3283
+ keywords: parseKeywords(obj["keywords"]),
3284
+ entities: parseEntities(obj["entities"])
3285
+ };
3286
+ } catch {
3287
+ return fallbackEvent(fallbackChunk);
3288
+ }
3289
+ }
3290
+ function parseKeywords(value) {
3291
+ if (!Array.isArray(value)) return [];
3292
+ const out = [];
3293
+ for (const item of value) if (typeof item === "string" && item.trim().length > 0) out.push(item.trim());
3294
+ return out.slice(0, 10);
3295
+ }
3296
+ function parseEntities(value) {
3297
+ if (!Array.isArray(value)) return [];
3298
+ const out = [];
3299
+ const seen = /* @__PURE__ */ new Set();
3300
+ for (const item of value) {
3301
+ if (item === null || typeof item !== "object") continue;
3302
+ const obj = item;
3303
+ const type = typeof obj["type"] === "string" ? obj["type"] : "";
3304
+ const name = typeof obj["name"] === "string" ? obj["name"].trim() : "";
3305
+ const description = typeof obj["description"] === "string" ? obj["description"].trim() : "";
3306
+ if (name.length === 0) continue;
3307
+ if (!ENTITY_TYPES.includes(type)) continue;
3308
+ const key = `${type}|${name.toLowerCase()}`;
3309
+ if (seen.has(key)) continue;
3310
+ seen.add(key);
3311
+ out.push({
3312
+ type,
3313
+ name,
3314
+ description
3315
+ });
3316
+ }
3317
+ return out.slice(0, 20);
3318
+ }
3319
+ function fallbackEvent(chunk) {
3320
+ return {
3321
+ title: chunk.heading ?? "Untitled section",
3322
+ summary: "",
3323
+ content: chunk.content,
3324
+ category: "other",
3325
+ keywords: [],
3326
+ entities: []
3327
+ };
3328
+ }
3329
+ /**
3330
+ * Run extraction on a single chunk via the LLM caller.
3331
+ * Retries with exponential backoff on transient failures; falls back to a
3332
+ * heading-derived event only after all attempts fail.
3333
+ */
3334
+ async function extractEventFromChunk(llm, chunk) {
3335
+ try {
3336
+ return parseExtractionResponse(await callLlmWithBackoff(llm, EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt(chunk)), chunk);
3337
+ } catch {
3338
+ return fallbackEvent(chunk);
3339
+ }
3340
+ }
3341
+ /**
3342
+ * Ask the LLM to rerank candidate events by relevance to a query.
3343
+ * Returns the candidate ids in reranked order (most relevant first).
3344
+ *
3345
+ * If the LLM fails or returns unparseable output, returns the input order.
3346
+ */
3347
+ async function rerankEventsWithLlm(llm, query, candidates, topK) {
3348
+ if (candidates.length === 0) return [];
3349
+ if (candidates.length <= topK) return candidates.map((c) => c.id);
3350
+ const candidateList = candidates.map((c, i) => `[${i}] id=${c.id}\n title: ${c.title}\n summary: ${c.summary}`).join("\n");
3351
+ const system = `You are a precise relevance judge for a knowledge base retrieval system.
3352
+
3353
+ Given a user query and a list of candidate events, select the ${topK} event ids most useful for answering the query.
3354
+
3355
+ Return ONLY a JSON object: {"ids": ["id1", "id2", ...]}
3356
+
3357
+ The ids must come from the candidate list. Output at most ${topK} ids, most relevant first. No prose, no markdown.`;
3358
+ const user = `Query: ${query}
3359
+
3360
+ Candidates:
3361
+ ${candidateList}
3362
+
3363
+ Return the JSON object now.`;
3364
+ try {
3365
+ const jsonText = extractJsonFromText(await llm.generate(system, user));
3366
+ if (jsonText === null) return candidates.slice(0, topK).map((c) => c.id);
3367
+ const parsed = JSON.parse(jsonText);
3368
+ if (!Array.isArray(parsed["ids"])) return candidates.slice(0, topK).map((c) => c.id);
3369
+ const validIds = new Set(candidates.map((c) => c.id));
3370
+ const result = [];
3371
+ for (const id of parsed["ids"]) {
3372
+ if (typeof id === "string" && validIds.has(id) && !result.includes(id)) result.push(id);
3373
+ if (result.length >= topK) break;
3374
+ }
3375
+ if (result.length === 0) return candidates.slice(0, topK).map((c) => c.id);
3376
+ return result;
3377
+ } catch {
3378
+ return candidates.slice(0, topK).map((c) => c.id);
3379
+ }
3380
+ }
3381
+ /**
3382
+ * Extract named entities from a query for entity-recall retrieval.
3383
+ * Returns a list of {type, name} pairs. Best-effort — falls back to empty.
3384
+ */
3385
+ async function extractQueryEntities(llm, query) {
3386
+ const system = `Extract named entities from the user query. Return ONLY a JSON object: {"entities": [{"type": "...", "name": "..."}]}
3387
+
3388
+ Use these entity types: ${ENTITY_TYPES.join(", ")}.
3389
+
3390
+ Only include entities that explicitly appear in the query. If no clear entities, return an empty array.`;
3391
+ try {
3392
+ const jsonText = extractJsonFromText(await llm.generate(system, `Query: ${query}`));
3393
+ if (jsonText === null) return [];
3394
+ const parsed = JSON.parse(jsonText);
3395
+ if (!Array.isArray(parsed["entities"])) return [];
3396
+ const out = [];
3397
+ for (const item of parsed["entities"]) {
3398
+ if (item === null || typeof item !== "object") continue;
3399
+ const obj = item;
3400
+ const type = typeof obj["type"] === "string" ? obj["type"] : "";
3401
+ const name = typeof obj["name"] === "string" ? obj["name"].trim() : "";
3402
+ if (name.length === 0) continue;
3403
+ if (!ENTITY_TYPES.includes(type)) continue;
3404
+ out.push({
3405
+ type,
3406
+ name
3407
+ });
3408
+ }
3409
+ return out;
3410
+ } catch {
3411
+ return [];
3412
+ }
3413
+ }
3414
+ //#endregion
3415
+ //#region ../../packages/knowledge/src/ingest.ts
3416
+ /**
3417
+ * Embed texts, returning null-array if embeddings are skipped or on failure.
3418
+ * Caller should set skipEmbedding=true when the model didn't load in time,
3419
+ * so the entire ingestion is consistent (all or nothing for embeddings).
3420
+ * Search falls back to FTS5 keyword match for chunks without embeddings.
3421
+ */
3422
+ async function embedOrNulls(engine, texts, skipEmbedding) {
3423
+ if (engine === void 0 || texts.length === 0 || skipEmbedding) return texts.map(() => null);
3424
+ if (!engine.available) return texts.map(() => null);
3425
+ const vectors = await engine.embedBatch(texts);
3426
+ if (vectors === null || vectors.length !== texts.length) return texts.map(() => null);
3427
+ return vectors;
3428
+ }
3429
+ /** LLM concurrency for extraction — callers and comments below refer to this constant. */
3430
+ const LLM_CONCURRENCY = 20;
3431
+ const SUPPORTED_EXTENSIONS = new Set([
3432
+ ".md",
3433
+ ".markdown",
3434
+ ".txt"
3435
+ ]);
3436
+ /** Read a file and return its content. */
3437
+ async function readFileContent(filePath) {
3438
+ return readFile(filePath, "utf-8");
3439
+ }
3440
+ function getFileExtension(filePath) {
3441
+ const lastDot = filePath.lastIndexOf(".");
3442
+ return lastDot > 0 ? filePath.slice(lastDot).toLowerCase() : "";
3443
+ }
3444
+ function chunkContent(content, ext) {
3445
+ return ext === ".txt" ? chunkText(content) : chunkMarkdown(content);
3446
+ }
3447
+ function isSupportedFile(filePath) {
3448
+ return SUPPORTED_EXTENSIONS.has(getFileExtension(filePath));
3449
+ }
3450
+ /** Map async iterator values with a concurrency limit. */
3451
+ async function mapWithConcurrency(items, limit, fn, onProgress) {
3452
+ const results = Array.from({ length: items.length });
3453
+ let cursor = 0;
3454
+ let completed = 0;
3455
+ const total = items.length;
3456
+ async function worker() {
3457
+ while (cursor < items.length) {
3458
+ const idx = cursor;
3459
+ cursor += 1;
3460
+ const item = items[idx];
3461
+ const result = await fn(item, idx);
3462
+ results[idx] = result;
3463
+ completed += 1;
3464
+ onProgress?.(completed, total, result);
3465
+ }
3466
+ }
3467
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
3468
+ await Promise.all(workers);
3469
+ return results;
3470
+ }
3471
+ /**
3472
+ * Stamp or verify the embedding-model identity recorded in the library.
3473
+ * A library written by a different model would mix vector spaces — block the
3474
+ * ingest with a clear message instead of silently storing incompatible
3475
+ * embeddings. A legacy library without the marker is stamped with the current
3476
+ * model (all existing data was written by it).
3477
+ */
3478
+ async function ensureEmbeddingModelMatches(store, engine) {
3479
+ if (engine === void 0) return;
3480
+ const recorded = await store.getMeta(EMBEDDING_MODEL_META_KEY);
3481
+ if (recorded === null) {
3482
+ await store.setMeta(EMBEDDING_MODEL_META_KEY, engine.modelName);
3483
+ return;
3484
+ }
3485
+ if (recorded !== engine.modelName) throw new Error(`知识库由模型 ${recorded} 嵌入,当前模型为 ${engine.modelName},请先在 /knowledge 中重新嵌入知识库`);
3486
+ }
3487
+ /**
3488
+ * Ingest a markdown file into the knowledge base.
3489
+ *
3490
+ * Steps:
3491
+ * 1. Dedupe by file_path — if a source already exists for this file, error.
3492
+ * 2. Read file → chunkMarkdown (heading_strict).
3493
+ * 3. Embed chunks ("{heading}\n{content}") → store knowledge_chunks.
3494
+ * 4. For each chunk (concurrency LLM_CONCURRENCY): LLM extract 1 event + N entities.
3495
+ * 5. Embed event title + content ("{title}\n\n{content}") → store knowledge_events.
3496
+ * 6. Upsert entities (dedupe by source_id+type+normalizedName) + embed entity name → store.
3497
+ * 7. For each (event, entity) pair: embed relation (entity.description || "{eventTitle} {entityName}") → store knowledge_event_entities.
3498
+ * 8. Update document status = 'completed'.
3499
+ *
3500
+ * All writes run inside a single transaction — a mid-ingest failure rolls back
3501
+ * every partial row (source/document/chunks/events/entities/edges).
3502
+ *
3503
+ * Reports progress via callback.
3504
+ */
3505
+ async function ingestFile(store, llm, filePath, onProgress) {
3506
+ const engine = store.getEmbeddingEngine();
3507
+ const skipEmbed = engine === void 0 || !engine.available;
3508
+ if (skipEmbed) onProgress?.({
3509
+ stage: "embedding-check",
3510
+ message: "向量模型未就绪,本次摄入跳过向量嵌入"
3511
+ });
3512
+ await ensureEmbeddingModelMatches(store, engine);
3513
+ onProgress?.({
3514
+ stage: "chunking",
3515
+ message: `读取并切分文件: ${basename$1(filePath)}`
3516
+ });
3517
+ const content = await readFileContent(filePath);
3518
+ const contentHash = createHash("sha256").update(content).digest("hex");
3519
+ const existing = await store.findSourceByFilePath(filePath);
3520
+ let replacedOldVersion = false;
3521
+ if (existing !== void 0) {
3522
+ if (existing.contentHash === contentHash || existing.contentHash === null) {
3523
+ if (existing.contentHash === null) await store.updateSourceContentHash(existing.id, contentHash);
3524
+ onProgress?.({
3525
+ stage: "completed",
3526
+ message: "内容未变化,已跳过"
3527
+ });
3528
+ return {
3529
+ documentId: null,
3530
+ chunkCount: 0,
3531
+ eventCount: 0,
3532
+ entityCount: 0,
3533
+ outcome: "unchanged"
3534
+ };
3535
+ }
3536
+ onProgress?.({
3537
+ stage: "chunking",
3538
+ message: `检测到内容变更,替换旧版本: ${existing.name}`
3539
+ });
3540
+ await store.deleteSource(existing.id);
3541
+ replacedOldVersion = true;
3542
+ }
3543
+ const sections = chunkContent(content, getFileExtension(filePath));
3544
+ store.beginTransaction();
3545
+ try {
3546
+ if (sections.length === 0) {
3547
+ onProgress?.({
3548
+ stage: "error",
3549
+ message: "文件无有效内容"
3550
+ });
3551
+ throw new Error("文件无有效内容可摄入");
3552
+ }
3553
+ const fileName = basename$1(filePath);
3554
+ const source = await store.createSource({
3555
+ name: fileName,
3556
+ filePath,
3557
+ description: null,
3558
+ contentHash
3559
+ });
3560
+ const document = await store.createDocument({
3561
+ sourceId: source.id,
3562
+ title: fileName,
3563
+ content
3564
+ });
3565
+ const { chunks, events, uniqueEntities } = await ingestSections(store, llm, {
3566
+ source,
3567
+ document,
3568
+ sections,
3569
+ engine,
3570
+ skipEmbed
3571
+ }, onProgress);
3572
+ return {
3573
+ documentId: document.id,
3574
+ chunkCount: chunks.length,
3575
+ eventCount: events.length,
3576
+ entityCount: uniqueEntities.length,
3577
+ outcome: replacedOldVersion ? "updated" : "created"
3578
+ };
3579
+ } catch (error) {
3580
+ store.rollbackTransaction();
3581
+ if (replacedOldVersion) {
3582
+ const message = error instanceof Error ? error.message : String(error);
3583
+ throw new Error(`旧版本已删除、新版本摄入失败,请重新摄入该文件: ${filePath}(${message})`);
3584
+ }
3585
+ throw error;
3586
+ }
3587
+ }
3588
+ /**
3589
+ * Shared steps 4-9 of both ingest paths: embed + store chunks, extract events
3590
+ * via LLM, embed + store events, upsert entities, store relations, commit the
3591
+ * caller-opened transaction, and emit the completed progress. The file and
3592
+ * content pipelines were identical here modulo formatting — one body keeps
3593
+ * them from drifting apart again (content had already diverged: it dropped
3594
+ * the per-chunk embedding variable and the entity-embedding temp). The caller
3595
+ * owns beginTransaction and the catch/rollback.
3596
+ */
3597
+ async function ingestSections(store, llm, args, onProgress) {
3598
+ const { source, document, sections, engine, skipEmbed } = args;
3599
+ onProgress?.({
3600
+ stage: "embedding-chunks",
3601
+ chunkIndex: 0,
3602
+ totalChunks: sections.length,
3603
+ message: `嵌入 chunks: 0/${sections.length}`
3604
+ });
3605
+ const chunkEmbeddings = await embedOrNulls(engine, sections.map((s) => s.heading !== null ? `${s.heading}\n${s.content}` : s.content), skipEmbed);
3606
+ const chunks = [];
3607
+ for (let i = 0; i < sections.length; i++) {
3608
+ const section = sections[i];
3609
+ const embedding = chunkEmbeddings[i] ?? null;
3610
+ const chunk = await store.insertChunk({
3611
+ sourceId: source.id,
3612
+ documentId: document.id,
3613
+ rank: section.rank,
3614
+ heading: section.heading,
3615
+ content: section.content,
3616
+ rawContent: section.rawContent,
3617
+ embedding
3618
+ });
3619
+ chunks.push(chunk);
3620
+ onProgress?.({
3621
+ stage: "embedding-chunks",
3622
+ chunkIndex: i + 1,
3623
+ totalChunks: sections.length,
3624
+ message: `嵌入 chunks: ${i + 1}/${sections.length}`
3625
+ });
3626
+ }
3627
+ onProgress?.({
3628
+ stage: "extracting",
3629
+ chunkIndex: 0,
3630
+ totalChunks: sections.length,
3631
+ message: `抽取事件: 0/${sections.length}`
3632
+ });
3633
+ const extractedEvents = await mapWithConcurrency(sections, LLM_CONCURRENCY, (section) => extractEventFromChunk(llm, section), (completed, total) => {
3634
+ onProgress?.({
3635
+ stage: "extracting",
3636
+ chunkIndex: completed,
3637
+ totalChunks: total,
3638
+ message: `抽取事件: ${completed}/${total}`
3639
+ });
3640
+ });
3641
+ onProgress?.({
3642
+ stage: "embedding-events",
3643
+ chunkIndex: 0,
3644
+ totalChunks: sections.length,
3645
+ message: `嵌入 events: 0/${sections.length}`
3646
+ });
3647
+ const titleTexts = extractedEvents.map((e) => e.title);
3648
+ const contentTexts = extractedEvents.map((e) => `${e.title}\n\n${e.content}`);
3649
+ const [titleEmbeddings, contentEmbeddings] = await Promise.all([embedOrNulls(engine, titleTexts, skipEmbed), embedOrNulls(engine, contentTexts, skipEmbed)]);
3650
+ const events = [];
3651
+ for (let i = 0; i < extractedEvents.length; i++) {
3652
+ const extracted = extractedEvents[i];
3653
+ const chunk = chunks[i];
3654
+ const event = await store.insertEvent({
3655
+ sourceId: source.id,
3656
+ documentId: document.id,
3657
+ chunkId: chunk.id,
3658
+ rank: i,
3659
+ title: extracted.title,
3660
+ summary: extracted.summary.length > 0 ? extracted.summary : null,
3661
+ content: extracted.content,
3662
+ category: extracted.category.length > 0 ? extracted.category : null,
3663
+ keywords: extracted.keywords,
3664
+ titleEmbedding: titleEmbeddings[i] ?? null,
3665
+ contentEmbedding: contentEmbeddings[i] ?? null
3666
+ });
3667
+ events.push(event);
3668
+ onProgress?.({
3669
+ stage: "embedding-events",
3670
+ chunkIndex: i + 1,
3671
+ totalChunks: sections.length,
3672
+ message: `嵌入 events: ${i + 1}/${sections.length}`
3673
+ });
3674
+ }
3675
+ onProgress?.({
3676
+ stage: "embedding-entities",
3677
+ message: "嵌入 entities..."
3678
+ });
3679
+ const entityMap = /* @__PURE__ */ new Map();
3680
+ for (const extracted of extractedEvents) for (const entity of extracted.entities) {
3681
+ const key = `${entity.type}|${entity.name.toLowerCase()}`;
3682
+ if (!entityMap.has(key)) entityMap.set(key, entity);
3683
+ }
3684
+ const uniqueEntities = Array.from(entityMap.values());
3685
+ const entityEmbeddings = uniqueEntities.length > 0 ? await embedOrNulls(engine, uniqueEntities.map((e) => e.name), skipEmbed) : [];
3686
+ const entityIdByEntityKey = /* @__PURE__ */ new Map();
3687
+ for (let i = 0; i < uniqueEntities.length; i++) {
3688
+ const entity = uniqueEntities[i];
3689
+ const embedding = entityEmbeddings[i] ?? null;
3690
+ const stored = await store.upsertEntity({
3691
+ sourceId: source.id,
3692
+ type: entity.type,
3693
+ name: entity.name,
3694
+ description: entity.description.length > 0 ? entity.description : null,
3695
+ embedding
3696
+ });
3697
+ entityIdByEntityKey.set(`${entity.type}|${entity.name.toLowerCase()}`, stored.id);
3698
+ }
3699
+ onProgress?.({
3700
+ stage: "embedding-relations",
3701
+ message: "嵌入关系..."
3702
+ });
3703
+ const relationPairs = [];
3704
+ for (let i = 0; i < extractedEvents.length; i++) {
3705
+ const extracted = extractedEvents[i];
3706
+ for (const entity of extracted.entities) relationPairs.push({
3707
+ eventIndex: i,
3708
+ entity
3709
+ });
3710
+ }
3711
+ const relationTexts = relationPairs.map(({ eventIndex, entity }) => {
3712
+ const event = events[eventIndex];
3713
+ return entity.description.length > 0 ? entity.description : `${event.title} ${entity.name}`;
3714
+ });
3715
+ const relationEmbeddings = relationPairs.length > 0 ? await embedOrNulls(engine, relationTexts, skipEmbed) : [];
3716
+ for (let i = 0; i < relationPairs.length; i++) {
3717
+ const pair = relationPairs[i];
3718
+ const event = events[pair.eventIndex];
3719
+ const entityKey = `${pair.entity.type}|${pair.entity.name.toLowerCase()}`;
3720
+ const entityId = entityIdByEntityKey.get(entityKey);
3721
+ if (entityId === void 0) continue;
3722
+ const embedding = relationEmbeddings?.[i] ?? null;
3723
+ await store.insertEventEntity({
3724
+ eventId: event.id,
3725
+ entityId,
3726
+ weight: 1,
3727
+ description: pair.entity.description.length > 0 ? pair.entity.description : null,
3728
+ embedding
3729
+ });
3730
+ }
3731
+ await store.updateDocumentStatus(document.id, "completed", chunks.length);
3732
+ store.commitTransaction();
3733
+ onProgress?.({
3734
+ stage: "completed",
3735
+ message: `摄入完成:${chunks.length} chunks, ${events.length} events, ${uniqueEntities.length} entities`
3736
+ });
3737
+ return {
3738
+ chunks,
3739
+ events,
3740
+ uniqueEntities
3741
+ };
3742
+ }
3743
+ async function collectSupportedFiles(dirPath) {
3744
+ const files = [];
3745
+ const entries = await readdir(dirPath, { withFileTypes: true });
3746
+ for (const entry of entries) {
3747
+ const fullPath = join$1(dirPath, entry.name);
3748
+ if (entry.isDirectory()) files.push(...await collectSupportedFiles(fullPath));
3749
+ else if (entry.isFile() && isSupportedFile(fullPath)) files.push(fullPath);
3750
+ }
3751
+ return files.sort();
3752
+ }
3753
+ async function ingestDirectory(store, llm, dirPath, onProgress) {
3754
+ const files = await collectSupportedFiles(dirPath);
3755
+ if (files.length === 0) throw new Error(`目录下没有支持的文件 (.md, .markdown, .txt): ${dirPath}`);
3756
+ const errors = [];
3757
+ let succeeded = 0;
3758
+ let created = 0;
3759
+ let updated = 0;
3760
+ let unchanged = 0;
3761
+ let totalChunks = 0;
3762
+ let totalEvents = 0;
3763
+ let totalEntities = 0;
3764
+ for (let i = 0; i < files.length; i++) {
3765
+ const filePath = files[i];
3766
+ const fileProgress = (progress) => {
3767
+ const prefix = `批量摄入 ${i + 1}/${files.length}: ${basename$1(filePath)}`;
3768
+ onProgress?.({
3769
+ ...progress,
3770
+ message: `${prefix} · ${progress.message}`
3771
+ });
3772
+ };
3773
+ try {
3774
+ const result = await ingestFile(store, llm, filePath, fileProgress);
3775
+ succeeded += 1;
3776
+ if (result.outcome === "created") created += 1;
3777
+ else if (result.outcome === "updated") updated += 1;
3778
+ else unchanged += 1;
3779
+ totalChunks += result.chunkCount;
3780
+ totalEvents += result.eventCount;
3781
+ totalEntities += result.entityCount;
3782
+ } catch (error) {
3783
+ const message = error instanceof Error ? error.message : String(error);
3784
+ errors.push({
3785
+ filePath,
3786
+ message
3787
+ });
3788
+ onProgress?.({
3789
+ stage: "error",
3790
+ message: `批量摄入 ${i + 1}/${files.length}: ${basename$1(filePath)} · 失败: ${message}`
3791
+ });
3792
+ }
3793
+ }
3794
+ onProgress?.({
3795
+ stage: "completed",
3796
+ message: `批量摄入完成:新增 ${created} · 更新 ${updated} · 未变化 ${unchanged} · 失败 ${errors.length}(${totalChunks} chunks, ${totalEvents} events, ${totalEntities} entities)`
3797
+ });
3798
+ return {
3799
+ succeeded,
3800
+ failed: errors.length,
3801
+ created,
3802
+ updated,
3803
+ unchanged,
3804
+ totalChunks,
3805
+ totalEvents,
3806
+ totalEntities,
3807
+ errors
3808
+ };
3809
+ }
3810
+ //#endregion
3811
+ //#region ../../packages/knowledge/src/search.ts
3812
+ const DEFAULT_TOP_K = 5;
3813
+ const MAX_TOP_K = 20;
3814
+ const SEED_EVENT_LIMIT = 30;
3815
+ const EXPANDED_EVENT_LIMIT = 100;
3816
+ const COARSE_RANK_LIMIT = 50;
3817
+ const ENTITY_VECTOR_THRESHOLD = .7;
3818
+ const TITLE_VECTOR_THRESHOLD = .4;
3819
+ async function timed(onStep, step, detail, fn, payload) {
3820
+ const start = performance.now();
3821
+ try {
3822
+ const result = await fn();
3823
+ const traceStep = {
3824
+ step,
3825
+ detail,
3826
+ durationMs: Math.round((performance.now() - start) * 100) / 100,
3827
+ ...payload !== void 0 ? { payload: payload(result) } : {}
3828
+ };
3829
+ onStep?.(traceStep);
3830
+ return result;
3831
+ } catch (error) {
3832
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
3833
+ onStep?.({
3834
+ step,
3835
+ detail: `${detail} 失败:${error instanceof Error ? error.message : String(error)}`,
3836
+ durationMs
3837
+ });
3838
+ throw error;
3839
+ }
3840
+ }
3841
+ /**
3842
+ * Multi-hop retrieval:
3843
+ * 1. Vectorize query.
3844
+ * 2. Entity recall — LLM extracts entities from query → match by name + vector.
3845
+ * 3. Seed events — events linked to recalled entities + events by title vector similarity.
3846
+ * 4. BFS expand (1 hop) — from seed events, walk to entities, then to other events.
3847
+ * 5. Coarse rank — score graph-reachable events by content embedding similarity.
3848
+ * Graph associativity is the gate; content vector orders within it.
3849
+ * 6. LLM rerank — pick top-K most relevant.
3850
+ * 7. Return corresponding chunks (deduped by chunk_id) with scores and provenance.
3851
+ * If rerank yields fewer than topK chunks, backfill by direct chunk vector search.
3852
+ *
3853
+ * Optional `onStep` callback receives a trace step at each phase — used by the
3854
+ * KnowledgeLookup tool to expose retrieval diagnostics to the agent.
3855
+ */
3856
+ async function multiSearch(store, llm, query, options = {}) {
3857
+ return (await multiSearchWithTrace(store, llm, query, options)).results;
3858
+ }
3859
+ /** Like multiSearch but also returns the retrieval trace for diagnostics. */
3860
+ async function multiSearchWithTrace(store, llm, query, options = {}) {
3861
+ const topK = Math.min(options.topK ?? DEFAULT_TOP_K, MAX_TOP_K);
3862
+ const skipLlm = options.skipLlm === true;
3863
+ const skipRerank = skipLlm || options.skipRerank === true;
3864
+ const steps = [];
3865
+ const onStep = (step) => steps.push(step);
3866
+ const rerankedEventTitles = [];
3867
+ let fallbackReason = null;
3868
+ const engine = store.getEmbeddingEngine();
3869
+ if (engine === void 0 || !engine.available) {
3870
+ fallbackReason = "embedding engine unavailable; used FTS5 keyword fallback";
3871
+ return {
3872
+ results: await ftsFallback(store, query, topK),
3873
+ trace: {
3874
+ steps,
3875
+ rerankedEventTitles,
3876
+ fallbackReason
3877
+ }
3878
+ };
3879
+ }
3880
+ const recordedModel = await store.getMeta(EMBEDDING_MODEL_META_KEY);
3881
+ if (recordedModel !== null && recordedModel !== engine.modelName) {
3882
+ fallbackReason = `embedding model mismatch (library: ${recordedModel}, current: ${engine.modelName}); used FTS5 keyword fallback`;
3883
+ return {
3884
+ results: await ftsFallback(store, query, topK),
3885
+ trace: {
3886
+ steps,
3887
+ rerankedEventTitles,
3888
+ fallbackReason
3889
+ }
3890
+ };
3891
+ }
3892
+ const queryEmbeddings = await timed(onStep, "queryEmbedding", "把用户问题转成向量,用于召回相关事件和切片。", async () => engine.embedBatch([query]));
3893
+ if (queryEmbeddings === null || queryEmbeddings.length === 0) {
3894
+ fallbackReason = "query embedding returned null; used FTS5 fallback";
3895
+ return {
3896
+ results: await ftsFallback(store, query, topK),
3897
+ trace: {
3898
+ steps,
3899
+ rerankedEventTitles,
3900
+ fallbackReason
3901
+ }
3902
+ };
3903
+ }
3904
+ const queryVec = queryEmbeddings[0];
3905
+ const recalledEntities = await timed(onStep, "entityRecall", skipLlm ? "本地向量实体召回(skipLlm,不调 LLM)。" : "LLM 抽 query 实体 + 名字精确匹配 + 向量召回。", () => recallEntities(store, llm, query, queryVec, !skipLlm), (entities) => ({ count: entities.length }));
3906
+ const seedEventIds = /* @__PURE__ */ new Set();
3907
+ for (const entity of recalledEntities) {
3908
+ const events = await store.findEventsByEntity(entity.id);
3909
+ for (const event of events) seedEventIds.add(event.id);
3910
+ }
3911
+ const titleMatches = await timed(onStep, "seedEventsByTitle", "按查询向量在事件标题向量上召回 seed events。", () => store.findEventsByTitleVector(queryVec, {
3912
+ limit: SEED_EVENT_LIMIT,
3913
+ threshold: TITLE_VECTOR_THRESHOLD
3914
+ }), (matches) => ({ count: matches.length }));
3915
+ for (const { event } of titleMatches) seedEventIds.add(event.id);
3916
+ if (seedEventIds.size === 0) {
3917
+ const results = await chunkVectorFallback(store, queryVec, topK);
3918
+ if (results.length > 0) {
3919
+ fallbackReason = "no seed events; used direct chunk vector search";
3920
+ return {
3921
+ results,
3922
+ trace: {
3923
+ steps,
3924
+ rerankedEventTitles,
3925
+ fallbackReason
3926
+ }
3927
+ };
3928
+ }
3929
+ fallbackReason = "no seed events and direct chunk vector search returned no results; used FTS5 keyword fallback";
3930
+ return {
3931
+ results: await ftsFallback(store, query, topK),
3932
+ trace: {
3933
+ steps,
3934
+ rerankedEventTitles,
3935
+ fallbackReason
3936
+ }
3937
+ };
3938
+ }
3939
+ const expandedEventIds = await timed(onStep, "bfsExpand", "从 seed events 沿实体关系 1 跳扩展候选事件。", async () => {
3940
+ const ids = new Set(seedEventIds);
3941
+ let capped = false;
3942
+ for (const eventId of seedEventIds) {
3943
+ const entities = await store.findEntitiesByEvent(eventId);
3944
+ for (const entity of entities) {
3945
+ const neighborEvents = await store.findEventsByEntity(entity.id);
3946
+ for (const neighbor of neighborEvents) {
3947
+ ids.add(neighbor.id);
3948
+ if (ids.size >= EXPANDED_EVENT_LIMIT) {
3949
+ capped = true;
3950
+ break;
3951
+ }
3952
+ }
3953
+ if (ids.size >= EXPANDED_EVENT_LIMIT) break;
3954
+ }
3955
+ if (ids.size >= EXPANDED_EVENT_LIMIT) break;
3956
+ }
3957
+ return {
3958
+ ids,
3959
+ capped
3960
+ };
3961
+ }, (result) => ({
3962
+ expandedCount: result.ids.size,
3963
+ capped: result.capped
3964
+ }));
3965
+ const { candidates, topCandidates } = await timed(onStep, "coarseRank", "按 content 向量相似度对候选事件排序,取前 COARSE_RANK_LIMIT 个。", async () => {
3966
+ const cands = [];
3967
+ for (const eventId of expandedEventIds.ids) {
3968
+ const event = await store.getEvent(eventId);
3969
+ if (event === void 0) continue;
3970
+ const vec = event.contentEmbedding;
3971
+ const score = vec === null ? 0 : engine.cosineSimilarity(queryVec, vec);
3972
+ cands.push({
3973
+ id: event.id,
3974
+ title: event.title,
3975
+ summary: event.summary ?? "",
3976
+ score,
3977
+ chunkId: event.chunkId
3978
+ });
3979
+ }
3980
+ cands.sort((a, b) => b.score - a.score);
3981
+ return {
3982
+ candidates: cands,
3983
+ topCandidates: cands.slice(0, COARSE_RANK_LIMIT)
3984
+ };
3985
+ }, (result) => ({
3986
+ totalCandidates: result.candidates.length,
3987
+ kept: result.topCandidates.length,
3988
+ topScore: result.topCandidates[0]?.score ?? 0
3989
+ }));
3990
+ if (candidates.length === 0) {
3991
+ const results = await chunkVectorFallback(store, queryVec, topK);
3992
+ if (results.length > 0) {
3993
+ fallbackReason = "no graph-reachable events with content; used chunk vector fallback";
3994
+ return {
3995
+ results,
3996
+ trace: {
3997
+ steps,
3998
+ rerankedEventTitles,
3999
+ fallbackReason
4000
+ }
4001
+ };
4002
+ }
4003
+ fallbackReason = "no graph-reachable events with content and chunk vector search returned no results; used FTS5 keyword fallback";
4004
+ return {
4005
+ results: await ftsFallback(store, query, topK),
4006
+ trace: {
4007
+ steps,
4008
+ rerankedEventTitles,
4009
+ fallbackReason
4010
+ }
4011
+ };
4012
+ }
4013
+ let rankedIds;
4014
+ if (skipRerank) {
4015
+ rankedIds = topCandidates.map((c) => c.id);
4016
+ onStep({
4017
+ step: "rerank",
4018
+ detail: "跳过 LLM rerank(skipRerank/skipLlm),直接用 coarse rank 顺序。",
4019
+ durationMs: 0,
4020
+ payload: { count: rankedIds.length }
4021
+ });
4022
+ } else if (topCandidates.length <= topK) {
4023
+ rankedIds = topCandidates.map((c) => c.id);
4024
+ onStep({
4025
+ step: "rerank",
4026
+ detail: `候选数 ${topCandidates.length} ≤ topK ${topK},无需 LLM rerank。`,
4027
+ durationMs: 0,
4028
+ payload: { count: rankedIds.length }
4029
+ });
4030
+ } else rankedIds = await timed(onStep, "rerank", `LLM 从 ${topCandidates.length} 个候选中选最相关的 ${topK} 个。`, () => rerankEventsWithLlm(llm, query, topCandidates.map((c) => ({
4031
+ id: c.id,
4032
+ title: c.title,
4033
+ summary: c.summary
4034
+ })), topK), (ids) => ({ count: ids.length }));
4035
+ for (const id of rankedIds) {
4036
+ const c = topCandidates.find((x) => x.id === id);
4037
+ if (c !== void 0) rerankedEventTitles.push(c.title);
4038
+ }
4039
+ const seen = /* @__PURE__ */ new Set();
4040
+ const results = [];
4041
+ for (const eventId of rankedIds) {
4042
+ const candidate = topCandidates.find((c) => c.id === eventId);
4043
+ if (candidate === void 0) continue;
4044
+ if (seen.has(candidate.chunkId)) continue;
4045
+ seen.add(candidate.chunkId);
4046
+ const result = await store.buildSearchResult(candidate.chunkId, candidate.score, eventId);
4047
+ if (result !== void 0) results.push(result);
4048
+ if (results.length >= topK) break;
4049
+ }
4050
+ if (results.length < topK) {
4051
+ let supplemented = 0;
4052
+ for (const candidate of topCandidates) {
4053
+ if (results.length >= topK) break;
4054
+ if (rankedIds.includes(candidate.id)) continue;
4055
+ if (seen.has(candidate.chunkId)) continue;
4056
+ seen.add(candidate.chunkId);
4057
+ const result = await store.buildSearchResult(candidate.chunkId, candidate.score, candidate.id);
4058
+ if (result !== void 0) {
4059
+ results.push(result);
4060
+ supplemented += 1;
4061
+ }
4062
+ }
4063
+ if (supplemented > 0) onStep({
4064
+ step: "supplementFromCoarse",
4065
+ detail: `rerank 结果不足,从 coarse rank 残余补 ${supplemented} 个。`,
4066
+ durationMs: 0,
4067
+ payload: { supplemented }
4068
+ });
4069
+ }
4070
+ if (results.length < topK) {
4071
+ const remaining = topK - results.length;
4072
+ const backfill = await timed(onStep, "backfill", `rerank 结果不足,回退到 chunk 向量搜索补 ${remaining} 个。`, () => store.searchChunksByVector(queryVec, { limit: remaining * 2 }), (matches) => ({ backfillCandidates: matches.length }));
4073
+ for (const { chunk, score } of backfill) {
4074
+ if (results.length >= topK) break;
4075
+ if (seen.has(chunk.id)) continue;
4076
+ seen.add(chunk.id);
4077
+ const result = await store.buildSearchResult(chunk.id, score);
4078
+ if (result !== void 0) results.push(result);
4079
+ }
4080
+ }
4081
+ if (results.length === 0) {
4082
+ fallbackReason = "vector retrieval returned no results; used FTS5 keyword fallback";
4083
+ return {
4084
+ results: await ftsFallback(store, query, topK),
4085
+ trace: {
4086
+ steps,
4087
+ rerankedEventTitles,
4088
+ fallbackReason
4089
+ }
4090
+ };
4091
+ }
4092
+ return {
4093
+ results,
4094
+ trace: {
4095
+ steps,
4096
+ rerankedEventTitles,
4097
+ fallbackReason
4098
+ }
4099
+ };
4100
+ }
4101
+ /** Recall entities by name (LLM-extracted) and by vector similarity. */
4102
+ async function recallEntities(store, llm, query, queryVec, useLlm) {
4103
+ const out = /* @__PURE__ */ new Map();
4104
+ if (useLlm) {
4105
+ const queryEntities = await extractQueryEntities(llm, query);
4106
+ for (const { name } of queryEntities) {
4107
+ const matches = await store.findEntitiesByName(name);
4108
+ for (const entity of matches) out.set(entity.id, { id: entity.id });
4109
+ }
4110
+ }
4111
+ const vectorMatches = await store.findEntitiesByVector(queryVec, {
4112
+ limit: 20,
4113
+ threshold: ENTITY_VECTOR_THRESHOLD
4114
+ });
4115
+ for (const { entity } of vectorMatches) out.set(entity.id, { id: entity.id });
4116
+ return Array.from(out.values());
4117
+ }
4118
+ /** FTS5 keyword fallback when embeddings are unavailable. */
4119
+ async function ftsFallback(store, query, topK) {
4120
+ const hits = await store.ftsSearchChunks(query, topK * 2);
4121
+ const results = [];
4122
+ for (const { chunk, score } of hits) {
4123
+ const result = await store.buildSearchResult(chunk.id, score);
4124
+ if (result !== void 0) results.push(result);
4125
+ if (results.length >= topK) break;
4126
+ }
4127
+ return results;
4128
+ }
4129
+ /** Direct chunk vector search fallback. */
4130
+ async function chunkVectorFallback(store, queryVec, topK) {
4131
+ const matches = await store.searchChunksByVector(queryVec, { limit: topK * 2 });
4132
+ const results = [];
4133
+ for (const { chunk, score } of matches) {
4134
+ const result = await store.buildSearchResult(chunk.id, score);
4135
+ if (result !== void 0) results.push(result);
4136
+ if (results.length >= topK) break;
4137
+ }
4138
+ return results;
4139
+ }
4140
+ //#endregion
4141
+ export { classifyEmbeddingFailure as A, rankMemos as B, applyConsolidation as C, parseMemoryMemos as D, buildExitExtractionPrompt as E, createMemoryMemo as F, normalize as G, dirname$1 as H, toSummary as I, resolve$1 as J, parse as K, generateTags as L, createFastEmbedEngine as M, hasEmbeddingModelCache as N, stripMemoryTags as O, probeLocalEmbeddingSupport as P, normalizeTags as R, DreamTracker as S, EXIT_EXTRACTION_SYSTEM_PROMPT as T, isAbsolute$1 as U, basename$1 as V, join$1 as W, splitLargeSection as _, isSupportedFile as a, sharedKnowledgeStoreReady as b, buildExtractionUserPrompt as c, extractQueryEntities as d, parseExtractionResponse as f, estimateTokens as g, chunkText as h, ingestFile as i, clearEmbeddingModelCache as j, MemoryMemoStore as k, extractEventFromChunk as l, chunkMarkdown as m, multiSearchWithTrace as n, ENTITY_TYPES as o, rerankEventsWithLlm as p, relative$1 as q, ingestDirectory as r, EXTRACTION_SYSTEM_PROMPT as s, multiSearch as t, extractJsonFromText as u, stripMarkdown as v, buildConsolidationPlan as w, KnowledgeStore as x, sharedKnowledgeStore as y, buildProjectTagCloud as z };