dsh-tdai-memory 0.3.2 → 0.3.3

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 (65) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +9 -1
  3. package/README.zh.md +8 -1
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/cordis.patch.yml +8 -8
  6. package/package.json +16 -4
  7. package/recall-inject.js +86 -86
  8. package/vendor/tdai/adapters/index.js +15 -15
  9. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  10. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  11. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  12. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  13. package/vendor/tdai/adapters/standalone/index.js +5 -5
  14. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  15. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  16. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  17. package/vendor/tdai/core/index.js +8 -8
  18. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  19. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  20. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  21. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  22. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  23. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  24. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  25. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  26. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  27. package/vendor/tdai/core/record/l1-reader.js +178 -178
  28. package/vendor/tdai/core/record/l1-writer.js +141 -141
  29. package/vendor/tdai/core/report/reporter.js +86 -86
  30. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  31. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  32. package/vendor/tdai/core/scene/scene-format.js +53 -53
  33. package/vendor/tdai/core/scene/scene-index.js +82 -82
  34. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  35. package/vendor/tdai/core/seed/input.js +404 -404
  36. package/vendor/tdai/core/seed/types.js +9 -9
  37. package/vendor/tdai/core/store/bm25-client.js +117 -117
  38. package/vendor/tdai/core/store/bm25-local.js +68 -68
  39. package/vendor/tdai/core/store/embedding.js +632 -632
  40. package/vendor/tdai/core/store/factory.js +95 -95
  41. package/vendor/tdai/core/store/search-utils.js +54 -54
  42. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  43. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  44. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  45. package/vendor/tdai/core/store/types.js +17 -17
  46. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  47. package/vendor/tdai/core/tools/memory-search.js +207 -207
  48. package/vendor/tdai/core/types.js +12 -12
  49. package/vendor/tdai/utils/backup.js +189 -189
  50. package/vendor/tdai/utils/checkpoint.js +360 -360
  51. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  52. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  53. package/vendor/tdai/utils/env.js +13 -13
  54. package/vendor/tdai/utils/managed-timer.js +127 -127
  55. package/vendor/tdai/utils/manifest.js +91 -91
  56. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  57. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  58. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  59. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  60. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  61. package/vendor/tdai/utils/sanitize.js +360 -360
  62. package/vendor/tdai/utils/serial-queue.js +103 -103
  63. package/vendor/tdai/utils/session-filter.js +88 -88
  64. package/vendor/tdai/utils/text-utils.js +29 -29
  65. package/vendor/tdai/utils/time.js +252 -252
@@ -1,892 +1,892 @@
1
- /**
2
- * MemoryPipelineManager: manages the L0→L1→L2→L3 memory extraction pipeline.
3
- *
4
- * ## Layered architecture
5
- *
6
- * - **L0 (capture)**: `auto-capture.ts` extracts new messages from each
7
- * `agent_end` event, sanitizes them, and passes them to the pipeline via
8
- * `notifyConversation(sessionKey, messages)`. Messages are buffered
9
- * locally per-session — NO remote call happens at this stage.
10
- *
11
- * - **L1 (batch extraction / ingest)**: When the conversation count reaches
12
- * `everyNConversations` OR the session goes idle for `l1IdleTimeoutSeconds`,
13
- * the L1 Runner is invoked with all buffered messages. The runner receives
14
- * `{ sessionKey, msg, bg_msg }` and is responsible for ingesting/extracting
15
- * them (e.g. calling appendEvent, or running local extraction logic).
16
- * `bg_msg` is reserved for background context; currently always empty.
17
- *
18
- * - **L2 (scene extraction)**: Per-session downward-only timer. After each
19
- * L2 completion, the next fire time is set to `now + maxInterval`. When
20
- * L1 completes (new memory event), the fire time is advanced (but never
21
- * postponed) to `max(now + delay, lastL2 + minInterval)`. When the timer
22
- * fires, if the session is cold (inactive > `sessionActiveWindowHours`),
23
- * the timer is cancelled rather than triggering L2 — it will be re-armed
24
- * by the next L1 event.
25
- *
26
- * - **L3 (persona generation)**: Global mutex (concurrency=1) + pending flag
27
- * dedup. Triggered after L2 completes.
28
- *
29
- * ## Timer semantics
30
- *
31
- * L1 uses a **resettable timer** (classic idle/debounce): each conversation
32
- * resets the countdown to `l1IdleTimeoutSeconds`. When the timer fires,
33
- * buffered messages are flushed through L1.
34
- *
35
- * L2 uses a **downward-only timer**: the scheduled fire time can only be
36
- * moved earlier, never later. This ensures both the maxInterval guarantee
37
- * and the delay-after-L1 responsiveness, while minInterval acts as a floor.
38
- *
39
- * Both timer types are implemented via `ManagedTimer` to eliminate
40
- * repetitive clear→set→fire→clean boilerplate.
41
- *
42
- * ## Trigger paths for L1
43
- * A. **Conversation threshold** (primary): when `conversation_count >=
44
- * effectiveThreshold` in `notifyConversation()`, L1 is triggered
45
- * immediately with all buffered messages. The effective threshold
46
- * is influenced by warm-up mode (see below).
47
- * B. **Idle timeout** (catch-up): when a session goes idle for
48
- * `l1IdleTimeoutSeconds`, L1 fires with whatever messages have
49
- * been buffered (below threshold).
50
- * C. **Shutdown flush**: on graceful shutdown, all pending buffers
51
- * are flushed through L1 then L2.
52
- *
53
- * ## Warm-up mode
54
- *
55
- * When `enableWarmup` is true (default), new sessions use an exponentially
56
- * increasing L1 trigger threshold instead of jumping straight to
57
- * `everyNConversations`. The sequence is: 1 → 2 → 4 → 8 → ... →
58
- * everyNConversations. This ensures early conversations are processed
59
- * quickly (first conversation triggers L1 immediately), while gradually
60
- * reducing processing frequency as the session matures.
61
- *
62
- * The `warmup_threshold` field in PipelineSessionState tracks the current
63
- * threshold. A value of 0 means warm-up is complete (graduated to
64
- * steady-state). The threshold doubles after each successful L1 run.
65
- *
66
- * ## Trigger paths for L2
67
- * A. **Delay-after-L1**: L1 completes → timer advanced to
68
- * `max(now + delay, lastL2 + min)` → fires → enqueue L2.
69
- * B. **MaxInterval guarantee**: L2 completes → timer set to
70
- * `now + maxInterval` → fires → enqueue L2 (if session active).
71
- * C. **Shutdown flush**: all pending L2 timers are flushed.
72
- *
73
- * All queues use SerialQueue (concurrency=1) for serial execution.
74
- *
75
- * ## Design doc
76
- * See `docs/08-pipeline-refactor-design.md` for full architecture.
77
- */
78
- import { SessionFilter } from "./session-filter.js";
79
- import { ManagedTimer } from "./managed-timer.js";
80
- import { SerialQueue } from "./serial-queue.js";
81
- import { report } from "../core/report/reporter.js";
82
- const TAG = "[memory-tdai] [pipeline]";
83
- export class MemoryPipelineManager {
84
- // Config (converted to ms internally)
85
- l1IdleTimeoutMs;
86
- everyNConversations;
87
- enableWarmup;
88
- l2DelayAfterL1Ms;
89
- l2MinIntervalMs;
90
- l2MaxIntervalMs;
91
- sessionActiveWindowMs;
92
- /** Delay before retrying a failed L1 (ms). */
93
- L1_RETRY_DELAY_MS = 30_000; // 30 seconds
94
- /** Max consecutive L1 retries per session before giving up. */
95
- L1_MAX_RETRIES = 5;
96
- // Queues (named for diagnostics)
97
- l1Queue = new SerialQueue("L1");
98
- l2Queue = new SerialQueue("L2");
99
- l3Queue = new SerialQueue("L3");
100
- // L3 dedup flag
101
- l3Pending = false;
102
- l3Running = false;
103
- // Per-session state
104
- sessionStates = new Map();
105
- sessionTimers = new Map();
106
- // Per-session message buffer: messages accumulated since last L1 run
107
- messageBuffers = new Map();
108
- // Per-session L2 last run time (epoch ms, for minInterval floor)
109
- l2LastRunTime = new Map();
110
- // Callbacks
111
- l1Runner = null;
112
- l2Runner = null;
113
- l3Runner = null;
114
- persister = null;
115
- logger;
116
- // Unified session filter (internal sessions + excludeAgents)
117
- sessionFilter;
118
- // Lifecycle
119
- destroyed = false;
120
- /** Plugin instance ID for metric reporting (set externally after async init). */
121
- instanceId;
122
- // Session GC: runs periodically to evict cold sessions from memory
123
- /** Multiplier on sessionActiveWindowMs to determine GC eligibility. */
124
- SESSION_GC_INACTIVE_MULTIPLIER = 3;
125
- /** Run GC every N calls to notifyConversation. */
126
- SESSION_GC_EVERY_N_NOTIFICATIONS = 50;
127
- /** Counter for GC scheduling. */
128
- notifyCounter = 0;
129
- constructor(config, logger, sessionFilter) {
130
- this.l1IdleTimeoutMs = config.l1.idleTimeoutSeconds * 1000;
131
- this.everyNConversations = config.everyNConversations;
132
- this.enableWarmup = config.enableWarmup;
133
- this.l2DelayAfterL1Ms = config.l2.delayAfterL1Seconds * 1000;
134
- this.l2MinIntervalMs = config.l2.minIntervalSeconds * 1000;
135
- this.l2MaxIntervalMs = config.l2.maxIntervalSeconds * 1000;
136
- this.sessionActiveWindowMs = config.l2.sessionActiveWindowHours * 60 * 60 * 1000;
137
- this.logger = logger;
138
- this.sessionFilter = sessionFilter ?? new SessionFilter();
139
- this.logger?.debug?.(`${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` +
140
- `warmup=${config.enableWarmup ? "enabled" : "disabled"}, ` +
141
- `l1IdleTimeout=${config.l1.idleTimeoutSeconds}s, ` +
142
- `l2DelayAfterL1=${config.l2.delayAfterL1Seconds}s, ` +
143
- `l2MinInterval=${config.l2.minIntervalSeconds}s, ` +
144
- `l2MaxInterval=${config.l2.maxIntervalSeconds}s, ` +
145
- `sessionActiveWindow=${config.l2.sessionActiveWindowHours}h`);
146
- // Wire up queue debug logging
147
- if (this.logger?.debug) {
148
- const debugFn = (msg) => this.logger?.debug?.(`${TAG} ${msg}`);
149
- this.l1Queue.setDebugLogger(debugFn);
150
- this.l2Queue.setDebugLogger(debugFn);
151
- this.l3Queue.setDebugLogger(debugFn);
152
- }
153
- }
154
- // ============================
155
- // Setup
156
- // ============================
157
- setL1Runner(runner) {
158
- this.l1Runner = runner;
159
- }
160
- setL2Runner(runner) {
161
- this.l2Runner = runner;
162
- }
163
- setL3Runner(runner) {
164
- this.l3Runner = runner;
165
- }
166
- setPersister(persister) {
167
- this.persister = persister;
168
- }
169
- /**
170
- * Restore session states from checkpoint and start the pipeline.
171
- * Sessions with pending counts will be immediately re-enqueued.
172
- */
173
- start(restoredStates) {
174
- if (this.destroyed)
175
- return;
176
- if (restoredStates) {
177
- let skipped = 0;
178
- for (const [sessionKey, state] of Object.entries(restoredStates)) {
179
- if (this.sessionFilter.shouldSkip(sessionKey)) {
180
- skipped++;
181
- continue;
182
- }
183
- // Backfill warmup_threshold for sessions persisted before warm-up feature.
184
- // Missing field → treat as graduated (warmup already complete).
185
- const patched = { ...state };
186
- if (patched.warmup_threshold == null) {
187
- patched.warmup_threshold = 0;
188
- }
189
- this.sessionStates.set(sessionKey, patched);
190
- }
191
- this.logger?.info(`${TAG} Restored ${this.sessionStates.size} session state(s) from checkpoint` +
192
- (skipped > 0 ? ` (filtered ${skipped} internal)` : ""));
193
- }
194
- // Recovery: re-enqueue sessions with pending work
195
- this.recoverPendingSessions();
196
- this.logger?.info(`${TAG} Pipeline started`);
197
- }
198
- // ============================
199
- // L0→L1: Notify (called from auto-capture on agent_end)
200
- // ============================
201
- /**
202
- * Get the effective conversation threshold for a session, considering warm-up.
203
- *
204
- * When warm-up is enabled, new sessions start with threshold=1 and double
205
- * after each successful L1 run: 1 → 2 → 4 → 8 → ... → everyNConversations.
206
- * Once the threshold reaches everyNConversations, warm-up is considered complete
207
- * (warmup_threshold is set to 0) and the fixed config value is used.
208
- */
209
- getEffectiveThreshold(state) {
210
- if (!this.enableWarmup)
211
- return this.everyNConversations;
212
- // warmup_threshold === 0 means warm-up completed; use steady-state config
213
- if (state.warmup_threshold <= 0)
214
- return this.everyNConversations;
215
- return Math.min(state.warmup_threshold, this.everyNConversations);
216
- }
217
- /**
218
- * Advance the warm-up threshold for a session after a successful L1 run.
219
- * Doubles the threshold until it reaches everyNConversations, then marks
220
- * warm-up as complete (warmup_threshold = 0).
221
- */
222
- advanceWarmupThreshold(state) {
223
- if (!this.enableWarmup)
224
- return;
225
- if (state.warmup_threshold <= 0)
226
- return; // already graduated
227
- const next = state.warmup_threshold * 2;
228
- if (next >= this.everyNConversations) {
229
- // Graduated: switch to steady-state
230
- state.warmup_threshold = 0;
231
- this.logger?.debug?.(`${TAG} Warm-up graduated → using steady-state threshold ${this.everyNConversations}`);
232
- }
233
- else {
234
- state.warmup_threshold = next;
235
- this.logger?.debug?.(`${TAG} Warm-up advanced → next threshold ${next}`);
236
- }
237
- }
238
- /**
239
- * Notify the pipeline that a conversation round has ended for a session,
240
- * and buffer the captured messages for L1 batch processing.
241
- *
242
- * Two trigger paths start here:
243
- * - **Path A (threshold)**: if conversation_count >= effective threshold
244
- * (warm-up or steady-state), trigger L1 immediately with all buffered messages.
245
- * - **Path B (idle)**: reset the L1 idle timer. When the timer fires (user
246
- * stops chatting), L1 runs with whatever has been buffered.
247
- */
248
- async notifyConversation(sessionKey, messages) {
249
- if (this.destroyed)
250
- return;
251
- if (this.sessionFilter.shouldSkip(sessionKey))
252
- return;
253
- const state = this.getOrCreateState(sessionKey);
254
- state.conversation_count += 1;
255
- state.last_active_time = Date.now();
256
- // Reset L1 retry count on new conversation (environment may have recovered)
257
- const timers = this.getOrCreateTimers(sessionKey);
258
- timers.l1RetryCount = 0;
259
- // Buffer messages for L1
260
- const buffer = this.messageBuffers.get(sessionKey) ?? [];
261
- buffer.push(...messages);
262
- this.messageBuffers.set(sessionKey, buffer);
263
- const effectiveThreshold = this.getEffectiveThreshold(state);
264
- const warmupInfo = this.enableWarmup && state.warmup_threshold > 0
265
- ? ` (warmup: ${state.warmup_threshold})`
266
- : "";
267
- this.logger?.debug?.(`${TAG} [${sessionKey}] notify: conversation_count=${state.conversation_count}/${effectiveThreshold}${warmupInfo}, ` +
268
- `buffered_messages=${buffer.length} (+${messages.length} new)`);
269
- await this.persistStates();
270
- // Path A: conversation count reached effective threshold → trigger L1 batch
271
- if (state.conversation_count >= effectiveThreshold) {
272
- this.logger?.debug?.(`${TAG} [${sessionKey}] Conversation threshold reached (${state.conversation_count}>=${effectiveThreshold}${warmupInfo}), triggering L1`);
273
- this.enqueueL1(sessionKey);
274
- return; // skip idle timer reset — L1 is already triggered
275
- }
276
- // Path B: below threshold → reset L1 idle timer (catch residual later)
277
- timers.l1Idle.schedule(this.l1IdleTimeoutMs, () => this.onL1IdleTimeout(sessionKey));
278
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timer reset (${this.l1IdleTimeoutMs / 1000}s)`);
279
- // Periodic GC: evict cold sessions from memory
280
- this.notifyCounter += 1;
281
- if (this.notifyCounter >= this.SESSION_GC_EVERY_N_NOTIFICATIONS) {
282
- this.notifyCounter = 0;
283
- this.gcStaleSessions();
284
- }
285
- }
286
- // ============================
287
- // Graceful shutdown
288
- // ============================
289
- /**
290
- * Per-session flush — scoped end-of-session handling.
291
- *
292
- * Semantically different from {@link destroy}:
293
- * - ``destroy`` tears down the *whole* scheduler (meant for process
294
- * shutdown such as OpenClaw's ``gateway_stop``).
295
- * - ``flushSession`` only processes the one session identified by
296
- * ``sessionKey`` and leaves every other session's timers, buffers
297
- * and pipeline state untouched. This is the correct semantic for
298
- * the Gateway's ``POST /session/end`` endpoint and for Hermes'
299
- * ``on_session_end`` callback, which fire when one conversation
300
- * ends while the process keeps serving other concurrent sessions.
301
- *
302
- * What it does:
303
- * 1. Cancel the session's pending L1 idle timer (no further idle
304
- * fires for this key).
305
- * 2. If the session's message buffer still holds work, enqueue an
306
- * immediate L1 run for this session (``triggerReason="flush"``).
307
- * 3. Await the shared ``l1Queue`` so the caller observes L1
308
- * completion before returning. We do not selectively wait
309
- * because L1 is already a single-consumer SerialQueue — waiting
310
- * for ``onIdle`` is the cheapest correct signal.
311
- *
312
- * What it deliberately does NOT do:
313
- * - Touch other sessions' timers / buffers / pipeline state.
314
- * - Destroy the scheduler or any of its queues.
315
- * - Reset global fields such as ``destroyed``.
316
- *
317
- * Unknown session keys are a no-op: the scheduler may legitimately
318
- * have evicted the session earlier via GC, or the session may never
319
- * have produced any captures.
320
- */
321
- async flushSession(sessionKey) {
322
- if (this.destroyed)
323
- return;
324
- if (this.sessionFilter.shouldSkip(sessionKey))
325
- return;
326
- const timers = this.sessionTimers.get(sessionKey);
327
- const buffer = this.messageBuffers.get(sessionKey);
328
- // Step 1: cancel the idle timer so it won't fire after we return.
329
- if (timers?.l1Idle.pending) {
330
- timers.l1Idle.cancel();
331
- }
332
- // Step 2: flush pending buffered messages through L1 if any.
333
- if (buffer && buffer.length > 0) {
334
- this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: enqueuing L1 for ${buffer.length} buffered message(s)`);
335
- this.enqueueL1(sessionKey, "flush");
336
- }
337
- // Step 3: wait for L1 to drain. L1 is a single-consumer SerialQueue
338
- // so this is the cheapest correct signal; it will not starve other
339
- // sessions because any cross-session interleaving L1 work was either
340
- // already queued or will be queued concurrently by their own capture
341
- // paths.
342
- await this.l1Queue.onIdle();
343
- this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: complete`);
344
- }
345
- /**
346
- * Maximum time (ms) to wait for pipeline flush during destroy.
347
- * Must be shorter than the gateway_stop hook timeout (3 s) to leave
348
- * headroom for VectorStore / EmbeddingService cleanup that runs after.
349
- */
350
- DESTROY_TIMEOUT_MS = 2_000;
351
- /**
352
- * Graceful shutdown with timeout protection:
353
- * 1. Mark destroyed, stop accepting new work
354
- * 2. Attempt to flush pending L1/L2/L3 work within DESTROY_TIMEOUT_MS
355
- * 3. If flush times out or fails, persist current state for recovery on next startup
356
- * 4. Pending work is never lost — it will be recovered via checkpoint on next start()
357
- */
358
- async destroy() {
359
- if (this.destroyed)
360
- return;
361
- this.destroyed = true;
362
- this.logger?.info(`${TAG} Destroying pipeline (timeout=${this.DESTROY_TIMEOUT_MS}ms)...`);
363
- try {
364
- let timeoutId;
365
- await Promise.race([
366
- this._doFlush(),
367
- new Promise((_, reject) => {
368
- timeoutId = setTimeout(() => reject(new Error("destroy timeout")), this.DESTROY_TIMEOUT_MS);
369
- }),
370
- ]).finally(() => {
371
- if (timeoutId !== undefined)
372
- clearTimeout(timeoutId);
373
- });
374
- this.logger?.info(`${TAG} Pipeline flushed successfully`);
375
- }
376
- catch (err) {
377
- this.logger?.warn(`${TAG} Pipeline flush timed out or failed: ${err instanceof Error ? err.message : String(err)}. ` +
378
- `Pending work will be recovered on next startup.`);
379
- }
380
- // Always persist state — whether flush succeeded, timed out, or failed.
381
- // This ensures pending work (buffered messages, L2 pending counts) is
382
- // saved to checkpoint and can be recovered by recoverPendingSessions().
383
- try {
384
- await this.persistStates();
385
- }
386
- catch (err) {
387
- this.logger?.error(`${TAG} Failed to persist states during destroy: ${err instanceof Error ? err.message : String(err)}`);
388
- }
389
- this.logger?.info(`${TAG} Pipeline destroyed`);
390
- }
391
- /**
392
- * Internal: attempt to flush all pending pipeline work (L1 → L2 → L3).
393
- * Extracted from destroy() so it can be wrapped with a timeout.
394
- */
395
- async _doFlush() {
396
- // Step 1: Flush all L1 idle timers — only enqueue if there are buffered messages
397
- for (const [sessionKey, timers] of this.sessionTimers) {
398
- if (timers.l1Idle.pending) {
399
- timers.l1Idle.cancel(); // don't fire the idle callback directly
400
- const buffer = this.messageBuffers.get(sessionKey);
401
- if (buffer && buffer.length > 0) {
402
- this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: enqueuing L1 for ${buffer.length} buffered messages`);
403
- this.enqueueL1(sessionKey, "flush");
404
- }
405
- }
406
- }
407
- // Step 2: Wait for L1 queue to drain
408
- this.logger?.debug?.(`${TAG} Waiting for L1 queue to drain (size=${this.l1Queue.size})`);
409
- await this.l1Queue.onIdle();
410
- // Step 3: Flush all L2 schedule timers
411
- for (const [sessionKey, timers] of this.sessionTimers) {
412
- if (timers.l2Schedule.pending) {
413
- this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: triggering L2 schedule timer`);
414
- timers.l2Schedule.flush();
415
- }
416
- }
417
- // Step 4: Wait for all remaining queues to drain
418
- this.logger?.debug?.(`${TAG} Waiting for queues to drain (l2=${this.l2Queue.size}, l3=${this.l3Queue.size})`);
419
- await Promise.all([
420
- this.l2Queue.onIdle(),
421
- this.l3Queue.onIdle(),
422
- ]);
423
- }
424
- // ============================
425
- // Internal: L1 idle timeout handler
426
- // ============================
427
- onL1IdleTimeout(sessionKey) {
428
- const buffer = this.messageBuffers.get(sessionKey);
429
- const state = this.sessionStates.get(sessionKey);
430
- if ((!buffer || buffer.length === 0) && (!state || state.conversation_count === 0)) {
431
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout but no pending messages or conversations`);
432
- return;
433
- }
434
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout fired (buffered=${buffer?.length ?? 0}, conversations=${state?.conversation_count ?? 0})`);
435
- this.enqueueL1(sessionKey, "idle_timeout");
436
- }
437
- // ============================
438
- // Internal: L1 queue
439
- // ============================
440
- enqueueL1(sessionKey, triggerReason = "threshold") {
441
- const timers = this.getOrCreateTimers(sessionKey);
442
- // Don't double-queue
443
- if (timers.l1Queued) {
444
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 already queued, skipping`);
445
- return;
446
- }
447
- // Cancel idle timer if running (threshold beat it)
448
- timers.l1Idle.cancel();
449
- timers.l1Queued = true;
450
- this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L1 (queue=${this.l1Queue.name})`);
451
- // ── pipeline_l1_trigger metric ──
452
- const state = this.sessionStates.get(sessionKey);
453
- const buffer = this.messageBuffers.get(sessionKey);
454
- if (this.instanceId && this.logger) {
455
- report("pipeline_l1_trigger", {
456
- sessionKey,
457
- triggerReason,
458
- conversationCount: state?.conversation_count ?? 0,
459
- bufferedMessageCount: buffer?.length ?? 0,
460
- });
461
- }
462
- this.l1Queue.add(async () => {
463
- await this.runL1(sessionKey);
464
- }).catch((err) => {
465
- this.logger?.error(`${TAG} [${sessionKey}] L1 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
466
- }).finally(() => {
467
- timers.l1Queued = false;
468
- });
469
- }
470
- /**
471
- * L1 runner: Takes all buffered messages for a session and passes them
472
- * to the L1Runner for batch processing (e.g. appendEvent, local extraction).
473
- *
474
- * After L1 completes successfully:
475
- * - conversation_count and message buffer are reset
476
- * - L2 timer is advanced (downward-only) to allow remote record generation
477
- *
478
- * If L1 fails, conversation_count and buffer are preserved for retry
479
- * on next idle timeout or threshold trigger.
480
- */
481
- async runL1(sessionKey) {
482
- const state = this.sessionStates.get(sessionKey);
483
- if (!state)
484
- return;
485
- // Drain the message buffer (take ownership, clear the shared ref)
486
- const buffer = this.messageBuffers.get(sessionKey) ?? [];
487
- this.messageBuffers.set(sessionKey, []);
488
- if (buffer.length === 0 && state.conversation_count === 0) {
489
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 skipped: no messages and no pending conversations`);
490
- return;
491
- }
492
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 running: messages=${buffer.length}, conversation_count=${state.conversation_count}`);
493
- if (!this.l1Runner) {
494
- this.logger?.warn(`${TAG} [${sessionKey}] No L1 runner set, skipping`);
495
- state.l2_pending_l1_count = state.conversation_count;
496
- state.conversation_count = 0;
497
- this.advanceWarmupThreshold(state);
498
- await this.persistStates();
499
- this.advanceL2Timer(sessionKey);
500
- return;
501
- }
502
- try {
503
- await this.l1Runner({
504
- sessionKey,
505
- msg: buffer,
506
- bg_msg: [], // reserved for future use
507
- });
508
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 complete: processed ${buffer.length} messages`);
509
- }
510
- catch (err) {
511
- this.logger?.error(`${TAG} [${sessionKey}] L1 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
512
- // On failure: put messages back into the buffer for retry
513
- const currentBuffer = this.messageBuffers.get(sessionKey) ?? [];
514
- this.messageBuffers.set(sessionKey, [...buffer, ...currentBuffer]);
515
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 failure: restored ${buffer.length} messages to buffer (total=${buffer.length + currentBuffer.length})`);
516
- // Re-arm L1 idle timer for automatic retry (with max retry limit)
517
- const timers = this.getOrCreateTimers(sessionKey);
518
- timers.l1RetryCount += 1;
519
- if (timers.l1RetryCount <= this.L1_MAX_RETRIES) {
520
- timers.l1Idle.schedule(this.L1_RETRY_DELAY_MS, () => this.onL1IdleTimeout(sessionKey));
521
- this.logger?.debug?.(`${TAG} [${sessionKey}] L1 retry scheduled in ${this.L1_RETRY_DELAY_MS / 1000}s ` +
522
- `(attempt ${timers.l1RetryCount}/${this.L1_MAX_RETRIES})`);
523
- }
524
- else {
525
- this.logger?.warn(`${TAG} [${sessionKey}] L1 max retries reached (${this.L1_MAX_RETRIES}), ` +
526
- `giving up auto-retry. ${buffer.length + currentBuffer.length} messages remain buffered. ` +
527
- `Will resume on next user conversation.`);
528
- }
529
- return; // don't advance state or trigger L2
530
- }
531
- // Success: reset retry count and advance state
532
- const timers = this.getOrCreateTimers(sessionKey);
533
- timers.l1RetryCount = 0;
534
- state.l2_pending_l1_count = state.conversation_count;
535
- state.conversation_count = 0;
536
- this.advanceWarmupThreshold(state);
537
- await this.persistStates();
538
- // Advance the L2 timer (downward-only) to fire after delay, respecting minInterval
539
- this.advanceL2Timer(sessionKey);
540
- }
541
- // ============================
542
- // Internal: L2 timer management (downward-only)
543
- // ============================
544
- /**
545
- * Advance the per-session L2 timer after an L1 event (new memory generated).
546
- *
547
- * Computes the desired fire time as:
548
- * T_desired = max(now + l2DelayAfterL1, lastL2Time + l2MinInterval)
549
- *
550
- * The timer is only moved if T_desired is earlier than the current schedule
551
- * (downward-only semantics). If no timer is pending, it's set unconditionally.
552
- */
553
- advanceL2Timer(sessionKey) {
554
- if (this.destroyed)
555
- return;
556
- const timers = this.getOrCreateTimers(sessionKey);
557
- const now = Date.now();
558
- // Compute the floor: lastL2 + minInterval (rate-limit protection)
559
- const lastL2 = this.l2LastRunTime.get(sessionKey) ?? 0;
560
- const minIntervalFloor = lastL2 > 0 ? lastL2 + this.l2MinIntervalMs : 0;
561
- // Desired fire time: delay after L1, but no earlier than minInterval floor
562
- const desiredTime = Math.max(now + this.l2DelayAfterL1Ms, minIntervalFloor);
563
- const advanced = timers.l2Schedule.tryAdvanceTo(desiredTime, () => this.onL2TimerFired(sessionKey, "delay-after-l1"));
564
- if (advanced) {
565
- const delaySec = Math.round((desiredTime - now) / 1000);
566
- this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer advanced: firing in ${delaySec}s` +
567
- (timers.l2Schedule.scheduledTime > 0
568
- ? ` (was ${Math.round((timers.l2Schedule.scheduledTime - now) / 1000)}s)`
569
- : " (newly armed)"));
570
- }
571
- else {
572
- this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer not advanced: current schedule is already earlier`);
573
- }
574
- }
575
- /**
576
- * Arm the L2 timer for the maxInterval guarantee after L2 completes.
577
- * Sets T = now + l2MaxInterval (unconditional, replaces any pending timer).
578
- */
579
- armL2MaxInterval(sessionKey) {
580
- if (this.destroyed)
581
- return;
582
- const timers = this.getOrCreateTimers(sessionKey);
583
- const fireAt = Date.now() + this.l2MaxIntervalMs;
584
- timers.l2Schedule.scheduleAt(fireAt, () => this.onL2TimerFired(sessionKey, "max-interval"));
585
- this.logger?.debug?.(`${TAG} [${sessionKey}] L2 maxInterval timer armed: ${Math.round(this.l2MaxIntervalMs / 1000)}s`);
586
- }
587
- /**
588
- * Called when a per-session L2 timer fires.
589
- *
590
- * Checks session activity: if the session is cold (inactive > activeWindow),
591
- * the timer is NOT re-armed — it will be revived by the next L1 event.
592
- * Otherwise, enqueues L2.
593
- *
594
- * The `source` parameter distinguishes the trigger origin:
595
- * - "delay-after-l1": fired shortly after L1 completed — skip cold check
596
- * because L1 completion itself proves recent activity.
597
- * - "max-interval": periodic timer — apply cold check normally.
598
- */
599
- onL2TimerFired(sessionKey, source) {
600
- const state = this.sessionStates.get(sessionKey);
601
- if (!state)
602
- return;
603
- const now = Date.now();
604
- // Cold session check: only applies to periodic (maxInterval) triggers.
605
- // Delay-after-L1 triggers are exempt because L1 just completed, proving
606
- // the session was recently active.
607
- if (source === "max-interval" && now - state.last_active_time >= this.sessionActiveWindowMs) {
608
- this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer fired but session is cold ` +
609
- `(inactive ${Math.round((now - state.last_active_time) / 3600_000)}h), timer stopped. ` +
610
- `Will re-arm on next L1 event.`);
611
- return; // timer not re-armed — advanceL2Timer() in runL1 will revive it
612
- }
613
- this.enqueueL2(sessionKey, `timer:${source}`);
614
- }
615
- // ============================
616
- // Internal: L2 queue
617
- // ============================
618
- enqueueL2(sessionKey, trigger) {
619
- const timers = this.getOrCreateTimers(sessionKey);
620
- // Cancel any pending L2 timer (we're about to run L2)
621
- timers.l2Schedule.cancel();
622
- // Conflict detection: warn if L2 is already queued
623
- if (timers.l2Queued) {
624
- this.logger?.warn(`${TAG} [${sessionKey}] L2 enqueue conflict on queue "${this.l2Queue.name}": ` +
625
- `task already queued/running (trigger=${trigger}), skipping`);
626
- return;
627
- }
628
- timers.l2Queued = true;
629
- this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L2 (trigger=${trigger}, queue=${this.l2Queue.name})`);
630
- this.l2Queue.add(async () => {
631
- await this.runL2(sessionKey);
632
- }).catch((err) => {
633
- this.logger?.error(`${TAG} [${sessionKey}] L2 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
634
- }).finally(() => {
635
- timers.l2Queued = false;
636
- });
637
- }
638
- async runL2(sessionKey) {
639
- const state = this.sessionStates.get(sessionKey);
640
- if (!state)
641
- return;
642
- if (!this.l2Runner) {
643
- this.logger?.warn(`${TAG} [${sessionKey}] No L2 runner set, skipping`);
644
- return;
645
- }
646
- this.logger?.debug?.(`${TAG} [${sessionKey}] L2 running: l2_pending_l1_count=${state.l2_pending_l1_count}`);
647
- const cursor = state.last_extraction_updated_time || undefined;
648
- let result;
649
- try {
650
- result = await this.l2Runner(sessionKey, cursor);
651
- }
652
- catch (err) {
653
- this.logger?.error(`${TAG} [${sessionKey}] L2 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
654
- // Even on failure, arm maxInterval so we retry eventually
655
- this.armL2MaxInterval(sessionKey);
656
- return;
657
- }
658
- // After L2: update state
659
- const now = Date.now();
660
- state.l2_pending_l1_count = 0;
661
- // Cold-start optimization: if this is the very first L2 run for this session
662
- // and it was skipped (no new records), do NOT update l2LastRunTime.
663
- // This prevents l2MinIntervalSeconds from blocking the next L2 trigger
664
- // when the first L1 extraction produces actual memories shortly after.
665
- const isFirstL2 = !this.l2LastRunTime.has(sessionKey);
666
- const wasSkipped = result?.skipped === true;
667
- if (isFirstL2 && wasSkipped) {
668
- this.logger?.info?.(`${TAG} [${sessionKey}] L2 cold-start skip: not updating l2LastRunTime ` +
669
- `(minInterval won't block next trigger)`);
670
- this.armL2MaxInterval(sessionKey);
671
- await this.persistStates();
672
- return;
673
- }
674
- state.last_extraction_time = new Date().toISOString();
675
- state.l2_last_extraction_time = new Date().toISOString();
676
- this.l2LastRunTime.set(sessionKey, now);
677
- // Advance cursor using the record timestamp returned by the runner
678
- if (result?.latestCursor) {
679
- state.last_extraction_updated_time = result.latestCursor;
680
- }
681
- else if (!state.last_extraction_updated_time) {
682
- // Cold-start guard: if runner returned void (e.g. extraction failure) and
683
- // last_extraction_updated_time is still empty, initialize it to now so
684
- // the next L2 run doesn't do a full table scan.
685
- state.last_extraction_updated_time = new Date().toISOString();
686
- }
687
- await this.persistStates();
688
- this.logger?.debug?.(`${TAG} [${sessionKey}] L2 complete`);
689
- // Arm the maxInterval timer for the next cycle
690
- this.armL2MaxInterval(sessionKey);
691
- // Trigger L3
692
- this.triggerL3();
693
- }
694
- // ============================
695
- // Internal: L3 queue (global, dedup)
696
- // ============================
697
- triggerL3() {
698
- if (this.destroyed)
699
- return;
700
- if (this.l3Running) {
701
- // L3 is in progress — mark pending so it runs again after current finishes
702
- this.l3Pending = true;
703
- this.logger?.debug?.(`${TAG} L3 already running, marking pending`);
704
- return;
705
- }
706
- this.logger?.debug?.(`${TAG} Triggering L3`);
707
- this.enqueueL3();
708
- }
709
- enqueueL3() {
710
- this.l3Running = true;
711
- this.l3Pending = false;
712
- this.logger?.debug?.(`${TAG} Enqueuing L3 (queue=${this.l3Queue.name})`);
713
- this.l3Queue.add(async () => {
714
- await this.runL3();
715
- }).catch((err) => {
716
- this.logger?.error(`${TAG} L3 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
717
- }).finally(() => {
718
- this.l3Running = false;
719
- // If new L2 completions happened while L3 was running, run again
720
- if (this.l3Pending && !this.destroyed) {
721
- this.logger?.debug?.(`${TAG} L3 has pending work, re-running`);
722
- this.enqueueL3();
723
- }
724
- });
725
- }
726
- async runL3() {
727
- if (!this.l3Runner) {
728
- this.logger?.warn(`${TAG} No L3 runner set, skipping`);
729
- return;
730
- }
731
- this.logger?.debug?.(`${TAG} L3 running`);
732
- try {
733
- await this.l3Runner();
734
- this.logger?.debug?.(`${TAG} L3 complete`);
735
- }
736
- catch (err) {
737
- this.logger?.error(`${TAG} L3 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
738
- }
739
- }
740
- // ============================
741
- // Internal: state management
742
- // ============================
743
- getOrCreateState(sessionKey) {
744
- let state = this.sessionStates.get(sessionKey);
745
- if (!state) {
746
- state = {
747
- conversation_count: 0,
748
- last_extraction_time: "",
749
- last_extraction_updated_time: "",
750
- last_active_time: Date.now(),
751
- l2_pending_l1_count: 0,
752
- warmup_threshold: this.enableWarmup ? 1 : 0,
753
- l2_last_extraction_time: "",
754
- };
755
- this.sessionStates.set(sessionKey, state);
756
- this.logger?.debug?.(`${TAG} [${sessionKey}] Created new session state`);
757
- }
758
- return state;
759
- }
760
- getOrCreateTimers(sessionKey) {
761
- let timers = this.sessionTimers.get(sessionKey);
762
- if (!timers) {
763
- const isDestroyed = () => this.destroyed;
764
- timers = {
765
- l1Idle: new ManagedTimer(`L1-idle:${sessionKey}`, isDestroyed),
766
- l2Schedule: new ManagedTimer(`L2-schedule:${sessionKey}`, isDestroyed),
767
- l1Queued: false,
768
- l2Queued: false,
769
- l1RetryCount: 0,
770
- };
771
- this.sessionTimers.set(sessionKey, timers);
772
- }
773
- return timers;
774
- }
775
- async persistStates() {
776
- if (!this.persister)
777
- return;
778
- // PipelineSessionState only contains pipeline-owned fields, so we can
779
- // safely persist the entire object without risk of overwriting runner state.
780
- const obj = {};
781
- for (const [k, v] of this.sessionStates) {
782
- obj[k] = { ...v };
783
- }
784
- try {
785
- this.logger?.debug?.(`Persisting states: ${JSON.stringify(obj)}`);
786
- await this.persister(obj);
787
- }
788
- catch (err) {
789
- this.logger?.error(`${TAG} Failed to persist states: ${err instanceof Error ? err.message : String(err)}`);
790
- }
791
- }
792
- /**
793
- * Evict cold sessions from in-memory maps to prevent unbounded growth.
794
- *
795
- * A session is eligible for GC when:
796
- * 1. Inactive for > sessionActiveWindowMs * SESSION_GC_INACTIVE_MULTIPLIER
797
- * 2. No queued/running L1 or L2 tasks
798
- * 3. No buffered messages pending processing
799
- *
800
- * Evicted sessions can be fully restored from checkpoint on next
801
- * `notifyConversation()` (state) or `start()` (recovery).
802
- */
803
- gcStaleSessions() {
804
- const now = Date.now();
805
- const maxInactiveMs = this.sessionActiveWindowMs * this.SESSION_GC_INACTIVE_MULTIPLIER;
806
- let evictedCount = 0;
807
- for (const [sessionKey, state] of this.sessionStates) {
808
- if (now - state.last_active_time < maxInactiveMs)
809
- continue;
810
- // Safety: don't evict sessions with active work
811
- const timers = this.sessionTimers.get(sessionKey);
812
- if (timers?.l1Queued || timers?.l2Queued)
813
- continue;
814
- const buffer = this.messageBuffers.get(sessionKey);
815
- if (buffer && buffer.length > 0)
816
- continue;
817
- // Evict: cancel any pending timers, then remove from all maps
818
- if (timers) {
819
- timers.l1Idle.cancel();
820
- timers.l2Schedule.cancel();
821
- }
822
- this.sessionStates.delete(sessionKey);
823
- this.sessionTimers.delete(sessionKey);
824
- this.messageBuffers.delete(sessionKey);
825
- this.l2LastRunTime.delete(sessionKey);
826
- evictedCount++;
827
- }
828
- if (evictedCount > 0) {
829
- this.logger?.debug?.(`${TAG} Session GC: evicted ${evictedCount} cold session(s), ` +
830
- `${this.sessionStates.size} remaining`);
831
- }
832
- }
833
- /**
834
- * Recovery: re-enqueue sessions that have pending work from before restart.
835
- *
836
- * On restart, message buffers are empty (in-memory only). Sessions with
837
- * non-zero conversation_count had messages that were either:
838
- * 1. Already processed by L1 (l2_pending_l1_count > 0) → arm L2 timer
839
- * 2. Never reached L1 (conversation_count > 0, messages lost) → arm L2
840
- * as best-effort recovery
841
- *
842
- * We arm L2 timers (with delay) rather than enqueuing immediately,
843
- * because the pipeline may be starting during management commands.
844
- */
845
- recoverPendingSessions() {
846
- for (const [sessionKey, state] of this.sessionStates) {
847
- if (state.conversation_count === 0 && state.l2_pending_l1_count === 0)
848
- continue;
849
- this.logger?.debug?.(`${TAG} [${sessionKey}] Recovery: conversation_count=${state.conversation_count}, ` +
850
- `l2_pending_l1_count=${state.l2_pending_l1_count}, arming L2 timer`);
851
- // Reset conversation_count since we can't recover the messages
852
- state.l2_pending_l1_count = Math.max(state.l2_pending_l1_count, state.conversation_count);
853
- state.conversation_count = 0;
854
- // Arm L2 timer with delay (gives the system time to fully start)
855
- this.advanceL2Timer(sessionKey);
856
- }
857
- }
858
- // ============================
859
- // Public accessors (for testing / status)
860
- // ============================
861
- /** Get the pipeline session state for a session (read-only copy). */
862
- getSessionState(sessionKey) {
863
- const state = this.sessionStates.get(sessionKey);
864
- return state ? { ...state } : undefined;
865
- }
866
- /** Get the buffered message count for a session. */
867
- getBufferedMessageCount(sessionKey) {
868
- return this.messageBuffers.get(sessionKey)?.length ?? 0;
869
- }
870
- /** Get all session keys being tracked. */
871
- getSessionKeys() {
872
- return Array.from(this.sessionStates.keys());
873
- }
874
- /** Whether the pipeline has been destroyed. */
875
- get isDestroyed() {
876
- return this.destroyed;
877
- }
878
- /** Queue sizes and running state for monitoring. */
879
- getQueueSizes() {
880
- return {
881
- l1: this.l1Queue.size,
882
- l2: this.l2Queue.size,
883
- l3: this.l3Queue.size,
884
- l1Pending: this.l1Queue.pending,
885
- l2Pending: this.l2Queue.pending,
886
- l3Pending: this.l3Queue.pending,
887
- l1Idle: this.l1Queue.idle,
888
- l2Idle: this.l2Queue.idle,
889
- l3Idle: this.l3Queue.idle,
890
- };
891
- }
892
- }
1
+ /**
2
+ * MemoryPipelineManager: manages the L0→L1→L2→L3 memory extraction pipeline.
3
+ *
4
+ * ## Layered architecture
5
+ *
6
+ * - **L0 (capture)**: `auto-capture.ts` extracts new messages from each
7
+ * `agent_end` event, sanitizes them, and passes them to the pipeline via
8
+ * `notifyConversation(sessionKey, messages)`. Messages are buffered
9
+ * locally per-session — NO remote call happens at this stage.
10
+ *
11
+ * - **L1 (batch extraction / ingest)**: When the conversation count reaches
12
+ * `everyNConversations` OR the session goes idle for `l1IdleTimeoutSeconds`,
13
+ * the L1 Runner is invoked with all buffered messages. The runner receives
14
+ * `{ sessionKey, msg, bg_msg }` and is responsible for ingesting/extracting
15
+ * them (e.g. calling appendEvent, or running local extraction logic).
16
+ * `bg_msg` is reserved for background context; currently always empty.
17
+ *
18
+ * - **L2 (scene extraction)**: Per-session downward-only timer. After each
19
+ * L2 completion, the next fire time is set to `now + maxInterval`. When
20
+ * L1 completes (new memory event), the fire time is advanced (but never
21
+ * postponed) to `max(now + delay, lastL2 + minInterval)`. When the timer
22
+ * fires, if the session is cold (inactive > `sessionActiveWindowHours`),
23
+ * the timer is cancelled rather than triggering L2 — it will be re-armed
24
+ * by the next L1 event.
25
+ *
26
+ * - **L3 (persona generation)**: Global mutex (concurrency=1) + pending flag
27
+ * dedup. Triggered after L2 completes.
28
+ *
29
+ * ## Timer semantics
30
+ *
31
+ * L1 uses a **resettable timer** (classic idle/debounce): each conversation
32
+ * resets the countdown to `l1IdleTimeoutSeconds`. When the timer fires,
33
+ * buffered messages are flushed through L1.
34
+ *
35
+ * L2 uses a **downward-only timer**: the scheduled fire time can only be
36
+ * moved earlier, never later. This ensures both the maxInterval guarantee
37
+ * and the delay-after-L1 responsiveness, while minInterval acts as a floor.
38
+ *
39
+ * Both timer types are implemented via `ManagedTimer` to eliminate
40
+ * repetitive clear→set→fire→clean boilerplate.
41
+ *
42
+ * ## Trigger paths for L1
43
+ * A. **Conversation threshold** (primary): when `conversation_count >=
44
+ * effectiveThreshold` in `notifyConversation()`, L1 is triggered
45
+ * immediately with all buffered messages. The effective threshold
46
+ * is influenced by warm-up mode (see below).
47
+ * B. **Idle timeout** (catch-up): when a session goes idle for
48
+ * `l1IdleTimeoutSeconds`, L1 fires with whatever messages have
49
+ * been buffered (below threshold).
50
+ * C. **Shutdown flush**: on graceful shutdown, all pending buffers
51
+ * are flushed through L1 then L2.
52
+ *
53
+ * ## Warm-up mode
54
+ *
55
+ * When `enableWarmup` is true (default), new sessions use an exponentially
56
+ * increasing L1 trigger threshold instead of jumping straight to
57
+ * `everyNConversations`. The sequence is: 1 → 2 → 4 → 8 → ... →
58
+ * everyNConversations. This ensures early conversations are processed
59
+ * quickly (first conversation triggers L1 immediately), while gradually
60
+ * reducing processing frequency as the session matures.
61
+ *
62
+ * The `warmup_threshold` field in PipelineSessionState tracks the current
63
+ * threshold. A value of 0 means warm-up is complete (graduated to
64
+ * steady-state). The threshold doubles after each successful L1 run.
65
+ *
66
+ * ## Trigger paths for L2
67
+ * A. **Delay-after-L1**: L1 completes → timer advanced to
68
+ * `max(now + delay, lastL2 + min)` → fires → enqueue L2.
69
+ * B. **MaxInterval guarantee**: L2 completes → timer set to
70
+ * `now + maxInterval` → fires → enqueue L2 (if session active).
71
+ * C. **Shutdown flush**: all pending L2 timers are flushed.
72
+ *
73
+ * All queues use SerialQueue (concurrency=1) for serial execution.
74
+ *
75
+ * ## Design doc
76
+ * See `docs/08-pipeline-refactor-design.md` for full architecture.
77
+ */
78
+ import { SessionFilter } from "./session-filter.js";
79
+ import { ManagedTimer } from "./managed-timer.js";
80
+ import { SerialQueue } from "./serial-queue.js";
81
+ import { report } from "../core/report/reporter.js";
82
+ const TAG = "[memory-tdai] [pipeline]";
83
+ export class MemoryPipelineManager {
84
+ // Config (converted to ms internally)
85
+ l1IdleTimeoutMs;
86
+ everyNConversations;
87
+ enableWarmup;
88
+ l2DelayAfterL1Ms;
89
+ l2MinIntervalMs;
90
+ l2MaxIntervalMs;
91
+ sessionActiveWindowMs;
92
+ /** Delay before retrying a failed L1 (ms). */
93
+ L1_RETRY_DELAY_MS = 30_000; // 30 seconds
94
+ /** Max consecutive L1 retries per session before giving up. */
95
+ L1_MAX_RETRIES = 5;
96
+ // Queues (named for diagnostics)
97
+ l1Queue = new SerialQueue("L1");
98
+ l2Queue = new SerialQueue("L2");
99
+ l3Queue = new SerialQueue("L3");
100
+ // L3 dedup flag
101
+ l3Pending = false;
102
+ l3Running = false;
103
+ // Per-session state
104
+ sessionStates = new Map();
105
+ sessionTimers = new Map();
106
+ // Per-session message buffer: messages accumulated since last L1 run
107
+ messageBuffers = new Map();
108
+ // Per-session L2 last run time (epoch ms, for minInterval floor)
109
+ l2LastRunTime = new Map();
110
+ // Callbacks
111
+ l1Runner = null;
112
+ l2Runner = null;
113
+ l3Runner = null;
114
+ persister = null;
115
+ logger;
116
+ // Unified session filter (internal sessions + excludeAgents)
117
+ sessionFilter;
118
+ // Lifecycle
119
+ destroyed = false;
120
+ /** Plugin instance ID for metric reporting (set externally after async init). */
121
+ instanceId;
122
+ // Session GC: runs periodically to evict cold sessions from memory
123
+ /** Multiplier on sessionActiveWindowMs to determine GC eligibility. */
124
+ SESSION_GC_INACTIVE_MULTIPLIER = 3;
125
+ /** Run GC every N calls to notifyConversation. */
126
+ SESSION_GC_EVERY_N_NOTIFICATIONS = 50;
127
+ /** Counter for GC scheduling. */
128
+ notifyCounter = 0;
129
+ constructor(config, logger, sessionFilter) {
130
+ this.l1IdleTimeoutMs = config.l1.idleTimeoutSeconds * 1000;
131
+ this.everyNConversations = config.everyNConversations;
132
+ this.enableWarmup = config.enableWarmup;
133
+ this.l2DelayAfterL1Ms = config.l2.delayAfterL1Seconds * 1000;
134
+ this.l2MinIntervalMs = config.l2.minIntervalSeconds * 1000;
135
+ this.l2MaxIntervalMs = config.l2.maxIntervalSeconds * 1000;
136
+ this.sessionActiveWindowMs = config.l2.sessionActiveWindowHours * 60 * 60 * 1000;
137
+ this.logger = logger;
138
+ this.sessionFilter = sessionFilter ?? new SessionFilter();
139
+ this.logger?.debug?.(`${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` +
140
+ `warmup=${config.enableWarmup ? "enabled" : "disabled"}, ` +
141
+ `l1IdleTimeout=${config.l1.idleTimeoutSeconds}s, ` +
142
+ `l2DelayAfterL1=${config.l2.delayAfterL1Seconds}s, ` +
143
+ `l2MinInterval=${config.l2.minIntervalSeconds}s, ` +
144
+ `l2MaxInterval=${config.l2.maxIntervalSeconds}s, ` +
145
+ `sessionActiveWindow=${config.l2.sessionActiveWindowHours}h`);
146
+ // Wire up queue debug logging
147
+ if (this.logger?.debug) {
148
+ const debugFn = (msg) => this.logger?.debug?.(`${TAG} ${msg}`);
149
+ this.l1Queue.setDebugLogger(debugFn);
150
+ this.l2Queue.setDebugLogger(debugFn);
151
+ this.l3Queue.setDebugLogger(debugFn);
152
+ }
153
+ }
154
+ // ============================
155
+ // Setup
156
+ // ============================
157
+ setL1Runner(runner) {
158
+ this.l1Runner = runner;
159
+ }
160
+ setL2Runner(runner) {
161
+ this.l2Runner = runner;
162
+ }
163
+ setL3Runner(runner) {
164
+ this.l3Runner = runner;
165
+ }
166
+ setPersister(persister) {
167
+ this.persister = persister;
168
+ }
169
+ /**
170
+ * Restore session states from checkpoint and start the pipeline.
171
+ * Sessions with pending counts will be immediately re-enqueued.
172
+ */
173
+ start(restoredStates) {
174
+ if (this.destroyed)
175
+ return;
176
+ if (restoredStates) {
177
+ let skipped = 0;
178
+ for (const [sessionKey, state] of Object.entries(restoredStates)) {
179
+ if (this.sessionFilter.shouldSkip(sessionKey)) {
180
+ skipped++;
181
+ continue;
182
+ }
183
+ // Backfill warmup_threshold for sessions persisted before warm-up feature.
184
+ // Missing field → treat as graduated (warmup already complete).
185
+ const patched = { ...state };
186
+ if (patched.warmup_threshold == null) {
187
+ patched.warmup_threshold = 0;
188
+ }
189
+ this.sessionStates.set(sessionKey, patched);
190
+ }
191
+ this.logger?.info(`${TAG} Restored ${this.sessionStates.size} session state(s) from checkpoint` +
192
+ (skipped > 0 ? ` (filtered ${skipped} internal)` : ""));
193
+ }
194
+ // Recovery: re-enqueue sessions with pending work
195
+ this.recoverPendingSessions();
196
+ this.logger?.info(`${TAG} Pipeline started`);
197
+ }
198
+ // ============================
199
+ // L0→L1: Notify (called from auto-capture on agent_end)
200
+ // ============================
201
+ /**
202
+ * Get the effective conversation threshold for a session, considering warm-up.
203
+ *
204
+ * When warm-up is enabled, new sessions start with threshold=1 and double
205
+ * after each successful L1 run: 1 → 2 → 4 → 8 → ... → everyNConversations.
206
+ * Once the threshold reaches everyNConversations, warm-up is considered complete
207
+ * (warmup_threshold is set to 0) and the fixed config value is used.
208
+ */
209
+ getEffectiveThreshold(state) {
210
+ if (!this.enableWarmup)
211
+ return this.everyNConversations;
212
+ // warmup_threshold === 0 means warm-up completed; use steady-state config
213
+ if (state.warmup_threshold <= 0)
214
+ return this.everyNConversations;
215
+ return Math.min(state.warmup_threshold, this.everyNConversations);
216
+ }
217
+ /**
218
+ * Advance the warm-up threshold for a session after a successful L1 run.
219
+ * Doubles the threshold until it reaches everyNConversations, then marks
220
+ * warm-up as complete (warmup_threshold = 0).
221
+ */
222
+ advanceWarmupThreshold(state) {
223
+ if (!this.enableWarmup)
224
+ return;
225
+ if (state.warmup_threshold <= 0)
226
+ return; // already graduated
227
+ const next = state.warmup_threshold * 2;
228
+ if (next >= this.everyNConversations) {
229
+ // Graduated: switch to steady-state
230
+ state.warmup_threshold = 0;
231
+ this.logger?.debug?.(`${TAG} Warm-up graduated → using steady-state threshold ${this.everyNConversations}`);
232
+ }
233
+ else {
234
+ state.warmup_threshold = next;
235
+ this.logger?.debug?.(`${TAG} Warm-up advanced → next threshold ${next}`);
236
+ }
237
+ }
238
+ /**
239
+ * Notify the pipeline that a conversation round has ended for a session,
240
+ * and buffer the captured messages for L1 batch processing.
241
+ *
242
+ * Two trigger paths start here:
243
+ * - **Path A (threshold)**: if conversation_count >= effective threshold
244
+ * (warm-up or steady-state), trigger L1 immediately with all buffered messages.
245
+ * - **Path B (idle)**: reset the L1 idle timer. When the timer fires (user
246
+ * stops chatting), L1 runs with whatever has been buffered.
247
+ */
248
+ async notifyConversation(sessionKey, messages) {
249
+ if (this.destroyed)
250
+ return;
251
+ if (this.sessionFilter.shouldSkip(sessionKey))
252
+ return;
253
+ const state = this.getOrCreateState(sessionKey);
254
+ state.conversation_count += 1;
255
+ state.last_active_time = Date.now();
256
+ // Reset L1 retry count on new conversation (environment may have recovered)
257
+ const timers = this.getOrCreateTimers(sessionKey);
258
+ timers.l1RetryCount = 0;
259
+ // Buffer messages for L1
260
+ const buffer = this.messageBuffers.get(sessionKey) ?? [];
261
+ buffer.push(...messages);
262
+ this.messageBuffers.set(sessionKey, buffer);
263
+ const effectiveThreshold = this.getEffectiveThreshold(state);
264
+ const warmupInfo = this.enableWarmup && state.warmup_threshold > 0
265
+ ? ` (warmup: ${state.warmup_threshold})`
266
+ : "";
267
+ this.logger?.debug?.(`${TAG} [${sessionKey}] notify: conversation_count=${state.conversation_count}/${effectiveThreshold}${warmupInfo}, ` +
268
+ `buffered_messages=${buffer.length} (+${messages.length} new)`);
269
+ await this.persistStates();
270
+ // Path A: conversation count reached effective threshold → trigger L1 batch
271
+ if (state.conversation_count >= effectiveThreshold) {
272
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Conversation threshold reached (${state.conversation_count}>=${effectiveThreshold}${warmupInfo}), triggering L1`);
273
+ this.enqueueL1(sessionKey);
274
+ return; // skip idle timer reset — L1 is already triggered
275
+ }
276
+ // Path B: below threshold → reset L1 idle timer (catch residual later)
277
+ timers.l1Idle.schedule(this.l1IdleTimeoutMs, () => this.onL1IdleTimeout(sessionKey));
278
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timer reset (${this.l1IdleTimeoutMs / 1000}s)`);
279
+ // Periodic GC: evict cold sessions from memory
280
+ this.notifyCounter += 1;
281
+ if (this.notifyCounter >= this.SESSION_GC_EVERY_N_NOTIFICATIONS) {
282
+ this.notifyCounter = 0;
283
+ this.gcStaleSessions();
284
+ }
285
+ }
286
+ // ============================
287
+ // Graceful shutdown
288
+ // ============================
289
+ /**
290
+ * Per-session flush — scoped end-of-session handling.
291
+ *
292
+ * Semantically different from {@link destroy}:
293
+ * - ``destroy`` tears down the *whole* scheduler (meant for process
294
+ * shutdown such as OpenClaw's ``gateway_stop``).
295
+ * - ``flushSession`` only processes the one session identified by
296
+ * ``sessionKey`` and leaves every other session's timers, buffers
297
+ * and pipeline state untouched. This is the correct semantic for
298
+ * the Gateway's ``POST /session/end`` endpoint and for Hermes'
299
+ * ``on_session_end`` callback, which fire when one conversation
300
+ * ends while the process keeps serving other concurrent sessions.
301
+ *
302
+ * What it does:
303
+ * 1. Cancel the session's pending L1 idle timer (no further idle
304
+ * fires for this key).
305
+ * 2. If the session's message buffer still holds work, enqueue an
306
+ * immediate L1 run for this session (``triggerReason="flush"``).
307
+ * 3. Await the shared ``l1Queue`` so the caller observes L1
308
+ * completion before returning. We do not selectively wait
309
+ * because L1 is already a single-consumer SerialQueue — waiting
310
+ * for ``onIdle`` is the cheapest correct signal.
311
+ *
312
+ * What it deliberately does NOT do:
313
+ * - Touch other sessions' timers / buffers / pipeline state.
314
+ * - Destroy the scheduler or any of its queues.
315
+ * - Reset global fields such as ``destroyed``.
316
+ *
317
+ * Unknown session keys are a no-op: the scheduler may legitimately
318
+ * have evicted the session earlier via GC, or the session may never
319
+ * have produced any captures.
320
+ */
321
+ async flushSession(sessionKey) {
322
+ if (this.destroyed)
323
+ return;
324
+ if (this.sessionFilter.shouldSkip(sessionKey))
325
+ return;
326
+ const timers = this.sessionTimers.get(sessionKey);
327
+ const buffer = this.messageBuffers.get(sessionKey);
328
+ // Step 1: cancel the idle timer so it won't fire after we return.
329
+ if (timers?.l1Idle.pending) {
330
+ timers.l1Idle.cancel();
331
+ }
332
+ // Step 2: flush pending buffered messages through L1 if any.
333
+ if (buffer && buffer.length > 0) {
334
+ this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: enqueuing L1 for ${buffer.length} buffered message(s)`);
335
+ this.enqueueL1(sessionKey, "flush");
336
+ }
337
+ // Step 3: wait for L1 to drain. L1 is a single-consumer SerialQueue
338
+ // so this is the cheapest correct signal; it will not starve other
339
+ // sessions because any cross-session interleaving L1 work was either
340
+ // already queued or will be queued concurrently by their own capture
341
+ // paths.
342
+ await this.l1Queue.onIdle();
343
+ this.logger?.debug?.(`${TAG} [${sessionKey}] flushSession: complete`);
344
+ }
345
+ /**
346
+ * Maximum time (ms) to wait for pipeline flush during destroy.
347
+ * Must be shorter than the gateway_stop hook timeout (3 s) to leave
348
+ * headroom for VectorStore / EmbeddingService cleanup that runs after.
349
+ */
350
+ DESTROY_TIMEOUT_MS = 2_000;
351
+ /**
352
+ * Graceful shutdown with timeout protection:
353
+ * 1. Mark destroyed, stop accepting new work
354
+ * 2. Attempt to flush pending L1/L2/L3 work within DESTROY_TIMEOUT_MS
355
+ * 3. If flush times out or fails, persist current state for recovery on next startup
356
+ * 4. Pending work is never lost — it will be recovered via checkpoint on next start()
357
+ */
358
+ async destroy() {
359
+ if (this.destroyed)
360
+ return;
361
+ this.destroyed = true;
362
+ this.logger?.info(`${TAG} Destroying pipeline (timeout=${this.DESTROY_TIMEOUT_MS}ms)...`);
363
+ try {
364
+ let timeoutId;
365
+ await Promise.race([
366
+ this._doFlush(),
367
+ new Promise((_, reject) => {
368
+ timeoutId = setTimeout(() => reject(new Error("destroy timeout")), this.DESTROY_TIMEOUT_MS);
369
+ }),
370
+ ]).finally(() => {
371
+ if (timeoutId !== undefined)
372
+ clearTimeout(timeoutId);
373
+ });
374
+ this.logger?.info(`${TAG} Pipeline flushed successfully`);
375
+ }
376
+ catch (err) {
377
+ this.logger?.warn(`${TAG} Pipeline flush timed out or failed: ${err instanceof Error ? err.message : String(err)}. ` +
378
+ `Pending work will be recovered on next startup.`);
379
+ }
380
+ // Always persist state — whether flush succeeded, timed out, or failed.
381
+ // This ensures pending work (buffered messages, L2 pending counts) is
382
+ // saved to checkpoint and can be recovered by recoverPendingSessions().
383
+ try {
384
+ await this.persistStates();
385
+ }
386
+ catch (err) {
387
+ this.logger?.error(`${TAG} Failed to persist states during destroy: ${err instanceof Error ? err.message : String(err)}`);
388
+ }
389
+ this.logger?.info(`${TAG} Pipeline destroyed`);
390
+ }
391
+ /**
392
+ * Internal: attempt to flush all pending pipeline work (L1 → L2 → L3).
393
+ * Extracted from destroy() so it can be wrapped with a timeout.
394
+ */
395
+ async _doFlush() {
396
+ // Step 1: Flush all L1 idle timers — only enqueue if there are buffered messages
397
+ for (const [sessionKey, timers] of this.sessionTimers) {
398
+ if (timers.l1Idle.pending) {
399
+ timers.l1Idle.cancel(); // don't fire the idle callback directly
400
+ const buffer = this.messageBuffers.get(sessionKey);
401
+ if (buffer && buffer.length > 0) {
402
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: enqueuing L1 for ${buffer.length} buffered messages`);
403
+ this.enqueueL1(sessionKey, "flush");
404
+ }
405
+ }
406
+ }
407
+ // Step 2: Wait for L1 queue to drain
408
+ this.logger?.debug?.(`${TAG} Waiting for L1 queue to drain (size=${this.l1Queue.size})`);
409
+ await this.l1Queue.onIdle();
410
+ // Step 3: Flush all L2 schedule timers
411
+ for (const [sessionKey, timers] of this.sessionTimers) {
412
+ if (timers.l2Schedule.pending) {
413
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: triggering L2 schedule timer`);
414
+ timers.l2Schedule.flush();
415
+ }
416
+ }
417
+ // Step 4: Wait for all remaining queues to drain
418
+ this.logger?.debug?.(`${TAG} Waiting for queues to drain (l2=${this.l2Queue.size}, l3=${this.l3Queue.size})`);
419
+ await Promise.all([
420
+ this.l2Queue.onIdle(),
421
+ this.l3Queue.onIdle(),
422
+ ]);
423
+ }
424
+ // ============================
425
+ // Internal: L1 idle timeout handler
426
+ // ============================
427
+ onL1IdleTimeout(sessionKey) {
428
+ const buffer = this.messageBuffers.get(sessionKey);
429
+ const state = this.sessionStates.get(sessionKey);
430
+ if ((!buffer || buffer.length === 0) && (!state || state.conversation_count === 0)) {
431
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout but no pending messages or conversations`);
432
+ return;
433
+ }
434
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 idle timeout fired (buffered=${buffer?.length ?? 0}, conversations=${state?.conversation_count ?? 0})`);
435
+ this.enqueueL1(sessionKey, "idle_timeout");
436
+ }
437
+ // ============================
438
+ // Internal: L1 queue
439
+ // ============================
440
+ enqueueL1(sessionKey, triggerReason = "threshold") {
441
+ const timers = this.getOrCreateTimers(sessionKey);
442
+ // Don't double-queue
443
+ if (timers.l1Queued) {
444
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 already queued, skipping`);
445
+ return;
446
+ }
447
+ // Cancel idle timer if running (threshold beat it)
448
+ timers.l1Idle.cancel();
449
+ timers.l1Queued = true;
450
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L1 (queue=${this.l1Queue.name})`);
451
+ // ── pipeline_l1_trigger metric ──
452
+ const state = this.sessionStates.get(sessionKey);
453
+ const buffer = this.messageBuffers.get(sessionKey);
454
+ if (this.instanceId && this.logger) {
455
+ report("pipeline_l1_trigger", {
456
+ sessionKey,
457
+ triggerReason,
458
+ conversationCount: state?.conversation_count ?? 0,
459
+ bufferedMessageCount: buffer?.length ?? 0,
460
+ });
461
+ }
462
+ this.l1Queue.add(async () => {
463
+ await this.runL1(sessionKey);
464
+ }).catch((err) => {
465
+ this.logger?.error(`${TAG} [${sessionKey}] L1 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
466
+ }).finally(() => {
467
+ timers.l1Queued = false;
468
+ });
469
+ }
470
+ /**
471
+ * L1 runner: Takes all buffered messages for a session and passes them
472
+ * to the L1Runner for batch processing (e.g. appendEvent, local extraction).
473
+ *
474
+ * After L1 completes successfully:
475
+ * - conversation_count and message buffer are reset
476
+ * - L2 timer is advanced (downward-only) to allow remote record generation
477
+ *
478
+ * If L1 fails, conversation_count and buffer are preserved for retry
479
+ * on next idle timeout or threshold trigger.
480
+ */
481
+ async runL1(sessionKey) {
482
+ const state = this.sessionStates.get(sessionKey);
483
+ if (!state)
484
+ return;
485
+ // Drain the message buffer (take ownership, clear the shared ref)
486
+ const buffer = this.messageBuffers.get(sessionKey) ?? [];
487
+ this.messageBuffers.set(sessionKey, []);
488
+ if (buffer.length === 0 && state.conversation_count === 0) {
489
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 skipped: no messages and no pending conversations`);
490
+ return;
491
+ }
492
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 running: messages=${buffer.length}, conversation_count=${state.conversation_count}`);
493
+ if (!this.l1Runner) {
494
+ this.logger?.warn(`${TAG} [${sessionKey}] No L1 runner set, skipping`);
495
+ state.l2_pending_l1_count = state.conversation_count;
496
+ state.conversation_count = 0;
497
+ this.advanceWarmupThreshold(state);
498
+ await this.persistStates();
499
+ this.advanceL2Timer(sessionKey);
500
+ return;
501
+ }
502
+ try {
503
+ await this.l1Runner({
504
+ sessionKey,
505
+ msg: buffer,
506
+ bg_msg: [], // reserved for future use
507
+ });
508
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 complete: processed ${buffer.length} messages`);
509
+ }
510
+ catch (err) {
511
+ this.logger?.error(`${TAG} [${sessionKey}] L1 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
512
+ // On failure: put messages back into the buffer for retry
513
+ const currentBuffer = this.messageBuffers.get(sessionKey) ?? [];
514
+ this.messageBuffers.set(sessionKey, [...buffer, ...currentBuffer]);
515
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 failure: restored ${buffer.length} messages to buffer (total=${buffer.length + currentBuffer.length})`);
516
+ // Re-arm L1 idle timer for automatic retry (with max retry limit)
517
+ const timers = this.getOrCreateTimers(sessionKey);
518
+ timers.l1RetryCount += 1;
519
+ if (timers.l1RetryCount <= this.L1_MAX_RETRIES) {
520
+ timers.l1Idle.schedule(this.L1_RETRY_DELAY_MS, () => this.onL1IdleTimeout(sessionKey));
521
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L1 retry scheduled in ${this.L1_RETRY_DELAY_MS / 1000}s ` +
522
+ `(attempt ${timers.l1RetryCount}/${this.L1_MAX_RETRIES})`);
523
+ }
524
+ else {
525
+ this.logger?.warn(`${TAG} [${sessionKey}] L1 max retries reached (${this.L1_MAX_RETRIES}), ` +
526
+ `giving up auto-retry. ${buffer.length + currentBuffer.length} messages remain buffered. ` +
527
+ `Will resume on next user conversation.`);
528
+ }
529
+ return; // don't advance state or trigger L2
530
+ }
531
+ // Success: reset retry count and advance state
532
+ const timers = this.getOrCreateTimers(sessionKey);
533
+ timers.l1RetryCount = 0;
534
+ state.l2_pending_l1_count = state.conversation_count;
535
+ state.conversation_count = 0;
536
+ this.advanceWarmupThreshold(state);
537
+ await this.persistStates();
538
+ // Advance the L2 timer (downward-only) to fire after delay, respecting minInterval
539
+ this.advanceL2Timer(sessionKey);
540
+ }
541
+ // ============================
542
+ // Internal: L2 timer management (downward-only)
543
+ // ============================
544
+ /**
545
+ * Advance the per-session L2 timer after an L1 event (new memory generated).
546
+ *
547
+ * Computes the desired fire time as:
548
+ * T_desired = max(now + l2DelayAfterL1, lastL2Time + l2MinInterval)
549
+ *
550
+ * The timer is only moved if T_desired is earlier than the current schedule
551
+ * (downward-only semantics). If no timer is pending, it's set unconditionally.
552
+ */
553
+ advanceL2Timer(sessionKey) {
554
+ if (this.destroyed)
555
+ return;
556
+ const timers = this.getOrCreateTimers(sessionKey);
557
+ const now = Date.now();
558
+ // Compute the floor: lastL2 + minInterval (rate-limit protection)
559
+ const lastL2 = this.l2LastRunTime.get(sessionKey) ?? 0;
560
+ const minIntervalFloor = lastL2 > 0 ? lastL2 + this.l2MinIntervalMs : 0;
561
+ // Desired fire time: delay after L1, but no earlier than minInterval floor
562
+ const desiredTime = Math.max(now + this.l2DelayAfterL1Ms, minIntervalFloor);
563
+ const advanced = timers.l2Schedule.tryAdvanceTo(desiredTime, () => this.onL2TimerFired(sessionKey, "delay-after-l1"));
564
+ if (advanced) {
565
+ const delaySec = Math.round((desiredTime - now) / 1000);
566
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer advanced: firing in ${delaySec}s` +
567
+ (timers.l2Schedule.scheduledTime > 0
568
+ ? ` (was ${Math.round((timers.l2Schedule.scheduledTime - now) / 1000)}s)`
569
+ : " (newly armed)"));
570
+ }
571
+ else {
572
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer not advanced: current schedule is already earlier`);
573
+ }
574
+ }
575
+ /**
576
+ * Arm the L2 timer for the maxInterval guarantee after L2 completes.
577
+ * Sets T = now + l2MaxInterval (unconditional, replaces any pending timer).
578
+ */
579
+ armL2MaxInterval(sessionKey) {
580
+ if (this.destroyed)
581
+ return;
582
+ const timers = this.getOrCreateTimers(sessionKey);
583
+ const fireAt = Date.now() + this.l2MaxIntervalMs;
584
+ timers.l2Schedule.scheduleAt(fireAt, () => this.onL2TimerFired(sessionKey, "max-interval"));
585
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L2 maxInterval timer armed: ${Math.round(this.l2MaxIntervalMs / 1000)}s`);
586
+ }
587
+ /**
588
+ * Called when a per-session L2 timer fires.
589
+ *
590
+ * Checks session activity: if the session is cold (inactive > activeWindow),
591
+ * the timer is NOT re-armed — it will be revived by the next L1 event.
592
+ * Otherwise, enqueues L2.
593
+ *
594
+ * The `source` parameter distinguishes the trigger origin:
595
+ * - "delay-after-l1": fired shortly after L1 completed — skip cold check
596
+ * because L1 completion itself proves recent activity.
597
+ * - "max-interval": periodic timer — apply cold check normally.
598
+ */
599
+ onL2TimerFired(sessionKey, source) {
600
+ const state = this.sessionStates.get(sessionKey);
601
+ if (!state)
602
+ return;
603
+ const now = Date.now();
604
+ // Cold session check: only applies to periodic (maxInterval) triggers.
605
+ // Delay-after-L1 triggers are exempt because L1 just completed, proving
606
+ // the session was recently active.
607
+ if (source === "max-interval" && now - state.last_active_time >= this.sessionActiveWindowMs) {
608
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer fired but session is cold ` +
609
+ `(inactive ${Math.round((now - state.last_active_time) / 3600_000)}h), timer stopped. ` +
610
+ `Will re-arm on next L1 event.`);
611
+ return; // timer not re-armed — advanceL2Timer() in runL1 will revive it
612
+ }
613
+ this.enqueueL2(sessionKey, `timer:${source}`);
614
+ }
615
+ // ============================
616
+ // Internal: L2 queue
617
+ // ============================
618
+ enqueueL2(sessionKey, trigger) {
619
+ const timers = this.getOrCreateTimers(sessionKey);
620
+ // Cancel any pending L2 timer (we're about to run L2)
621
+ timers.l2Schedule.cancel();
622
+ // Conflict detection: warn if L2 is already queued
623
+ if (timers.l2Queued) {
624
+ this.logger?.warn(`${TAG} [${sessionKey}] L2 enqueue conflict on queue "${this.l2Queue.name}": ` +
625
+ `task already queued/running (trigger=${trigger}), skipping`);
626
+ return;
627
+ }
628
+ timers.l2Queued = true;
629
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Enqueuing L2 (trigger=${trigger}, queue=${this.l2Queue.name})`);
630
+ this.l2Queue.add(async () => {
631
+ await this.runL2(sessionKey);
632
+ }).catch((err) => {
633
+ this.logger?.error(`${TAG} [${sessionKey}] L2 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
634
+ }).finally(() => {
635
+ timers.l2Queued = false;
636
+ });
637
+ }
638
+ async runL2(sessionKey) {
639
+ const state = this.sessionStates.get(sessionKey);
640
+ if (!state)
641
+ return;
642
+ if (!this.l2Runner) {
643
+ this.logger?.warn(`${TAG} [${sessionKey}] No L2 runner set, skipping`);
644
+ return;
645
+ }
646
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L2 running: l2_pending_l1_count=${state.l2_pending_l1_count}`);
647
+ const cursor = state.last_extraction_updated_time || undefined;
648
+ let result;
649
+ try {
650
+ result = await this.l2Runner(sessionKey, cursor);
651
+ }
652
+ catch (err) {
653
+ this.logger?.error(`${TAG} [${sessionKey}] L2 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
654
+ // Even on failure, arm maxInterval so we retry eventually
655
+ this.armL2MaxInterval(sessionKey);
656
+ return;
657
+ }
658
+ // After L2: update state
659
+ const now = Date.now();
660
+ state.l2_pending_l1_count = 0;
661
+ // Cold-start optimization: if this is the very first L2 run for this session
662
+ // and it was skipped (no new records), do NOT update l2LastRunTime.
663
+ // This prevents l2MinIntervalSeconds from blocking the next L2 trigger
664
+ // when the first L1 extraction produces actual memories shortly after.
665
+ const isFirstL2 = !this.l2LastRunTime.has(sessionKey);
666
+ const wasSkipped = result?.skipped === true;
667
+ if (isFirstL2 && wasSkipped) {
668
+ this.logger?.info?.(`${TAG} [${sessionKey}] L2 cold-start skip: not updating l2LastRunTime ` +
669
+ `(minInterval won't block next trigger)`);
670
+ this.armL2MaxInterval(sessionKey);
671
+ await this.persistStates();
672
+ return;
673
+ }
674
+ state.last_extraction_time = new Date().toISOString();
675
+ state.l2_last_extraction_time = new Date().toISOString();
676
+ this.l2LastRunTime.set(sessionKey, now);
677
+ // Advance cursor using the record timestamp returned by the runner
678
+ if (result?.latestCursor) {
679
+ state.last_extraction_updated_time = result.latestCursor;
680
+ }
681
+ else if (!state.last_extraction_updated_time) {
682
+ // Cold-start guard: if runner returned void (e.g. extraction failure) and
683
+ // last_extraction_updated_time is still empty, initialize it to now so
684
+ // the next L2 run doesn't do a full table scan.
685
+ state.last_extraction_updated_time = new Date().toISOString();
686
+ }
687
+ await this.persistStates();
688
+ this.logger?.debug?.(`${TAG} [${sessionKey}] L2 complete`);
689
+ // Arm the maxInterval timer for the next cycle
690
+ this.armL2MaxInterval(sessionKey);
691
+ // Trigger L3
692
+ this.triggerL3();
693
+ }
694
+ // ============================
695
+ // Internal: L3 queue (global, dedup)
696
+ // ============================
697
+ triggerL3() {
698
+ if (this.destroyed)
699
+ return;
700
+ if (this.l3Running) {
701
+ // L3 is in progress — mark pending so it runs again after current finishes
702
+ this.l3Pending = true;
703
+ this.logger?.debug?.(`${TAG} L3 already running, marking pending`);
704
+ return;
705
+ }
706
+ this.logger?.debug?.(`${TAG} Triggering L3`);
707
+ this.enqueueL3();
708
+ }
709
+ enqueueL3() {
710
+ this.l3Running = true;
711
+ this.l3Pending = false;
712
+ this.logger?.debug?.(`${TAG} Enqueuing L3 (queue=${this.l3Queue.name})`);
713
+ this.l3Queue.add(async () => {
714
+ await this.runL3();
715
+ }).catch((err) => {
716
+ this.logger?.error(`${TAG} L3 task failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
717
+ }).finally(() => {
718
+ this.l3Running = false;
719
+ // If new L2 completions happened while L3 was running, run again
720
+ if (this.l3Pending && !this.destroyed) {
721
+ this.logger?.debug?.(`${TAG} L3 has pending work, re-running`);
722
+ this.enqueueL3();
723
+ }
724
+ });
725
+ }
726
+ async runL3() {
727
+ if (!this.l3Runner) {
728
+ this.logger?.warn(`${TAG} No L3 runner set, skipping`);
729
+ return;
730
+ }
731
+ this.logger?.debug?.(`${TAG} L3 running`);
732
+ try {
733
+ await this.l3Runner();
734
+ this.logger?.debug?.(`${TAG} L3 complete`);
735
+ }
736
+ catch (err) {
737
+ this.logger?.error(`${TAG} L3 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
738
+ }
739
+ }
740
+ // ============================
741
+ // Internal: state management
742
+ // ============================
743
+ getOrCreateState(sessionKey) {
744
+ let state = this.sessionStates.get(sessionKey);
745
+ if (!state) {
746
+ state = {
747
+ conversation_count: 0,
748
+ last_extraction_time: "",
749
+ last_extraction_updated_time: "",
750
+ last_active_time: Date.now(),
751
+ l2_pending_l1_count: 0,
752
+ warmup_threshold: this.enableWarmup ? 1 : 0,
753
+ l2_last_extraction_time: "",
754
+ };
755
+ this.sessionStates.set(sessionKey, state);
756
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Created new session state`);
757
+ }
758
+ return state;
759
+ }
760
+ getOrCreateTimers(sessionKey) {
761
+ let timers = this.sessionTimers.get(sessionKey);
762
+ if (!timers) {
763
+ const isDestroyed = () => this.destroyed;
764
+ timers = {
765
+ l1Idle: new ManagedTimer(`L1-idle:${sessionKey}`, isDestroyed),
766
+ l2Schedule: new ManagedTimer(`L2-schedule:${sessionKey}`, isDestroyed),
767
+ l1Queued: false,
768
+ l2Queued: false,
769
+ l1RetryCount: 0,
770
+ };
771
+ this.sessionTimers.set(sessionKey, timers);
772
+ }
773
+ return timers;
774
+ }
775
+ async persistStates() {
776
+ if (!this.persister)
777
+ return;
778
+ // PipelineSessionState only contains pipeline-owned fields, so we can
779
+ // safely persist the entire object without risk of overwriting runner state.
780
+ const obj = {};
781
+ for (const [k, v] of this.sessionStates) {
782
+ obj[k] = { ...v };
783
+ }
784
+ try {
785
+ this.logger?.debug?.(`Persisting states: ${JSON.stringify(obj)}`);
786
+ await this.persister(obj);
787
+ }
788
+ catch (err) {
789
+ this.logger?.error(`${TAG} Failed to persist states: ${err instanceof Error ? err.message : String(err)}`);
790
+ }
791
+ }
792
+ /**
793
+ * Evict cold sessions from in-memory maps to prevent unbounded growth.
794
+ *
795
+ * A session is eligible for GC when:
796
+ * 1. Inactive for > sessionActiveWindowMs * SESSION_GC_INACTIVE_MULTIPLIER
797
+ * 2. No queued/running L1 or L2 tasks
798
+ * 3. No buffered messages pending processing
799
+ *
800
+ * Evicted sessions can be fully restored from checkpoint on next
801
+ * `notifyConversation()` (state) or `start()` (recovery).
802
+ */
803
+ gcStaleSessions() {
804
+ const now = Date.now();
805
+ const maxInactiveMs = this.sessionActiveWindowMs * this.SESSION_GC_INACTIVE_MULTIPLIER;
806
+ let evictedCount = 0;
807
+ for (const [sessionKey, state] of this.sessionStates) {
808
+ if (now - state.last_active_time < maxInactiveMs)
809
+ continue;
810
+ // Safety: don't evict sessions with active work
811
+ const timers = this.sessionTimers.get(sessionKey);
812
+ if (timers?.l1Queued || timers?.l2Queued)
813
+ continue;
814
+ const buffer = this.messageBuffers.get(sessionKey);
815
+ if (buffer && buffer.length > 0)
816
+ continue;
817
+ // Evict: cancel any pending timers, then remove from all maps
818
+ if (timers) {
819
+ timers.l1Idle.cancel();
820
+ timers.l2Schedule.cancel();
821
+ }
822
+ this.sessionStates.delete(sessionKey);
823
+ this.sessionTimers.delete(sessionKey);
824
+ this.messageBuffers.delete(sessionKey);
825
+ this.l2LastRunTime.delete(sessionKey);
826
+ evictedCount++;
827
+ }
828
+ if (evictedCount > 0) {
829
+ this.logger?.debug?.(`${TAG} Session GC: evicted ${evictedCount} cold session(s), ` +
830
+ `${this.sessionStates.size} remaining`);
831
+ }
832
+ }
833
+ /**
834
+ * Recovery: re-enqueue sessions that have pending work from before restart.
835
+ *
836
+ * On restart, message buffers are empty (in-memory only). Sessions with
837
+ * non-zero conversation_count had messages that were either:
838
+ * 1. Already processed by L1 (l2_pending_l1_count > 0) → arm L2 timer
839
+ * 2. Never reached L1 (conversation_count > 0, messages lost) → arm L2
840
+ * as best-effort recovery
841
+ *
842
+ * We arm L2 timers (with delay) rather than enqueuing immediately,
843
+ * because the pipeline may be starting during management commands.
844
+ */
845
+ recoverPendingSessions() {
846
+ for (const [sessionKey, state] of this.sessionStates) {
847
+ if (state.conversation_count === 0 && state.l2_pending_l1_count === 0)
848
+ continue;
849
+ this.logger?.debug?.(`${TAG} [${sessionKey}] Recovery: conversation_count=${state.conversation_count}, ` +
850
+ `l2_pending_l1_count=${state.l2_pending_l1_count}, arming L2 timer`);
851
+ // Reset conversation_count since we can't recover the messages
852
+ state.l2_pending_l1_count = Math.max(state.l2_pending_l1_count, state.conversation_count);
853
+ state.conversation_count = 0;
854
+ // Arm L2 timer with delay (gives the system time to fully start)
855
+ this.advanceL2Timer(sessionKey);
856
+ }
857
+ }
858
+ // ============================
859
+ // Public accessors (for testing / status)
860
+ // ============================
861
+ /** Get the pipeline session state for a session (read-only copy). */
862
+ getSessionState(sessionKey) {
863
+ const state = this.sessionStates.get(sessionKey);
864
+ return state ? { ...state } : undefined;
865
+ }
866
+ /** Get the buffered message count for a session. */
867
+ getBufferedMessageCount(sessionKey) {
868
+ return this.messageBuffers.get(sessionKey)?.length ?? 0;
869
+ }
870
+ /** Get all session keys being tracked. */
871
+ getSessionKeys() {
872
+ return Array.from(this.sessionStates.keys());
873
+ }
874
+ /** Whether the pipeline has been destroyed. */
875
+ get isDestroyed() {
876
+ return this.destroyed;
877
+ }
878
+ /** Queue sizes and running state for monitoring. */
879
+ getQueueSizes() {
880
+ return {
881
+ l1: this.l1Queue.size,
882
+ l2: this.l2Queue.size,
883
+ l3: this.l3Queue.size,
884
+ l1Pending: this.l1Queue.pending,
885
+ l2Pending: this.l2Queue.pending,
886
+ l3Pending: this.l3Queue.pending,
887
+ l1Idle: this.l1Queue.idle,
888
+ l2Idle: this.l2Queue.idle,
889
+ l3Idle: this.l3Queue.idle,
890
+ };
891
+ }
892
+ }