@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
package/dist/engine.js ADDED
@@ -0,0 +1,2254 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { MemoryStore } from './store/store-v4.js';
5
+ import { Embedder } from './providers/embedder-v5.js';
6
+ import { Retriever } from './retrieval/retriever-v4.js';
7
+ import { CausalEngine } from './cognition/causal-engine.js';
8
+ import { CausalAttributionEngine } from './cognition/causal-attribution.js';
9
+ import { HooksEngine } from './cognition/hooks-engine.js';
10
+ import { GraphStore } from './store/graph-store.js';
11
+ import { GraphEnumerator } from './store/graph-enumerator.js';
12
+ import { InboxWatcher } from './pipeline/inbox-watcher.js';
13
+ import { ConflictDetector } from './cognition/conflict-detector.js';
14
+ import { StatusManager } from './store/status-manager.js';
15
+ import { CapsuleBridge } from './pipeline/capsule-bridge.js';
16
+ import { ConcentratorAdapter } from './distill/concentrator-adapter.js';
17
+ import { GlobalWorkingMemory } from './cognition/global-working-memory.js';
18
+ import { CleanupEngine } from './lifecycle/cleanup-engine.js';
19
+ import { chooseStartupRecoveryMode, readCleanupState, shouldRunStartupRecovery, writeCleanupState, } from './lifecycle/cleanup-state.js';
20
+ import { NightConsolidator } from './lifecycle/night-consolidation.js';
21
+ import { setBoundedMapEntry } from './util/bounded-map.js';
22
+ // ── Ralph Loop 核心組件 (搬移至 src 並轉為 .ts 後的引用路徑) ──────────────────
23
+ import { trimTailErrors, generateWarning, extractGoalFromMsgs, RalphState } from './cognition/ralph-core.js';
24
+ import { validateSkillDef } from './skills/validate.js';
25
+ import { resolveSessionIdentity, resolveSessionIdentityFromArgs, setFallbackObserver, GLOBAL_FALLBACK_KEY, } from './util/session-identity.js';
26
+ import { writeCompactRequest, } from './pipeline/compact-request.js';
27
+ import { buildNightRecoveryMetadata, healthCheck as runNightRecoveryHealthCheck, } from './lifecycle/night-recovery.js';
28
+ import { hashQuery } from './util/util-hash.js';
29
+ const SESSION_WATERMARK_MAX = 500;
30
+ const WATERMARK_INTERVAL = 20000;
31
+ const RECENT_COMPACT_TTL_MS = 60_000;
32
+ const NIGHT_HEALTH_CHECK_INTERVAL_MS = 30 * 60 * 1000;
33
+ const NIGHT_STARTUP_RECOVERY_DELAY_MS = 5000;
34
+ const PENDING_ATTRIBUTION_MAX_KEYS = 100;
35
+ const PENDING_ATTRIBUTION_TTL_MS = 30 * 60 * 1000;
36
+ export class MemoryRiverEngine {
37
+ config;
38
+ deps;
39
+ constructor(config, deps) {
40
+ this.config = config;
41
+ this.deps = deps;
42
+ this.activePluginConfig = config;
43
+ }
44
+ configure(config, deps) {
45
+ this.config = config;
46
+ this.deps = deps;
47
+ this.activePluginConfig = config;
48
+ this.isAutoRecallEnabled = config.autoRecall;
49
+ if (!this.embedderRef) {
50
+ this.embedderRef = deps.embedder
51
+ ? deps.embedder
52
+ : new Embedder({
53
+ ...config.embedding,
54
+ ollamaUrl: deps.ollamaUrl,
55
+ });
56
+ }
57
+ const embedder = this.embedderRef;
58
+ if (!this.memoryStoreRef) {
59
+ this.memoryStoreRef = new MemoryStore(config.dbPath, config.ramDbPath, config.embedding.dimensions, deps.paths.walFile, config.health, embedder);
60
+ }
61
+ const store = this.memoryStoreRef;
62
+ if (!this.statusManagerRef)
63
+ this.statusManagerRef = new StatusManager(store);
64
+ if (!this.fallbackObserverRegistered) {
65
+ setFallbackObserver(() => {
66
+ if (!this.memoryStoreRef)
67
+ return;
68
+ void this.memoryStoreRef.recordConcentratorStat({
69
+ canonicalKey: GLOBAL_FALLBACK_KEY,
70
+ sessionId: null,
71
+ provider: 'all_failed',
72
+ outcome: 'failure',
73
+ attemptedProviders: '[]',
74
+ inputTokens: 0,
75
+ outputTokens: null,
76
+ durationMs: 0,
77
+ failureReason: 'other',
78
+ createdAt: Date.now(),
79
+ }).catch((err) => {
80
+ console.warn('[memory-river] Failed to write session_identity_fallback stat:', err?.message ?? err);
81
+ });
82
+ });
83
+ this.fallbackObserverRegistered = true;
84
+ }
85
+ if (!this.activeConcentrator) {
86
+ this.activeConcentrator = new ConcentratorAdapter({
87
+ apiKey: config.concentration?.geminiApiKey || config.embedding.apiKey || deps.geminiApiKey,
88
+ model: config.concentration?.model || 'gemini-2.5-flash-lite',
89
+ inboxPath: config.inboxPath,
90
+ provider: config.concentration?.provider || 'gemini',
91
+ maxTokens: config.concentration?.maxTokens ?? 8192,
92
+ deepseekApiKey: config.concentration?.deepseekApiKey || deps.deepseekApiKey,
93
+ deepseekModel: config.concentration?.deepseekModel || 'deepseek-v4-flash',
94
+ statsStore: store,
95
+ transcriptArchive: deps.transcriptArchive,
96
+ sessionSummaryDir: deps.paths.sessionSummaryDir,
97
+ llm: deps.llm,
98
+ });
99
+ }
100
+ if (!this.causalEngineRef)
101
+ this.causalEngineRef = new CausalEngine(store, embedder, config.causalEngine);
102
+ if (!this.conflictDetectorRef) {
103
+ this.conflictDetectorRef = new ConflictDetector(store, embedder, this.activeConcentrator, this.statusManagerRef);
104
+ }
105
+ if (!this.inboxWatcherRef) {
106
+ this.inboxWatcherRef = new InboxWatcher(store, embedder, this.causalEngineRef, null, null, this.activeConcentrator, config.inboxPath, 2000, this.conflictDetectorRef, this.statusManagerRef, (req) => this.processAsyncCompactRequest(req));
107
+ }
108
+ if (!this.capsuleBridgeRef)
109
+ this.capsuleBridgeRef = new CapsuleBridge(config.inboxPath);
110
+ if (!this.gwmRef)
111
+ this.gwmRef = new GlobalWorkingMemory(embedder, deps.paths.gwmStateFile, config.driftThreshold);
112
+ if (!this.cleanupEngineRef) {
113
+ this.cleanupEngineRef = new CleanupEngine(store, {
114
+ enabled: config.cleanupEngine?.enabled ?? config.cleanup?.enabled ?? true,
115
+ decayDays: config.cleanupEngine?.decayDays ?? config.cleanup?.decayDays ?? 7,
116
+ deleteBelow: config.cleanupEngine?.deleteBelow ?? config.cleanup?.deleteBelow ?? 10,
117
+ coreCategories: config.cleanupEngine?.coreCategories ?? config.cleanupEngine.coreCategories,
118
+ coreImportanceThreshold: config.cleanupEngine?.coreImportanceThreshold ?? config.cleanupEngine.coreImportanceThreshold,
119
+ skillCapsuleProtection: config.cleanupEngine?.skillCapsuleProtection ?? config.cleanupEngine.skillCapsuleProtection,
120
+ useTrash: config.cleanupEngine?.useTrash ?? config.cleanupEngine.useTrash,
121
+ dryRun: config.cleanupEngine?.dryRun ?? config.cleanupEngine.dryRun,
122
+ trashPath: deps.paths.trashDir,
123
+ trashRetentionDays: config.cleanupEngine?.trashRetentionDays ?? config.cleanup?.trashRetentionDays ?? 7,
124
+ enableTrashAutoPurge: true,
125
+ });
126
+ }
127
+ if (!this.cleanupEngineInstanceRegistered) {
128
+ CleanupEngine.setInstance(this.cleanupEngineRef);
129
+ this.cleanupEngineInstanceRegistered = true;
130
+ }
131
+ }
132
+ activeConcentrator = null;
133
+ retrieverRef = null;
134
+ isAutoRecallEnabled = false;
135
+ gwmRef = null;
136
+ cleanupEngineRef = null;
137
+ nightConsolidatorRef = null;
138
+ memoryStoreRef = null;
139
+ embedderRef = null;
140
+ hooksEngineRef = null;
141
+ graphStoreRef = null;
142
+ inboxWatcherRef = null;
143
+ causalEngineRef = null;
144
+ conflictDetectorRef = null;
145
+ statusManagerRef = null;
146
+ capsuleBridgeRef = null;
147
+ pluginInitPromise = null;
148
+ pluginInitialized = false;
149
+ pluginInitError = null;
150
+ fallbackObserverRegistered = false;
151
+ cleanupEngineInstanceRegistered = false;
152
+ nightTimerId = null;
153
+ nightIntervalId = null;
154
+ nightHealthCheckIntervalId = null;
155
+ nightStartupRecoveryTimerId = null;
156
+ nightConsolidatorIsRunning = false;
157
+ lastNightConsolidatorSuccessfulRunAt = 0;
158
+ cleanupTimeoutId = null;
159
+ cleanupIntervalId = null;
160
+ lastCleanupSuccessfulRunAt = 0;
161
+ activePluginConfig;
162
+ initFailedResult(err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ return {
165
+ content: [{ type: 'text', text: `❌ MEMORY_RIVER_INIT_FAILED: ${message}` }],
166
+ isError: true,
167
+ };
168
+ }
169
+ async recordPluginInitSmokeStat(store, outcome, err) {
170
+ try {
171
+ const recordSubsystemEffectiveness = store?.recordSubsystemEffectiveness;
172
+ if (typeof recordSubsystemEffectiveness !== 'function')
173
+ return;
174
+ const errorMessage = err instanceof Error ? err.message : err ? String(err) : '';
175
+ await recordSubsystemEffectiveness.call(store, {
176
+ subsystem: 'plugin',
177
+ event: 'init_completed',
178
+ outcome,
179
+ metadata: outcome === 'failed' ? { error: errorMessage } : {},
180
+ });
181
+ }
182
+ catch (statErr) {
183
+ console.warn('[memory-river] Failed to write plugin init smoke stat:', statErr?.message ?? statErr);
184
+ }
185
+ }
186
+ sessionTokenWatermark = new Map();
187
+ sessionCompactWatermark = new Map();
188
+ sessionFileMappings = new Map();
189
+ recentlyCompacted = new Map();
190
+ lastArchivedLineCount = new Map();
191
+ maintainLocks = new Map();
192
+ skillLocks = new Map();
193
+ queuedAsyncCompactKeys = new Set();
194
+ runningAsyncCompactKeys = new Set();
195
+ pendingAttribution = new Map();
196
+ recentLlmIdentityByKey = new Map();
197
+ gwmSessionState = new Map();
198
+ lastObservedSessionIdentity = null;
199
+ pickNonEmptyString(value) {
200
+ if (typeof value !== 'string')
201
+ return null;
202
+ const trimmed = value.trim();
203
+ return trimmed.length > 0 ? trimmed : null;
204
+ }
205
+ addAttributionKey(keys, prefix, value) {
206
+ const str = this.pickNonEmptyString(value);
207
+ if (str)
208
+ keys.add(`${prefix}:${str}`);
209
+ }
210
+ extractAttributionRequestKeyFromArgs(...args) {
211
+ for (const arg of args) {
212
+ if (!arg || typeof arg !== 'object')
213
+ continue;
214
+ const obj = arg;
215
+ const requestId = this.pickNonEmptyString(obj.requestId);
216
+ if (requestId)
217
+ return `request:${requestId}`;
218
+ const runId = this.pickNonEmptyString(obj.runId);
219
+ if (runId)
220
+ return `run:${runId}`;
221
+ }
222
+ return null;
223
+ }
224
+ extractAutoRecallResultsObserverFromArgs(...args) {
225
+ for (const arg of args) {
226
+ if (!arg || typeof arg !== 'object')
227
+ continue;
228
+ const candidate = arg.onAutoRecallResults;
229
+ if (typeof candidate === 'function') {
230
+ return candidate;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ attributionKeysFromIdentity(identity, requestKey) {
236
+ const keys = new Set();
237
+ if (requestKey)
238
+ keys.add(requestKey);
239
+ this.addAttributionKey(keys, 'canonical', identity.canonicalKey);
240
+ this.addAttributionKey(keys, 'sessionKey', identity.sessionKey);
241
+ this.addAttributionKey(keys, 'sessionId', identity.sessionId);
242
+ return [...keys];
243
+ }
244
+ attributionKeysFromLlmOutput(event, ctx) {
245
+ const keys = new Set();
246
+ this.addAttributionKey(keys, 'request', event?.requestId ?? ctx?.requestId);
247
+ this.addAttributionKey(keys, 'run', event?.runId ?? ctx?.runId);
248
+ this.addAttributionKey(keys, 'sessionId', event?.sessionId ?? ctx?.sessionId);
249
+ this.addAttributionKey(keys, 'sessionKey', event?.sessionKey ?? ctx?.sessionKey);
250
+ this.addAttributionKey(keys, 'canonical', ctx?.sessionKey ?? event?.sessionKey ?? ctx?.sessionId ?? event?.sessionId);
251
+ return [...keys];
252
+ }
253
+ getRecentLlmIdentityForKeys(keys) {
254
+ for (const key of keys) {
255
+ const identity = this.recentLlmIdentityByKey.get(key);
256
+ if (identity)
257
+ return identity;
258
+ }
259
+ return null;
260
+ }
261
+ getOrCreateGwmSessionState(sessionKey) {
262
+ let state = this.gwmSessionState.get(sessionKey);
263
+ if (!state) {
264
+ state = {
265
+ injectionCount: 0,
266
+ turnIndex: 0,
267
+ lastInjectionTurnIndex: null,
268
+ pendingEpisode: null,
269
+ };
270
+ this.gwmSessionState.set(sessionKey, state);
271
+ }
272
+ return state;
273
+ }
274
+ cleanupPendingAttribution(now = Date.now()) {
275
+ for (const record of new Set(this.pendingAttribution.values())) {
276
+ if (now - record.createdAt <= PENDING_ATTRIBUTION_TTL_MS)
277
+ continue;
278
+ for (const key of record.keys)
279
+ this.pendingAttribution.delete(key);
280
+ }
281
+ while (this.pendingAttribution.size > PENDING_ATTRIBUTION_MAX_KEYS) {
282
+ const oldest = this.pendingAttribution.values().next().value;
283
+ if (!oldest)
284
+ break;
285
+ for (const key of oldest.keys)
286
+ this.pendingAttribution.delete(key);
287
+ }
288
+ }
289
+ rememberInjectedMemories(identity, injected, requestKey) {
290
+ if (injected.length === 0)
291
+ return;
292
+ this.cleanupPendingAttribution();
293
+ const keys = this.attributionKeysFromIdentity(identity, requestKey);
294
+ if (keys.length === 0)
295
+ return;
296
+ const record = {
297
+ injected,
298
+ keys,
299
+ createdAt: Date.now(),
300
+ };
301
+ for (const key of keys)
302
+ this.pendingAttribution.set(key, record);
303
+ this.cleanupPendingAttribution();
304
+ }
305
+ takePendingAttribution(keys) {
306
+ this.cleanupPendingAttribution();
307
+ for (const key of keys) {
308
+ const record = this.pendingAttribution.get(key);
309
+ if (!record)
310
+ continue;
311
+ for (const alias of record.keys)
312
+ this.pendingAttribution.delete(alias);
313
+ return record.injected;
314
+ }
315
+ return [];
316
+ }
317
+ getAsyncCompactConcurrency() {
318
+ return Math.max(1, this.activePluginConfig.concentration?.asyncCompactConcurrency ?? 1);
319
+ }
320
+ async countFileLines(filePath) {
321
+ const raw = await fs.promises.readFile(filePath, 'utf-8');
322
+ if (!raw.trim())
323
+ return 0;
324
+ return raw.trim().split('\n').filter(Boolean).length;
325
+ }
326
+ enqueueAsyncCompact(req) {
327
+ if (this.queuedAsyncCompactKeys.has(req.trackingKey) || this.runningAsyncCompactKeys.has(req.trackingKey)) {
328
+ console.log(`[asyncCompact] skipped duplicate trackingKey=${req.trackingKey}`);
329
+ return;
330
+ }
331
+ this.queuedAsyncCompactKeys.add(req.trackingKey);
332
+ const item = {
333
+ type: 'compact_request',
334
+ version: 1,
335
+ requestId: randomUUID(),
336
+ trackingKey: req.trackingKey,
337
+ sessionId: req.sessionId,
338
+ sessionKey: req.sessionKey,
339
+ originalTokens: req.originalTokens,
340
+ compressedTokens: req.compressedTokens,
341
+ createdAt: req.timestamp,
342
+ source: 'asyncCompactAfterAssemble',
343
+ };
344
+ const inboxPath = this.activePluginConfig?.inboxPath;
345
+ if (!inboxPath) {
346
+ console.warn(`[asyncCompact] no inboxPath configured, dropping trackingKey=${req.trackingKey}`);
347
+ this.queuedAsyncCompactKeys.delete(req.trackingKey);
348
+ return;
349
+ }
350
+ const t0 = Date.now();
351
+ void writeCompactRequest(inboxPath, item)
352
+ .then((filePath) => {
353
+ console.log(`[asyncCompact] persisted trackingKey=${req.trackingKey} requestId=${item.requestId} writeMs=${Date.now() - t0} path=${filePath}`);
354
+ })
355
+ .catch((err) => {
356
+ console.error(`[asyncCompact] failed to persist trackingKey=${req.trackingKey}:`, err);
357
+ this.queuedAsyncCompactKeys.delete(req.trackingKey);
358
+ });
359
+ }
360
+ async processAsyncCompactRequest(req) {
361
+ this.runningAsyncCompactKeys.add(req.trackingKey);
362
+ try {
363
+ const resolved = this.resolveSessionFile({
364
+ sessionKey: req.sessionKey,
365
+ sessionId: req.sessionId,
366
+ });
367
+ if (!resolved.sessionFile) {
368
+ console.warn(`[asyncCompact] no sessionFile, skip trackingKey=${req.trackingKey}`);
369
+ return;
370
+ }
371
+ console.log(`[asyncCompact] processing trackingKey=${req.trackingKey}`);
372
+ const statBefore = await fs.promises.stat(resolved.sessionFile);
373
+ const lineCountBefore = await this.countFileLines(resolved.sessionFile);
374
+ const compactResult = await this.compact({
375
+ sessionId: req.sessionId,
376
+ sessionKey: req.sessionKey,
377
+ sessionFile: resolved.sessionFile,
378
+ expectedLineCount: lineCountBefore,
379
+ expectedSize: statBefore.size,
380
+ expectedMtime: statBefore.mtimeMs,
381
+ source: 'asyncCompactAfterAssemble',
382
+ force: true,
383
+ });
384
+ if (compactResult?.aborted) {
385
+ console.warn(`[asyncCompact] race detected trackingKey=${req.trackingKey} reason=${compactResult.reason ?? 'unknown'}`);
386
+ return;
387
+ }
388
+ if (!compactResult?.ok || !compactResult?.compacted) {
389
+ console.warn(`[asyncCompact] failed trackingKey=${req.trackingKey} error=${compactResult?.reason ?? 'compact-returned-no-result'}`);
390
+ return;
391
+ }
392
+ const statAfter = await fs.promises.stat(resolved.sessionFile);
393
+ const lineCountAfter = await this.countFileLines(resolved.sessionFile);
394
+ console.log(`[asyncCompact] success trackingKey=${req.trackingKey} compressedTokens=${req.compressedTokens} newSessionFileLines=${lineCountAfter} size=${statAfter.size}`);
395
+ }
396
+ finally {
397
+ this.queuedAsyncCompactKeys.delete(req.trackingKey);
398
+ this.runningAsyncCompactKeys.delete(req.trackingKey);
399
+ }
400
+ }
401
+ get asyncCompactTestHooks() {
402
+ return {
403
+ enqueue: (req) => this.enqueueAsyncCompact(req),
404
+ reset: () => {
405
+ this.queuedAsyncCompactKeys.clear();
406
+ this.runningAsyncCompactKeys.clear();
407
+ this.activePluginConfig = this.config;
408
+ },
409
+ setInboxPath: (inboxPath) => {
410
+ this.activePluginConfig = {
411
+ ...this.activePluginConfig,
412
+ inboxPath,
413
+ };
414
+ },
415
+ isQueued: (trackingKey) => this.queuedAsyncCompactKeys.has(trackingKey),
416
+ };
417
+ }
418
+ setWatermark(canonicalKey, value) {
419
+ setBoundedMapEntry(this.sessionTokenWatermark, canonicalKey, value, SESSION_WATERMARK_MAX);
420
+ }
421
+ setCompactWatermark(canonicalKey, value) {
422
+ setBoundedMapEntry(this.sessionCompactWatermark, canonicalKey, value, SESSION_WATERMARK_MAX);
423
+ }
424
+ setSessionFileMapping(canonicalKey, record) {
425
+ setBoundedMapEntry(this.sessionFileMappings, canonicalKey, record, SESSION_WATERMARK_MAX);
426
+ }
427
+ setArchivedLineCount(key, sessionId, lineCount) {
428
+ setBoundedMapEntry(this.lastArchivedLineCount, key, { sessionId, lineCount }, SESSION_WATERMARK_MAX);
429
+ }
430
+ async resolveArchivedLineCount(store, canonicalKey, sessionId) {
431
+ const cached = this.lastArchivedLineCount.get(canonicalKey);
432
+ if (cached !== undefined && cached.sessionId === sessionId)
433
+ return cached;
434
+ if (!store)
435
+ return { sessionId: null, lineCount: 0 };
436
+ try {
437
+ const row = await store.getTranscriptWatermark(canonicalKey);
438
+ if (!row)
439
+ return { sessionId: null, lineCount: 0 };
440
+ this.setArchivedLineCount(canonicalKey, row.sessionId, row.lineCount);
441
+ return { sessionId: row.sessionId, lineCount: row.lineCount };
442
+ }
443
+ catch (err) {
444
+ console.warn(`[memory-river] Failed to read transcript watermark canonicalKey=${canonicalKey}:`, err);
445
+ return { sessionId: null, lineCount: 0 };
446
+ }
447
+ }
448
+ async persistArchivedLineCount(store, canonicalKey, sessionId, lineCount) {
449
+ if (store) {
450
+ try {
451
+ await store.setTranscriptWatermark(canonicalKey, sessionId, lineCount);
452
+ }
453
+ catch (err) {
454
+ console.warn(`[memory-river] Failed to write transcript watermark canonicalKey=${canonicalKey}:`, err);
455
+ }
456
+ }
457
+ this.setArchivedLineCount(canonicalKey, sessionId, lineCount);
458
+ }
459
+ markRecentlyCompacted(canonicalKey) {
460
+ const now = Date.now();
461
+ for (const [key, timestamp] of this.recentlyCompacted) {
462
+ if (now - timestamp > RECENT_COMPACT_TTL_MS) {
463
+ this.recentlyCompacted.delete(key);
464
+ }
465
+ }
466
+ if (this.recentlyCompacted.size >= SESSION_WATERMARK_MAX && !this.recentlyCompacted.has(canonicalKey)) {
467
+ const oldest = this.recentlyCompacted.keys().next().value;
468
+ if (oldest !== undefined)
469
+ this.recentlyCompacted.delete(oldest);
470
+ }
471
+ this.recentlyCompacted.set(canonicalKey, now);
472
+ }
473
+ wasRecentlyCompacted(canonicalKey) {
474
+ const timestamp = this.recentlyCompacted.get(canonicalKey);
475
+ if (!timestamp)
476
+ return false;
477
+ if (Date.now() - timestamp > RECENT_COMPACT_TTL_MS) {
478
+ this.recentlyCompacted.delete(canonicalKey);
479
+ return false;
480
+ }
481
+ return true;
482
+ }
483
+ deriveSessionFileFromStaticRule(args) {
484
+ return this.deps.deriveSessionFile(args);
485
+ }
486
+ resolveSessionFile(args) {
487
+ const identity = resolveSessionIdentity(args);
488
+ const cached = this.sessionFileMappings.get(identity.canonicalKey);
489
+ if (cached?.sessionFile) {
490
+ return { sessionFile: cached.sessionFile, source: 'cache' };
491
+ }
492
+ const fallbackPath = this.deriveSessionFileFromStaticRule({
493
+ sessionId: identity.sessionId ?? undefined,
494
+ sessionKey: identity.sessionKey ?? undefined,
495
+ });
496
+ if (fallbackPath) {
497
+ return { sessionFile: fallbackPath, source: 'fallback' };
498
+ }
499
+ return { sessionFile: null, source: 'none' };
500
+ }
501
+ init() {
502
+ if (this.pluginInitPromise) {
503
+ if (this.pluginInitialized && this.nightConsolidatorRef && !this.nightTimerId && !this.nightIntervalId) {
504
+ this.scheduleNightConsolidator();
505
+ }
506
+ if (this.pluginInitialized && this.cleanupEngineRef && !this.cleanupTimeoutId && !this.cleanupIntervalId) {
507
+ this.scheduleCleanupEngine();
508
+ }
509
+ return this.pluginInitPromise;
510
+ }
511
+ this.pluginInitPromise = (async () => {
512
+ if (!this.memoryStoreRef || !this.embedderRef || !this.activeConcentrator) {
513
+ throw new Error('memory-river 初始化前置依賴缺失');
514
+ }
515
+ await this.memoryStoreRef.ensureInitialized();
516
+ this.graphStoreRef = new GraphStore(this.memoryStoreRef.db, this.memoryStoreRef.ssd, this.embedderRef, this.config.embedding.dimensions);
517
+ this.hooksEngineRef = new HooksEngine(this.memoryStoreRef, this.embedderRef, this.config.hooks ?? {}, this.activeConcentrator, this.memoryStoreRef.db);
518
+ this.hooksEngineRef.setGraphStore(this.graphStoreRef);
519
+ await this.hooksEngineRef.loadStats();
520
+ this.retrieverRef = new Retriever(this.memoryStoreRef, this.embedderRef, this.config.retrieval, this.deps.paths.rerankerCacheDir, this.hooksEngineRef);
521
+ if (this.inboxWatcherRef && this.hooksEngineRef && this.graphStoreRef) {
522
+ this.inboxWatcherRef.setDependencies(this.hooksEngineRef, this.graphStoreRef);
523
+ }
524
+ if (this.gwmRef) {
525
+ await this.gwmRef.load();
526
+ }
527
+ if (!this.nightConsolidatorRef) {
528
+ this.nightConsolidatorRef = new NightConsolidator(this.memoryStoreRef, {
529
+ concentrator: this.activeConcentrator,
530
+ statusManager: this.statusManagerRef,
531
+ notifier: this.deps.notifier,
532
+ }, this.deps.paths.consolidationLog);
533
+ }
534
+ this.scheduleNightConsolidator();
535
+ this.scheduleCleanupEngine();
536
+ this.pluginInitialized = true;
537
+ this.pluginInitError = null;
538
+ })().catch((err) => {
539
+ this.pluginInitPromise = null;
540
+ this.pluginInitialized = false;
541
+ this.pluginInitError = err;
542
+ this.retrieverRef = null;
543
+ this.hooksEngineRef = null;
544
+ this.graphStoreRef = null;
545
+ console.error('[memory-river] Initialization failed (partial state cleared):', err);
546
+ throw err;
547
+ });
548
+ return this.pluginInitPromise;
549
+ }
550
+ clearNightConsolidatorTimers() {
551
+ if (this.nightTimerId) {
552
+ clearTimeout(this.nightTimerId);
553
+ this.nightTimerId = null;
554
+ }
555
+ if (this.nightIntervalId) {
556
+ clearInterval(this.nightIntervalId);
557
+ this.nightIntervalId = null;
558
+ }
559
+ if (this.nightHealthCheckIntervalId) {
560
+ clearInterval(this.nightHealthCheckIntervalId);
561
+ this.nightHealthCheckIntervalId = null;
562
+ }
563
+ if (this.nightStartupRecoveryTimerId) {
564
+ clearTimeout(this.nightStartupRecoveryTimerId);
565
+ this.nightStartupRecoveryTimerId = null;
566
+ }
567
+ }
568
+ recordNightConsolidationStat(stat) {
569
+ if (!this.memoryStoreRef)
570
+ return;
571
+ void this.memoryStoreRef.recordNightConsolidationStat(stat).catch((err) => {
572
+ console.warn('[NightConsolidator] stats write failed:', err?.message ?? err);
573
+ });
574
+ }
575
+ async getLastSuccessfulNightRunTs() {
576
+ if (!this.memoryStoreRef?.db)
577
+ return null;
578
+ try {
579
+ if (typeof this.memoryStoreRef.db.tableNames === 'function') {
580
+ const tableNames = await this.memoryStoreRef.db.tableNames();
581
+ if (!tableNames.includes('night_consolidation_stats'))
582
+ return null;
583
+ }
584
+ const table = await this.memoryStoreRef.db.openTable('night_consolidation_stats');
585
+ const rows = await table
586
+ .query()
587
+ .where("phase = 'run_completed' AND outcome = 'ok'")
588
+ .limit(1000)
589
+ .toArray();
590
+ let latest = null;
591
+ for (const row of rows) {
592
+ const ts = Number(row?.ts);
593
+ if (Number.isFinite(ts) && (latest === null || ts > latest))
594
+ latest = ts;
595
+ }
596
+ return latest;
597
+ }
598
+ catch (err) {
599
+ console.warn('[NightConsolidator] last-success lookup failed:', err?.message ?? err);
600
+ return null;
601
+ }
602
+ }
603
+ async runNightConsolidatorNow(runId = randomUUID(), scheduledFor, source = 'scheduled_timer') {
604
+ if (!this.nightConsolidatorRef)
605
+ return;
606
+ const now = Date.now();
607
+ if (this.lastNightConsolidatorSuccessfulRunAt > 0 && now - this.lastNightConsolidatorSuccessfulRunAt > 48 * 60 * 60 * 1000) {
608
+ console.warn(`[NightConsolidator] warning: last successful run was ${new Date(this.lastNightConsolidatorSuccessfulRunAt).toISOString()}, over 48h ago`);
609
+ }
610
+ const startedAt = Date.now();
611
+ this.recordNightConsolidationStat({
612
+ runId,
613
+ phase: 'run_started',
614
+ ts: startedAt,
615
+ scheduledFor: scheduledFor ?? null,
616
+ metadata: buildNightRecoveryMetadata({ source }),
617
+ });
618
+ console.log('[NightConsolidator] run started');
619
+ try {
620
+ const result = await this.nightConsolidatorRef.consolidateToday(runId, source);
621
+ this.lastNightConsolidatorSuccessfulRunAt = Date.now();
622
+ this.recordNightConsolidationStat({
623
+ runId,
624
+ phase: 'run_completed',
625
+ ts: Date.now(),
626
+ outcome: result.errors.length === 0 ? 'ok' : 'failed',
627
+ durationMs: Date.now() - startedAt,
628
+ decisionCount: result.plan.decisions.length,
629
+ mergeCount: result.plan.mergedCount,
630
+ deleteCount: result.plan.decisions.filter(d => d.action === 'delete').length,
631
+ deprecatedCount: result.plan.decisions.filter(d => d.action === 'deprecated').length,
632
+ updateCount: result.plan.updatedCount,
633
+ keepCount: result.plan.keptCount,
634
+ candidateCount: result.plan.processedCount,
635
+ metadata: buildNightRecoveryMetadata({ source, errorsCount: result.errors.length }),
636
+ });
637
+ console.log(`[NightConsolidator] run completed processed=${result.plan.processedCount}`);
638
+ }
639
+ catch (err) {
640
+ this.recordNightConsolidationStat({
641
+ runId,
642
+ phase: 'run_failed',
643
+ ts: Date.now(),
644
+ outcome: 'failed',
645
+ durationMs: Date.now() - startedAt,
646
+ scheduledFor: scheduledFor ?? null,
647
+ errorMessage: err?.message ?? String(err),
648
+ metadata: buildNightRecoveryMetadata({ source }),
649
+ });
650
+ console.error('[NightConsolidator] run failed:', err);
651
+ if (err?.stack) {
652
+ console.error(err.stack);
653
+ }
654
+ }
655
+ }
656
+ async tryRunNightConsolidator(source, runId = randomUUID(), scheduledFor) {
657
+ await runNightRecoveryHealthCheck({
658
+ source,
659
+ isRunning: () => this.nightConsolidatorIsRunning,
660
+ setRunning: (running) => {
661
+ this.nightConsolidatorIsRunning = running;
662
+ },
663
+ getLastSuccessfulRunTs: () => this.getLastSuccessfulNightRunTs(),
664
+ recordStat: (stat) => this.recordNightConsolidationStat(stat),
665
+ runNightConsolidation: async (runSource) => {
666
+ await this.runNightConsolidatorNow(runId, scheduledFor, runSource);
667
+ },
668
+ });
669
+ }
670
+ scheduleNightConsolidator() {
671
+ // Benchmark confirmatory A/B (MR_OTTER_READONLY=1) freezes the river during QA so each question
672
+ // is an independent probe. Night consolidation is a timer-scheduled writer that can fire mid-run
673
+ // and mutate memory content/health -> cross-question contamination. Gate it (no config flag for
674
+ // it exists). Production untouched (flag default off).
675
+ if (process.env.MR_OTTER_READONLY === '1')
676
+ return;
677
+ if (!this.nightConsolidatorRef)
678
+ return;
679
+ this.clearNightConsolidatorTimers();
680
+ const now = new Date();
681
+ const nextRun = new Date(now);
682
+ nextRun.setHours(3, 0, 0, 0);
683
+ if (nextRun.getTime() <= now.getTime())
684
+ nextRun.setDate(nextRun.getDate() + 1);
685
+ const scheduledFor = nextRun.getTime();
686
+ const scheduledRunId = randomUUID();
687
+ const msUntilFirstRun = nextRun.getTime() - now.getTime();
688
+ this.recordNightConsolidationStat({
689
+ runId: scheduledRunId,
690
+ phase: 'schedule_created',
691
+ ts: Date.now(),
692
+ scheduledFor,
693
+ metadata: buildNightRecoveryMetadata({ source: 'scheduled_timer' }),
694
+ });
695
+ console.log(`[NightConsolidator] timer scheduled, fires at ${nextRun.toISOString()} (${msUntilFirstRun} ms)`);
696
+ this.nightTimerId = setTimeout(() => {
697
+ const firedAt = Date.now();
698
+ this.recordNightConsolidationStat({
699
+ runId: scheduledRunId,
700
+ phase: 'timer_fired',
701
+ ts: firedAt,
702
+ scheduledFor,
703
+ driftMs: firedAt - scheduledFor,
704
+ metadata: buildNightRecoveryMetadata({ source: 'scheduled_timer' }),
705
+ });
706
+ console.log(`[NightConsolidator] timer fired at ${new Date().toISOString()}`);
707
+ void this.tryRunNightConsolidator('scheduled_timer', scheduledRunId, scheduledFor);
708
+ let intervalScheduledFor = scheduledFor + 24 * 60 * 60 * 1000;
709
+ let intervalRunId = randomUUID();
710
+ this.recordNightConsolidationStat({
711
+ runId: intervalRunId,
712
+ phase: 'schedule_created',
713
+ ts: Date.now(),
714
+ scheduledFor: intervalScheduledFor,
715
+ metadata: buildNightRecoveryMetadata({ source: 'scheduled_timer' }),
716
+ });
717
+ this.nightIntervalId = setInterval(() => {
718
+ const intervalFiredAt = Date.now();
719
+ const currentRunId = intervalRunId;
720
+ const currentScheduledFor = intervalScheduledFor;
721
+ this.recordNightConsolidationStat({
722
+ runId: currentRunId,
723
+ phase: 'timer_fired',
724
+ ts: intervalFiredAt,
725
+ scheduledFor: currentScheduledFor,
726
+ driftMs: intervalFiredAt - currentScheduledFor,
727
+ metadata: buildNightRecoveryMetadata({ source: 'scheduled_timer' }),
728
+ });
729
+ console.log(`[NightConsolidator] timer fired at ${new Date().toISOString()}`);
730
+ void this.tryRunNightConsolidator('scheduled_timer', currentRunId, currentScheduledFor);
731
+ intervalScheduledFor = currentScheduledFor + 24 * 60 * 60 * 1000;
732
+ intervalRunId = randomUUID();
733
+ this.recordNightConsolidationStat({
734
+ runId: intervalRunId,
735
+ phase: 'schedule_created',
736
+ ts: Date.now(),
737
+ scheduledFor: intervalScheduledFor,
738
+ metadata: buildNightRecoveryMetadata({ source: 'scheduled_timer' }),
739
+ });
740
+ }, 24 * 60 * 60 * 1000);
741
+ this.nightIntervalId.unref?.();
742
+ }, msUntilFirstRun);
743
+ this.nightTimerId.unref?.();
744
+ this.nightHealthCheckIntervalId = setInterval(() => {
745
+ void this.tryRunNightConsolidator('health_check_recovery').catch((err) => {
746
+ console.warn('[NightConsolidator] health-check failed:', err?.message ?? err);
747
+ });
748
+ }, NIGHT_HEALTH_CHECK_INTERVAL_MS);
749
+ this.nightHealthCheckIntervalId.unref?.();
750
+ this.nightStartupRecoveryTimerId = setTimeout(() => {
751
+ void this.tryRunNightConsolidator('startup_recovery').catch((err) => {
752
+ console.warn('[NightConsolidator] startup recovery check failed:', err?.message ?? err);
753
+ });
754
+ }, NIGHT_STARTUP_RECOVERY_DELAY_MS);
755
+ this.nightStartupRecoveryTimerId.unref?.();
756
+ console.log(`[memory-river] NightConsolidator scheduled; next run: ${nextRun.toISOString()}`);
757
+ }
758
+ clearCleanupEngineTimers() {
759
+ if (this.cleanupTimeoutId) {
760
+ clearTimeout(this.cleanupTimeoutId);
761
+ this.cleanupTimeoutId = null;
762
+ }
763
+ if (this.cleanupIntervalId) {
764
+ clearInterval(this.cleanupIntervalId);
765
+ this.cleanupIntervalId = null;
766
+ }
767
+ }
768
+ async runCleanupEngineNow(source) {
769
+ if (!this.cleanupEngineRef)
770
+ return;
771
+ try {
772
+ const result = await this.cleanupEngineRef.runSmartCleanup(source);
773
+ this.recordCleanupSuccess(result);
774
+ }
775
+ catch (err) {
776
+ console.error(`[CleanupEngine] run failed, source=${source}:`, err);
777
+ if (err?.stack) {
778
+ console.error(err.stack);
779
+ }
780
+ }
781
+ }
782
+ recordCleanupSuccess(result) {
783
+ const now = Date.now();
784
+ this.lastCleanupSuccessfulRunAt = now;
785
+ writeCleanupState({
786
+ lastSuccessfulRunAt: now,
787
+ lastDeleteCount: result.deleted,
788
+ lastDecayCount: result.updated,
789
+ }, path.join(this.deps.paths.stateDir, 'cleanup-state.json'));
790
+ }
791
+ getStartupRecoveryLimits() {
792
+ return {
793
+ maxStartupDelete: this.activePluginConfig.cleanupEngine?.maxStartupDelete ?? this.config.cleanupEngine.maxStartupDelete ?? 20,
794
+ maxStartupDecay: this.activePluginConfig.cleanupEngine?.maxStartupDecay ?? this.config.cleanupEngine.maxStartupDecay ?? 50,
795
+ };
796
+ }
797
+ formatHours(hours) {
798
+ return hours.toFixed(1).replace(/\.0$/, '');
799
+ }
800
+ formatCandidateSummary(summary) {
801
+ const range = `${summary.firstId ?? 'none'}..${summary.lastId ?? 'none'}`;
802
+ const createdAtRange = summary.minCreatedAt && summary.maxCreatedAt
803
+ ? `${new Date(summary.minCreatedAt).toISOString()}..${new Date(summary.maxCreatedAt).toISOString()}`
804
+ : 'none';
805
+ return `count=${summary.count} idRange=${range} createdAtRange=${createdAtRange} createdAtByDay=${JSON.stringify(summary.createdAtByDay)}`;
806
+ }
807
+ shouldScheduleStartupRecoveryFromState(state, nowMs = Date.now()) {
808
+ return shouldRunStartupRecovery(state, nowMs);
809
+ }
810
+ async runStartupRecoveryWithProtection() {
811
+ if (!this.cleanupEngineRef)
812
+ return;
813
+ const limits = this.getStartupRecoveryLimits();
814
+ console.log(`[CleanupEngine] startup recovery estimating candidates, maxDelete=${limits.maxStartupDelete}, maxDecay=${limits.maxStartupDecay}`);
815
+ const estimate = await this.cleanupEngineRef.runSmartCleanup('startup-recovery', { dryRunOverride: true });
816
+ const mode = chooseStartupRecoveryMode(estimate.wouldDelete, limits);
817
+ if (mode.dryRunOnly) {
818
+ console.warn(`[CleanupEngine] startup-recovery backlog too large: estimatedDelete=${estimate.wouldDelete} limit=${mode.maxDelete}, dryRun=true, ${this.formatCandidateSummary(estimate.deleteCandidateSummary)}`);
819
+ this.recordCleanupSuccess({ deleted: 0, updated: 0 });
820
+ return;
821
+ }
822
+ const result = await this.cleanupEngineRef.runSmartCleanup('startup-recovery', {
823
+ maxDelete: mode.maxDelete,
824
+ maxDecay: mode.maxDecay,
825
+ });
826
+ if (result.deferredDelete > 0 || result.deferredDecay > 0) {
827
+ console.log(`[CleanupEngine] startup-recovery: hit limit, deferred ${result.deferredDelete} delete candidates and ${result.deferredDecay} decay candidates to next run`);
828
+ }
829
+ this.recordCleanupSuccess(result);
830
+ }
831
+ scheduleCleanupEngine() {
832
+ // See scheduleNightConsolidator: under MR_OTTER_READONLY=1 the startup-recovery path here runs
833
+ // healthScore decay (store-v4.ts) and the interval timer keeps writing -> cross-question drift.
834
+ // Gate it; production untouched (flag default off).
835
+ if (process.env.MR_OTTER_READONLY === '1')
836
+ return;
837
+ if (!this.cleanupEngineRef)
838
+ return;
839
+ this.clearCleanupEngineTimers();
840
+ const nowMs = Date.now();
841
+ const state = readCleanupState(path.join(this.deps.paths.stateDir, 'cleanup-state.json'));
842
+ const decision = shouldRunStartupRecovery(state, nowMs);
843
+ if (decision.shouldRun) {
844
+ console.log(`[CleanupEngine] startup recovery triggered, source=startup-recovery, reason=${decision.reason}`);
845
+ void this.runStartupRecoveryWithProtection().catch((err) => {
846
+ console.error('[CleanupEngine] startup recovery failed:', err);
847
+ if (err?.stack)
848
+ console.error(err.stack);
849
+ });
850
+ }
851
+ else {
852
+ console.log(`[CleanupEngine] startup-recovery skipped: last success was ${this.formatHours(decision.hoursSinceLastSuccess)}h ago`);
853
+ }
854
+ const now = new Date();
855
+ const nextRun = new Date(now);
856
+ nextRun.setHours(4, 0, 0, 0);
857
+ if (nextRun.getTime() <= now.getTime())
858
+ nextRun.setDate(nextRun.getDate() + 1);
859
+ const msUntilFirstRun = nextRun.getTime() - now.getTime();
860
+ console.log(`[CleanupEngine] timer scheduled, fires at ${nextRun.toISOString()} (${msUntilFirstRun} ms)`);
861
+ this.cleanupTimeoutId = setTimeout(() => {
862
+ console.log(`[CleanupEngine] timer fired at ${new Date().toISOString()}, source=daily-schedule`);
863
+ void this.runCleanupEngineNow('daily-schedule');
864
+ this.cleanupIntervalId = setInterval(() => {
865
+ console.log(`[CleanupEngine] timer fired at ${new Date().toISOString()}, source=daily-schedule`);
866
+ void this.runCleanupEngineNow('daily-schedule');
867
+ }, 24 * 60 * 60 * 1000);
868
+ }, msUntilFirstRun);
869
+ }
870
+ /** 提取訊息陣列 */
871
+ extractMessages(...args) {
872
+ for (const arg of args) {
873
+ if (Array.isArray(arg))
874
+ return arg;
875
+ if (arg && Array.isArray(arg.messages))
876
+ return arg.messages;
877
+ if (arg && arg.session && Array.isArray(arg.session.messages))
878
+ return arg.session.messages;
879
+ if (arg && arg.message)
880
+ return Array.isArray(arg.message.content) ? arg.message.content : [arg.message];
881
+ }
882
+ return [];
883
+ }
884
+ normalizeFrameworkLine(line) {
885
+ return line.trim().replace(/:/g, ':').toLowerCase();
886
+ }
887
+ metadataHeadingLength(text) {
888
+ const match = text.match(/^[^\S\r\n]*[^\r\n]*\(untrusted metadata\)[^\r\n]*[::]?[^\r\n]*(?:\r?\n|$)/i);
889
+ return match ? match[0].length : 0;
890
+ }
891
+ stripLeadingUntrustedMetadataBlocks(text) {
892
+ let rest = text;
893
+ while (true) {
894
+ rest = rest.trimStart();
895
+ const headingLength = this.metadataHeadingLength(rest);
896
+ if (headingLength === 0)
897
+ break;
898
+ const afterHeading = rest.slice(headingLength);
899
+ const blankLineMatch = afterHeading.match(/\r?\n[ \t]*\r?\n/);
900
+ const nextHeadingMatch = afterHeading.match(/\r?\n[^\S\r\n]*[^\r\n]*\(untrusted metadata\)[^\r\n]*[::]?[^\r\n]*(?:\r?\n|$)/i);
901
+ const blankEnd = blankLineMatch ? blankLineMatch.index + blankLineMatch[0].length : Infinity;
902
+ const nextHeadingEnd = nextHeadingMatch ? nextHeadingMatch.index + 1 : Infinity;
903
+ const end = Math.min(blankEnd, nextHeadingEnd);
904
+ rest = end === Infinity ? '' : afterHeading.slice(end);
905
+ }
906
+ return rest.trim();
907
+ }
908
+ /** 提取最後一則 User 訊息文字 */
909
+ extractLastUserMessage(msgs) {
910
+ const matchFrameworkMetadata = (text) => {
911
+ if (!text)
912
+ return null;
913
+ const trimmed = text.trimStart();
914
+ const normalized = this.normalizeFrameworkLine(trimmed.split(/\r?\n/, 1)[0] ?? trimmed);
915
+ return normalized.startsWith('conversation info (untrusted metadata)') ? 'conversation_info' :
916
+ normalized.startsWith('note: the previous agent run was aborted') ? 'run_aborted' :
917
+ normalized.startsWith('[media attached:') ? 'media_attached' :
918
+ normalized.startsWith('(system)') ? 'system_tag' :
919
+ normalized.startsWith('[metadata]') ? 'metadata_tag' :
920
+ null;
921
+ };
922
+ const extractText = (content) => {
923
+ if (typeof content === 'string')
924
+ return content;
925
+ if (Array.isArray(content)) {
926
+ return content
927
+ .map((c) => c?.type === 'text' ? c.text : '')
928
+ .join(' ')
929
+ .trim();
930
+ }
931
+ return '';
932
+ };
933
+ for (let i = msgs.length - 1; i >= 0; i--) {
934
+ const m = msgs[i];
935
+ if (m?.role !== 'user')
936
+ continue;
937
+ const text = extractText(m.content);
938
+ if (!text)
939
+ continue;
940
+ const matched = matchFrameworkMetadata(text);
941
+ if (matched) {
942
+ if (matched === 'conversation_info') {
943
+ const stripped = this.stripLeadingUntrustedMetadataBlocks(text);
944
+ if (stripped)
945
+ return stripped;
946
+ console.log('[autoRecall] skipped framework: pattern=conversation_info_after_strip');
947
+ }
948
+ else {
949
+ console.log(`[autoRecall] skipped framework: pattern=${matched}`);
950
+ }
951
+ continue;
952
+ }
953
+ return text;
954
+ }
955
+ return '';
956
+ }
957
+ recordHookPromptIncludedEvents(store, results, searchResponse) {
958
+ const recordSubsystemEffectiveness = store?.recordSubsystemEffectiveness;
959
+ if (typeof recordSubsystemEffectiveness !== "function")
960
+ return;
961
+ if (!searchResponse?.queryHash || !Array.isArray(searchResponse.hookOriginIds))
962
+ return;
963
+ const hookOriginIds = new Set(searchResponse.hookOriginIds);
964
+ if (hookOriginIds.size === 0)
965
+ return;
966
+ results.forEach((result, index) => {
967
+ const memoryId = result?.entry?.id;
968
+ if (typeof memoryId !== "string" || !hookOriginIds.has(memoryId))
969
+ return;
970
+ const score = Number(result?.finalScore ?? result?.fusedScore ?? 0);
971
+ void recordSubsystemEffectiveness.call(store, {
972
+ subsystem: "hooks",
973
+ event: "hook_prompt_included",
974
+ entityId: memoryId,
975
+ relatedId: "",
976
+ queryHash: searchResponse.queryHash,
977
+ outcome: "included",
978
+ count: 1,
979
+ score: Number.isFinite(score) ? score : 0,
980
+ durationMs: 0,
981
+ metadata: {
982
+ rank: index + 1,
983
+ keyword: searchResponse.hookOriginKeywords?.[memoryId] ?? "",
984
+ },
985
+ }).catch((err) => {
986
+ console.warn("[memory-river] hooks prompt effectiveness write failed:", err?.message ?? err);
987
+ });
988
+ });
989
+ }
990
+ recordGwmEffectiveness(store, event) {
991
+ const fn = store?.recordSubsystemEffectiveness;
992
+ if (typeof fn !== 'function')
993
+ return;
994
+ void fn.call(store, {
995
+ subsystem: 'gwm',
996
+ relatedId: '',
997
+ durationMs: 0,
998
+ ...event,
999
+ }).catch((err) => {
1000
+ console.warn('[memory-river] gwm effectiveness write failed:', err?.message ?? err);
1001
+ });
1002
+ }
1003
+ async executeMemoryRecall(params) {
1004
+ try {
1005
+ await this.init();
1006
+ }
1007
+ catch (err) {
1008
+ return this.initFailedResult(err);
1009
+ }
1010
+ if (!this.retrieverRef) {
1011
+ return this.initFailedResult(this.pluginInitError ?? new Error('retriever unavailable after initialization'));
1012
+ }
1013
+ const searchResponse = await this.retrieverRef.hybridSearch(params.query, params.limit || 5);
1014
+ const results = searchResponse.results;
1015
+ if (results.length === 0) {
1016
+ const queryHash = searchResponse.queryHash || hashQuery(String(params.query ?? ''));
1017
+ let searched = 'unknown';
1018
+ try {
1019
+ const store = typeof this.retrieverRef.getStore === 'function'
1020
+ ? this.retrieverRef.getStore()
1021
+ : this.memoryStoreRef;
1022
+ const count = await store?.count?.();
1023
+ if (Number.isFinite(Number(count)))
1024
+ searched = String(Number(count));
1025
+ }
1026
+ catch {
1027
+ searched = 'unknown';
1028
+ }
1029
+ return { content: [{ type: 'text', text: `查無相關記憶 (queryHash=${queryHash}, searched=${searched} memories)` }] };
1030
+ }
1031
+ const text = results.map((r) => `• ${r.entry.text}`).join('\n');
1032
+ return { content: [{ type: 'text', text: `[相關記憶]\n${text}` }] };
1033
+ }
1034
+ async executeMemoryStore(params) {
1035
+ if (!this.capsuleBridgeRef) {
1036
+ return {
1037
+ content: [{ type: 'text', text: '❌ INBOX_WRITER_UNAVAILABLE: capsule bridge not initialized' }],
1038
+ isError: true,
1039
+ };
1040
+ }
1041
+ const { text, category, importance } = params;
1042
+ await this.capsuleBridgeRef.writeInboxItem(text, { category: category || 'other', importance: importance ?? 0.7 });
1043
+ return { content: [{ type: 'text', text: `📝 已寫入 Inbox(待濃縮入庫)` }] };
1044
+ }
1045
+ async remember(text, opts = {}) {
1046
+ await this.init();
1047
+ if (!this.memoryStoreRef || !this.embedderRef) {
1048
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1049
+ }
1050
+ const vector = await this.embedderRef.embed(text, 'store');
1051
+ return this.memoryStoreRef.store({
1052
+ text,
1053
+ vector,
1054
+ category: (opts.category || 'other'),
1055
+ importance: opts.importance ?? 0.7,
1056
+ parentId: null,
1057
+ metadata: JSON.stringify(opts.metadata ?? {}),
1058
+ });
1059
+ }
1060
+ async updateMemory(id, updates) {
1061
+ await this.init();
1062
+ if (!this.memoryStoreRef || !this.embedderRef) {
1063
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1064
+ }
1065
+ // F2 修復:改字要跟著改向量,否則向量檢索仍代表舊語意。
1066
+ const newVector = updates.text !== undefined
1067
+ ? await this.embedderRef.embed(updates.text, 'store')
1068
+ : undefined;
1069
+ return this.memoryStoreRef.update(id, updates, newVector);
1070
+ }
1071
+ async setMemoryStatus(req) {
1072
+ await this.init();
1073
+ if (!this.statusManagerRef) {
1074
+ throw this.pluginInitError ?? new Error('status manager unavailable after initialization');
1075
+ }
1076
+ return this.statusManagerRef.changeStatus(req);
1077
+ }
1078
+ async recall(query, limit = 5) {
1079
+ await this.init();
1080
+ if (!this.memoryStoreRef) {
1081
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1082
+ }
1083
+ return this.memoryStoreRef.hybridVectorSearch(query, limit);
1084
+ }
1085
+ // ⚠️ DEPRECATED / DORMANT (2026-06-19): graph-enumerate is superseded by memory_recall
1086
+ // for cat1 enumeration. Fair full-10: recall SiblingRecall@10 ~48% vs enumerate ~29%, at
1087
+ // LOWER noise. Not wired into any agent/MCP/answer path (facade-only). Do NOT invest further;
1088
+ // every retrieval lever (ranking/breadth/direction/fanout/relation-typing) was falsified.
1089
+ // Revisit only if ingestion-side relation normalization happens. See
1090
+ // docs/internal/FINDINGS_ENUM_CAT1_20260619.md.
1091
+ async enumerate(plan, limit = 1000) {
1092
+ await this.init();
1093
+ if (!this.graphStoreRef || !this.memoryStoreRef || !this.embedderRef) {
1094
+ throw this.pluginInitError ?? new Error('graph enumeration unavailable after initialization');
1095
+ }
1096
+ if (limit <= 0)
1097
+ return [];
1098
+ const enumerator = new GraphEnumerator(this.graphStoreRef, this.embedderRef);
1099
+ // Enumerate the full answer set internally; `limit` caps returned MEMORIES so
1100
+ // callers get a true top-`limit` result (recall@k / Noise@k are measured over
1101
+ // at most `limit` memories, not over the memories backing the top-`limit` answers).
1102
+ const result = await enumerator.enumerate(plan);
1103
+ const ids = [];
1104
+ const seen = new Set();
1105
+ for (const answer of result.answers) {
1106
+ for (const id of answer.sourceMemoryIds) {
1107
+ if (!seen.has(id)) {
1108
+ seen.add(id);
1109
+ ids.push(id);
1110
+ }
1111
+ }
1112
+ }
1113
+ // Filter hidden-status memories BEFORE applying the limit: getByIds drops
1114
+ // non-active rows, so slicing to `limit` first would let a deprecated memory
1115
+ // in the top-`limit` prefix silently shrink the result below `limit` (at
1116
+ // limit=1 a deprecated head returns []). Hydrate the full id list, then cap.
1117
+ const entries = (await this.memoryStoreRef.getByIds(ids)).slice(0, limit);
1118
+ return entries.map(entry => ({
1119
+ entry,
1120
+ // Non-ranking placeholders: enumeration hits are provenance-backed, not similarity-ranked.
1121
+ rawDistance: Number.POSITIVE_INFINITY,
1122
+ vectorScore: 0,
1123
+ rankScore: 0,
1124
+ bm25Score: 0,
1125
+ fusedScore: 0,
1126
+ }));
1127
+ }
1128
+ async searchMemory(query, limit = 5) {
1129
+ await this.init();
1130
+ if (!this.retrieverRef) {
1131
+ throw this.pluginInitError ?? new Error('retriever unavailable after initialization');
1132
+ }
1133
+ // TODO(read-only): hybridSearchWithoutBoost still records recall metadata.
1134
+ return (await this.retrieverRef.hybridSearchWithoutBoost(query, limit)).results;
1135
+ }
1136
+ async withSkillLock(name, fn) {
1137
+ const previous = this.skillLocks.get(name) ?? Promise.resolve();
1138
+ const run = previous.catch(() => { }).then(fn);
1139
+ const chain = run.catch(() => { });
1140
+ this.skillLocks.set(name, chain);
1141
+ try {
1142
+ return await run;
1143
+ }
1144
+ finally {
1145
+ if (this.skillLocks.get(name) === chain) {
1146
+ this.skillLocks.delete(name);
1147
+ }
1148
+ }
1149
+ }
1150
+ async saveSkill(def) {
1151
+ validateSkillDef(def);
1152
+ await this.init();
1153
+ if (!this.memoryStoreRef || !this.embedderRef || !this.statusManagerRef) {
1154
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1155
+ }
1156
+ return this.withSkillLock(def.name, async () => {
1157
+ const existing = (await this.memoryStoreRef.queryAllWithMeta())
1158
+ .filter(entry => entry.metadataObj.capsuleVersion === 2)
1159
+ .filter(entry => entry.metadataObj.status === 'active')
1160
+ .filter(entry => entry.metadataObj.skillName === def.name);
1161
+ const vector = await this.embedderRef.embed(def.summary, 'store');
1162
+ const created = await this.memoryStoreRef.store({
1163
+ text: def.summary,
1164
+ vector,
1165
+ category: 'skill',
1166
+ importance: 0.7,
1167
+ parentId: null,
1168
+ metadata: JSON.stringify({
1169
+ capsuleVersion: 2,
1170
+ skillName: def.name,
1171
+ triggerConditions: def.triggers,
1172
+ executionSteps: def.steps,
1173
+ usageCount: 0,
1174
+ lastUsedAt: null,
1175
+ status: 'active',
1176
+ }),
1177
+ });
1178
+ try {
1179
+ for (const old of existing) {
1180
+ const result = await this.statusManagerRef.changeStatus({
1181
+ memoryId: old.id,
1182
+ toStatus: 'superseded',
1183
+ reason: 'manual',
1184
+ source: 'skills.save',
1185
+ supersededBy: created.id,
1186
+ });
1187
+ if (!result.ok) {
1188
+ throw new Error(`failed to supersede skill ${old.id}: ${result.error ?? 'unknown error'}`);
1189
+ }
1190
+ }
1191
+ }
1192
+ catch (err) {
1193
+ try {
1194
+ const rollback = await this.statusManagerRef.changeStatus({
1195
+ memoryId: created.id,
1196
+ toStatus: 'trashed',
1197
+ reason: 'saveSkill_supersede_rollback',
1198
+ source: 'skills.save',
1199
+ });
1200
+ if (!rollback.ok) {
1201
+ console.warn(`[memory-river] saveSkill rollback failed for ${created.id}: ${rollback.error ?? 'unknown error'}`);
1202
+ }
1203
+ }
1204
+ catch (rollbackErr) {
1205
+ console.warn(`[memory-river] saveSkill rollback failed for ${created.id}:`, rollbackErr);
1206
+ }
1207
+ throw err;
1208
+ }
1209
+ return { id: created.id };
1210
+ });
1211
+ }
1212
+ async searchSkills(query, limit = 2) {
1213
+ await this.init();
1214
+ if (!this.memoryStoreRef) {
1215
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1216
+ }
1217
+ const candidates = await this.memoryStoreRef.hybridSkillCapsuleSearch(query, limit, { capsuleVersion: 2, status: 'active' });
1218
+ return candidates
1219
+ .map(candidate => ({
1220
+ name: candidate.skillName,
1221
+ triggerConditions: candidate.triggerConditions,
1222
+ summary: candidate.summary,
1223
+ }));
1224
+ }
1225
+ async loadSkill(name) {
1226
+ await this.init();
1227
+ if (!this.memoryStoreRef) {
1228
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1229
+ }
1230
+ return this.withSkillLock(name, async () => {
1231
+ const matches = (await this.memoryStoreRef.queryAllWithMeta())
1232
+ .filter(candidate => candidate.metadataObj.capsuleVersion === 2
1233
+ && candidate.metadataObj.status === 'active'
1234
+ && candidate.metadataObj.skillName === name)
1235
+ .sort((a, b) => b.createdAt - a.createdAt);
1236
+ if (matches.length > 1) {
1237
+ console.warn(`[memory-river] Multiple active v2 skills named ${name}: ${matches.map(entry => entry.id).join(', ')}`);
1238
+ }
1239
+ const entry = matches[0];
1240
+ if (!entry)
1241
+ return null;
1242
+ const metadata = entry.metadataObj;
1243
+ metadata.usageCount = (metadata.usageCount ?? 0) + 1;
1244
+ metadata.lastUsedAt = Date.now();
1245
+ await this.memoryStoreRef.update(entry.id, { metadata: JSON.stringify(metadata) });
1246
+ await this.memoryStoreRef.boostHealth(entry.id);
1247
+ const updated = await this.memoryStoreRef.getById(entry.id);
1248
+ return updated ? this.toSkillCapsuleV2(updated) : null;
1249
+ });
1250
+ }
1251
+ async listSkills() {
1252
+ await this.init();
1253
+ if (!this.memoryStoreRef) {
1254
+ throw this.pluginInitError ?? new Error('memory store unavailable after initialization');
1255
+ }
1256
+ const newestByName = new Map();
1257
+ for (const entry of (await this.memoryStoreRef.queryAllWithMeta())
1258
+ .filter(entry => entry.metadataObj.capsuleVersion === 2 && entry.metadataObj.status === 'active')
1259
+ .sort((a, b) => b.createdAt - a.createdAt)) {
1260
+ const name = String(entry.metadataObj.skillName);
1261
+ if (!newestByName.has(name))
1262
+ newestByName.set(name, entry);
1263
+ }
1264
+ return Array.from(newestByName.values())
1265
+ .map(entry => ({
1266
+ name: String(entry.metadataObj.skillName),
1267
+ triggerConditions: Array.isArray(entry.metadataObj.triggerConditions) ? entry.metadataObj.triggerConditions : [],
1268
+ summary: entry.text,
1269
+ }));
1270
+ }
1271
+ toSkillCapsuleV2(entry) {
1272
+ const metadata = JSON.parse(entry.metadata || '{}');
1273
+ return {
1274
+ id: entry.id,
1275
+ name: metadata.skillName,
1276
+ triggerConditions: metadata.triggerConditions ?? [],
1277
+ executionSteps: metadata.executionSteps ?? [],
1278
+ summary: entry.text,
1279
+ category: 'skill',
1280
+ importance: 0.7,
1281
+ capsuleVersion: 2,
1282
+ usageCount: metadata.usageCount ?? 0,
1283
+ lastUsedAt: metadata.lastUsedAt ?? null,
1284
+ status: metadata.status,
1285
+ createdAt: entry.createdAt,
1286
+ updatedAt: entry.updatedAt,
1287
+ };
1288
+ }
1289
+ gwmNotInitializedResult() {
1290
+ return {
1291
+ content: [{ type: 'text', text: '❌ GWM_NOT_INITIALIZED: global working memory is not initialized' }],
1292
+ isError: true,
1293
+ };
1294
+ }
1295
+ async executeGwmOn(params) {
1296
+ if (!this.gwmRef)
1297
+ return this.gwmNotInitializedResult();
1298
+ const result = await this.gwmRef.gwmOn(params.taskName, params.taskDescription, params.keywords);
1299
+ const sessionIdentity = this.lastObservedSessionIdentity;
1300
+ const sessionKey = sessionIdentity?.canonicalKey ?? 'global';
1301
+ const sessionState = this.getOrCreateGwmSessionState(sessionKey);
1302
+ sessionState.injectionCount = 0;
1303
+ sessionState.turnIndex = 0;
1304
+ sessionState.lastInjectionTurnIndex = null;
1305
+ sessionState.pendingEpisode = null;
1306
+ const llmIdentity = sessionIdentity
1307
+ ? this.getRecentLlmIdentityForKeys(this.attributionKeysFromIdentity(sessionIdentity))
1308
+ : null;
1309
+ const gwmState = this.gwmRef?.state;
1310
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1311
+ event: 'gwm_lifecycle',
1312
+ outcome: 'on',
1313
+ sessionKey: sessionIdentity?.sessionKey ?? sessionIdentity?.canonicalKey ?? '',
1314
+ sessionId: sessionIdentity?.sessionId ?? '',
1315
+ count: sessionState.injectionCount,
1316
+ metadata: {
1317
+ lifecycle: 'on',
1318
+ llmModel: llmIdentity?.model ?? null,
1319
+ llmProvider: llmIdentity?.provider ?? null,
1320
+ taskDescriptionHash: gwmState?.taskDescription ? hashQuery(gwmState.taskDescription) : hashQuery(String(params.taskDescription ?? '')),
1321
+ roundsSinceLastInjection: null,
1322
+ sessionTrackingKey: sessionKey,
1323
+ source: 'tool_call_proxy',
1324
+ },
1325
+ });
1326
+ return { content: [{ type: 'text', text: result }] };
1327
+ }
1328
+ async executeGwmOff() {
1329
+ if (!this.gwmRef)
1330
+ return this.gwmNotInitializedResult();
1331
+ const sessionIdentity = this.lastObservedSessionIdentity;
1332
+ const sessionKey = sessionIdentity?.canonicalKey ?? 'global';
1333
+ const sessionState = this.getOrCreateGwmSessionState(sessionKey);
1334
+ const llmIdentity = sessionIdentity
1335
+ ? this.getRecentLlmIdentityForKeys(this.attributionKeysFromIdentity(sessionIdentity))
1336
+ : null;
1337
+ const gwmStateBeforeOff = this.gwmRef?.state;
1338
+ const roundsSinceLastInjection = sessionState.lastInjectionTurnIndex === null
1339
+ ? null
1340
+ : Math.max(0, sessionState.turnIndex - sessionState.lastInjectionTurnIndex);
1341
+ const result = await this.gwmRef.gwmOff();
1342
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1343
+ event: 'gwm_lifecycle',
1344
+ outcome: 'off',
1345
+ sessionKey: sessionIdentity?.sessionKey ?? sessionIdentity?.canonicalKey ?? '',
1346
+ sessionId: sessionIdentity?.sessionId ?? '',
1347
+ count: sessionState.injectionCount,
1348
+ metadata: {
1349
+ lifecycle: 'off',
1350
+ llmModel: llmIdentity?.model ?? null,
1351
+ llmProvider: llmIdentity?.provider ?? null,
1352
+ taskDescriptionHash: gwmStateBeforeOff?.taskDescription ? hashQuery(gwmStateBeforeOff.taskDescription) : null,
1353
+ roundsSinceLastInjection,
1354
+ sessionTrackingKey: sessionKey,
1355
+ closeReason: 'tool_call_unknown_actor',
1356
+ source: 'tool_call_proxy',
1357
+ },
1358
+ });
1359
+ sessionState.pendingEpisode = null;
1360
+ return { content: [{ type: 'text', text: result }] };
1361
+ }
1362
+ executeGwmStatus() {
1363
+ if (!this.gwmRef)
1364
+ return this.gwmNotInitializedResult();
1365
+ const result = this.gwmRef.gwmStatus();
1366
+ return { content: [{ type: 'text', text: result }] };
1367
+ }
1368
+ async executeGwmUpdate(params) {
1369
+ if (!this.gwmRef)
1370
+ return this.gwmNotInitializedResult();
1371
+ const sessionIdentity = this.lastObservedSessionIdentity;
1372
+ const sessionKey = sessionIdentity?.canonicalKey ?? 'global';
1373
+ const sessionState = this.getOrCreateGwmSessionState(sessionKey);
1374
+ const llmIdentity = sessionIdentity
1375
+ ? this.getRecentLlmIdentityForKeys(this.attributionKeysFromIdentity(sessionIdentity))
1376
+ : null;
1377
+ const gwmStateBeforeUpdate = this.gwmRef?.state;
1378
+ const oldTaskDescriptionHash = gwmStateBeforeUpdate?.taskDescription
1379
+ ? hashQuery(gwmStateBeforeUpdate.taskDescription)
1380
+ : null;
1381
+ const result = await this.gwmRef.gwmUpdate(params);
1382
+ const gwmStateAfterUpdate = this.gwmRef?.state;
1383
+ const roundsSinceLastInjection = sessionState.lastInjectionTurnIndex === null
1384
+ ? null
1385
+ : Math.max(0, sessionState.turnIndex - sessionState.lastInjectionTurnIndex);
1386
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1387
+ event: 'gwm_lifecycle',
1388
+ outcome: 'update',
1389
+ sessionKey: sessionIdentity?.sessionKey ?? sessionIdentity?.canonicalKey ?? '',
1390
+ sessionId: sessionIdentity?.sessionId ?? '',
1391
+ count: sessionState.injectionCount,
1392
+ metadata: {
1393
+ lifecycle: 'update',
1394
+ llmModel: llmIdentity?.model ?? null,
1395
+ llmProvider: llmIdentity?.provider ?? null,
1396
+ taskDescriptionHash: gwmStateAfterUpdate?.taskDescription ? hashQuery(gwmStateAfterUpdate.taskDescription) : oldTaskDescriptionHash,
1397
+ oldTaskDescriptionHash,
1398
+ newTaskDescriptionHash: gwmStateAfterUpdate?.taskDescription ? hashQuery(gwmStateAfterUpdate.taskDescription) : oldTaskDescriptionHash,
1399
+ roundsSinceLastInjection,
1400
+ sessionTrackingKey: sessionKey,
1401
+ source: 'tool_call_proxy',
1402
+ },
1403
+ });
1404
+ return { content: [{ type: 'text', text: result }] };
1405
+ }
1406
+ get testHooks() {
1407
+ return {
1408
+ resetState: () => this.resetStateForTests(),
1409
+ setState: (state) => this.setStateForTests(state),
1410
+ getState: () => this.getStateForTests(),
1411
+ ensurePluginInitialized: (_config = this.config) => this.init(),
1412
+ recordPluginInitSmokeStat: (store, outcome, err) => this.recordPluginInitSmokeStat(store, outcome, err),
1413
+ executeMemoryRecall: (params, _config = this.config) => this.executeMemoryRecall(params),
1414
+ executeMemoryStore: (params) => this.executeMemoryStore(params),
1415
+ executeGwmOn: (params) => this.executeGwmOn(params),
1416
+ executeGwmOff: () => this.executeGwmOff(),
1417
+ executeGwmStatus: () => this.executeGwmStatus(),
1418
+ executeGwmUpdate: (params) => this.executeGwmUpdate(params),
1419
+ };
1420
+ }
1421
+ resetStateForTests() {
1422
+ this.activeConcentrator = null;
1423
+ this.retrieverRef = null;
1424
+ this.isAutoRecallEnabled = false;
1425
+ this.memoryStoreRef = null;
1426
+ this.embedderRef = null;
1427
+ this.hooksEngineRef = null;
1428
+ this.graphStoreRef = null;
1429
+ this.inboxWatcherRef = null;
1430
+ this.causalEngineRef = null;
1431
+ this.conflictDetectorRef = null;
1432
+ this.statusManagerRef = null;
1433
+ this.capsuleBridgeRef = null;
1434
+ this.gwmRef = null;
1435
+ this.cleanupEngineRef = null;
1436
+ this.pluginInitPromise = null;
1437
+ this.pluginInitialized = false;
1438
+ this.pluginInitError = null;
1439
+ this.fallbackObserverRegistered = false;
1440
+ this.cleanupEngineInstanceRegistered = false;
1441
+ this.pendingAttribution.clear();
1442
+ this.recentLlmIdentityByKey.clear();
1443
+ this.gwmSessionState.clear();
1444
+ this.lastObservedSessionIdentity = null;
1445
+ }
1446
+ setStateForTests(state) {
1447
+ const keys = ['activeConcentrator', 'retrieverRef', 'memoryStoreRef', 'embedderRef', 'hooksEngineRef', 'graphStoreRef', 'inboxWatcherRef', 'causalEngineRef', 'conflictDetectorRef', 'statusManagerRef', 'capsuleBridgeRef', 'gwmRef', 'cleanupEngineRef', 'pluginInitPromise', 'pluginInitialized', 'pluginInitError'];
1448
+ for (const key of keys)
1449
+ if (key in state)
1450
+ this[key] = state[key];
1451
+ }
1452
+ getStateForTests() {
1453
+ const { memoryStoreRef, retrieverRef, hooksEngineRef, graphStoreRef, inboxWatcherRef, causalEngineRef, conflictDetectorRef, statusManagerRef, capsuleBridgeRef, gwmRef, cleanupEngineRef, pluginInitPromise, pluginInitialized, pluginInitError } = this;
1454
+ return { memoryStoreRef, retrieverRef, hooksEngineRef, graphStoreRef, inboxWatcherRef, causalEngineRef, conflictDetectorRef, statusManagerRef, capsuleBridgeRef, gwmRef, cleanupEngineRef, pluginInitPromise, pluginInitialized, pluginInitError };
1455
+ }
1456
+ start(onStateCleared) {
1457
+ this.sessionTokenWatermark.clear();
1458
+ this.sessionCompactWatermark.clear();
1459
+ this.sessionFileMappings.clear();
1460
+ this.recentlyCompacted.clear();
1461
+ this.lastArchivedLineCount.clear();
1462
+ this.maintainLocks.clear();
1463
+ this.queuedAsyncCompactKeys.clear();
1464
+ this.runningAsyncCompactKeys.clear();
1465
+ this.recentLlmIdentityByKey.clear();
1466
+ this.gwmSessionState.clear();
1467
+ this.lastObservedSessionIdentity = null;
1468
+ this.deps.transcriptArchive.clearTranscriptCache();
1469
+ onStateCleared?.();
1470
+ this.inboxWatcherRef?.start();
1471
+ void this.init().catch(err => {
1472
+ console.error('[memory-river] Background initialization failed:', err);
1473
+ });
1474
+ }
1475
+ async stop() {
1476
+ this.inboxWatcherRef?.stop();
1477
+ this.clearNightConsolidatorTimers();
1478
+ this.clearCleanupEngineTimers();
1479
+ await this.memoryStoreRef?.shutdown();
1480
+ }
1481
+ async onSessionCompactBefore(event) {
1482
+ if (!this.activeConcentrator)
1483
+ return;
1484
+ const messages = event?.messages || [];
1485
+ if (messages.length === 0)
1486
+ return;
1487
+ const identity = resolveSessionIdentity(event);
1488
+ const canonicalKey = identity.canonicalKey;
1489
+ if (this.wasRecentlyCompacted(canonicalKey)) {
1490
+ console.log(`[memory-river] session:compact:before skipped: ${canonicalKey} was compacted within 60s`);
1491
+ return;
1492
+ }
1493
+ try {
1494
+ await this.activeConcentrator.concentrate(messages, false, true, { sessionIdentity: identity });
1495
+ this.markRecentlyCompacted(canonicalKey);
1496
+ console.log('[memory-river] Session compaction complete (capsule and memory notes written)');
1497
+ }
1498
+ catch (err) {
1499
+ console.error('[memory-river] Session compaction failed:', err);
1500
+ }
1501
+ }
1502
+ onSessionEnd(event) {
1503
+ const sessionId = event?.sessionId ?? 'unknown';
1504
+ const sessionKey = typeof event?.sessionKey === 'string' && event.sessionKey.trim().length > 0
1505
+ ? event.sessionKey.trim()
1506
+ : sessionId;
1507
+ if (this.gwmRef?.isActive()) {
1508
+ const sessionState = this.getOrCreateGwmSessionState(sessionKey);
1509
+ const llmIdentity = this.getRecentLlmIdentityForKeys(this.attributionKeysFromLlmOutput(event, {}));
1510
+ const gwmState = this.gwmRef?.state;
1511
+ const roundsSinceLastInjection = sessionState.lastInjectionTurnIndex === null
1512
+ ? null
1513
+ : Math.max(0, sessionState.turnIndex - sessionState.lastInjectionTurnIndex);
1514
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1515
+ event: 'gwm_lifecycle',
1516
+ outcome: 'off',
1517
+ sessionKey: sessionKey === 'unknown' ? '' : sessionKey,
1518
+ sessionId: sessionId === 'unknown' ? '' : sessionId,
1519
+ count: sessionState.injectionCount,
1520
+ metadata: {
1521
+ lifecycle: 'off',
1522
+ llmModel: llmIdentity?.model ?? null,
1523
+ llmProvider: llmIdentity?.provider ?? null,
1524
+ taskDescriptionHash: gwmState?.taskDescription ? hashQuery(gwmState.taskDescription) : null,
1525
+ roundsSinceLastInjection,
1526
+ sessionTrackingKey: sessionKey,
1527
+ closeReason: 'session_end_unclosed',
1528
+ source: 'session_end_proxy',
1529
+ },
1530
+ });
1531
+ }
1532
+ void this.cleanupEngineRef?.onSessionEnd(sessionId, [], 'session-end');
1533
+ }
1534
+ onLlmOutput(event, ctx) {
1535
+ const keys = this.attributionKeysFromLlmOutput(event, ctx);
1536
+ const identity = {
1537
+ model: this.pickNonEmptyString(event?.model),
1538
+ provider: this.pickNonEmptyString(event?.provider),
1539
+ updatedAt: Date.now(),
1540
+ };
1541
+ for (const key of keys)
1542
+ this.recentLlmIdentityByKey.set(key, identity);
1543
+ const injected = this.takePendingAttribution(keys);
1544
+ if (injected.length === 0)
1545
+ return;
1546
+ const assistantTexts = Array.isArray(event?.assistantTexts)
1547
+ ? event.assistantTexts.filter((text) => typeof text === 'string')
1548
+ : [];
1549
+ const requestId = this.pickNonEmptyString(event?.runId)
1550
+ ?? this.pickNonEmptyString(ctx?.runId)
1551
+ ?? this.pickNonEmptyString(event?.sessionId)
1552
+ ?? this.pickNonEmptyString(ctx?.sessionId)
1553
+ ?? 'unknown';
1554
+ const store = this.memoryStoreRef;
1555
+ const attributionEngine = new CausalAttributionEngine({
1556
+ recordEvent(row) {
1557
+ const fn = store?.recordSubsystemEffectiveness;
1558
+ if (typeof fn !== 'function')
1559
+ return;
1560
+ return fn.call(store, row);
1561
+ },
1562
+ }, this.embedderRef ?? undefined);
1563
+ attributionEngine.attributeMemoriesAsync(injected, assistantTexts.join('\n\n'), requestId);
1564
+ }
1565
+ get store() {
1566
+ return this.memoryStoreRef;
1567
+ }
1568
+ // ── 核心流程:assemble (Gateway 對話組裝) ──────────────────────────────────
1569
+ async assemble(...args) {
1570
+ let msgs = this.extractMessages(...args);
1571
+ const sessionIdentity = resolveSessionIdentityFromArgs(...args);
1572
+ this.lastObservedSessionIdentity = sessionIdentity;
1573
+ const attributionRequestKey = this.extractAttributionRequestKeyFromArgs(...args);
1574
+ const onAutoRecallResults = this.extractAutoRecallResultsObserverFromArgs(...args);
1575
+ console.log('[memory-river] assemble called, msgs.length=', msgs.length, 'isAutoRecallEnabled=', this.isAutoRecallEnabled);
1576
+ if (msgs.length === 0)
1577
+ return { messages: [] };
1578
+ // 【Step 1: Ralph Loop 斷路器 — 優先執行!】
1579
+ if (RalphState.shouldIntercept()) {
1580
+ console.log('[memory-river] Ralph Loop detected consecutive errors; applying hard truncation...');
1581
+ const goal = extractGoalFromMsgs(msgs);
1582
+ const trimmed = trimTailErrors(msgs);
1583
+ const warning = generateWarning(goal);
1584
+ msgs = [...trimmed, warning];
1585
+ RalphState.reset(); // 電擊完畢,狀態重置
1586
+ }
1587
+ // 【Step 2 (原 Step 4): 主動濃縮評估與防護 (先瘦身!)】
1588
+ // 改用增量觸發:每增加 WATERMARK_INTERVAL tokens 才檢查一次
1589
+ // 壓縮後 watermark 重置為壓縮後 token 數,不會無限往上疊
1590
+ // =========================================================
1591
+ if (this.activeConcentrator && this.retrieverRef) {
1592
+ try {
1593
+ const canonicalKey = sessionIdentity.canonicalKey;
1594
+ const totalTokens = this.activeConcentrator.estimateTokens(msgs);
1595
+ const tokenBreakdown = this.activeConcentrator.estimateTokenBreakdown(msgs);
1596
+ const assembleWatermark = this.sessionTokenWatermark.get(canonicalKey) ?? 0;
1597
+ const compactWatermark = this.sessionCompactWatermark.get(canonicalKey) ?? 0;
1598
+ const tokenGrowth = totalTokens - assembleWatermark;
1599
+ const exceedsAssembleWatermark = tokenGrowth >= WATERMARK_INTERVAL;
1600
+ const exceedsCompactWatermark = totalTokens > compactWatermark + WATERMARK_INTERVAL;
1601
+ // Confirmatory flag: never run assemble-time concentration, which writes sessionTokenWatermark
1602
+ // (a cross-question in-memory gate). For cat1 the per-question context is far below the 20k
1603
+ // WATERMARK_INTERVAL so this never fires anyway, but gate it to keep each question independent.
1604
+ const shouldConcentrate = process.env.MR_OTTER_READONLY !== '1'
1605
+ && exceedsAssembleWatermark && exceedsCompactWatermark;
1606
+ const reason = shouldConcentrate
1607
+ ? 'dual-watermark-pass'
1608
+ : !exceedsAssembleWatermark
1609
+ ? `growth=${tokenGrowth} < interval=${WATERMARK_INTERVAL}`
1610
+ : `total=${totalTokens} <= compact+interval=${compactWatermark + WATERMARK_INTERVAL}`;
1611
+ console.log(`[concentrator] tokens: real=${tokenBreakdown.realTokens} tool=${tokenBreakdown.toolTokens} total=${tokenBreakdown.total} assembleWatermark=${assembleWatermark} compactWatermark=${compactWatermark} decision=${shouldConcentrate ? 'compress' : 'skip'} reason=${reason}`);
1612
+ // 增量觸發:token 增長量超過 WATERMARK_INTERVAL 才進入 concentrate 檢查
1613
+ if (shouldConcentrate) {
1614
+ const result = await this.activeConcentrator.concentrate(msgs, false, false, { sessionIdentity });
1615
+ if (result && result.wasConcentrated) {
1616
+ msgs = result.messages;
1617
+ // assemble 壓縮只影響本輪上下文,不會立即持久化回 session file。
1618
+ // 因此 watermark 必須記錄本次已處理過的「原始 token 規模」,
1619
+ // 否則下一輪看到同一批完整歷史時會再次重複濃縮。
1620
+ const compressedTokens = this.activeConcentrator.estimateTokens(msgs);
1621
+ this.setWatermark(canonicalKey, totalTokens);
1622
+ console.log(`[memory-river] Compaction succeeded: ${totalTokens} -> ${compressedTokens} tokens; watermark recorded original baseline ${totalTokens}`);
1623
+ if (this.activePluginConfig.concentration?.asyncCompactAfterAssemble === true) {
1624
+ this.enqueueAsyncCompact({
1625
+ trackingKey: canonicalKey,
1626
+ sessionId: sessionIdentity.sessionId ?? undefined,
1627
+ sessionKey: sessionIdentity.sessionKey ?? undefined,
1628
+ compressedTokens,
1629
+ originalTokens: totalTokens,
1630
+ timestamp: Date.now(),
1631
+ });
1632
+ }
1633
+ }
1634
+ else {
1635
+ // 未壓縮:記錄當前位置(下次再漲 20k 才重新檢查)
1636
+ this.setWatermark(canonicalKey, totalTokens);
1637
+ console.log(`[memory-river] Dynamic watermark not reached; next check at ~${totalTokens + WATERMARK_INTERVAL} tokens`);
1638
+ }
1639
+ }
1640
+ }
1641
+ catch (err) {
1642
+ console.warn('[memory-river] Compaction evaluation failed; skipping this compaction to preserve operation:', err);
1643
+ }
1644
+ }
1645
+ // =========================================================
1646
+ // 【Step 3 (原 Step 2): autoRecall 檢索相關記憶 (注入瘦身後的陣列頂端)】
1647
+ // =========================================================
1648
+ if (this.isAutoRecallEnabled && this.retrieverRef) {
1649
+ let userText = this.extractLastUserMessage(msgs);
1650
+ let gwmExpandedShortQuery = false;
1651
+ let gwmOriginalUserText = null;
1652
+ let gwmKeywords = [];
1653
+ let gwmOriginalQueryHash = null;
1654
+ const injectedMemories = [];
1655
+ if (userText && userText.trim().length < 5) {
1656
+ // 繞過 TS 檢查提取 state,並加入嚴格空值保護
1657
+ const gwmState = this.gwmRef?.state;
1658
+ if (this.gwmRef && this.gwmRef.isActive() && gwmState?.keywords && gwmState.keywords.length > 0) {
1659
+ gwmOriginalUserText = userText;
1660
+ gwmKeywords = gwmState.keywords;
1661
+ gwmOriginalQueryHash = hashQuery(userText);
1662
+ const contextStr = gwmState.keywords.join(' ');
1663
+ console.log(`[memory-river] Short-query expansion: '${userText}' expanded to '${userText} ${contextStr}'`);
1664
+ userText = `${userText} ${contextStr}`; // 補上任務關鍵字再搜
1665
+ gwmExpandedShortQuery = true;
1666
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1667
+ event: 'gwm_short_query_expanded',
1668
+ outcome: 'expanded',
1669
+ queryHash: hashQuery(userText),
1670
+ count: gwmKeywords.length,
1671
+ metadata: {
1672
+ originalLen: gwmOriginalUserText.length,
1673
+ expandedLen: userText.length,
1674
+ keywordCount: gwmKeywords.length,
1675
+ originalQueryHash: gwmOriginalQueryHash,
1676
+ },
1677
+ });
1678
+ }
1679
+ else {
1680
+ console.log(`[memory-river] Short query skipped: '${userText}' does not trigger autoRecall (length < 5 and no task keyword)`);
1681
+ userText = ''; // 直接清空,阻斷後續檢索
1682
+ }
1683
+ }
1684
+ if (userText) {
1685
+ let gwmRecallUsedFallback = false;
1686
+ try {
1687
+ let searchResponse = null;
1688
+ let results = [];
1689
+ const autoRecallK = (() => {
1690
+ const raw = Number(process.env.MR_AUTORECALL_K);
1691
+ // Ceiling of 5 (== CRAG gate top-K): inject up to 5, but the relevance
1692
+ // gate trims to however many actually pass, so irrelevant turns stay lean.
1693
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 5;
1694
+ })();
1695
+ try {
1696
+ searchResponse = await this.retrieverRef.hybridSearch(userText, autoRecallK);
1697
+ }
1698
+ catch (primaryErr) {
1699
+ console.warn('[memory-river] autoRecall primary search failed, fallback to no-boost search:', primaryErr?.message);
1700
+ gwmRecallUsedFallback = true;
1701
+ searchResponse = await this.retrieverRef.hybridSearchWithoutBoost(userText, autoRecallK);
1702
+ }
1703
+ results = searchResponse.results;
1704
+ onAutoRecallResults?.({ query: userText, results });
1705
+ let skills = [];
1706
+ try {
1707
+ skills = await this.searchSkills(userText, 2);
1708
+ }
1709
+ catch (skillErr) {
1710
+ console.warn('[memory-river] Skill search failed (non-fatal):', skillErr?.message);
1711
+ }
1712
+ let preamble = '';
1713
+ if (skills.length > 0) {
1714
+ preamble += '[可用技能]\n'
1715
+ + skills.map(skill => (`- 【${skill.name}】觸發: ${skill.triggerConditions.join(', ')} | 摘要: ${skill.summary}`
1716
+ + ` → 完整步驟用 skill_load("${skill.name}")`)).join('\n')
1717
+ + '\n\n';
1718
+ }
1719
+ if (results?.length > 0) {
1720
+ const memoryPromptLines = results.map((r) => {
1721
+ const rawMeta = r.entry?.metadata;
1722
+ const meta = typeof rawMeta === 'string'
1723
+ ? (() => { try {
1724
+ return JSON.parse(rawMeta);
1725
+ }
1726
+ catch {
1727
+ return {};
1728
+ } })()
1729
+ : rawMeta || {};
1730
+ const isLossy = (meta.confidence != null && meta.confidence < 0.6)
1731
+ || (meta.compressionRatio != null && meta.compressionRatio > 15);
1732
+ const sourceEntryIds = Array.isArray(meta.sourceEntryIds)
1733
+ ? meta.sourceEntryIds.filter((id) => typeof id === 'number' && Number.isFinite(id))
1734
+ : [];
1735
+ let lossyPrefix = '';
1736
+ if (isLossy) {
1737
+ const conf = meta.confidence != null ? meta.confidence.toFixed(2) : '?';
1738
+ const firstAt = meta.firstTimestamp ?? '';
1739
+ const lastAt = meta.lastTimestamp ?? '';
1740
+ if (sourceEntryIds.length > 0) {
1741
+ lossyPrefix = `⚠️ [lossy, confidence=${conf}, firstAt=${firstAt}, lastAt=${lastAt}, call memory_rehydrate with mode='entry_ids' + entryIds=${JSON.stringify(sourceEntryIds)}] `;
1742
+ }
1743
+ else {
1744
+ const windowMinutes = (meta.firstTimestamp && meta.lastTimestamp)
1745
+ ? Math.ceil((meta.lastTimestamp - meta.firstTimestamp) / 60000) + 30
1746
+ : 60;
1747
+ lossyPrefix = `⚠️ [lossy, confidence=${conf}, firstAt=${firstAt}, lastAt=${lastAt}, call memory_rehydrate with mode='time_range' + timestamp=${firstAt} + windowMinutes=${windowMinutes}] `;
1748
+ }
1749
+ }
1750
+ else if (sourceEntryIds.length > 0) {
1751
+ lossyPrefix = `[來源turns entryIds=${JSON.stringify(sourceEntryIds)}|需要精確細節時可用 memory_rehydrate mode='entry_ids'] `;
1752
+ }
1753
+ return `• ${lossyPrefix}${r.entry?.text || ''}`;
1754
+ });
1755
+ preamble += '[相關記憶]:\n'
1756
+ + '[記憶為候選證據,未必相關或足夠;不足時優先用其 sourceEntryIds 做 entry_ids rehydrate,召回空泛時改用問題中的具體實體 keyword,確認原文支持再回答]\n'
1757
+ + memoryPromptLines.join('\n');
1758
+ const injectedAt = Date.now();
1759
+ const hookOriginIds = new Set(searchResponse?.hookOriginIds ?? []);
1760
+ results.forEach((r) => {
1761
+ const memoryId = r?.entry?.id;
1762
+ if (typeof memoryId !== 'string' || memoryId.length === 0)
1763
+ return;
1764
+ const memoryText = typeof r?.entry?.text === 'string' ? r.entry.text : '';
1765
+ if (!memoryText || memoryText.length < 10)
1766
+ return;
1767
+ if (memoryText.startsWith('[lossy'))
1768
+ return;
1769
+ if (memoryText.startsWith('[SYSTEM ERROR]'))
1770
+ return;
1771
+ if (memoryText.includes('confidence=0.00'))
1772
+ return;
1773
+ if (memoryText.includes('call memory_rehydrate'))
1774
+ return;
1775
+ const viaHook = hookOriginIds.has(memoryId);
1776
+ injectedMemories.push({
1777
+ memoryId,
1778
+ snippet: memoryText,
1779
+ source: 'autoRecall',
1780
+ injectedAt,
1781
+ viaHook,
1782
+ hookKeyword: viaHook ? searchResponse?.hookOriginKeywords?.[memoryId] : undefined,
1783
+ });
1784
+ });
1785
+ this.recordHookPromptIncludedEvents(this.retrieverRef.getStore(), results, searchResponse);
1786
+ }
1787
+ if (gwmExpandedShortQuery) {
1788
+ const memoryCount = results?.length ?? 0;
1789
+ const capsuleCount = skills.length;
1790
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1791
+ event: 'gwm_keywords_recalled',
1792
+ outcome: (memoryCount + capsuleCount) > 0 ? 'recalled' : 'empty',
1793
+ queryHash: searchResponse?.queryHash ?? gwmOriginalQueryHash ?? '',
1794
+ count: memoryCount + capsuleCount,
1795
+ metadata: {
1796
+ memoryCount,
1797
+ capsuleCount,
1798
+ keywordCount: gwmKeywords.length,
1799
+ originalLen: gwmOriginalUserText?.length ?? 0,
1800
+ expandedLen: userText.length,
1801
+ usedFallback: gwmRecallUsedFallback,
1802
+ },
1803
+ });
1804
+ }
1805
+ if (preamble) {
1806
+ msgs = [{ role: 'system', content: preamble }, ...msgs];
1807
+ this.rememberInjectedMemories(sessionIdentity, injectedMemories, attributionRequestKey);
1808
+ console.log(`[memory-river] autoRecall injected: ${results?.length || 0} memories, ${skills.length} skill capsules`);
1809
+ }
1810
+ }
1811
+ catch (err) {
1812
+ if (gwmExpandedShortQuery) {
1813
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1814
+ event: 'gwm_keywords_recalled',
1815
+ outcome: 'search_failed',
1816
+ queryHash: hashQuery(userText) || gwmOriginalQueryHash || '',
1817
+ count: 0,
1818
+ metadata: {
1819
+ memoryCount: 0,
1820
+ capsuleCount: 0,
1821
+ keywordCount: gwmKeywords.length,
1822
+ originalLen: gwmOriginalUserText?.length ?? 0,
1823
+ expandedLen: userText.length,
1824
+ usedFallback: gwmRecallUsedFallback,
1825
+ },
1826
+ });
1827
+ }
1828
+ console.warn('[memory-river] autoRecall retrieval failed:', err?.message);
1829
+ }
1830
+ }
1831
+ }
1832
+ // =========================================================
1833
+ // 【Step 4 (原 Step 3): GWM 任務目標維護 (注入陣列底部)】
1834
+ // =========================================================
1835
+ if (this.gwmRef && this.gwmRef.isActive()) {
1836
+ try {
1837
+ const sessionState = this.getOrCreateGwmSessionState(sessionIdentity.canonicalKey);
1838
+ const lastUserMsg = this.extractLastUserMessage(msgs);
1839
+ if (lastUserMsg)
1840
+ sessionState.turnIndex += 1;
1841
+ const drift = await this.gwmRef.detectDrift(msgs);
1842
+ if (lastUserMsg && sessionState.pendingEpisode) {
1843
+ const completedEpisode = sessionState.pendingEpisode;
1844
+ const llmIdentity = this.getRecentLlmIdentityForKeys(this.attributionKeysFromIdentity(sessionIdentity));
1845
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1846
+ event: 'gwm_injection_episode',
1847
+ outcome: 'next_user_similarity_observed',
1848
+ entityId: completedEpisode.episodeId,
1849
+ sessionKey: sessionIdentity.sessionKey ?? sessionIdentity.canonicalKey,
1850
+ sessionId: sessionIdentity.sessionId ?? '',
1851
+ queryHash: hashQuery(lastUserMsg),
1852
+ count: completedEpisode.injectionOrdinal,
1853
+ score: drift.similarity,
1854
+ metadata: {
1855
+ llmModel: llmIdentity?.model ?? completedEpisode.llmModel,
1856
+ llmProvider: llmIdentity?.provider ?? completedEpisode.llmProvider,
1857
+ injectionOrdinal: completedEpisode.injectionOrdinal,
1858
+ preInjectDriftRoundCount: completedEpisode.preInjectDriftRoundCount,
1859
+ similarityAtInjection: completedEpisode.similarityAtInjection,
1860
+ roundsSinceLastInjection: completedEpisode.roundsSinceLastInjection,
1861
+ taskDescriptionHash: completedEpisode.taskDescriptionHash,
1862
+ nextUserSimilarity: drift.similarity,
1863
+ sessionTrackingKey: sessionIdentity.canonicalKey,
1864
+ observedOnTurnIndex: sessionState.turnIndex,
1865
+ },
1866
+ });
1867
+ sessionState.pendingEpisode = null;
1868
+ }
1869
+ if (drift.isDrifting && this.gwmRef.shouldInject()) {
1870
+ const reminder = this.gwmRef.getReminderMessage();
1871
+ if (reminder) {
1872
+ const driftQueryHash = hashQuery(lastUserMsg ?? '');
1873
+ const llmIdentity = this.getRecentLlmIdentityForKeys(this.attributionKeysFromIdentity(sessionIdentity));
1874
+ const gwmStateBeforeInject = this.gwmRef?.state;
1875
+ const preInjectDriftRoundCount = typeof gwmStateBeforeInject?.driftRoundCount === 'number'
1876
+ ? gwmStateBeforeInject.driftRoundCount
1877
+ : null;
1878
+ const taskDescriptionHash = gwmStateBeforeInject?.taskDescription
1879
+ ? hashQuery(gwmStateBeforeInject.taskDescription)
1880
+ : '';
1881
+ const roundsSinceLastInjection = sessionState.lastInjectionTurnIndex === null
1882
+ ? null
1883
+ : Math.max(0, sessionState.turnIndex - sessionState.lastInjectionTurnIndex);
1884
+ const episodeId = randomUUID();
1885
+ sessionState.injectionCount += 1;
1886
+ // 放在陣列最後面,確保 LLM 注意力不渙散
1887
+ msgs = [...msgs, { role: 'system', content: reminder }];
1888
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1889
+ event: 'gwm_injection_episode',
1890
+ outcome: 'injected',
1891
+ entityId: episodeId,
1892
+ sessionKey: sessionIdentity.sessionKey ?? sessionIdentity.canonicalKey,
1893
+ sessionId: sessionIdentity.sessionId ?? '',
1894
+ queryHash: driftQueryHash,
1895
+ count: sessionState.injectionCount,
1896
+ score: drift.similarity,
1897
+ metadata: {
1898
+ llmModel: llmIdentity?.model ?? null,
1899
+ llmProvider: llmIdentity?.provider ?? null,
1900
+ injectionOrdinal: sessionState.injectionCount,
1901
+ preInjectDriftRoundCount,
1902
+ roundsSinceLastInjection,
1903
+ taskDescriptionHash,
1904
+ nextUserSimilarity: null,
1905
+ sessionTrackingKey: sessionIdentity.canonicalKey,
1906
+ },
1907
+ });
1908
+ sessionState.pendingEpisode = {
1909
+ episodeId,
1910
+ injectionOrdinal: sessionState.injectionCount,
1911
+ queryHash: driftQueryHash,
1912
+ similarityAtInjection: drift.similarity,
1913
+ preInjectDriftRoundCount,
1914
+ roundsSinceLastInjection,
1915
+ taskDescriptionHash,
1916
+ llmModel: llmIdentity?.model ?? null,
1917
+ llmProvider: llmIdentity?.provider ?? null,
1918
+ };
1919
+ sessionState.lastInjectionTurnIndex = sessionState.turnIndex;
1920
+ await this.gwmRef.markInjected();
1921
+ console.log(`[memory-river] Drift detected: topic changed (similarity: ${drift.similarity.toFixed(2)}); reminder injected`);
1922
+ const gwmState = this.gwmRef?.state;
1923
+ this.recordGwmEffectiveness(this.retrieverRef?.getStore?.() ?? this.memoryStoreRef, {
1924
+ event: 'gwm_drift_injected',
1925
+ outcome: 'injected',
1926
+ sessionKey: sessionIdentity.sessionKey ?? sessionIdentity.canonicalKey,
1927
+ sessionId: sessionIdentity.sessionId ?? '',
1928
+ queryHash: driftQueryHash,
1929
+ score: drift.similarity,
1930
+ count: gwmState?.keywords?.length ?? 0,
1931
+ metadata: {
1932
+ similarity: drift.similarity,
1933
+ reminderLen: reminder.length,
1934
+ messageCount: msgs.length,
1935
+ taskName: gwmState?.taskName ?? null,
1936
+ keywordCount: gwmState?.keywords?.length ?? 0,
1937
+ },
1938
+ });
1939
+ }
1940
+ }
1941
+ else if (drift.isDrifting) {
1942
+ console.log(`[memory-river] Drift detected: topic is shifting (similarity: ${drift.similarity.toFixed(2)})`);
1943
+ }
1944
+ }
1945
+ catch (err) {
1946
+ console.warn('[memory-river] GWM check failed:', err);
1947
+ }
1948
+ }
1949
+ const sessionFileProbe = this.resolveSessionFile({
1950
+ sessionKey: sessionIdentity.sessionKey ?? undefined,
1951
+ sessionId: sessionIdentity.sessionId ?? undefined,
1952
+ });
1953
+ const fallbackProbe = this.deriveSessionFileFromStaticRule({
1954
+ sessionId: sessionIdentity.sessionId ?? undefined,
1955
+ sessionKey: sessionIdentity.sessionKey ?? undefined,
1956
+ });
1957
+ console.log(`[sessionMap] assemble probe: canonicalKey=${sessionIdentity.canonicalKey} source=${sessionIdentity.source} cacheHit=${sessionFileProbe.source === 'cache'} fallbackWouldWork=${!!fallbackProbe}`);
1958
+ // 把最終組裝好的 msgs 回傳給 Gateway
1959
+ return { messages: msgs };
1960
+ }
1961
+ async ingest(...args) { }
1962
+ async archiveTranscript(session, messages) {
1963
+ // F5 修復:原本結果被整個丟棄,caller 永遠拿不到失敗訊號。
1964
+ return await this.deps.transcriptArchive.archiveSnapshot({
1965
+ canonicalKey: session.sessionKey ?? session.sessionId ?? 'global',
1966
+ sessionKey: session.sessionKey ?? null,
1967
+ sessionId: session.sessionId ?? null,
1968
+ }, messages);
1969
+ }
1970
+ async archiveSessionFileTail(identity, sessionFile) {
1971
+ const logger = console;
1972
+ try {
1973
+ logger.info(`[memory-river] maintain archive branch check: hasSessionKey=${!!identity.sessionKey} hasSessionFile=${!!sessionFile} canonicalKey=${identity.canonicalKey}`);
1974
+ if (!identity.sessionKey) {
1975
+ logger.info(`[memory-river] maintain branch: archive skipped hasSessionKey=false hasSessionFile=${!!sessionFile} canonicalKey=${identity.canonicalKey}`);
1976
+ return 'archive-skipped-missing-identity-or-file';
1977
+ }
1978
+ const fss = await import('fs');
1979
+ logger.info(`[memory-river] maintain session file read: path=${sessionFile}`);
1980
+ if (!fss.existsSync(sessionFile)) {
1981
+ logger.info(`[memory-river] maintain branch: sessionFile missing path=${sessionFile} canonicalKey=${identity.canonicalKey}`);
1982
+ return 'session-file-missing';
1983
+ }
1984
+ const rawContent = fss.readFileSync(sessionFile, 'utf-8');
1985
+ const rawLines = rawContent.split('\n');
1986
+ if (rawContent.endsWith('\n'))
1987
+ rawLines.pop();
1988
+ const allLines = rawLines.filter((l) => l.trim());
1989
+ const hasIncompleteTail = rawContent.length > 0 && !rawContent.endsWith('\n') && rawLines[rawLines.length - 1]?.trim();
1990
+ const processableLineCount = hasIncompleteTail ? Math.max(0, allLines.length - 1) : allLines.length;
1991
+ logger.info(`[memory-river] maintain session entries loaded: allLines.length=${allLines.length} canonicalKey=${identity.canonicalKey}`);
1992
+ const currentSessionId = identity.sessionId ?? 'unknown';
1993
+ if (!identity.sessionId) {
1994
+ logger.warn(`[memory-river] maintain missing sessionId, using unknown for transcript watermark canonicalKey=${identity.canonicalKey}`);
1995
+ }
1996
+ const watermark = await this.resolveArchivedLineCount(this.memoryStoreRef, identity.canonicalKey, currentSessionId);
1997
+ let prevCount = watermark.lineCount;
1998
+ if (watermark.sessionId !== currentSessionId) {
1999
+ logger.info(`[memory-river] session changed, reset watermark: prev=${watermark.sessionId ?? '(none)'} new=${currentSessionId} canonicalKey=${identity.canonicalKey}`);
2000
+ prevCount = 0;
2001
+ }
2002
+ logger.info(`[memory-river] maintain archived line count: prevCount=${prevCount} source=resolveArchivedLineCount canonicalKey=${identity.canonicalKey}`);
2003
+ logger.info(`[memory-river] maintain early-return check: prevCount=${prevCount} allLines.length=${allLines.length} processableLineCount=${processableLineCount} hit=${prevCount === processableLineCount}`);
2004
+ const newLines = allLines.slice(prevCount, processableLineCount);
2005
+ logger.info(`[memory-river] maintain new lines: newLines.length=${newLines.length} prevCount=${prevCount} allLines.length=${allLines.length} processableLineCount=${processableLineCount} incompleteTail=${!!hasIncompleteTail}`);
2006
+ if (newLines.length === 0) {
2007
+ logger.info(`[memory-river] maintain branch: no new lines canonicalKey=${identity.canonicalKey} skippedLineCount=${Math.max(0, allLines.length - prevCount)}`);
2008
+ return 'no-new-lines';
2009
+ }
2010
+ const entries = [];
2011
+ let processedLineCount = 0;
2012
+ let parseFailureLineNumber = null;
2013
+ for (const line of newLines) {
2014
+ try {
2015
+ const entry = JSON.parse(line);
2016
+ processedLineCount++;
2017
+ if (entry.type === 'message' && entry.message?.role && entry.message?.content) {
2018
+ const outerTs = typeof entry.timestamp === 'string'
2019
+ ? Date.parse(entry.timestamp)
2020
+ : typeof entry.timestamp === 'number'
2021
+ ? entry.timestamp
2022
+ : NaN;
2023
+ // Prefer the outer entry timestamp when present/valid; otherwise keep any
2024
+ // timestamp the message already carries (do NOT clobber it to undefined —
2025
+ // some session formats put the timestamp only on the inner message).
2026
+ if (Number.isFinite(outerTs))
2027
+ entry.message.timestamp = outerTs;
2028
+ entries.push(entry.message);
2029
+ }
2030
+ }
2031
+ catch {
2032
+ parseFailureLineNumber = prevCount + processedLineCount + 1;
2033
+ break;
2034
+ }
2035
+ }
2036
+ const watermarkLineCount = prevCount + processedLineCount;
2037
+ const skippedLineCount = Math.max(0, allLines.length - watermarkLineCount);
2038
+ logger.info(`[memory-river] maintain parsed entries: entries.length=${entries.length} newLines.length=${newLines.length} processedLineCount=${processedLineCount} skippedLineCount=${skippedLineCount} watermarkLineCount=${watermarkLineCount} parseFailureLine=${parseFailureLineNumber ?? '(none)'}`);
2039
+ if (entries.length > 0) {
2040
+ logger.info(`[memory-river] archiveSnapshot 即將呼叫: entries.length=${entries.length} prevCount=${prevCount}`);
2041
+ const archiveResult = this.deps.transcriptArchive.archiveSnapshot(identity, entries);
2042
+ logger.info(`[memory-river] archiveSnapshot 回傳: ok=${archiveResult.ok} entries.length=${entries.length} prevCount=${prevCount}`);
2043
+ if (!archiveResult.ok) {
2044
+ logger.info(`[memory-river] maintain archiveSnapshot returned not ok: entries.length=${entries.length} canonicalKey=${identity.canonicalKey}`);
2045
+ return 'archive-returned-not-ok';
2046
+ }
2047
+ logger.info(`[memory-river] 📝 archiveSnapshot: ${entries.length} 筆新訊息歸檔 (lines ${prevCount}→${watermarkLineCount}) canonicalKey=${identity.canonicalKey}`);
2048
+ await this.persistArchivedLineCount(this.memoryStoreRef, identity.canonicalKey, currentSessionId, watermarkLineCount);
2049
+ logger.info(`[memory-river] maintain persistArchivedLineCount done: lineCount=${watermarkLineCount} canonicalKey=${identity.canonicalKey}`);
2050
+ return 'archive-ok';
2051
+ }
2052
+ if (processedLineCount > 0) {
2053
+ logger.info(`[memory-river] maintain branch: no parsed entries, persist line count=${watermarkLineCount} canonicalKey=${identity.canonicalKey}`);
2054
+ await this.persistArchivedLineCount(this.memoryStoreRef, identity.canonicalKey, currentSessionId, watermarkLineCount);
2055
+ logger.info(`[memory-river] maintain persistArchivedLineCount done: lineCount=${watermarkLineCount} canonicalKey=${identity.canonicalKey}`);
2056
+ return 'no-parsed-entries';
2057
+ }
2058
+ logger.info(`[memory-river] maintain branch: no processed lines, keep watermark=${prevCount} skippedLineCount=${skippedLineCount} canonicalKey=${identity.canonicalKey}`);
2059
+ return 'no-processed-lines';
2060
+ }
2061
+ catch (err) {
2062
+ const archiveErr = err;
2063
+ logger.error(`[memory-river] archiveSnapshot 失敗:`, { message: archiveErr?.message, stack: archiveErr?.stack });
2064
+ return 'archive-error-non-fatal';
2065
+ }
2066
+ }
2067
+ async maintain(params) {
2068
+ // B.P0-2:per-session lock,防 concurrent maintain 對同一 session 雙寫 archive
2069
+ const identity = resolveSessionIdentity(params);
2070
+ const lockKey = identity.canonicalKey;
2071
+ const logger = console;
2072
+ let exitReason = 'entered';
2073
+ const existing = this.maintainLocks.get(lockKey) ?? Promise.resolve();
2074
+ let release;
2075
+ const next = new Promise(resolve => { release = resolve; });
2076
+ this.maintainLocks.set(lockKey, next);
2077
+ await existing;
2078
+ try {
2079
+ logger.info(`[memory-river] 🛠️ maintain: canonicalKey=${identity.canonicalKey} source=${identity.source} hasSessionFile=${!!params?.sessionFile}`);
2080
+ logger.info(`[memory-river] maintain identity: hasIdentity=${!!identity} canonicalKey=${identity.canonicalKey} sessionKey=${identity.sessionKey ?? '(none)'} sessionId=${identity.sessionId ?? '(none)'} source=${identity.source}`);
2081
+ if (params?.sessionFile) {
2082
+ const record = {
2083
+ trackingKey: identity.canonicalKey,
2084
+ sessionKey: identity.sessionKey ?? undefined,
2085
+ sessionId: identity.sessionId ?? undefined,
2086
+ sessionFile: params.sessionFile,
2087
+ updatedAt: Date.now(),
2088
+ source: 'maintain',
2089
+ };
2090
+ this.setSessionFileMapping(identity.canonicalKey, record);
2091
+ logger.info(`[sessionMap] maintain captured canonicalKey=${identity.canonicalKey} sessionId=${record.sessionId ?? '(none)'} sessionFile=${record.sessionFile}`);
2092
+ }
2093
+ else {
2094
+ exitReason = 'no-session-file-param';
2095
+ logger.info(`[memory-river] maintain branch: no params.sessionFile canonicalKey=${identity.canonicalKey}`);
2096
+ }
2097
+ // 歸檔原始對話(給 memory_rehydrate 使用)
2098
+ // P0-2 修復:只歸檔自上次 maintain 以來新增的行,避免 O(n²) 重複寫入
2099
+ // Phase 4-2:lastArchivedLineCount 改為 canonicalKey 為 key;archiveSnapshot 接 identity,
2100
+ // 檔名仍由 sessionKey 衍生(Q6 漸進路徑)。
2101
+ if (params?.sessionFile) {
2102
+ exitReason = await this.archiveSessionFileTail(identity, params.sessionFile);
2103
+ }
2104
+ else {
2105
+ exitReason = 'archive-skipped-missing-identity-or-file';
2106
+ logger.info(`[memory-river] maintain branch: archive skipped hasSessionKey=${!!identity.sessionKey} hasSessionFile=false canonicalKey=${identity.canonicalKey}`);
2107
+ }
2108
+ if (exitReason === 'entered')
2109
+ exitReason = 'return-success';
2110
+ return {
2111
+ changed: false,
2112
+ bytesFreed: 0,
2113
+ rewrittenEntries: 0,
2114
+ };
2115
+ }
2116
+ finally {
2117
+ logger.info(`[memory-river] maintain() exit canonicalKey=${identity.canonicalKey} reason=${exitReason}`);
2118
+ release();
2119
+ if (this.maintainLocks.get(lockKey) === next) {
2120
+ this.maintainLocks.delete(lockKey);
2121
+ }
2122
+ }
2123
+ }
2124
+ async compact(params) {
2125
+ const { sessionId, sessionFile, force } = params;
2126
+ console.log(`[memory-river] compact() called by Core sessionId=${sessionId} force=${force}`);
2127
+ try {
2128
+ if (!this.activeConcentrator) {
2129
+ console.warn('[memory-river] compact: activeConcentrator not initialized');
2130
+ return { ok: false, compacted: false };
2131
+ }
2132
+ if (!sessionFile) {
2133
+ console.warn('[memory-river] compact: sessionFile path missing');
2134
+ return { ok: false, compacted: false };
2135
+ }
2136
+ if (this.activePluginConfig.concentration?.asyncCompactRaceGuard !== false &&
2137
+ params.expectedLineCount !== undefined &&
2138
+ params.expectedSize !== undefined &&
2139
+ params.expectedMtime !== undefined) {
2140
+ const stat = await fs.promises.stat(sessionFile);
2141
+ const currentLineCount = await this.countFileLines(sessionFile);
2142
+ const lineDelta = currentLineCount - params.expectedLineCount;
2143
+ const sizeDelta = stat.size - params.expectedSize;
2144
+ const mtimeDelta = stat.mtimeMs - params.expectedMtime;
2145
+ if (lineDelta > 0 || sizeDelta > 0 || mtimeDelta > 100) {
2146
+ console.warn(`[compact] race detected: lineDelta=${lineDelta} sizeDelta=${sizeDelta} mtimeDelta=${mtimeDelta}, abort`);
2147
+ return { ok: false, compacted: false, aborted: true, reason: 'race-condition' };
2148
+ }
2149
+ }
2150
+ // 讀 jsonl
2151
+ const fsPromises = await import('fs/promises');
2152
+ const raw = await fsPromises.readFile(sessionFile, 'utf-8');
2153
+ const sessionFileSizeBeforeConcentration = Buffer.byteLength(raw, 'utf8');
2154
+ const lines = raw.trim().split('\n').filter(Boolean);
2155
+ // 分離 session header 和 message entries
2156
+ let sessionHeader = '';
2157
+ const msgs = [];
2158
+ for (let i = 0; i < lines.length; i++) {
2159
+ try {
2160
+ const entry = JSON.parse(lines[i]);
2161
+ if (i === 0 && entry.type === 'session') {
2162
+ sessionHeader = lines[i];
2163
+ continue;
2164
+ }
2165
+ if (entry.type === 'message' && entry.message?.role && entry.message?.content) {
2166
+ const outerTs = typeof entry.timestamp === 'string'
2167
+ ? Date.parse(entry.timestamp)
2168
+ : typeof entry.timestamp === 'number'
2169
+ ? entry.timestamp
2170
+ : NaN;
2171
+ // Prefer the outer entry timestamp; otherwise keep any inner message
2172
+ // timestamp (do NOT clobber to undefined).
2173
+ if (Number.isFinite(outerTs))
2174
+ entry.message.timestamp = outerTs;
2175
+ msgs.push(entry.message);
2176
+ }
2177
+ }
2178
+ catch { /* skip malformed line */ }
2179
+ }
2180
+ if (msgs.length === 0) {
2181
+ console.warn('[memory-river] compact: no messages available for compaction');
2182
+ return { ok: true, compacted: false };
2183
+ }
2184
+ const identity = resolveSessionIdentity(params);
2185
+ const canonicalKey = identity.canonicalKey;
2186
+ const mappingRecord = {
2187
+ trackingKey: canonicalKey,
2188
+ sessionKey: identity.sessionKey ?? undefined,
2189
+ sessionId: identity.sessionId ?? undefined,
2190
+ sessionFile,
2191
+ updatedAt: Date.now(),
2192
+ source: 'compact',
2193
+ };
2194
+ this.setSessionFileMapping(canonicalKey, mappingRecord);
2195
+ console.log(`[sessionMap] compact captured canonicalKey=${canonicalKey} sessionId=${mappingRecord.sessionId ?? '(none)'} sessionFile=${mappingRecord.sessionFile}`);
2196
+ if (this.wasRecentlyCompacted(canonicalKey)) {
2197
+ console.log(`[memory-river] compact: ${canonicalKey} was compacted within 60s; skipping duplicate compaction`);
2198
+ return { ok: true, compacted: false, deduped: true };
2199
+ }
2200
+ const existingArchive = this.maintainLocks.get(canonicalKey) ?? Promise.resolve();
2201
+ let releaseArchive;
2202
+ const archiveLock = new Promise(resolve => { releaseArchive = resolve; });
2203
+ this.maintainLocks.set(canonicalKey, archiveLock);
2204
+ await existingArchive;
2205
+ try {
2206
+ await this.archiveSessionFileTail(identity, sessionFile);
2207
+ }
2208
+ finally {
2209
+ releaseArchive();
2210
+ if (this.maintainLocks.get(canonicalKey) === archiveLock) {
2211
+ this.maintainLocks.delete(canonicalKey);
2212
+ }
2213
+ }
2214
+ // 執行濃縮(dryRun=false, force=true)
2215
+ const result = await this.activeConcentrator.concentrate(msgs, false, true, { sessionIdentity: identity });
2216
+ if (!result?.wasConcentrated || !result.messages) {
2217
+ console.warn('[memory-river] compact: compaction failed or returned no result');
2218
+ return { ok: true, compacted: false };
2219
+ }
2220
+ // 寫回 sessionFile(保留 header + 壓縮後的 messages)
2221
+ const newLines = [];
2222
+ if (sessionHeader)
2223
+ newLines.push(sessionHeader);
2224
+ for (const msg of result.messages) {
2225
+ newLines.push(JSON.stringify({
2226
+ type: 'message',
2227
+ timestamp: new Date().toISOString(),
2228
+ message: msg,
2229
+ }));
2230
+ }
2231
+ if ((await fsPromises.stat(sessionFile)).size > sessionFileSizeBeforeConcentration) {
2232
+ console.warn('[memory-river] compact: session file grew during concentration; skipping write-back');
2233
+ return { ok: true, compacted: false };
2234
+ }
2235
+ // P0-1 修復:原子寫入(先寫 .tmp 再 rename,防 crash 丟失資料)
2236
+ const tmpFile = `${sessionFile}.tmp-${process.pid}-${Date.now()}`;
2237
+ await fsPromises.writeFile(tmpFile, newLines.join('\n') + '\n', 'utf-8');
2238
+ await fsPromises.rename(tmpFile, sessionFile);
2239
+ console.log(`[memory-river] compact() complete; wrote back ${result.messages.length} messages`);
2240
+ this.markRecentlyCompacted(canonicalKey);
2241
+ const compactedTokens = this.activeConcentrator.estimateTokens(result.messages);
2242
+ this.setCompactWatermark(canonicalKey, compactedTokens);
2243
+ this.setWatermark(canonicalKey, compactedTokens);
2244
+ // P0-2 修復:compact 重寫了 sessionFile,重置歸檔 offset
2245
+ // Phase 4-2:archive offset map 統一以 canonicalKey 為 key
2246
+ this.lastArchivedLineCount.delete(canonicalKey);
2247
+ return { ok: true, compacted: true };
2248
+ }
2249
+ catch (err) {
2250
+ console.error('[memory-river] compact failed:', err);
2251
+ return { ok: false, compacted: false };
2252
+ }
2253
+ }
2254
+ }