@felan-ai/ext-memory 0.7.0 → 0.9.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 (78) hide show
  1. package/README.md +41 -30
  2. package/dist/data.d.ts +3 -0
  3. package/dist/data.d.ts.map +1 -0
  4. package/dist/data.js +2 -0
  5. package/dist/data.js.map +1 -0
  6. package/dist/diagnostics.d.ts +3 -0
  7. package/dist/diagnostics.d.ts.map +1 -0
  8. package/dist/diagnostics.js +14 -0
  9. package/dist/diagnostics.js.map +1 -0
  10. package/dist/extension.d.ts +5 -0
  11. package/dist/extension.d.ts.map +1 -0
  12. package/dist/extension.js +37 -0
  13. package/dist/extension.js.map +1 -0
  14. package/dist/hydration.d.ts +0 -3
  15. package/dist/hydration.d.ts.map +1 -1
  16. package/dist/hydration.js +0 -78
  17. package/dist/hydration.js.map +1 -1
  18. package/dist/index.d.ts +18 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +31 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/local/context.d.ts +10 -0
  23. package/dist/local/context.d.ts.map +1 -0
  24. package/dist/local/context.js +10 -0
  25. package/dist/local/context.js.map +1 -0
  26. package/dist/local/control.d.ts +13 -0
  27. package/dist/local/control.d.ts.map +1 -0
  28. package/dist/local/control.js +183 -0
  29. package/dist/local/control.js.map +1 -0
  30. package/dist/local/coordinator.d.ts +93 -0
  31. package/dist/local/coordinator.d.ts.map +1 -0
  32. package/dist/local/coordinator.js +859 -0
  33. package/dist/local/coordinator.js.map +1 -0
  34. package/dist/local/dreamer.d.ts +53 -0
  35. package/dist/local/dreamer.d.ts.map +1 -0
  36. package/dist/local/dreamer.js +490 -0
  37. package/dist/local/dreamer.js.map +1 -0
  38. package/dist/local/history.d.ts +33 -0
  39. package/dist/local/history.d.ts.map +1 -0
  40. package/dist/local/history.js +300 -0
  41. package/dist/local/history.js.map +1 -0
  42. package/dist/local/hydration.d.ts +6 -0
  43. package/dist/local/hydration.d.ts.map +1 -0
  44. package/dist/local/hydration.js +84 -0
  45. package/dist/local/hydration.js.map +1 -0
  46. package/dist/local/lease.d.ts +14 -0
  47. package/dist/local/lease.d.ts.map +1 -0
  48. package/dist/local/lease.js +184 -0
  49. package/dist/local/lease.js.map +1 -0
  50. package/dist/local/lock.d.ts +28 -0
  51. package/dist/local/lock.d.ts.map +1 -0
  52. package/dist/local/lock.js +431 -0
  53. package/dist/local/lock.js.map +1 -0
  54. package/dist/local/project.d.ts +8 -0
  55. package/dist/local/project.d.ts.map +1 -0
  56. package/dist/local/project.js +33 -0
  57. package/dist/local/project.js.map +1 -0
  58. package/dist/local/run-retention.d.ts +13 -0
  59. package/dist/local/run-retention.d.ts.map +1 -0
  60. package/dist/local/run-retention.js +272 -0
  61. package/dist/local/run-retention.js.map +1 -0
  62. package/dist/local/run.d.ts +84 -0
  63. package/dist/local/run.d.ts.map +1 -0
  64. package/dist/local/run.js +361 -0
  65. package/dist/local/run.js.map +1 -0
  66. package/dist/local/store.d.ts +117 -0
  67. package/dist/local/store.d.ts.map +1 -0
  68. package/dist/local/store.js +735 -0
  69. package/dist/local/store.js.map +1 -0
  70. package/dist/local/ui.d.ts +9 -0
  71. package/dist/local/ui.d.ts.map +1 -0
  72. package/dist/local/ui.js +166 -0
  73. package/dist/local/ui.js.map +1 -0
  74. package/dist/session-history.d.ts +18 -0
  75. package/dist/session-history.d.ts.map +1 -0
  76. package/dist/session-history.js +91 -0
  77. package/dist/session-history.js.map +1 -0
  78. package/package.json +9 -5
@@ -0,0 +1,859 @@
1
+ import { removeIfPresent } from './context.js';
2
+ import { createDefaultLocalMemoryDreamRunner, materializeMemoryInput } from './dreamer.js';
3
+ import { LocalMemoryRun } from './run.js';
4
+ import { readMemorySessionCheckpoint } from '../session-history.js';
5
+ import { LocalMemoryStore } from './store.js';
6
+ import { acquireLocalMemoryLease } from './lease.js';
7
+ import { resolveLocalMemoryProject } from './project.js';
8
+ import { hydrateMemoryDirectory, readMemoryDirectory } from './hydration.js';
9
+ import { join } from 'node:path';
10
+ import { createMemorySnapshot, createMemoryProjectionSnapshot, extractSourceIds } from '../index.js';
11
+ import {} from '@felan-ai/agent-core';
12
+ import { MemoryModelUnavailableError } from './dreamer.js';
13
+ import { LocalMemoryControlError } from './store.js';
14
+ import { sanitizeMemoryDiagnostic } from '../diagnostics.js';
15
+ const MEMORY_INPUT_BLOCKED_MESSAGE = 'Some memory checkpoints could not be materialized; evidence remains pending';
16
+ const MEMORY_AUTOMATIC_UPDATE_THRESHOLD = 5;
17
+ const MEMORY_AUTOMATIC_INTERVAL_MS = 24 * 60 * 60 * 1_000;
18
+ const MEMORY_DREAM_INPUT_MAX_BYTES = 10 * 1024 * 1024;
19
+ const MEMORY_MAX_BATCH_SESSIONS = 8;
20
+ export class LocalMemoryCoordinator {
21
+ environment;
22
+ #options;
23
+ #contexts = new Map();
24
+ #projections = new Map();
25
+ #statusListeners = new Set();
26
+ #dreamRunner;
27
+ #batchSize;
28
+ #enabled;
29
+ #selectedModel;
30
+ #scopedModels;
31
+ #classifier;
32
+ #disposed = false;
33
+ #draining = false;
34
+ #monitor;
35
+ #monitoring;
36
+ #enableVersion = 0;
37
+ constructor(environment, options) {
38
+ this.environment = environment;
39
+ this.#options = options;
40
+ this.#batchSize = boundedMemoryBatchSize(options.batchSize);
41
+ this.#enabled = options.enabled !== false;
42
+ this.#selectedModel = options.selectedModel;
43
+ this.#scopedModels = options.scopedModels === undefined ? undefined : [...options.scopedModels];
44
+ this.#classifier = options.classifier;
45
+ this.#dreamRunner = options.dreamRunner ?? createDefaultLocalMemoryDreamRunner(this.environment);
46
+ if (this.#enabled)
47
+ this.#startMonitor();
48
+ }
49
+ createSessionHost(options) {
50
+ return new LocalMemorySessionHost(this, options);
51
+ }
52
+ isEnabled() {
53
+ return this.#enabled;
54
+ }
55
+ hasActiveWork() {
56
+ return !!this.#monitoring || [...this.#contexts.values()].some(context => !!context.run || !!context.ready || !!context.recovery || context.followUps.size > 0);
57
+ }
58
+ subscribeStatusChanges(listener) {
59
+ if (this.#disposed)
60
+ return () => { };
61
+ this.#statusListeners.add(listener);
62
+ return () => this.#statusListeners.delete(listener);
63
+ }
64
+ setModelSelection(model, scopedModels, classifier) {
65
+ this.#scopedModels = scopedModels === undefined ? undefined : [...scopedModels];
66
+ this.#classifier = classifier;
67
+ this.setSelectedModel(model);
68
+ }
69
+ setSelectedModel(model) {
70
+ this.#selectedModel = model;
71
+ for (const context of this.#contexts.values()) {
72
+ context.blocked.clear();
73
+ if (context.initialized)
74
+ void this.#trackScheduleIfPending(context);
75
+ }
76
+ }
77
+ setEnabled(enabled) {
78
+ this.#enableVersion += 1;
79
+ enabled = enabled && !this.#options.unavailableReason;
80
+ this.#enabled = enabled;
81
+ if (enabled)
82
+ this.#startMonitor();
83
+ else if (this.#monitor) {
84
+ clearInterval(this.#monitor);
85
+ this.#monitor = undefined;
86
+ }
87
+ for (const context of this.#contexts.values()) {
88
+ if (!enabled)
89
+ context.recoveryAbort?.abort();
90
+ if (!enabled)
91
+ this.#cancelScheduledRun(context);
92
+ if (!enabled)
93
+ this.#cancelContext(context, 'Memory processing was disabled', true);
94
+ if (enabled) {
95
+ context.storageBlocked = false;
96
+ context.writerBlocked = false;
97
+ context.blocked.clear();
98
+ if (context.initialized)
99
+ this.#startRecovery(context);
100
+ }
101
+ context.state = enabled ? 'idle' : 'disabled';
102
+ }
103
+ if (enabled) {
104
+ for (const context of this.#contexts.values()) {
105
+ if (context.initialized)
106
+ void this.#trackScheduleIfPending(context);
107
+ }
108
+ }
109
+ this.#emitStatusChange();
110
+ }
111
+ async status(cwd) {
112
+ try {
113
+ const context = await this.#context(cwd);
114
+ const state = await context.store.status();
115
+ const control = await context.store.readControl();
116
+ const processed = Object.values(state.processed);
117
+ const lastProcessedAt = processed
118
+ .map((entry) => entry.processedAt)
119
+ .sort()
120
+ .at(-1);
121
+ return {
122
+ enabled: this.#enabled && !control.autoDisabled,
123
+ state: !this.#enabled || control.autoDisabled ? 'disabled'
124
+ : control.nextRetryAt && Date.parse(control.nextRetryAt) > this.#now() ? 'scheduled'
125
+ : context.state === 'error' ? 'error'
126
+ : control.activeAttempt ? 'processing' : context.state,
127
+ pendingCheckpoints: Object.keys(state.pending).length,
128
+ memoryFingerprint: state.memoryFingerprint,
129
+ ...(lastProcessedAt === undefined ? {} : { lastProcessedAt }),
130
+ ...(context.message || this.#options.unavailableReason ? { message: context.message ?? this.#options.unavailableReason } : {}),
131
+ consecutiveFailures: control.consecutiveFailures,
132
+ ...(control.nextRetryAt ? { nextRetryAt: control.nextRetryAt } : {}),
133
+ ...(control.autoDisabled ? { autoDisabled: control.autoDisabled } : {}),
134
+ ...(control.activeAttempt || control.lastOutcome ? {
135
+ lastRunId: control.activeAttempt?.runId ?? control.lastOutcome.runId,
136
+ } : {}),
137
+ };
138
+ }
139
+ catch (error) {
140
+ return {
141
+ enabled: this.#enabled,
142
+ state: this.#enabled ? 'error' : 'disabled',
143
+ pendingCheckpoints: 0,
144
+ message: error instanceof LocalMemoryControlError ? error.message : 'Local memory storage is unavailable',
145
+ };
146
+ }
147
+ }
148
+ async readCurrent(cwd, sessionStorageRoot) {
149
+ const context = await this.#context(cwd);
150
+ const snapshot = await context.store.readCurrent();
151
+ const projectionKey = `${context.project.key}:${sessionStorageRoot}`;
152
+ const memoryPath = join(sessionStorageRoot, context.store.projectionName);
153
+ if (this.#projections.get(projectionKey) !== snapshot.fingerprint) {
154
+ const projection = await context.store.projectTo(sessionStorageRoot, snapshot);
155
+ this.#projections.set(projectionKey, snapshot.fingerprint);
156
+ return projection;
157
+ }
158
+ return createMemoryProjectionSnapshot(snapshot, memoryPath);
159
+ }
160
+ async recordCheckpoint(cwd, checkpoint) {
161
+ const context = await this.#context(cwd);
162
+ if (await context.store.recordCheckpoint(checkpoint)) {
163
+ context.blocked.delete(checkpoint.sessionId);
164
+ await this.#trackScheduleIfPending(context);
165
+ this.#emitStatusChange();
166
+ }
167
+ }
168
+ async runNow(cwd) {
169
+ if (!this.#enabled || this.#draining)
170
+ return this.status(cwd);
171
+ const context = await this.#context(cwd);
172
+ this.#cancelScheduledRun(context);
173
+ while (context.run || context.followUps.size > 0) {
174
+ await Promise.allSettled([
175
+ ...(context.run ? [context.run] : []),
176
+ ...context.followUps,
177
+ ]);
178
+ this.#cancelScheduledRun(context);
179
+ }
180
+ context.blocked.clear();
181
+ const run = this.#run(context, true);
182
+ context.run = run;
183
+ try {
184
+ await run;
185
+ }
186
+ finally {
187
+ if (context.run === run)
188
+ context.run = undefined;
189
+ await this.#trackScheduleIfPending(context);
190
+ }
191
+ return this.status(cwd);
192
+ }
193
+ async retryProject(cwd) {
194
+ if (!this.#enabled || this.#draining)
195
+ return this.status(cwd);
196
+ const context = await this.#context(cwd);
197
+ if (!this.#enabled || this.#disposed || this.#draining)
198
+ return this.status(cwd);
199
+ const enableVersion = this.#enableVersion;
200
+ this.#cancelScheduledRun(context);
201
+ try {
202
+ await context.store.resetProjectControl(this.#now(), () => this.#enabled && !this.#disposed && !this.#draining && this.#enableVersion === enableVersion);
203
+ }
204
+ catch (error) {
205
+ throw error;
206
+ }
207
+ context.abort?.abort(new Error('Memory processing was explicitly retried'));
208
+ if (context.run)
209
+ await context.run;
210
+ context.blocked.clear();
211
+ context.storageBlocked = false;
212
+ context.message = undefined;
213
+ await this.runNow(cwd);
214
+ return this.status(cwd);
215
+ }
216
+ async canonicalDirectory(cwd) {
217
+ const context = await this.#context(cwd);
218
+ return context.store.currentDirectory;
219
+ }
220
+ async drain() {
221
+ this.#draining = true;
222
+ if (this.#monitor)
223
+ clearInterval(this.#monitor);
224
+ this.#monitor = undefined;
225
+ for (const context of this.#contexts.values()) {
226
+ context.recoveryAbort?.abort();
227
+ this.#cancelScheduledRun(context);
228
+ }
229
+ while (true) {
230
+ const work = [...this.#contexts.values()].flatMap(context => [
231
+ ...(context.run ? [context.run] : []), ...(context.ready ? [context.ready] : []),
232
+ ...(context.recovery ? [context.recovery] : []), ...context.followUps,
233
+ ]);
234
+ if (this.#monitoring)
235
+ work.push(this.#monitoring);
236
+ if (!work.length)
237
+ return;
238
+ const results = await Promise.allSettled(work);
239
+ const failures = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
240
+ if (failures.length)
241
+ throw new AggregateError(failures, 'Memory drain could not settle all work.');
242
+ }
243
+ }
244
+ async dispose() {
245
+ if (this.#disposed)
246
+ return;
247
+ this.#disposed = true;
248
+ if (this.#monitor)
249
+ clearInterval(this.#monitor);
250
+ this.#monitor = undefined;
251
+ for (const context of this.#contexts.values()) {
252
+ context.recoveryAbort?.abort();
253
+ this.#cancelScheduledRun(context);
254
+ this.#cancelContext(context, 'Memory processing was cancelled during shutdown', false);
255
+ }
256
+ while (true) {
257
+ const work = [...this.#contexts.values()].flatMap((context) => [
258
+ ...(context.run ? [context.run] : []),
259
+ ...(context.ready ? [context.ready] : []),
260
+ ...(context.recovery ? [context.recovery] : []),
261
+ ...context.followUps,
262
+ ]);
263
+ if (this.#monitoring)
264
+ work.push(this.#monitoring);
265
+ if (work.length === 0)
266
+ break;
267
+ await Promise.allSettled(work);
268
+ }
269
+ this.#statusListeners.clear();
270
+ }
271
+ async #context(cwd) {
272
+ if (this.#disposed)
273
+ throw new Error('Local memory coordinator is closed');
274
+ const project = await resolveLocalMemoryProject(this.environment, cwd);
275
+ if (this.#disposed)
276
+ throw new Error('Local memory coordinator is closed');
277
+ const existing = this.#contexts.get(project.key);
278
+ if (existing) {
279
+ await this.#initialize(existing);
280
+ return existing;
281
+ }
282
+ const store = new LocalMemoryStore(this.environment, this.#options.agentDir, project, {
283
+ ...(this.#options.retryDelaysMs ? { retryDelaysMs: this.#options.retryDelaysMs } : {}),
284
+ });
285
+ const context = {
286
+ project,
287
+ cwd,
288
+ ...(this.#options.sessionDir === undefined ? {} : { sessionDir: this.#options.sessionDir }),
289
+ store,
290
+ ready: undefined,
291
+ initialized: false,
292
+ recovery: undefined,
293
+ recoveryAbort: undefined,
294
+ recoveryRetryAt: undefined,
295
+ recoveryCompleted: false,
296
+ diagnosticsReconciled: false,
297
+ timer: undefined,
298
+ timerDueAt: undefined,
299
+ run: undefined,
300
+ followUps: new Set(),
301
+ abort: undefined,
302
+ attempt: undefined,
303
+ storageBlocked: false,
304
+ writerBlocked: false,
305
+ statusSignature: undefined,
306
+ blocked: new Map(),
307
+ state: this.#enabled ? 'idle' : 'disabled',
308
+ message: undefined,
309
+ };
310
+ this.#contexts.set(project.key, context);
311
+ await this.#initialize(context);
312
+ return context;
313
+ }
314
+ async #initialize(context) {
315
+ if (context.ready) {
316
+ await context.ready;
317
+ return;
318
+ }
319
+ if (context.initialized && context.diagnosticsReconciled) {
320
+ this.#startRecovery(context);
321
+ return;
322
+ }
323
+ const ready = this.#initializeContext(context).finally(() => {
324
+ if (context.ready === ready)
325
+ context.ready = undefined;
326
+ });
327
+ context.ready = ready;
328
+ await ready;
329
+ }
330
+ async #initializeContext(context) {
331
+ if (!context.initialized) {
332
+ await context.store.initialize(this.#now()).then(() => {
333
+ context.initialized = true;
334
+ context.state = this.#enabled ? 'idle' : 'disabled';
335
+ context.message = undefined;
336
+ }).catch(async (error) => {
337
+ if (!(error instanceof LocalMemoryControlError))
338
+ throw error;
339
+ const current = await context.store.readCurrent();
340
+ if (current.fingerprint !== (await context.store.status()).memoryFingerprint)
341
+ throw error;
342
+ context.message = error.message;
343
+ });
344
+ }
345
+ if (!context.initialized)
346
+ return;
347
+ if (!context.diagnosticsReconciled) {
348
+ try {
349
+ const lease = await acquireLocalMemoryLease(this.environment, context.store.projectDirectory, this.#options.leaseOptions);
350
+ if (lease) {
351
+ try {
352
+ await this.#reconcileRunDiagnostics(context, lease);
353
+ context.diagnosticsReconciled = true;
354
+ }
355
+ finally {
356
+ await lease.release();
357
+ }
358
+ }
359
+ }
360
+ catch (error) {
361
+ context.diagnosticsReconciled = true;
362
+ context.storageBlocked = true;
363
+ context.message = `Memory diagnostics recovery is blocked: ${sanitizeMemoryDiagnostic(error)}`;
364
+ }
365
+ }
366
+ if (this.#disposed)
367
+ return;
368
+ if (this.#enabled)
369
+ void this.#trackScheduleIfPending(context);
370
+ this.#startRecovery(context);
371
+ }
372
+ #startRecovery(context) {
373
+ if (this.#disposed || this.#draining || !this.#enabled || this.#options.recover === false
374
+ || context.recoveryCompleted || context.recovery
375
+ || context.recoveryRetryAt !== undefined && this.#now() < context.recoveryRetryAt)
376
+ return;
377
+ const abort = new AbortController();
378
+ const recovery = this.#recover(context, abort.signal).then((completed) => {
379
+ if (completed)
380
+ context.recoveryCompleted = true;
381
+ }).catch(() => {
382
+ context.message = 'Memory startup recovery could not complete';
383
+ }).finally(() => {
384
+ if (context.recovery === recovery) {
385
+ context.recovery = undefined;
386
+ context.recoveryAbort = undefined;
387
+ }
388
+ });
389
+ context.recovery = recovery;
390
+ context.recoveryAbort = abort;
391
+ }
392
+ #now() { return this.#options.now?.() ?? Date.now(); }
393
+ #startMonitor() {
394
+ if (this.#disposed || this.#draining || this.#monitor)
395
+ return;
396
+ const interval = this.#options.monitorIntervalMs ?? 2_000;
397
+ if (!Number.isFinite(interval) || interval < 1)
398
+ throw new Error('Memory monitor interval must be positive');
399
+ this.#monitor = setInterval(() => {
400
+ if (this.#monitoring)
401
+ return;
402
+ this.#monitoring = this.#refresh().catch(() => { }).finally(() => { this.#monitoring = undefined; });
403
+ }, interval);
404
+ this.#monitor.unref?.();
405
+ }
406
+ async #refresh() {
407
+ if (this.#disposed || this.#draining)
408
+ return;
409
+ for (const context of this.#contexts.values()) {
410
+ try {
411
+ await this.#initialize(context);
412
+ const observedAttempt = context.attempt;
413
+ const control = await context.store.readControl();
414
+ if (observedAttempt && context.attempt === observedAttempt && (!this.#enabled || control.autoDisabled
415
+ || control.activeAttempt?.runId !== observedAttempt.runId
416
+ || control.activeAttempt?.generation !== observedAttempt.generation)) {
417
+ this.#cancelContext(context, 'Memory processing was cancelled by another session', true);
418
+ }
419
+ context.writerBlocked = false;
420
+ if (context.initialized)
421
+ await this.#trackScheduleIfPending(context);
422
+ const signature = JSON.stringify([this.#enabled, context.state, context.message, control.updatedAt]);
423
+ if (signature !== context.statusSignature) {
424
+ context.statusSignature = signature;
425
+ this.#emitStatusChange();
426
+ }
427
+ }
428
+ catch (error) {
429
+ context.state = 'error';
430
+ context.message = sanitizeMemoryDiagnostic(error);
431
+ context.abort?.abort(error);
432
+ this.#emitStatusChange();
433
+ }
434
+ }
435
+ }
436
+ #cancelContext(context, reason, cancelPersisted) {
437
+ const alreadyAborted = context.abort?.signal.aborted ?? false;
438
+ context.abort?.abort(new Error(reason));
439
+ if (alreadyAborted || !context.initialized || (!cancelPersisted && !context.attempt))
440
+ return;
441
+ const cancellation = cancelPersisted
442
+ ? context.store.cancelActiveAttempt(this.#now())
443
+ : context.store.finishAttempt(undefined, context.attempt, { status: 'cancelled', reason }, this.#now());
444
+ const followUp = cancellation
445
+ .then(() => { })
446
+ .catch(() => { })
447
+ .finally(() => { context.followUps.delete(followUp); });
448
+ context.followUps.add(followUp);
449
+ }
450
+ async #schedule(context, dueAt) {
451
+ if (this.#disposed || this.#draining || !this.#enabled || context.run || context.storageBlocked)
452
+ return;
453
+ if (context.timer && context.timerDueAt === dueAt)
454
+ return;
455
+ this.#cancelScheduledRun(context);
456
+ context.state = 'scheduled';
457
+ const delay = Math.max(0, dueAt - this.#now());
458
+ const timer = setTimeout(() => {
459
+ if (context.timer !== timer)
460
+ return;
461
+ context.timer = undefined;
462
+ context.timerDueAt = undefined;
463
+ const run = this.#run(context).catch((error) => {
464
+ context.state = 'error';
465
+ context.storageBlocked = true;
466
+ context.message = sanitizeMemoryDiagnostic(error);
467
+ this.#emitStatusChange();
468
+ }).finally(async () => {
469
+ context.run = undefined;
470
+ await this.#trackScheduleIfPending(context);
471
+ });
472
+ context.run = run;
473
+ }, delay);
474
+ context.timer = timer;
475
+ context.timerDueAt = dueAt;
476
+ timer.unref?.();
477
+ }
478
+ async #scheduleIfPending(context) {
479
+ if (!this.#enabled || this.#disposed || this.#draining)
480
+ return;
481
+ try {
482
+ const control = await context.store.readControl();
483
+ if (control.autoDisabled) {
484
+ this.#cancelScheduledRun(context);
485
+ context.state = 'disabled';
486
+ return;
487
+ }
488
+ if (context.storageBlocked)
489
+ return;
490
+ if (context.writerBlocked)
491
+ return;
492
+ const state = await context.store.status();
493
+ const pending = Object.values(state.pending);
494
+ if (control.activeAttempt) {
495
+ this.#cancelScheduledRun(context);
496
+ }
497
+ else if (pending.some(({ checkpoint }) => !isBlockedCheckpoint(context, checkpoint))) {
498
+ const acceptedUpdates = await context.store.pendingAcceptedUpdates((checkpoint) => !isBlockedCheckpoint(context, checkpoint));
499
+ if (acceptedUpdates < MEMORY_AUTOMATIC_UPDATE_THRESHOLD) {
500
+ this.#cancelScheduledRun(context);
501
+ if (context.state === 'scheduled')
502
+ context.state = 'idle';
503
+ return;
504
+ }
505
+ const processedAt = Object.values(state.processed)
506
+ .map(({ processedAt }) => Date.parse(processedAt))
507
+ .filter(Number.isFinite)
508
+ .reduce((latest, at) => Math.max(latest, at), 0);
509
+ const dueAt = control.nextRetryAt
510
+ ? Date.parse(control.nextRetryAt)
511
+ : processedAt === 0
512
+ ? this.#now()
513
+ : processedAt + (this.#options.debounceMs ?? MEMORY_AUTOMATIC_INTERVAL_MS);
514
+ await this.#schedule(context, dueAt);
515
+ }
516
+ else if (pending.length > 0) {
517
+ this.#cancelScheduledRun(context);
518
+ const message = context.state === 'blocked' ? context.message : undefined;
519
+ context.state = 'blocked';
520
+ context.message = message ?? MEMORY_INPUT_BLOCKED_MESSAGE;
521
+ }
522
+ else {
523
+ this.#cancelScheduledRun(context);
524
+ if (context.state === 'scheduled')
525
+ context.state = 'idle';
526
+ }
527
+ }
528
+ catch {
529
+ context.state = 'error';
530
+ context.message = 'Local memory storage is unavailable';
531
+ }
532
+ }
533
+ #trackScheduleIfPending(context) {
534
+ const followUp = this.#scheduleIfPending(context).finally(() => {
535
+ context.followUps.delete(followUp);
536
+ });
537
+ context.followUps.add(followUp);
538
+ return followUp;
539
+ }
540
+ async #run(context, bypassRetryAt = false) {
541
+ if (this.#disposed || this.#draining || !this.#enabled || context.storageBlocked)
542
+ return;
543
+ context.state = 'processing';
544
+ context.message = undefined;
545
+ let lease;
546
+ try {
547
+ lease = await acquireLocalMemoryLease(this.environment, context.store.projectDirectory, this.#options.leaseOptions);
548
+ }
549
+ catch (error) {
550
+ context.writerBlocked = true;
551
+ context.state = 'error';
552
+ context.message = safeProcessingMessage(error);
553
+ return;
554
+ }
555
+ if (!lease) {
556
+ context.writerBlocked = true;
557
+ context.state = 'blocked';
558
+ context.message = 'Another Felan Code process owns the memory writer';
559
+ return;
560
+ }
561
+ context.writerBlocked = false;
562
+ const abort = new AbortController();
563
+ context.abort = abort;
564
+ const abortOnLeaseCompromise = () => {
565
+ abort.abort(new Error('Memory writer lease was lost'));
566
+ };
567
+ lease.compromised.addEventListener('abort', abortOnLeaseCompromise, { once: true });
568
+ let staging;
569
+ let retainedRun;
570
+ let published = false;
571
+ let attempt;
572
+ try {
573
+ const control = await this.#reconcileRunDiagnostics(context, lease);
574
+ if (control.autoDisabled) {
575
+ context.state = 'disabled';
576
+ return;
577
+ }
578
+ if (!bypassRetryAt && control.nextRetryAt && Date.parse(control.nextRetryAt) > this.#now()) {
579
+ context.state = 'scheduled';
580
+ return;
581
+ }
582
+ const snapshot = await context.store.processingSnapshot(this.#batchSize, (checkpoint) => !isBlockedCheckpoint(context, checkpoint));
583
+ if (snapshot.checkpoints.length === 0) {
584
+ context.state = 'idle';
585
+ return;
586
+ }
587
+ retainedRun = await LocalMemoryRun.create(this.environment, {
588
+ projectDirectory: context.store.projectDirectory,
589
+ sessionDirectory: join(this.#options.sessionDir ?? join(this.#options.agentDir, 'sessions'), 'memory'),
590
+ projectKey: context.project.key,
591
+ projectRoot: context.project.canonicalRoot,
592
+ checkpoints: snapshot.checkpoints.map(({ checkpoint }) => checkpoint),
593
+ baseFingerprint: snapshot.fingerprint,
594
+ ...(control.lastDisabledRunId ? { protectedRunIds: [control.lastDisabledRunId] } : {}),
595
+ });
596
+ staging = retainedRun.workspace;
597
+ const memoryDirectory = join(staging, '.memory');
598
+ await hydrateMemoryDirectory(this.environment, snapshot.artifact, memoryDirectory, { memoryPath: '.memory', mode: 'read' });
599
+ const manifest = await materializeMemoryInput(this.environment, {
600
+ stagingDirectory: staging,
601
+ checkpoints: snapshot.checkpoints.map(({ checkpoint }) => checkpoint),
602
+ previousCheckpoints: Object.fromEntries(Object.entries(snapshot.state.processed).map(([sessionId, entry]) => [sessionId, entry.checkpoint])),
603
+ baseSnapshot: createMemorySnapshot(snapshot.artifact, '.memory', { mode: 'read' }),
604
+ maxInputBytes: this.#options.maxInputBytes ?? MEMORY_DREAM_INPUT_MAX_BYTES,
605
+ ...(this.#options.maxTranscriptBytes === undefined
606
+ ? {}
607
+ : { maxTranscriptBytes: this.#options.maxTranscriptBytes }),
608
+ signal: abort.signal,
609
+ });
610
+ for (const failure of manifest.failures) {
611
+ context.blocked.set(failure.checkpoint.sessionId, {
612
+ leafId: failure.checkpoint.leafId,
613
+ transcriptDigest: failure.checkpoint.transcriptDigest,
614
+ });
615
+ }
616
+ const processedCheckpoints = snapshot.checkpoints.filter(({ checkpoint }) => manifest.sessions.some((session) => session.checkpoint.sessionId === checkpoint.sessionId
617
+ && sameCheckpointCursor(session.checkpoint, checkpoint)));
618
+ if (processedCheckpoints.length === 0) {
619
+ context.state = 'blocked';
620
+ context.message = MEMORY_INPUT_BLOCKED_MESSAGE;
621
+ await retainedRun.finish('blocked', MEMORY_INPUT_BLOCKED_MESSAGE);
622
+ return;
623
+ }
624
+ await retainedRun.record({ phase: 'model', checkpoints: processedCheckpoints.map(({ checkpoint }) => checkpoint) });
625
+ if (abort.signal.aborted || !this.#enabled || this.#disposed)
626
+ throw new Error('Memory processing was cancelled');
627
+ attempt = await context.store.beginAttempt(lease, {
628
+ runId: retainedRun.metadata.sessionId,
629
+ baseFingerprint: snapshot.fingerprint,
630
+ checkpoints: processedCheckpoints,
631
+ ...(bypassRetryAt ? { bypassRetryAt: true } : {}),
632
+ }, this.#now());
633
+ if (!attempt) {
634
+ await retainedRun.finish('cancelled', 'Memory inputs or processing policy changed before the attempt started');
635
+ return;
636
+ }
637
+ context.attempt = attempt;
638
+ this.#emitStatusChange();
639
+ const result = await this.#dreamRunner({
640
+ stagingDirectory: staging,
641
+ memoryDirectory,
642
+ inputDirectory: join(staging, '.dreaming', 'input'),
643
+ baseSnapshot: createMemorySnapshot(snapshot.artifact, '.memory', { mode: 'read' }),
644
+ manifest,
645
+ models: this.#options.models,
646
+ ...(this.#selectedModel === undefined ? {} : { selectedModel: this.#selectedModel }),
647
+ ...(this.#scopedModels === undefined ? {} : { scopedModels: this.#scopedModels }),
648
+ ...(this.#classifier === undefined ? {} : { classifier: this.#classifier }),
649
+ sessionDirectory: join(this.#options.sessionDir ?? join(this.#options.agentDir, 'sessions'), 'memory'),
650
+ run: retainedRun,
651
+ signal: abort.signal,
652
+ });
653
+ if (abort.signal.aborted || !this.#enabled || this.#disposed)
654
+ throw new Error('Memory processing was cancelled');
655
+ if (result)
656
+ await hydrateMemoryDirectory(this.environment, result, memoryDirectory, { replace: true, memoryPath: '.memory' });
657
+ await retainedRun.record({ phase: 'validate' });
658
+ const artifact = await readMemoryDirectory(this.environment, memoryDirectory, {
659
+ memoryPath: '.memory',
660
+ sourceSessionIds: allowedSourceIds(snapshot, manifest),
661
+ requireSources: true,
662
+ });
663
+ await retainedRun.record({
664
+ phase: 'publish',
665
+ outputFingerprint: createMemorySnapshot(artifact, '.memory').fingerprint,
666
+ });
667
+ await context.store.commit(lease, snapshot.fingerprint, artifact, processedCheckpoints, attempt);
668
+ published = true;
669
+ await context.store.finishAttempt(lease, attempt, { status: 'success' }, this.#now());
670
+ await retainedRun.finish('completed');
671
+ if (manifest.failures.length > 0) {
672
+ context.state = 'blocked';
673
+ context.message = MEMORY_INPUT_BLOCKED_MESSAGE;
674
+ }
675
+ else {
676
+ context.state = 'idle';
677
+ }
678
+ this.#emitStatusChange();
679
+ }
680
+ catch (error) {
681
+ const leaseValid = await lease.verify();
682
+ const explicitlyCancelled = !this.#enabled || this.#disposed
683
+ || (abort.signal.aborted && !lease.compromised.aborted);
684
+ const unavailableModel = error instanceof MemoryModelUnavailableError;
685
+ const blockedBeforeInference = retainedRun !== undefined && attempt === undefined && !explicitlyCancelled;
686
+ if (attempt && (leaseValid || explicitlyCancelled)) {
687
+ try {
688
+ const control = await context.store.finishAttempt(explicitlyCancelled || unavailableModel ? undefined : lease, attempt, explicitlyCancelled || unavailableModel ? { status: 'cancelled' }
689
+ : { status: 'failure', reason: sanitizeMemoryDiagnostic(error) }, this.#now());
690
+ published ||= control.lastOutcome?.runId === attempt.runId && control.lastOutcome.status === 'success';
691
+ }
692
+ catch {
693
+ context.storageBlocked = true;
694
+ }
695
+ }
696
+ if (published) {
697
+ context.state = 'idle';
698
+ context.message = 'Memory was published, but its diagnostics could not be fully saved';
699
+ this.#emitStatusChange();
700
+ return;
701
+ }
702
+ context.state = 'error';
703
+ context.message = !leaseValid
704
+ ? safeProcessingMessage(new Error('Memory writer lease was lost'))
705
+ : safeProcessingMessage(error);
706
+ if (unavailableModel || blockedBeforeInference) {
707
+ context.state = 'blocked';
708
+ for (const checkpoint of retainedRun?.metadata.checkpoints ?? []) {
709
+ context.blocked.set(checkpoint.sessionId, checkpoint);
710
+ }
711
+ }
712
+ if (!retainedRun) {
713
+ context.storageBlocked = true;
714
+ context.message = `Memory processing is paused: ${sanitizeMemoryDiagnostic(error)}`;
715
+ }
716
+ await retainedRun?.finish(explicitlyCancelled ? 'cancelled'
717
+ : unavailableModel || blockedBeforeInference ? 'blocked'
718
+ : !leaseValid ? 'interrupted' : 'failed', error).catch(() => {
719
+ context.storageBlocked = true;
720
+ context.message = 'Memory diagnostics could not be saved; evidence remains pending';
721
+ });
722
+ this.#emitStatusChange();
723
+ }
724
+ finally {
725
+ lease.compromised.removeEventListener('abort', abortOnLeaseCompromise);
726
+ context.abort = undefined;
727
+ context.attempt = undefined;
728
+ await lease.release();
729
+ }
730
+ }
731
+ async #reconcileRunDiagnostics(context, lease) {
732
+ let control = await context.store.readControl();
733
+ control = await context.store.reconcileAbandonedAttempt(lease, this.#now());
734
+ const options = {
735
+ projectDirectory: context.store.projectDirectory,
736
+ projectKey: context.project.key,
737
+ sessionDirectory: join(this.#options.sessionDir ?? join(this.#options.agentDir, 'sessions'), 'memory'),
738
+ ...(control.lastDisabledRunId ? { protectedRunIds: [control.lastDisabledRunId] } : {}),
739
+ };
740
+ if (control.lastOutcome)
741
+ await LocalMemoryRun.reconcile(this.environment, options, control.lastOutcome);
742
+ await LocalMemoryRun.reconcileAbandoned(this.environment, options, [
743
+ ...(control.activeAttempt ? [control.activeAttempt.runId] : []),
744
+ ...(control.lastOutcome ? [control.lastOutcome.runId] : []),
745
+ ], this.#now());
746
+ return control;
747
+ }
748
+ async #recover(context, signal) {
749
+ let cursor;
750
+ let recovered = false;
751
+ const stableAfter = this.#options.recoveryStableMs ?? 30_000;
752
+ const stopped = () => this.#disposed || this.#draining || !this.#enabled || signal.aborted;
753
+ try {
754
+ do {
755
+ const page = await this.environment.sessions.list({ scope: 'project' }, { ...(cursor ? { cursor } : {}), signal });
756
+ for (const info of page.items) {
757
+ if (stopped())
758
+ return false;
759
+ if (info.parent || this.#now() - info.updatedAt < stableAfter)
760
+ continue;
761
+ try {
762
+ const checkpoint = await readMemorySessionCheckpoint(this.environment.sessions, info.id, signal);
763
+ if (stopped())
764
+ return false;
765
+ recovered = (await context.store.recordCheckpoint(checkpoint)) || recovered;
766
+ }
767
+ catch {
768
+ if (stopped())
769
+ return false;
770
+ // A session that cannot be read now is skipped; its live checkpoint or a later run records it.
771
+ }
772
+ }
773
+ cursor = page.nextCursor;
774
+ } while (cursor);
775
+ }
776
+ catch {
777
+ if (stopped())
778
+ return false;
779
+ if (context.recoveryRetryAt === undefined) {
780
+ context.recoveryRetryAt = this.#now() + (this.#options.recoveryRetryMs ?? 30_000);
781
+ return false;
782
+ }
783
+ context.message = 'Memory startup recovery could not inspect sessions';
784
+ return true;
785
+ }
786
+ if (recovered && !this.#disposed && this.#enabled) {
787
+ await this.#trackScheduleIfPending(context);
788
+ this.#emitStatusChange();
789
+ }
790
+ return true;
791
+ }
792
+ #emitStatusChange() {
793
+ for (const listener of [...this.#statusListeners]) {
794
+ try {
795
+ listener();
796
+ }
797
+ catch { }
798
+ }
799
+ }
800
+ #cancelScheduledRun(context) {
801
+ if (context.timer)
802
+ clearTimeout(context.timer);
803
+ context.timer = undefined;
804
+ context.timerDueAt = undefined;
805
+ }
806
+ }
807
+ class LocalMemorySessionHost {
808
+ #coordinator;
809
+ #cwd;
810
+ #sessionStorageRoot;
811
+ constructor(coordinator, options) {
812
+ this.#coordinator = coordinator;
813
+ this.#cwd = options.cwd;
814
+ this.#sessionStorageRoot = options.sessionStorageRoot;
815
+ }
816
+ readCurrent() {
817
+ return this.#coordinator.readCurrent(this.#cwd, this.#sessionStorageRoot);
818
+ }
819
+ async recordCheckpoint(checkpoint) {
820
+ await this.#coordinator.recordCheckpoint(this.#cwd, checkpoint);
821
+ }
822
+ status() {
823
+ return this.#coordinator.status(this.#cwd);
824
+ }
825
+ }
826
+ function isBlockedCheckpoint(context, checkpoint) {
827
+ const blocked = context.blocked.get(checkpoint.sessionId);
828
+ return blocked !== undefined && sameCheckpointCursor(blocked, checkpoint);
829
+ }
830
+ function boundedMemoryBatchSize(requested) {
831
+ if (requested === undefined)
832
+ return MEMORY_MAX_BATCH_SESSIONS;
833
+ if (!Number.isSafeInteger(requested) || requested <= 0) {
834
+ throw new Error('Memory batch size must be a positive safe integer');
835
+ }
836
+ return Math.min(requested, MEMORY_MAX_BATCH_SESSIONS);
837
+ }
838
+ function sameCheckpointCursor(left, right) {
839
+ return left.leafId === right.leafId && left.transcriptDigest === right.transcriptDigest;
840
+ }
841
+ function allowedSourceIds(snapshot, manifest) {
842
+ return [...new Set([
843
+ ...snapshot.artifact.files.flatMap(({ content }) => extractSourceIds(content)),
844
+ ...manifest.sessions.map(({ checkpoint }) => checkpoint.sessionId),
845
+ ])];
846
+ }
847
+ function safeProcessingMessage(error) {
848
+ if (error instanceof MemoryModelUnavailableError) {
849
+ return 'Low-tier memory model is unavailable; evidence remains pending';
850
+ }
851
+ if (error instanceof Error && error.message.includes('cancel'))
852
+ return 'Memory processing was cancelled';
853
+ if (error instanceof Error && error.message.includes('writer lease'))
854
+ return 'Memory writer lease was lost; evidence remains pending';
855
+ if (error instanceof Error && error.message.includes('changed'))
856
+ return 'Memory changed during processing; evidence remains pending';
857
+ return 'Memory processing failed; evidence remains pending';
858
+ }
859
+ //# sourceMappingURL=coordinator.js.map