openclaw-cortex-memory 0.1.0-Alpha.30 → 0.1.0-Alpha.32

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -12
  3. package/SIGNATURE.md +7 -0
  4. package/SKILL.md +18 -3
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +148 -6
  8. package/dist/index.js.map +1 -1
  9. package/dist/openclaw.plugin.json +120 -4
  10. package/dist/src/engine/memory_engine.d.ts +5 -1
  11. package/dist/src/engine/memory_engine.d.ts.map +1 -1
  12. package/dist/src/engine/ts_engine.d.ts +116 -0
  13. package/dist/src/engine/ts_engine.d.ts.map +1 -1
  14. package/dist/src/engine/ts_engine.js +417 -102
  15. package/dist/src/engine/ts_engine.js.map +1 -1
  16. package/dist/src/engine/types.d.ts +17 -0
  17. package/dist/src/engine/types.d.ts.map +1 -1
  18. package/dist/src/graph/ontology.d.ts +23 -1
  19. package/dist/src/graph/ontology.d.ts.map +1 -1
  20. package/dist/src/graph/ontology.js +743 -70
  21. package/dist/src/graph/ontology.js.map +1 -1
  22. package/dist/src/quality/llm_output_validator.d.ts +20 -2
  23. package/dist/src/quality/llm_output_validator.d.ts.map +1 -1
  24. package/dist/src/quality/llm_output_validator.js +296 -41
  25. package/dist/src/quality/llm_output_validator.js.map +1 -1
  26. package/dist/src/store/archive_store.d.ts +8 -0
  27. package/dist/src/store/archive_store.d.ts.map +1 -1
  28. package/dist/src/store/archive_store.js +244 -84
  29. package/dist/src/store/archive_store.js.map +1 -1
  30. package/dist/src/store/graph_memory_store.d.ts +72 -2
  31. package/dist/src/store/graph_memory_store.d.ts.map +1 -1
  32. package/dist/src/store/graph_memory_store.js +723 -50
  33. package/dist/src/store/graph_memory_store.js.map +1 -1
  34. package/dist/src/store/read_store.d.ts +3 -0
  35. package/dist/src/store/read_store.d.ts.map +1 -1
  36. package/dist/src/store/read_store.js +1004 -209
  37. package/dist/src/store/read_store.js.map +1 -1
  38. package/dist/src/store/vector_store.d.ts +1 -0
  39. package/dist/src/store/vector_store.d.ts.map +1 -1
  40. package/dist/src/store/vector_store.js +1 -0
  41. package/dist/src/store/vector_store.js.map +1 -1
  42. package/dist/src/store/write_store.d.ts +2 -0
  43. package/dist/src/store/write_store.d.ts.map +1 -1
  44. package/dist/src/store/write_store.js +45 -3
  45. package/dist/src/store/write_store.js.map +1 -1
  46. package/dist/src/sync/session_sync.d.ts +20 -1
  47. package/dist/src/sync/session_sync.d.ts.map +1 -1
  48. package/dist/src/sync/session_sync.js +1810 -161
  49. package/dist/src/sync/session_sync.js.map +1 -1
  50. package/dist/src/wiki/wiki_linter.d.ts +25 -0
  51. package/dist/src/wiki/wiki_linter.d.ts.map +1 -0
  52. package/dist/src/wiki/wiki_linter.js +268 -0
  53. package/dist/src/wiki/wiki_linter.js.map +1 -0
  54. package/dist/src/wiki/wiki_logger.d.ts +10 -0
  55. package/dist/src/wiki/wiki_logger.d.ts.map +1 -0
  56. package/dist/src/wiki/wiki_logger.js +78 -0
  57. package/dist/src/wiki/wiki_logger.js.map +1 -0
  58. package/dist/src/wiki/wiki_maintainer.d.ts +36 -0
  59. package/dist/src/wiki/wiki_maintainer.d.ts.map +1 -0
  60. package/dist/src/wiki/wiki_maintainer.js +38 -0
  61. package/dist/src/wiki/wiki_maintainer.js.map +1 -0
  62. package/dist/src/wiki/wiki_projector.d.ts +33 -0
  63. package/dist/src/wiki/wiki_projector.d.ts.map +1 -0
  64. package/dist/src/wiki/wiki_projector.js +633 -0
  65. package/dist/src/wiki/wiki_projector.js.map +1 -0
  66. package/dist/src/wiki/wiki_queue.d.ts +29 -0
  67. package/dist/src/wiki/wiki_queue.d.ts.map +1 -0
  68. package/dist/src/wiki/wiki_queue.js +137 -0
  69. package/dist/src/wiki/wiki_queue.js.map +1 -0
  70. package/openclaw.plugin.json +120 -4
  71. package/package.json +8 -4
  72. package/schema/graph.schema.yaml +188 -33
  73. package/skills/cortex-memory/SKILL.md +49 -0
  74. package/skills/cortex-memory/references/agent-manual.md +115 -0
  75. package/skills/cortex-memory/references/configuration.md +92 -0
  76. package/skills/cortex-memory/references/publish-checklist.md +46 -0
  77. package/skills/cortex-memory/references/system-prompt-template.md +27 -0
  78. package/skills/cortex-memory/references/tools.md +181 -0
  79. package/skills/cortex-memory/scripts/smoke-check.ps1 +56 -0
@@ -38,6 +38,12 @@ const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const module_1 = require("module");
40
40
  const http_post_1 = require("../net/http_post");
41
+ function graphRelationKey(relation) {
42
+ const source = (relation.source || "").trim().toLowerCase();
43
+ const type = (relation.type || "related_to").trim().toLowerCase();
44
+ const target = (relation.target || "").trim().toLowerCase();
45
+ return `${source}|${type}|${target}`;
46
+ }
41
47
  function buildEntityGraphSummaryDocs(graphDocs) {
42
48
  const entityEdges = new Map();
43
49
  const entityLatestTs = new Map();
@@ -339,8 +345,17 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
339
345
  try {
340
346
  const parsed = JSON.parse(trimmed);
341
347
  const summaryText = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
348
+ const causeText = typeof parsed.cause === "string" ? parsed.cause.trim() : "";
349
+ const processText = typeof parsed.process === "string" ? parsed.process.trim() : "";
350
+ const resultText = typeof parsed.result === "string" ? parsed.result.trim() : "";
342
351
  const sourceText = typeof parsed.source_text === "string" ? parsed.source_text.trim() : "";
343
- const text = summaryText || normalizeRecordText(parsed);
352
+ const activeContent = typeof parsed.content === "string" ? parsed.content.trim() : "";
353
+ const text = [
354
+ summaryText,
355
+ causeText ? `cause: ${causeText}` : "",
356
+ processText ? `process: ${processText}` : "",
357
+ resultText ? `result: ${resultText}` : "",
358
+ ].filter(Boolean).join("\n") || normalizeRecordText(parsed);
344
359
  if (!text.trim()) {
345
360
  continue;
346
361
  }
@@ -350,7 +365,7 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
350
365
  id,
351
366
  text,
352
367
  summaryText: summaryText || text,
353
- sourceText: sourceText || undefined,
368
+ sourceText: sourceText || activeContent || undefined,
354
369
  sourceEventId: typeof parsed.source_event_id === "string" ? parsed.source_event_id : undefined,
355
370
  sourceFile: typeof parsed.source_file === "string" ? parsed.source_file : undefined,
356
371
  source: sourceLabel,
@@ -400,6 +415,209 @@ function parseMarkdownFile(filePath, sourceLabel) {
400
415
  },
401
416
  ];
402
417
  }
418
+ function normalizeFactStatus(value) {
419
+ const token = (value || "").trim().toLowerCase();
420
+ if (!token)
421
+ return null;
422
+ if (token === "active")
423
+ return "active";
424
+ if (token === "pending" || token === "pending_conflict")
425
+ return "pending_conflict";
426
+ if (token === "superseded")
427
+ return "superseded";
428
+ if (token === "rejected")
429
+ return "rejected";
430
+ return null;
431
+ }
432
+ function uniqueStrings(values) {
433
+ const output = [];
434
+ const seen = new Set();
435
+ for (const value of values) {
436
+ const key = (value || "").trim();
437
+ if (!key || seen.has(key))
438
+ continue;
439
+ seen.add(key);
440
+ output.push(key);
441
+ }
442
+ return output;
443
+ }
444
+ function buildGraphEvidenceIds(args) {
445
+ const relationKey = graphRelationKey({
446
+ source: args.source,
447
+ target: args.target,
448
+ type: args.type,
449
+ });
450
+ const evidenceIds = [
451
+ relationKey ? `graph:relation:${relationKey}` : "",
452
+ args.sourceEventId ? `graph:event:${args.sourceEventId}` : "",
453
+ args.evidenceSpan
454
+ ? `graph:evidence:${args.sourceEventId || relationKey}`
455
+ : "",
456
+ args.wikiRef
457
+ ? `wiki:${args.wikiRef}${args.wikiAnchor ? `#${args.wikiAnchor}` : ""}`
458
+ : "",
459
+ ];
460
+ return uniqueStrings(evidenceIds);
461
+ }
462
+ function toMemoryRelativePath(memoryRoot, filePath) {
463
+ return path.relative(memoryRoot, filePath).replace(/\\/g, "/");
464
+ }
465
+ function toAnchorToken(value) {
466
+ const token = (value || "")
467
+ .trim()
468
+ .toLowerCase()
469
+ .replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")
470
+ .replace(/^-+|-+$/g, "");
471
+ return token || "facts";
472
+ }
473
+ function parseWikiRelationLine(line) {
474
+ const text = line.trim();
475
+ if (!text.startsWith("- "))
476
+ return null;
477
+ if (text === "- (none)")
478
+ return null;
479
+ const body = text.replace(/^-+\s*/, "");
480
+ const matched = body.match(/^(.+?)\s+--([^\/]+)\/([^-\s]+)-->\s+(.+?)\s*(?:\((.*)\))?$/);
481
+ if (!matched) {
482
+ return null;
483
+ }
484
+ const source = matched[1].trim();
485
+ const type = matched[2].trim();
486
+ const status = normalizeFactStatus(matched[3]);
487
+ const target = matched[4].trim();
488
+ const attrs = (matched[5] || "").trim();
489
+ if (!source || !target || !type) {
490
+ return null;
491
+ }
492
+ const attributeMap = new Map();
493
+ if (attrs) {
494
+ const parts = attrs.split(",").map(item => item.trim()).filter(Boolean);
495
+ for (const part of parts) {
496
+ const eq = part.indexOf("=");
497
+ if (eq <= 0)
498
+ continue;
499
+ const key = part.slice(0, eq).trim().toLowerCase();
500
+ const value = part.slice(eq + 1).trim();
501
+ if (!key)
502
+ continue;
503
+ attributeMap.set(key, value);
504
+ }
505
+ }
506
+ const evidenceSpanRaw = attributeMap.get("evidence") || "";
507
+ const confidenceRaw = attributeMap.get("confidence") || "";
508
+ const sourceEventIdRaw = attributeMap.get("source_event_id") || "";
509
+ const conflictIdRaw = attributeMap.get("conflict_id") || "";
510
+ const confidenceNum = confidenceRaw ? Number(confidenceRaw) : NaN;
511
+ const relationKey = graphRelationKey({ source, target, type });
512
+ return {
513
+ relation: {
514
+ source,
515
+ target,
516
+ type,
517
+ evidence_span: evidenceSpanRaw && evidenceSpanRaw.toLowerCase() !== "n/a" ? evidenceSpanRaw : undefined,
518
+ confidence: Number.isFinite(confidenceNum) ? confidenceNum : undefined,
519
+ fact_status: status || undefined,
520
+ source_event_id: sourceEventIdRaw && sourceEventIdRaw.toLowerCase() !== "n/a" ? sourceEventIdRaw : undefined,
521
+ conflict_id: conflictIdRaw && conflictIdRaw.toLowerCase() !== "n/a" ? conflictIdRaw : undefined,
522
+ relation_key: relationKey,
523
+ },
524
+ };
525
+ }
526
+ function parseWikiProjectionDocuments(memoryRoot, logger) {
527
+ const wikiRoot = path.join(memoryRoot, "wiki");
528
+ const folders = [
529
+ { dir: path.join(wikiRoot, "entities"), kind: "entity" },
530
+ { dir: path.join(wikiRoot, "topics"), kind: "topic" },
531
+ { dir: path.join(wikiRoot, "timelines"), kind: "timeline" },
532
+ ];
533
+ const docs = [];
534
+ for (const { dir, kind } of folders) {
535
+ if (!fs.existsSync(dir))
536
+ continue;
537
+ let files = [];
538
+ try {
539
+ files = fs.readdirSync(dir)
540
+ .filter(file => file.toLowerCase().endsWith(".md"))
541
+ .sort((a, b) => a.localeCompare(b));
542
+ }
543
+ catch (error) {
544
+ logger.debug(`Skipping wiki projection directory ${dir}: ${error}`);
545
+ continue;
546
+ }
547
+ for (const file of files) {
548
+ const filePath = path.join(dir, file);
549
+ const markdown = safeReadFile(filePath);
550
+ if (!markdown.trim())
551
+ continue;
552
+ const relativePath = toMemoryRelativePath(memoryRoot, filePath);
553
+ const mtime = (() => {
554
+ try {
555
+ return fs.statSync(filePath).mtimeMs;
556
+ }
557
+ catch {
558
+ return NaN;
559
+ }
560
+ })();
561
+ let section = "Facts";
562
+ const sectionCounters = new Map();
563
+ const lines = markdown.split(/\r?\n/);
564
+ for (const rawLine of lines) {
565
+ const line = rawLine.trim();
566
+ if (!line)
567
+ continue;
568
+ if (line.startsWith("## ")) {
569
+ section = line.replace(/^##\s+/, "").trim() || "Facts";
570
+ continue;
571
+ }
572
+ const parsed = parseWikiRelationLine(line);
573
+ if (!parsed)
574
+ continue;
575
+ const sectionToken = toAnchorToken(section);
576
+ const index = (sectionCounters.get(sectionToken) || 0) + 1;
577
+ sectionCounters.set(sectionToken, index);
578
+ const anchor = `${sectionToken}-${index}`;
579
+ const relation = parsed.relation;
580
+ const factStatus = relation.fact_status || "active";
581
+ const evidenceIds = buildGraphEvidenceIds({
582
+ source: relation.source,
583
+ target: relation.target,
584
+ type: relation.type,
585
+ sourceEventId: relation.source_event_id,
586
+ evidenceSpan: relation.evidence_span,
587
+ wikiRef: relativePath,
588
+ wikiAnchor: anchor,
589
+ });
590
+ docs.push({
591
+ id: `wiki:${relativePath}#${anchor}`,
592
+ text: [
593
+ `# Wiki Projection`,
594
+ `wiki_kind: ${kind}`,
595
+ `wiki_path: ${relativePath}`,
596
+ `wiki_section: ${section}`,
597
+ `fact_status: ${factStatus}`,
598
+ `${relation.source} ${relation.type} ${relation.target}`,
599
+ line,
600
+ ].join("\n"),
601
+ source: "sessions_graph_wiki",
602
+ timestamp: Number.isFinite(mtime) ? Math.floor(mtime) : undefined,
603
+ layer: "archive",
604
+ sourceFile: relativePath,
605
+ sourceMemoryId: relation.relation_key || `wiki:${relativePath}`,
606
+ sourceEventId: relation.source_event_id,
607
+ eventType: "graph_wiki_projection",
608
+ qualityScore: 0.95,
609
+ entities: uniqueStrings([relation.source, relation.target]),
610
+ relations: [relation],
611
+ factStatus,
612
+ wikiRef: relativePath,
613
+ wikiAnchor: anchor,
614
+ evidenceIds,
615
+ });
616
+ }
617
+ }
618
+ }
619
+ return docs;
620
+ }
403
621
  function extractPrioritizedRuleLines(text, maxRules) {
404
622
  if (!text.trim() || maxRules <= 0) {
405
623
  return [];
@@ -693,6 +911,95 @@ function sourceWeight(source, intent) {
693
911
  }
694
912
  return 1;
695
913
  }
914
+ const QUERY_PLAN_STOPWORDS = new Set([
915
+ "what",
916
+ "is",
917
+ "the",
918
+ "a",
919
+ "an",
920
+ "of",
921
+ "for",
922
+ "to",
923
+ "in",
924
+ "on",
925
+ "and",
926
+ "or",
927
+ "with",
928
+ "about",
929
+ "this",
930
+ "that",
931
+ "这些",
932
+ "那些",
933
+ "这个",
934
+ "那个",
935
+ "之前",
936
+ "提到",
937
+ "提过",
938
+ "一下",
939
+ "请问",
940
+ "关于",
941
+ "相关",
942
+ "什么",
943
+ "怎么",
944
+ "如何",
945
+ "是否",
946
+ "有没有",
947
+ "是什么",
948
+ "哪些",
949
+ "哪个",
950
+ ]);
951
+ function planQueryKeywords(query) {
952
+ const normalized = (query || "").trim().replace(/\s+/g, " ");
953
+ if (!normalized)
954
+ return [];
955
+ const output = [];
956
+ const seen = new Set();
957
+ const push = (value) => {
958
+ const item = (value || "").trim();
959
+ if (!item)
960
+ return;
961
+ const key = item.toLowerCase();
962
+ if (seen.has(key))
963
+ return;
964
+ seen.add(key);
965
+ output.push(item);
966
+ };
967
+ push(normalized);
968
+ for (const match of normalized.matchAll(/["“”'‘’]([^"“”'‘’]{2,64})["“”'‘’]/g)) {
969
+ push(match[1]);
970
+ }
971
+ for (const match of normalized.match(/https?:\/\/[^\s]+/gi) || []) {
972
+ push(match);
973
+ }
974
+ for (const match of normalized.matchAll(/\b[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+[A-Za-z0-9][A-Za-z0-9._/-]*){0,3}\b/g)) {
975
+ const phrase = match[0].trim();
976
+ const words = phrase.split(/\s+/).filter(Boolean);
977
+ const hasStrongSignal = words.length >= 2 || /[A-Z]{2,}/.test(phrase);
978
+ if (hasStrongSignal && !QUERY_PLAN_STOPWORDS.has(phrase.toLowerCase())) {
979
+ push(phrase);
980
+ }
981
+ }
982
+ const normalizedForSplit = normalized
983
+ .replace(/[(){}\[\]<>]/g, " ")
984
+ .replace(/[,。!?;:,.!?;:、]/g, " ")
985
+ .replace(/(之前提到的|之前提到|提到的|提到|请问|一下|是什么|什么|哪个|哪篇|哪里|如何|怎么|有关|关于|以及|还有|并且|然后|能否|可以|帮我|给我)/g, " ");
986
+ for (const raw of normalizedForSplit.split(/[\/\-\s]+/)) {
987
+ const token = raw.trim();
988
+ if (!token || token.length < 2)
989
+ continue;
990
+ if (QUERY_PLAN_STOPWORDS.has(token.toLowerCase()))
991
+ continue;
992
+ push(token);
993
+ }
994
+ return output.slice(0, 5);
995
+ }
996
+ function shouldTriggerFulltextFallback(ranked, topK) {
997
+ const scoped = ranked.slice(0, Math.max(1, topK));
998
+ if (scoped.length === 0)
999
+ return true;
1000
+ const summaryHits = scoped.filter(item => Array.isArray(item.reason_tags) && item.reason_tags.includes("summary_hit")).length;
1001
+ return summaryHits === 0;
1002
+ }
696
1003
  function mergeKeyFromDoc(doc) {
697
1004
  const canonical = typeof doc.sourceMemoryCanonicalId === "string" ? doc.sourceMemoryCanonicalId.trim() : "";
698
1005
  if (canonical) {
@@ -704,6 +1011,42 @@ function mergeKeyFromDoc(doc) {
704
1011
  }
705
1012
  return `id:${doc.id}`;
706
1013
  }
1014
+ function docFactStatus(doc) {
1015
+ const direct = normalizeFactStatus(typeof doc.factStatus === "string" ? doc.factStatus : "");
1016
+ if (direct)
1017
+ return direct;
1018
+ const relation = Array.isArray(doc.relations) && doc.relations.length > 0 ? doc.relations[0] : null;
1019
+ const relationStatus = normalizeFactStatus(typeof relation?.fact_status === "string" ? relation.fact_status : "");
1020
+ if (relationStatus)
1021
+ return relationStatus;
1022
+ return "active";
1023
+ }
1024
+ function docEvidenceIds(doc) {
1025
+ if (Array.isArray(doc.evidenceIds) && doc.evidenceIds.length > 0) {
1026
+ return uniqueStrings(doc.evidenceIds);
1027
+ }
1028
+ const relation = Array.isArray(doc.relations) && doc.relations.length > 0 ? doc.relations[0] : null;
1029
+ const source = relation?.source || "";
1030
+ const target = relation?.target || "";
1031
+ const type = relation?.type || "";
1032
+ const evidence = source && target && type
1033
+ ? buildGraphEvidenceIds({
1034
+ source,
1035
+ target,
1036
+ type,
1037
+ sourceEventId: relation?.source_event_id || doc.sourceEventId,
1038
+ evidenceSpan: relation?.evidence_span,
1039
+ wikiRef: doc.wikiRef,
1040
+ wikiAnchor: doc.wikiAnchor,
1041
+ })
1042
+ : [];
1043
+ return uniqueStrings([
1044
+ ...evidence,
1045
+ doc.sourceEventId ? `graph:event:${doc.sourceEventId}` : "",
1046
+ doc.wikiRef ? `wiki:${doc.wikiRef}${doc.wikiAnchor ? `#${doc.wikiAnchor}` : ""}` : "",
1047
+ `doc:${doc.id}`,
1048
+ ]);
1049
+ }
707
1050
  function customChannelWeight(source, options) {
708
1051
  const weights = options?.channelWeights;
709
1052
  if (!weights)
@@ -1111,6 +1454,24 @@ function createReadStore(options) {
1111
1454
  return hitStatsCache;
1112
1455
  }
1113
1456
  }
1457
+ function directorySignature(dirPath, extension) {
1458
+ try {
1459
+ if (!fs.existsSync(dirPath)) {
1460
+ return `${dirPath}:missing`;
1461
+ }
1462
+ const files = fs.readdirSync(dirPath)
1463
+ .filter(file => file.toLowerCase().endsWith(extension.toLowerCase()))
1464
+ .sort((a, b) => a.localeCompare(b));
1465
+ if (files.length === 0) {
1466
+ return `${dirPath}:empty`;
1467
+ }
1468
+ const signatures = files.map(file => fileSignature(path.join(dirPath, file)));
1469
+ return `${dirPath}:${signatures.join("|")}`;
1470
+ }
1471
+ catch {
1472
+ return `${dirPath}:error`;
1473
+ }
1474
+ }
1114
1475
  function saveHitStats(state) {
1115
1476
  try {
1116
1477
  const dir = path.dirname(hitStatsPath);
@@ -1169,16 +1530,26 @@ function createReadStore(options) {
1169
1530
  }
1170
1531
  function loadAllDocuments() {
1171
1532
  const cortexRulesPath = path.join(memoryRoot, "CORTEX_RULES.md");
1172
- const memoryMdPath = path.join(memoryRoot, "MEMORY.md");
1173
1533
  const activeSessionsPath = path.join(memoryRoot, "sessions", "active", "sessions.jsonl");
1174
1534
  const archiveSessionsPath = path.join(memoryRoot, "sessions", "archive", "sessions.jsonl");
1175
1535
  const graphMemoryPath = path.join(memoryRoot, "graph", "memory.jsonl");
1536
+ const supersededRelationPath = path.join(memoryRoot, "graph", "superseded_relations.jsonl");
1537
+ const conflictQueuePath = path.join(memoryRoot, "graph", "conflict_queue.jsonl");
1538
+ const wikiEntitiesDir = path.join(memoryRoot, "wiki", "entities");
1539
+ const wikiTopicsDir = path.join(memoryRoot, "wiki", "topics");
1540
+ const wikiTimelinesDir = path.join(memoryRoot, "wiki", "timelines");
1541
+ const wikiProjectionIndexPath = path.join(memoryRoot, "wiki", ".projection_index.json");
1176
1542
  const signature = [
1177
1543
  fileSignature(cortexRulesPath),
1178
- fileSignature(memoryMdPath),
1179
1544
  fileSignature(activeSessionsPath),
1180
1545
  fileSignature(archiveSessionsPath),
1181
1546
  fileSignature(graphMemoryPath),
1547
+ fileSignature(supersededRelationPath),
1548
+ fileSignature(conflictQueuePath),
1549
+ directorySignature(wikiEntitiesDir, ".md"),
1550
+ directorySignature(wikiTopicsDir, ".md"),
1551
+ directorySignature(wikiTimelinesDir, ".md"),
1552
+ fileSignature(wikiProjectionIndexPath),
1182
1553
  ].join("|");
1183
1554
  if (docsCache && docsCache.signature === signature) {
1184
1555
  return docsCache.docs;
@@ -1202,6 +1573,25 @@ function createReadStore(options) {
1202
1573
  }
1203
1574
  }
1204
1575
  const graphDocs = [];
1576
+ const supersededRelationKeys = new Set();
1577
+ if (fs.existsSync(supersededRelationPath)) {
1578
+ const supersededContent = safeReadFile(supersededRelationPath);
1579
+ for (const line of supersededContent.split(/\r?\n/)) {
1580
+ const trimmed = line.trim();
1581
+ if (!trimmed)
1582
+ continue;
1583
+ try {
1584
+ const parsed = JSON.parse(trimmed);
1585
+ const relationKey = typeof parsed.relation_key === "string" ? parsed.relation_key.trim().toLowerCase() : "";
1586
+ if (relationKey) {
1587
+ supersededRelationKeys.add(relationKey);
1588
+ }
1589
+ }
1590
+ catch (error) {
1591
+ options.logger.debug(`Skipping invalid superseded relation line: ${error}`);
1592
+ }
1593
+ }
1594
+ }
1205
1595
  if (fs.existsSync(graphMemoryPath)) {
1206
1596
  const graphContent = safeReadFile(graphMemoryPath);
1207
1597
  for (const line of graphContent.split(/\r?\n/)) {
@@ -1211,12 +1601,21 @@ function createReadStore(options) {
1211
1601
  try {
1212
1602
  const parsed = JSON.parse(trimmed);
1213
1603
  const id = typeof parsed.id === "string" ? parsed.id : "";
1604
+ const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
1605
+ const sourceTextNav = typeof parsed.source_text_nav === "object" && parsed.source_text_nav !== null && !Array.isArray(parsed.source_text_nav)
1606
+ ? parsed.source_text_nav
1607
+ : undefined;
1214
1608
  const sourceEventId = typeof parsed.source_event_id === "string" ? parsed.source_event_id : "";
1215
1609
  const archiveEventId = typeof parsed.archive_event_id === "string" ? parsed.archive_event_id : "";
1216
- const eventRefId = archiveEventId || sourceEventId;
1217
- const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
1218
- const sourceLayer = typeof parsed.source_layer === "string" ? parsed.source_layer : "";
1219
- const sourceFile = typeof parsed.source_file === "string" ? parsed.source_file : "";
1610
+ const navSourceEventId = typeof sourceTextNav?.source_event_id === "string" ? sourceTextNav.source_event_id.trim() : "";
1611
+ const navSourceMemoryId = typeof sourceTextNav?.source_memory_id === "string" ? sourceTextNav.source_memory_id.trim() : "";
1612
+ const navSessionId = typeof sourceTextNav?.session_id === "string" ? sourceTextNav.session_id.trim() : "";
1613
+ const navSourceLayer = typeof sourceTextNav?.layer === "string" ? sourceTextNav.layer.trim() : "";
1614
+ const navSourceFile = typeof sourceTextNav?.source_file === "string" ? sourceTextNav.source_file.trim() : "";
1615
+ const eventRefId = navSourceMemoryId || archiveEventId || navSourceEventId || sourceEventId;
1616
+ const sessionId = navSessionId || (typeof parsed.session_id === "string" ? parsed.session_id : "");
1617
+ const sourceLayer = navSourceLayer || (typeof parsed.source_layer === "string" ? parsed.source_layer : "");
1618
+ const sourceFile = navSourceFile || (typeof parsed.source_file === "string" ? parsed.source_file : "");
1220
1619
  const timestamp = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
1221
1620
  const entities = Array.isArray(parsed.entities)
1222
1621
  ? parsed.entities.map((item) => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
@@ -1224,65 +1623,114 @@ function createReadStore(options) {
1224
1623
  const entityTypes = typeof parsed.entity_types === "object" && parsed.entity_types !== null
1225
1624
  ? parsed.entity_types
1226
1625
  : {};
1227
- const relations = Array.isArray(parsed.relations)
1228
- ? parsed.relations
1229
- .map((item) => {
1230
- if (typeof item !== "object" || item === null)
1231
- return null;
1232
- const relation = item;
1233
- const source = typeof relation.source === "string" ? relation.source.trim() : "";
1234
- const target = typeof relation.target === "string" ? relation.target.trim() : "";
1235
- const type = typeof relation.type === "string" ? relation.type.trim() : "related_to";
1236
- if (!source || !target)
1237
- return null;
1238
- return { source, target, type };
1239
- })
1240
- .filter((item) => Boolean(item))
1241
- : [];
1626
+ const relations = Array.isArray(parsed.relations) ? parsed.relations : [];
1242
1627
  const eventType = (typeof parsed.event_type === "string" ? parsed.event_type : "") || archiveEventTypeById.get(eventRefId) || "";
1243
- const entityLines = entities.length > 0
1244
- ? entities.map((entity, index) => {
1245
- const entityType = entityTypes[entity];
1246
- return `${index + 1}. ${entity}${entityType ? ` (${entityType})` : ""}`;
1247
- }).join("\n")
1248
- : "none";
1249
- const relationLines = relations.length > 0
1250
- ? relations.map((relation, index) => `${index + 1}. ${relation.source} -[${relation.type}]-> ${relation.target}`).join("\n")
1251
- : "none";
1252
- const relationFacts = relations.length > 0
1253
- ? relations.map(relation => `${relation.source} ${relation.type} ${relation.target}`).join(" | ")
1254
- : "none";
1255
- const text = [
1256
- `# Graph Record`,
1257
- `record_id: ${id}`,
1258
- `source_event_id: ${sourceEventId || archiveEventId || "unknown"}`,
1259
- `source_layer: ${sourceLayer || "unknown"}`,
1260
- `archive_event_id: ${archiveEventId || "n/a"}`,
1261
- `event_type: ${eventType || "unknown"}`,
1262
- `session_id: ${sessionId || "unknown"}`,
1263
- `source_file: ${sourceFile || "unknown"}`,
1264
- ``,
1265
- `## Entities`,
1266
- entityLines,
1267
- ``,
1268
- `## Relations`,
1269
- relationLines,
1270
- ``,
1271
- `## Relation Facts`,
1272
- relationFacts,
1273
- ].join("\n");
1274
- if (id && text.trim()) {
1628
+ let relationCount = 0;
1629
+ for (const relationRaw of relations) {
1630
+ if (typeof relationRaw !== "object" || relationRaw === null)
1631
+ continue;
1632
+ const relationRecord = relationRaw;
1633
+ const source = typeof relationRecord.source === "string" ? relationRecord.source.trim() : "";
1634
+ const target = typeof relationRecord.target === "string" ? relationRecord.target.trim() : "";
1635
+ const type = typeof relationRecord.type === "string" ? relationRecord.type.trim() : "related_to";
1636
+ if (!source || !target)
1637
+ continue;
1638
+ relationCount += 1;
1639
+ const relationKey = graphRelationKey({ source, target, type });
1640
+ const factStatus = supersededRelationKeys.has(relationKey) ? "superseded" : "active";
1641
+ const evidenceSpan = typeof relationRecord.evidence_span === "string" && relationRecord.evidence_span.trim()
1642
+ ? relationRecord.evidence_span.trim()
1643
+ : undefined;
1644
+ const confidenceValue = typeof relationRecord.confidence === "number" && Number.isFinite(relationRecord.confidence)
1645
+ ? relationRecord.confidence
1646
+ : undefined;
1647
+ const relationOrigin = typeof relationRecord.relation_origin === "string" && relationRecord.relation_origin.trim()
1648
+ ? relationRecord.relation_origin.trim()
1649
+ : undefined;
1650
+ const relationDefinition = typeof relationRecord.relation_definition === "string" && relationRecord.relation_definition.trim()
1651
+ ? relationRecord.relation_definition.trim()
1652
+ : undefined;
1653
+ const contextChunk = typeof relationRecord.context_chunk === "string" && relationRecord.context_chunk.trim()
1654
+ ? relationRecord.context_chunk.trim()
1655
+ : undefined;
1656
+ const relation = {
1657
+ source,
1658
+ target,
1659
+ type,
1660
+ relation_origin: relationOrigin,
1661
+ relation_definition: relationDefinition,
1662
+ evidence_span: evidenceSpan,
1663
+ context_chunk: contextChunk,
1664
+ confidence: confidenceValue,
1665
+ fact_status: factStatus,
1666
+ source_event_id: sourceEventId || archiveEventId || undefined,
1667
+ relation_key: relationKey,
1668
+ };
1669
+ const relationEntities = uniqueStrings([...entities, source, target]);
1670
+ const entityLines = relationEntities.length > 0
1671
+ ? relationEntities.map((entity, index) => {
1672
+ const entityType = entityTypes[entity];
1673
+ return `${index + 1}. ${entity}${entityType ? ` (${entityType})` : ""}`;
1674
+ }).join("\n")
1675
+ : "none";
1676
+ const text = [
1677
+ `# Graph Relation`,
1678
+ `record_id: ${id}`,
1679
+ `relation_index: ${relationCount}`,
1680
+ `relation_key: ${relationKey}`,
1681
+ `fact_status: ${factStatus}`,
1682
+ `source_event_id: ${sourceEventId || archiveEventId || "unknown"}`,
1683
+ `source_layer: ${sourceLayer || "unknown"}`,
1684
+ `archive_event_id: ${archiveEventId || "n/a"}`,
1685
+ `event_type: ${eventType || "unknown"}`,
1686
+ `session_id: ${sessionId || "unknown"}`,
1687
+ `source_file: ${sourceFile || "unknown"}`,
1688
+ `evidence_span: ${evidenceSpan || "n/a"}`,
1689
+ `context_chunk: ${contextChunk || "n/a"}`,
1690
+ `relation_origin: ${relationOrigin || "n/a"}`,
1691
+ `relation_definition: ${relationDefinition || "n/a"}`,
1692
+ `confidence: ${typeof confidenceValue === "number" ? confidenceValue : "n/a"}`,
1693
+ ``,
1694
+ `## Summary`,
1695
+ summary || "n/a",
1696
+ ``,
1697
+ `## Source References`,
1698
+ `source_event_id: ${navSourceEventId || sourceEventId || archiveEventId || "unknown"}`,
1699
+ `source_memory_id: ${navSourceMemoryId || eventRefId || "unknown"}`,
1700
+ `source_layer: ${sourceLayer || "unknown"}`,
1701
+ `source_file: ${sourceFile || "unknown"}`,
1702
+ `session_id: ${sessionId || "unknown"}`,
1703
+ ``,
1704
+ `## Entities`,
1705
+ entityLines,
1706
+ ``,
1707
+ `## Relation`,
1708
+ `${source} -[${type}/${factStatus}]-> ${target}`,
1709
+ ].join("\n");
1275
1710
  graphDocs.push({
1276
- id,
1711
+ id: `${id || "graph"}:rel:${relationCount}`,
1277
1712
  text,
1278
1713
  source: "sessions_graph",
1279
1714
  timestamp: Number.isFinite(timestamp) ? timestamp : undefined,
1280
1715
  layer: sourceLayer === "active_only" ? "active" : "archive",
1716
+ summaryText: summary || undefined,
1717
+ sourceText: contextChunk || undefined,
1281
1718
  sourceMemoryId: eventRefId || id,
1282
- sourceEventId: sourceEventId || archiveEventId || undefined,
1719
+ sourceEventId: navSourceEventId || sourceEventId || archiveEventId || undefined,
1720
+ sourceFile: sourceFile || undefined,
1283
1721
  sessionId,
1284
- entities,
1285
- relations,
1722
+ entities: relationEntities,
1723
+ relations: [relation],
1724
+ eventType: eventType || undefined,
1725
+ qualityScore: typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence) ? parsed.confidence : 1,
1726
+ factStatus,
1727
+ evidenceIds: buildGraphEvidenceIds({
1728
+ source,
1729
+ target,
1730
+ type,
1731
+ sourceEventId: relation.source_event_id,
1732
+ evidenceSpan,
1733
+ }),
1286
1734
  });
1287
1735
  }
1288
1736
  }
@@ -1291,14 +1739,152 @@ function createReadStore(options) {
1291
1739
  }
1292
1740
  }
1293
1741
  }
1294
- const entitySummaryDocs = buildEntityGraphSummaryDocs(graphDocs);
1742
+ if (fs.existsSync(conflictQueuePath)) {
1743
+ const queueContent = safeReadFile(conflictQueuePath);
1744
+ for (const line of queueContent.split(/\r?\n/)) {
1745
+ const trimmed = line.trim();
1746
+ if (!trimmed)
1747
+ continue;
1748
+ try {
1749
+ const parsed = JSON.parse(trimmed);
1750
+ const status = normalizeFactStatus(typeof parsed.status === "string" ? parsed.status : "");
1751
+ if (status !== "pending_conflict" && status !== "rejected") {
1752
+ continue;
1753
+ }
1754
+ const conflictId = typeof parsed.conflict_id === "string" ? parsed.conflict_id.trim() : "";
1755
+ const sourceEventId = typeof parsed.source_event_id === "string" ? parsed.source_event_id.trim() : "";
1756
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id.trim() : "";
1757
+ const sourceFile = typeof parsed.source_file === "string" ? parsed.source_file.trim() : "";
1758
+ const sourceLayer = typeof parsed.source_layer === "string" ? parsed.source_layer.trim() : "";
1759
+ const updatedAt = typeof parsed.updated_at === "string" ? Date.parse(parsed.updated_at) : NaN;
1760
+ const candidate = typeof parsed.candidate === "object" && parsed.candidate !== null
1761
+ ? parsed.candidate
1762
+ : {};
1763
+ const candidateSummary = typeof candidate.summary === "string" ? candidate.summary.trim() : "";
1764
+ const candidateSourceTextNav = typeof candidate.source_text_nav === "object" && candidate.source_text_nav !== null && !Array.isArray(candidate.source_text_nav)
1765
+ ? candidate.source_text_nav
1766
+ : undefined;
1767
+ const navSourceEventId = typeof candidateSourceTextNav?.source_event_id === "string" ? candidateSourceTextNav.source_event_id.trim() : "";
1768
+ const navSourceMemoryId = typeof candidateSourceTextNav?.source_memory_id === "string" ? candidateSourceTextNav.source_memory_id.trim() : "";
1769
+ const navSourceLayer = typeof candidateSourceTextNav?.layer === "string" ? candidateSourceTextNav.layer.trim() : "";
1770
+ const navSourceFile = typeof candidateSourceTextNav?.source_file === "string" ? candidateSourceTextNav.source_file.trim() : "";
1771
+ const navSessionId = typeof candidateSourceTextNav?.session_id === "string" ? candidateSourceTextNav.session_id.trim() : "";
1772
+ const candidateEventType = typeof candidate.event_type === "string" ? candidate.event_type.trim() : "";
1773
+ const candidateRelations = Array.isArray(candidate.relations) ? candidate.relations : [];
1774
+ let relationIndex = 0;
1775
+ for (const relationRaw of candidateRelations) {
1776
+ if (typeof relationRaw !== "object" || relationRaw === null)
1777
+ continue;
1778
+ const relationRecord = relationRaw;
1779
+ const source = typeof relationRecord.source === "string" ? relationRecord.source.trim() : "";
1780
+ const target = typeof relationRecord.target === "string" ? relationRecord.target.trim() : "";
1781
+ const type = typeof relationRecord.type === "string" ? relationRecord.type.trim() : "related_to";
1782
+ if (!source || !target)
1783
+ continue;
1784
+ relationIndex += 1;
1785
+ const relationKey = graphRelationKey({ source, target, type });
1786
+ const evidenceSpan = typeof relationRecord.evidence_span === "string" && relationRecord.evidence_span.trim()
1787
+ ? relationRecord.evidence_span.trim()
1788
+ : undefined;
1789
+ const confidenceValue = typeof relationRecord.confidence === "number" && Number.isFinite(relationRecord.confidence)
1790
+ ? relationRecord.confidence
1791
+ : undefined;
1792
+ const relationOrigin = typeof relationRecord.relation_origin === "string" && relationRecord.relation_origin.trim()
1793
+ ? relationRecord.relation_origin.trim()
1794
+ : undefined;
1795
+ const relationDefinition = typeof relationRecord.relation_definition === "string" && relationRecord.relation_definition.trim()
1796
+ ? relationRecord.relation_definition.trim()
1797
+ : undefined;
1798
+ const contextChunk = typeof relationRecord.context_chunk === "string" && relationRecord.context_chunk.trim()
1799
+ ? relationRecord.context_chunk.trim()
1800
+ : undefined;
1801
+ const relation = {
1802
+ source,
1803
+ target,
1804
+ type,
1805
+ relation_origin: relationOrigin,
1806
+ relation_definition: relationDefinition,
1807
+ evidence_span: evidenceSpan,
1808
+ context_chunk: contextChunk,
1809
+ confidence: confidenceValue,
1810
+ fact_status: status,
1811
+ source_event_id: sourceEventId || undefined,
1812
+ conflict_id: conflictId || undefined,
1813
+ relation_key: relationKey,
1814
+ };
1815
+ const evidenceIds = uniqueStrings([
1816
+ ...buildGraphEvidenceIds({
1817
+ source,
1818
+ target,
1819
+ type,
1820
+ sourceEventId: sourceEventId || undefined,
1821
+ evidenceSpan,
1822
+ }),
1823
+ conflictId ? `graph:conflict:${conflictId}` : "",
1824
+ ]);
1825
+ const text = [
1826
+ `# Graph Conflict Candidate`,
1827
+ `conflict_id: ${conflictId || "unknown"}`,
1828
+ `fact_status: ${status}`,
1829
+ `source_event_id: ${sourceEventId || "unknown"}`,
1830
+ `source_layer: ${sourceLayer || "unknown"}`,
1831
+ `event_type: ${candidateEventType || "unknown"}`,
1832
+ `session_id: ${sessionId || "unknown"}`,
1833
+ `source_file: ${sourceFile || "unknown"}`,
1834
+ `relation_key: ${relationKey}`,
1835
+ `evidence_span: ${evidenceSpan || "n/a"}`,
1836
+ `context_chunk: ${contextChunk || "n/a"}`,
1837
+ `relation_origin: ${relationOrigin || "n/a"}`,
1838
+ `relation_definition: ${relationDefinition || "n/a"}`,
1839
+ `confidence: ${typeof confidenceValue === "number" ? confidenceValue : "n/a"}`,
1840
+ ``,
1841
+ `## Summary`,
1842
+ candidateSummary || "n/a",
1843
+ ``,
1844
+ `## Source References`,
1845
+ `source_event_id: ${navSourceEventId || sourceEventId || "unknown"}`,
1846
+ `source_memory_id: ${navSourceMemoryId || sourceEventId || "unknown"}`,
1847
+ `source_layer: ${navSourceLayer || sourceLayer || "unknown"}`,
1848
+ `source_file: ${navSourceFile || sourceFile || "unknown"}`,
1849
+ `session_id: ${navSessionId || sessionId || "unknown"}`,
1850
+ ``,
1851
+ `${source} -[${type}/${status}]-> ${target}`,
1852
+ ].join("\n");
1853
+ graphDocs.push({
1854
+ id: `gcf:${conflictId || "unknown"}:rel:${relationIndex}`,
1855
+ text,
1856
+ source: "sessions_graph_conflict",
1857
+ timestamp: Number.isFinite(updatedAt) ? updatedAt : undefined,
1858
+ layer: (navSourceLayer || sourceLayer) === "active_only" ? "active" : "archive",
1859
+ summaryText: candidateSummary || undefined,
1860
+ sourceText: contextChunk || undefined,
1861
+ sourceMemoryId: navSourceMemoryId || relationKey,
1862
+ sourceEventId: navSourceEventId || sourceEventId || undefined,
1863
+ sourceFile: navSourceFile || sourceFile || undefined,
1864
+ sessionId: navSessionId || sessionId || undefined,
1865
+ entities: uniqueStrings([source, target]),
1866
+ relations: [relation],
1867
+ eventType: candidateEventType || "graph_conflict",
1868
+ qualityScore: typeof confidenceValue === "number" ? confidenceValue : 0.8,
1869
+ factStatus: status,
1870
+ evidenceIds,
1871
+ });
1872
+ }
1873
+ }
1874
+ catch (error) {
1875
+ options.logger.debug(`Skipping invalid conflict queue line: ${error}`);
1876
+ }
1877
+ }
1878
+ }
1879
+ const entitySummaryDocs = buildEntityGraphSummaryDocs(graphDocs.filter(item => item.factStatus === "active"));
1880
+ const wikiProjectionDocs = parseWikiProjectionDocuments(memoryRoot, options.logger);
1295
1881
  const docs = [
1296
1882
  ...parseMarkdownFile(cortexRulesPath, "CORTEX_RULES.md"),
1297
- ...parseMarkdownFile(memoryMdPath, "MEMORY.md"),
1298
1883
  ...parseJsonlFile(activeSessionsPath, "sessions_active", options.logger),
1299
1884
  ...parseJsonlFile(archiveSessionsPath, "sessions_archive", options.logger),
1300
1885
  ...graphDocs,
1301
1886
  ...entitySummaryDocs,
1887
+ ...wikiProjectionDocs,
1302
1888
  ];
1303
1889
  docsCache = { signature, docs };
1304
1890
  return docs;
@@ -1312,24 +1898,32 @@ function createReadStore(options) {
1312
1898
  vectorFallbackCache = { signature, docs };
1313
1899
  return docs;
1314
1900
  }
1315
- function getBm25Tokens(doc, signature) {
1901
+ function getBm25Tokens(doc, signature, channel) {
1316
1902
  if (bm25TokenCacheSignature !== signature) {
1317
1903
  bm25TokenCacheSignature = signature;
1318
1904
  bm25TokenCache = new Map();
1319
1905
  }
1320
- const key = `${doc.source}|${doc.id}|${doc.text.length}|${doc.text.slice(0, 64)}`;
1906
+ const channelText = channel === "fulltext"
1907
+ ? ((doc.sourceText || "").trim() || (doc.summaryText || doc.text || ""))
1908
+ : ((doc.summaryText || doc.text || "").trim());
1909
+ const key = `${channel}:${doc.source}|${doc.id}|${channelText.length}|${channelText.slice(0, 64)}`;
1321
1910
  const cached = bm25TokenCache.get(key);
1322
1911
  if (cached) {
1323
1912
  return cached;
1324
1913
  }
1325
- const tokens = tokenize(doc.text);
1914
+ const tokens = tokenize(channelText);
1326
1915
  bm25TokenCache.set(key, tokens);
1327
1916
  return tokens;
1328
1917
  }
1329
1918
  async function searchMemory(args) {
1330
1919
  const query = args.query?.trim();
1331
1920
  if (!query) {
1332
- return { results: [] };
1921
+ return {
1922
+ results: [],
1923
+ semantic_results: [],
1924
+ keyword_results: [],
1925
+ strategy: "vector_sentence_and_keyword_parallel",
1926
+ };
1333
1927
  }
1334
1928
  const mode = args.mode === "lightweight" ? "lightweight" : "default";
1335
1929
  const lightweightMode = mode === "lightweight";
@@ -1337,6 +1931,11 @@ function createReadStore(options) {
1337
1931
  const hitStats = loadHitStats();
1338
1932
  const intent = classifyIntent(query);
1339
1933
  const preferredTypes = preferredEventTypes(intent);
1934
+ const plannedQueriesRaw = lightweightMode ? [query] : planQueryKeywords(query);
1935
+ const plannedQueries = plannedQueriesRaw.length > 0 ? plannedQueriesRaw : [query];
1936
+ const summaryChannelWeight = 1;
1937
+ const fulltextChannelWeight = 0.35;
1938
+ const maxCandidatePool = Math.max(1, Math.max(args.topK, 20));
1340
1939
  let queryEmbedding = null;
1341
1940
  const embeddingModel = options.embedding?.model || "";
1342
1941
  const embeddingApiKey = options.embedding?.apiKey || "";
@@ -1393,11 +1992,11 @@ function createReadStore(options) {
1393
1992
  }
1394
1993
  }
1395
1994
  const graphDocs = docs
1396
- .filter(doc => doc.source === "sessions_graph" || doc.source === "sessions_graph_entity")
1995
+ .filter(doc => doc.source.startsWith("sessions_graph"))
1397
1996
  .map(doc => {
1398
1997
  const graphText = [
1399
1998
  doc.text,
1400
- ...(doc.relations || []).map(relation => `${relation.source} ${relation.type} ${relation.target}`),
1999
+ ...(doc.relations || []).map(relation => `${relation.source} ${relation.type} ${relation.target} ${relation.fact_status || doc.factStatus || "active"}`),
1401
2000
  ].join(" | ");
1402
2001
  return {
1403
2002
  ...doc,
@@ -1406,153 +2005,302 @@ function createReadStore(options) {
1406
2005
  });
1407
2006
  const rulesDocs = docs.filter(doc => doc.source === "CORTEX_RULES.md");
1408
2007
  const archiveDocs = docs.filter(doc => doc.source === "sessions_active" || doc.source === "sessions_archive");
1409
- const bm25Terms = tokenize(query);
1410
2008
  const bm25Corpus = [...rulesDocs, ...archiveDocs, ...vectorDocs, ...graphDocs];
1411
2009
  const bm25Signature = `${docsCache?.signature || "na"}|vector:${vectorDocs.length}:${vectorDocs.slice(0, 40).map(item => `${item.id}:${item.text.length}`).join(",")}`;
1412
- const bm25Stats = buildBm25Stats(bm25Corpus, bm25Terms, doc => getBm25Tokens(doc, bm25Signature));
1413
- const combinedCandidates = [];
1414
- const channels = {
1415
- rules: [],
1416
- archive: [],
1417
- vector: [],
1418
- graph: [],
2010
+ const rankByQuery = (plannedQuery, includeFulltext) => {
2011
+ const bm25Terms = tokenize(plannedQuery);
2012
+ const bm25StatsSummary = buildBm25Stats(bm25Corpus, bm25Terms, doc => getBm25Tokens(doc, bm25Signature, "summary"));
2013
+ const bm25StatsFulltext = includeFulltext
2014
+ ? buildBm25Stats(bm25Corpus, bm25Terms, doc => getBm25Tokens(doc, bm25Signature, "fulltext"))
2015
+ : { avgDocLen: 1, docFreq: new Map() };
2016
+ const channels = {
2017
+ rules: [],
2018
+ archive: [],
2019
+ vector: [],
2020
+ graph: [],
2021
+ };
2022
+ const evaluateDoc = (doc, source) => {
2023
+ const summaryText = (doc.summaryText || doc.text || "").trim();
2024
+ const fulltextText = (doc.sourceText || "").trim();
2025
+ const summaryBm25 = bm25Score({
2026
+ queryTerms: bm25Terms,
2027
+ docText: summaryText,
2028
+ docTokens: getBm25Tokens(doc, bm25Signature, "summary"),
2029
+ docCount: bm25Corpus.length,
2030
+ avgDocLen: bm25StatsSummary.avgDocLen,
2031
+ docFreq: bm25StatsSummary.docFreq,
2032
+ });
2033
+ const fulltextBm25 = includeFulltext
2034
+ ? bm25Score({
2035
+ queryTerms: bm25Terms,
2036
+ docText: fulltextText,
2037
+ docTokens: getBm25Tokens(doc, bm25Signature, "fulltext"),
2038
+ docCount: bm25Corpus.length,
2039
+ avgDocLen: bm25StatsFulltext.avgDocLen,
2040
+ docFreq: bm25StatsFulltext.docFreq,
2041
+ })
2042
+ : 0;
2043
+ const summaryCombined = scoreText(plannedQuery, summaryText) + summaryBm25 * readTuning.scoring.bm25Scale;
2044
+ const fulltextCombined = includeFulltext
2045
+ ? scoreText(plannedQuery, fulltextText) + fulltextBm25 * readTuning.scoring.bm25Scale
2046
+ : 0;
2047
+ const lexicalCombined = summaryCombined * summaryChannelWeight + fulltextCombined * fulltextChannelWeight;
2048
+ const semantic = plannedQuery === query && queryEmbedding && Array.isArray(doc.embedding) && doc.embedding.length > 0
2049
+ ? Math.max(0, cosineSimilarity(queryEmbedding, doc.embedding) * 5)
2050
+ : 0;
2051
+ if (lexicalCombined <= 0 && semantic <= 0) {
2052
+ return null;
2053
+ }
2054
+ if (source === "graph") {
2055
+ const status = docFactStatus(doc);
2056
+ if (status === "superseded" || status === "rejected") {
2057
+ return null;
2058
+ }
2059
+ }
2060
+ const recency = recencyScore(doc.timestamp, readTuning.recency.buckets);
2061
+ const quality = typeof doc.qualityScore === "number" ? Math.max(0, Math.min(1, doc.qualityScore)) : 0.5;
2062
+ const typeMatch = preferredTypes.length > 0 && doc.eventType
2063
+ ? (preferredTypes.includes(doc.eventType) ? 1 : 0)
2064
+ : 0.5;
2065
+ const graphMatch = source === "graph" ? 1 : 0;
2066
+ const sourceBaseWeight = sourceWeight(source, intent);
2067
+ const sourceConfigWeight = customChannelWeight(source, options.fusion);
2068
+ const lengthNorm = lengthNormalizeFactor(doc, options.fusion);
2069
+ const baseWeighted = (readTuning.scoring.lexicalWeight * lexicalCombined +
2070
+ readTuning.scoring.semanticWeight * (semantic * lengthNorm) +
2071
+ readTuning.scoring.recencyWeight * recency +
2072
+ readTuning.scoring.qualityWeight * quality +
2073
+ readTuning.scoring.typeMatchWeight * typeMatch +
2074
+ readTuning.scoring.graphMatchWeight * graphMatch) * sourceBaseWeight * sourceConfigWeight;
2075
+ const decayFactor = computeDecayFactor(doc.id, doc.eventType, doc.timestamp, options.memoryDecay, hitStats);
2076
+ const weighted = baseWeighted * decayFactor;
2077
+ return {
2078
+ doc,
2079
+ source,
2080
+ lexical: lexicalCombined,
2081
+ bm25: summaryBm25 + fulltextBm25,
2082
+ semantic,
2083
+ recency,
2084
+ quality,
2085
+ typeMatch,
2086
+ graphMatch,
2087
+ decayFactor,
2088
+ weighted,
2089
+ summaryCombined,
2090
+ fulltextCombined,
2091
+ };
2092
+ };
2093
+ for (const doc of rulesDocs) {
2094
+ const candidate = evaluateDoc(doc, "rules");
2095
+ if (candidate)
2096
+ channels.rules.push(candidate);
2097
+ }
2098
+ for (const doc of archiveDocs) {
2099
+ const candidate = evaluateDoc(doc, "archive");
2100
+ if (candidate)
2101
+ channels.archive.push(candidate);
2102
+ }
2103
+ for (const doc of vectorDocs) {
2104
+ const candidate = evaluateDoc(doc, "vector");
2105
+ if (candidate)
2106
+ channels.vector.push(candidate);
2107
+ }
2108
+ for (const doc of graphDocs) {
2109
+ const candidate = evaluateDoc(doc, "graph");
2110
+ if (candidate)
2111
+ channels.graph.push(candidate);
2112
+ }
2113
+ const rrfMap = new Map();
2114
+ const weightedMap = new Map();
2115
+ const rrfK = readTuning.rrf.k;
2116
+ for (const key of Object.keys(channels)) {
2117
+ const list = channels[key].sort((a, b) => b.weighted - a.weighted);
2118
+ const capped = list.slice(0, channelQuota(key, args.topK, options.fusion));
2119
+ for (let i = 0; i < capped.length; i += 1) {
2120
+ const candidate = capped[i];
2121
+ const rrf = 1 / (rrfK + i + 1);
2122
+ const mergeKey = mergeKeyFromDoc(candidate.doc);
2123
+ rrfMap.set(mergeKey, (rrfMap.get(mergeKey) || 0) + rrf);
2124
+ const current = weightedMap.get(mergeKey);
2125
+ if (!current || candidate.weighted > current.weighted) {
2126
+ weightedMap.set(mergeKey, candidate);
2127
+ }
2128
+ }
2129
+ }
2130
+ return [...weightedMap.entries()]
2131
+ .map(([mergeKey, candidate]) => ({
2132
+ id: candidate.doc.id,
2133
+ merge_key: mergeKey,
2134
+ source_memory_id: candidate.doc.sourceMemoryId || "",
2135
+ source_memory_canonical_id: candidate.doc.sourceMemoryCanonicalId || "",
2136
+ source_event_id: candidate.doc.sourceEventId || "",
2137
+ source_field: candidate.doc.sourceField || "",
2138
+ text: candidate.doc.summaryText || candidate.doc.text,
2139
+ source_text: candidate.doc.sourceText ? candidate.doc.sourceText.slice(0, 4000) : "",
2140
+ source_excerpt: candidate.doc.sourceText ? candidate.doc.sourceText.slice(0, 360) : "",
2141
+ source_file: candidate.doc.sourceFile || "",
2142
+ source: candidate.doc.source,
2143
+ layer: candidate.doc.layer || "",
2144
+ event_type: candidate.doc.eventType || "",
2145
+ fact_status: docFactStatus(candidate.doc),
2146
+ wiki_ref: candidate.doc.wikiRef || "",
2147
+ quality_score: candidate.quality,
2148
+ timestamp: candidate.doc.timestamp ? new Date(candidate.doc.timestamp).toISOString() : "",
2149
+ evidence_ids: docEvidenceIds(candidate.doc),
2150
+ score: candidate.weighted + (rrfMap.get(mergeKey) || 0) * readTuning.rrf.weight,
2151
+ score_breakdown: {
2152
+ lexical: Number(candidate.lexical.toFixed(4)),
2153
+ bm25: Number(candidate.bm25.toFixed(4)),
2154
+ semantic: Number(candidate.semantic.toFixed(4)),
2155
+ recency: Number(candidate.recency.toFixed(4)),
2156
+ quality: Number(candidate.quality.toFixed(4)),
2157
+ type: Number(candidate.typeMatch.toFixed(4)),
2158
+ graph: Number(candidate.graphMatch.toFixed(4)),
2159
+ summary: Number(candidate.summaryCombined.toFixed(4)),
2160
+ fulltext: Number(candidate.fulltextCombined.toFixed(4)),
2161
+ decay: Number(candidate.decayFactor.toFixed(4)),
2162
+ rrf: Number(((rrfMap.get(mergeKey) || 0) * readTuning.rrf.weight).toFixed(4)),
2163
+ weighted: Number(candidate.weighted.toFixed(4)),
2164
+ },
2165
+ reason_tags: [
2166
+ `intent:${intent.toLowerCase()}`,
2167
+ candidate.summaryCombined > 0 ? "summary_hit" : "",
2168
+ candidate.fulltextCombined > 0 ? "fulltext_hit" : "",
2169
+ candidate.semantic > 0 ? "vector_hit" : "",
2170
+ candidate.lexical > 0 ? "lexical_hit" : "",
2171
+ candidate.typeMatch >= 1 ? "event_type_match" : "event_type_weak",
2172
+ candidate.recency >= 0.8 ? "recent" : "historical",
2173
+ candidate.quality >= 0.7 ? "high_quality" : "normal_quality",
2174
+ candidate.decayFactor < 1 ? `decay:${candidate.decayFactor.toFixed(3)}` : "decay:1.000",
2175
+ `source:${candidate.source}`,
2176
+ `merge_key:${mergeKey}`,
2177
+ `query_term:${plannedQuery}`,
2178
+ ].filter(Boolean),
2179
+ matched_keywords: [plannedQuery],
2180
+ }))
2181
+ .sort((a, b) => b.score - a.score)
2182
+ .slice(0, maxCandidatePool);
1419
2183
  };
1420
- const evaluateDoc = (doc, source) => {
1421
- const lexical = scoreText(query, doc.text);
1422
- const bm25 = bm25Score({
1423
- queryTerms: bm25Terms,
1424
- docText: doc.text,
1425
- docTokens: getBm25Tokens(doc, bm25Signature),
1426
- docCount: bm25Corpus.length,
1427
- avgDocLen: bm25Stats.avgDocLen,
1428
- docFreq: bm25Stats.docFreq,
1429
- });
1430
- const lexicalCombined = lexical + bm25 * readTuning.scoring.bm25Scale;
1431
- const semantic = queryEmbedding && Array.isArray(doc.embedding) && doc.embedding.length > 0
1432
- ? Math.max(0, cosineSimilarity(queryEmbedding, doc.embedding) * 5)
1433
- : 0;
1434
- if (lexicalCombined <= 0 && semantic <= 0) {
1435
- return null;
2184
+ const queryRuns = await Promise.all(plannedQueries.map(async (plannedQuery) => {
2185
+ const stage1 = rankByQuery(plannedQuery, false);
2186
+ const needFallback = shouldTriggerFulltextFallback(stage1, args.topK);
2187
+ if (!needFallback || lightweightMode) {
2188
+ return { plannedQuery, ranked: stage1, fulltextFallback: false };
2189
+ }
2190
+ const stage2 = rankByQuery(plannedQuery, true);
2191
+ const merged = new Map();
2192
+ for (const item of [...stage1, ...stage2]) {
2193
+ const mergeKey = item.merge_key || item.id || "";
2194
+ const existing = merged.get(mergeKey);
2195
+ if (!existing) {
2196
+ merged.set(mergeKey, { ...item });
2197
+ continue;
2198
+ }
2199
+ const best = Number(item.score || 0) > Number(existing.score || 0)
2200
+ ? { ...existing, ...item }
2201
+ : { ...item, ...existing };
2202
+ const mergedReasonTags = uniqueStrings([
2203
+ ...(Array.isArray(existing.reason_tags) ? existing.reason_tags.map(v => String(v)) : []),
2204
+ ...(Array.isArray(item.reason_tags) ? item.reason_tags.map(v => String(v)) : []),
2205
+ ]);
2206
+ const mergedEvidenceIds = uniqueStrings([
2207
+ ...(Array.isArray(existing.evidence_ids) ? existing.evidence_ids.map(v => String(v)) : []),
2208
+ ...(Array.isArray(item.evidence_ids) ? item.evidence_ids.map(v => String(v)) : []),
2209
+ ]);
2210
+ const mergedKeywords = uniqueStrings([
2211
+ ...(Array.isArray(existing.matched_keywords) ? existing.matched_keywords.map(v => String(v)) : []),
2212
+ ...(Array.isArray(item.matched_keywords) ? item.matched_keywords.map(v => String(v)) : []),
2213
+ ]);
2214
+ merged.set(mergeKey, {
2215
+ ...best,
2216
+ score: Math.max(existing.score || 0, item.score || 0),
2217
+ reason_tags: mergedReasonTags,
2218
+ evidence_ids: mergedEvidenceIds,
2219
+ matched_keywords: mergedKeywords,
2220
+ });
1436
2221
  }
1437
- const recency = recencyScore(doc.timestamp, readTuning.recency.buckets);
1438
- const quality = typeof doc.qualityScore === "number" ? Math.max(0, Math.min(1, doc.qualityScore)) : 0.5;
1439
- const typeMatch = preferredTypes.length > 0 && doc.eventType
1440
- ? (preferredTypes.includes(doc.eventType) ? 1 : 0)
1441
- : 0.5;
1442
- const graphMatch = source === "graph" ? 1 : 0;
1443
- const sourceBaseWeight = sourceWeight(source, intent);
1444
- const sourceConfigWeight = customChannelWeight(source, options.fusion);
1445
- const lengthNorm = lengthNormalizeFactor(doc, options.fusion);
1446
- const baseWeighted = (readTuning.scoring.lexicalWeight * lexicalCombined +
1447
- readTuning.scoring.semanticWeight * (semantic * lengthNorm) +
1448
- readTuning.scoring.recencyWeight * recency +
1449
- readTuning.scoring.qualityWeight * quality +
1450
- readTuning.scoring.typeMatchWeight * typeMatch +
1451
- readTuning.scoring.graphMatchWeight * graphMatch) * sourceBaseWeight * sourceConfigWeight;
1452
- const decayFactor = computeDecayFactor(doc.id, doc.eventType, doc.timestamp, options.memoryDecay, hitStats);
1453
- const weighted = baseWeighted * decayFactor;
1454
2222
  return {
1455
- doc,
1456
- source,
1457
- lexical: lexicalCombined,
1458
- bm25,
1459
- semantic,
1460
- recency,
1461
- quality,
1462
- typeMatch,
1463
- graphMatch,
1464
- decayFactor,
1465
- weighted,
2223
+ plannedQuery,
2224
+ ranked: [...merged.values()]
2225
+ .sort((a, b) => Number(b.score || 0) - Number(a.score || 0))
2226
+ .slice(0, maxCandidatePool)
2227
+ .map((item) => ({
2228
+ ...item,
2229
+ reason_tags: uniqueStrings([
2230
+ ...(Array.isArray(item.reason_tags) ? item.reason_tags.map((v) => String(v)) : []),
2231
+ "fulltext_fallback",
2232
+ ]),
2233
+ })),
2234
+ fulltextFallback: true,
1466
2235
  };
1467
- };
1468
- for (const doc of rulesDocs) {
1469
- const candidate = evaluateDoc(doc, "rules");
1470
- if (candidate)
1471
- channels.rules.push(candidate);
1472
- }
1473
- for (const doc of archiveDocs) {
1474
- const candidate = evaluateDoc(doc, "archive");
1475
- if (candidate)
1476
- channels.archive.push(candidate);
1477
- }
1478
- for (const doc of vectorDocs) {
1479
- const candidate = evaluateDoc(doc, "vector");
1480
- if (candidate)
1481
- channels.vector.push(candidate);
1482
- }
1483
- for (const doc of graphDocs) {
1484
- const candidate = evaluateDoc(doc, "graph");
1485
- if (candidate)
1486
- channels.graph.push(candidate);
1487
- }
1488
- for (const key of Object.keys(channels)) {
1489
- channels[key].sort((a, b) => b.weighted - a.weighted);
1490
- combinedCandidates.push(...channels[key].slice(0, channelQuota(key, args.topK, options.fusion)));
1491
- }
1492
- const rrfMap = new Map();
1493
- const weightedMap = new Map();
1494
- const rrfK = readTuning.rrf.k;
1495
- for (const key of Object.keys(channels)) {
1496
- const list = channels[key];
1497
- for (let i = 0; i < list.length; i += 1) {
1498
- const candidate = list[i];
1499
- const rrf = 1 / (rrfK + i + 1);
1500
- const mergeKey = mergeKeyFromDoc(candidate.doc);
1501
- rrfMap.set(mergeKey, (rrfMap.get(mergeKey) || 0) + rrf);
1502
- const current = weightedMap.get(mergeKey);
1503
- if (!current || candidate.weighted > current.weighted) {
1504
- weightedMap.set(mergeKey, candidate);
2236
+ }));
2237
+ const mergedByQuery = new Map();
2238
+ for (const run of queryRuns) {
2239
+ for (const item of run.ranked) {
2240
+ const mergeKey = item.merge_key || item.id || "";
2241
+ const existing = mergedByQuery.get(mergeKey);
2242
+ if (!existing) {
2243
+ mergedByQuery.set(mergeKey, {
2244
+ ...item,
2245
+ matched_keywords: uniqueStrings([run.plannedQuery]),
2246
+ fulltext_fallback_used: run.fulltextFallback,
2247
+ });
2248
+ continue;
1505
2249
  }
2250
+ const mergedReasonTags = uniqueStrings([
2251
+ ...(Array.isArray(existing.reason_tags) ? existing.reason_tags.map(v => String(v)) : []),
2252
+ ...(Array.isArray(item.reason_tags) ? item.reason_tags.map(v => String(v)) : []),
2253
+ ]);
2254
+ const mergedEvidenceIds = uniqueStrings([
2255
+ ...(Array.isArray(existing.evidence_ids) ? existing.evidence_ids.map(v => String(v)) : []),
2256
+ ...(Array.isArray(item.evidence_ids) ? item.evidence_ids.map(v => String(v)) : []),
2257
+ ]);
2258
+ const matchedKeywords = uniqueStrings([
2259
+ ...(Array.isArray(existing.matched_keywords) ? existing.matched_keywords.map(v => String(v)) : []),
2260
+ run.plannedQuery,
2261
+ ]);
2262
+ const preferred = Number(item.score || 0) > Number(existing.score || 0)
2263
+ ? { ...existing, ...item }
2264
+ : { ...item, ...existing };
2265
+ mergedByQuery.set(mergeKey, {
2266
+ ...preferred,
2267
+ score: Math.max(existing.score || 0, item.score || 0),
2268
+ reason_tags: mergedReasonTags,
2269
+ evidence_ids: mergedEvidenceIds,
2270
+ matched_keywords: matchedKeywords,
2271
+ fulltext_fallback_used: Boolean(existing.fulltext_fallback_used) || run.fulltextFallback,
2272
+ });
1506
2273
  }
1507
2274
  }
1508
- const preRanked = [...weightedMap.entries()]
1509
- .map(([mergeKey, candidate]) => ({
1510
- id: candidate.doc.id,
1511
- merge_key: mergeKey,
1512
- source_memory_id: candidate.doc.sourceMemoryId || "",
1513
- source_memory_canonical_id: candidate.doc.sourceMemoryCanonicalId || "",
1514
- source_event_id: candidate.doc.sourceEventId || "",
1515
- source_field: candidate.doc.sourceField || "",
1516
- text: candidate.doc.summaryText || candidate.doc.text,
1517
- source_text: candidate.doc.sourceText ? candidate.doc.sourceText.slice(0, 4000) : "",
1518
- source_excerpt: candidate.doc.sourceText ? candidate.doc.sourceText.slice(0, 360) : "",
1519
- source_file: candidate.doc.sourceFile || "",
1520
- source: candidate.doc.source,
1521
- layer: candidate.doc.layer || "",
1522
- event_type: candidate.doc.eventType || "",
1523
- quality_score: candidate.quality,
1524
- timestamp: candidate.doc.timestamp ? new Date(candidate.doc.timestamp).toISOString() : "",
1525
- score: candidate.weighted + (rrfMap.get(mergeKey) || 0) * readTuning.rrf.weight,
1526
- score_breakdown: {
1527
- lexical: Number(candidate.lexical.toFixed(4)),
1528
- bm25: Number(candidate.bm25.toFixed(4)),
1529
- semantic: Number(candidate.semantic.toFixed(4)),
1530
- recency: Number(candidate.recency.toFixed(4)),
1531
- quality: Number(candidate.quality.toFixed(4)),
1532
- type: Number(candidate.typeMatch.toFixed(4)),
1533
- graph: Number(candidate.graphMatch.toFixed(4)),
1534
- decay: Number(candidate.decayFactor.toFixed(4)),
1535
- rrf: Number(((rrfMap.get(mergeKey) || 0) * readTuning.rrf.weight).toFixed(4)),
1536
- weighted: Number(candidate.weighted.toFixed(4)),
1537
- },
1538
- reason_tags: [
1539
- `intent:${intent.toLowerCase()}`,
1540
- candidate.semantic > 0 ? "vector_hit" : "lexical_hit",
1541
- candidate.typeMatch >= 1 ? "event_type_match" : "event_type_weak",
1542
- candidate.recency >= 0.8 ? "recent" : "historical",
1543
- candidate.quality >= 0.7 ? "high_quality" : "normal_quality",
1544
- candidate.decayFactor < 1 ? `decay:${candidate.decayFactor.toFixed(3)}` : "decay:1.000",
1545
- `source:${candidate.source}`,
1546
- `merge_key:${mergeKey}`,
1547
- ],
1548
- }))
1549
- .sort((a, b) => b.score - a.score)
1550
- .slice(0, Math.max(1, Math.max(args.topK, 20)));
1551
- const lexicalRanked = preRanked
1552
- .map(doc => {
1553
- const boost = withRecencyBoost(doc.score, doc.timestamp ? Date.parse(doc.timestamp) : undefined, readTuning.recency.buckets);
1554
- return { ...doc, score: Number(boost.toFixed(4)) };
1555
- });
2275
+ const lexicalRanked = [...mergedByQuery.values()]
2276
+ .map((item) => {
2277
+ const matchedKeywords = Array.isArray(item.matched_keywords)
2278
+ ? uniqueStrings(item.matched_keywords.map(v => String(v)))
2279
+ : [query];
2280
+ const keywordBonus = Math.max(0, matchedKeywords.length - 1) * 0.12;
2281
+ const boosted = withRecencyBoost(Number(item.score || 0) + keywordBonus, typeof item.timestamp === "string" ? Date.parse(item.timestamp) : undefined, readTuning.recency.buckets);
2282
+ return {
2283
+ ...item,
2284
+ matched_keywords: matchedKeywords,
2285
+ query_plan_keywords: plannedQueries,
2286
+ score: Number(boosted.toFixed(4)),
2287
+ reason_tags: uniqueStrings([
2288
+ ...(Array.isArray(item.reason_tags) ? item.reason_tags.map((v) => String(v)) : []),
2289
+ `keyword_hits:${matchedKeywords.length}`,
2290
+ `query_plan:${plannedQueries.length}`,
2291
+ Boolean(item.fulltext_fallback_used) ? "fulltext_fallback_used" : "",
2292
+ ]),
2293
+ };
2294
+ })
2295
+ .sort((a, b) => Number(b.score || 0) - Number(a.score || 0))
2296
+ .slice(0, maxCandidatePool);
2297
+ const isVectorSource = (value) => value.startsWith("vector_");
2298
+ const semanticResults = lexicalRanked
2299
+ .filter(item => isVectorSource(item.source) && Array.isArray(item.reason_tags) && item.reason_tags.includes("vector_hit"))
2300
+ .slice(0, Math.max(1, args.topK));
2301
+ const keywordResults = lexicalRanked
2302
+ .filter(item => isVectorSource(item.source) && Array.isArray(item.reason_tags) && item.reason_tags.includes("lexical_hit"))
2303
+ .slice(0, Math.max(1, args.topK));
1556
2304
  const rerankerModel = options.reranker?.model || "";
1557
2305
  const rerankerApiKey = options.reranker?.apiKey || "";
1558
2306
  const rerankerBaseUrl = normalizeBaseUrl(options.reranker?.baseURL || options.reranker?.baseUrl);
@@ -1604,8 +2352,11 @@ function createReadStore(options) {
1604
2352
  source: item.source,
1605
2353
  layer: hit?.layer || "",
1606
2354
  event_type: hit?.event_type || "",
2355
+ fact_status: hit?.fact_status || "active",
2356
+ wiki_ref: hit?.wiki_ref || "",
1607
2357
  quality_score: hit?.quality_score ?? 0,
1608
2358
  timestamp: hit?.timestamp || "",
2359
+ evidence_ids: Array.isArray(hit?.evidence_ids) ? hit?.evidence_ids : [],
1609
2360
  score: Number(item.score.toFixed(4)),
1610
2361
  score_breakdown: hit?.score_breakdown || {},
1611
2362
  reason_tags: Array.isArray(hit?.reason_tags) ? hit?.reason_tags : [],
@@ -1618,6 +2369,9 @@ function createReadStore(options) {
1618
2369
  channel: item.source,
1619
2370
  source_file: hit?.source_file || "",
1620
2371
  layer: hit?.layer || "",
2372
+ fact_status: hit?.fact_status || "active",
2373
+ wiki_ref: hit?.wiki_ref || "",
2374
+ evidence_ids: Array.isArray(hit?.evidence_ids) ? hit?.evidence_ids : [],
1621
2375
  score_breakdown: hit?.score_breakdown || {},
1622
2376
  reason_tags: Array.isArray(hit?.reason_tags) ? hit?.reason_tags : [],
1623
2377
  },
@@ -1647,8 +2401,11 @@ function createReadStore(options) {
1647
2401
  source: item.source,
1648
2402
  layer: item.layer,
1649
2403
  event_type: item.event_type,
2404
+ fact_status: item.fact_status || "active",
2405
+ wiki_ref: item.wiki_ref || "",
1650
2406
  quality_score: item.quality_score,
1651
2407
  timestamp: item.timestamp,
2408
+ evidence_ids: Array.isArray(item.evidence_ids) ? item.evidence_ids : [],
1652
2409
  score: Number(item.score.toFixed(4)),
1653
2410
  score_breakdown: item.score_breakdown || {},
1654
2411
  reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
@@ -1661,6 +2418,9 @@ function createReadStore(options) {
1661
2418
  channel: item.source,
1662
2419
  source_file: item.source_file || "",
1663
2420
  layer: item.layer,
2421
+ fact_status: item.fact_status || "active",
2422
+ wiki_ref: item.wiki_ref || "",
2423
+ evidence_ids: Array.isArray(item.evidence_ids) ? item.evidence_ids : [],
1664
2424
  score_breakdown: item.score_breakdown || {},
1665
2425
  reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
1666
2426
  },
@@ -1689,8 +2449,11 @@ function createReadStore(options) {
1689
2449
  source: item.source,
1690
2450
  layer: item.layer,
1691
2451
  event_type: item.event_type,
2452
+ fact_status: item.fact_status || "active",
2453
+ wiki_ref: item.wiki_ref || "",
1692
2454
  quality_score: item.quality_score,
1693
2455
  timestamp: item.timestamp,
2456
+ evidence_ids: Array.isArray(item.evidence_ids) ? item.evidence_ids : [],
1694
2457
  score: Number(item.score.toFixed(4)),
1695
2458
  score_breakdown: item.score_breakdown || {},
1696
2459
  reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
@@ -1703,6 +2466,9 @@ function createReadStore(options) {
1703
2466
  channel: item.source,
1704
2467
  source_file: item.source_file || "",
1705
2468
  layer: item.layer,
2469
+ fact_status: item.fact_status || "active",
2470
+ wiki_ref: item.wiki_ref || "",
2471
+ evidence_ids: Array.isArray(item.evidence_ids) ? item.evidence_ids : [],
1706
2472
  score_breakdown: item.score_breakdown || {},
1707
2473
  reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
1708
2474
  },
@@ -1742,6 +2508,18 @@ function createReadStore(options) {
1742
2508
  if (!Array.isArray(fusion.evidence_ids) || fusion.evidence_ids.length === 0) {
1743
2509
  throw new Error("fusion_missing_whitelisted_evidence");
1744
2510
  }
2511
+ const fusedEvidenceIds = uniqueStrings(fusion.evidence_ids.flatMap(item => {
2512
+ const linked = ranked.find(candidate => candidate.id === item);
2513
+ if (!linked)
2514
+ return [item];
2515
+ const linkedEvidence = Array.isArray(linked.evidence_ids) ? linked.evidence_ids : [];
2516
+ return linkedEvidence.length > 0 ? linkedEvidence : [item];
2517
+ }));
2518
+ const wikiRefs = uniqueStrings(fusion.evidence_ids.flatMap(item => {
2519
+ const linked = ranked.find(candidate => candidate.id === item);
2520
+ const wikiRef = typeof linked?.wiki_ref === "string" ? linked.wiki_ref : "";
2521
+ return wikiRef ? [wikiRef] : [];
2522
+ }));
1745
2523
  const fulltextFetchHints = (Array.isArray(fusion.need_fulltext_event_ids) ? fusion.need_fulltext_event_ids : [])
1746
2524
  .map(eventId => {
1747
2525
  const linked = ranked.find(item => item.source_memory_id === eventId ||
@@ -1777,21 +2555,33 @@ function createReadStore(options) {
1777
2555
  fused_risks: fusion.risks || [],
1778
2556
  fused_action_items: fusion.action_items || [],
1779
2557
  fused_conflicts: fusion.conflicts,
1780
- fused_evidence_ids: fusion.evidence_ids,
2558
+ evidence_ids: fusedEvidenceIds,
2559
+ wiki_refs: wikiRefs,
2560
+ fused_evidence_ids: fusedEvidenceIds,
1781
2561
  fused_need_fulltext_event_ids: fusion.need_fulltext_event_ids || [],
1782
2562
  fulltext_fetch_hints: fulltextFetchHints,
1783
2563
  };
1784
2564
  const authoritative = options.fusion?.authoritative !== false;
1785
2565
  if (authoritative) {
1786
2566
  markHit(Array.isArray(fusion.evidence_ids) ? fusion.evidence_ids : []);
1787
- return { results: [fusedItem] };
2567
+ return {
2568
+ results: [fusedItem],
2569
+ semantic_results: semanticResults,
2570
+ keyword_results: keywordResults,
2571
+ strategy: "vector_sentence_and_keyword_parallel",
2572
+ };
1788
2573
  }
1789
2574
  const merged = [fusedItem, ...ranked];
1790
2575
  markHit([
1791
2576
  ...(Array.isArray(fusion.evidence_ids) ? fusion.evidence_ids : []),
1792
2577
  ...ranked.map(item => item.id),
1793
2578
  ]);
1794
- return { results: merged.slice(0, Math.max(1, args.topK)) };
2579
+ return {
2580
+ results: merged.slice(0, Math.max(1, args.topK)),
2581
+ semantic_results: semanticResults,
2582
+ keyword_results: keywordResults,
2583
+ strategy: "vector_sentence_and_keyword_parallel",
2584
+ };
1795
2585
  }
1796
2586
  }
1797
2587
  catch (error) {
@@ -1800,7 +2590,12 @@ function createReadStore(options) {
1800
2590
  }
1801
2591
  const finalRanked = ranked.slice(0, Math.max(1, args.topK));
1802
2592
  markHit(finalRanked.map(item => item.id));
1803
- return { results: finalRanked };
2593
+ return {
2594
+ results: finalRanked,
2595
+ semantic_results: semanticResults,
2596
+ keyword_results: keywordResults,
2597
+ strategy: "vector_sentence_and_keyword_parallel",
2598
+ };
1804
2599
  }
1805
2600
  async function getHotContext(args) {
1806
2601
  const limit = Math.max(1, args.limit);