agent-working-memory 0.8.8 → 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 (54) hide show
  1. package/README.md +165 -46
  2. package/dist/api/routes.js +7 -7
  3. package/dist/cli/migrate.js +29 -29
  4. package/dist/cli.js +104 -104
  5. package/dist/coordination/circuit-breaker.js +23 -23
  6. package/dist/core/write-pipeline.d.ts.map +1 -1
  7. package/dist/core/write-pipeline.js +17 -0
  8. package/dist/core/write-pipeline.js.map +1 -1
  9. package/dist/engine/activation.d.ts +28 -0
  10. package/dist/engine/activation.d.ts.map +1 -1
  11. package/dist/engine/activation.js +341 -11
  12. package/dist/engine/activation.js.map +1 -1
  13. package/dist/engine/connections.d.ts +12 -0
  14. package/dist/engine/connections.d.ts.map +1 -1
  15. package/dist/engine/connections.js +95 -0
  16. package/dist/engine/connections.js.map +1 -1
  17. package/dist/mcp.js +90 -90
  18. package/dist/storage/pglite-schema.js +143 -143
  19. package/dist/storage/pglite.js +138 -138
  20. package/dist/types/engram.d.ts +1 -0
  21. package/dist/types/engram.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/api/index.ts +3 -3
  24. package/src/cli/migrate.ts +307 -307
  25. package/src/coordination/circuit-breaker.ts +83 -83
  26. package/src/coordination/failure-modes.ts +50 -50
  27. package/src/core/decay.ts +63 -63
  28. package/src/core/embeddings.ts +110 -110
  29. package/src/core/index.ts +5 -5
  30. package/src/core/logger.ts +36 -36
  31. package/src/core/ml-worker-entry.ts +194 -194
  32. package/src/core/ml-worker.ts +281 -281
  33. package/src/core/query-expander.ts +122 -122
  34. package/src/core/reranker.ts +119 -119
  35. package/src/core/write-pipeline.ts +15 -0
  36. package/src/engine/activation.ts +328 -11
  37. package/src/engine/confidence.ts +120 -120
  38. package/src/engine/connections.ts +94 -0
  39. package/src/engine/consolidation-scheduler.ts +242 -242
  40. package/src/engine/eval.ts +102 -102
  41. package/src/engine/eviction.ts +101 -101
  42. package/src/engine/index.ts +8 -8
  43. package/src/engine/retraction.ts +366 -366
  44. package/src/engine/staging.ts +74 -74
  45. package/src/storage/factory.ts +147 -147
  46. package/src/storage/index.ts +3 -3
  47. package/src/storage/pglite-schema.ts +166 -166
  48. package/src/storage/pglite.ts +1363 -1363
  49. package/src/storage/store.ts +80 -80
  50. package/src/types/agent.ts +67 -67
  51. package/src/types/checkpoint.ts +46 -46
  52. package/src/types/engram.ts +1 -0
  53. package/src/types/eval.ts +100 -100
  54. package/src/types/index.ts +6 -6
@@ -28,6 +28,50 @@ import type { Engram } from '../types/index.js';
28
28
 
29
29
  const COLD_START_THRESHOLD = Number(process.env.AWM_CONNECTION_COLD_START_THRESHOLD ?? 10);
30
30
 
31
+ /**
32
+ * R1 — broaden edge FORMATION beyond high-cosine semantic links.
33
+ *
34
+ * The semantic `activate` path only links engrams at ≥0.7 cosine, so two facts
35
+ * that share an entity but are lexically/semantically distant ("my main project
36
+ * is Atlas" vs "Atlas's codename is Magpie") never get an edge — starving the
37
+ * graph walk / spreading activation of exactly the bridges multi-hop needs.
38
+ *
39
+ * When enabled, after the semantic pass we also form *entity co-occurrence*
40
+ * edges: extract proper-noun entities from the engram, find other engrams that
41
+ * literally mention the same entity (BM25 + substring re-check for precision),
42
+ * and link them at a LOWER weight than semantic edges. Recall-only by design —
43
+ * the edges feed candidate generation; the reranker still makes the final cut.
44
+ *
45
+ * Default-OFF (gate per docs/awm-improvement-register.md). Set
46
+ * `AWM_BROAD_EDGES=1` to enable.
47
+ */
48
+ const BROAD_EDGES = process.env.AWM_BROAD_EDGES === '1';
49
+ /** Max entity-co-occurrence edges formed per engram (on top of semantic). */
50
+ const MAX_ENTITY_EDGES = Number(process.env.AWM_BROAD_EDGES_MAX ?? 6);
51
+ /** Proper-noun entity extraction — mirrors auto-tagger's `entity:` pattern. */
52
+ const ENTITY_RE = /\b([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]+)*)\b/g;
53
+ /** Common capitalized words that are not useful entity bridges. */
54
+ const ENTITY_STOPWORDS = new Set([
55
+ 'The', 'This', 'That', 'These', 'Those', 'There', 'Then', 'They', 'Them',
56
+ 'And', 'But', 'For', 'With', 'From', 'Into', 'When', 'What', 'Where', 'Which',
57
+ 'While', 'Who', 'Why', 'How', 'Also', 'After', 'Before', 'Because', 'Should',
58
+ 'Would', 'Could', 'Will', 'Was', 'Were', 'Has', 'Have', 'Had', 'Not', 'Now',
59
+ 'New', 'One', 'Two', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
60
+ 'Saturday', 'Sunday', 'January', 'February', 'March', 'April', 'June', 'July',
61
+ 'August', 'September', 'October', 'November', 'December',
62
+ ]);
63
+
64
+ function extractEntities(text: string): string[] {
65
+ const out = new Set<string>();
66
+ for (const m of text.matchAll(ENTITY_RE)) {
67
+ const name = m[1].trim();
68
+ if (name.length < 3 || name.length > 40) continue;
69
+ if (ENTITY_STOPWORDS.has(name)) continue;
70
+ out.add(name);
71
+ }
72
+ return [...out];
73
+ }
74
+
31
75
  export class ConnectionEngine {
32
76
  private store: EngramStore;
33
77
  private engine: ActivationEngine;
@@ -130,6 +174,7 @@ export class ConnectionEngine {
130
174
  limit: 5,
131
175
  minScore: this.threshold,
132
176
  internal: true,
177
+ spread: false, // edge discovery must not recurse through R2 spreading
133
178
  });
134
179
 
135
180
  // Filter out self and already-connected engrams
@@ -157,6 +202,55 @@ export class ConnectionEngine {
157
202
  result.score,
158
203
  'connection'
159
204
  );
205
+ existingIds.add(result.engram.id);
206
+ }
207
+
208
+ if (BROAD_EDGES) {
209
+ await this.formEntityEdges(engram, existingIds);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * R1 — form entity co-occurrence edges (default-off, `AWM_BROAD_EDGES=1`).
215
+ *
216
+ * Extract proper-noun entities from the engram, find other engrams that
217
+ * literally mention the same entity, and link them at a lower weight than
218
+ * the semantic edges above. The BM25 candidate is re-checked with a
219
+ * case-insensitive substring match so a coincidental capitalized word
220
+ * doesn't create a spurious edge (precision guard); edge weight scales with
221
+ * the number of shared entities but stays below the 0.7 semantic floor so
222
+ * semantic links still dominate the graph walk.
223
+ */
224
+ private async formEntityEdges(engram: Engram, existingIds: Set<string>): Promise<void> {
225
+ const entities = extractEntities(`${engram.concept} ${engram.content}`);
226
+ if (entities.length === 0) return;
227
+
228
+ // Gather candidates that match any of the engram's entities (BM25 OR).
229
+ const query = entities.slice(0, 6).join(' ');
230
+ const candidates = await this.store.searchBM25(engram.agentId, query, 20);
231
+ const lowerEntities = entities.map(e => e.toLowerCase());
232
+
233
+ // Rank candidates by how many of our entities they literally contain.
234
+ const scored: Array<{ id: string; engram: Engram; shared: number }> = [];
235
+ for (const cand of candidates) {
236
+ if (cand.id === engram.id) continue;
237
+ if (existingIds.has(cand.id)) continue;
238
+ if (cand.stage !== 'active') continue;
239
+ const candText = `${cand.concept} ${cand.content}`.toLowerCase();
240
+ let shared = 0;
241
+ for (const ent of lowerEntities) {
242
+ if (candText.includes(ent)) shared++;
243
+ }
244
+ if (shared > 0) scored.push({ id: cand.id, engram: cand, shared });
245
+ }
246
+
247
+ scored.sort((a, b) => b.shared - a.shared);
248
+ for (const { id, shared } of scored.slice(0, MAX_ENTITY_EDGES)) {
249
+ // Below the 0.7 semantic floor; more shared entities → stronger edge.
250
+ const weight = Math.min(0.6, 0.4 + 0.1 * shared);
251
+ await this.store.upsertAssociation(engram.id, id, weight, 'connection');
252
+ await this.store.upsertAssociation(id, engram.id, weight, 'connection');
253
+ existingIds.add(id);
160
254
  }
161
255
  }
162
256
  }
@@ -1,242 +1,242 @@
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
- }
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
+ }