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.
- package/README.md +73 -44
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +40 -1
- package/dist/api/routes.js.map +1 -1
- package/dist/cli.js +401 -1
- package/dist/cli.js.map +1 -1
- package/dist/coordination/mcp-tools.d.ts.map +1 -1
- package/dist/coordination/mcp-tools.js +10 -5
- package/dist/coordination/mcp-tools.js.map +1 -1
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +155 -16
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +35 -1
- package/dist/coordination/schema.js.map +1 -1
- package/dist/coordination/schemas.d.ts +21 -2
- package/dist/coordination/schemas.d.ts.map +1 -1
- package/dist/coordination/schemas.js +16 -0
- package/dist/coordination/schemas.js.map +1 -1
- package/dist/coordination/stale.d.ts +2 -0
- package/dist/coordination/stale.d.ts.map +1 -1
- package/dist/coordination/stale.js +5 -0
- package/dist/coordination/stale.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +119 -23
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +27 -6
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/index.js +81 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +61 -3
- package/dist/mcp.js.map +1 -1
- package/dist/storage/sqlite.d.ts +18 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +50 -5
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +24 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +3 -1
- package/src/api/routes.ts +50 -1
- package/src/cli.ts +454 -1
- package/src/coordination/mcp-tools.ts +10 -5
- package/src/coordination/routes.ts +209 -19
- package/src/coordination/schema.ts +27 -1
- package/src/coordination/schemas.ts +19 -0
- package/src/coordination/stale.ts +8 -0
- package/src/engine/activation.ts +125 -23
- package/src/engine/consolidation.ts +29 -6
- package/src/index.ts +74 -3
- package/src/mcp.ts +72 -3
- package/src/storage/sqlite.ts +54 -5
- package/src/types/engram.ts +28 -0
package/src/engine/activation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
218
|
-
|
|
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:
|
|
225
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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 = (
|
|
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 >
|
|
418
|
+
if (z > adaptive.zScoreGate) vm = Math.min(1, (z - adaptive.zScoreGate) / 2.0);
|
|
320
419
|
}
|
|
321
|
-
const tm =
|
|
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 = (
|
|
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,
|
|
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;
|
|
561
|
-
const BEAM_WIDTH =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
409
|
-
pruned.add(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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); });
|