agent-working-memory 0.5.6 → 0.6.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 +73 -44
  2. package/dist/api/routes.d.ts.map +1 -1
  3. package/dist/api/routes.js +40 -1
  4. package/dist/api/routes.js.map +1 -1
  5. package/dist/cli.js +401 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/coordination/mcp-tools.d.ts.map +1 -1
  8. package/dist/coordination/mcp-tools.js +10 -5
  9. package/dist/coordination/mcp-tools.js.map +1 -1
  10. package/dist/coordination/routes.d.ts.map +1 -1
  11. package/dist/coordination/routes.js +155 -16
  12. package/dist/coordination/routes.js.map +1 -1
  13. package/dist/coordination/schema.d.ts.map +1 -1
  14. package/dist/coordination/schema.js +35 -1
  15. package/dist/coordination/schema.js.map +1 -1
  16. package/dist/coordination/schemas.d.ts +21 -2
  17. package/dist/coordination/schemas.d.ts.map +1 -1
  18. package/dist/coordination/schemas.js +16 -0
  19. package/dist/coordination/schemas.js.map +1 -1
  20. package/dist/coordination/stale.d.ts +2 -0
  21. package/dist/coordination/stale.d.ts.map +1 -1
  22. package/dist/coordination/stale.js +5 -0
  23. package/dist/coordination/stale.js.map +1 -1
  24. package/dist/engine/activation.d.ts.map +1 -1
  25. package/dist/engine/activation.js +119 -23
  26. package/dist/engine/activation.js.map +1 -1
  27. package/dist/engine/consolidation.d.ts.map +1 -1
  28. package/dist/engine/consolidation.js +27 -6
  29. package/dist/engine/consolidation.js.map +1 -1
  30. package/dist/index.js +81 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp.js +61 -3
  33. package/dist/mcp.js.map +1 -1
  34. package/dist/storage/sqlite.d.ts +18 -0
  35. package/dist/storage/sqlite.d.ts.map +1 -1
  36. package/dist/storage/sqlite.js +50 -5
  37. package/dist/storage/sqlite.js.map +1 -1
  38. package/dist/types/engram.d.ts +24 -0
  39. package/dist/types/engram.d.ts.map +1 -1
  40. package/dist/types/engram.js.map +1 -1
  41. package/package.json +3 -1
  42. package/src/api/routes.ts +50 -1
  43. package/src/cli.ts +454 -1
  44. package/src/coordination/mcp-tools.ts +10 -5
  45. package/src/coordination/routes.ts +209 -19
  46. package/src/coordination/schema.ts +27 -1
  47. package/src/coordination/schemas.ts +19 -0
  48. package/src/coordination/stale.ts +8 -0
  49. package/src/engine/activation.ts +125 -23
  50. package/src/engine/consolidation.ts +29 -6
  51. package/src/index.ts +74 -3
  52. package/src/mcp.ts +72 -3
  53. package/src/storage/sqlite.ts +54 -5
  54. package/src/types/engram.ts +28 -0
@@ -26,10 +26,104 @@ import { embed, cosineSimilarity } from '../core/embeddings.js';
26
26
  import { rerank } from '../core/reranker.js';
27
27
  import { expandQuery } from '../core/query-expander.js';
28
28
  import type {
29
- Engram, ActivationResult, ActivationQuery, Association, PhaseScores,
29
+ Engram, ActivationResult, ActivationQuery, Association, PhaseScores, QueryMode,
30
30
  } from '../types/index.js';
31
31
  import type { EngramStore } from '../storage/sqlite.js';
32
32
 
33
+ // ─── Query-adaptive pipeline parameters ───────────────────────────
34
+
35
+ interface AdaptiveParams {
36
+ mode: 'targeted' | 'exploratory' | 'balanced';
37
+ textWeight: number; // Weight for text match in composite (default 0.6)
38
+ temporalWeight: number; // Weight for temporal signals (default 0.4)
39
+ decayExponentBase: number;// Base ACT-R decay exponent (default 0.5)
40
+ zScoreGate: number; // Z-score threshold for vector match (default 0.5)
41
+ beamWidth: number; // Graph walk beam width (default 15)
42
+ hopPenalty: number; // Graph walk hop penalty (default 0.3)
43
+ }
44
+
45
+ const ADAPTIVE_PRESETS: Record<'targeted' | 'exploratory' | 'balanced', AdaptiveParams> = {
46
+ targeted: {
47
+ mode: 'targeted',
48
+ textWeight: 0.75, // Heavy BM25/keyword emphasis
49
+ temporalWeight: 0.25,
50
+ decayExponentBase: 0.6, // Stronger decay — recent exact matches matter more
51
+ zScoreGate: 0.8, // Strict vector gate — only strong semantic matches
52
+ beamWidth: 3, // Narrow beam — don't wander
53
+ hopPenalty: 0.2, // Steeper hop penalty
54
+ },
55
+ exploratory: {
56
+ mode: 'exploratory',
57
+ textWeight: 0.4, // Lower BM25 weight
58
+ temporalWeight: 0.6, // Lean on temporal/associative signals
59
+ decayExponentBase: 0.3, // Weaker decay — surface older memories
60
+ zScoreGate: 0.3, // Relaxed vector gate — cast wider net
61
+ beamWidth: 20, // Wide beam — explore associations
62
+ hopPenalty: 0.4, // Gentler hop penalty
63
+ },
64
+ balanced: {
65
+ mode: 'balanced',
66
+ textWeight: 0.6,
67
+ temporalWeight: 0.4,
68
+ decayExponentBase: 0.5,
69
+ zScoreGate: 0.5,
70
+ beamWidth: 15,
71
+ hopPenalty: 0.3,
72
+ },
73
+ };
74
+
75
+ /**
76
+ * Classify a query as targeted, exploratory, or balanced.
77
+ *
78
+ * Targeted signals: identifiers (PROJ-123, camelCase, snake_case, UUIDs),
79
+ * short queries (< 8 words), quoted strings, file paths.
80
+ *
81
+ * Exploratory signals: question words, long queries (> 15 words),
82
+ * vague modifiers ("general", "overview", "about", "related to").
83
+ */
84
+ function classifyQuery(context: string): 'targeted' | 'exploratory' | 'balanced' {
85
+ const words = context.split(/\s+/).filter(w => w.length > 0);
86
+ const wordCount = words.length;
87
+ const lower = context.toLowerCase();
88
+
89
+ let targetedScore = 0;
90
+ let exploratoryScore = 0;
91
+
92
+ // Identifier patterns
93
+ if (/[A-Z]+-\d+/.test(context)) targetedScore += 2; // PROJ-123
94
+ if (/[a-z][A-Z]/.test(context)) targetedScore += 1; // camelCase
95
+ if (/\w+_\w+/.test(context)) targetedScore += 1; // snake_case
96
+ if (/[0-9a-f]{8}-[0-9a-f]{4}/.test(lower)) targetedScore += 2; // UUID fragment
97
+ if (/["']/.test(context)) targetedScore += 1; // Quoted strings
98
+ if (/[\/\\]/.test(context)) targetedScore += 1; // File paths
99
+ if (/\.\w{1,4}$/.test(context.trim())) targetedScore += 1; // File extensions
100
+
101
+ // Short queries are usually targeted
102
+ if (wordCount <= 5) targetedScore += 2;
103
+ else if (wordCount <= 8) targetedScore += 1;
104
+
105
+ // Question words / exploratory modifiers
106
+ if (/^(what|how|why|when|where|who|which|can|does|is|are)\b/i.test(context)) exploratoryScore += 1;
107
+ if (/\b(overview|general|about|related|similar|like|broad|concept|idea|approach|strategy)\b/i.test(lower)) exploratoryScore += 1;
108
+ if (/\b(any|all|everything|anything)\b/i.test(lower)) exploratoryScore += 1;
109
+
110
+ // Long queries are usually exploratory
111
+ if (wordCount > 15) exploratoryScore += 2;
112
+ else if (wordCount > 10) exploratoryScore += 1;
113
+
114
+ const diff = targetedScore - exploratoryScore;
115
+ if (diff >= 2) return 'targeted';
116
+ if (diff <= -2) return 'exploratory';
117
+ return 'balanced';
118
+ }
119
+
120
+ function resolveAdaptiveParams(query: ActivationQuery): AdaptiveParams {
121
+ const mode = query.mode ?? 'auto';
122
+ if (mode !== 'auto') return ADAPTIVE_PRESETS[mode];
123
+ const classified = classifyQuery(query.context);
124
+ return ADAPTIVE_PRESETS[classified];
125
+ }
126
+
33
127
  /**
34
128
  * Common English stopwords — filtered from similarity calculations.
35
129
  * These words carry no semantic signal for memory retrieval.
@@ -85,6 +179,7 @@ export class ActivationEngine {
85
179
  const useReranker = query.useReranker ?? true;
86
180
  const useExpansion = query.useExpansion ?? true;
87
181
  const abstentionThreshold = query.abstentionThreshold ?? 0;
182
+ const adaptive = resolveAdaptiveParams(query);
88
183
 
89
184
  // Phase -1: Coref expansion — if query has pronouns, append recent entity names
90
185
  // Helps conversational recall where "she/he/they/it" refers to a named entity.
@@ -155,7 +250,12 @@ export class ActivationEngine {
155
250
  const candidateMap = new Map<string, Engram>();
156
251
  for (const r of bm25Ranked) candidateMap.set(r.engram.id, r.engram);
157
252
  for (const e of allActive) candidateMap.set(e.id, e);
158
- const candidates = Array.from(candidateMap.values());
253
+ let candidates = Array.from(candidateMap.values());
254
+
255
+ // Filter by memory type if specified
256
+ if (query.memoryType) {
257
+ candidates = candidates.filter(e => e.memoryType === query.memoryType);
258
+ }
159
259
 
160
260
  if (candidates.length === 0) return [];
161
261
 
@@ -208,30 +308,31 @@ export class ActivationEngine {
208
308
 
209
309
  // --- Vector similarity (semantic signal) ---
210
310
  // Two-stage: absolute floor prevents noise, then z-score ranks within matches.
211
- // Stage 1: Raw cosine must exceed mean + 1 stddev (absolute relevance gate)
212
- // Stage 2: Z-score maps relative position to 0-1 for ranking quality
311
+ // z-gate adapts to query mode: targeted uses strict gate (0.8), exploratory relaxes (0.3).
213
312
  let vectorMatch = 0;
214
313
  const rawSim = rawCosineSims.get(engram.id);
215
314
  if (rawSim !== undefined) {
216
315
  const zScore = (rawSim - simMean) / simStdDev;
217
- // Gate: must be at least 1 stddev above mean to be considered a match
218
- if (zScore > 1.0) {
219
- // Map z=1..3 → 0..1 linearly
220
- vectorMatch = Math.min(1, (zScore - 1.0) / 2.0);
316
+ if (zScore > adaptive.zScoreGate) {
317
+ vectorMatch = Math.min(1, (zScore - adaptive.zScoreGate) / 2.0);
221
318
  }
222
319
  }
223
320
 
224
- // Combined text match: best of keyword and vector signals
225
- const textMatch = Math.max(keywordMatch, vectorMatch);
321
+ // Combined text match: weighted blend of keyword and vector signals.
322
+ // BM25 is better at lexical discrimination; vector is better at semantic matching.
323
+ // When both are non-zero, blend favors the stronger signal with a boost.
324
+ const textMatch = keywordMatch > 0 && vectorMatch > 0
325
+ ? 0.5 * Math.max(keywordMatch, vectorMatch) + 0.3 * Math.min(keywordMatch, vectorMatch) + 0.2 * (keywordMatch * vectorMatch)
326
+ : Math.max(keywordMatch, vectorMatch);
226
327
 
227
328
  // --- Temporal signals ---
228
329
 
229
330
  // ACT-R decay — confidence + replay modulated (synaptic tagging)
230
331
  // High-confidence memories decay slower. Heavily-accessed memories also resist decay.
231
- // Default exponent: 0.5. High confidence (0.8+): 0.3. High access (10+): further -0.05.
332
+ // Base exponent adapts to query mode: targeted (0.6) decays harder, exploratory (0.3) preserves older memories.
232
333
  const confMod = 0.2 * Math.max(0, (engram.confidence - 0.5) / 0.5);
233
334
  const replayMod = Math.min(0.1, 0.05 * Math.log1p(engram.accessCount));
234
- const decayExponent = Math.max(0.2, 0.5 - confMod - replayMod);
335
+ const decayExponent = Math.max(0.2, adaptive.decayExponentBase - confMod - replayMod);
235
336
  const decayScore = baseLevelActivation(engram.accessCount, ageDays, decayExponent);
236
337
 
237
338
  // Hebbian boost from associations — capped to prevent popular memories
@@ -261,12 +362,10 @@ export class ActivationEngine {
261
362
  : 0;
262
363
 
263
364
  // --- Composite score: relevance-gated additive ---
264
- // Text relevance must be present for temporal signals to contribute.
265
- // Without text relevance, a memory shouldn't activate regardless of recency.
266
- // Temporal contribution scales with text relevance (weak match = weak temporal boost).
365
+ // Text/temporal weights adapt to query mode: targeted (0.75/0.25), exploratory (0.4/0.6).
267
366
  const temporalNorm = Math.min(softplus(decayScore + hebbianBoost), 3.0) / 3.0;
268
367
  const relevanceGate = textMatch > 0.1 ? textMatch : 0.0; // Proportional gate
269
- const composite = (0.6 * textMatch + 0.4 * temporalNorm * relevanceGate + centralityBoost * relevanceGate + feedbackBonus * relevanceGate) * confidenceGate;
368
+ const composite = (adaptive.textWeight * textMatch + adaptive.temporalWeight * temporalNorm * relevanceGate + centralityBoost * relevanceGate + feedbackBonus * relevanceGate) * confidenceGate;
270
369
 
271
370
  const phaseScores: PhaseScores = {
272
371
  textMatch,
@@ -316,14 +415,16 @@ export class ActivationEngine {
316
415
  const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
317
416
  if (rs) {
318
417
  const z = (rs - simMean) / simStdDev;
319
- if (z > 1.0) vm = Math.min(1, (z - 1.0) / 2.0);
418
+ if (z > adaptive.zScoreGate) vm = Math.min(1, (z - adaptive.zScoreGate) / 2.0);
320
419
  }
321
- const tm = Math.max(km, vm);
420
+ const tm = km > 0 && vm > 0
421
+ ? 0.5 * Math.max(km, vm) + 0.3 * Math.min(km, vm) + 0.2 * (km * vm)
422
+ : Math.max(km, vm);
322
423
  const ds = baseLevelActivation(engram.accessCount, ageDays);
323
424
  const rh = associations.length > 0 ? Math.min(associations.reduce((s, a) => s + a.weight, 0) / associations.length, 0.5) : 0;
324
425
  const tn = Math.min(softplus(ds + rh), 3.0) / 3.0;
325
426
  const rg = tm > 0.1 ? tm : 0.0;
326
- const comp = (0.6 * tm + 0.4 * tn * rg) * engram.confidence;
427
+ const comp = (adaptive.textWeight * tm + adaptive.temporalWeight * tn * rg) * engram.confidence;
327
428
  scored.push({ engram, score: comp, phaseScores: { textMatch: tm, vectorMatch: vm, decayScore: ds, hebbianBoost: rh, graphBoost: 0, confidenceGate: engram.confidence, composite: comp, rerankerScore: 0 }, associations });
328
429
  }
329
430
  }
@@ -405,7 +506,7 @@ export class ActivationEngine {
405
506
  // Only walk from engrams that had text relevance (composite > 0 pre-walk)
406
507
  const sorted = scored.sort((a, b) => b.score - a.score);
407
508
  const topN = sorted.slice(0, limit * 3);
408
- this.graphWalk(topN, 2, 0.3);
509
+ this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
409
510
 
410
511
  // Phase 6: Initial filter and sort for re-ranking pool
411
512
  const pool = topN
@@ -554,11 +655,12 @@ export class ActivationEngine {
554
655
  private graphWalk(
555
656
  scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
556
657
  maxDepth: number,
557
- hopPenalty: number
658
+ hopPenalty: number,
659
+ beamWidth: number = 15
558
660
  ): void {
559
661
  const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
560
- const MAX_TOTAL_BOOST = 0.25; // Slightly higher cap for beam search (deeper paths earn it)
561
- const BEAM_WIDTH = 15;
662
+ const MAX_TOTAL_BOOST = 0.25;
663
+ const BEAM_WIDTH = beamWidth;
562
664
 
563
665
  // Seed the beam with high-scoring, text-relevant items
564
666
  const beam = scored
@@ -68,11 +68,15 @@ const FORGET_ARCHIVE_DAYS = 30;
68
68
  /** Age at which archived, never-retrieved, unconnected memories get deleted (days) */
69
69
  const FORGET_DELETE_DAYS = 90;
70
70
 
71
- /** Cosine similarity above which two low-confidence memories are considered redundant */
72
- const REDUNDANCY_THRESHOLD = 0.85;
71
+ /** Cosine similarity above which two low-confidence memories are considered redundant.
72
+ * MiniLM-L6 paraphrases typically score 0.75-0.88 cosine; 0.85 misses most of them.
73
+ * 0.75 catches paraphrases while keeping precision above 0.60 for unrelated facts. */
74
+ const REDUNDANCY_THRESHOLD = 0.75;
73
75
 
74
- /** Max redundant memories to prune per cycle (gradual, not sudden) */
75
- const MAX_REDUNDANCY_PRUNE_PER_CYCLE = 10;
76
+ /** Max redundant memories to prune per cycle (gradual, not sudden).
77
+ * Raised from 10 to 25 — the eval harness runs multiple cycles anyway,
78
+ * but faster convergence reduces consolidation time for larger pools. */
79
+ const MAX_REDUNDANCY_PRUNE_PER_CYCLE = 25;
76
80
 
77
81
  /** Max confidence drift per consolidation cycle (prevents runaway) */
78
82
  const CONFIDENCE_DRIFT_CAP = 0.03;
@@ -404,9 +408,28 @@ export class ConsolidationEngine {
404
408
 
405
409
  const sim = cosineSimilarity(sortedLow[i].embedding!, sortedLow[j].embedding!);
406
410
  if (sim >= REDUNDANCY_THRESHOLD) {
411
+ const survivorId = sortedLow[i].id;
412
+ const prunedId = sortedLow[j].id;
413
+
414
+ // Transfer associations from pruned memory to survivor
415
+ const prunedEdges = this.store.getAssociationsFor(prunedId);
416
+ for (const edge of prunedEdges) {
417
+ const peerId = edge.fromEngramId === prunedId ? edge.toEngramId : edge.fromEngramId;
418
+ if (peerId === survivorId) continue; // Skip self-loops
419
+ this.store.upsertAssociation(survivorId, peerId, edge.weight, edge.type, edge.confidence);
420
+ }
421
+
422
+ // Merge tags from pruned to survivor
423
+ const survivor = sortedLow[i];
424
+ const prunedMem = sortedLow[j];
425
+ const mergedTags = [...new Set([...survivor.tags, ...prunedMem.tags])];
426
+ if (mergedTags.length > survivor.tags.length) {
427
+ this.store.updateTags(survivorId, mergedTags);
428
+ }
429
+
407
430
  // Archive the lower-quality duplicate
408
- this.store.updateStage(sortedLow[j].id, 'archived');
409
- pruned.add(sortedLow[j].id);
431
+ this.store.updateStage(prunedId, 'archived');
432
+ pruned.add(prunedId);
410
433
  redundancyCount++;
411
434
  }
412
435
  }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Copyright 2026 Robert Winter / Complete Ideas
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { readFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
4
4
  import { resolve, dirname, basename } from 'node:path';
5
5
  import Fastify from 'fastify';
6
6
 
@@ -60,6 +60,36 @@ async function main() {
60
60
  // Storage
61
61
  const store = new EngramStore(DB_PATH);
62
62
 
63
+ // Integrity check
64
+ const integrity = store.integrityCheck();
65
+ if (!integrity.ok) {
66
+ console.error(`DB integrity check FAILED: ${integrity.result}`);
67
+ // Close corrupt DB, restore from backup, and exit for process manager to restart
68
+ store.close();
69
+ const dbDir = dirname(resolve(DB_PATH));
70
+ const backupDir = resolve(dbDir, 'backups');
71
+ if (existsSync(backupDir)) {
72
+ const backups = readdirSync(backupDir)
73
+ .filter(f => f.endsWith('.db'))
74
+ .sort()
75
+ .reverse();
76
+ if (backups.length > 0) {
77
+ const restorePath = resolve(backupDir, backups[0]);
78
+ console.error(`Attempting restore from: ${restorePath}`);
79
+ try {
80
+ copyFileSync(restorePath, resolve(DB_PATH));
81
+ console.error('Restore complete — exiting for restart with restored DB');
82
+ process.exit(1);
83
+ } catch (restoreErr) {
84
+ console.error(`Restore failed: ${(restoreErr as Error).message}`);
85
+ }
86
+ }
87
+ }
88
+ console.error('No backup available — continuing with potentially corrupt DB');
89
+ } else {
90
+ console.log(' DB integrity check: ok');
91
+ }
92
+
63
93
  // Engines
64
94
  const activationEngine = new ActivationEngine(store);
65
95
  const connectionEngine = new ConnectionEngine(store, activationEngine);
@@ -97,10 +127,13 @@ async function main() {
97
127
  if (isCoordinationEnabled()) {
98
128
  initCoordination(app, store.getDb());
99
129
  // Prune stale heartbeat events every 30s (keeps assignment/command events permanently)
100
- const { pruneOldHeartbeats } = await import('./coordination/stale.js');
130
+ // Purge dead agents older than 24h every 30s to prevent table bloat
131
+ const { pruneOldHeartbeats, purgeDeadAgents } = await import('./coordination/stale.js');
101
132
  heartbeatPruneTimer = setInterval(() => {
102
133
  const pruned = pruneOldHeartbeats(store.getDb());
103
134
  if (pruned > 0) console.log(`[coordination] pruned ${pruned} old heartbeat event(s)`);
135
+ const purged = purgeDeadAgents(store.getDb());
136
+ if (purged > 0) console.log(`[coordination] purged ${purged} dead agent(s) older than 24h`);
104
137
  }, 30_000);
105
138
  } else {
106
139
  console.log(' Coordination module disabled (set AWM_COORDINATION=true to enable)');
@@ -110,6 +143,42 @@ async function main() {
110
143
  stagingBuffer.start(DEFAULT_AGENT_CONFIG.stagingTtlMs);
111
144
  consolidationScheduler.start();
112
145
 
146
+ // Periodic hot backup every 10 minutes (keep last 6 = 1hr coverage)
147
+ const dbDir = dirname(resolve(DB_PATH));
148
+ const backupDir = resolve(dbDir, 'backups');
149
+ mkdirSync(backupDir, { recursive: true });
150
+
151
+ // Cleanup old backups on startup (older than 2 hours)
152
+ try {
153
+ const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
154
+ const now = Date.now();
155
+ for (const f of readdirSync(backupDir).filter(f => f.endsWith('.db'))) {
156
+ const match = f.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
157
+ if (match) {
158
+ const fileDate = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`);
159
+ if (now - fileDate.getTime() > TWO_HOURS_MS) {
160
+ unlinkSync(resolve(backupDir, f));
161
+ }
162
+ }
163
+ }
164
+ } catch { /* cleanup is non-fatal */ }
165
+
166
+ const backupTimer = setInterval(() => {
167
+ try {
168
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
169
+ const backupPath = resolve(backupDir, `${basename(DB_PATH, '.db')}-${ts}.db`);
170
+ store.backup(backupPath);
171
+ // Prune: keep only last 6 backups
172
+ const backups = readdirSync(backupDir).filter(f => f.endsWith('.db')).sort();
173
+ while (backups.length > 6) {
174
+ const old = backups.shift()!;
175
+ try { unlinkSync(resolve(backupDir, old)); } catch { /* non-fatal */ }
176
+ }
177
+ } catch (err) {
178
+ console.warn(`[backup] failed: ${(err as Error).message}`);
179
+ }
180
+ }, 10 * 60_000); // 10 minutes
181
+
113
182
  // Pre-load ML models (downloads on first run: embeddings ~22MB, reranker ~22MB, expander ~80MB)
114
183
  getEmbedder().catch(err => console.warn('Embedding model unavailable:', err.message));
115
184
  getReranker().catch(err => console.warn('Reranker model unavailable:', err.message));
@@ -117,13 +186,15 @@ async function main() {
117
186
 
118
187
  // Start server
119
188
  await app.listen({ port: PORT, host: '0.0.0.0' });
120
- console.log(`AgentWorkingMemory v0.5.5 listening on port ${PORT}`);
189
+ console.log(`AgentWorkingMemory v0.6.0 listening on port ${PORT}`);
121
190
 
122
191
  // Graceful shutdown
123
192
  const shutdown = () => {
193
+ clearInterval(backupTimer);
124
194
  if (heartbeatPruneTimer) clearInterval(heartbeatPruneTimer);
125
195
  consolidationScheduler.stop();
126
196
  stagingBuffer.stop();
197
+ try { store.walCheckpoint(); } catch { /* non-fatal */ }
127
198
  store.close();
128
199
  process.exit(0);
129
200
  };
package/src/mcp.ts CHANGED
@@ -71,7 +71,7 @@ const INCOGNITO = process.env.AWM_INCOGNITO === '1' || process.env.AWM_INCOGNITO
71
71
 
72
72
  if (INCOGNITO) {
73
73
  console.error('AWM: incognito mode — all memory tools disabled, nothing will be recorded');
74
- const server = new McpServer({ name: 'agent-working-memory', version: '0.5.5' });
74
+ const server = new McpServer({ name: 'agent-working-memory', version: '0.6.0' });
75
75
  const transport = new StdioServerTransport();
76
76
  server.connect(transport).catch(err => {
77
77
  console.error('MCP server failed:', err);
@@ -103,11 +103,33 @@ const consolidationScheduler = new ConsolidationScheduler(store, consolidationEn
103
103
  stagingBuffer.start(DEFAULT_AGENT_CONFIG.stagingTtlMs);
104
104
  consolidationScheduler.start();
105
105
 
106
+ // Coordination DB handle — set when AWM_COORDINATION=true, used by memory_write for decision propagation
107
+ let coordDb: import('better-sqlite3').Database | null = null;
108
+
106
109
  const server = new McpServer({
107
110
  name: 'agent-working-memory',
108
- version: '0.5.5',
111
+ version: '0.6.0',
109
112
  });
110
113
 
114
+ // --- Auto-classification for memory types ---
115
+
116
+ function classifyMemoryType(content: string): 'episodic' | 'semantic' | 'procedural' | 'unclassified' {
117
+ const lower = content.toLowerCase();
118
+ // Procedural: how-to, steps, numbered lists
119
+ if (/\bhow to\b|\bsteps?:/i.test(content) || /^\s*\d+[\.\)]\s/m.test(content) || /\bthen run\b|\bfirst,?\s/i.test(content)) {
120
+ return 'procedural';
121
+ }
122
+ // Episodic: past tense events, incidents, specific time references
123
+ if (/\b(discovered|debugged|fixed|encountered|happened|resolved|found that|we did|i did|yesterday|last week|today)\b/i.test(content)) {
124
+ return 'episodic';
125
+ }
126
+ // Semantic: facts, decisions, rules, patterns
127
+ if (/\b(is|are|should|always|never|must|uses?|requires?|means|pattern|decision|rule|convention)\b/i.test(content) && content.length < 500) {
128
+ return 'semantic';
129
+ }
130
+ return 'unclassified';
131
+ }
132
+
111
133
  // --- Tools ---
112
134
 
113
135
  server.tool(
@@ -139,6 +161,8 @@ The concept should be a short label (3-8 words). The content should be the full
139
161
  .describe('How much effort to resolve? 0=trivial, 1=significant debugging'),
140
162
  memory_class: z.enum(['canonical', 'working', 'ephemeral']).optional().default('working')
141
163
  .describe('Memory class: canonical (source-of-truth, never stages), working (default), ephemeral (temporary, decays faster)'),
164
+ memory_type: z.enum(['episodic', 'semantic', 'procedural', 'unclassified']).optional()
165
+ .describe('Memory type: episodic (events/incidents), semantic (facts/decisions), procedural (how-to/steps). Auto-classified if omitted.'),
142
166
  supersedes: z.string().optional()
143
167
  .describe('ID of an older memory this one replaces. The old memory is down-ranked, not deleted.'),
144
168
  },
@@ -206,6 +230,8 @@ The concept should be a short label (3-8 words). The content should be the full
206
230
  ? 0.40
207
231
  : CONFIDENCE_PRIORS[params.event_type ?? 'observation'] ?? 0.45;
208
232
 
233
+ const memoryType = params.memory_type ?? classifyMemoryType(params.content);
234
+
209
235
  const engram = store.createEngram({
210
236
  agentId: AGENT_ID,
211
237
  concept: params.concept,
@@ -217,6 +243,7 @@ The concept should be a short label (3-8 words). The content should be the full
217
243
  reasonCodes: salience.reasonCodes,
218
244
  ttl: salience.disposition === 'staging' ? DEFAULT_AGENT_CONFIG.stagingTtlMs : undefined,
219
245
  memoryClass: params.memory_class,
246
+ memoryType,
220
247
  supersedes: params.supersedes,
221
248
  });
222
249
 
@@ -244,13 +271,28 @@ The concept should be a short label (3-8 words). The content should be the full
244
271
  // Auto-checkpoint: track write
245
272
  try { store.updateAutoCheckpointWrite(AGENT_ID, engram.id); } catch { /* non-fatal */ }
246
273
 
274
+ // Decision propagation: when decision_made=true and coordination is enabled,
275
+ // broadcast to coord_decisions so other agents can discover it
276
+ if (params.decision_made && coordDb) {
277
+ try {
278
+ const agent = coordDb.prepare(
279
+ `SELECT id, current_task FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
280
+ ).get(AGENT_ID) as { id: string; current_task: string | null } | undefined;
281
+ if (agent) {
282
+ coordDb.prepare(
283
+ `INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`
284
+ ).run(agent.id, agent.current_task, params.tags ? JSON.stringify(params.tags) : null, params.concept);
285
+ }
286
+ } catch { /* decision propagation is non-fatal */ }
287
+ }
288
+
247
289
  const logDisposition = isLowSalience ? 'low-salience' : salience.disposition;
248
290
  log(AGENT_ID, `write:${logDisposition}`, `"${params.concept}" salience=${salience.score.toFixed(2)} novelty=${novelty.toFixed(1)} id=${engram.id}`);
249
291
 
250
292
  return {
251
293
  content: [{
252
294
  type: 'text' as const,
253
- text: `Stored (${salience.disposition}) "${params.concept}" [${salience.score.toFixed(2)}]`,
295
+ text: `Stored (${salience.disposition}) "${params.concept}" [${salience.score.toFixed(2)}]\nID: ${engram.id}`,
254
296
  }],
255
297
  };
256
298
  }
@@ -276,6 +318,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
276
318
  include_staging: z.boolean().optional().default(false).describe('Include weak/unconfirmed memories?'),
277
319
  use_reranker: z.boolean().optional().default(true).describe('Use cross-encoder re-ranking for better relevance (default true)'),
278
320
  use_expansion: z.boolean().optional().default(true).describe('Expand query with synonyms for better recall (default true)'),
321
+ memory_type: z.enum(['episodic', 'semantic', 'procedural']).optional().describe('Filter by memory type (omit to search all types)'),
279
322
  },
280
323
  async (params) => {
281
324
  const queryText = params.query ?? params.context;
@@ -295,6 +338,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
295
338
  includeStaging: params.include_staging,
296
339
  useReranker: params.use_reranker,
297
340
  useExpansion: params.use_expansion,
341
+ memoryType: params.memory_type,
298
342
  });
299
343
 
300
344
  // Auto-checkpoint: track recall
@@ -638,6 +682,29 @@ Use this at the start of every session or after compaction to pick up where you
638
682
  }
639
683
  }
640
684
 
685
+ // Peer decisions: show recent decisions from other agents (last 30 min)
686
+ if (coordDb) {
687
+ try {
688
+ const myAgent = coordDb.prepare(
689
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
690
+ ).get(AGENT_ID) as { id: string } | undefined;
691
+
692
+ const peerDecisions = coordDb.prepare(
693
+ `SELECT d.summary, a.name AS author_name, d.created_at
694
+ FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
695
+ WHERE d.author_id != ? AND d.created_at > datetime('now', '-30 minutes')
696
+ ORDER BY d.created_at DESC LIMIT 10`
697
+ ).all(myAgent?.id ?? '') as Array<{ summary: string; author_name: string; created_at: string }>;
698
+
699
+ if (peerDecisions.length > 0) {
700
+ parts.push(`\n**Peer decisions (last 30 min):**`);
701
+ for (const d of peerDecisions) {
702
+ parts.push(`- [${d.author_name}] ${d.summary} (${d.created_at})`);
703
+ }
704
+ }
705
+ } catch { /* peer decisions are non-fatal */ }
706
+ }
707
+
641
708
  return {
642
709
  content: [{
643
710
  type: 'text' as const,
@@ -997,6 +1064,7 @@ async function main() {
997
1064
  const { registerCoordinationTools } = await import('./coordination/mcp-tools.js');
998
1065
  initCoordinationTables(store.getDb());
999
1066
  registerCoordinationTools(server, store.getDb());
1067
+ coordDb = store.getDb();
1000
1068
  } else {
1001
1069
  console.error('AWM: coordination tools disabled (set AWM_COORDINATION=true to enable)');
1002
1070
  }
@@ -1010,6 +1078,7 @@ async function main() {
1010
1078
  sidecar.close();
1011
1079
  consolidationScheduler.stop();
1012
1080
  stagingBuffer.stop();
1081
+ try { store.walCheckpoint(); } catch { /* non-fatal */ }
1013
1082
  store.close();
1014
1083
  };
1015
1084
  process.on('SIGINT', () => { cleanup(); process.exit(0); });