openclaw-cortex-memory 0.1.0-Alpha.3 → 0.1.0-Alpha.31

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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -203
  3. package/SIGNATURE.md +7 -0
  4. package/SKILL.md +92 -268
  5. package/dist/index.d.ts +100 -22
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1249 -1252
  8. package/dist/index.js.map +1 -1
  9. package/dist/openclaw.plugin.json +501 -16
  10. package/dist/src/dedup/three_stage_deduplicator.d.ts +25 -0
  11. package/dist/src/dedup/three_stage_deduplicator.d.ts.map +1 -0
  12. package/dist/src/dedup/three_stage_deduplicator.js +224 -0
  13. package/dist/src/dedup/three_stage_deduplicator.js.map +1 -0
  14. package/dist/src/engine/memory_engine.d.ts +6 -1
  15. package/dist/src/engine/memory_engine.d.ts.map +1 -1
  16. package/dist/src/engine/ts_engine.d.ts +242 -0
  17. package/dist/src/engine/ts_engine.d.ts.map +1 -1
  18. package/dist/src/engine/ts_engine.js +1468 -52
  19. package/dist/src/engine/ts_engine.js.map +1 -1
  20. package/dist/src/engine/types.d.ts +29 -0
  21. package/dist/src/engine/types.d.ts.map +1 -1
  22. package/dist/src/graph/ontology.d.ts +125 -0
  23. package/dist/src/graph/ontology.d.ts.map +1 -0
  24. package/dist/src/graph/ontology.js +1237 -0
  25. package/dist/src/graph/ontology.js.map +1 -0
  26. package/dist/src/net/http_post.d.ts +17 -0
  27. package/dist/src/net/http_post.d.ts.map +1 -0
  28. package/dist/src/net/http_post.js +56 -0
  29. package/dist/src/net/http_post.js.map +1 -0
  30. package/dist/src/quality/llm_output_validator.d.ts +66 -0
  31. package/dist/src/quality/llm_output_validator.d.ts.map +1 -0
  32. package/dist/src/quality/llm_output_validator.js +659 -0
  33. package/dist/src/quality/llm_output_validator.js.map +1 -0
  34. package/dist/src/reflect/reflector.d.ts +7 -0
  35. package/dist/src/reflect/reflector.d.ts.map +1 -1
  36. package/dist/src/reflect/reflector.js +352 -8
  37. package/dist/src/reflect/reflector.js.map +1 -1
  38. package/dist/src/rules/rule_store.d.ts.map +1 -1
  39. package/dist/src/rules/rule_store.js +75 -16
  40. package/dist/src/rules/rule_store.js.map +1 -1
  41. package/dist/src/session/session_end.d.ts +33 -0
  42. package/dist/src/session/session_end.d.ts.map +1 -1
  43. package/dist/src/session/session_end.js +67 -64
  44. package/dist/src/session/session_end.js.map +1 -1
  45. package/dist/src/store/archive_store.d.ts +136 -0
  46. package/dist/src/store/archive_store.d.ts.map +1 -0
  47. package/dist/src/store/archive_store.js +635 -0
  48. package/dist/src/store/archive_store.js.map +1 -0
  49. package/dist/src/store/embedding_utils.d.ts +32 -0
  50. package/dist/src/store/embedding_utils.d.ts.map +1 -0
  51. package/dist/src/store/embedding_utils.js +173 -0
  52. package/dist/src/store/embedding_utils.js.map +1 -0
  53. package/dist/src/store/graph_memory_store.d.ts +114 -0
  54. package/dist/src/store/graph_memory_store.d.ts.map +1 -0
  55. package/dist/src/store/graph_memory_store.js +841 -0
  56. package/dist/src/store/graph_memory_store.js.map +1 -0
  57. package/dist/src/store/read_store.d.ts +89 -0
  58. package/dist/src/store/read_store.d.ts.map +1 -1
  59. package/dist/src/store/read_store.js +2459 -28
  60. package/dist/src/store/read_store.js.map +1 -1
  61. package/dist/src/store/vector_store.d.ts +45 -0
  62. package/dist/src/store/vector_store.d.ts.map +1 -0
  63. package/dist/src/store/vector_store.js +202 -0
  64. package/dist/src/store/vector_store.js.map +1 -0
  65. package/dist/src/store/write_store.d.ts +54 -0
  66. package/dist/src/store/write_store.d.ts.map +1 -1
  67. package/dist/src/store/write_store.js +284 -6
  68. package/dist/src/store/write_store.js.map +1 -1
  69. package/dist/src/sync/session_sync.d.ts +119 -2
  70. package/dist/src/sync/session_sync.d.ts.map +1 -1
  71. package/dist/src/sync/session_sync.js +2377 -31
  72. package/dist/src/sync/session_sync.js.map +1 -1
  73. package/dist/src/utils/runtime_env.d.ts +4 -0
  74. package/dist/src/utils/runtime_env.d.ts.map +1 -0
  75. package/dist/src/utils/runtime_env.js +51 -0
  76. package/dist/src/utils/runtime_env.js.map +1 -0
  77. package/dist/src/wiki/wiki_linter.d.ts +25 -0
  78. package/dist/src/wiki/wiki_linter.d.ts.map +1 -0
  79. package/dist/src/wiki/wiki_linter.js +268 -0
  80. package/dist/src/wiki/wiki_linter.js.map +1 -0
  81. package/dist/src/wiki/wiki_logger.d.ts +10 -0
  82. package/dist/src/wiki/wiki_logger.d.ts.map +1 -0
  83. package/dist/src/wiki/wiki_logger.js +78 -0
  84. package/dist/src/wiki/wiki_logger.js.map +1 -0
  85. package/dist/src/wiki/wiki_maintainer.d.ts +36 -0
  86. package/dist/src/wiki/wiki_maintainer.d.ts.map +1 -0
  87. package/dist/src/wiki/wiki_maintainer.js +38 -0
  88. package/dist/src/wiki/wiki_maintainer.js.map +1 -0
  89. package/dist/src/wiki/wiki_projector.d.ts +33 -0
  90. package/dist/src/wiki/wiki_projector.d.ts.map +1 -0
  91. package/dist/src/wiki/wiki_projector.js +633 -0
  92. package/dist/src/wiki/wiki_projector.js.map +1 -0
  93. package/dist/src/wiki/wiki_queue.d.ts +29 -0
  94. package/dist/src/wiki/wiki_queue.d.ts.map +1 -0
  95. package/dist/src/wiki/wiki_queue.js +137 -0
  96. package/dist/src/wiki/wiki_queue.js.map +1 -0
  97. package/openclaw.plugin.json +501 -16
  98. package/package.json +58 -7
  99. package/schema/graph.schema.yaml +330 -0
  100. package/scripts/cli.js +19 -14
  101. package/scripts/repair-memory.js +321 -0
  102. package/scripts/uninstall.js +22 -5
  103. package/skills/cortex-memory/SKILL.md +49 -0
  104. package/skills/cortex-memory/references/agent-manual.md +115 -0
  105. package/skills/cortex-memory/references/configuration.md +92 -0
  106. package/skills/cortex-memory/references/publish-checklist.md +46 -0
  107. package/skills/cortex-memory/references/system-prompt-template.md +27 -0
  108. package/skills/cortex-memory/references/tools.md +181 -0
  109. package/skills/cortex-memory/scripts/smoke-check.ps1 +56 -0
  110. package/index.ts +0 -2142
@@ -36,6 +36,185 @@ 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");
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
+ }
47
+ function buildEntityGraphSummaryDocs(graphDocs) {
48
+ const entityEdges = new Map();
49
+ const entityLatestTs = new Map();
50
+ const entitySession = new Map();
51
+ for (const doc of graphDocs) {
52
+ const ts = typeof doc.timestamp === "number" ? doc.timestamp : 0;
53
+ const sessionId = typeof doc.sessionId === "string" ? doc.sessionId : "";
54
+ const relations = Array.isArray(doc.relations) ? doc.relations : [];
55
+ for (const relation of relations) {
56
+ const source = (relation.source || "").trim();
57
+ const target = (relation.target || "").trim();
58
+ const type = (relation.type || "").trim();
59
+ if (!source || !target || !type)
60
+ continue;
61
+ if (!entityEdges.has(source))
62
+ entityEdges.set(source, []);
63
+ if (!entityEdges.has(target))
64
+ entityEdges.set(target, []);
65
+ entityEdges.get(source)?.push({ source, target, type });
66
+ entityEdges.get(target)?.push({ source, target, type });
67
+ entityLatestTs.set(source, Math.max(entityLatestTs.get(source) || 0, ts));
68
+ entityLatestTs.set(target, Math.max(entityLatestTs.get(target) || 0, ts));
69
+ if (sessionId) {
70
+ if (!entitySession.has(source))
71
+ entitySession.set(source, sessionId);
72
+ if (!entitySession.has(target))
73
+ entitySession.set(target, sessionId);
74
+ }
75
+ }
76
+ }
77
+ const output = [];
78
+ for (const [entity, edges] of entityEdges.entries()) {
79
+ if (!edges.length)
80
+ continue;
81
+ const outgoing = edges.filter(edge => edge.source === entity);
82
+ const incoming = edges.filter(edge => edge.target === entity);
83
+ const typeCounter = new Map();
84
+ for (const edge of edges) {
85
+ typeCounter.set(edge.type, (typeCounter.get(edge.type) || 0) + 1);
86
+ }
87
+ const typeSummary = [...typeCounter.entries()]
88
+ .sort((a, b) => b[1] - a[1])
89
+ .map(([type, count]) => `${type}:${count}`)
90
+ .join(", ");
91
+ const sortedOutgoing = [...outgoing].sort((a, b) => a.type.localeCompare(b.type));
92
+ const sortedIncoming = [...incoming].sort((a, b) => a.type.localeCompare(b.type));
93
+ const cappedOutgoing = sortedOutgoing.slice(0, 20);
94
+ const cappedIncoming = sortedIncoming.slice(0, 20);
95
+ const relationFacts = edges
96
+ .slice(0, 40)
97
+ .map(edge => `${edge.source} ${edge.type} ${edge.target}`)
98
+ .join(" | ");
99
+ const outgoingBlock = cappedOutgoing.length > 0
100
+ ? cappedOutgoing.map((edge, index) => `${index + 1}. ${edge.source} -[${edge.type}]-> ${edge.target}`).join("\n")
101
+ : "none";
102
+ const incomingBlock = cappedIncoming.length > 0
103
+ ? cappedIncoming.map((edge, index) => `${index + 1}. ${edge.source} -[${edge.type}]-> ${edge.target}`).join("\n")
104
+ : "none";
105
+ const summaryText = [
106
+ `# Graph Entity Summary`,
107
+ `entity: ${entity}`,
108
+ ``,
109
+ `## Stats`,
110
+ `relation_total: ${edges.length}`,
111
+ `outgoing_total: ${outgoing.length}`,
112
+ `incoming_total: ${incoming.length}`,
113
+ typeSummary ? `relation_type_distribution: ${typeSummary}` : "relation_type_distribution: none",
114
+ ``,
115
+ `## Outgoing Relations`,
116
+ outgoingBlock,
117
+ outgoing.length > cappedOutgoing.length ? `...truncated_outgoing: ${outgoing.length - cappedOutgoing.length}` : "",
118
+ ``,
119
+ `## Incoming Relations`,
120
+ incomingBlock,
121
+ incoming.length > cappedIncoming.length ? `...truncated_incoming: ${incoming.length - cappedIncoming.length}` : "",
122
+ ``,
123
+ `## Relation Facts`,
124
+ relationFacts || "none",
125
+ ].filter(Boolean).join("\n");
126
+ output.push({
127
+ id: `gph_entity_${entity.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "_")}`,
128
+ text: summaryText,
129
+ source: "sessions_graph_entity",
130
+ timestamp: (entityLatestTs.get(entity) || 0) > 0 ? entityLatestTs.get(entity) : undefined,
131
+ layer: "archive",
132
+ sourceMemoryId: entity,
133
+ sessionId: entitySession.get(entity) || undefined,
134
+ entities: [entity],
135
+ relations: edges,
136
+ eventType: "graph_summary",
137
+ qualityScore: 1,
138
+ });
139
+ }
140
+ return output;
141
+ }
142
+ const DEFAULT_READ_TUNING = {
143
+ scoring: {
144
+ lexicalWeight: 0.2,
145
+ bm25Scale: 2,
146
+ semanticWeight: 0.3,
147
+ recencyWeight: 0.1,
148
+ qualityWeight: 0.15,
149
+ typeMatchWeight: 0.15,
150
+ graphMatchWeight: 0.1,
151
+ },
152
+ rrf: {
153
+ k: 60,
154
+ weight: 1.5,
155
+ },
156
+ recency: {
157
+ buckets: [
158
+ { maxAgeHours: 12, score: 1, bonus: 0.6 },
159
+ { maxAgeHours: 24, score: 0.8, bonus: 0.6 },
160
+ { maxAgeHours: 72, score: 0.6, bonus: 0.3 },
161
+ { maxAgeHours: 168, score: 0.4, bonus: 0.3 },
162
+ { maxAgeHours: 720, score: 0.2, bonus: 0 },
163
+ { maxAgeHours: Number.POSITIVE_INFINITY, score: 0.05, bonus: 0 },
164
+ ],
165
+ },
166
+ autoContext: {
167
+ queryMaxChars: 80,
168
+ lightweightSearch: true,
169
+ },
170
+ };
171
+ function resolveReadTuning(options) {
172
+ const configuredBuckets = Array.isArray(options?.recency?.buckets)
173
+ ? options?.recency?.buckets
174
+ .filter(item => item &&
175
+ Number.isFinite(item.maxAgeHours) &&
176
+ item.maxAgeHours > 0 &&
177
+ Number.isFinite(item.score) &&
178
+ item.score >= 0 &&
179
+ Number.isFinite(item.bonus))
180
+ .map(item => ({
181
+ maxAgeHours: item.maxAgeHours,
182
+ score: Math.max(0, item.score),
183
+ bonus: Math.max(0, item.bonus),
184
+ }))
185
+ : [];
186
+ const sortedBuckets = configuredBuckets
187
+ .sort((a, b) => a.maxAgeHours - b.maxAgeHours);
188
+ const buckets = sortedBuckets.length > 0 ? sortedBuckets : DEFAULT_READ_TUNING.recency.buckets;
189
+ const numberOr = (value, fallback, min) => {
190
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
191
+ return fallback;
192
+ }
193
+ return value;
194
+ };
195
+ return {
196
+ scoring: {
197
+ lexicalWeight: numberOr(options?.scoring?.lexicalWeight, DEFAULT_READ_TUNING.scoring.lexicalWeight, 0),
198
+ bm25Scale: numberOr(options?.scoring?.bm25Scale, DEFAULT_READ_TUNING.scoring.bm25Scale, 0),
199
+ semanticWeight: numberOr(options?.scoring?.semanticWeight, DEFAULT_READ_TUNING.scoring.semanticWeight, 0),
200
+ recencyWeight: numberOr(options?.scoring?.recencyWeight, DEFAULT_READ_TUNING.scoring.recencyWeight, 0),
201
+ qualityWeight: numberOr(options?.scoring?.qualityWeight, DEFAULT_READ_TUNING.scoring.qualityWeight, 0),
202
+ typeMatchWeight: numberOr(options?.scoring?.typeMatchWeight, DEFAULT_READ_TUNING.scoring.typeMatchWeight, 0),
203
+ graphMatchWeight: numberOr(options?.scoring?.graphMatchWeight, DEFAULT_READ_TUNING.scoring.graphMatchWeight, 0),
204
+ },
205
+ rrf: {
206
+ k: Math.floor(numberOr(options?.rrf?.k, DEFAULT_READ_TUNING.rrf.k, 1)),
207
+ weight: numberOr(options?.rrf?.weight, DEFAULT_READ_TUNING.rrf.weight, 0),
208
+ },
209
+ recency: {
210
+ buckets,
211
+ },
212
+ autoContext: {
213
+ queryMaxChars: Math.floor(numberOr(options?.autoContext?.queryMaxChars, DEFAULT_READ_TUNING.autoContext.queryMaxChars, 20)),
214
+ lightweightSearch: options?.autoContext?.lightweightSearch !== false,
215
+ },
216
+ };
217
+ }
39
218
  function safeReadFile(filePath) {
40
219
  try {
41
220
  if (!fs.existsSync(filePath)) {
@@ -65,7 +244,64 @@ function scoreText(query, text) {
65
244
  }
66
245
  return score;
67
246
  }
247
+ function tokenize(text) {
248
+ return text
249
+ .toLowerCase()
250
+ .split(/[^a-z0-9\u4e00-\u9fa5]+/i)
251
+ .map(token => token.trim())
252
+ .filter(Boolean);
253
+ }
254
+ function buildBm25Stats(docs, queryTerms, getTokens) {
255
+ const docFreq = new Map();
256
+ let totalLen = 0;
257
+ for (const doc of docs) {
258
+ const tokens = typeof getTokens === "function" ? getTokens(doc) : tokenize(doc.text);
259
+ totalLen += tokens.length;
260
+ if (queryTerms.length === 0) {
261
+ continue;
262
+ }
263
+ const termSet = new Set(tokens);
264
+ for (const term of queryTerms) {
265
+ if (termSet.has(term)) {
266
+ docFreq.set(term, (docFreq.get(term) || 0) + 1);
267
+ }
268
+ }
269
+ }
270
+ const avgDocLen = docs.length > 0 ? Math.max(1, totalLen / docs.length) : 1;
271
+ return { avgDocLen, docFreq };
272
+ }
273
+ function bm25Score(args) {
274
+ const tokens = Array.isArray(args.docTokens) ? args.docTokens : tokenize(args.docText);
275
+ if (tokens.length === 0 || args.queryTerms.length === 0 || args.docCount <= 0) {
276
+ return 0;
277
+ }
278
+ const termFreq = new Map();
279
+ for (const token of tokens) {
280
+ termFreq.set(token, (termFreq.get(token) || 0) + 1);
281
+ }
282
+ const k1 = 1.2;
283
+ const b = 0.75;
284
+ let score = 0;
285
+ for (const term of args.queryTerms) {
286
+ const tf = termFreq.get(term) || 0;
287
+ if (tf <= 0)
288
+ continue;
289
+ const df = args.docFreq.get(term) || 0;
290
+ const idf = Math.log(1 + ((args.docCount - df + 0.5) / (df + 0.5)));
291
+ const denominator = tf + k1 * (1 - b + b * (tokens.length / Math.max(1, args.avgDocLen)));
292
+ score += idf * (((k1 + 1) * tf) / Math.max(1e-6, denominator));
293
+ }
294
+ return score;
295
+ }
68
296
  function normalizeRecordText(record) {
297
+ const summary = typeof record.summary === "string" ? record.summary.trim() : "";
298
+ const sourceText = typeof record.source_text === "string" ? record.source_text.trim() : "";
299
+ if (summary && sourceText) {
300
+ return [
301
+ `summary: ${summary}`,
302
+ `source_text: ${sourceText}`,
303
+ ].join("\n");
304
+ }
69
305
  const direct = [record.content, record.summary, record.text, record.message]
70
306
  .find(v => typeof v === "string" && v.trim());
71
307
  if (direct) {
@@ -108,7 +344,18 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
108
344
  }
109
345
  try {
110
346
  const parsed = JSON.parse(trimmed);
111
- const text = normalizeRecordText(parsed);
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() : "";
351
+ const sourceText = typeof parsed.source_text === "string" ? parsed.source_text.trim() : "";
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);
112
359
  if (!text.trim()) {
113
360
  continue;
114
361
  }
@@ -117,8 +364,29 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
117
364
  docs.push({
118
365
  id,
119
366
  text,
367
+ summaryText: summaryText || text,
368
+ sourceText: sourceText || activeContent || undefined,
369
+ sourceEventId: typeof parsed.source_event_id === "string" ? parsed.source_event_id : undefined,
370
+ sourceFile: typeof parsed.source_file === "string" ? parsed.source_file : undefined,
120
371
  source: sourceLabel,
121
372
  timestamp: Number.isFinite(timestampValue) ? timestampValue : undefined,
373
+ layer: parsed.layer === "active" || parsed.layer === "archive"
374
+ ? parsed.layer
375
+ : (sourceLabel === "sessions_active" ? "active" : (sourceLabel === "sessions_archive" ? "archive" : undefined)),
376
+ sourceMemoryId: typeof parsed.source_memory_id === "string"
377
+ ? parsed.source_memory_id
378
+ : id,
379
+ sourceMemoryCanonicalId: typeof parsed.source_memory_canonical_id === "string"
380
+ ? parsed.source_memory_canonical_id
381
+ : (typeof parsed.canonical_id === "string" ? parsed.canonical_id : undefined),
382
+ embedding: Array.isArray(parsed.embedding) ? parsed.embedding.filter(item => Number.isFinite(item)) : undefined,
383
+ eventType: typeof parsed.event_type === "string" ? parsed.event_type.trim() : undefined,
384
+ qualityScore: typeof parsed.quality_score === "number" ? parsed.quality_score : undefined,
385
+ charCount: typeof parsed.char_count === "number" ? parsed.char_count : undefined,
386
+ tokenCount: typeof parsed.token_count === "number" ? parsed.token_count : undefined,
387
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : undefined,
388
+ entities: [],
389
+ relations: [],
122
390
  });
123
391
  }
124
392
  catch (error) {
@@ -147,69 +415,2213 @@ function parseMarkdownFile(filePath, sourceLabel) {
147
415
  },
148
416
  ];
149
417
  }
150
- function withRecencyBoost(score, timestamp) {
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
+ }
621
+ function extractPrioritizedRuleLines(text, maxRules) {
622
+ if (!text.trim() || maxRules <= 0) {
623
+ return [];
624
+ }
625
+ const lines = text
626
+ .split(/\r?\n/)
627
+ .map(line => line.trim())
628
+ .filter(Boolean)
629
+ .filter(line => !/^core rules and knowledge extracted/i.test(line))
630
+ .filter(line => !/^core rules\b/i.test(line));
631
+ if (lines.length === 0) {
632
+ return [];
633
+ }
634
+ const dedupedFromTail = [];
635
+ const seen = new Set();
636
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
637
+ const line = lines[i];
638
+ const key = line.toLowerCase().replace(/\s+/g, " ").trim();
639
+ if (!key || seen.has(key)) {
640
+ continue;
641
+ }
642
+ seen.add(key);
643
+ dedupedFromTail.push(line);
644
+ }
645
+ const scored = dedupedFromTail.map((line, indexFromTail) => {
646
+ let score = 0;
647
+ if (/(must|should|ensure|avoid|prefer|always|never|fallback|verify|validate|retry|sanitize)/i.test(line)) {
648
+ score += 3;
649
+ }
650
+ if (/(fix|resolved|success|stable|deploy|release|incident|rollback|constraint|decision)/i.test(line)) {
651
+ score += 2;
652
+ }
653
+ if (/(确保|避免|优先|必须|建议|回退|重试|校验|稳定|发布|决策)/.test(line)) {
654
+ score += 2;
655
+ }
656
+ if (line.length >= 30 && line.length <= 220) {
657
+ score += 2;
658
+ }
659
+ else if (line.length > 220) {
660
+ score -= 1;
661
+ }
662
+ if (/[.!?]$/.test(line)) {
663
+ score += 1;
664
+ }
665
+ score += Math.max(0, 2 - indexFromTail * 0.08);
666
+ return { line, score, indexFromTail };
667
+ });
668
+ const selected = scored
669
+ .sort((a, b) => (b.score - a.score) || (a.indexFromTail - b.indexFromTail))
670
+ .slice(0, maxRules)
671
+ .sort((a, b) => a.indexFromTail - b.indexFromTail)
672
+ .map(item => item.line);
673
+ return selected;
674
+ }
675
+ function withRecencyBoost(score, timestamp, buckets) {
151
676
  if (!timestamp) {
152
677
  return score;
153
678
  }
154
679
  const ageHours = (Date.now() - timestamp) / (1000 * 60 * 60);
155
- if (ageHours < 24) {
156
- return score + 0.6;
157
- }
158
- if (ageHours < 168) {
159
- return score + 0.3;
680
+ for (const bucket of buckets) {
681
+ if (ageHours <= bucket.maxAgeHours) {
682
+ return score + bucket.bonus;
683
+ }
160
684
  }
161
685
  return score;
162
686
  }
687
+ function recencyScore(timestamp, buckets) {
688
+ if (!timestamp) {
689
+ return 0;
690
+ }
691
+ const ageHours = (Date.now() - timestamp) / (1000 * 60 * 60);
692
+ for (const bucket of buckets) {
693
+ if (ageHours <= bucket.maxAgeHours) {
694
+ return bucket.score;
695
+ }
696
+ }
697
+ return 0;
698
+ }
699
+ function eventTypeHalfLifeDays(eventType, options) {
700
+ const fallback = typeof options?.defaultHalfLifeDays === "number" && options.defaultHalfLifeDays > 0
701
+ ? options.defaultHalfLifeDays
702
+ : 90;
703
+ const type = (eventType || "").trim().toLowerCase();
704
+ if (!type)
705
+ return fallback;
706
+ const configured = options?.halfLifeByEventType || {};
707
+ if (typeof configured[type] === "number" && configured[type] > 0) {
708
+ return configured[type];
709
+ }
710
+ if (["issue", "fix", "action_item", "blocker"].includes(type))
711
+ return 30;
712
+ if (["plan", "milestone", "follow_up"].includes(type))
713
+ return 60;
714
+ if (["decision", "insight", "retrospective"].includes(type))
715
+ return 120;
716
+ if (["preference", "constraint", "requirement", "dependency", "assumption"].includes(type))
717
+ return 240;
718
+ return fallback;
719
+ }
720
+ function computeAntiDecayBoost(id, hitStats, options) {
721
+ const anti = options?.antiDecay;
722
+ if (anti?.enabled === false) {
723
+ return 1;
724
+ }
725
+ const item = hitStats.items[id];
726
+ if (!item) {
727
+ return 1;
728
+ }
729
+ const hitWeight = typeof anti?.hitWeight === "number" && anti.hitWeight > 0 ? anti.hitWeight : 0.08;
730
+ const maxBoost = typeof anti?.maxBoost === "number" && anti.maxBoost >= 1 ? anti.maxBoost : 1.6;
731
+ const recentWindowDays = typeof anti?.recentWindowDays === "number" && anti.recentWindowDays > 0 ? anti.recentWindowDays : 30;
732
+ const lastHitTs = Date.parse(item.lastHitAt || "");
733
+ const ageDays = Number.isFinite(lastHitTs) ? Math.max(0, (Date.now() - lastHitTs) / (1000 * 60 * 60 * 24)) : recentWindowDays * 2;
734
+ const freshness = ageDays <= recentWindowDays ? (1 - ageDays / recentWindowDays) : 0;
735
+ const countFactor = Math.log1p(Math.max(0, item.count));
736
+ const boost = 1 + countFactor * hitWeight * (0.5 + 0.5 * freshness);
737
+ return Math.min(maxBoost, Math.max(1, boost));
738
+ }
739
+ function computeDecayFactor(id, eventType, timestamp, options, hitStats) {
740
+ const enabled = options?.enabled !== false;
741
+ if (!enabled || !timestamp) {
742
+ return computeAntiDecayBoost(id, hitStats, options);
743
+ }
744
+ const ageDays = Math.max(0, (Date.now() - timestamp) / (1000 * 60 * 60 * 24));
745
+ const halfLife = eventTypeHalfLifeDays(eventType, options);
746
+ const base = Math.pow(2, -ageDays / Math.max(1, halfLife));
747
+ const floor = typeof options?.minFloor === "number"
748
+ ? Math.max(0, Math.min(1, options.minFloor))
749
+ : 0.15;
750
+ const decay = Math.max(floor, base);
751
+ const boost = computeAntiDecayBoost(id, hitStats, options);
752
+ return Math.min(1, decay * boost);
753
+ }
754
+ function normalizeBaseUrl(value) {
755
+ if (!value)
756
+ return "";
757
+ return value.endsWith("/") ? value.slice(0, -1) : value;
758
+ }
759
+ const READ_FUSION_PROMPT_VERSION = "read-fusion.v1.2.0";
760
+ const READ_FUSION_REGRESSION_SAMPLES = [
761
+ "Example A: if archive and vector refer to the same source_memory_id, keep one main conclusion and keep the rest as supporting evidence.",
762
+ "Example B: if conclusions conflict, write conflicts and explain prioritization in canonical_answer (time, quality, explicitness).",
763
+ "Example C: if summary/excerpt is insufficient, return event ids in need_fulltext_event_ids for full-text lookup.",
764
+ ];
765
+ function cosineSimilarity(left, right) {
766
+ if (left.length === 0 || right.length === 0) {
767
+ return 0;
768
+ }
769
+ const size = Math.min(left.length, right.length);
770
+ let dot = 0;
771
+ let leftNorm = 0;
772
+ let rightNorm = 0;
773
+ for (let i = 0; i < size; i += 1) {
774
+ const a = left[i];
775
+ const b = right[i];
776
+ dot += a * b;
777
+ leftNorm += a * a;
778
+ rightNorm += b * b;
779
+ }
780
+ if (leftNorm === 0 || rightNorm === 0) {
781
+ return 0;
782
+ }
783
+ return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
784
+ }
785
+ async function requestEmbedding(args) {
786
+ const endpoint = args.baseUrl.endsWith("/embeddings") ? args.baseUrl : `${args.baseUrl}/embeddings`;
787
+ const body = {
788
+ input: args.text,
789
+ model: args.model,
790
+ };
791
+ if (typeof args.dimensions === "number" && Number.isFinite(args.dimensions) && args.dimensions > 0) {
792
+ body.dimensions = args.dimensions;
793
+ }
794
+ let lastError = null;
795
+ for (let attempt = 0; attempt < 3; attempt += 1) {
796
+ const response = await (0, http_post_1.postJsonWithTimeout)({
797
+ endpoint,
798
+ apiKey: args.apiKey,
799
+ body,
800
+ timeoutMs: 10000,
801
+ });
802
+ if (!response.ok) {
803
+ lastError = new Error(response.status > 0 ? `embedding_http_${response.status}` : (response.error || "embedding_network_error"));
804
+ continue;
805
+ }
806
+ try {
807
+ const json = (response.json || {});
808
+ const embedding = json?.data?.[0]?.embedding;
809
+ if (Array.isArray(embedding) && embedding.length > 0) {
810
+ return embedding.filter(item => Number.isFinite(item));
811
+ }
812
+ lastError = new Error("embedding_empty");
813
+ }
814
+ catch (error) {
815
+ lastError = error;
816
+ }
817
+ }
818
+ if (lastError) {
819
+ throw lastError;
820
+ }
821
+ return null;
822
+ }
823
+ async function requestRerank(args) {
824
+ const endpoint = args.baseUrl.endsWith("/rerank") ? args.baseUrl : `${args.baseUrl}/rerank`;
825
+ const documents = args.candidates.map(item => item.text);
826
+ const body = {
827
+ model: args.model,
828
+ query: args.query,
829
+ documents,
830
+ top_n: args.candidates.length,
831
+ };
832
+ let lastError = null;
833
+ for (let attempt = 0; attempt < 2; attempt += 1) {
834
+ const response = await (0, http_post_1.postJsonWithTimeout)({
835
+ endpoint,
836
+ apiKey: args.apiKey,
837
+ body,
838
+ timeoutMs: 12000,
839
+ });
840
+ if (!response.ok) {
841
+ lastError = new Error(response.status > 0 ? `rerank_http_${response.status}` : (response.error || "rerank_network_error"));
842
+ continue;
843
+ }
844
+ try {
845
+ const json = (response.json || {});
846
+ const list = Array.isArray(json.results) ? json.results : (Array.isArray(json.data) ? json.data : []);
847
+ if (!Array.isArray(list) || list.length === 0) {
848
+ lastError = new Error("rerank_empty");
849
+ continue;
850
+ }
851
+ const mapped = list
852
+ .map((item, rank) => {
853
+ const index = typeof item.index === "number" ? item.index : rank;
854
+ const hit = args.candidates[index];
855
+ if (!hit)
856
+ return null;
857
+ const score = typeof item.relevance_score === "number" ? item.relevance_score : (typeof item.score === "number" ? item.score : hit.score);
858
+ return { ...hit, score };
859
+ })
860
+ .filter((item) => Boolean(item));
861
+ if (mapped.length > 0) {
862
+ return mapped;
863
+ }
864
+ lastError = new Error("rerank_map_empty");
865
+ }
866
+ catch (error) {
867
+ lastError = error;
868
+ }
869
+ }
870
+ throw lastError instanceof Error ? lastError : new Error(String(lastError || "rerank_failed"));
871
+ }
872
+ function classifyIntent(query) {
873
+ const text = query.toLowerCase();
874
+ const relationHints = /(关系|依赖|关联|上下游|图谱|拓扑|graph|relation|entity|dependency)/i;
875
+ if (relationHints.test(text))
876
+ return "RELATION_DISCOVERY";
877
+ const troubleHints = /(报错|错误|异常|失败|超时|无法|崩溃|故障|修复|bug|error|failed|timeout|fix)/i;
878
+ if (troubleHints.test(text))
879
+ return "TROUBLESHOOTING";
880
+ const preferenceHints = /(偏好|习惯|口味|喜欢|不喜欢|偏向|preference)/i;
881
+ if (preferenceHints.test(text))
882
+ return "PREFERENCE_PROFILE";
883
+ const timelineHints = /(最近|上次|之前|时间线|历史|timeline|history)/i;
884
+ if (timelineHints.test(text))
885
+ return "TIMELINE_REVIEW";
886
+ const decisionHints = /(方案|决策|选择|建议|取舍|权衡|tradeoff|plan)/i;
887
+ if (decisionHints.test(text))
888
+ return "DECISION_SUPPORT";
889
+ return "FACT_LOOKUP";
890
+ }
891
+ function preferredEventTypes(intent) {
892
+ if (intent === "TROUBLESHOOTING")
893
+ return ["issue", "fix", "risk", "blocker", "dependency", "retrospective"];
894
+ if (intent === "PREFERENCE_PROFILE")
895
+ return ["preference", "decision", "constraint", "requirement"];
896
+ if (intent === "DECISION_SUPPORT")
897
+ return ["decision", "plan", "insight", "assumption", "constraint", "requirement"];
898
+ if (intent === "TIMELINE_REVIEW")
899
+ return ["action_item", "follow_up", "milestone", "plan", "decision", "issue", "fix"];
900
+ return [];
901
+ }
902
+ function sourceWeight(source, intent) {
903
+ if (source === "rules") {
904
+ return intent === "DECISION_SUPPORT" || intent === "TROUBLESHOOTING" ? 1.15 : 0.9;
905
+ }
906
+ if (source === "graph") {
907
+ return intent === "RELATION_DISCOVERY" ? 1.25 : 0.85;
908
+ }
909
+ if (source === "vector") {
910
+ return 1.05;
911
+ }
912
+ return 1;
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
+ }
1003
+ function mergeKeyFromDoc(doc) {
1004
+ const canonical = typeof doc.sourceMemoryCanonicalId === "string" ? doc.sourceMemoryCanonicalId.trim() : "";
1005
+ if (canonical) {
1006
+ return `canonical:${canonical}`;
1007
+ }
1008
+ const sourceMemoryId = typeof doc.sourceMemoryId === "string" ? doc.sourceMemoryId.trim() : "";
1009
+ if (sourceMemoryId) {
1010
+ return `source:${sourceMemoryId}`;
1011
+ }
1012
+ return `id:${doc.id}`;
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
+ }
1050
+ function customChannelWeight(source, options) {
1051
+ const weights = options?.channelWeights;
1052
+ if (!weights)
1053
+ return 1;
1054
+ const value = weights[source];
1055
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
1056
+ return 1;
1057
+ }
1058
+ return value;
1059
+ }
1060
+ function lengthNormalizeFactor(doc, options) {
1061
+ const lengthNorm = options?.lengthNorm;
1062
+ if (lengthNorm?.enabled === false) {
1063
+ return 1;
1064
+ }
1065
+ const pivotChars = typeof lengthNorm?.pivotChars === "number" && lengthNorm.pivotChars > 0
1066
+ ? lengthNorm.pivotChars
1067
+ : 1200;
1068
+ const strength = typeof lengthNorm?.strength === "number" && lengthNorm.strength > 0
1069
+ ? lengthNorm.strength
1070
+ : 0.75;
1071
+ const minFactor = typeof lengthNorm?.minFactor === "number" && lengthNorm.minFactor > 0 && lengthNorm.minFactor <= 1
1072
+ ? lengthNorm.minFactor
1073
+ : 0.45;
1074
+ const charCount = typeof doc.charCount === "number" && Number.isFinite(doc.charCount)
1075
+ ? doc.charCount
1076
+ : doc.text.length;
1077
+ if (charCount <= pivotChars) {
1078
+ return 1;
1079
+ }
1080
+ const over = (charCount - pivotChars) / pivotChars;
1081
+ const factor = 1 / (1 + over * strength);
1082
+ return Math.max(minFactor, Math.min(1, factor));
1083
+ }
1084
+ function channelQuota(source, topK, options) {
1085
+ const configured = options?.channelTopK?.[source];
1086
+ if (typeof configured === "number" && Number.isFinite(configured) && configured >= 1) {
1087
+ return Math.floor(configured);
1088
+ }
1089
+ if (source === "rules")
1090
+ return Math.max(6, topK * 2);
1091
+ if (source === "graph")
1092
+ return Math.max(8, topK * 3);
1093
+ return Math.max(12, topK * 4);
1094
+ }
1095
+ function parseJsonStringArray(value) {
1096
+ if (typeof value !== "string" || !value.trim()) {
1097
+ return [];
1098
+ }
1099
+ try {
1100
+ const parsed = JSON.parse(value);
1101
+ if (!Array.isArray(parsed)) {
1102
+ return [];
1103
+ }
1104
+ return parsed
1105
+ .map(item => (typeof item === "string" ? item.trim() : ""))
1106
+ .filter(Boolean);
1107
+ }
1108
+ catch {
1109
+ return [];
1110
+ }
1111
+ }
1112
+ function parseJsonRelations(value) {
1113
+ if (typeof value !== "string" || !value.trim()) {
1114
+ return [];
1115
+ }
1116
+ try {
1117
+ const parsed = JSON.parse(value);
1118
+ if (!Array.isArray(parsed)) {
1119
+ return [];
1120
+ }
1121
+ return parsed
1122
+ .map(item => {
1123
+ if (typeof item !== "object" || item === null)
1124
+ return null;
1125
+ const relation = item;
1126
+ const source = typeof relation.source === "string" ? relation.source.trim() : "";
1127
+ const target = typeof relation.target === "string" ? relation.target.trim() : "";
1128
+ const type = typeof relation.type === "string" && relation.type.trim() ? relation.type.trim() : "related_to";
1129
+ if (!source || !target)
1130
+ return null;
1131
+ return { source, target, type };
1132
+ })
1133
+ .filter((item) => Boolean(item));
1134
+ }
1135
+ catch {
1136
+ return [];
1137
+ }
1138
+ }
1139
+ async function searchLanceDb(args) {
1140
+ try {
1141
+ const require = (0, module_1.createRequire)(__filename);
1142
+ const lancedbDir = path.join(args.memoryRoot, "vector", "lancedb");
1143
+ if (!fs.existsSync(lancedbDir)) {
1144
+ return [];
1145
+ }
1146
+ const moduleValue = require("@lancedb/lancedb");
1147
+ const connect = moduleValue.connect;
1148
+ if (typeof connect !== "function") {
1149
+ return [];
1150
+ }
1151
+ const db = await connect(lancedbDir);
1152
+ if (!db || typeof db.openTable !== "function") {
1153
+ return [];
1154
+ }
1155
+ const table = await db.openTable("events");
1156
+ if (!table || typeof table.search !== "function") {
1157
+ return [];
1158
+ }
1159
+ const searchObj = table.search(args.queryEmbedding);
1160
+ if (!searchObj || typeof searchObj.limit !== "function") {
1161
+ return [];
1162
+ }
1163
+ const limited = searchObj.limit(args.limit);
1164
+ if (!limited || typeof limited.toArray !== "function") {
1165
+ return [];
1166
+ }
1167
+ const rows = await limited.toArray();
1168
+ const docs = [];
1169
+ for (const row of rows) {
1170
+ if (typeof row !== "object" || row === null)
1171
+ continue;
1172
+ const record = row;
1173
+ const id = typeof record.id === "string" ? record.id : "";
1174
+ const summary = typeof record.summary === "string" ? record.summary : "";
1175
+ if (!id || !summary)
1176
+ continue;
1177
+ const ts = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
1178
+ const entities = parseJsonStringArray(record.entities_json);
1179
+ const relations = parseJsonRelations(record.relations_json);
1180
+ docs.push({
1181
+ id,
1182
+ text: summary,
1183
+ source: "vector_lancedb",
1184
+ timestamp: Number.isFinite(ts) ? ts : undefined,
1185
+ layer: record.layer === "active" || record.layer === "archive" ? record.layer : undefined,
1186
+ sourceMemoryId: typeof record.source_memory_id === "string" ? record.source_memory_id : undefined,
1187
+ sourceMemoryCanonicalId: typeof record.source_memory_canonical_id === "string" ? record.source_memory_canonical_id : undefined,
1188
+ sourceEventId: typeof record.source_event_id === "string" ? record.source_event_id : undefined,
1189
+ sourceField: record.source_field === "summary" || record.source_field === "evidence"
1190
+ ? record.source_field
1191
+ : undefined,
1192
+ embedding: Array.isArray(record.vector) ? record.vector.filter(item => Number.isFinite(item)) : undefined,
1193
+ eventType: typeof record.event_type === "string" ? record.event_type : undefined,
1194
+ qualityScore: typeof record.quality_score === "number" ? record.quality_score : undefined,
1195
+ charCount: typeof record.char_count === "number" ? record.char_count : undefined,
1196
+ tokenCount: typeof record.token_count === "number" ? record.token_count : undefined,
1197
+ sessionId: typeof record.session_id === "string" ? record.session_id : undefined,
1198
+ entities,
1199
+ relations: Array.isArray(relations) ? relations : [],
1200
+ });
1201
+ }
1202
+ return docs;
1203
+ }
1204
+ catch (error) {
1205
+ args.logger.debug(`LanceDB search fallback: ${error}`);
1206
+ return [];
1207
+ }
1208
+ }
1209
+ function parseVectorFallback(filePath, logger) {
1210
+ const content = safeReadFile(filePath);
1211
+ if (!content) {
1212
+ return [];
1213
+ }
1214
+ const docs = [];
1215
+ for (const line of content.split(/\r?\n/)) {
1216
+ const trimmed = line.trim();
1217
+ if (!trimmed)
1218
+ continue;
1219
+ try {
1220
+ const parsed = JSON.parse(trimmed);
1221
+ const id = typeof parsed.id === "string" ? parsed.id : "";
1222
+ const summary = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
1223
+ if (!id || !summary)
1224
+ continue;
1225
+ const ts = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
1226
+ const entities = Array.isArray(parsed.entities)
1227
+ ? parsed.entities.map(item => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
1228
+ : [];
1229
+ const relations = Array.isArray(parsed.relations)
1230
+ ? parsed.relations
1231
+ .map(item => {
1232
+ if (typeof item !== "object" || item === null)
1233
+ return null;
1234
+ const relation = item;
1235
+ const source = typeof relation.source === "string" ? relation.source.trim() : "";
1236
+ const target = typeof relation.target === "string" ? relation.target.trim() : "";
1237
+ const type = typeof relation.type === "string" ? relation.type.trim() : "related_to";
1238
+ if (!source || !target)
1239
+ return null;
1240
+ return { source, target, type };
1241
+ })
1242
+ .filter((item) => Boolean(item))
1243
+ : [];
1244
+ docs.push({
1245
+ id,
1246
+ text: summary,
1247
+ source: "vector_jsonl",
1248
+ timestamp: Number.isFinite(ts) ? ts : undefined,
1249
+ layer: parsed.layer === "active" || parsed.layer === "archive" ? parsed.layer : undefined,
1250
+ sourceMemoryId: typeof parsed.source_memory_id === "string" ? parsed.source_memory_id : undefined,
1251
+ sourceMemoryCanonicalId: typeof parsed.source_memory_canonical_id === "string" ? parsed.source_memory_canonical_id : undefined,
1252
+ sourceEventId: typeof parsed.source_event_id === "string" ? parsed.source_event_id : undefined,
1253
+ sourceField: parsed.source_field === "summary" || parsed.source_field === "evidence"
1254
+ ? parsed.source_field
1255
+ : undefined,
1256
+ embedding: Array.isArray(parsed.embedding) ? parsed.embedding.filter(item => Number.isFinite(item)) : undefined,
1257
+ eventType: typeof parsed.event_type === "string" ? parsed.event_type.trim() : undefined,
1258
+ qualityScore: typeof parsed.quality_score === "number" ? parsed.quality_score : undefined,
1259
+ charCount: typeof parsed.char_count === "number" ? parsed.char_count : undefined,
1260
+ tokenCount: typeof parsed.token_count === "number" ? parsed.token_count : undefined,
1261
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : undefined,
1262
+ entities,
1263
+ relations,
1264
+ });
1265
+ }
1266
+ catch (error) {
1267
+ logger.debug(`Skip invalid vector jsonl line: ${error}`);
1268
+ }
1269
+ }
1270
+ return docs;
1271
+ }
1272
+ async function requestFusion(args) {
1273
+ const candidateIdSet = new Set(args.candidates.map(item => item.id));
1274
+ const endpoint = args.llm.baseUrl.endsWith("/chat/completions")
1275
+ ? args.llm.baseUrl
1276
+ : `${args.llm.baseUrl}/chat/completions`;
1277
+ const evidenceText = args.candidates
1278
+ .map((item, index) => {
1279
+ const excerpt = (item.source_excerpt || "").trim();
1280
+ const sourceFile = (item.source_file || "").trim();
1281
+ const sourceMemoryId = (item.source_memory_id || "").trim();
1282
+ const sourceMemoryCanonicalId = (item.source_memory_canonical_id || "").trim();
1283
+ const sourceLayer = (item.source_layer || "").trim();
1284
+ const sourceEventId = (item.source_event_id || "").trim();
1285
+ const sourceField = (item.source_field || "").trim();
1286
+ const extraParts = [];
1287
+ if (sourceMemoryId)
1288
+ extraParts.push(`source_memory_id=${sourceMemoryId}`);
1289
+ if (sourceMemoryCanonicalId)
1290
+ extraParts.push(`source_memory_canonical_id=${sourceMemoryCanonicalId}`);
1291
+ if (sourceLayer)
1292
+ extraParts.push(`source_layer=${sourceLayer}`);
1293
+ if (sourceEventId)
1294
+ extraParts.push(`source_event_id=${sourceEventId}`);
1295
+ if (sourceField)
1296
+ extraParts.push(`source_field=${sourceField}`);
1297
+ if (sourceFile)
1298
+ extraParts.push(`source_file=${sourceFile}`);
1299
+ if (excerpt)
1300
+ extraParts.push(`source_excerpt=${excerpt}`);
1301
+ const extra = extraParts.length > 0 ? `\n ${extraParts.join("\n ")}` : "";
1302
+ return `${index + 1}. [${item.id}] (${item.source}, score=${item.score.toFixed(4)}) ${item.text}${extra}`;
1303
+ })
1304
+ .join("\n")
1305
+ .slice(0, 18000);
1306
+ const prompt = [
1307
+ `prompt_version=${READ_FUSION_PROMPT_VERSION}`,
1308
+ "You are a memory retrieval fusion engine. Fuse multi-channel evidence into a structured answer package for the agent.",
1309
+ "Core values and principles:",
1310
+ "A) Truthfulness first: do not fabricate; do not infer beyond evidence.",
1311
+ "B) Evidence first: every key conclusion must be traceable via evidence_ids.",
1312
+ "C) Make conflicts explicit: write conflicts instead of silently overriding.",
1313
+ "D) Be transparent about uncertainty: put uncertain parts in coverage_note.",
1314
+ "E) Summary-first: prefer summary evidence for conclusions; source_excerpt is supporting evidence.",
1315
+ "F) Same-source dedup: merge duplicate evidence from the same source_memory_id/source_memory_canonical_id.",
1316
+ "G) Full-text recall: if summary/excerpt is insufficient, return event ids in need_fulltext_event_ids.",
1317
+ "Source channel semantics:",
1318
+ "- rules: policy/constraints; use for what should be done.",
1319
+ "- archive: event-level stable facts (summary-first).",
1320
+ "- vector: semantic neighbors for recall; source_field=summary/evidence indicates chunk role.",
1321
+ "- graph: entity-relation structure; prefer for dependency/relationship questions.",
1322
+ "Query alignment:",
1323
+ "- answer the user query first; ignore evidence unrelated to the query.",
1324
+ "- when evidence conflicts, prioritize recency + quality + explicitness and record the conflict.",
1325
+ "Return strict JSON only:",
1326
+ "{\"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[], \"need_fulltext_event_ids\": string[], \"confidence\": number}",
1327
+ "Output constraints:",
1328
+ "1) canonical_answer must be directly usable.",
1329
+ "2) facts: usually 3-12 items; prefer high-quality evidence.",
1330
+ "3) evidence_ids must come from input candidate ids.",
1331
+ "4) conflicts must be [] when no conflict exists.",
1332
+ "5) confidence must be within [0, 1].",
1333
+ "6) uncertain parts must be explicitly stated in coverage_note.",
1334
+ ...READ_FUSION_REGRESSION_SAMPLES,
1335
+ ].join("\n");
1336
+ const body = {
1337
+ model: args.llm.model,
1338
+ temperature: 0.1,
1339
+ messages: [
1340
+ { role: "system", content: "Output JSON only. No extra text." },
1341
+ { role: "user", content: `${prompt}\n\nQuery:\n${args.query}\n\nCandidate Evidence:\n${evidenceText}` },
1342
+ ],
1343
+ };
1344
+ let lastError = null;
1345
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1346
+ const response = await (0, http_post_1.postJsonWithTimeout)({
1347
+ endpoint,
1348
+ apiKey: args.llm.apiKey,
1349
+ body,
1350
+ timeoutMs: 20000,
1351
+ });
1352
+ if (!response.ok) {
1353
+ lastError = new Error(response.status > 0 ? `fusion_http_${response.status}` : (response.error || "fusion_network_error"));
1354
+ continue;
1355
+ }
1356
+ try {
1357
+ const json = (response.json || {});
1358
+ const content = json?.choices?.[0]?.message?.content?.trim() || "";
1359
+ if (!content) {
1360
+ lastError = new Error("fusion_empty");
1361
+ continue;
1362
+ }
1363
+ const parsed = JSON.parse(content);
1364
+ if (!parsed || typeof parsed.canonical_answer !== "string" || !parsed.canonical_answer.trim()) {
1365
+ lastError = new Error("fusion_invalid");
1366
+ continue;
1367
+ }
1368
+ const evidenceIds = Array.isArray(parsed.evidence_ids)
1369
+ ? parsed.evidence_ids.filter(item => typeof item === "string" && item.trim())
1370
+ : [];
1371
+ const whitelistedEvidenceIds = [...new Set(evidenceIds.filter(id => candidateIdSet.has(id)))];
1372
+ const needFulltextEventIds = Array.isArray(parsed.need_fulltext_event_ids)
1373
+ ? [...new Set(parsed.need_fulltext_event_ids
1374
+ .filter(item => typeof item === "string")
1375
+ .map(item => item.trim())
1376
+ .filter(Boolean))]
1377
+ : [];
1378
+ return {
1379
+ canonical_answer: parsed.canonical_answer.trim().slice(0, 6000),
1380
+ coverage_note: typeof parsed.coverage_note === "string" ? parsed.coverage_note.trim().slice(0, 1200) : "",
1381
+ facts: Array.isArray(parsed.facts) ? parsed.facts : [],
1382
+ timeline: Array.isArray(parsed.timeline) ? parsed.timeline : [],
1383
+ entities: Array.isArray(parsed.entities) ? parsed.entities : [],
1384
+ decisions: Array.isArray(parsed.decisions) ? parsed.decisions : [],
1385
+ fixes: Array.isArray(parsed.fixes) ? parsed.fixes : [],
1386
+ preferences: Array.isArray(parsed.preferences) ? parsed.preferences : [],
1387
+ risks: Array.isArray(parsed.risks) ? parsed.risks : [],
1388
+ action_items: Array.isArray(parsed.action_items) ? parsed.action_items : [],
1389
+ conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
1390
+ evidence_ids: whitelistedEvidenceIds,
1391
+ need_fulltext_event_ids: needFulltextEventIds,
1392
+ confidence: typeof parsed.confidence === "number"
1393
+ ? Math.max(0, Math.min(1, parsed.confidence))
1394
+ : 0.5,
1395
+ };
1396
+ }
1397
+ catch (error) {
1398
+ lastError = error;
1399
+ }
1400
+ }
1401
+ throw lastError instanceof Error ? lastError : new Error(String(lastError || "fusion_failed"));
1402
+ }
163
1403
  function createReadStore(options) {
164
1404
  const memoryRoot = options.dbPath ? path.resolve(options.dbPath) : path.join(options.projectRoot, "data", "memory");
1405
+ const vectorFallbackPath = path.join(memoryRoot, "vector", "lancedb_events.jsonl");
1406
+ const hitStatsPath = path.join(memoryRoot, ".read_hit_stats.json");
1407
+ let docsCache = null;
1408
+ let vectorFallbackCache = null;
1409
+ let bm25TokenCacheSignature = "";
1410
+ let bm25TokenCache = new Map();
1411
+ let hitStatsCache = null;
1412
+ let hitStatsDirty = false;
1413
+ let hitStatsPendingMutations = 0;
1414
+ let lastHitStatsFlushAt = 0;
1415
+ const hitStatsFlushIntervalMs = 5000;
1416
+ const hitStatsFlushBatch = 24;
1417
+ const readTuning = resolveReadTuning(options.readTuning);
1418
+ function fileSignature(filePath) {
1419
+ try {
1420
+ if (!fs.existsSync(filePath)) {
1421
+ return `${filePath}:missing`;
1422
+ }
1423
+ const stat = fs.statSync(filePath);
1424
+ return `${filePath}:${stat.size}:${Math.floor(stat.mtimeMs)}`;
1425
+ }
1426
+ catch {
1427
+ return `${filePath}:error`;
1428
+ }
1429
+ }
1430
+ function loadHitStats() {
1431
+ if (hitStatsCache) {
1432
+ return hitStatsCache;
1433
+ }
1434
+ try {
1435
+ if (!fs.existsSync(hitStatsPath)) {
1436
+ hitStatsCache = { items: {} };
1437
+ return hitStatsCache;
1438
+ }
1439
+ const content = fs.readFileSync(hitStatsPath, "utf-8").trim();
1440
+ if (!content) {
1441
+ hitStatsCache = { items: {} };
1442
+ return hitStatsCache;
1443
+ }
1444
+ const parsed = JSON.parse(content);
1445
+ if (!parsed || typeof parsed !== "object" || !parsed.items || typeof parsed.items !== "object") {
1446
+ hitStatsCache = { items: {} };
1447
+ return hitStatsCache;
1448
+ }
1449
+ hitStatsCache = parsed;
1450
+ return hitStatsCache;
1451
+ }
1452
+ catch {
1453
+ hitStatsCache = { items: {} };
1454
+ return hitStatsCache;
1455
+ }
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
+ }
1475
+ function saveHitStats(state) {
1476
+ try {
1477
+ const dir = path.dirname(hitStatsPath);
1478
+ if (!fs.existsSync(dir)) {
1479
+ fs.mkdirSync(dir, { recursive: true });
1480
+ }
1481
+ fs.writeFileSync(hitStatsPath, JSON.stringify(state, null, 2), "utf-8");
1482
+ }
1483
+ catch (error) {
1484
+ options.logger.warn(`Failed to persist read hit stats: ${error}`);
1485
+ }
1486
+ }
1487
+ function maybeFlushHitStats(force) {
1488
+ if (!hitStatsDirty || !hitStatsCache) {
1489
+ return;
1490
+ }
1491
+ const now = Date.now();
1492
+ if (!force &&
1493
+ hitStatsPendingMutations < hitStatsFlushBatch &&
1494
+ (now - lastHitStatsFlushAt) < hitStatsFlushIntervalMs) {
1495
+ return;
1496
+ }
1497
+ saveHitStats(hitStatsCache);
1498
+ hitStatsDirty = false;
1499
+ hitStatsPendingMutations = 0;
1500
+ lastHitStatsFlushAt = now;
1501
+ }
1502
+ function markHit(ids) {
1503
+ if (!ids.length) {
1504
+ return;
1505
+ }
1506
+ const state = loadHitStats();
1507
+ const now = new Date().toISOString();
1508
+ for (const id of ids) {
1509
+ const key = (id || "").trim();
1510
+ if (!key)
1511
+ continue;
1512
+ const prev = state.items[key];
1513
+ state.items[key] = {
1514
+ count: (prev?.count || 0) + 1,
1515
+ lastHitAt: now,
1516
+ };
1517
+ }
1518
+ const entries = Object.entries(state.items)
1519
+ .sort((a, b) => {
1520
+ const ta = Date.parse(a[1].lastHitAt || "");
1521
+ const tb = Date.parse(b[1].lastHitAt || "");
1522
+ return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0);
1523
+ })
1524
+ .slice(0, 20000);
1525
+ state.items = Object.fromEntries(entries);
1526
+ hitStatsCache = state;
1527
+ hitStatsDirty = true;
1528
+ hitStatsPendingMutations += ids.length;
1529
+ maybeFlushHitStats(false);
1530
+ }
165
1531
  function loadAllDocuments() {
166
1532
  const cortexRulesPath = path.join(memoryRoot, "CORTEX_RULES.md");
167
- const memoryMdPath = path.join(memoryRoot, "MEMORY.md");
168
1533
  const activeSessionsPath = path.join(memoryRoot, "sessions", "active", "sessions.jsonl");
169
1534
  const archiveSessionsPath = path.join(memoryRoot, "sessions", "archive", "sessions.jsonl");
170
- return [
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");
1542
+ const signature = [
1543
+ fileSignature(cortexRulesPath),
1544
+ fileSignature(activeSessionsPath),
1545
+ fileSignature(archiveSessionsPath),
1546
+ fileSignature(graphMemoryPath),
1547
+ fileSignature(supersededRelationPath),
1548
+ fileSignature(conflictQueuePath),
1549
+ directorySignature(wikiEntitiesDir, ".md"),
1550
+ directorySignature(wikiTopicsDir, ".md"),
1551
+ directorySignature(wikiTimelinesDir, ".md"),
1552
+ fileSignature(wikiProjectionIndexPath),
1553
+ ].join("|");
1554
+ if (docsCache && docsCache.signature === signature) {
1555
+ return docsCache.docs;
1556
+ }
1557
+ const archiveEventTypeById = new Map();
1558
+ if (fs.existsSync(archiveSessionsPath)) {
1559
+ const archiveContent = safeReadFile(archiveSessionsPath);
1560
+ for (const line of archiveContent.split(/\r?\n/)) {
1561
+ const trimmed = line.trim();
1562
+ if (!trimmed)
1563
+ continue;
1564
+ try {
1565
+ const parsed = JSON.parse(trimmed);
1566
+ const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
1567
+ const eventType = typeof parsed.event_type === "string" ? parsed.event_type.trim() : "";
1568
+ if (id && eventType) {
1569
+ archiveEventTypeById.set(id, eventType);
1570
+ }
1571
+ }
1572
+ catch { }
1573
+ }
1574
+ }
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
+ }
1595
+ if (fs.existsSync(graphMemoryPath)) {
1596
+ const graphContent = safeReadFile(graphMemoryPath);
1597
+ for (const line of graphContent.split(/\r?\n/)) {
1598
+ const trimmed = line.trim();
1599
+ if (!trimmed)
1600
+ continue;
1601
+ try {
1602
+ const parsed = JSON.parse(trimmed);
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;
1608
+ const sourceEventId = typeof parsed.source_event_id === "string" ? parsed.source_event_id : "";
1609
+ const archiveEventId = typeof parsed.archive_event_id === "string" ? parsed.archive_event_id : "";
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 : "");
1619
+ const timestamp = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
1620
+ const entities = Array.isArray(parsed.entities)
1621
+ ? parsed.entities.map((item) => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
1622
+ : [];
1623
+ const entityTypes = typeof parsed.entity_types === "object" && parsed.entity_types !== null
1624
+ ? parsed.entity_types
1625
+ : {};
1626
+ const relations = Array.isArray(parsed.relations) ? parsed.relations : [];
1627
+ const eventType = (typeof parsed.event_type === "string" ? parsed.event_type : "") || archiveEventTypeById.get(eventRefId) || "";
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");
1710
+ graphDocs.push({
1711
+ id: `${id || "graph"}:rel:${relationCount}`,
1712
+ text,
1713
+ source: "sessions_graph",
1714
+ timestamp: Number.isFinite(timestamp) ? timestamp : undefined,
1715
+ layer: sourceLayer === "active_only" ? "active" : "archive",
1716
+ summaryText: summary || undefined,
1717
+ sourceText: contextChunk || undefined,
1718
+ sourceMemoryId: eventRefId || id,
1719
+ sourceEventId: navSourceEventId || sourceEventId || archiveEventId || undefined,
1720
+ sourceFile: sourceFile || undefined,
1721
+ sessionId,
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
+ }),
1734
+ });
1735
+ }
1736
+ }
1737
+ catch (error) {
1738
+ options.logger.debug(`Skipping invalid graph memory line: ${error}`);
1739
+ }
1740
+ }
1741
+ }
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);
1881
+ const docs = [
171
1882
  ...parseMarkdownFile(cortexRulesPath, "CORTEX_RULES.md"),
172
- ...parseMarkdownFile(memoryMdPath, "MEMORY.md"),
173
1883
  ...parseJsonlFile(activeSessionsPath, "sessions_active", options.logger),
174
1884
  ...parseJsonlFile(archiveSessionsPath, "sessions_archive", options.logger),
1885
+ ...graphDocs,
1886
+ ...entitySummaryDocs,
1887
+ ...wikiProjectionDocs,
175
1888
  ];
1889
+ docsCache = { signature, docs };
1890
+ return docs;
1891
+ }
1892
+ function loadVectorFallbackCached() {
1893
+ const signature = fileSignature(vectorFallbackPath);
1894
+ if (vectorFallbackCache && vectorFallbackCache.signature === signature) {
1895
+ return vectorFallbackCache.docs;
1896
+ }
1897
+ const docs = parseVectorFallback(vectorFallbackPath, options.logger);
1898
+ vectorFallbackCache = { signature, docs };
1899
+ return docs;
1900
+ }
1901
+ function getBm25Tokens(doc, signature, channel) {
1902
+ if (bm25TokenCacheSignature !== signature) {
1903
+ bm25TokenCacheSignature = signature;
1904
+ bm25TokenCache = new Map();
1905
+ }
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)}`;
1910
+ const cached = bm25TokenCache.get(key);
1911
+ if (cached) {
1912
+ return cached;
1913
+ }
1914
+ const tokens = tokenize(channelText);
1915
+ bm25TokenCache.set(key, tokens);
1916
+ return tokens;
176
1917
  }
177
1918
  async function searchMemory(args) {
178
1919
  const query = args.query?.trim();
179
1920
  if (!query) {
180
- return { results: [] };
1921
+ return {
1922
+ results: [],
1923
+ semantic_results: [],
1924
+ keyword_results: [],
1925
+ strategy: "vector_sentence_and_keyword_parallel",
1926
+ };
181
1927
  }
1928
+ const mode = args.mode === "lightweight" ? "lightweight" : "default";
1929
+ const lightweightMode = mode === "lightweight";
182
1930
  const docs = loadAllDocuments();
183
- const ranked = docs
1931
+ const hitStats = loadHitStats();
1932
+ const intent = classifyIntent(query);
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));
1939
+ let queryEmbedding = null;
1940
+ const embeddingModel = options.embedding?.model || "";
1941
+ const embeddingApiKey = options.embedding?.apiKey || "";
1942
+ const embeddingBaseUrl = normalizeBaseUrl(options.embedding?.baseURL || options.embedding?.baseUrl);
1943
+ if (!lightweightMode && embeddingModel && embeddingApiKey && embeddingBaseUrl) {
1944
+ try {
1945
+ queryEmbedding = await requestEmbedding({
1946
+ text: query,
1947
+ model: embeddingModel,
1948
+ apiKey: embeddingApiKey,
1949
+ baseUrl: embeddingBaseUrl,
1950
+ dimensions: options.embedding?.dimensions,
1951
+ });
1952
+ }
1953
+ catch (error) {
1954
+ options.logger.warn(`Embedding query failed, fallback to lexical search: ${error}`);
1955
+ }
1956
+ }
1957
+ const vectorDocsFromLance = queryEmbedding && queryEmbedding.length > 0
1958
+ ? await searchLanceDb({ memoryRoot, queryEmbedding, limit: Math.max(20, args.topK * 8), logger: options.logger })
1959
+ : [];
1960
+ const vectorDocsFallback = vectorDocsFromLance.length > 0
1961
+ ? []
1962
+ : loadVectorFallbackCached();
1963
+ const vectorDocs = [...vectorDocsFromLance, ...vectorDocsFallback];
1964
+ const archiveSourceById = new Map();
1965
+ for (const doc of docs) {
1966
+ if (doc.source !== "sessions_archive")
1967
+ continue;
1968
+ const key = (doc.sourceMemoryId || doc.id || "").trim();
1969
+ if (!key)
1970
+ continue;
1971
+ archiveSourceById.set(key, {
1972
+ sourceText: doc.sourceText,
1973
+ summaryText: doc.summaryText || doc.text,
1974
+ sourceFile: doc.sourceFile,
1975
+ });
1976
+ }
1977
+ for (const doc of vectorDocs) {
1978
+ const key = (doc.sourceMemoryId || "").trim();
1979
+ if (!key)
1980
+ continue;
1981
+ const linked = archiveSourceById.get(key);
1982
+ if (!linked)
1983
+ continue;
1984
+ if (!doc.sourceText && linked.sourceText) {
1985
+ doc.sourceText = linked.sourceText;
1986
+ }
1987
+ if (!doc.summaryText && linked.summaryText) {
1988
+ doc.summaryText = linked.summaryText;
1989
+ }
1990
+ if (!doc.sourceFile && linked.sourceFile) {
1991
+ doc.sourceFile = linked.sourceFile;
1992
+ }
1993
+ }
1994
+ const graphDocs = docs
1995
+ .filter(doc => doc.source.startsWith("sessions_graph"))
184
1996
  .map(doc => {
185
- const base = scoreText(query, doc.text);
186
- const total = withRecencyBoost(base, doc.timestamp);
187
- return { doc, score: total };
1997
+ const graphText = [
1998
+ doc.text,
1999
+ ...(doc.relations || []).map(relation => `${relation.source} ${relation.type} ${relation.target} ${relation.fact_status || doc.factStatus || "active"}`),
2000
+ ].join(" | ");
2001
+ return {
2002
+ ...doc,
2003
+ text: graphText,
2004
+ };
2005
+ });
2006
+ const rulesDocs = docs.filter(doc => doc.source === "CORTEX_RULES.md");
2007
+ const archiveDocs = docs.filter(doc => doc.source === "sessions_active" || doc.source === "sessions_archive");
2008
+ const bm25Corpus = [...rulesDocs, ...archiveDocs, ...vectorDocs, ...graphDocs];
2009
+ const bm25Signature = `${docsCache?.signature || "na"}|vector:${vectorDocs.length}:${vectorDocs.slice(0, 40).map(item => `${item.id}:${item.text.length}`).join(",")}`;
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);
2183
+ };
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
+ });
2221
+ }
2222
+ return {
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,
2235
+ };
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;
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
+ });
2273
+ }
2274
+ }
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
+ };
188
2294
  })
189
- .filter(item => item.score > 0)
190
- .sort((a, b) => b.score - a.score)
191
- .slice(0, Math.max(1, args.topK))
192
- .map(item => ({
193
- id: item.doc.id,
194
- text: item.doc.text,
195
- source: item.doc.source,
196
- score: Number(item.score.toFixed(4)),
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));
2304
+ const rerankerModel = options.reranker?.model || "";
2305
+ const rerankerApiKey = options.reranker?.apiKey || "";
2306
+ const rerankerBaseUrl = normalizeBaseUrl(options.reranker?.baseURL || options.reranker?.baseUrl);
2307
+ const fusionEnabled = !lightweightMode && options.fusion?.enabled !== false;
2308
+ const llmModel = options.llm?.model || "";
2309
+ const llmApiKey = options.llm?.apiKey || "";
2310
+ const llmBaseUrl = normalizeBaseUrl(options.llm?.baseURL || options.llm?.baseUrl);
2311
+ const fusionAuthoritative = options.fusion?.authoritative !== false;
2312
+ const skipRerankerForFusion = fusionEnabled && fusionAuthoritative && llmModel && llmApiKey && llmBaseUrl;
2313
+ let rerankedSimple = lexicalRanked.map(item => ({
2314
+ id: item.id,
2315
+ merge_key: item.merge_key,
2316
+ text: item.text,
2317
+ source: item.source,
2318
+ score: item.score,
197
2319
  }));
198
- return { results: ranked };
2320
+ if (!lightweightMode && rerankerModel && rerankerApiKey && rerankerBaseUrl && lexicalRanked.length > 1 && !skipRerankerForFusion) {
2321
+ try {
2322
+ rerankedSimple = await requestRerank({
2323
+ query,
2324
+ candidates: lexicalRanked.map(item => ({ id: item.id, text: item.text, source: item.source, score: item.score })),
2325
+ model: rerankerModel,
2326
+ apiKey: rerankerApiKey,
2327
+ baseUrl: rerankerBaseUrl,
2328
+ });
2329
+ rerankedSimple = rerankedSimple.map(item => {
2330
+ const found = lexicalRanked.find(entry => entry.id === item.id);
2331
+ return { ...item, merge_key: found?.merge_key || item.id };
2332
+ });
2333
+ }
2334
+ catch (error) {
2335
+ options.logger.warn(`Reranker failed, keep hybrid ranking: ${error}`);
2336
+ }
2337
+ }
2338
+ const ranked = rerankedSimple.slice(0, Math.max(1, args.topK)).map(item => {
2339
+ const hit = lexicalRanked.find(entry => entry.id === item.id);
2340
+ return {
2341
+ id: item.id,
2342
+ merge_key: hit?.merge_key || item.merge_key || item.id,
2343
+ source_memory_id: hit?.source_memory_id || "",
2344
+ source_memory_canonical_id: hit?.source_memory_canonical_id || "",
2345
+ source_event_id: hit?.source_event_id || "",
2346
+ source_field: hit?.source_field || "",
2347
+ fulltext_event_id: (hit?.source_event_id || hit?.source_memory_id || item.id || ""),
2348
+ text: item.text,
2349
+ source_text: hit?.source_text || "",
2350
+ source_excerpt: hit?.source_excerpt || "",
2351
+ source_file: hit?.source_file || "",
2352
+ source: item.source,
2353
+ layer: hit?.layer || "",
2354
+ event_type: hit?.event_type || "",
2355
+ fact_status: hit?.fact_status || "active",
2356
+ wiki_ref: hit?.wiki_ref || "",
2357
+ quality_score: hit?.quality_score ?? 0,
2358
+ timestamp: hit?.timestamp || "",
2359
+ evidence_ids: Array.isArray(hit?.evidence_ids) ? hit?.evidence_ids : [],
2360
+ score: Number(item.score.toFixed(4)),
2361
+ score_breakdown: hit?.score_breakdown || {},
2362
+ reason_tags: Array.isArray(hit?.reason_tags) ? hit?.reason_tags : [],
2363
+ explain: {
2364
+ merge_key: hit?.merge_key || item.merge_key || item.id,
2365
+ source_memory_id: hit?.source_memory_id || "",
2366
+ source_memory_canonical_id: hit?.source_memory_canonical_id || "",
2367
+ source_event_id: hit?.source_event_id || "",
2368
+ source_field: hit?.source_field || "",
2369
+ channel: item.source,
2370
+ source_file: hit?.source_file || "",
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 : [],
2375
+ score_breakdown: hit?.score_breakdown || {},
2376
+ reason_tags: Array.isArray(hit?.reason_tags) ? hit?.reason_tags : [],
2377
+ },
2378
+ };
2379
+ });
2380
+ const minLexicalHits = Math.max(0, Math.floor(options.fusion?.minLexicalHits ?? 1));
2381
+ const minSemanticHits = Math.max(0, Math.floor(options.fusion?.minSemanticHits ?? 1));
2382
+ const fallbackPool = lexicalRanked.filter(item => !ranked.some(existing => existing.id === item.id));
2383
+ const lexicalCount = ranked.filter(item => item.reason_tags.includes("lexical_hit")).length;
2384
+ const semanticCount = ranked.filter(item => item.reason_tags.includes("vector_hit")).length;
2385
+ if (semanticCount < minSemanticHits) {
2386
+ const needed = minSemanticHits - semanticCount;
2387
+ const supplement = fallbackPool.filter(item => item.reason_tags.includes("vector_hit")).slice(0, needed);
2388
+ for (const item of supplement) {
2389
+ ranked.push({
2390
+ id: item.id,
2391
+ merge_key: item.merge_key,
2392
+ source_memory_id: item.source_memory_id,
2393
+ source_memory_canonical_id: item.source_memory_canonical_id,
2394
+ source_event_id: item.source_event_id || "",
2395
+ source_field: item.source_field || "",
2396
+ fulltext_event_id: item.source_event_id || item.source_memory_id || item.id,
2397
+ text: item.text,
2398
+ source_text: item.source_text || "",
2399
+ source_excerpt: item.source_excerpt || "",
2400
+ source_file: item.source_file || "",
2401
+ source: item.source,
2402
+ layer: item.layer,
2403
+ event_type: item.event_type,
2404
+ fact_status: item.fact_status || "active",
2405
+ wiki_ref: item.wiki_ref || "",
2406
+ quality_score: item.quality_score,
2407
+ timestamp: item.timestamp,
2408
+ evidence_ids: Array.isArray(item.evidence_ids) ? item.evidence_ids : [],
2409
+ score: Number(item.score.toFixed(4)),
2410
+ score_breakdown: item.score_breakdown || {},
2411
+ reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
2412
+ explain: {
2413
+ merge_key: item.merge_key,
2414
+ source_memory_id: item.source_memory_id,
2415
+ source_memory_canonical_id: item.source_memory_canonical_id,
2416
+ source_event_id: item.source_event_id || "",
2417
+ source_field: item.source_field || "",
2418
+ channel: item.source,
2419
+ source_file: item.source_file || "",
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 : [],
2424
+ score_breakdown: item.score_breakdown || {},
2425
+ reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
2426
+ },
2427
+ });
2428
+ }
2429
+ }
2430
+ if (lexicalCount < minLexicalHits) {
2431
+ const needed = minLexicalHits - lexicalCount;
2432
+ const supplement = fallbackPool.filter(item => item.reason_tags.includes("lexical_hit")).slice(0, needed);
2433
+ for (const item of supplement) {
2434
+ if (ranked.some(existing => existing.id === item.id)) {
2435
+ continue;
2436
+ }
2437
+ ranked.push({
2438
+ id: item.id,
2439
+ merge_key: item.merge_key,
2440
+ source_memory_id: item.source_memory_id,
2441
+ source_memory_canonical_id: item.source_memory_canonical_id,
2442
+ source_event_id: item.source_event_id || "",
2443
+ source_field: item.source_field || "",
2444
+ fulltext_event_id: item.source_event_id || item.source_memory_id || item.id,
2445
+ text: item.text,
2446
+ source_text: item.source_text || "",
2447
+ source_excerpt: item.source_excerpt || "",
2448
+ source_file: item.source_file || "",
2449
+ source: item.source,
2450
+ layer: item.layer,
2451
+ event_type: item.event_type,
2452
+ fact_status: item.fact_status || "active",
2453
+ wiki_ref: item.wiki_ref || "",
2454
+ quality_score: item.quality_score,
2455
+ timestamp: item.timestamp,
2456
+ evidence_ids: Array.isArray(item.evidence_ids) ? item.evidence_ids : [],
2457
+ score: Number(item.score.toFixed(4)),
2458
+ score_breakdown: item.score_breakdown || {},
2459
+ reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
2460
+ explain: {
2461
+ merge_key: item.merge_key,
2462
+ source_memory_id: item.source_memory_id,
2463
+ source_memory_canonical_id: item.source_memory_canonical_id,
2464
+ source_event_id: item.source_event_id || "",
2465
+ source_field: item.source_field || "",
2466
+ channel: item.source,
2467
+ source_file: item.source_file || "",
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 : [],
2472
+ score_breakdown: item.score_breakdown || {},
2473
+ reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
2474
+ },
2475
+ });
2476
+ }
2477
+ }
2478
+ ranked.sort((a, b) => b.score - a.score);
2479
+ if (fusionEnabled && llmModel && llmApiKey && llmBaseUrl && ranked.length > 1) {
2480
+ try {
2481
+ const maxCandidates = Math.max(4, Math.min(20, options.fusion?.maxCandidates ?? 10));
2482
+ const fusion = await requestFusion({
2483
+ query,
2484
+ candidates: ranked.slice(0, maxCandidates).map(item => ({
2485
+ id: item.id,
2486
+ text: item.text,
2487
+ source_excerpt: typeof item.source_excerpt === "string" ? item.source_excerpt : "",
2488
+ source_file: typeof item.source_file === "string" ? item.source_file : "",
2489
+ source_memory_id: typeof item.source_memory_id === "string" ? item.source_memory_id : "",
2490
+ source_memory_canonical_id: typeof item.source_memory_canonical_id === "string" ? item.source_memory_canonical_id : "",
2491
+ source_layer: typeof item.layer === "string" ? item.layer : "",
2492
+ source_event_id: typeof item.source_event_id === "string" ? item.source_event_id : "",
2493
+ source_field: item.source_field === "summary" || item.source_field === "evidence" ? item.source_field : "",
2494
+ source: item.source,
2495
+ event_type: item.event_type,
2496
+ quality_score: item.quality_score,
2497
+ timestamp: item.timestamp,
2498
+ score: item.score,
2499
+ reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
2500
+ })),
2501
+ llm: {
2502
+ model: llmModel,
2503
+ apiKey: llmApiKey,
2504
+ baseUrl: llmBaseUrl,
2505
+ },
2506
+ });
2507
+ if (fusion && fusion.canonical_answer) {
2508
+ if (!Array.isArray(fusion.evidence_ids) || fusion.evidence_ids.length === 0) {
2509
+ throw new Error("fusion_missing_whitelisted_evidence");
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
+ }));
2523
+ const fulltextFetchHints = (Array.isArray(fusion.need_fulltext_event_ids) ? fusion.need_fulltext_event_ids : [])
2524
+ .map(eventId => {
2525
+ const linked = ranked.find(item => item.source_memory_id === eventId ||
2526
+ item.source_memory_canonical_id === eventId ||
2527
+ item.id === eventId);
2528
+ return {
2529
+ event_id: eventId,
2530
+ source_file: linked?.source_file || "",
2531
+ source_excerpt: linked?.source_excerpt || "",
2532
+ };
2533
+ });
2534
+ const fusedItem = {
2535
+ id: `fusion_${Date.now().toString(36)}`,
2536
+ text: fusion.canonical_answer,
2537
+ source: "llm_fusion",
2538
+ event_type: "fusion",
2539
+ quality_score: Number(fusion.confidence.toFixed(4)),
2540
+ timestamp: new Date().toISOString(),
2541
+ score: Number((Math.max(...ranked.map(item => item.score)) + 1).toFixed(4)),
2542
+ reason_tags: ["llm_fused_authoritative", `evidence:${fusion.evidence_ids.length}`],
2543
+ explain: {
2544
+ channel: "llm_fusion",
2545
+ fused_from: ranked.slice(0, maxCandidates).map(item => item.id),
2546
+ reason_tags: ["llm_fused_authoritative", `evidence:${fusion.evidence_ids.length}`],
2547
+ },
2548
+ fused_coverage_note: fusion.coverage_note || "",
2549
+ fused_facts: fusion.facts,
2550
+ fused_timeline: fusion.timeline || [],
2551
+ fused_entities: fusion.entities || [],
2552
+ fused_decisions: fusion.decisions || [],
2553
+ fused_fixes: fusion.fixes || [],
2554
+ fused_preferences: fusion.preferences || [],
2555
+ fused_risks: fusion.risks || [],
2556
+ fused_action_items: fusion.action_items || [],
2557
+ fused_conflicts: fusion.conflicts,
2558
+ evidence_ids: fusedEvidenceIds,
2559
+ wiki_refs: wikiRefs,
2560
+ fused_evidence_ids: fusedEvidenceIds,
2561
+ fused_need_fulltext_event_ids: fusion.need_fulltext_event_ids || [],
2562
+ fulltext_fetch_hints: fulltextFetchHints,
2563
+ };
2564
+ const authoritative = options.fusion?.authoritative !== false;
2565
+ if (authoritative) {
2566
+ markHit(Array.isArray(fusion.evidence_ids) ? fusion.evidence_ids : []);
2567
+ return {
2568
+ results: [fusedItem],
2569
+ semantic_results: semanticResults,
2570
+ keyword_results: keywordResults,
2571
+ strategy: "vector_sentence_and_keyword_parallel",
2572
+ };
2573
+ }
2574
+ const merged = [fusedItem, ...ranked];
2575
+ markHit([
2576
+ ...(Array.isArray(fusion.evidence_ids) ? fusion.evidence_ids : []),
2577
+ ...ranked.map(item => item.id),
2578
+ ]);
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
+ };
2585
+ }
2586
+ }
2587
+ catch (error) {
2588
+ options.logger.warn(`LLM fusion failed, fallback to reranked results: ${error}`);
2589
+ }
2590
+ }
2591
+ const finalRanked = ranked.slice(0, Math.max(1, args.topK));
2592
+ markHit(finalRanked.map(item => item.id));
2593
+ return {
2594
+ results: finalRanked,
2595
+ semantic_results: semanticResults,
2596
+ keyword_results: keywordResults,
2597
+ strategy: "vector_sentence_and_keyword_parallel",
2598
+ };
199
2599
  }
200
2600
  async function getHotContext(args) {
201
2601
  const limit = Math.max(1, args.limit);
202
2602
  const docs = loadAllDocuments();
203
2603
  const coreRules = docs.find(doc => doc.source === "CORTEX_RULES.md");
204
- const sessionDocs = docs
205
- .filter(doc => doc.source.startsWith("sessions_"))
2604
+ const ruleBudget = Math.max(1, Math.min(6, Math.floor(limit / 3)));
2605
+ const archiveDocs = docs
2606
+ .filter(doc => doc.source === "sessions_archive")
206
2607
  .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
207
2608
  .slice(0, limit);
2609
+ const issueFixPairs = docs
2610
+ .filter(doc => doc.source === "sessions_archive" && (doc.eventType === "issue" || doc.eventType === "fix"))
2611
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
2612
+ .slice(0, 2);
208
2613
  const result = [];
209
2614
  if (coreRules) {
210
- result.push({ id: coreRules.id, text: coreRules.text, source: coreRules.source });
2615
+ const selectedRules = extractPrioritizedRuleLines(coreRules.text, ruleBudget);
2616
+ if (selectedRules.length > 0) {
2617
+ result.push({
2618
+ id: `${coreRules.id}.hot`,
2619
+ text: `# Hot Rules\n${selectedRules.map((line, index) => `${index + 1}. ${line}`).join("\n")}`,
2620
+ source: coreRules.source,
2621
+ });
2622
+ }
211
2623
  }
212
- for (const doc of sessionDocs) {
2624
+ for (const doc of [...issueFixPairs, ...archiveDocs]) {
213
2625
  result.push({ id: doc.id, text: doc.text, source: doc.source });
214
2626
  }
215
2627
  return { context: result.slice(0, limit) };
@@ -223,6 +2635,25 @@ function createReadStore(options) {
223
2635
  age_seconds: args.cachedAutoSearch.ageSeconds,
224
2636
  };
225
2637
  }
2638
+ if (!result.auto_search) {
2639
+ const docs = loadAllDocuments()
2640
+ .filter(doc => doc.source === "sessions_archive" && doc.sessionId === args.sessionId)
2641
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
2642
+ const latest = docs[0];
2643
+ if (latest && latest.text.trim()) {
2644
+ const autoQuery = latest.text.slice(0, Math.max(20, readTuning.autoContext.queryMaxChars));
2645
+ const light = await searchMemory({
2646
+ query: autoQuery,
2647
+ topK: 3,
2648
+ mode: readTuning.autoContext.lightweightSearch ? "lightweight" : "default",
2649
+ });
2650
+ result.auto_search = {
2651
+ query: autoQuery,
2652
+ results: light.results,
2653
+ age_seconds: 0,
2654
+ };
2655
+ }
2656
+ }
226
2657
  if (args.includeHot) {
227
2658
  const hot = await getHotContext({ limit: 20 });
228
2659
  result.hot_context = hot.context;