agent-working-memory 0.7.17 → 0.8.5

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 (186) hide show
  1. package/README.md +156 -15
  2. package/dist/adapters/common.js +202 -202
  3. package/dist/adapters/http.js +22 -22
  4. package/dist/api/routes.d.ts +1 -1
  5. package/dist/api/routes.d.ts.map +1 -1
  6. package/dist/api/routes.js +258 -79
  7. package/dist/api/routes.js.map +1 -1
  8. package/dist/cli/migrate.d.ts +42 -0
  9. package/dist/cli/migrate.d.ts.map +1 -0
  10. package/dist/cli/migrate.js +268 -0
  11. package/dist/cli/migrate.js.map +1 -0
  12. package/dist/cli.js +147 -103
  13. package/dist/cli.js.map +1 -1
  14. package/dist/coordination/circuit-breaker.d.ts +24 -0
  15. package/dist/coordination/circuit-breaker.d.ts.map +1 -0
  16. package/dist/coordination/circuit-breaker.js +70 -0
  17. package/dist/coordination/circuit-breaker.js.map +1 -0
  18. package/dist/coordination/failure-modes.d.ts +21 -0
  19. package/dist/coordination/failure-modes.d.ts.map +1 -0
  20. package/dist/coordination/failure-modes.js +49 -0
  21. package/dist/coordination/failure-modes.js.map +1 -0
  22. package/dist/coordination/peer-decisions.js +7 -7
  23. package/dist/coordination/routes.d.ts.map +1 -1
  24. package/dist/coordination/routes.js +44 -1
  25. package/dist/coordination/routes.js.map +1 -1
  26. package/dist/coordination/schema.d.ts.map +1 -1
  27. package/dist/coordination/schema.js +18 -0
  28. package/dist/coordination/schema.js.map +1 -1
  29. package/dist/coordination/schemas.d.ts +9 -9
  30. package/dist/coordination/stale.d.ts +9 -1
  31. package/dist/coordination/stale.d.ts.map +1 -1
  32. package/dist/coordination/stale.js +53 -9
  33. package/dist/coordination/stale.js.map +1 -1
  34. package/dist/core/embeddings.d.ts +16 -10
  35. package/dist/core/embeddings.d.ts.map +1 -1
  36. package/dist/core/embeddings.js +54 -38
  37. package/dist/core/embeddings.js.map +1 -1
  38. package/dist/core/ml-worker-entry.d.ts +17 -0
  39. package/dist/core/ml-worker-entry.d.ts.map +1 -0
  40. package/dist/core/ml-worker-entry.js +193 -0
  41. package/dist/core/ml-worker-entry.js.map +1 -0
  42. package/dist/core/ml-worker.d.ts +59 -0
  43. package/dist/core/ml-worker.d.ts.map +1 -0
  44. package/dist/core/ml-worker.js +253 -0
  45. package/dist/core/ml-worker.js.map +1 -0
  46. package/dist/core/query-expander.d.ts +13 -15
  47. package/dist/core/query-expander.d.ts.map +1 -1
  48. package/dist/core/query-expander.js +48 -54
  49. package/dist/core/query-expander.js.map +1 -1
  50. package/dist/core/reranker.d.ts +9 -19
  51. package/dist/core/reranker.d.ts.map +1 -1
  52. package/dist/core/reranker.js +33 -35
  53. package/dist/core/reranker.js.map +1 -1
  54. package/dist/core/salience.d.ts +32 -5
  55. package/dist/core/salience.d.ts.map +1 -1
  56. package/dist/core/salience.js +201 -28
  57. package/dist/core/salience.js.map +1 -1
  58. package/dist/core/write-pipeline.d.ts +10 -2
  59. package/dist/core/write-pipeline.d.ts.map +1 -1
  60. package/dist/core/write-pipeline.js +218 -49
  61. package/dist/core/write-pipeline.js.map +1 -1
  62. package/dist/engine/activation.d.ts +2 -2
  63. package/dist/engine/activation.d.ts.map +1 -1
  64. package/dist/engine/activation.js +238 -115
  65. package/dist/engine/activation.js.map +1 -1
  66. package/dist/engine/confidence.d.ts +62 -0
  67. package/dist/engine/confidence.d.ts.map +1 -0
  68. package/dist/engine/confidence.js +100 -0
  69. package/dist/engine/confidence.js.map +1 -0
  70. package/dist/engine/connections.d.ts +47 -8
  71. package/dist/engine/connections.d.ts.map +1 -1
  72. package/dist/engine/connections.js +83 -22
  73. package/dist/engine/connections.js.map +1 -1
  74. package/dist/engine/consolidation-scheduler.d.ts +46 -10
  75. package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
  76. package/dist/engine/consolidation-scheduler.js +170 -50
  77. package/dist/engine/consolidation-scheduler.js.map +1 -1
  78. package/dist/engine/consolidation.d.ts +16 -2
  79. package/dist/engine/consolidation.d.ts.map +1 -1
  80. package/dist/engine/consolidation.js +143 -44
  81. package/dist/engine/consolidation.js.map +1 -1
  82. package/dist/engine/eval.d.ts +2 -2
  83. package/dist/engine/eval.d.ts.map +1 -1
  84. package/dist/engine/eval.js +9 -9
  85. package/dist/engine/eval.js.map +1 -1
  86. package/dist/engine/eviction.d.ts +4 -4
  87. package/dist/engine/eviction.d.ts.map +1 -1
  88. package/dist/engine/eviction.js +15 -15
  89. package/dist/engine/eviction.js.map +1 -1
  90. package/dist/engine/retraction.d.ts +93 -5
  91. package/dist/engine/retraction.d.ts.map +1 -1
  92. package/dist/engine/retraction.js +238 -19
  93. package/dist/engine/retraction.js.map +1 -1
  94. package/dist/engine/staging.d.ts +1 -1
  95. package/dist/engine/staging.d.ts.map +1 -1
  96. package/dist/engine/staging.js +3 -3
  97. package/dist/engine/staging.js.map +1 -1
  98. package/dist/hooks/sidecar.d.ts +1 -1
  99. package/dist/hooks/sidecar.d.ts.map +1 -1
  100. package/dist/hooks/sidecar.js +4 -4
  101. package/dist/hooks/sidecar.js.map +1 -1
  102. package/dist/index.js +48 -31
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp.js +167 -141
  105. package/dist/mcp.js.map +1 -1
  106. package/dist/storage/factory.d.ts +55 -0
  107. package/dist/storage/factory.d.ts.map +1 -0
  108. package/dist/storage/factory.js +135 -0
  109. package/dist/storage/factory.js.map +1 -0
  110. package/dist/storage/pglite-schema.d.ts +20 -0
  111. package/dist/storage/pglite-schema.d.ts.map +1 -0
  112. package/dist/storage/pglite-schema.js +165 -0
  113. package/dist/storage/pglite-schema.js.map +1 -0
  114. package/dist/storage/pglite.d.ts +187 -0
  115. package/dist/storage/pglite.d.ts.map +1 -0
  116. package/dist/storage/pglite.js +1114 -0
  117. package/dist/storage/pglite.js.map +1 -0
  118. package/dist/storage/sqlite.d.ts +117 -0
  119. package/dist/storage/sqlite.d.ts.map +1 -1
  120. package/dist/storage/sqlite.js +357 -8
  121. package/dist/storage/sqlite.js.map +1 -1
  122. package/dist/storage/store.d.ts +63 -0
  123. package/dist/storage/store.d.ts.map +1 -0
  124. package/dist/storage/store.js +27 -0
  125. package/dist/storage/store.js.map +1 -0
  126. package/dist/types/engram.d.ts +92 -2
  127. package/dist/types/engram.d.ts.map +1 -1
  128. package/dist/types/engram.js.map +1 -1
  129. package/package.json +3 -1
  130. package/src/adapters/claude-code.ts +218 -218
  131. package/src/adapters/codex.ts +252 -252
  132. package/src/adapters/common.ts +411 -411
  133. package/src/adapters/cursor.ts +135 -135
  134. package/src/adapters/http.ts +93 -93
  135. package/src/adapters/index.ts +31 -31
  136. package/src/adapters/types.ts +75 -75
  137. package/src/api/index.ts +3 -3
  138. package/src/api/routes.ts +970 -714
  139. package/src/cli/migrate.ts +307 -0
  140. package/src/cli.ts +755 -719
  141. package/src/coordination/circuit-breaker.ts +83 -0
  142. package/src/coordination/events.ts +90 -90
  143. package/src/coordination/failure-modes.ts +50 -0
  144. package/src/coordination/peer-decisions.ts +105 -105
  145. package/src/coordination/plugin-loader.ts +60 -60
  146. package/src/coordination/plugin.ts +44 -44
  147. package/src/coordination/routes.ts +56 -1
  148. package/src/coordination/schema.ts +13 -0
  149. package/src/coordination/stale.ts +80 -11
  150. package/src/coordination/types.ts +311 -311
  151. package/src/coordination/write-mutex.ts +69 -69
  152. package/src/core/auto-tagger.ts +168 -168
  153. package/src/core/decay.ts +63 -63
  154. package/src/core/embeddings.ts +110 -93
  155. package/src/core/index.ts +5 -5
  156. package/src/core/logger.ts +36 -36
  157. package/src/core/ml-worker-entry.ts +194 -0
  158. package/src/core/ml-worker.ts +281 -0
  159. package/src/core/query-expander.ts +122 -128
  160. package/src/core/reranker.ts +119 -125
  161. package/src/core/salience.ts +200 -33
  162. package/src/core/write-pipeline.ts +521 -343
  163. package/src/engine/activation.ts +235 -109
  164. package/src/engine/confidence.ts +120 -0
  165. package/src/engine/connections.ts +162 -103
  166. package/src/engine/consolidation-scheduler.ts +242 -125
  167. package/src/engine/consolidation.ts +138 -45
  168. package/src/engine/eval.ts +102 -102
  169. package/src/engine/eviction.ts +101 -101
  170. package/src/engine/index.ts +8 -8
  171. package/src/engine/retraction.ts +366 -116
  172. package/src/engine/staging.ts +74 -74
  173. package/src/hooks/sidecar.ts +5 -5
  174. package/src/index.ts +226 -212
  175. package/src/mcp.ts +1145 -1121
  176. package/src/storage/factory.ts +147 -0
  177. package/src/storage/index.ts +3 -3
  178. package/src/storage/pglite-schema.ts +166 -0
  179. package/src/storage/pglite.ts +1363 -0
  180. package/src/storage/sqlite.ts +402 -7
  181. package/src/storage/store.ts +80 -0
  182. package/src/types/agent.ts +67 -67
  183. package/src/types/checkpoint.ts +46 -46
  184. package/src/types/engram.ts +101 -3
  185. package/src/types/eval.ts +100 -100
  186. package/src/types/index.ts +6 -6
@@ -1,103 +1,162 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Connection Engine — discovers links between memories.
5
- *
6
- * Runs asynchronously. When a new engram is written, the connection
7
- * engine checks it against existing memories and forms association
8
- * edges where resonance exceeds a threshold.
9
- *
10
- * Connection memories are first-class engrams they can themselves
11
- * activate and form higher-order connections, producing emergent
12
- * associative structure over time.
13
- */
14
-
15
- import type { EngramStore } from '../storage/sqlite.js';
16
- import type { ActivationEngine } from './activation.js';
17
- import type { Engram } from '../types/index.js';
18
-
19
- export class ConnectionEngine {
20
- private store: EngramStore;
21
- private engine: ActivationEngine;
22
- private threshold: number;
23
- private queue: string[] = [];
24
- private processing = false;
25
-
26
- constructor(
27
- store: EngramStore,
28
- engine: ActivationEngine,
29
- threshold: number = 0.7
30
- ) {
31
- this.store = store;
32
- this.engine = engine;
33
- this.threshold = threshold;
34
- }
35
-
36
- /**
37
- * Queue a newly written engram for connection discovery.
38
- */
39
- enqueue(engramId: string): void {
40
- this.queue.push(engramId);
41
- if (!this.processing) {
42
- this.processQueue();
43
- }
44
- }
45
-
46
- private async processQueue(): Promise<void> {
47
- this.processing = true;
48
-
49
- while (this.queue.length > 0) {
50
- const engramId = this.queue.shift()!;
51
- const engram = this.store.getEngram(engramId);
52
- if (!engram || engram.stage !== 'active') continue;
53
-
54
- try {
55
- await this.findConnections(engram);
56
- } catch {
57
- // Connection discovery is best-effort — don't crash the server
58
- }
59
- }
60
-
61
- this.processing = false;
62
- }
63
-
64
- /**
65
- * Find and create connections for a given engram.
66
- */
67
- private async findConnections(engram: Engram): Promise<void> {
68
- const results = await this.engine.activate({
69
- agentId: engram.agentId,
70
- context: `${engram.concept} ${engram.content}`,
71
- limit: 5,
72
- minScore: this.threshold,
73
- internal: true,
74
- });
75
-
76
- // Filter out self and already-connected engrams
77
- const existing = this.store.getAssociationsFor(engram.id);
78
- const existingIds = new Set(existing.map(a =>
79
- a.fromEngramId === engram.id ? a.toEngramId : a.fromEngramId
80
- ));
81
-
82
- for (const result of results) {
83
- if (result.engram.id === engram.id) continue;
84
- if (existingIds.has(result.engram.id)) continue;
85
-
86
- // Create a connection association
87
- this.store.upsertAssociation(
88
- engram.id,
89
- result.engram.id,
90
- result.score,
91
- 'connection'
92
- );
93
-
94
- // Bidirectional
95
- this.store.upsertAssociation(
96
- result.engram.id,
97
- engram.id,
98
- result.score,
99
- 'connection'
100
- );
101
- }
102
- }
103
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Connection Engine — discovers links between memories.
5
+ *
6
+ * **Lifecycle (v0.8.2):** `enqueue()` just appends to an in-memory queue and
7
+ * returns. The queue is drained by `processQueue()`, which is called from
8
+ * the consolidation cycle. Per-write inline drain was removed because each
9
+ * `findConnections` call runs a full activation cycle (embed + BM25 + vector
10
+ * + rerank) ~200-500 ms of event-loop blocking per write, queued ahead
11
+ * of subsequent requests under load.
12
+ *
13
+ * Cold-start exception: when the agent has fewer than
14
+ * `AWM_CONNECTION_COLD_START_THRESHOLD` (default 10) active engrams, callers
15
+ * can opt into inline drain via `enqueueAndMaybeFlush()` so the first few
16
+ * writes still produce a useful association graph before the next
17
+ * consolidation cycle fires. Once the pool grows past the threshold, all
18
+ * discovery defers to consolidation regardless.
19
+ *
20
+ * Footprint: when AWM is idle and no consolidation is running, the queue
21
+ * is a plain `string[]` — no timers, no background work, ~24 bytes per
22
+ * queued ID. AWM remains cheap to NOT use.
23
+ */
24
+
25
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
26
+ import type { ActivationEngine } from './activation.js';
27
+ import type { Engram } from '../types/index.js';
28
+
29
+ const COLD_START_THRESHOLD = Number(process.env.AWM_CONNECTION_COLD_START_THRESHOLD ?? 10);
30
+
31
+ export class ConnectionEngine {
32
+ private store: EngramStore;
33
+ private engine: ActivationEngine;
34
+ private threshold: number;
35
+ private queue: string[] = [];
36
+ private processing = false;
37
+
38
+ constructor(
39
+ store: EngramStore,
40
+ engine: ActivationEngine,
41
+ threshold: number = 0.7
42
+ ) {
43
+ this.store = store;
44
+ this.engine = engine;
45
+ this.threshold = threshold;
46
+ }
47
+
48
+ /**
49
+ * Queue a newly written engram for connection discovery.
50
+ *
51
+ * Synchronous and non-triggering. The queue is drained later by:
52
+ * - `processQueue()` called from the consolidation cycle, or
53
+ * - `enqueueAndMaybeFlush()` for cold-start inline drain.
54
+ */
55
+ enqueue(engramId: string): void {
56
+ this.queue.push(engramId);
57
+ }
58
+
59
+ /**
60
+ * Queue + opportunistic inline drain for cold-start agents.
61
+ *
62
+ * If the agent has fewer than `AWM_CONNECTION_COLD_START_THRESHOLD`
63
+ * active engrams (default 10), drain the queue inline so the first few
64
+ * writes produce a useful association graph before consolidation runs.
65
+ * Once the pool grows past the threshold, this falls back to deferred
66
+ * (consolidation-driven) drain.
67
+ *
68
+ * Returns immediately the inline drain runs as a fire-and-forget
69
+ * background task so the calling write doesn't block on it.
70
+ */
71
+ enqueueAndMaybeFlush(engramId: string, agentId: string): void {
72
+ this.queue.push(engramId);
73
+ if (this.processing) return;
74
+ void this.maybeDrainColdStart(agentId);
75
+ }
76
+
77
+ private async maybeDrainColdStart(agentId: string): Promise<void> {
78
+ try {
79
+ const count = await this.store.getActiveCount(agentId);
80
+ if (count < COLD_START_THRESHOLD) {
81
+ await this.processQueue();
82
+ }
83
+ } catch {
84
+ // Cold-start drain is best-effort. The next consolidation cycle
85
+ // will drain whatever stayed queued.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Drain the queue: run connection discovery for every queued engram.
91
+ *
92
+ * Called from the consolidation cycle (`ConsolidationEngine.consolidate`)
93
+ * at the start of each run, and from `enqueueAndMaybeFlush()` for
94
+ * cold-start agents. Reentrant-safe via the `processing` flag.
95
+ *
96
+ * Exposed publicly so callers (consolidation, tests) can explicitly drain.
97
+ */
98
+ async processQueue(): Promise<void> {
99
+ if (this.processing) return; // Reentrancy guard
100
+ this.processing = true;
101
+ try {
102
+ while (this.queue.length > 0) {
103
+ const engramId = this.queue.shift()!;
104
+ const engram = await this.store.getEngram(engramId);
105
+ if (!engram || engram.stage !== 'active') continue;
106
+
107
+ try {
108
+ await this.findConnections(engram);
109
+ } catch {
110
+ // Connection discovery is best-effort — don't crash the server
111
+ }
112
+ }
113
+ } finally {
114
+ this.processing = false;
115
+ }
116
+ }
117
+
118
+ /** Number of engrams currently queued for connection discovery. */
119
+ queueSize(): number {
120
+ return this.queue.length;
121
+ }
122
+
123
+ /**
124
+ * Find and create connections for a given engram.
125
+ */
126
+ private async findConnections(engram: Engram): Promise<void> {
127
+ const results = await this.engine.activate({
128
+ agentId: engram.agentId,
129
+ context: `${engram.concept} ${engram.content}`,
130
+ limit: 5,
131
+ minScore: this.threshold,
132
+ internal: true,
133
+ });
134
+
135
+ // Filter out self and already-connected engrams
136
+ const existing = await this.store.getAssociationsFor(engram.id);
137
+ const existingIds = new Set(existing.map(a =>
138
+ a.fromEngramId === engram.id ? a.toEngramId : a.fromEngramId
139
+ ));
140
+
141
+ for (const result of results) {
142
+ if (result.engram.id === engram.id) continue;
143
+ if (existingIds.has(result.engram.id)) continue;
144
+
145
+ // Create a connection association
146
+ await this.store.upsertAssociation(
147
+ engram.id,
148
+ result.engram.id,
149
+ result.score,
150
+ 'connection'
151
+ );
152
+
153
+ // Bidirectional
154
+ await this.store.upsertAssociation(
155
+ result.engram.id,
156
+ engram.id,
157
+ result.score,
158
+ 'connection'
159
+ );
160
+ }
161
+ }
162
+ }
@@ -1,125 +1,242 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Consolidation Scheduler automatically triggers sleep cycles.
5
- *
6
- * Four triggers:
7
- * 1. Idle — agent inactive >10min → full consolidation
8
- * 2. Volume 50+ writes since last consolidation full consolidation
9
- * 3. Time 30min since last consolidation → full consolidation
10
- * 4. Adaptive — retrieval precision <0.4 → full consolidation
11
- *
12
- * Also provides mini-consolidation for restore (fire-and-forget, lightweight).
13
- * Checks every 30 seconds across all active agents.
14
- */
15
-
16
- import type { EngramStore } from '../storage/sqlite.js';
17
- import type { ConsolidationEngine } from './consolidation.js';
18
-
19
- const TICK_INTERVAL_MS = 30_000; // Check every 30s
20
- const IDLE_THRESHOLD_MS = 10 * 60_000; // 10 minutes
21
- const VOLUME_THRESHOLD = 50; // 50 writes
22
- const TIME_THRESHOLD_MS = 30 * 60_000; // 30 minutes
23
- const PRECISION_THRESHOLD = 0.4; // Below this triggers consolidation
24
-
25
- export class ConsolidationScheduler {
26
- private timer: ReturnType<typeof setInterval> | null = null;
27
- private running = false;
28
-
29
- constructor(
30
- private store: EngramStore,
31
- private consolidationEngine: ConsolidationEngine,
32
- ) {}
33
-
34
- start(): void {
35
- if (this.timer) return;
36
- this.timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
37
- console.log('ConsolidationScheduler started (30s tick)');
38
- }
39
-
40
- stop(): void {
41
- if (this.timer) {
42
- clearInterval(this.timer);
43
- this.timer = null;
44
- }
45
- console.log('ConsolidationScheduler stopped');
46
- }
47
-
48
- /**
49
- * Mini-consolidation lightweight, called from restore path.
50
- * Only runs replay + strengthen (phases 1-2), skips heavy phases.
51
- */
52
- async runMiniConsolidation(agentId: string): Promise<void> {
53
- if (this.running) return;
54
- this.running = true;
55
- try {
56
- console.log(`[scheduler] mini-consolidation for ${agentId}`);
57
- await this.consolidationEngine.consolidate(agentId);
58
- this.store.markConsolidation(agentId, true);
59
- } catch (err) {
60
- console.error(`[scheduler] mini-consolidation failed for ${agentId}:`, err);
61
- } finally {
62
- this.running = false;
63
- }
64
- }
65
-
66
- private tick(): void {
67
- if (this.running) return;
68
-
69
- const agents = this.store.getActiveAgents();
70
- const now = Date.now();
71
-
72
- for (const agent of agents) {
73
- const idleMs = now - agent.lastActivityAt.getTime();
74
- const sinceConsolidation = agent.lastConsolidationAt
75
- ? now - agent.lastConsolidationAt.getTime()
76
- : Infinity;
77
-
78
- let trigger: string | null = null;
79
-
80
- // 1. Idle trigger — agent stopped writing/recalling >10min ago
81
- if (idleMs > IDLE_THRESHOLD_MS && sinceConsolidation > IDLE_THRESHOLD_MS) {
82
- trigger = `idle (${Math.round(idleMs / 60_000)}min)`;
83
- }
84
-
85
- // 2. Volume trigger many writes accumulated
86
- if (!trigger && agent.writeCount >= VOLUME_THRESHOLD) {
87
- trigger = `volume (${agent.writeCount} writes)`;
88
- }
89
-
90
- // 3. Time trigger been too long since last consolidation
91
- if (!trigger && sinceConsolidation > TIME_THRESHOLD_MS) {
92
- trigger = `time (${Math.round(sinceConsolidation / 60_000)}min)`;
93
- }
94
-
95
- // 4. Adaptive trigger — precision is low
96
- if (!trigger) {
97
- try {
98
- const precision = this.store.getRetrievalPrecision(agent.agentId, 1);
99
- if (precision > 0 && precision < PRECISION_THRESHOLD) {
100
- trigger = `adaptive (precision ${(precision * 100).toFixed(0)}%)`;
101
- }
102
- } catch { /* precision check is non-fatal */ }
103
- }
104
-
105
- if (trigger) {
106
- this.runFullConsolidation(agent.agentId, trigger);
107
- return; // One consolidation per tick to avoid overload
108
- }
109
- }
110
- }
111
-
112
- private async runFullConsolidation(agentId: string, reason: string): Promise<void> {
113
- this.running = true;
114
- try {
115
- console.log(`[scheduler] full consolidation for ${agentId} — trigger: ${reason}`);
116
- const result = await this.consolidationEngine.consolidate(agentId);
117
- this.store.markConsolidation(agentId, false);
118
- console.log(`[scheduler] consolidation done: ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
119
- } catch (err) {
120
- console.error(`[scheduler] consolidation failed for ${agentId}:`, err);
121
- } finally {
122
- this.running = false;
123
- }
124
- }
125
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Consolidation Scheduler - sleep-only consolidation (AWM 0.8.x).
5
+ *
6
+ * Two triggers, both modeled on biological sleep (offline consolidation, not in-band):
7
+ *
8
+ * 1. Cron - fires at a configured time (default: 0 3 * * * = 3 AM local time).
9
+ * Configurable via AWM_CONSOLIDATION_CRON env var.
10
+ *
11
+ * 2. Quiescence - fires when ALL active agents have been idle >30 min.
12
+ * "Truly asleep" - no agent is currently writing or recalling.
13
+ *
14
+ * Kill switch: AWM_DISABLE_SCHEDULER=1 skips both triggers. Manual
15
+ * consolidation via POST /system/consolidate still works.
16
+ *
17
+ * Removed in 2.0: in-band idle/volume/time/precision triggers that fired
18
+ * during active hours and blocked HTTP.
19
+ *
20
+ * Tick granularity: 1 minute (sufficient for cron-at-the-minute precision
21
+ * and quiescence checks at human timescales).
22
+ */
23
+
24
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
25
+ import type { ConsolidationEngine } from './consolidation.js';
26
+
27
+ const TICK_INTERVAL_MS = 60_000; // Check every 60s
28
+ const QUIESCENCE_THRESHOLD_MS = 30 * 60_000; // 30 minutes
29
+ const DEFAULT_CRON = '0 3 * * *'; // 3 AM local time daily
30
+
31
+ // --- Cron matcher (hand-rolled, minute-granularity) ---
32
+
33
+ /**
34
+ * Parse a single cron field into a Set of valid integer values.
35
+ * Supports: wildcard, literal value, range A-B, list A,B,C, step A-B/N.
36
+ */
37
+ function parseCronField(field: string, min: number, max: number): Set<number> {
38
+ const result = new Set<number>();
39
+ for (const part of field.split(',')) {
40
+ // Handle step: <range>/N
41
+ const stepMatch = part.match(/^(.+?)\/(\d+)$/);
42
+ if (stepMatch) {
43
+ const range = stepMatch[1];
44
+ const step = parseInt(stepMatch[2], 10);
45
+ if (step <= 0) continue;
46
+ const [lo, hi] = range === '*'
47
+ ? [min, max]
48
+ : range.includes('-')
49
+ ? range.split('-').map(n => parseInt(n, 10)) as [number, number]
50
+ : [parseInt(range, 10), max];
51
+ for (let n = lo; n <= hi; n += step) {
52
+ if (n >= min && n <= max) result.add(n);
53
+ }
54
+ continue;
55
+ }
56
+ // Range: A-B
57
+ if (part.includes('-')) {
58
+ const [lo, hi] = part.split('-').map(n => parseInt(n, 10));
59
+ for (let n = lo; n <= hi; n++) {
60
+ if (n >= min && n <= max) result.add(n);
61
+ }
62
+ continue;
63
+ }
64
+ // Wildcard
65
+ if (part === '*') {
66
+ for (let n = min; n <= max; n++) result.add(n);
67
+ continue;
68
+ }
69
+ // Single value
70
+ const n = parseInt(part, 10);
71
+ if (!Number.isNaN(n) && n >= min && n <= max) result.add(n);
72
+ }
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Return true if `now` matches the cron expression at minute granularity.
78
+ * Format: "minute hour dayOfMonth month dayOfWeek" (5 fields, space-separated).
79
+ * Day-of-week: 0-6 (0 = Sunday). 7 is also accepted as Sunday alias.
80
+ */
81
+ export function cronMatches(now: Date, expr: string): boolean {
82
+ const fields = expr.trim().split(/\s+/);
83
+ if (fields.length !== 5) return false;
84
+
85
+ const minutes = parseCronField(fields[0], 0, 59);
86
+ const hours = parseCronField(fields[1], 0, 23);
87
+ const daysOfMonth = parseCronField(fields[2], 1, 31);
88
+ const months = parseCronField(fields[3], 1, 12);
89
+ const daysOfWeekRaw = parseCronField(fields[4], 0, 7);
90
+ // Normalize: 7 == 0 (Sunday)
91
+ const daysOfWeek = new Set<number>();
92
+ for (const d of daysOfWeekRaw) daysOfWeek.add(d === 7 ? 0 : d);
93
+
94
+ return (
95
+ minutes.has(now.getMinutes()) &&
96
+ hours.has(now.getHours()) &&
97
+ daysOfMonth.has(now.getDate()) &&
98
+ months.has(now.getMonth() + 1) &&
99
+ daysOfWeek.has(now.getDay())
100
+ );
101
+ }
102
+
103
+ // --- Scheduler ---
104
+
105
+ export class ConsolidationScheduler {
106
+ private timer: ReturnType<typeof setInterval> | null = null;
107
+ private running = false;
108
+ private readonly cronExpr: string;
109
+ private readonly disabled: boolean;
110
+ private lastCronTriggeredMinute: string | null = null;
111
+
112
+ constructor(
113
+ private store: EngramStore,
114
+ private consolidationEngine: ConsolidationEngine,
115
+ ) {
116
+ this.cronExpr = process.env.AWM_CONSOLIDATION_CRON ?? DEFAULT_CRON;
117
+ this.disabled = process.env.AWM_DISABLE_SCHEDULER === '1' || process.env.AWM_DISABLE_SCHEDULER === 'true';
118
+ }
119
+
120
+ start(): void {
121
+ if (this.timer) return;
122
+ if (this.disabled) {
123
+ console.log('ConsolidationScheduler disabled (AWM_DISABLE_SCHEDULER=1)');
124
+ return;
125
+ }
126
+ this.timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
127
+ console.log(`ConsolidationScheduler started - cron='${this.cronExpr}', quiescence-gate=${QUIESCENCE_THRESHOLD_MS / 60_000}min`);
128
+ }
129
+
130
+ stop(): void {
131
+ if (this.timer) {
132
+ clearInterval(this.timer);
133
+ this.timer = null;
134
+ }
135
+ console.log('ConsolidationScheduler stopped');
136
+ }
137
+
138
+ /** True if the scheduler is currently running a consolidation cycle. */
139
+ isRunning(): boolean {
140
+ return this.running;
141
+ }
142
+
143
+ /** True if the scheduler's automatic triggers are disabled (kill switch). */
144
+ isDisabled(): boolean {
145
+ return this.disabled;
146
+ }
147
+
148
+ /**
149
+ * Mini-consolidation - lightweight, called from restore path.
150
+ * Only runs replay + strengthen (phases 1-2), skips heavy phases.
151
+ */
152
+ async runMiniConsolidation(agentId: string): Promise<void> {
153
+ if (this.running) return;
154
+ this.running = true;
155
+ try {
156
+ console.log(`[scheduler] mini-consolidation for ${agentId}`);
157
+ await this.consolidationEngine.consolidate(agentId);
158
+ await this.store.markConsolidation(agentId, true);
159
+ } catch (err) {
160
+ console.error(`[scheduler] mini-consolidation failed for ${agentId}:`, err);
161
+ } finally {
162
+ this.running = false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Tick handler - checks both triggers. Cron first (planned), then quiescence (opportunistic).
168
+ * Fires consolidation for at most one agent per tick to avoid overload.
169
+ */
170
+ private async tick(): Promise<void> {
171
+ if (this.running) return;
172
+ const now = new Date();
173
+
174
+ // Trigger 1: cron
175
+ if (cronMatches(now, this.cronExpr)) {
176
+ const minuteKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}T${now.getHours()}:${now.getMinutes()}`;
177
+ if (this.lastCronTriggeredMinute !== minuteKey) {
178
+ this.lastCronTriggeredMinute = minuteKey;
179
+ const agent = await this.pickAgent();
180
+ if (agent) {
181
+ this.runFullConsolidation(agent.agentId, `cron (${this.cronExpr})`);
182
+ return;
183
+ }
184
+ }
185
+ }
186
+
187
+ // Trigger 2: quiescence
188
+ if (await this.isQuiescent(now)) {
189
+ const agent = await this.pickAgent();
190
+ if (agent) {
191
+ this.runFullConsolidation(agent.agentId, `quiescence (>${QUIESCENCE_THRESHOLD_MS / 60_000}min idle, all agents)`);
192
+ return;
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Pick the agent that benefits most from consolidation:
199
+ * highest writeCount since last consolidation, tie-break by oldest consolidation.
200
+ */
201
+ private async pickAgent(): Promise<{ agentId: string } | null> {
202
+ const agents = await this.store.getActiveAgents();
203
+ if (agents.length === 0) return null;
204
+ const sorted = [...agents].sort((a, b) => {
205
+ if (b.writeCount !== a.writeCount) return b.writeCount - a.writeCount;
206
+ const aT = a.lastConsolidationAt?.getTime() ?? 0;
207
+ const bT = b.lastConsolidationAt?.getTime() ?? 0;
208
+ return aT - bT;
209
+ });
210
+ return sorted[0];
211
+ }
212
+
213
+ /**
214
+ * Quiescence check: ALL active agents must have lastActivityAt > threshold ago.
215
+ * `lastActivityAt` is updated on both writes and recalls so this captures both.
216
+ * If no active agents, the system is trivially quiescent.
217
+ */
218
+ private async isQuiescent(now: Date): Promise<boolean> {
219
+ const agents = await this.store.getActiveAgents();
220
+ if (agents.length === 0) return true;
221
+ const nowMs = now.getTime();
222
+ for (const agent of agents) {
223
+ const idleMs = nowMs - agent.lastActivityAt.getTime();
224
+ if (idleMs < QUIESCENCE_THRESHOLD_MS) return false;
225
+ }
226
+ return true;
227
+ }
228
+
229
+ private async runFullConsolidation(agentId: string, reason: string): Promise<void> {
230
+ this.running = true;
231
+ try {
232
+ console.log(`[scheduler] full consolidation for ${agentId} - trigger: ${reason}`);
233
+ const result = await this.consolidationEngine.consolidate(agentId);
234
+ await this.store.markConsolidation(agentId, false);
235
+ console.log(`[scheduler] consolidation done: ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
236
+ } catch (err) {
237
+ console.error(`[scheduler] consolidation failed for ${agentId}:`, err);
238
+ } finally {
239
+ this.running = false;
240
+ }
241
+ }
242
+ }