openclaw-cortex-memory 0.1.0-Alpha.20 → 0.1.0-Alpha.21

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 (60) hide show
  1. package/README.md +163 -228
  2. package/SKILL.md +71 -332
  3. package/dist/index.d.ts +36 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +194 -6
  6. package/dist/index.js.map +1 -1
  7. package/dist/openclaw.plugin.json +208 -12
  8. package/dist/src/dedup/three_stage_deduplicator.d.ts.map +1 -1
  9. package/dist/src/dedup/three_stage_deduplicator.js +1 -2
  10. package/dist/src/dedup/three_stage_deduplicator.js.map +1 -1
  11. package/dist/src/engine/ts_engine.d.ts +31 -0
  12. package/dist/src/engine/ts_engine.d.ts.map +1 -1
  13. package/dist/src/engine/ts_engine.js +262 -14
  14. package/dist/src/engine/ts_engine.js.map +1 -1
  15. package/dist/src/engine/types.d.ts +1 -0
  16. package/dist/src/engine/types.d.ts.map +1 -1
  17. package/dist/src/graph/ontology.d.ts +65 -15
  18. package/dist/src/graph/ontology.d.ts.map +1 -1
  19. package/dist/src/graph/ontology.js +316 -4
  20. package/dist/src/graph/ontology.js.map +1 -1
  21. package/dist/src/quality/llm_output_validator.d.ts +48 -0
  22. package/dist/src/quality/llm_output_validator.d.ts.map +1 -0
  23. package/dist/src/quality/llm_output_validator.js +404 -0
  24. package/dist/src/quality/llm_output_validator.js.map +1 -0
  25. package/dist/src/reflect/reflector.d.ts.map +1 -1
  26. package/dist/src/reflect/reflector.js +284 -8
  27. package/dist/src/reflect/reflector.js.map +1 -1
  28. package/dist/src/rules/rule_store.d.ts.map +1 -1
  29. package/dist/src/rules/rule_store.js +75 -16
  30. package/dist/src/rules/rule_store.js.map +1 -1
  31. package/dist/src/session/session_end.d.ts +20 -43
  32. package/dist/src/session/session_end.d.ts.map +1 -1
  33. package/dist/src/session/session_end.js +21 -233
  34. package/dist/src/session/session_end.js.map +1 -1
  35. package/dist/src/store/archive_store.d.ts +20 -7
  36. package/dist/src/store/archive_store.d.ts.map +1 -1
  37. package/dist/src/store/archive_store.js +96 -61
  38. package/dist/src/store/archive_store.js.map +1 -1
  39. package/dist/src/store/graph_memory_store.d.ts +44 -0
  40. package/dist/src/store/graph_memory_store.d.ts.map +1 -0
  41. package/dist/src/store/graph_memory_store.js +168 -0
  42. package/dist/src/store/graph_memory_store.js.map +1 -0
  43. package/dist/src/store/read_store.d.ts +27 -0
  44. package/dist/src/store/read_store.d.ts.map +1 -1
  45. package/dist/src/store/read_store.js +653 -94
  46. package/dist/src/store/read_store.js.map +1 -1
  47. package/dist/src/store/vector_store.d.ts +1 -0
  48. package/dist/src/store/vector_store.d.ts.map +1 -1
  49. package/dist/src/store/vector_store.js +1 -0
  50. package/dist/src/store/vector_store.js.map +1 -1
  51. package/dist/src/store/write_store.d.ts +7 -0
  52. package/dist/src/store/write_store.d.ts.map +1 -1
  53. package/dist/src/store/write_store.js +15 -3
  54. package/dist/src/store/write_store.js.map +1 -1
  55. package/dist/src/sync/session_sync.d.ts +48 -0
  56. package/dist/src/sync/session_sync.d.ts.map +1 -1
  57. package/dist/src/sync/session_sync.js +277 -78
  58. package/dist/src/sync/session_sync.js.map +1 -1
  59. package/openclaw.plugin.json +208 -12
  60. package/package.json +6 -4
@@ -37,6 +37,177 @@ exports.createReadStore = createReadStore;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const module_1 = require("module");
40
+ function buildEntityGraphSummaryDocs(graphDocs) {
41
+ const entityEdges = new Map();
42
+ const entityLatestTs = new Map();
43
+ const entitySession = new Map();
44
+ for (const doc of graphDocs) {
45
+ const ts = typeof doc.timestamp === "number" ? doc.timestamp : 0;
46
+ const sessionId = typeof doc.sessionId === "string" ? doc.sessionId : "";
47
+ const relations = Array.isArray(doc.relations) ? doc.relations : [];
48
+ for (const relation of relations) {
49
+ const source = (relation.source || "").trim();
50
+ const target = (relation.target || "").trim();
51
+ const type = (relation.type || "").trim();
52
+ if (!source || !target || !type)
53
+ continue;
54
+ if (!entityEdges.has(source))
55
+ entityEdges.set(source, []);
56
+ if (!entityEdges.has(target))
57
+ entityEdges.set(target, []);
58
+ entityEdges.get(source)?.push({ source, target, type });
59
+ entityEdges.get(target)?.push({ source, target, type });
60
+ entityLatestTs.set(source, Math.max(entityLatestTs.get(source) || 0, ts));
61
+ entityLatestTs.set(target, Math.max(entityLatestTs.get(target) || 0, ts));
62
+ if (sessionId) {
63
+ if (!entitySession.has(source))
64
+ entitySession.set(source, sessionId);
65
+ if (!entitySession.has(target))
66
+ entitySession.set(target, sessionId);
67
+ }
68
+ }
69
+ }
70
+ const output = [];
71
+ for (const [entity, edges] of entityEdges.entries()) {
72
+ if (!edges.length)
73
+ continue;
74
+ const outgoing = edges.filter(edge => edge.source === entity);
75
+ const incoming = edges.filter(edge => edge.target === entity);
76
+ const typeCounter = new Map();
77
+ for (const edge of edges) {
78
+ typeCounter.set(edge.type, (typeCounter.get(edge.type) || 0) + 1);
79
+ }
80
+ const typeSummary = [...typeCounter.entries()]
81
+ .sort((a, b) => b[1] - a[1])
82
+ .map(([type, count]) => `${type}:${count}`)
83
+ .join(", ");
84
+ const sortedOutgoing = [...outgoing].sort((a, b) => a.type.localeCompare(b.type));
85
+ const sortedIncoming = [...incoming].sort((a, b) => a.type.localeCompare(b.type));
86
+ const cappedOutgoing = sortedOutgoing.slice(0, 20);
87
+ const cappedIncoming = sortedIncoming.slice(0, 20);
88
+ const relationFacts = edges
89
+ .slice(0, 40)
90
+ .map(edge => `${edge.source} ${edge.type} ${edge.target}`)
91
+ .join(" | ");
92
+ const outgoingBlock = cappedOutgoing.length > 0
93
+ ? cappedOutgoing.map((edge, index) => `${index + 1}. ${edge.source} -[${edge.type}]-> ${edge.target}`).join("\n")
94
+ : "none";
95
+ const incomingBlock = cappedIncoming.length > 0
96
+ ? cappedIncoming.map((edge, index) => `${index + 1}. ${edge.source} -[${edge.type}]-> ${edge.target}`).join("\n")
97
+ : "none";
98
+ const summaryText = [
99
+ `# Graph Entity Summary`,
100
+ `entity: ${entity}`,
101
+ ``,
102
+ `## Stats`,
103
+ `relation_total: ${edges.length}`,
104
+ `outgoing_total: ${outgoing.length}`,
105
+ `incoming_total: ${incoming.length}`,
106
+ typeSummary ? `relation_type_distribution: ${typeSummary}` : "relation_type_distribution: none",
107
+ ``,
108
+ `## Outgoing Relations`,
109
+ outgoingBlock,
110
+ outgoing.length > cappedOutgoing.length ? `...truncated_outgoing: ${outgoing.length - cappedOutgoing.length}` : "",
111
+ ``,
112
+ `## Incoming Relations`,
113
+ incomingBlock,
114
+ incoming.length > cappedIncoming.length ? `...truncated_incoming: ${incoming.length - cappedIncoming.length}` : "",
115
+ ``,
116
+ `## Relation Facts`,
117
+ relationFacts || "none",
118
+ ].filter(Boolean).join("\n");
119
+ output.push({
120
+ id: `gph_entity_${entity.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "_")}`,
121
+ text: summaryText,
122
+ source: "sessions_graph_entity",
123
+ timestamp: (entityLatestTs.get(entity) || 0) > 0 ? entityLatestTs.get(entity) : undefined,
124
+ layer: "archive",
125
+ sourceMemoryId: entity,
126
+ sessionId: entitySession.get(entity) || undefined,
127
+ entities: [entity],
128
+ relations: edges,
129
+ eventType: "graph_summary",
130
+ qualityScore: 1,
131
+ });
132
+ }
133
+ return output;
134
+ }
135
+ const DEFAULT_READ_TUNING = {
136
+ scoring: {
137
+ lexicalWeight: 0.2,
138
+ bm25Scale: 2,
139
+ semanticWeight: 0.3,
140
+ recencyWeight: 0.1,
141
+ qualityWeight: 0.15,
142
+ typeMatchWeight: 0.15,
143
+ graphMatchWeight: 0.1,
144
+ },
145
+ rrf: {
146
+ k: 60,
147
+ weight: 1.5,
148
+ },
149
+ recency: {
150
+ buckets: [
151
+ { maxAgeHours: 12, score: 1, bonus: 0.6 },
152
+ { maxAgeHours: 24, score: 0.8, bonus: 0.6 },
153
+ { maxAgeHours: 72, score: 0.6, bonus: 0.3 },
154
+ { maxAgeHours: 168, score: 0.4, bonus: 0.3 },
155
+ { maxAgeHours: 720, score: 0.2, bonus: 0 },
156
+ { maxAgeHours: Number.POSITIVE_INFINITY, score: 0.05, bonus: 0 },
157
+ ],
158
+ },
159
+ autoContext: {
160
+ queryMaxChars: 80,
161
+ lightweightSearch: true,
162
+ },
163
+ };
164
+ function resolveReadTuning(options) {
165
+ const configuredBuckets = Array.isArray(options?.recency?.buckets)
166
+ ? options?.recency?.buckets
167
+ .filter(item => item &&
168
+ Number.isFinite(item.maxAgeHours) &&
169
+ item.maxAgeHours > 0 &&
170
+ Number.isFinite(item.score) &&
171
+ item.score >= 0 &&
172
+ Number.isFinite(item.bonus))
173
+ .map(item => ({
174
+ maxAgeHours: item.maxAgeHours,
175
+ score: Math.max(0, item.score),
176
+ bonus: Math.max(0, item.bonus),
177
+ }))
178
+ : [];
179
+ const sortedBuckets = configuredBuckets
180
+ .sort((a, b) => a.maxAgeHours - b.maxAgeHours);
181
+ const buckets = sortedBuckets.length > 0 ? sortedBuckets : DEFAULT_READ_TUNING.recency.buckets;
182
+ const numberOr = (value, fallback, min) => {
183
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
184
+ return fallback;
185
+ }
186
+ return value;
187
+ };
188
+ return {
189
+ scoring: {
190
+ lexicalWeight: numberOr(options?.scoring?.lexicalWeight, DEFAULT_READ_TUNING.scoring.lexicalWeight, 0),
191
+ bm25Scale: numberOr(options?.scoring?.bm25Scale, DEFAULT_READ_TUNING.scoring.bm25Scale, 0),
192
+ semanticWeight: numberOr(options?.scoring?.semanticWeight, DEFAULT_READ_TUNING.scoring.semanticWeight, 0),
193
+ recencyWeight: numberOr(options?.scoring?.recencyWeight, DEFAULT_READ_TUNING.scoring.recencyWeight, 0),
194
+ qualityWeight: numberOr(options?.scoring?.qualityWeight, DEFAULT_READ_TUNING.scoring.qualityWeight, 0),
195
+ typeMatchWeight: numberOr(options?.scoring?.typeMatchWeight, DEFAULT_READ_TUNING.scoring.typeMatchWeight, 0),
196
+ graphMatchWeight: numberOr(options?.scoring?.graphMatchWeight, DEFAULT_READ_TUNING.scoring.graphMatchWeight, 0),
197
+ },
198
+ rrf: {
199
+ k: Math.floor(numberOr(options?.rrf?.k, DEFAULT_READ_TUNING.rrf.k, 1)),
200
+ weight: numberOr(options?.rrf?.weight, DEFAULT_READ_TUNING.rrf.weight, 0),
201
+ },
202
+ recency: {
203
+ buckets,
204
+ },
205
+ autoContext: {
206
+ queryMaxChars: Math.floor(numberOr(options?.autoContext?.queryMaxChars, DEFAULT_READ_TUNING.autoContext.queryMaxChars, 20)),
207
+ lightweightSearch: options?.autoContext?.lightweightSearch !== false,
208
+ },
209
+ };
210
+ }
40
211
  function safeReadFile(filePath) {
41
212
  try {
42
213
  if (!fs.existsSync(filePath)) {
@@ -116,6 +287,14 @@ function bm25Score(args) {
116
287
  return score;
117
288
  }
118
289
  function normalizeRecordText(record) {
290
+ const summary = typeof record.summary === "string" ? record.summary.trim() : "";
291
+ const sourceText = typeof record.source_text === "string" ? record.source_text.trim() : "";
292
+ if (summary && sourceText) {
293
+ return [
294
+ `summary: ${summary}`,
295
+ `source_text: ${sourceText}`,
296
+ ].join("\n");
297
+ }
119
298
  const direct = [record.content, record.summary, record.text, record.message]
120
299
  .find(v => typeof v === "string" && v.trim());
121
300
  if (direct) {
@@ -158,33 +337,21 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
158
337
  }
159
338
  try {
160
339
  const parsed = JSON.parse(trimmed);
161
- const text = normalizeRecordText(parsed);
340
+ const summaryText = typeof parsed.summary === "string" ? parsed.summary.trim() : "";
341
+ const sourceText = typeof parsed.source_text === "string" ? parsed.source_text.trim() : "";
342
+ const text = summaryText || normalizeRecordText(parsed);
162
343
  if (!text.trim()) {
163
344
  continue;
164
345
  }
165
346
  const id = typeof parsed.id === "string" ? parsed.id : `${sourceLabel}:${docs.length + 1}`;
166
347
  const timestampValue = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
167
- const entities = Array.isArray(parsed.entities)
168
- ? parsed.entities.map(item => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
169
- : [];
170
- const relations = Array.isArray(parsed.relations)
171
- ? parsed.relations
172
- .map(item => {
173
- if (typeof item !== "object" || item === null)
174
- return null;
175
- const relation = item;
176
- const source = typeof relation.source === "string" ? relation.source.trim() : "";
177
- const target = typeof relation.target === "string" ? relation.target.trim() : "";
178
- const type = typeof relation.type === "string" ? relation.type.trim() : "related_to";
179
- if (!source || !target)
180
- return null;
181
- return { source, target, type };
182
- })
183
- .filter((item) => Boolean(item))
184
- : [];
185
348
  docs.push({
186
349
  id,
187
350
  text,
351
+ summaryText: summaryText || text,
352
+ sourceText: sourceText || undefined,
353
+ sourceEventId: typeof parsed.source_event_id === "string" ? parsed.source_event_id : undefined,
354
+ sourceFile: typeof parsed.source_file === "string" ? parsed.source_file : undefined,
188
355
  source: sourceLabel,
189
356
  timestamp: Number.isFinite(timestampValue) ? timestampValue : undefined,
190
357
  layer: parsed.layer === "active" || parsed.layer === "archive"
@@ -202,8 +369,8 @@ function parseJsonlFile(filePath, sourceLabel, logger) {
202
369
  charCount: typeof parsed.char_count === "number" ? parsed.char_count : undefined,
203
370
  tokenCount: typeof parsed.token_count === "number" ? parsed.token_count : undefined,
204
371
  sessionId: typeof parsed.session_id === "string" ? parsed.session_id : undefined,
205
- entities,
206
- relations,
372
+ entities: [],
373
+ relations: [],
207
374
  });
208
375
  }
209
376
  catch (error) {
@@ -232,35 +399,83 @@ function parseMarkdownFile(filePath, sourceLabel) {
232
399
  },
233
400
  ];
234
401
  }
235
- function withRecencyBoost(score, timestamp) {
402
+ function extractPrioritizedRuleLines(text, maxRules) {
403
+ if (!text.trim() || maxRules <= 0) {
404
+ return [];
405
+ }
406
+ const lines = text
407
+ .split(/\r?\n/)
408
+ .map(line => line.trim())
409
+ .filter(Boolean)
410
+ .filter(line => !/^core rules and knowledge extracted/i.test(line))
411
+ .filter(line => !/^core rules\b/i.test(line));
412
+ if (lines.length === 0) {
413
+ return [];
414
+ }
415
+ const dedupedFromTail = [];
416
+ const seen = new Set();
417
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
418
+ const line = lines[i];
419
+ const key = line.toLowerCase().replace(/\s+/g, " ").trim();
420
+ if (!key || seen.has(key)) {
421
+ continue;
422
+ }
423
+ seen.add(key);
424
+ dedupedFromTail.push(line);
425
+ }
426
+ const scored = dedupedFromTail.map((line, indexFromTail) => {
427
+ let score = 0;
428
+ if (/(must|should|ensure|avoid|prefer|always|never|fallback|verify|validate|retry|sanitize)/i.test(line)) {
429
+ score += 3;
430
+ }
431
+ if (/(fix|resolved|success|stable|deploy|release|incident|rollback|constraint|decision)/i.test(line)) {
432
+ score += 2;
433
+ }
434
+ if (/(确保|避免|优先|必须|建议|回退|重试|校验|稳定|发布|决策)/.test(line)) {
435
+ score += 2;
436
+ }
437
+ if (line.length >= 30 && line.length <= 220) {
438
+ score += 2;
439
+ }
440
+ else if (line.length > 220) {
441
+ score -= 1;
442
+ }
443
+ if (/[.!?]$/.test(line)) {
444
+ score += 1;
445
+ }
446
+ score += Math.max(0, 2 - indexFromTail * 0.08);
447
+ return { line, score, indexFromTail };
448
+ });
449
+ const selected = scored
450
+ .sort((a, b) => (b.score - a.score) || (a.indexFromTail - b.indexFromTail))
451
+ .slice(0, maxRules)
452
+ .sort((a, b) => a.indexFromTail - b.indexFromTail)
453
+ .map(item => item.line);
454
+ return selected;
455
+ }
456
+ function withRecencyBoost(score, timestamp, buckets) {
236
457
  if (!timestamp) {
237
458
  return score;
238
459
  }
239
460
  const ageHours = (Date.now() - timestamp) / (1000 * 60 * 60);
240
- if (ageHours < 24) {
241
- return score + 0.6;
242
- }
243
- if (ageHours < 168) {
244
- return score + 0.3;
461
+ for (const bucket of buckets) {
462
+ if (ageHours <= bucket.maxAgeHours) {
463
+ return score + bucket.bonus;
464
+ }
245
465
  }
246
466
  return score;
247
467
  }
248
- function recencyScore(timestamp) {
468
+ function recencyScore(timestamp, buckets) {
249
469
  if (!timestamp) {
250
470
  return 0;
251
471
  }
252
472
  const ageHours = (Date.now() - timestamp) / (1000 * 60 * 60);
253
- if (ageHours < 12)
254
- return 1;
255
- if (ageHours < 24)
256
- return 0.8;
257
- if (ageHours < 72)
258
- return 0.6;
259
- if (ageHours < 168)
260
- return 0.4;
261
- if (ageHours < 720)
262
- return 0.2;
263
- return 0.05;
473
+ for (const bucket of buckets) {
474
+ if (ageHours <= bucket.maxAgeHours) {
475
+ return bucket.score;
476
+ }
477
+ }
478
+ return 0;
264
479
  }
265
480
  function eventTypeHalfLifeDays(eventType, options) {
266
481
  const fallback = typeof options?.defaultHalfLifeDays === "number" && options.defaultHalfLifeDays > 0
@@ -322,10 +537,11 @@ function normalizeBaseUrl(value) {
322
537
  return "";
323
538
  return value.endsWith("/") ? value.slice(0, -1) : value;
324
539
  }
325
- const READ_FUSION_PROMPT_VERSION = "read-fusion.v1.1.0";
540
+ const READ_FUSION_PROMPT_VERSION = "read-fusion.v1.2.0";
326
541
  const READ_FUSION_REGRESSION_SAMPLES = [
327
- "样例A: 同一 source_memory_id 同时出现在 archive vector,输出中只保留一条主事实并在证据链保留两者关联。",
328
- "样例B: 新旧决策冲突时,将冲突写入 conflicts,并在 canonical_answer 标注优先级依据(时间、质量、明确性)。",
542
+ "Example A: if archive and vector refer to the same source_memory_id, keep one main conclusion and keep the rest as supporting evidence.",
543
+ "Example B: if conclusions conflict, write conflicts and explain prioritization in canonical_answer (time, quality, explicitness).",
544
+ "Example C: if summary/excerpt is insufficient, return event ids in need_fulltext_event_ids for full-text lookup.",
329
545
  ];
330
546
  function cosineSimilarity(left, right) {
331
547
  if (left.length === 0 || right.length === 0) {
@@ -450,19 +666,19 @@ async function requestRerank(args) {
450
666
  }
451
667
  function classifyIntent(query) {
452
668
  const text = query.toLowerCase();
453
- const relationHints = /(关系|依赖|关联|上下游|graph|relation|entity|拓扑)/i;
669
+ const relationHints = /(关系|依赖|关联|上下游|图谱|拓扑|graph|relation|entity|dependency)/i;
454
670
  if (relationHints.test(text))
455
671
  return "RELATION_DISCOVERY";
456
- const troubleHints = /(报错|错误|异常|失败|超时|无法|崩溃|error|failed|timeout|fix)/i;
672
+ const troubleHints = /(报错|错误|异常|失败|超时|无法|崩溃|故障|修复|bug|error|failed|timeout|fix)/i;
457
673
  if (troubleHints.test(text))
458
674
  return "TROUBLESHOOTING";
459
675
  const preferenceHints = /(偏好|习惯|口味|喜欢|不喜欢|偏向|preference)/i;
460
676
  if (preferenceHints.test(text))
461
677
  return "PREFERENCE_PROFILE";
462
- const timelineHints = /(最近|上次|之前|时间线|timeline|history)/i;
678
+ const timelineHints = /(最近|上次|之前|时间线|历史|timeline|history)/i;
463
679
  if (timelineHints.test(text))
464
680
  return "TIMELINE_REVIEW";
465
- const decisionHints = /(方案|决策|选择|建议|取舍|tradeoff|plan)/i;
681
+ const decisionHints = /(方案|决策|选择|建议|取舍|权衡|tradeoff|plan)/i;
466
682
  if (decisionHints.test(text))
467
683
  return "DECISION_SUPPORT";
468
684
  return "FACT_LOOKUP";
@@ -546,6 +762,50 @@ function channelQuota(source, topK, options) {
546
762
  return Math.max(8, topK * 3);
547
763
  return Math.max(12, topK * 4);
548
764
  }
765
+ function parseJsonStringArray(value) {
766
+ if (typeof value !== "string" || !value.trim()) {
767
+ return [];
768
+ }
769
+ try {
770
+ const parsed = JSON.parse(value);
771
+ if (!Array.isArray(parsed)) {
772
+ return [];
773
+ }
774
+ return parsed
775
+ .map(item => (typeof item === "string" ? item.trim() : ""))
776
+ .filter(Boolean);
777
+ }
778
+ catch {
779
+ return [];
780
+ }
781
+ }
782
+ function parseJsonRelations(value) {
783
+ if (typeof value !== "string" || !value.trim()) {
784
+ return [];
785
+ }
786
+ try {
787
+ const parsed = JSON.parse(value);
788
+ if (!Array.isArray(parsed)) {
789
+ return [];
790
+ }
791
+ return parsed
792
+ .map(item => {
793
+ if (typeof item !== "object" || item === null)
794
+ return null;
795
+ const relation = item;
796
+ const source = typeof relation.source === "string" ? relation.source.trim() : "";
797
+ const target = typeof relation.target === "string" ? relation.target.trim() : "";
798
+ const type = typeof relation.type === "string" && relation.type.trim() ? relation.type.trim() : "related_to";
799
+ if (!source || !target)
800
+ return null;
801
+ return { source, target, type };
802
+ })
803
+ .filter((item) => Boolean(item));
804
+ }
805
+ catch {
806
+ return [];
807
+ }
808
+ }
549
809
  async function searchLanceDb(args) {
550
810
  try {
551
811
  const require = (0, module_1.createRequire)(__filename);
@@ -585,12 +845,8 @@ async function searchLanceDb(args) {
585
845
  if (!id || !summary)
586
846
  continue;
587
847
  const ts = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : NaN;
588
- const entities = typeof record.entities_json === "string"
589
- ? JSON.parse(record.entities_json).filter(item => typeof item === "string" && item.trim())
590
- : [];
591
- const relations = typeof record.relations_json === "string"
592
- ? JSON.parse(record.relations_json)
593
- : [];
848
+ const entities = parseJsonStringArray(record.entities_json);
849
+ const relations = parseJsonRelations(record.relations_json);
594
850
  docs.push({
595
851
  id,
596
852
  text: summary,
@@ -599,6 +855,10 @@ async function searchLanceDb(args) {
599
855
  layer: record.layer === "active" || record.layer === "archive" ? record.layer : undefined,
600
856
  sourceMemoryId: typeof record.source_memory_id === "string" ? record.source_memory_id : undefined,
601
857
  sourceMemoryCanonicalId: typeof record.source_memory_canonical_id === "string" ? record.source_memory_canonical_id : undefined,
858
+ sourceEventId: typeof record.source_event_id === "string" ? record.source_event_id : undefined,
859
+ sourceField: record.source_field === "summary" || record.source_field === "evidence"
860
+ ? record.source_field
861
+ : undefined,
602
862
  embedding: Array.isArray(record.vector) ? record.vector.filter(item => Number.isFinite(item)) : undefined,
603
863
  eventType: typeof record.event_type === "string" ? record.event_type : undefined,
604
864
  qualityScore: typeof record.quality_score === "number" ? record.quality_score : undefined,
@@ -659,6 +919,10 @@ function parseVectorFallback(filePath, logger) {
659
919
  layer: parsed.layer === "active" || parsed.layer === "archive" ? parsed.layer : undefined,
660
920
  sourceMemoryId: typeof parsed.source_memory_id === "string" ? parsed.source_memory_id : undefined,
661
921
  sourceMemoryCanonicalId: typeof parsed.source_memory_canonical_id === "string" ? parsed.source_memory_canonical_id : undefined,
922
+ sourceEventId: typeof parsed.source_event_id === "string" ? parsed.source_event_id : undefined,
923
+ sourceField: parsed.source_field === "summary" || parsed.source_field === "evidence"
924
+ ? parsed.source_field
925
+ : undefined,
662
926
  embedding: Array.isArray(parsed.embedding) ? parsed.embedding.filter(item => Number.isFinite(item)) : undefined,
663
927
  eventType: typeof parsed.event_type === "string" ? parsed.event_type.trim() : undefined,
664
928
  qualityScore: typeof parsed.quality_score === "number" ? parsed.quality_score : undefined,
@@ -676,34 +940,75 @@ function parseVectorFallback(filePath, logger) {
676
940
  return docs;
677
941
  }
678
942
  async function requestFusion(args) {
943
+ const candidateIdSet = new Set(args.candidates.map(item => item.id));
679
944
  const endpoint = args.llm.baseUrl.endsWith("/chat/completions")
680
945
  ? args.llm.baseUrl
681
946
  : `${args.llm.baseUrl}/chat/completions`;
682
947
  const evidenceText = args.candidates
683
- .map((item, index) => `${index + 1}. [${item.id}] (${item.source}, score=${item.score.toFixed(4)}) ${item.text}`)
948
+ .map((item, index) => {
949
+ const excerpt = (item.source_excerpt || "").trim();
950
+ const sourceFile = (item.source_file || "").trim();
951
+ const sourceMemoryId = (item.source_memory_id || "").trim();
952
+ const sourceMemoryCanonicalId = (item.source_memory_canonical_id || "").trim();
953
+ const sourceLayer = (item.source_layer || "").trim();
954
+ const sourceEventId = (item.source_event_id || "").trim();
955
+ const sourceField = (item.source_field || "").trim();
956
+ const extraParts = [];
957
+ if (sourceMemoryId)
958
+ extraParts.push(`source_memory_id=${sourceMemoryId}`);
959
+ if (sourceMemoryCanonicalId)
960
+ extraParts.push(`source_memory_canonical_id=${sourceMemoryCanonicalId}`);
961
+ if (sourceLayer)
962
+ extraParts.push(`source_layer=${sourceLayer}`);
963
+ if (sourceEventId)
964
+ extraParts.push(`source_event_id=${sourceEventId}`);
965
+ if (sourceField)
966
+ extraParts.push(`source_field=${sourceField}`);
967
+ if (sourceFile)
968
+ extraParts.push(`source_file=${sourceFile}`);
969
+ if (excerpt)
970
+ extraParts.push(`source_excerpt=${excerpt}`);
971
+ const extra = extraParts.length > 0 ? `\n ${extraParts.join("\n ")}` : "";
972
+ return `${index + 1}. [${item.id}] (${item.source}, score=${item.score.toFixed(4)}) ${item.text}${extra}`;
973
+ })
684
974
  .join("\n")
685
975
  .slice(0, 18000);
686
976
  const prompt = [
687
977
  `prompt_version=${READ_FUSION_PROMPT_VERSION}`,
688
- "你是记忆检索融合器。请融合多路召回结果,产出可直接给 Agent 使用的完整记忆包,不要让 Agent 再去翻历史。",
689
- "必须严格返回 JSON:",
690
- "{\"canonical_answer\": string, \"coverage_note\": string, \"facts\": [{\"text\": string, \"evidence_ids\": string[]}], \"timeline\": [{\"when\": string, \"event\": string, \"evidence_ids\": string[]}], \"entities\": [{\"name\": string, \"role\": string}], \"decisions\": [{\"decision\": string, \"rationale\": string, \"evidence_ids\": string[]}], \"fixes\": [{\"issue\": string, \"fix\": string, \"evidence_ids\": string[]}], \"preferences\": [{\"subject\": string, \"preference\": string, \"evidence_ids\": string[]}], \"risks\": [{\"risk\": string, \"mitigation\": string, \"evidence_ids\": string[]}], \"action_items\": [{\"item\": string, \"owner\": string, \"status\": string, \"evidence_ids\": string[]}], \"conflicts\": [{\"topic\": string, \"details\": string}], \"evidence_ids\": string[], \"confidence\": number}",
691
- "要求:",
692
- "1) canonical_answer 是完整可执行答案,不要只写摘要",
693
- "2) facts 3-12 条,优先高分证据",
694
- "3) evidence_ids 必须来自输入候选 id",
695
- "4) 若存在冲突写入 conflicts,否则返回空数组",
696
- "5) confidence 0~1",
697
- "6) 不确定信息必须在 coverage_note 标注",
698
- "7) 同源记录合并:同 source_memory_id/source_memory_canonical_id 的候选只保留一条主结论,其余作为证据补充",
978
+ "You are a memory retrieval fusion engine. Fuse multi-channel evidence into a structured answer package for the agent.",
979
+ "Core values and principles:",
980
+ "A) Truthfulness first: do not fabricate; do not infer beyond evidence.",
981
+ "B) Evidence first: every key conclusion must be traceable via evidence_ids.",
982
+ "C) Make conflicts explicit: write conflicts instead of silently overriding.",
983
+ "D) Be transparent about uncertainty: put uncertain parts in coverage_note.",
984
+ "E) Summary-first: prefer summary evidence for conclusions; source_excerpt is supporting evidence.",
985
+ "F) Same-source dedup: merge duplicate evidence from the same source_memory_id/source_memory_canonical_id.",
986
+ "G) Full-text recall: if summary/excerpt is insufficient, return event ids in need_fulltext_event_ids.",
987
+ "Source channel semantics:",
988
+ "- rules: policy/constraints; use for what should be done.",
989
+ "- archive: event-level stable facts (summary-first).",
990
+ "- vector: semantic neighbors for recall; source_field=summary/evidence indicates chunk role.",
991
+ "- graph: entity-relation structure; prefer for dependency/relationship questions.",
992
+ "Query alignment:",
993
+ "- answer the user query first; ignore evidence unrelated to the query.",
994
+ "- when evidence conflicts, prioritize recency + quality + explicitness and record the conflict.",
995
+ "Return strict JSON only:",
996
+ "{\"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}",
997
+ "Output constraints:",
998
+ "1) canonical_answer must be directly usable.",
999
+ "2) facts: usually 3-12 items; prefer high-quality evidence.",
1000
+ "3) evidence_ids must come from input candidate ids.",
1001
+ "4) conflicts must be [] when no conflict exists.",
1002
+ "5) confidence must be within [0, 1].",
1003
+ "6) uncertain parts must be explicitly stated in coverage_note.",
699
1004
  ...READ_FUSION_REGRESSION_SAMPLES,
700
1005
  ].join("\n");
701
1006
  const body = {
702
1007
  model: args.llm.model,
703
1008
  temperature: 0.1,
704
1009
  messages: [
705
- { role: "system", content: "你只输出 JSON,不要额外解释。" },
706
- { role: "user", content: `${prompt}\n\n问题:\n${args.query}\n\n候选证据:\n${evidenceText}` },
1010
+ { role: "system", content: "Output JSON only. No extra text." },
1011
+ { role: "user", content: `${prompt}\n\nQuery:\n${args.query}\n\nCandidate Evidence:\n${evidenceText}` },
707
1012
  ],
708
1013
  };
709
1014
  let lastError = null;
@@ -739,6 +1044,13 @@ async function requestFusion(args) {
739
1044
  const evidenceIds = Array.isArray(parsed.evidence_ids)
740
1045
  ? parsed.evidence_ids.filter(item => typeof item === "string" && item.trim())
741
1046
  : [];
1047
+ const whitelistedEvidenceIds = [...new Set(evidenceIds.filter(id => candidateIdSet.has(id)))];
1048
+ const needFulltextEventIds = Array.isArray(parsed.need_fulltext_event_ids)
1049
+ ? [...new Set(parsed.need_fulltext_event_ids
1050
+ .filter(item => typeof item === "string")
1051
+ .map(item => item.trim())
1052
+ .filter(Boolean))]
1053
+ : [];
742
1054
  return {
743
1055
  canonical_answer: parsed.canonical_answer.trim().slice(0, 6000),
744
1056
  coverage_note: typeof parsed.coverage_note === "string" ? parsed.coverage_note.trim().slice(0, 1200) : "",
@@ -751,7 +1063,8 @@ async function requestFusion(args) {
751
1063
  risks: Array.isArray(parsed.risks) ? parsed.risks : [],
752
1064
  action_items: Array.isArray(parsed.action_items) ? parsed.action_items : [],
753
1065
  conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
754
- evidence_ids: evidenceIds,
1066
+ evidence_ids: whitelistedEvidenceIds,
1067
+ need_fulltext_event_ids: needFulltextEventIds,
755
1068
  confidence: typeof parsed.confidence === "number"
756
1069
  ? Math.max(0, Math.min(1, parsed.confidence))
757
1070
  : 0.5,
@@ -772,6 +1085,13 @@ function createReadStore(options) {
772
1085
  let vectorFallbackCache = null;
773
1086
  let bm25TokenCacheSignature = "";
774
1087
  let bm25TokenCache = new Map();
1088
+ let hitStatsCache = null;
1089
+ let hitStatsDirty = false;
1090
+ let hitStatsPendingMutations = 0;
1091
+ let lastHitStatsFlushAt = 0;
1092
+ const hitStatsFlushIntervalMs = 5000;
1093
+ const hitStatsFlushBatch = 24;
1094
+ const readTuning = resolveReadTuning(options.readTuning);
775
1095
  function fileSignature(filePath) {
776
1096
  try {
777
1097
  if (!fs.existsSync(filePath)) {
@@ -785,22 +1105,30 @@ function createReadStore(options) {
785
1105
  }
786
1106
  }
787
1107
  function loadHitStats() {
1108
+ if (hitStatsCache) {
1109
+ return hitStatsCache;
1110
+ }
788
1111
  try {
789
1112
  if (!fs.existsSync(hitStatsPath)) {
790
- return { items: {} };
1113
+ hitStatsCache = { items: {} };
1114
+ return hitStatsCache;
791
1115
  }
792
1116
  const content = fs.readFileSync(hitStatsPath, "utf-8").trim();
793
1117
  if (!content) {
794
- return { items: {} };
1118
+ hitStatsCache = { items: {} };
1119
+ return hitStatsCache;
795
1120
  }
796
1121
  const parsed = JSON.parse(content);
797
1122
  if (!parsed || typeof parsed !== "object" || !parsed.items || typeof parsed.items !== "object") {
798
- return { items: {} };
1123
+ hitStatsCache = { items: {} };
1124
+ return hitStatsCache;
799
1125
  }
800
- return parsed;
1126
+ hitStatsCache = parsed;
1127
+ return hitStatsCache;
801
1128
  }
802
1129
  catch {
803
- return { items: {} };
1130
+ hitStatsCache = { items: {} };
1131
+ return hitStatsCache;
804
1132
  }
805
1133
  }
806
1134
  function saveHitStats(state) {
@@ -815,6 +1143,21 @@ function createReadStore(options) {
815
1143
  options.logger.warn(`Failed to persist read hit stats: ${error}`);
816
1144
  }
817
1145
  }
1146
+ function maybeFlushHitStats(force) {
1147
+ if (!hitStatsDirty || !hitStatsCache) {
1148
+ return;
1149
+ }
1150
+ const now = Date.now();
1151
+ if (!force &&
1152
+ hitStatsPendingMutations < hitStatsFlushBatch &&
1153
+ (now - lastHitStatsFlushAt) < hitStatsFlushIntervalMs) {
1154
+ return;
1155
+ }
1156
+ saveHitStats(hitStatsCache);
1157
+ hitStatsDirty = false;
1158
+ hitStatsPendingMutations = 0;
1159
+ lastHitStatsFlushAt = now;
1160
+ }
818
1161
  function markHit(ids) {
819
1162
  if (!ids.length) {
820
1163
  return;
@@ -839,27 +1182,143 @@ function createReadStore(options) {
839
1182
  })
840
1183
  .slice(0, 20000);
841
1184
  state.items = Object.fromEntries(entries);
842
- saveHitStats(state);
1185
+ hitStatsCache = state;
1186
+ hitStatsDirty = true;
1187
+ hitStatsPendingMutations += ids.length;
1188
+ maybeFlushHitStats(false);
843
1189
  }
844
1190
  function loadAllDocuments() {
845
1191
  const cortexRulesPath = path.join(memoryRoot, "CORTEX_RULES.md");
846
1192
  const memoryMdPath = path.join(memoryRoot, "MEMORY.md");
847
1193
  const activeSessionsPath = path.join(memoryRoot, "sessions", "active", "sessions.jsonl");
848
1194
  const archiveSessionsPath = path.join(memoryRoot, "sessions", "archive", "sessions.jsonl");
1195
+ const graphMemoryPath = path.join(memoryRoot, "graph", "memory.jsonl");
849
1196
  const signature = [
850
1197
  fileSignature(cortexRulesPath),
851
1198
  fileSignature(memoryMdPath),
852
1199
  fileSignature(activeSessionsPath),
853
1200
  fileSignature(archiveSessionsPath),
1201
+ fileSignature(graphMemoryPath),
854
1202
  ].join("|");
855
1203
  if (docsCache && docsCache.signature === signature) {
856
1204
  return docsCache.docs;
857
1205
  }
1206
+ const archiveEventTypeById = new Map();
1207
+ if (fs.existsSync(archiveSessionsPath)) {
1208
+ const archiveContent = safeReadFile(archiveSessionsPath);
1209
+ for (const line of archiveContent.split(/\r?\n/)) {
1210
+ const trimmed = line.trim();
1211
+ if (!trimmed)
1212
+ continue;
1213
+ try {
1214
+ const parsed = JSON.parse(trimmed);
1215
+ const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
1216
+ const eventType = typeof parsed.event_type === "string" ? parsed.event_type.trim() : "";
1217
+ if (id && eventType) {
1218
+ archiveEventTypeById.set(id, eventType);
1219
+ }
1220
+ }
1221
+ catch { }
1222
+ }
1223
+ }
1224
+ const graphDocs = [];
1225
+ if (fs.existsSync(graphMemoryPath)) {
1226
+ const graphContent = safeReadFile(graphMemoryPath);
1227
+ for (const line of graphContent.split(/\r?\n/)) {
1228
+ const trimmed = line.trim();
1229
+ if (!trimmed)
1230
+ continue;
1231
+ try {
1232
+ const parsed = JSON.parse(trimmed);
1233
+ const id = typeof parsed.id === "string" ? parsed.id : "";
1234
+ const sourceEventId = typeof parsed.source_event_id === "string" ? parsed.source_event_id : "";
1235
+ const archiveEventId = typeof parsed.archive_event_id === "string" ? parsed.archive_event_id : "";
1236
+ const eventRefId = archiveEventId || sourceEventId;
1237
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
1238
+ const sourceLayer = typeof parsed.source_layer === "string" ? parsed.source_layer : "";
1239
+ const sourceFile = typeof parsed.source_file === "string" ? parsed.source_file : "";
1240
+ const timestamp = typeof parsed.timestamp === "string" ? Date.parse(parsed.timestamp) : NaN;
1241
+ const entities = Array.isArray(parsed.entities)
1242
+ ? parsed.entities.map((item) => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
1243
+ : [];
1244
+ const entityTypes = typeof parsed.entity_types === "object" && parsed.entity_types !== null
1245
+ ? parsed.entity_types
1246
+ : {};
1247
+ const relations = Array.isArray(parsed.relations)
1248
+ ? parsed.relations
1249
+ .map((item) => {
1250
+ if (typeof item !== "object" || item === null)
1251
+ return null;
1252
+ const relation = item;
1253
+ const source = typeof relation.source === "string" ? relation.source.trim() : "";
1254
+ const target = typeof relation.target === "string" ? relation.target.trim() : "";
1255
+ const type = typeof relation.type === "string" ? relation.type.trim() : "related_to";
1256
+ if (!source || !target)
1257
+ return null;
1258
+ return { source, target, type };
1259
+ })
1260
+ .filter((item) => Boolean(item))
1261
+ : [];
1262
+ const eventType = (typeof parsed.event_type === "string" ? parsed.event_type : "") || archiveEventTypeById.get(eventRefId) || "";
1263
+ const entityLines = entities.length > 0
1264
+ ? entities.map((entity, index) => {
1265
+ const entityType = entityTypes[entity];
1266
+ return `${index + 1}. ${entity}${entityType ? ` (${entityType})` : ""}`;
1267
+ }).join("\n")
1268
+ : "none";
1269
+ const relationLines = relations.length > 0
1270
+ ? relations.map((relation, index) => `${index + 1}. ${relation.source} -[${relation.type}]-> ${relation.target}`).join("\n")
1271
+ : "none";
1272
+ const relationFacts = relations.length > 0
1273
+ ? relations.map(relation => `${relation.source} ${relation.type} ${relation.target}`).join(" | ")
1274
+ : "none";
1275
+ const text = [
1276
+ `# Graph Record`,
1277
+ `record_id: ${id}`,
1278
+ `source_event_id: ${sourceEventId || archiveEventId || "unknown"}`,
1279
+ `source_layer: ${sourceLayer || "unknown"}`,
1280
+ `archive_event_id: ${archiveEventId || "n/a"}`,
1281
+ `event_type: ${eventType || "unknown"}`,
1282
+ `session_id: ${sessionId || "unknown"}`,
1283
+ `source_file: ${sourceFile || "unknown"}`,
1284
+ ``,
1285
+ `## Entities`,
1286
+ entityLines,
1287
+ ``,
1288
+ `## Relations`,
1289
+ relationLines,
1290
+ ``,
1291
+ `## Relation Facts`,
1292
+ relationFacts,
1293
+ ].join("\n");
1294
+ if (id && text.trim()) {
1295
+ graphDocs.push({
1296
+ id,
1297
+ text,
1298
+ source: "sessions_graph",
1299
+ timestamp: Number.isFinite(timestamp) ? timestamp : undefined,
1300
+ layer: sourceLayer === "active_only" ? "active" : "archive",
1301
+ sourceMemoryId: eventRefId || id,
1302
+ sourceEventId: sourceEventId || archiveEventId || undefined,
1303
+ sessionId,
1304
+ entities,
1305
+ relations,
1306
+ });
1307
+ }
1308
+ }
1309
+ catch (error) {
1310
+ options.logger.debug(`Skipping invalid graph memory line: ${error}`);
1311
+ }
1312
+ }
1313
+ }
1314
+ const entitySummaryDocs = buildEntityGraphSummaryDocs(graphDocs);
858
1315
  const docs = [
859
1316
  ...parseMarkdownFile(cortexRulesPath, "CORTEX_RULES.md"),
860
1317
  ...parseMarkdownFile(memoryMdPath, "MEMORY.md"),
861
1318
  ...parseJsonlFile(activeSessionsPath, "sessions_active", options.logger),
862
1319
  ...parseJsonlFile(archiveSessionsPath, "sessions_archive", options.logger),
1320
+ ...graphDocs,
1321
+ ...entitySummaryDocs,
863
1322
  ];
864
1323
  docsCache = { signature, docs };
865
1324
  return docs;
@@ -892,6 +1351,8 @@ function createReadStore(options) {
892
1351
  if (!query) {
893
1352
  return { results: [] };
894
1353
  }
1354
+ const mode = args.mode === "lightweight" ? "lightweight" : "default";
1355
+ const lightweightMode = mode === "lightweight";
895
1356
  const docs = loadAllDocuments();
896
1357
  const hitStats = loadHitStats();
897
1358
  const intent = classifyIntent(query);
@@ -900,7 +1361,7 @@ function createReadStore(options) {
900
1361
  const embeddingModel = options.embedding?.model || "";
901
1362
  const embeddingApiKey = options.embedding?.apiKey || "";
902
1363
  const embeddingBaseUrl = normalizeBaseUrl(options.embedding?.baseURL || options.embedding?.baseUrl);
903
- if (embeddingModel && embeddingApiKey && embeddingBaseUrl) {
1364
+ if (!lightweightMode && embeddingModel && embeddingApiKey && embeddingBaseUrl) {
904
1365
  try {
905
1366
  queryEmbedding = await requestEmbedding({
906
1367
  text: query,
@@ -921,8 +1382,38 @@ function createReadStore(options) {
921
1382
  ? []
922
1383
  : loadVectorFallbackCached();
923
1384
  const vectorDocs = [...vectorDocsFromLance, ...vectorDocsFallback];
1385
+ const archiveSourceById = new Map();
1386
+ for (const doc of docs) {
1387
+ if (doc.source !== "sessions_archive")
1388
+ continue;
1389
+ const key = (doc.sourceMemoryId || doc.id || "").trim();
1390
+ if (!key)
1391
+ continue;
1392
+ archiveSourceById.set(key, {
1393
+ sourceText: doc.sourceText,
1394
+ summaryText: doc.summaryText || doc.text,
1395
+ sourceFile: doc.sourceFile,
1396
+ });
1397
+ }
1398
+ for (const doc of vectorDocs) {
1399
+ const key = (doc.sourceMemoryId || "").trim();
1400
+ if (!key)
1401
+ continue;
1402
+ const linked = archiveSourceById.get(key);
1403
+ if (!linked)
1404
+ continue;
1405
+ if (!doc.sourceText && linked.sourceText) {
1406
+ doc.sourceText = linked.sourceText;
1407
+ }
1408
+ if (!doc.summaryText && linked.summaryText) {
1409
+ doc.summaryText = linked.summaryText;
1410
+ }
1411
+ if (!doc.sourceFile && linked.sourceFile) {
1412
+ doc.sourceFile = linked.sourceFile;
1413
+ }
1414
+ }
924
1415
  const graphDocs = docs
925
- .filter(doc => Array.isArray(doc.relations) && doc.relations.length > 0)
1416
+ .filter(doc => doc.source === "sessions_graph" || doc.source === "sessions_graph_entity")
926
1417
  .map(doc => {
927
1418
  const graphText = [
928
1419
  doc.text,
@@ -934,7 +1425,7 @@ function createReadStore(options) {
934
1425
  };
935
1426
  });
936
1427
  const rulesDocs = docs.filter(doc => doc.source === "CORTEX_RULES.md");
937
- const archiveDocs = docs.filter(doc => doc.source.startsWith("sessions_"));
1428
+ const archiveDocs = docs.filter(doc => doc.source === "sessions_active" || doc.source === "sessions_archive");
938
1429
  const bm25Terms = tokenize(query);
939
1430
  const bm25Corpus = [...rulesDocs, ...archiveDocs, ...vectorDocs, ...graphDocs];
940
1431
  const bm25Signature = `${docsCache?.signature || "na"}|vector:${vectorDocs.length}:${vectorDocs.slice(0, 40).map(item => `${item.id}:${item.text.length}`).join(",")}`;
@@ -956,14 +1447,14 @@ function createReadStore(options) {
956
1447
  avgDocLen: bm25Stats.avgDocLen,
957
1448
  docFreq: bm25Stats.docFreq,
958
1449
  });
959
- const lexicalCombined = lexical + bm25 * 2;
1450
+ const lexicalCombined = lexical + bm25 * readTuning.scoring.bm25Scale;
960
1451
  const semantic = queryEmbedding && Array.isArray(doc.embedding) && doc.embedding.length > 0
961
1452
  ? Math.max(0, cosineSimilarity(queryEmbedding, doc.embedding) * 5)
962
1453
  : 0;
963
1454
  if (lexicalCombined <= 0 && semantic <= 0) {
964
1455
  return null;
965
1456
  }
966
- const recency = recencyScore(doc.timestamp);
1457
+ const recency = recencyScore(doc.timestamp, readTuning.recency.buckets);
967
1458
  const quality = typeof doc.qualityScore === "number" ? Math.max(0, Math.min(1, doc.qualityScore)) : 0.5;
968
1459
  const typeMatch = preferredTypes.length > 0 && doc.eventType
969
1460
  ? (preferredTypes.includes(doc.eventType) ? 1 : 0)
@@ -972,12 +1463,12 @@ function createReadStore(options) {
972
1463
  const sourceBaseWeight = sourceWeight(source, intent);
973
1464
  const sourceConfigWeight = customChannelWeight(source, options.fusion);
974
1465
  const lengthNorm = lengthNormalizeFactor(doc, options.fusion);
975
- const baseWeighted = (0.2 * lexicalCombined +
976
- 0.3 * (semantic * lengthNorm) +
977
- 0.1 * recency +
978
- 0.15 * quality +
979
- 0.15 * typeMatch +
980
- 0.1 * graphMatch) * sourceBaseWeight * sourceConfigWeight;
1466
+ const baseWeighted = (readTuning.scoring.lexicalWeight * lexicalCombined +
1467
+ readTuning.scoring.semanticWeight * (semantic * lengthNorm) +
1468
+ readTuning.scoring.recencyWeight * recency +
1469
+ readTuning.scoring.qualityWeight * quality +
1470
+ readTuning.scoring.typeMatchWeight * typeMatch +
1471
+ readTuning.scoring.graphMatchWeight * graphMatch) * sourceBaseWeight * sourceConfigWeight;
981
1472
  const decayFactor = computeDecayFactor(doc.id, doc.eventType, doc.timestamp, options.memoryDecay, hitStats);
982
1473
  const weighted = baseWeighted * decayFactor;
983
1474
  return {
@@ -1020,7 +1511,7 @@ function createReadStore(options) {
1020
1511
  }
1021
1512
  const rrfMap = new Map();
1022
1513
  const weightedMap = new Map();
1023
- const rrfK = 60;
1514
+ const rrfK = readTuning.rrf.k;
1024
1515
  for (const key of Object.keys(channels)) {
1025
1516
  const list = channels[key];
1026
1517
  for (let i = 0; i < list.length; i += 1) {
@@ -1040,13 +1531,18 @@ function createReadStore(options) {
1040
1531
  merge_key: mergeKey,
1041
1532
  source_memory_id: candidate.doc.sourceMemoryId || "",
1042
1533
  source_memory_canonical_id: candidate.doc.sourceMemoryCanonicalId || "",
1043
- text: candidate.doc.text,
1534
+ source_event_id: candidate.doc.sourceEventId || "",
1535
+ source_field: candidate.doc.sourceField || "",
1536
+ text: candidate.doc.summaryText || candidate.doc.text,
1537
+ source_text: candidate.doc.sourceText ? candidate.doc.sourceText.slice(0, 4000) : "",
1538
+ source_excerpt: candidate.doc.sourceText ? candidate.doc.sourceText.slice(0, 360) : "",
1539
+ source_file: candidate.doc.sourceFile || "",
1044
1540
  source: candidate.doc.source,
1045
1541
  layer: candidate.doc.layer || "",
1046
1542
  event_type: candidate.doc.eventType || "",
1047
1543
  quality_score: candidate.quality,
1048
1544
  timestamp: candidate.doc.timestamp ? new Date(candidate.doc.timestamp).toISOString() : "",
1049
- score: candidate.weighted + (rrfMap.get(mergeKey) || 0) * 1.5,
1545
+ score: candidate.weighted + (rrfMap.get(mergeKey) || 0) * readTuning.rrf.weight,
1050
1546
  score_breakdown: {
1051
1547
  lexical: Number(candidate.lexical.toFixed(4)),
1052
1548
  bm25: Number(candidate.bm25.toFixed(4)),
@@ -1056,7 +1552,7 @@ function createReadStore(options) {
1056
1552
  type: Number(candidate.typeMatch.toFixed(4)),
1057
1553
  graph: Number(candidate.graphMatch.toFixed(4)),
1058
1554
  decay: Number(candidate.decayFactor.toFixed(4)),
1059
- rrf: Number(((rrfMap.get(mergeKey) || 0) * 1.5).toFixed(4)),
1555
+ rrf: Number(((rrfMap.get(mergeKey) || 0) * readTuning.rrf.weight).toFixed(4)),
1060
1556
  weighted: Number(candidate.weighted.toFixed(4)),
1061
1557
  },
1062
1558
  reason_tags: [
@@ -1074,13 +1570,13 @@ function createReadStore(options) {
1074
1570
  .slice(0, Math.max(1, Math.max(args.topK, 20)));
1075
1571
  const lexicalRanked = preRanked
1076
1572
  .map(doc => {
1077
- const boost = withRecencyBoost(doc.score, doc.timestamp ? Date.parse(doc.timestamp) : undefined);
1573
+ const boost = withRecencyBoost(doc.score, doc.timestamp ? Date.parse(doc.timestamp) : undefined, readTuning.recency.buckets);
1078
1574
  return { ...doc, score: Number(boost.toFixed(4)) };
1079
1575
  });
1080
1576
  const rerankerModel = options.reranker?.model || "";
1081
1577
  const rerankerApiKey = options.reranker?.apiKey || "";
1082
1578
  const rerankerBaseUrl = normalizeBaseUrl(options.reranker?.baseURL || options.reranker?.baseUrl);
1083
- const fusionEnabled = options.fusion?.enabled !== false;
1579
+ const fusionEnabled = !lightweightMode && options.fusion?.enabled !== false;
1084
1580
  const llmModel = options.llm?.model || "";
1085
1581
  const llmApiKey = options.llm?.apiKey || "";
1086
1582
  const llmBaseUrl = normalizeBaseUrl(options.llm?.baseURL || options.llm?.baseUrl);
@@ -1093,7 +1589,7 @@ function createReadStore(options) {
1093
1589
  source: item.source,
1094
1590
  score: item.score,
1095
1591
  }));
1096
- if (rerankerModel && rerankerApiKey && rerankerBaseUrl && lexicalRanked.length > 1 && !skipRerankerForFusion) {
1592
+ if (!lightweightMode && rerankerModel && rerankerApiKey && rerankerBaseUrl && lexicalRanked.length > 1 && !skipRerankerForFusion) {
1097
1593
  try {
1098
1594
  rerankedSimple = await requestRerank({
1099
1595
  query,
@@ -1118,7 +1614,13 @@ function createReadStore(options) {
1118
1614
  merge_key: hit?.merge_key || item.merge_key || item.id,
1119
1615
  source_memory_id: hit?.source_memory_id || "",
1120
1616
  source_memory_canonical_id: hit?.source_memory_canonical_id || "",
1617
+ source_event_id: hit?.source_event_id || "",
1618
+ source_field: hit?.source_field || "",
1619
+ fulltext_event_id: (hit?.source_event_id || hit?.source_memory_id || item.id || ""),
1121
1620
  text: item.text,
1621
+ source_text: hit?.source_text || "",
1622
+ source_excerpt: hit?.source_excerpt || "",
1623
+ source_file: hit?.source_file || "",
1122
1624
  source: item.source,
1123
1625
  layer: hit?.layer || "",
1124
1626
  event_type: hit?.event_type || "",
@@ -1131,7 +1633,10 @@ function createReadStore(options) {
1131
1633
  merge_key: hit?.merge_key || item.merge_key || item.id,
1132
1634
  source_memory_id: hit?.source_memory_id || "",
1133
1635
  source_memory_canonical_id: hit?.source_memory_canonical_id || "",
1636
+ source_event_id: hit?.source_event_id || "",
1637
+ source_field: hit?.source_field || "",
1134
1638
  channel: item.source,
1639
+ source_file: hit?.source_file || "",
1135
1640
  layer: hit?.layer || "",
1136
1641
  score_breakdown: hit?.score_breakdown || {},
1137
1642
  reason_tags: Array.isArray(hit?.reason_tags) ? hit?.reason_tags : [],
@@ -1152,7 +1657,13 @@ function createReadStore(options) {
1152
1657
  merge_key: item.merge_key,
1153
1658
  source_memory_id: item.source_memory_id,
1154
1659
  source_memory_canonical_id: item.source_memory_canonical_id,
1660
+ source_event_id: item.source_event_id || "",
1661
+ source_field: item.source_field || "",
1662
+ fulltext_event_id: item.source_event_id || item.source_memory_id || item.id,
1155
1663
  text: item.text,
1664
+ source_text: item.source_text || "",
1665
+ source_excerpt: item.source_excerpt || "",
1666
+ source_file: item.source_file || "",
1156
1667
  source: item.source,
1157
1668
  layer: item.layer,
1158
1669
  event_type: item.event_type,
@@ -1165,7 +1676,10 @@ function createReadStore(options) {
1165
1676
  merge_key: item.merge_key,
1166
1677
  source_memory_id: item.source_memory_id,
1167
1678
  source_memory_canonical_id: item.source_memory_canonical_id,
1679
+ source_event_id: item.source_event_id || "",
1680
+ source_field: item.source_field || "",
1168
1681
  channel: item.source,
1682
+ source_file: item.source_file || "",
1169
1683
  layer: item.layer,
1170
1684
  score_breakdown: item.score_breakdown || {},
1171
1685
  reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
@@ -1185,7 +1699,13 @@ function createReadStore(options) {
1185
1699
  merge_key: item.merge_key,
1186
1700
  source_memory_id: item.source_memory_id,
1187
1701
  source_memory_canonical_id: item.source_memory_canonical_id,
1702
+ source_event_id: item.source_event_id || "",
1703
+ source_field: item.source_field || "",
1704
+ fulltext_event_id: item.source_event_id || item.source_memory_id || item.id,
1188
1705
  text: item.text,
1706
+ source_text: item.source_text || "",
1707
+ source_excerpt: item.source_excerpt || "",
1708
+ source_file: item.source_file || "",
1189
1709
  source: item.source,
1190
1710
  layer: item.layer,
1191
1711
  event_type: item.event_type,
@@ -1198,7 +1718,10 @@ function createReadStore(options) {
1198
1718
  merge_key: item.merge_key,
1199
1719
  source_memory_id: item.source_memory_id,
1200
1720
  source_memory_canonical_id: item.source_memory_canonical_id,
1721
+ source_event_id: item.source_event_id || "",
1722
+ source_field: item.source_field || "",
1201
1723
  channel: item.source,
1724
+ source_file: item.source_file || "",
1202
1725
  layer: item.layer,
1203
1726
  score_breakdown: item.score_breakdown || {},
1204
1727
  reason_tags: Array.isArray(item.reason_tags) ? item.reason_tags : [],
@@ -1215,6 +1738,13 @@ function createReadStore(options) {
1215
1738
  candidates: ranked.slice(0, maxCandidates).map(item => ({
1216
1739
  id: item.id,
1217
1740
  text: item.text,
1741
+ source_excerpt: typeof item.source_excerpt === "string" ? item.source_excerpt : "",
1742
+ source_file: typeof item.source_file === "string" ? item.source_file : "",
1743
+ source_memory_id: typeof item.source_memory_id === "string" ? item.source_memory_id : "",
1744
+ source_memory_canonical_id: typeof item.source_memory_canonical_id === "string" ? item.source_memory_canonical_id : "",
1745
+ source_layer: typeof item.layer === "string" ? item.layer : "",
1746
+ source_event_id: typeof item.source_event_id === "string" ? item.source_event_id : "",
1747
+ source_field: item.source_field === "summary" || item.source_field === "evidence" ? item.source_field : "",
1218
1748
  source: item.source,
1219
1749
  event_type: item.event_type,
1220
1750
  quality_score: item.quality_score,
@@ -1229,6 +1759,20 @@ function createReadStore(options) {
1229
1759
  },
1230
1760
  });
1231
1761
  if (fusion && fusion.canonical_answer) {
1762
+ if (!Array.isArray(fusion.evidence_ids) || fusion.evidence_ids.length === 0) {
1763
+ throw new Error("fusion_missing_whitelisted_evidence");
1764
+ }
1765
+ const fulltextFetchHints = (Array.isArray(fusion.need_fulltext_event_ids) ? fusion.need_fulltext_event_ids : [])
1766
+ .map(eventId => {
1767
+ const linked = ranked.find(item => item.source_memory_id === eventId ||
1768
+ item.source_memory_canonical_id === eventId ||
1769
+ item.id === eventId);
1770
+ return {
1771
+ event_id: eventId,
1772
+ source_file: linked?.source_file || "",
1773
+ source_excerpt: linked?.source_excerpt || "",
1774
+ };
1775
+ });
1232
1776
  const fusedItem = {
1233
1777
  id: `fusion_${Date.now().toString(36)}`,
1234
1778
  text: fusion.canonical_answer,
@@ -1254,6 +1798,8 @@ function createReadStore(options) {
1254
1798
  fused_action_items: fusion.action_items || [],
1255
1799
  fused_conflicts: fusion.conflicts,
1256
1800
  fused_evidence_ids: fusion.evidence_ids,
1801
+ fused_need_fulltext_event_ids: fusion.need_fulltext_event_ids || [],
1802
+ fulltext_fetch_hints: fulltextFetchHints,
1257
1803
  };
1258
1804
  const authoritative = options.fusion?.authoritative !== false;
1259
1805
  if (authoritative) {
@@ -1280,6 +1826,7 @@ function createReadStore(options) {
1280
1826
  const limit = Math.max(1, args.limit);
1281
1827
  const docs = loadAllDocuments();
1282
1828
  const coreRules = docs.find(doc => doc.source === "CORTEX_RULES.md");
1829
+ const ruleBudget = Math.max(1, Math.min(6, Math.floor(limit / 3)));
1283
1830
  const archiveDocs = docs
1284
1831
  .filter(doc => doc.source === "sessions_archive")
1285
1832
  .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
@@ -1290,7 +1837,14 @@ function createReadStore(options) {
1290
1837
  .slice(0, 2);
1291
1838
  const result = [];
1292
1839
  if (coreRules) {
1293
- result.push({ id: coreRules.id, text: coreRules.text, source: coreRules.source });
1840
+ const selectedRules = extractPrioritizedRuleLines(coreRules.text, ruleBudget);
1841
+ if (selectedRules.length > 0) {
1842
+ result.push({
1843
+ id: `${coreRules.id}.hot`,
1844
+ text: `# Hot Rules\n${selectedRules.map((line, index) => `${index + 1}. ${line}`).join("\n")}`,
1845
+ source: coreRules.source,
1846
+ });
1847
+ }
1294
1848
  }
1295
1849
  for (const doc of [...issueFixPairs, ...archiveDocs]) {
1296
1850
  result.push({ id: doc.id, text: doc.text, source: doc.source });
@@ -1312,9 +1866,14 @@ function createReadStore(options) {
1312
1866
  .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
1313
1867
  const latest = docs[0];
1314
1868
  if (latest && latest.text.trim()) {
1315
- const light = await searchMemory({ query: latest.text.slice(0, 80), topK: 3 });
1869
+ const autoQuery = latest.text.slice(0, Math.max(20, readTuning.autoContext.queryMaxChars));
1870
+ const light = await searchMemory({
1871
+ query: autoQuery,
1872
+ topK: 3,
1873
+ mode: readTuning.autoContext.lightweightSearch ? "lightweight" : "default",
1874
+ });
1316
1875
  result.auto_search = {
1317
- query: latest.text.slice(0, 80),
1876
+ query: autoQuery,
1318
1877
  results: light.results,
1319
1878
  age_seconds: 0,
1320
1879
  };