@memory-river/core 0.2.0

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 (86) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +222 -0
  3. package/README.zh-TW.md +186 -0
  4. package/dist/api.d.ts +100 -0
  5. package/dist/api.js +156 -0
  6. package/dist/cognition/causal-attribution.d.ts +36 -0
  7. package/dist/cognition/causal-attribution.js +239 -0
  8. package/dist/cognition/causal-engine.d.ts +105 -0
  9. package/dist/cognition/causal-engine.js +150 -0
  10. package/dist/cognition/conflict-detector.d.ts +39 -0
  11. package/dist/cognition/conflict-detector.js +193 -0
  12. package/dist/cognition/global-working-memory.d.ts +53 -0
  13. package/dist/cognition/global-working-memory.js +211 -0
  14. package/dist/cognition/hooks-engine.d.ts +99 -0
  15. package/dist/cognition/hooks-engine.js +672 -0
  16. package/dist/cognition/ralph-core.d.ts +28 -0
  17. package/dist/cognition/ralph-core.js +104 -0
  18. package/dist/distill/concentrator-adapter.d.ts +167 -0
  19. package/dist/distill/concentrator-adapter.js +1876 -0
  20. package/dist/engine.d.ts +402 -0
  21. package/dist/engine.js +2254 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lifecycle/cleanup-engine.d.ts +80 -0
  25. package/dist/lifecycle/cleanup-engine.js +162 -0
  26. package/dist/lifecycle/cleanup-state.d.ts +34 -0
  27. package/dist/lifecycle/cleanup-state.js +50 -0
  28. package/dist/lifecycle/night-consolidation.d.ts +102 -0
  29. package/dist/lifecycle/night-consolidation.js +640 -0
  30. package/dist/lifecycle/night-recovery.d.ts +40 -0
  31. package/dist/lifecycle/night-recovery.js +107 -0
  32. package/dist/paths.d.ts +17 -0
  33. package/dist/paths.js +16 -0
  34. package/dist/pipeline/capsule-bridge.d.ts +35 -0
  35. package/dist/pipeline/capsule-bridge.js +86 -0
  36. package/dist/pipeline/compact-request.d.ts +30 -0
  37. package/dist/pipeline/compact-request.js +66 -0
  38. package/dist/pipeline/inbox-watcher.d.ts +112 -0
  39. package/dist/pipeline/inbox-watcher.js +1039 -0
  40. package/dist/ports.d.ts +29 -0
  41. package/dist/ports.js +1 -0
  42. package/dist/providers/embedder-v5.d.ts +46 -0
  43. package/dist/providers/embedder-v5.js +155 -0
  44. package/dist/providers/ollama-embedding.d.ts +25 -0
  45. package/dist/providers/ollama-embedding.js +166 -0
  46. package/dist/retrieval/abstractness-judge.d.ts +14 -0
  47. package/dist/retrieval/abstractness-judge.js +87 -0
  48. package/dist/retrieval/coverage-selection.d.ts +3 -0
  49. package/dist/retrieval/coverage-selection.js +53 -0
  50. package/dist/retrieval/cross-encoder-gate.d.ts +40 -0
  51. package/dist/retrieval/cross-encoder-gate.js +239 -0
  52. package/dist/retrieval/retriever-v4.d.ts +78 -0
  53. package/dist/retrieval/retriever-v4.js +1200 -0
  54. package/dist/skills/validate.d.ts +6 -0
  55. package/dist/skills/validate.js +69 -0
  56. package/dist/storage.d.ts +19 -0
  57. package/dist/storage.js +54 -0
  58. package/dist/store/aux-table-maintenance.d.ts +5 -0
  59. package/dist/store/aux-table-maintenance.js +64 -0
  60. package/dist/store/graph-enumerator.d.ts +21 -0
  61. package/dist/store/graph-enumerator.js +185 -0
  62. package/dist/store/graph-store.d.ts +107 -0
  63. package/dist/store/graph-store.js +478 -0
  64. package/dist/store/status-manager.d.ts +44 -0
  65. package/dist/store/status-manager.js +235 -0
  66. package/dist/store/store-v4.d.ts +339 -0
  67. package/dist/store/store-v4.js +2871 -0
  68. package/dist/transcript/keyword-search.d.ts +9 -0
  69. package/dist/transcript/keyword-search.js +67 -0
  70. package/dist/transcript/rehydrate-keyword.d.ts +6 -0
  71. package/dist/transcript/rehydrate-keyword.js +29 -0
  72. package/dist/transcript/rehydrate.d.ts +33 -0
  73. package/dist/transcript/rehydrate.js +285 -0
  74. package/dist/transcript/transcript-archive.d.ts +46 -0
  75. package/dist/transcript/transcript-archive.js +516 -0
  76. package/dist/types.d.ts +409 -0
  77. package/dist/types.js +104 -0
  78. package/dist/util/bounded-map.d.ts +1 -0
  79. package/dist/util/bounded-map.js +8 -0
  80. package/dist/util/rate-limiter.d.ts +12 -0
  81. package/dist/util/rate-limiter.js +54 -0
  82. package/dist/util/session-identity.d.ts +65 -0
  83. package/dist/util/session-identity.js +227 -0
  84. package/dist/util/util-hash.d.ts +1 -0
  85. package/dist/util/util-hash.js +4 -0
  86. package/package.json +59 -0
@@ -0,0 +1,640 @@
1
+ /**
2
+ * Night Consolidation — 記憶夜間整理模組
3
+ *
4
+ * 每天凌晨(或手動觸發)自動執行:
5
+ * 1. 取出當日所有新寫入的記憶
6
+ * 2. 依 slotKey 分組,找出重覆/碎片記憶
7
+ * 3. 丟 MiniMax M2.7 (thinking=high) 做品質把關決策
8
+ * 4. 執行:合併、刪除、更新 confidence/category
9
+ * 5. 寫入 consolidation 日誌
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { CATEGORY_DESCRIPTIONS } from '../types.js';
15
+ import { buildNightRecoveryMetadata } from './night-recovery.js';
16
+ // ============================================================================
17
+ // Night Consolidator
18
+ // ============================================================================
19
+ export class NightConsolidator {
20
+ store;
21
+ _options;
22
+ logPath;
23
+ constructor(store, _options, consolidationLog) {
24
+ this.store = store;
25
+ this._options = _options;
26
+ if (!_options.statusManager)
27
+ throw new Error('[NightConsolidator] statusManager is required');
28
+ this.logPath = consolidationLog;
29
+ }
30
+ recordStat(record) {
31
+ const recordNightConsolidationStat = this.store.recordNightConsolidationStat;
32
+ if (!recordNightConsolidationStat)
33
+ return;
34
+ void recordNightConsolidationStat.call(this.store, record).catch((err) => {
35
+ console.warn('[NightConsolidation] stats write failed:', err?.message ?? err);
36
+ });
37
+ }
38
+ statMetadata(source, extra = {}) {
39
+ return buildNightRecoveryMetadata({ source, ...extra });
40
+ }
41
+ _broadcast(message) {
42
+ const notifier = this._options.notifier;
43
+ if (!notifier)
44
+ return;
45
+ void notifier.notify(message).catch((err) => {
46
+ console.error(`[NightConsolidation] _broadcast failed: ${err.message}`);
47
+ });
48
+ }
49
+ // ── 入口 ─────────────────────────────────────────────────────────────────
50
+ /**
51
+ * 執行夜間整理(當日記憶)
52
+ */
53
+ async consolidateToday(runId = randomUUID(), source = 'scheduled_timer') {
54
+ return this.consolidateRange('today', runId, source);
55
+ }
56
+ /**
57
+ * 執行夜間整理(指定時間範圍)
58
+ * @param range 'today' | 'yesterday' | number (days ago)
59
+ */
60
+ async consolidateRange(range, runId = randomUUID(), source = 'scheduled_timer') {
61
+ const startMs = Date.now();
62
+ const errors = [];
63
+ // 1. 取出目標記憶
64
+ let startOfDay;
65
+ let endOfDay;
66
+ if (range === 'today') {
67
+ const now = new Date();
68
+ startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0).getTime();
69
+ endOfDay = Date.now();
70
+ }
71
+ else if (range === 'yesterday') {
72
+ const yesterday = new Date();
73
+ yesterday.setDate(yesterday.getDate() - 1);
74
+ startOfDay = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate(), 0, 0, 0).getTime();
75
+ endOfDay = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate(), 23, 59, 59, 999).getTime();
76
+ }
77
+ else {
78
+ // days ago
79
+ const target = new Date();
80
+ target.setDate(target.getDate() - Number(range));
81
+ startOfDay = new Date(target.getFullYear(), target.getMonth(), target.getDate(), 0, 0, 0).getTime();
82
+ endOfDay = new Date(target.getFullYear(), target.getMonth(), target.getDate(), 23, 59, 59, 999).getTime();
83
+ }
84
+ console.log(`[NightConsolidation] Range: ${new Date(startOfDay).toLocaleString('zh-TW')} ~ ${range === 'today' ? 'now' : new Date(endOfDay).toLocaleString('zh-TW')}`);
85
+ let memories;
86
+ try {
87
+ const all = await this.store.queryAll(10000);
88
+ memories = all.filter(m => {
89
+ const t = m.createdAt || m.updatedAt;
90
+ return t >= startOfDay && t <= endOfDay;
91
+ });
92
+ const candidateTimes = memories
93
+ .map(m => m.createdAt || m.updatedAt)
94
+ .filter(t => typeof t === 'number' && Number.isFinite(t));
95
+ this.recordStat({
96
+ runId,
97
+ phase: 'query_completed',
98
+ ts: Date.now(),
99
+ outcome: 'ok',
100
+ scannedCount: all.length,
101
+ candidateCount: memories.length,
102
+ metadata: this.statMetadata(source, {
103
+ startOfDay,
104
+ endOfDay,
105
+ candidateMinCreatedAt: candidateTimes.length ? Math.min(...candidateTimes) : null,
106
+ candidateMaxCreatedAt: candidateTimes.length ? Math.max(...candidateTimes) : null,
107
+ }),
108
+ });
109
+ }
110
+ catch (err) {
111
+ errors.push(`取出記憶失敗: ${err.message}`);
112
+ this.recordStat({
113
+ runId,
114
+ phase: 'query_completed',
115
+ ts: Date.now(),
116
+ outcome: 'failed',
117
+ errorMessage: err?.message ?? String(err),
118
+ metadata: this.statMetadata(source, { startOfDay, endOfDay }),
119
+ });
120
+ this._broadcast(`❌ Night Consolidation 失敗|錯誤: ${err.message}`);
121
+ return {
122
+ plan: { decisions: [], summary: '', processedCount: 0, mergedCount: 0, deletedCount: 0, updatedCount: 0, keptCount: 0 },
123
+ executedAt: Date.now(),
124
+ durationMs: Date.now() - startMs,
125
+ errors,
126
+ };
127
+ }
128
+ if (memories.length === 0) {
129
+ this.recordStat({
130
+ runId,
131
+ phase: 'zero_candidates',
132
+ ts: Date.now(),
133
+ outcome: 'skipped',
134
+ candidateCount: 0,
135
+ metadata: this.statMetadata(source),
136
+ });
137
+ console.log('[NightConsolidation] No new memories; skipping');
138
+ return {
139
+ plan: { decisions: [], summary: '無新記憶', processedCount: 0, mergedCount: 0, deletedCount: 0, updatedCount: 0, keptCount: 0 },
140
+ executedAt: Date.now(),
141
+ durationMs: Date.now() - startMs,
142
+ errors: [],
143
+ };
144
+ }
145
+ console.log(`[NightConsolidation] Retrieved ${memories.length} records`);
146
+ // 廣播:開始執行
147
+ const rangeLabel = range === 'today' ? '今晚' : `近 ${range} 天`;
148
+ this._broadcast(`🌙 Night Consolidation 啟動(範圍:${rangeLabel}),共 ${memories.length} 筆記錄待處理`);
149
+ // 2. LLM 決策
150
+ let plan;
151
+ try {
152
+ plan = await this.llmDecide(memories, runId, source);
153
+ this.recordStat({
154
+ runId,
155
+ phase: 'plan_created',
156
+ ts: Date.now(),
157
+ outcome: 'ok',
158
+ candidateCount: memories.length,
159
+ decisionCount: plan.decisions.length,
160
+ mergeCount: plan.mergedCount,
161
+ deleteCount: plan.decisions.filter(d => d.action === 'delete').length,
162
+ deprecatedCount: plan.decisions.filter(d => d.action === 'deprecated').length,
163
+ updateCount: plan.updatedCount,
164
+ keepCount: plan.keptCount,
165
+ metadata: this.statMetadata(source),
166
+ });
167
+ }
168
+ catch (err) {
169
+ errors.push(`LLM 決策失敗: ${err.message}`);
170
+ this.recordStat({
171
+ runId,
172
+ phase: 'llm_failed',
173
+ ts: Date.now(),
174
+ outcome: 'failed',
175
+ candidateCount: memories.length,
176
+ errorMessage: err?.message ?? String(err),
177
+ metadata: this.statMetadata(source, { processedCount: memories.length }),
178
+ });
179
+ this._broadcast(`❌ Night Consolidation 失敗|錯誤: ${err.message}`);
180
+ return {
181
+ plan: { decisions: [], summary: '', processedCount: memories.length, mergedCount: 0, deletedCount: 0, updatedCount: 0, keptCount: 0 },
182
+ executedAt: Date.now(),
183
+ durationMs: Date.now() - startMs,
184
+ errors,
185
+ };
186
+ }
187
+ // 3. 執行決策
188
+ try {
189
+ await this.executePlan(plan, errors, runId, source);
190
+ }
191
+ catch (err) {
192
+ errors.push(`執行計畫失敗: ${err.message}`);
193
+ this._broadcast(`❌ Night Consolidation 失敗|錯誤: ${err.message}`);
194
+ }
195
+ // 4. 寫日誌
196
+ const result = {
197
+ plan,
198
+ executedAt: Date.now(),
199
+ durationMs: Date.now() - startMs,
200
+ errors,
201
+ };
202
+ this.writeLog(result);
203
+ // 成功時廣播(executePlan 失敗已廣播過,這裡只廣播成功路徑)
204
+ if (errors.length === 0) {
205
+ const durationMin = result.durationMs < 60000 ? `${Math.round(result.durationMs / 1000)}秒` : `${Math.round(result.durationMs / 60000)}分`;
206
+ this._broadcast(`✅ Night Consolidation 完成|合併:${plan.mergedCount} 刪除:${plan.deletedCount} 更新:${plan.updatedCount} 保留:${plan.keptCount}|耗時:${durationMin}|摘要:${plan.summary}`);
207
+ }
208
+ console.log(`[NightConsolidation] Complete: merged=${plan.mergedCount} deleted=${plan.deletedCount} updated=${plan.updatedCount} kept=${plan.keptCount} (${result.durationMs}ms)`);
209
+ return result;
210
+ }
211
+ // ── LLM 決策引擎 ────────────────────────────────────────────────────────
212
+ async llmDecide(memories, runId, source) {
213
+ const BATCH_SIZE = 50;
214
+ let allDecisions = [];
215
+ let summaryParts = [];
216
+ for (let i = 0; i < memories.length; i += BATCH_SIZE) {
217
+ const batch = memories.slice(i, i + BATCH_SIZE);
218
+ const batchIndex = i / BATCH_SIZE + 1;
219
+ const batchStartedAt = Date.now();
220
+ this.recordStat({
221
+ runId,
222
+ phase: 'llm_batch_started',
223
+ ts: batchStartedAt,
224
+ batchIndex,
225
+ batchSize: batch.length,
226
+ metadata: this.statMetadata(source),
227
+ });
228
+ console.log(`[NightConsolidation] LLM analysis batch ${batchIndex} (${batch.length} records)`);
229
+ let decisions;
230
+ let summary;
231
+ try {
232
+ ({ decisions, summary } = await this._llmDecideBatch(batch));
233
+ this.recordStat({
234
+ runId,
235
+ phase: 'llm_batch_completed',
236
+ ts: Date.now(),
237
+ outcome: 'ok',
238
+ durationMs: Date.now() - batchStartedAt,
239
+ batchIndex,
240
+ batchSize: batch.length,
241
+ decisionCount: decisions.length,
242
+ metadata: this.statMetadata(source),
243
+ });
244
+ }
245
+ catch (err) {
246
+ this.recordStat({
247
+ runId,
248
+ phase: 'llm_batch_completed',
249
+ ts: Date.now(),
250
+ outcome: 'failed',
251
+ durationMs: Date.now() - batchStartedAt,
252
+ batchIndex,
253
+ batchSize: batch.length,
254
+ errorMessage: err?.message ?? String(err),
255
+ metadata: this.statMetadata(source),
256
+ });
257
+ throw err;
258
+ }
259
+ allDecisions = allDecisions.concat(decisions);
260
+ if (summary)
261
+ summaryParts.push(summary);
262
+ }
263
+ const mergedCount = allDecisions.filter(d => d.action === 'merge').length;
264
+ const deletedCount = allDecisions.filter(d => d.action === 'delete' || d.action === 'deprecated').length;
265
+ const updatedCount = allDecisions.filter(d => d.action === 'update').length;
266
+ const keptCount = allDecisions.filter(d => d.action === 'keep').length;
267
+ return {
268
+ decisions: allDecisions,
269
+ summary: summaryParts.join(' | ') || '無摘要',
270
+ processedCount: memories.length,
271
+ mergedCount,
272
+ deletedCount,
273
+ updatedCount,
274
+ keptCount,
275
+ };
276
+ }
277
+ async _llmDecideBatch(memories) {
278
+ // 建構 prompt
279
+ const memoryTextsArr = await Promise.all(memories.map(async (m, i) => {
280
+ const meta = this.parseMeta(m.metadata);
281
+ const text = String(m.text ?? '');
282
+ const confidence = m.confidence != null ? String(m.confidence) : 'N/A';
283
+ const status = String(meta.status ?? 'active');
284
+ const cat = String(m.category);
285
+ const imp = String(m.importance);
286
+ const sk = String(m.slotKey ?? '—');
287
+ const sv = String(m.slotValue ?? '—');
288
+ const createdAt = m.createdAt ? new Date(m.createdAt).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }) : 'N/A';
289
+ const updatedAt = m.updatedAt ? new Date(m.updatedAt).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }) : 'N/A';
290
+ // 檢查是否有被替代的舊記錄(supersedes chain)
291
+ let supersededTag = '';
292
+ if (sk && sk !== '—') {
293
+ const supersededBy = await this.checkSupersedes(sk, '');
294
+ if (supersededBy.length > 0) {
295
+ supersededTag = `\n ⚠️ [已被替代 by: ${supersededBy.map((id) => id.slice(0, 8)).join(', ')}]`;
296
+ }
297
+ }
298
+ return `[${i}] id=${m.id} createdAt=${createdAt} updatedAt=${updatedAt}\n category=${cat} confidence=${confidence} importance=${imp}\n slotKey=${sk} slotValue=${sv}\n status=${status}${supersededTag} text="${text.slice(0, 300)}${text.length > 300 ? '...' : ''}"`;
299
+ }));
300
+ const memoryTexts = memoryTextsArr.join('\n\n');
301
+ const prompt = `你是記憶品質把關引擎。今晚需要整理以下 ${memories.length} 筆記錄。
302
+
303
+ 【目標】
304
+ - 找出重覆/相似的記憶,建議合併
305
+ - 檢查 category 是否正確(類別說明:${Object.entries(CATEGORY_DESCRIPTIONS).map(([k, v]) => `${k}=${v}`).join(', ')})
306
+ - 評估 confidence(0.5-0.8 的記憶重新打分)
307
+ - 判定衝突記憶(同一件事有矛盾說法)
308
+ - 決定要 keep / merge / delete / update / deprecated
309
+
310
+ 【記憶清單】
311
+ ${memoryTexts}
312
+
313
+ 【決策規則】
314
+ - 同一 slotKey 的多筆記錄 → 合併(保留最完整的一筆,標記其他為 deprecated)
315
+ - 相似內文(語意重疊 > 50%)但不同 slotKey → 建議合併或刪除較差者
316
+ - category 明顯錯誤 → update 為正確類別
317
+ - confidence 0.5-0.8 → 重新評估(>=0.8 保留,<0.5 刪除)
318
+ - 衝突(矛盾說法)→ 保留較新且較有根據的,標記衝突的為 deprecated
319
+ - 純 free-text 無 slotKey → 保守保留,除非明顯重覆
320
+ - 新記憶(createdAt 接近)→ 優先保留新的
321
+ - deprecated → 標記為過期(status='deprecated'),內容保留但未來查詢會被排除(適用於重複、过时、冗餘的記憶)
322
+
323
+ 【時間邏輯】
324
+ - 同一 slotKey 的多筆記錄 → 合併(保留最完整且最新的)
325
+ - 較新的記憶(createdAt/updatedAt 更晚)→ 優先保留
326
+ - 衝突時 → 保留較新且有根據的版本
327
+
328
+ 【輸出格式】(pure JSON,無任何其他文字)
329
+ {
330
+ "decisions": [
331
+ {
332
+ "action": "merge|delete|update|keep|deprecated",
333
+ "memoryId": "<完整id>",
334
+ "reason": "...",
335
+ "mergeIntoId": "<完整id>"(僅 merge 時需要)
336
+ }
337
+ ],
338
+ "summary": "今晚整理摘要(50字內)"
339
+ }`;
340
+ // 透過注入的 LLM client 呼叫 MiniMax M2.7。
341
+ const response = await this.callAgent(prompt);
342
+ const content = this.extractJson(response);
343
+ if (!content) {
344
+ throw new Error(`LLM 回應解析失敗,回應內容: ${response.slice(0, 300)}`);
345
+ }
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(content);
349
+ }
350
+ catch {
351
+ throw new Error(`JSON 解析失敗: ${content.slice(0, 200)}`);
352
+ }
353
+ const candidateIds = new Set(memories.map(memory => memory.id));
354
+ const validActions = new Set(['merge', 'delete', 'update', 'keep', 'deprecated']);
355
+ const decisions = [];
356
+ for (const d of parsed.decisions ?? []) {
357
+ if (!validActions.has(d.action)) {
358
+ console.warn('[NightConsolidation] invalid LLM decision skipped: action');
359
+ continue;
360
+ }
361
+ if (typeof d.memoryId !== 'string' || !candidateIds.has(d.memoryId)) {
362
+ console.warn('[NightConsolidation] invalid LLM decision skipped: memoryId');
363
+ continue;
364
+ }
365
+ if (d.action === 'merge' && (typeof d.mergeIntoId !== 'string'
366
+ || !candidateIds.has(d.mergeIntoId)
367
+ || d.mergeIntoId === d.memoryId)) {
368
+ console.warn('[NightConsolidation] invalid LLM decision skipped: mergeIntoId');
369
+ continue;
370
+ }
371
+ if (d.newConfidence !== undefined && (typeof d.newConfidence !== 'number'
372
+ || !Number.isFinite(d.newConfidence)
373
+ || d.newConfidence < 0
374
+ || d.newConfidence > 1)) {
375
+ console.warn('[NightConsolidation] invalid LLM decision skipped: newConfidence');
376
+ continue;
377
+ }
378
+ decisions.push({
379
+ action: d.action,
380
+ memoryId: d.memoryId,
381
+ reason: d.reason ?? '',
382
+ mergeIntoId: d.mergeIntoId,
383
+ newCategory: d.newCategory,
384
+ newConfidence: d.newConfidence,
385
+ newSlotKey: d.newSlotKey,
386
+ newText: d.newText,
387
+ });
388
+ }
389
+ return {
390
+ decisions,
391
+ summary: parsed.summary ?? '',
392
+ };
393
+ }
394
+ async callAgent(prompt) {
395
+ if (!this._options.concentrator) {
396
+ console.warn('[NightConsolidation] ConcentratorAdapter unavailable; cannot invoke LLM');
397
+ throw new Error('No concentrator provided');
398
+ }
399
+ // 直接透過 ConcentratorAdapter 呼叫 LLM (會自己走 Gemini 或 MiniMax)
400
+ return await this._options.concentrator.generate(prompt, { purpose: 'night-consolidation' });
401
+ }
402
+ // ── 執行決策 ──────────────────────────────────────────────────────────────
403
+ async executePlan(plan, errors, runId, source) {
404
+ let attemptedCount = 0;
405
+ let failedCount = 0;
406
+ const initialErrorCount = errors.length;
407
+ const decisionsByAction = {
408
+ merge: plan.decisions.filter(d => d.action === 'merge'),
409
+ delete: plan.decisions.filter(d => d.action === 'delete'),
410
+ update: plan.decisions.filter(d => d.action === 'update'),
411
+ keep: plan.decisions.filter(d => d.action === 'keep'),
412
+ deprecated: plan.decisions.filter(d => d.action === 'deprecated'),
413
+ };
414
+ // ── P0-3: 收集 StatusChangeRequest,最後統一走 changeStatusBatch ──
415
+ const statusChangeReqs = [];
416
+ // Merge → 標記舊的為 deprecated,引用新的
417
+ for (const d of decisionsByAction.merge) {
418
+ const id = d.memoryId;
419
+ if (!id || !d.mergeIntoId)
420
+ continue;
421
+ try {
422
+ const meta = await this.store.getById(id, true);
423
+ const targetMeta = await this.store.getById(d.mergeIntoId, true);
424
+ // P1 Fix #4: 防止 LLM 幻覺,確保兩個 ID 都存在
425
+ if (!meta || !targetMeta)
426
+ continue;
427
+ statusChangeReqs.push({
428
+ memoryId: id,
429
+ toStatus: 'deprecated',
430
+ reason: 'night_consolidation',
431
+ source: 'night-consolidator.merge',
432
+ supersededBy: d.mergeIntoId,
433
+ meta: { consolidationReason: d.reason },
434
+ });
435
+ console.log(`[NightConsolidation] merge: ${id.slice(0, 8)} -> ${d.mergeIntoId?.slice(0, 8)}`);
436
+ }
437
+ catch (err) {
438
+ errors.push(`merge ${id.slice(0, 8)}: ${err.message}`);
439
+ }
440
+ }
441
+ // Delete → 軟刪除(status=trashed)
442
+ for (const d of decisionsByAction.delete) {
443
+ const id = d.memoryId;
444
+ if (!id)
445
+ continue;
446
+ try {
447
+ const meta = await this.store.getById(id, true);
448
+ if (!meta)
449
+ continue;
450
+ statusChangeReqs.push({
451
+ memoryId: id,
452
+ toStatus: 'trashed',
453
+ reason: 'night_consolidation',
454
+ source: 'night-consolidator.delete',
455
+ meta: { trashReason: d.reason },
456
+ });
457
+ console.log(`[NightConsolidation] delete: ${id.slice(0, 8)}`);
458
+ }
459
+ catch (err) {
460
+ errors.push(`delete ${id.slice(0, 8)}: ${err.message}`);
461
+ }
462
+ }
463
+ // Deprecated → 標記為 deprecated
464
+ for (const d of decisionsByAction.deprecated) {
465
+ const id = d.memoryId;
466
+ if (!id)
467
+ continue;
468
+ try {
469
+ const meta = await this.store.getById(id, true);
470
+ if (!meta)
471
+ continue;
472
+ statusChangeReqs.push({
473
+ memoryId: id,
474
+ toStatus: 'deprecated',
475
+ reason: 'night_consolidation',
476
+ source: 'night-consolidator.deprecated',
477
+ meta: { consolidationReason: d.reason },
478
+ });
479
+ console.log(`[NightConsolidation] deprecated: ${id.slice(0, 8)}`);
480
+ }
481
+ catch (err) {
482
+ errors.push(`deprecated ${id.slice(0, 8)}: ${err.message}`);
483
+ }
484
+ }
485
+ // P0-3: 統一執行 batch status change
486
+ if (statusChangeReqs.length > 0) {
487
+ attemptedCount += statusChangeReqs.length;
488
+ let batchResults;
489
+ try {
490
+ batchResults = await this._options.statusManager.changeStatusBatch(statusChangeReqs);
491
+ }
492
+ catch (err) {
493
+ failedCount += statusChangeReqs.length;
494
+ this.recordStat({
495
+ runId,
496
+ phase: 'execute_completed',
497
+ ts: Date.now(),
498
+ outcome: 'failed',
499
+ attemptedCount,
500
+ failedCount,
501
+ errorMessage: err?.message ?? String(err),
502
+ metadata: this.statMetadata(source, { errorsCount: errors.length - initialErrorCount }),
503
+ });
504
+ throw err;
505
+ }
506
+ for (const result of batchResults) {
507
+ if (!result.ok) {
508
+ failedCount++;
509
+ errors.push(`status_change ${result.memoryId.slice(0, 8)}: ${result.error}`);
510
+ }
511
+ }
512
+ }
513
+ // Update → 更新欄位
514
+ for (const d of decisionsByAction.update) {
515
+ const id = d.memoryId;
516
+ if (!id)
517
+ continue;
518
+ let updateAttempted = false;
519
+ try {
520
+ const updates = {};
521
+ if (d.newCategory)
522
+ updates.category = d.newCategory;
523
+ if (d.newConfidence !== undefined) {
524
+ const meta = await this.store.getById(id, true);
525
+ if (meta) {
526
+ const parsed = this.parseMeta(meta.metadata);
527
+ parsed.confidence = d.newConfidence;
528
+ updates.metadata = JSON.stringify(parsed);
529
+ }
530
+ }
531
+ if (d.newSlotKey)
532
+ updates.slotKey = d.newSlotKey;
533
+ if (d.newText)
534
+ updates.text = d.newText;
535
+ attemptedCount++;
536
+ updateAttempted = true;
537
+ await this.store.update(id, updates);
538
+ console.log(`[NightConsolidation] update: ${id.slice(0, 8)}`);
539
+ }
540
+ catch (err) {
541
+ if (updateAttempted)
542
+ failedCount++;
543
+ errors.push(`update ${id.slice(0, 8)}: ${err.message}`);
544
+ }
545
+ }
546
+ // Keep → 不做任何事(P1 Fix #6: 移除無限 +5 health boost,避免分數膨脹)
547
+ for (const d of decisionsByAction.keep) {
548
+ const id = d.memoryId;
549
+ if (!id)
550
+ continue;
551
+ // 僅維持原狀,記錄已成功跑過整理
552
+ try {
553
+ const meta = await this.store.getById(id, true);
554
+ if (!meta)
555
+ continue;
556
+ }
557
+ catch (err) {
558
+ errors.push(`keep ${id.slice(0, 8)}: ${err.message}`);
559
+ }
560
+ }
561
+ this.recordStat({
562
+ runId,
563
+ phase: 'execute_completed',
564
+ ts: Date.now(),
565
+ outcome: failedCount === 0 ? 'ok' : 'failed',
566
+ attemptedCount,
567
+ failedCount,
568
+ metadata: this.statMetadata(source, { errorsCount: errors.length - initialErrorCount }),
569
+ });
570
+ }
571
+ // ── 工具 ──────────────────────────────────────────────────────────────────
572
+ parseMeta(metadata) {
573
+ try {
574
+ return typeof metadata === 'string' ? JSON.parse(metadata) : (metadata ?? {});
575
+ }
576
+ catch {
577
+ return {};
578
+ }
579
+ }
580
+ /**
581
+ * checkSupersedes — 查詢同 slotKey 的所有 active 舊版本
582
+ * @returns 被取代的舊 entry id 清單
583
+ */
584
+ async checkSupersedes(slotKey, _newId) {
585
+ if (!slotKey)
586
+ return [];
587
+ try {
588
+ const existing = await this.store.searchBySlotKey(slotKey);
589
+ // 只回傳 status = 'active' 的舊版本(排除已 deprecated)
590
+ return existing
591
+ .filter(e => {
592
+ const meta = this.parseMeta(e.metadata);
593
+ return meta.status !== 'deprecated' && meta.status !== 'trashed';
594
+ })
595
+ .map(e => e.id);
596
+ }
597
+ catch (err) {
598
+ console.warn('[NightConsolidator] checkSupersedes failed:', err);
599
+ return [];
600
+ }
601
+ }
602
+ extractJson(text) {
603
+ // 去掉 markdown code block
604
+ let trimmed = text.trim();
605
+ if (trimmed.startsWith('```')) {
606
+ const lines = trimmed.split('\n');
607
+ trimmed = lines.slice(1, lines.length - 1).join('\n');
608
+ }
609
+ // 找第一個 { 到最後一個 }
610
+ const start = trimmed.indexOf('{');
611
+ const end = trimmed.lastIndexOf('}');
612
+ if (start !== -1 && end !== -1 && end > start) {
613
+ return trimmed.slice(start, end + 1);
614
+ }
615
+ return trimmed;
616
+ }
617
+ writeLog(result) {
618
+ try {
619
+ const dir = path.dirname(this.logPath);
620
+ fs.mkdirSync(dir, { recursive: true });
621
+ const line = JSON.stringify({
622
+ type: 'consolidation',
623
+ executedAt: result.executedAt,
624
+ durationMs: result.durationMs,
625
+ summary: result.plan.summary,
626
+ processedCount: result.plan.processedCount,
627
+ mergedCount: result.plan.mergedCount,
628
+ deletedCount: result.plan.deletedCount,
629
+ updatedCount: result.plan.updatedCount,
630
+ keptCount: result.plan.keptCount,
631
+ decisionCount: result.plan.decisions.length,
632
+ errors: result.errors,
633
+ }) + '\n';
634
+ fs.appendFileSync(this.logPath, line, 'utf-8');
635
+ }
636
+ catch (err) {
637
+ console.error('[NightConsolidation] Failed to write log:', err.message);
638
+ }
639
+ }
640
+ }
@@ -0,0 +1,40 @@
1
+ export declare const NIGHT_RECOVERY_THRESHOLD_MS: number;
2
+ export type NightRecoverySource = 'scheduled_timer' | 'health_check_recovery' | 'startup_recovery';
3
+ export type NightRecoverySkipReason = 'recent_run' | 'already_running';
4
+ export type NightRecoveryRunReason = 'stale_run' | 'no_success_record';
5
+ export type NightRecoveryDecision = {
6
+ shouldRun: boolean;
7
+ reason: NightRecoverySkipReason | NightRecoveryRunReason;
8
+ lastSuccessfulRunTs: number | null;
9
+ };
10
+ export type NightRecoveryStatInput = {
11
+ runId: string;
12
+ phase: string;
13
+ ts?: number;
14
+ outcome?: string | null;
15
+ metadata?: string | Record<string, unknown> | null;
16
+ };
17
+ export type NightRecoveryHealthCheckOptions = {
18
+ source: NightRecoverySource;
19
+ isRunning: () => boolean;
20
+ setRunning?: (running: boolean) => void;
21
+ getLastSuccessfulRunTs: () => Promise<number | null>;
22
+ recordStat: (stat: NightRecoveryStatInput) => void;
23
+ runNightConsolidation: (source: NightRecoverySource) => Promise<void>;
24
+ now?: () => number;
25
+ thresholdMs?: number;
26
+ runIdFactory?: () => string;
27
+ };
28
+ export declare function buildNightRecoveryMetadata(args: {
29
+ source: NightRecoverySource;
30
+ reason?: NightRecoverySkipReason;
31
+ lastSuccessfulRunTs?: number | null;
32
+ [key: string]: unknown;
33
+ }): string;
34
+ export declare function shouldRunNow(args: {
35
+ isRunning: boolean;
36
+ lastSuccessfulRunTs: number | null;
37
+ nowMs?: number;
38
+ thresholdMs?: number;
39
+ }): Promise<NightRecoveryDecision>;
40
+ export declare function healthCheck(options: NightRecoveryHealthCheckOptions): Promise<NightRecoveryDecision>;