openclaw-cortex-memory 0.1.0-Alpha.4 → 0.1.0-Alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +84 -3
  2. package/SKILL.md +58 -3
  3. package/dist/index.d.ts +18 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +118 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/openclaw.plugin.json +102 -2
  8. package/dist/src/dedup/three_stage_deduplicator.d.ts +25 -0
  9. package/dist/src/dedup/three_stage_deduplicator.d.ts.map +1 -0
  10. package/dist/src/dedup/three_stage_deduplicator.js +225 -0
  11. package/dist/src/dedup/three_stage_deduplicator.js.map +1 -0
  12. package/dist/src/engine/ts_engine.d.ts +36 -0
  13. package/dist/src/engine/ts_engine.d.ts.map +1 -1
  14. package/dist/src/engine/ts_engine.js +197 -32
  15. package/dist/src/engine/ts_engine.js.map +1 -1
  16. package/dist/src/engine/types.d.ts +4 -0
  17. package/dist/src/engine/types.d.ts.map +1 -1
  18. package/dist/src/graph/ontology.d.ts +53 -0
  19. package/dist/src/graph/ontology.d.ts.map +1 -0
  20. package/dist/src/graph/ontology.js +252 -0
  21. package/dist/src/graph/ontology.js.map +1 -0
  22. package/dist/src/session/session_end.d.ts +55 -0
  23. package/dist/src/session/session_end.d.ts.map +1 -1
  24. package/dist/src/session/session_end.js +237 -51
  25. package/dist/src/session/session_end.js.map +1 -1
  26. package/dist/src/store/archive_store.d.ts +89 -0
  27. package/dist/src/store/archive_store.d.ts.map +1 -0
  28. package/dist/src/store/archive_store.js +242 -0
  29. package/dist/src/store/archive_store.js.map +1 -0
  30. package/dist/src/store/read_store.d.ts +24 -0
  31. package/dist/src/store/read_store.d.ts.map +1 -1
  32. package/dist/src/store/read_store.js +636 -27
  33. package/dist/src/store/read_store.js.map +1 -1
  34. package/dist/src/store/vector_store.d.ts +30 -0
  35. package/dist/src/store/vector_store.d.ts.map +1 -0
  36. package/dist/src/store/vector_store.js +128 -0
  37. package/dist/src/store/vector_store.js.map +1 -0
  38. package/dist/src/sync/session_sync.d.ts +7 -0
  39. package/dist/src/sync/session_sync.d.ts.map +1 -1
  40. package/dist/src/sync/session_sync.js +109 -7
  41. package/dist/src/sync/session_sync.js.map +1 -1
  42. package/openclaw.plugin.json +102 -2
  43. package/package.json +7 -3
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.createReadStore = createReadStore;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const module_1 = require("module");
39
40
  function safeReadFile(filePath) {
40
41
  try {
41
42
  if (!fs.existsSync(filePath)) {
@@ -114,12 +115,35 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
114
115
  }
115
116
  const id = typeof parsed.id === "string" ? parsed.id : `${sourceLabel}:${docs.length + 1}`;
116
117
  const timestampValue = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
118
+ const entities = Array.isArray(parsed.entities)
119
+ ? parsed.entities.map(item => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
120
+ : [];
121
+ const relations = Array.isArray(parsed.relations)
122
+ ? parsed.relations
123
+ .map(item => {
124
+ if (typeof item !== "object" || item === null)
125
+ return null;
126
+ const relation = item;
127
+ const source = typeof relation.source === "string" ? relation.source.trim() : "";
128
+ const target = typeof relation.target === "string" ? relation.target.trim() : "";
129
+ const type = typeof relation.type === "string" ? relation.type.trim() : "related_to";
130
+ if (!source || !target)
131
+ return null;
132
+ return { source, target, type };
133
+ })
134
+ .filter((item) => Boolean(item))
135
+ : [];
117
136
  docs.push({
118
137
  id,
119
138
  text,
120
139
  source: sourceLabel,
121
140
  timestamp: Number.isFinite(timestampValue) ? timestampValue : undefined,
122
141
  embedding: Array.isArray(parsed.embedding) ? parsed.embedding.filter(item => Number.isFinite(item)) : undefined,
142
+ eventType: typeof parsed.event_type === "string" ? parsed.event_type.trim() : undefined,
143
+ qualityScore: typeof parsed.quality_score === "number" ? parsed.quality_score : undefined,
144
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : undefined,
145
+ entities,
146
+ relations,
123
147
  });
124
148
  }
125
149
  catch (error) {
@@ -161,6 +185,78 @@ function withRecencyBoost(score, timestamp) {
161
185
  }
162
186
  return score;
163
187
  }
188
+ function recencyScore(timestamp) {
189
+ if (!timestamp) {
190
+ return 0;
191
+ }
192
+ const ageHours = (Date.now() - timestamp) / (1000 * 60 * 60);
193
+ if (ageHours < 12)
194
+ return 1;
195
+ if (ageHours < 24)
196
+ return 0.8;
197
+ if (ageHours < 72)
198
+ return 0.6;
199
+ if (ageHours < 168)
200
+ return 0.4;
201
+ if (ageHours < 720)
202
+ return 0.2;
203
+ return 0.05;
204
+ }
205
+ function eventTypeHalfLifeDays(eventType, options) {
206
+ const fallback = typeof options?.defaultHalfLifeDays === "number" && options.defaultHalfLifeDays > 0
207
+ ? options.defaultHalfLifeDays
208
+ : 90;
209
+ const type = (eventType || "").trim().toLowerCase();
210
+ if (!type)
211
+ return fallback;
212
+ const configured = options?.halfLifeByEventType || {};
213
+ if (typeof configured[type] === "number" && configured[type] > 0) {
214
+ return configured[type];
215
+ }
216
+ if (["issue", "fix", "action_item", "blocker"].includes(type))
217
+ return 30;
218
+ if (["plan", "milestone", "follow_up"].includes(type))
219
+ return 60;
220
+ if (["decision", "insight", "retrospective"].includes(type))
221
+ return 120;
222
+ if (["preference", "constraint", "requirement", "dependency", "assumption"].includes(type))
223
+ return 240;
224
+ return fallback;
225
+ }
226
+ function computeAntiDecayBoost(id, hitStats, options) {
227
+ const anti = options?.antiDecay;
228
+ if (anti?.enabled === false) {
229
+ return 1;
230
+ }
231
+ const item = hitStats.items[id];
232
+ if (!item) {
233
+ return 1;
234
+ }
235
+ const hitWeight = typeof anti?.hitWeight === "number" && anti.hitWeight > 0 ? anti.hitWeight : 0.08;
236
+ const maxBoost = typeof anti?.maxBoost === "number" && anti.maxBoost >= 1 ? anti.maxBoost : 1.6;
237
+ const recentWindowDays = typeof anti?.recentWindowDays === "number" && anti.recentWindowDays > 0 ? anti.recentWindowDays : 30;
238
+ const lastHitTs = Date.parse(item.lastHitAt || "");
239
+ const ageDays = Number.isFinite(lastHitTs) ? Math.max(0, (Date.now() - lastHitTs) / (1000 * 60 * 60 * 24)) : recentWindowDays * 2;
240
+ const freshness = ageDays <= recentWindowDays ? (1 - ageDays / recentWindowDays) : 0;
241
+ const countFactor = Math.log1p(Math.max(0, item.count));
242
+ const boost = 1 + countFactor * hitWeight * (0.5 + 0.5 * freshness);
243
+ return Math.min(maxBoost, Math.max(1, boost));
244
+ }
245
+ function computeDecayFactor(id, eventType, timestamp, options, hitStats) {
246
+ const enabled = options?.enabled !== false;
247
+ if (!enabled || !timestamp) {
248
+ return computeAntiDecayBoost(id, hitStats, options);
249
+ }
250
+ const ageDays = Math.max(0, (Date.now() - timestamp) / (1000 * 60 * 60 * 24));
251
+ const halfLife = eventTypeHalfLifeDays(eventType, options);
252
+ const base = Math.pow(2, -ageDays / Math.max(1, halfLife));
253
+ const floor = typeof options?.minFloor === "number"
254
+ ? Math.max(0, Math.min(1, options.minFloor))
255
+ : 0.15;
256
+ const decay = Math.max(floor, base);
257
+ const boost = computeAntiDecayBoost(id, hitStats, options);
258
+ return Math.min(1, decay * boost);
259
+ }
164
260
  function normalizeBaseUrl(value) {
165
261
  if (!value)
166
262
  return "";
@@ -287,8 +383,314 @@ async function requestRerank(args) {
287
383
  }
288
384
  throw lastError instanceof Error ? lastError : new Error(String(lastError || "rerank_failed"));
289
385
  }
386
+ function classifyIntent(query) {
387
+ const text = query.toLowerCase();
388
+ const relationHints = /(关系|依赖|关联|上下游|graph|relation|entity|拓扑)/i;
389
+ if (relationHints.test(text))
390
+ return "RELATION_DISCOVERY";
391
+ const troubleHints = /(报错|错误|异常|失败|超时|无法|崩溃|error|failed|timeout|fix)/i;
392
+ if (troubleHints.test(text))
393
+ return "TROUBLESHOOTING";
394
+ const preferenceHints = /(偏好|习惯|口味|喜欢|不喜欢|偏向|preference)/i;
395
+ if (preferenceHints.test(text))
396
+ return "PREFERENCE_PROFILE";
397
+ const timelineHints = /(最近|上次|之前|时间线|timeline|history)/i;
398
+ if (timelineHints.test(text))
399
+ return "TIMELINE_REVIEW";
400
+ const decisionHints = /(方案|决策|选择|建议|取舍|tradeoff|plan)/i;
401
+ if (decisionHints.test(text))
402
+ return "DECISION_SUPPORT";
403
+ return "FACT_LOOKUP";
404
+ }
405
+ function preferredEventTypes(intent) {
406
+ if (intent === "TROUBLESHOOTING")
407
+ return ["issue", "fix", "risk", "blocker", "dependency", "retrospective"];
408
+ if (intent === "PREFERENCE_PROFILE")
409
+ return ["preference", "decision", "constraint", "requirement"];
410
+ if (intent === "DECISION_SUPPORT")
411
+ return ["decision", "plan", "insight", "assumption", "constraint", "requirement"];
412
+ if (intent === "TIMELINE_REVIEW")
413
+ return ["action_item", "follow_up", "milestone", "plan", "decision", "issue", "fix"];
414
+ return [];
415
+ }
416
+ function sourceWeight(source, intent) {
417
+ if (source === "rules") {
418
+ return intent === "DECISION_SUPPORT" || intent === "TROUBLESHOOTING" ? 1.15 : 0.9;
419
+ }
420
+ if (source === "graph") {
421
+ return intent === "RELATION_DISCOVERY" ? 1.25 : 0.85;
422
+ }
423
+ if (source === "vector") {
424
+ return 1.05;
425
+ }
426
+ return 1;
427
+ }
428
+ async function searchLanceDb(args) {
429
+ try {
430
+ const require = (0, module_1.createRequire)(__filename);
431
+ const lancedbDir = path.join(args.memoryRoot, "vector", "lancedb");
432
+ if (!fs.existsSync(lancedbDir)) {
433
+ return [];
434
+ }
435
+ const moduleValue = require("@lancedb/lancedb");
436
+ const connect = moduleValue.connect;
437
+ if (typeof connect !== "function") {
438
+ return [];
439
+ }
440
+ const db = await connect(lancedbDir);
441
+ if (!db || typeof db.openTable !== "function") {
442
+ return [];
443
+ }
444
+ const table = await db.openTable("events");
445
+ if (!table || typeof table.search !== "function") {
446
+ return [];
447
+ }
448
+ const searchObj = table.search(args.queryEmbedding);
449
+ if (!searchObj || typeof searchObj.limit !== "function") {
450
+ return [];
451
+ }
452
+ const limited = searchObj.limit(args.limit);
453
+ if (!limited || typeof limited.toArray !== "function") {
454
+ return [];
455
+ }
456
+ const rows = await limited.toArray();
457
+ const docs = [];
458
+ for (const row of rows) {
459
+ if (typeof row !== "object" || row === null)
460
+ continue;
461
+ const record = row;
462
+ const id = typeof record.id === "string" ? record.id : "";
463
+ const summary = typeof record.summary === "string" ? record.summary : "";
464
+ if (!id || !summary)
465
+ continue;
466
+ const ts = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
467
+ const entities = typeof record.entities_json === "string"
468
+ ? JSON.parse(record.entities_json).filter(item => typeof item === "string" && item.trim())
469
+ : [];
470
+ const relations = typeof record.relations_json === "string"
471
+ ? JSON.parse(record.relations_json)
472
+ : [];
473
+ docs.push({
474
+ id,
475
+ text: summary,
476
+ source: "vector_lancedb",
477
+ timestamp: Number.isFinite(ts) ? ts : undefined,
478
+ embedding: Array.isArray(record.vector) ? record.vector.filter(item => Number.isFinite(item)) : undefined,
479
+ eventType: typeof record.event_type === "string" ? record.event_type : undefined,
480
+ qualityScore: typeof record.quality_score === "number" ? record.quality_score : undefined,
481
+ sessionId: typeof record.session_id === "string" ? record.session_id : undefined,
482
+ entities,
483
+ relations: Array.isArray(relations) ? relations : [],
484
+ });
485
+ }
486
+ return docs;
487
+ }
488
+ catch (error) {
489
+ args.logger.debug(`LanceDB search fallback: ${error}`);
490
+ return [];
491
+ }
492
+ }
493
+ function parseVectorFallback(filePath, logger) {
494
+ const content = safeReadFile(filePath);
495
+ if (!content) {
496
+ return [];
497
+ }
498
+ const docs = [];
499
+ for (const line of content.split(/\r?\n/)) {
500
+ const trimmed = line.trim();
501
+ if (!trimmed)
502
+ continue;
503
+ try {
504
+ const parsed = JSON.parse(trimmed);
505
+ const id = typeof parsed.id === "string" ? parsed.id : "";
506
+ const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
507
+ if (!id || !summary)
508
+ continue;
509
+ const ts = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
510
+ const entities = Array.isArray(parsed.entities)
511
+ ? parsed.entities.map(item => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
512
+ : [];
513
+ const relations = Array.isArray(parsed.relations)
514
+ ? parsed.relations
515
+ .map(item => {
516
+ if (typeof item !== "object" || item === null)
517
+ return null;
518
+ const relation = item;
519
+ const source = typeof relation.source === "string" ? relation.source.trim() : "";
520
+ const target = typeof relation.target === "string" ? relation.target.trim() : "";
521
+ const type = typeof relation.type === "string" ? relation.type.trim() : "related_to";
522
+ if (!source || !target)
523
+ return null;
524
+ return { source, target, type };
525
+ })
526
+ .filter((item) => Boolean(item))
527
+ : [];
528
+ docs.push({
529
+ id,
530
+ text: summary,
531
+ source: "vector_jsonl",
532
+ timestamp: Number.isFinite(ts) ? ts : undefined,
533
+ embedding: Array.isArray(parsed.embedding) ? parsed.embedding.filter(item => Number.isFinite(item)) : undefined,
534
+ eventType: typeof parsed.event_type === "string" ? parsed.event_type.trim() : undefined,
535
+ qualityScore: typeof parsed.quality_score === "number" ? parsed.quality_score : undefined,
536
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : undefined,
537
+ entities,
538
+ relations,
539
+ });
540
+ }
541
+ catch (error) {
542
+ logger.debug(`Skip invalid vector jsonl line: ${error}`);
543
+ }
544
+ }
545
+ return docs;
546
+ }
547
+ async function requestFusion(args) {
548
+ const endpoint = args.llm.baseUrl.endsWith("/chat/completions")
549
+ ? args.llm.baseUrl
550
+ : `${args.llm.baseUrl}/chat/completions`;
551
+ const evidenceText = args.candidates
552
+ .map((item, index) => `${index + 1}. [${item.id}] (${item.source}, score=${item.score.toFixed(4)}) ${item.text}`)
553
+ .join("\n")
554
+ .slice(0, 18000);
555
+ const prompt = [
556
+ "你是记忆检索融合器。请融合多路召回结果,产出可直接给 Agent 使用的完整记忆包,不要让 Agent 再去翻历史。",
557
+ "必须严格返回 JSON:",
558
+ "{\"canonical_answer\": string, \"coverage_note\": string, \"facts\": [{\"text\": string, \"evidence_ids\": string[]}], \"timeline\": [{\"when\": string, \"event\": string, \"evidence_ids\": string[]}], \"entities\": [{\"name\": string, \"role\": string}], \"decisions\": [{\"decision\": string, \"rationale\": string, \"evidence_ids\": string[]}], \"fixes\": [{\"issue\": string, \"fix\": string, \"evidence_ids\": string[]}], \"preferences\": [{\"subject\": string, \"preference\": string, \"evidence_ids\": string[]}], \"risks\": [{\"risk\": string, \"mitigation\": string, \"evidence_ids\": string[]}], \"action_items\": [{\"item\": string, \"owner\": string, \"status\": string, \"evidence_ids\": string[]}], \"conflicts\": [{\"topic\": string, \"details\": string}], \"evidence_ids\": string[], \"confidence\": number}",
559
+ "要求:",
560
+ "1) canonical_answer 是完整可执行答案,不要只写摘要",
561
+ "2) facts 3-12 条,优先高分证据",
562
+ "3) evidence_ids 必须来自输入候选 id",
563
+ "4) 若存在冲突写入 conflicts,否则返回空数组",
564
+ "5) confidence 0~1",
565
+ "6) 不确定信息必须在 coverage_note 标注",
566
+ ].join("\n");
567
+ const body = {
568
+ model: args.llm.model,
569
+ temperature: 0.1,
570
+ messages: [
571
+ { role: "system", content: "你只输出 JSON,不要额外解释。" },
572
+ { role: "user", content: `${prompt}\n\n问题:\n${args.query}\n\n候选证据:\n${evidenceText}` },
573
+ ],
574
+ };
575
+ let lastError = null;
576
+ for (let attempt = 0; attempt < 2; attempt += 1) {
577
+ const controller = new AbortController();
578
+ const timeoutId = setTimeout(() => controller.abort(), 20000);
579
+ try {
580
+ const response = await fetch(endpoint, {
581
+ method: "POST",
582
+ headers: {
583
+ "content-type": "application/json",
584
+ authorization: `Bearer ${args.llm.apiKey}`,
585
+ },
586
+ body: JSON.stringify(body),
587
+ signal: controller.signal,
588
+ });
589
+ clearTimeout(timeoutId);
590
+ if (!response.ok) {
591
+ lastError = new Error(`fusion_http_${response.status}`);
592
+ continue;
593
+ }
594
+ const json = await response.json();
595
+ const content = json?.choices?.[0]?.message?.content?.trim() || "";
596
+ if (!content) {
597
+ lastError = new Error("fusion_empty");
598
+ continue;
599
+ }
600
+ const parsed = JSON.parse(content);
601
+ if (!parsed || typeof parsed.canonical_answer !== "string" || !parsed.canonical_answer.trim()) {
602
+ lastError = new Error("fusion_invalid");
603
+ continue;
604
+ }
605
+ const evidenceIds = Array.isArray(parsed.evidence_ids)
606
+ ? parsed.evidence_ids.filter(item => typeof item === "string" && item.trim())
607
+ : [];
608
+ return {
609
+ canonical_answer: parsed.canonical_answer.trim().slice(0, 6000),
610
+ coverage_note: typeof parsed.coverage_note === "string" ? parsed.coverage_note.trim().slice(0, 1200) : "",
611
+ facts: Array.isArray(parsed.facts) ? parsed.facts : [],
612
+ timeline: Array.isArray(parsed.timeline) ? parsed.timeline : [],
613
+ entities: Array.isArray(parsed.entities) ? parsed.entities : [],
614
+ decisions: Array.isArray(parsed.decisions) ? parsed.decisions : [],
615
+ fixes: Array.isArray(parsed.fixes) ? parsed.fixes : [],
616
+ preferences: Array.isArray(parsed.preferences) ? parsed.preferences : [],
617
+ risks: Array.isArray(parsed.risks) ? parsed.risks : [],
618
+ action_items: Array.isArray(parsed.action_items) ? parsed.action_items : [],
619
+ conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
620
+ evidence_ids: evidenceIds,
621
+ confidence: typeof parsed.confidence === "number"
622
+ ? Math.max(0, Math.min(1, parsed.confidence))
623
+ : 0.5,
624
+ };
625
+ }
626
+ catch (error) {
627
+ clearTimeout(timeoutId);
628
+ lastError = error;
629
+ }
630
+ }
631
+ throw lastError instanceof Error ? lastError : new Error(String(lastError || "fusion_failed"));
632
+ }
290
633
  function createReadStore(options) {
291
634
  const memoryRoot = options.dbPath ? path.resolve(options.dbPath) : path.join(options.projectRoot, "data", "memory");
635
+ const vectorFallbackPath = path.join(memoryRoot, "vector", "lancedb_events.jsonl");
636
+ const hitStatsPath = path.join(memoryRoot, ".read_hit_stats.json");
637
+ function loadHitStats() {
638
+ try {
639
+ if (!fs.existsSync(hitStatsPath)) {
640
+ return { items: {} };
641
+ }
642
+ const content = fs.readFileSync(hitStatsPath, "utf-8").trim();
643
+ if (!content) {
644
+ return { items: {} };
645
+ }
646
+ const parsed = JSON.parse(content);
647
+ if (!parsed || typeof parsed !== "object" || !parsed.items || typeof parsed.items !== "object") {
648
+ return { items: {} };
649
+ }
650
+ return parsed;
651
+ }
652
+ catch {
653
+ return { items: {} };
654
+ }
655
+ }
656
+ function saveHitStats(state) {
657
+ try {
658
+ const dir = path.dirname(hitStatsPath);
659
+ if (!fs.existsSync(dir)) {
660
+ fs.mkdirSync(dir, { recursive: true });
661
+ }
662
+ fs.writeFileSync(hitStatsPath, JSON.stringify(state, null, 2), "utf-8");
663
+ }
664
+ catch (error) {
665
+ options.logger.warn(`Failed to persist read hit stats: ${error}`);
666
+ }
667
+ }
668
+ function markHit(ids) {
669
+ if (!ids.length) {
670
+ return;
671
+ }
672
+ const state = loadHitStats();
673
+ const now = new Date().toISOString();
674
+ for (const id of ids) {
675
+ const key = (id || "").trim();
676
+ if (!key)
677
+ continue;
678
+ const prev = state.items[key];
679
+ state.items[key] = {
680
+ count: (prev?.count || 0) + 1,
681
+ lastHitAt: now,
682
+ };
683
+ }
684
+ const entries = Object.entries(state.items)
685
+ .sort((a, b) => {
686
+ const ta = Date.parse(a[1].lastHitAt || "");
687
+ const tb = Date.parse(b[1].lastHitAt || "");
688
+ return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0);
689
+ })
690
+ .slice(0, 20000);
691
+ state.items = Object.fromEntries(entries);
692
+ saveHitStats(state);
693
+ }
292
694
  function loadAllDocuments() {
293
695
  const cortexRulesPath = path.join(memoryRoot, "CORTEX_RULES.md");
294
696
  const memoryMdPath = path.join(memoryRoot, "MEMORY.md");
@@ -307,6 +709,9 @@ function createReadStore(options) {
307
709
  return { results: [] };
308
710
  }
309
711
  const docs = loadAllDocuments();
712
+ const hitStats = loadHitStats();
713
+ const intent = classifyIntent(query);
714
+ const preferredTypes = preferredEventTypes(intent);
310
715
  let queryEmbedding = null;
311
716
  const embeddingModel = options.embedding?.model || "";
312
717
  const embeddingApiKey = options.embedding?.apiKey || "";
@@ -325,34 +730,148 @@ function createReadStore(options) {
325
730
  options.logger.warn(`Embedding query failed, fallback to lexical search: ${error}`);
326
731
  }
327
732
  }
328
- const lexicalRanked = docs
733
+ const vectorDocsFromLance = queryEmbedding && queryEmbedding.length > 0
734
+ ? await searchLanceDb({ memoryRoot, queryEmbedding, limit: Math.max(20, args.topK * 8), logger: options.logger })
735
+ : [];
736
+ const vectorDocsFallback = vectorDocsFromLance.length > 0
737
+ ? []
738
+ : parseVectorFallback(vectorFallbackPath, options.logger);
739
+ const vectorDocs = [...vectorDocsFromLance, ...vectorDocsFallback];
740
+ const graphDocs = docs
741
+ .filter(doc => Array.isArray(doc.relations) && doc.relations.length > 0)
329
742
  .map(doc => {
330
- const lexicalScore = scoreText(query, doc.text);
331
- const semanticScore = queryEmbedding && Array.isArray(doc.embedding) && doc.embedding.length > 0
743
+ const graphText = [
744
+ doc.text,
745
+ ...(doc.relations || []).map(relation => `${relation.source} ${relation.type} ${relation.target}`),
746
+ ].join(" | ");
747
+ return {
748
+ ...doc,
749
+ text: graphText,
750
+ };
751
+ });
752
+ const rulesDocs = docs.filter(doc => doc.source === "CORTEX_RULES.md");
753
+ const archiveDocs = docs.filter(doc => doc.source.startsWith("sessions_"));
754
+ const combinedCandidates = [];
755
+ const channels = {
756
+ rules: [],
757
+ archive: [],
758
+ vector: [],
759
+ graph: [],
760
+ };
761
+ const evaluateDoc = (doc, source) => {
762
+ const lexical = scoreText(query, doc.text);
763
+ const semantic = queryEmbedding && Array.isArray(doc.embedding) && doc.embedding.length > 0
332
764
  ? Math.max(0, cosineSimilarity(queryEmbedding, doc.embedding) * 5)
333
765
  : 0;
334
- const hybrid = lexicalScore + semanticScore;
335
- const total = withRecencyBoost(hybrid, doc.timestamp);
336
- return { doc, score: total };
337
- })
338
- .filter(item => item.score > 0)
766
+ if (lexical <= 0 && semantic <= 0) {
767
+ return null;
768
+ }
769
+ const recency = recencyScore(doc.timestamp);
770
+ const quality = typeof doc.qualityScore === "number" ? Math.max(0, Math.min(1, doc.qualityScore)) : 0.5;
771
+ const typeMatch = preferredTypes.length > 0 && doc.eventType
772
+ ? (preferredTypes.includes(doc.eventType) ? 1 : 0)
773
+ : 0.5;
774
+ const graphMatch = source === "graph" ? 1 : 0;
775
+ const baseWeighted = (0.2 * lexical +
776
+ 0.3 * semantic +
777
+ 0.1 * recency +
778
+ 0.15 * quality +
779
+ 0.15 * typeMatch +
780
+ 0.1 * graphMatch) * sourceWeight(source, intent);
781
+ const decayFactor = computeDecayFactor(doc.id, doc.eventType, doc.timestamp, options.memoryDecay, hitStats);
782
+ const weighted = baseWeighted * decayFactor;
783
+ return {
784
+ doc,
785
+ source,
786
+ lexical,
787
+ semantic,
788
+ recency,
789
+ quality,
790
+ typeMatch,
791
+ graphMatch,
792
+ decayFactor,
793
+ weighted,
794
+ };
795
+ };
796
+ for (const doc of rulesDocs) {
797
+ const candidate = evaluateDoc(doc, "rules");
798
+ if (candidate)
799
+ channels.rules.push(candidate);
800
+ }
801
+ for (const doc of archiveDocs) {
802
+ const candidate = evaluateDoc(doc, "archive");
803
+ if (candidate)
804
+ channels.archive.push(candidate);
805
+ }
806
+ for (const doc of vectorDocs) {
807
+ const candidate = evaluateDoc(doc, "vector");
808
+ if (candidate)
809
+ channels.vector.push(candidate);
810
+ }
811
+ for (const doc of graphDocs) {
812
+ const candidate = evaluateDoc(doc, "graph");
813
+ if (candidate)
814
+ channels.graph.push(candidate);
815
+ }
816
+ for (const key of Object.keys(channels)) {
817
+ channels[key].sort((a, b) => b.weighted - a.weighted);
818
+ combinedCandidates.push(...channels[key].slice(0, Math.max(20, args.topK * 5)));
819
+ }
820
+ const rrfMap = new Map();
821
+ const weightedMap = new Map();
822
+ const rrfK = 60;
823
+ for (const key of Object.keys(channels)) {
824
+ const list = channels[key];
825
+ for (let i = 0; i < list.length; i += 1) {
826
+ const candidate = list[i];
827
+ const rrf = 1 / (rrfK + i + 1);
828
+ rrfMap.set(candidate.doc.id, (rrfMap.get(candidate.doc.id) || 0) + rrf);
829
+ const current = weightedMap.get(candidate.doc.id);
830
+ if (!current || candidate.weighted > current.weighted) {
831
+ weightedMap.set(candidate.doc.id, candidate);
832
+ }
833
+ }
834
+ }
835
+ const preRanked = [...weightedMap.values()]
836
+ .map(candidate => ({
837
+ id: candidate.doc.id,
838
+ text: candidate.doc.text,
839
+ source: candidate.doc.source,
840
+ event_type: candidate.doc.eventType || "",
841
+ quality_score: candidate.quality,
842
+ timestamp: candidate.doc.timestamp ? new Date(candidate.doc.timestamp).toISOString() : "",
843
+ score: candidate.weighted + (rrfMap.get(candidate.doc.id) || 0) * 1.5,
844
+ reason_tags: [
845
+ `intent:${intent.toLowerCase()}`,
846
+ candidate.semantic > 0 ? "vector_hit" : "lexical_hit",
847
+ candidate.typeMatch >= 1 ? "event_type_match" : "event_type_weak",
848
+ candidate.recency >= 0.8 ? "recent" : "historical",
849
+ candidate.quality >= 0.7 ? "high_quality" : "normal_quality",
850
+ candidate.decayFactor < 1 ? `decay:${candidate.decayFactor.toFixed(3)}` : "decay:1.000",
851
+ `source:${candidate.source}`,
852
+ ],
853
+ }))
339
854
  .sort((a, b) => b.score - a.score)
340
- .slice(0, Math.max(1, Math.max(args.topK, 12)))
341
- .map(item => ({
342
- id: item.doc.id,
343
- text: item.doc.text,
344
- source: item.doc.source,
345
- score: Number(item.score.toFixed(4)),
346
- }));
855
+ .slice(0, Math.max(1, Math.max(args.topK, 20)));
856
+ const lexicalRanked = preRanked
857
+ .map(doc => {
858
+ const boost = withRecencyBoost(doc.score, doc.timestamp ? Date.parse(doc.timestamp) : undefined);
859
+ return { ...doc, score: Number(boost.toFixed(4)) };
860
+ });
347
861
  const rerankerModel = options.reranker?.model || "";
348
862
  const rerankerApiKey = options.reranker?.apiKey || "";
349
863
  const rerankerBaseUrl = normalizeBaseUrl(options.reranker?.baseURL || options.reranker?.baseUrl);
350
- let ranked = lexicalRanked;
864
+ let rerankedSimple = lexicalRanked.map(item => ({
865
+ id: item.id,
866
+ text: item.text,
867
+ source: item.source,
868
+ score: item.score,
869
+ }));
351
870
  if (rerankerModel && rerankerApiKey && rerankerBaseUrl && lexicalRanked.length > 1) {
352
871
  try {
353
- ranked = await requestRerank({
872
+ rerankedSimple = await requestRerank({
354
873
  query,
355
- candidates: lexicalRanked,
874
+ candidates: lexicalRanked.map(item => ({ id: item.id, text: item.text, source: item.source, score: item.score })),
356
875
  model: rerankerModel,
357
876
  apiKey: rerankerApiKey,
358
877
  baseUrl: rerankerBaseUrl,
@@ -362,27 +881,103 @@ function createReadStore(options) {
362
881
  options.logger.warn(`Reranker failed, keep hybrid ranking: ${error}`);
363
882
  }
364
883
  }
365
- ranked = ranked.slice(0, Math.max(1, args.topK)).map(item => ({
366
- id: item.id,
367
- text: item.text,
368
- source: item.source,
369
- score: Number(item.score.toFixed(4)),
370
- }));
884
+ const ranked = rerankedSimple.slice(0, Math.max(1, args.topK)).map(item => {
885
+ const hit = lexicalRanked.find(entry => entry.id === item.id);
886
+ return {
887
+ id: item.id,
888
+ text: item.text,
889
+ source: item.source,
890
+ event_type: hit?.event_type || "",
891
+ quality_score: hit?.quality_score ?? 0,
892
+ timestamp: hit?.timestamp || "",
893
+ score: Number(item.score.toFixed(4)),
894
+ reason_tags: Array.isArray(hit?.reason_tags) ? hit?.reason_tags : [],
895
+ };
896
+ });
897
+ const fusionEnabled = options.fusion?.enabled !== false;
898
+ const llmModel = options.llm?.model || "";
899
+ const llmApiKey = options.llm?.apiKey || "";
900
+ const llmBaseUrl = normalizeBaseUrl(options.llm?.baseURL || options.llm?.baseUrl);
901
+ if (fusionEnabled && llmModel && llmApiKey && llmBaseUrl && ranked.length > 1) {
902
+ try {
903
+ const maxCandidates = Math.max(4, Math.min(20, options.fusion?.maxCandidates ?? 10));
904
+ const fusion = await requestFusion({
905
+ query,
906
+ candidates: ranked.slice(0, maxCandidates).map(item => ({
907
+ id: item.id,
908
+ text: item.text,
909
+ source: item.source,
910
+ event_type: item.event_type,
911
+ quality_score: item.quality_score,
912
+ timestamp: item.timestamp,
913
+ score: item.score,
914
+ reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
915
+ })),
916
+ llm: {
917
+ model: llmModel,
918
+ apiKey: llmApiKey,
919
+ baseUrl: llmBaseUrl,
920
+ },
921
+ });
922
+ if (fusion && fusion.canonical_answer) {
923
+ const fusedItem = {
924
+ id: `fusion_${Date.now().toString(36)}`,
925
+ text: fusion.canonical_answer,
926
+ source: "llm_fusion",
927
+ event_type: "fusion",
928
+ quality_score: Number(fusion.confidence.toFixed(4)),
929
+ timestamp: new Date().toISOString(),
930
+ score: Number((Math.max(...ranked.map(item => item.score)) + 1).toFixed(4)),
931
+ reason_tags: ["llm_fused_authoritative", `evidence:${fusion.evidence_ids.length}`],
932
+ fused_coverage_note: fusion.coverage_note || "",
933
+ fused_facts: fusion.facts,
934
+ fused_timeline: fusion.timeline || [],
935
+ fused_entities: fusion.entities || [],
936
+ fused_decisions: fusion.decisions || [],
937
+ fused_fixes: fusion.fixes || [],
938
+ fused_preferences: fusion.preferences || [],
939
+ fused_risks: fusion.risks || [],
940
+ fused_action_items: fusion.action_items || [],
941
+ fused_conflicts: fusion.conflicts,
942
+ fused_evidence_ids: fusion.evidence_ids,
943
+ };
944
+ const authoritative = options.fusion?.authoritative !== false;
945
+ if (authoritative) {
946
+ markHit(Array.isArray(fusion.evidence_ids) ? fusion.evidence_ids : []);
947
+ return { results: [fusedItem] };
948
+ }
949
+ const merged = [fusedItem, ...ranked];
950
+ markHit([
951
+ ...(Array.isArray(fusion.evidence_ids) ? fusion.evidence_ids : []),
952
+ ...ranked.map(item => item.id),
953
+ ]);
954
+ return { results: merged.slice(0, Math.max(1, args.topK)) };
955
+ }
956
+ }
957
+ catch (error) {
958
+ options.logger.warn(`LLM fusion failed, fallback to reranked results: ${error}`);
959
+ }
960
+ }
961
+ markHit(ranked.map(item => item.id));
371
962
  return { results: ranked };
372
963
  }
373
964
  async function getHotContext(args) {
374
965
  const limit = Math.max(1, args.limit);
375
966
  const docs = loadAllDocuments();
376
967
  const coreRules = docs.find(doc => doc.source === "CORTEX_RULES.md");
377
- const sessionDocs = docs
378
- .filter(doc => doc.source.startsWith("sessions_"))
968
+ const archiveDocs = docs
969
+ .filter(doc => doc.source === "sessions_archive")
379
970
  .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
380
971
  .slice(0, limit);
972
+ const issueFixPairs = docs
973
+ .filter(doc => doc.source === "sessions_archive" && (doc.eventType === "issue" || doc.eventType === "fix"))
974
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
975
+ .slice(0, 2);
381
976
  const result = [];
382
977
  if (coreRules) {
383
978
  result.push({ id: coreRules.id, text: coreRules.text, source: coreRules.source });
384
979
  }
385
- for (const doc of sessionDocs) {
980
+ for (const doc of [...issueFixPairs, ...archiveDocs]) {
386
981
  result.push({ id: doc.id, text: doc.text, source: doc.source });
387
982
  }
388
983
  return { context: result.slice(0, limit) };
@@ -396,6 +991,20 @@ function createReadStore(options) {
396
991
  age_seconds: args.cachedAutoSearch.ageSeconds,
397
992
  };
398
993
  }
994
+ if (!result.auto_search) {
995
+ const docs = loadAllDocuments()
996
+ .filter(doc => doc.source === "sessions_archive" && doc.sessionId === args.sessionId)
997
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
998
+ const latest = docs[0];
999
+ if (latest && latest.text.trim()) {
1000
+ const light = await searchMemory({ query: latest.text.slice(0, 80), topK: 3 });
1001
+ result.auto_search = {
1002
+ query: latest.text.slice(0, 80),
1003
+ results: light.results,
1004
+ age_seconds: 0,
1005
+ };
1006
+ }
1007
+ }
399
1008
  if (args.includeHot) {
400
1009
  const hot = await getHotContext({ limit: 20 });
401
1010
  result.hot_context = hot.context;