agent-working-memory 0.5.2 → 0.5.4

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.
@@ -3,16 +3,20 @@
3
3
  /**
4
4
  * Embedding Engine — local vector embeddings via transformers.js
5
5
  *
6
- * Default: gte-small (384 dimensions, ~34MB int8, MTEB 61.4) for semantic similarity.
6
+ * Default: bge-small-en-v1.5 (384 dimensions, ~90MB, MTEB retrieval-optimized).
7
+ * Better short-text similarity than MiniLM for agent memory concepts.
7
8
  * Configurable via AWM_EMBED_MODEL env var.
8
9
  * Model is downloaded once on first use and cached locally.
9
10
  *
10
11
  * Singleton pattern — call getEmbedder() to get the shared instance.
12
+ *
13
+ * NOTE: Changing the model invalidates existing embeddings.
14
+ * Set AWM_EMBED_MODEL=Xenova/all-MiniLM-L6-v2 for backward compatibility.
11
15
  */
12
16
 
13
17
  import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
14
18
 
15
- const MODEL_ID = process.env.AWM_EMBED_MODEL ?? 'Xenova/all-MiniLM-L6-v2';
19
+ const MODEL_ID = process.env.AWM_EMBED_MODEL ?? 'Xenova/bge-small-en-v1.5';
16
20
  const DIMENSIONS = parseInt(process.env.AWM_EMBED_DIMS ?? '384', 10);
17
21
  const POOLING = (process.env.AWM_EMBED_POOLING ?? 'mean') as 'cls' | 'mean';
18
22
 
@@ -166,3 +166,54 @@ export function computeNovelty(store: EngramStore, agentId: string, concept: str
166
166
  return 0.8;
167
167
  }
168
168
  }
169
+
170
+ /**
171
+ * Result from novelty computation with match info for reinforcement.
172
+ */
173
+ export interface NoveltyResult {
174
+ novelty: number;
175
+ matchedEngramId: string | null;
176
+ matchScore: number;
177
+ }
178
+
179
+ /**
180
+ * Compute novelty score AND return the best matching engram (for reinforcement-on-duplicate).
181
+ * Uses BM25 (synchronous, fast) to find the closest existing memory.
182
+ * Optionally checks workspace-scoped memories too (cross-agent dedup).
183
+ */
184
+ export function computeNoveltyWithMatch(
185
+ store: EngramStore, agentId: string, concept: string, content: string,
186
+ workspace?: string | null
187
+ ): NoveltyResult {
188
+ try {
189
+ const contentStr = typeof content === 'string' ? content : '';
190
+ const conceptStr = typeof concept === 'string' ? concept : '';
191
+ const searchText = `${conceptStr} ${contentStr.slice(0, 100)}`;
192
+
193
+ // Agent-scoped search (limit:3 to avoid single shallow match suppressing novelty)
194
+ const results = store.searchBM25WithRank(agentId, searchText, 3);
195
+
196
+ // Workspace search — only if the store supports it (v0.5.4+)
197
+ let wsResults: { engram: { id: string }; bm25Score: number }[] = [];
198
+ if (workspace && typeof (store as any).searchBM25WithRankWorkspace === 'function') {
199
+ wsResults = (store as any).searchBM25WithRankWorkspace(agentId, searchText, 3, workspace);
200
+ }
201
+
202
+ const allResults = [...results, ...wsResults];
203
+ if (allResults.length === 0) return { novelty: 1.0, matchedEngramId: null, matchScore: 0 };
204
+
205
+ allResults.sort((a, b) => b.bm25Score - a.bm25Score);
206
+ const top = allResults[0];
207
+ const topScore = top.bm25Score;
208
+
209
+ // Concept penalty for exact duplicates
210
+ const conceptLower = conceptStr.toLowerCase().trim();
211
+ const exactMatch = allResults.some(r => (r.engram as any)?.concept?.toLowerCase().trim() === conceptLower);
212
+ const conceptPenalty = exactMatch ? 0.4 : 0;
213
+
214
+ const novelty = Math.max(0.1, Math.min(0.95, 1.0 - topScore - conceptPenalty));
215
+ return { novelty, matchedEngramId: top.engram.id, matchScore: topScore };
216
+ } catch {
217
+ return { novelty: 0.8, matchedEngramId: null, matchScore: 0 };
218
+ }
219
+ }
@@ -86,8 +86,27 @@ export class ActivationEngine {
86
86
  const useExpansion = query.useExpansion ?? true;
87
87
  const abstentionThreshold = query.abstentionThreshold ?? 0;
88
88
 
89
+ // Phase -1: Coref expansion — if query has pronouns, append recent entity names
90
+ // Helps conversational recall where "she/he/they/it" refers to a named entity.
91
+ let queryContext = query.context;
92
+ const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
93
+ if (pronounPattern.test(queryContext)) {
94
+ // Pull recent entity names from the agent's most-accessed memories
95
+ try {
96
+ const recentEntities = this.store.getEngramsByAgent(query.agentId, 'active')
97
+ .sort((a, b) => b.accessCount - a.accessCount)
98
+ .slice(0, 10)
99
+ .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
100
+ .filter((v, i, a) => a.indexOf(v) === i)
101
+ .slice(0, 5);
102
+ if (recentEntities.length > 0) {
103
+ queryContext = `${queryContext} ${recentEntities.join(' ')}`;
104
+ }
105
+ } catch { /* non-fatal */ }
106
+ }
107
+
89
108
  // Phase 0: Query expansion — add related terms to improve BM25 recall
90
- let searchContext = query.context;
109
+ let searchContext = queryContext;
91
110
  if (useExpansion) {
92
111
  try {
93
112
  searchContext = await expandQuery(query.context);
@@ -96,18 +115,35 @@ export class ActivationEngine {
96
115
  }
97
116
  }
98
117
 
99
- // Phase 1: Embed original query for vector similarity (original, not expanded)
118
+ // Phase 1: Embed query for vector similarity (uses coref-expanded context)
100
119
  let queryEmbedding: number[] | null = null;
101
120
  try {
102
- queryEmbedding = await embed(query.context);
121
+ queryEmbedding = await embed(queryContext);
103
122
  } catch {
104
123
  // Embedding unavailable — fall back to text-only matching
105
124
  }
106
125
 
107
- // Phase 2: Parallel retrieval — BM25 with rank scores + all active engrams
108
- // Use expanded query for BM25 (more terms = better keyword recall)
109
- const bm25Ranked = this.store.searchBM25WithRank(query.agentId, searchContext, limit * 3);
110
- const bm25ScoreMap = new Map(bm25Ranked.map(r => [r.engram.id, r.bm25Score]));
126
+ // Phase 2: Parallel retrieval — dual BM25 + all active engrams
127
+ // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
128
+ const keywordQuery = Array.from(tokenize(query.context)).join(' ');
129
+ const bm25Keyword = keywordQuery.length > 2
130
+ ? this.store.searchBM25WithRank(query.agentId, keywordQuery, limit * 3)
131
+ : [];
132
+ const bm25Expanded = this.store.searchBM25WithRank(query.agentId, searchContext, limit * 3);
133
+
134
+ // Merge: take the best BM25 score per engram from either pass
135
+ const bm25ScoreMap = new Map<string, number>();
136
+ const bm25EngramMap = new Map<string, any>();
137
+ for (const r of [...bm25Keyword, ...bm25Expanded]) {
138
+ const existing = bm25ScoreMap.get(r.engram.id) ?? 0;
139
+ if (r.bm25Score > existing) {
140
+ bm25ScoreMap.set(r.engram.id, r.bm25Score);
141
+ bm25EngramMap.set(r.engram.id, r.engram);
142
+ }
143
+ }
144
+ const bm25Ranked = Array.from(bm25EngramMap.entries()).map(([id, engram]) => ({
145
+ engram, bm25Score: bm25ScoreMap.get(id) ?? 0,
146
+ }));
111
147
 
112
148
  const allActive = this.store.getEngramsByAgent(
113
149
  query.agentId,
@@ -190,10 +226,12 @@ export class ActivationEngine {
190
226
 
191
227
  // --- Temporal signals ---
192
228
 
193
- // ACT-R decay — confidence-modulated
194
- // High-confidence memories (confirmed useful via feedback) decay slower.
195
- // Default exponent: 0.5. At confidence 0.8+: 0.3 (much slower decay).
196
- const decayExponent = 0.5 - 0.2 * Math.max(0, (engram.confidence - 0.5) / 0.5);
229
+ // ACT-R decay — confidence + replay modulated (synaptic tagging)
230
+ // 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.
232
+ const confMod = 0.2 * Math.max(0, (engram.confidence - 0.5) / 0.5);
233
+ const replayMod = Math.min(0.1, 0.05 * Math.log1p(engram.accessCount));
234
+ const decayExponent = Math.max(0.2, 0.5 - confMod - replayMod);
197
235
  const decayScore = baseLevelActivation(engram.accessCount, ageDays, decayExponent);
198
236
 
199
237
  // Hebbian boost from associations — capped to prevent popular memories
@@ -401,20 +439,52 @@ export class ActivationEngine {
401
439
  }
402
440
  }
403
441
 
404
- // Phase 8a: Semantic drift penalty if no candidate has meaningful vector match
405
- // (none exceeded 1 stddev above mean), the query is likely off-topic.
406
- if (queryEmbedding && rerankPool.length > 0) {
407
- const maxVectorSim = Math.max(...rerankPool.map(r => r.phaseScores.vectorMatch));
408
- if (maxVectorSim < 0.05) {
409
- // Query is semantically distant from everything — apply drift penalty
442
+ // Phase 8: Multi-channel OOD detection + agreement gate
443
+ // Requires at least 2 of 3 retrieval channels to agree the query is in-domain.
444
+ if (rerankPool.length >= 3) {
445
+ const topBM25 = Math.max(...rerankPool.map(r => bm25ScoreMap.get(r.engram.id) ?? 0));
446
+ const topVector = queryEmbedding
447
+ ? Math.max(...rerankPool.map(r => r.phaseScores.vectorMatch))
448
+ : 0;
449
+ const topReranker = Math.max(...rerankPool.map(r => r.phaseScores.rerankerScore));
450
+
451
+ const bm25Ok = topBM25 > 0.3;
452
+ const vectorOk = topVector > 0.05;
453
+ const rerankerOk = topReranker > 0.25;
454
+ const channelsAgreeing = (bm25Ok ? 1 : 0) + (vectorOk ? 1 : 0) + (rerankerOk ? 1 : 0);
455
+
456
+ const rerankerScores = rerankPool
457
+ .map(r => r.phaseScores.rerankerScore)
458
+ .sort((a, b) => b - a);
459
+ const margin = rerankerScores.length >= 2
460
+ ? rerankerScores[0] - rerankerScores[1]
461
+ : rerankerScores[0];
462
+
463
+ const maxRawCosine = queryEmbedding && simValues.length > 0
464
+ ? Math.max(...simValues)
465
+ : 1.0;
466
+
467
+ // Stricter gate when caller explicitly requests abstention (e.g., noise filter queries)
468
+ const requiredChannels = abstentionThreshold > 0 ? 3 : 2;
469
+
470
+ // Hard abstention: fewer than required channels agree AND semantic drift is high
471
+ if (channelsAgreeing < requiredChannels && maxRawCosine < (simMean + simStdDev * 1.5)) {
472
+ return [];
473
+ }
474
+
475
+ // Soft penalty: only 1 channel agrees or margin is thin
476
+ if (channelsAgreeing < 2 || margin < 0.05) {
477
+ // If caller explicitly requested abstention, honor it when agreement is weak
478
+ if (abstentionThreshold > 0) {
479
+ return [];
480
+ }
410
481
  for (const item of rerankPool) {
411
- item.score *= 0.5;
482
+ item.score *= 0.4;
412
483
  }
413
484
  }
414
485
  }
415
486
 
416
- // Phase 8b: Entropy gating if top-5 reranker scores are flat (low variance),
417
- // the reranker can't distinguish relevant from irrelevant. Abstain.
487
+ // Legacy abstention gate (when explicitly requested)
418
488
  if (abstentionThreshold > 0 && rerankPool.length >= 3) {
419
489
  const topRerankerScores = rerankPool
420
490
  .map(r => r.phaseScores.rerankerScore)
@@ -424,7 +494,6 @@ export class ActivationEngine {
424
494
  const meanScore = topRerankerScores.reduce((s, v) => s + v, 0) / topRerankerScores.length;
425
495
  const variance = topRerankerScores.reduce((s, v) => s + (v - meanScore) ** 2, 0) / topRerankerScores.length;
426
496
 
427
- // Abstain if: top score below threshold OR scores are flat (low discrimination)
428
497
  if (maxScore < abstentionThreshold || (maxScore < 0.5 && variance < 0.01)) {
429
498
  return [];
430
499
  }
@@ -54,7 +54,7 @@ export class ConsolidationScheduler {
54
54
  this.running = true;
55
55
  try {
56
56
  console.log(`[scheduler] mini-consolidation for ${agentId}`);
57
- this.consolidationEngine.consolidate(agentId);
57
+ await this.consolidationEngine.consolidate(agentId);
58
58
  this.store.markConsolidation(agentId, true);
59
59
  } catch (err) {
60
60
  console.error(`[scheduler] mini-consolidation failed for ${agentId}:`, err);
@@ -109,11 +109,11 @@ export class ConsolidationScheduler {
109
109
  }
110
110
  }
111
111
 
112
- private runFullConsolidation(agentId: string, reason: string): void {
112
+ private async runFullConsolidation(agentId: string, reason: string): Promise<void> {
113
113
  this.running = true;
114
114
  try {
115
115
  console.log(`[scheduler] full consolidation for ${agentId} — trigger: ${reason}`);
116
- const result = this.consolidationEngine.consolidate(agentId);
116
+ const result = await this.consolidationEngine.consolidate(agentId);
117
117
  this.store.markConsolidation(agentId, false);
118
118
  console.log(`[scheduler] consolidation done: ${result.edgesStrengthened} strengthened, ${result.memoriesForgotten} forgotten`);
119
119
  } catch (err) {
@@ -25,9 +25,13 @@ import { strengthenAssociation, decayAssociation } from '../core/hebbian.js';
25
25
  import type { Engram } from '../types/index.js';
26
26
  import type { EngramStore } from '../storage/sqlite.js';
27
27
 
28
- /** Cosine similarity threshold for considering two memories related */
28
+ /** Cosine similarity for initial candidate detection (single-link entry gate) */
29
29
  const SIMILARITY_THRESHOLD = 0.65;
30
30
 
31
+ /** Minimum pairwise cosine for cluster diameter enforcement.
32
+ * Prevents chaining: a candidate must be this similar to ALL cluster members. */
33
+ const MIN_PAIRWISE_COS = 0.50;
34
+
31
35
  /** Lower threshold for cross-cluster bridge edges */
32
36
  const BRIDGE_THRESHOLD = 0.25;
33
37
 
@@ -70,6 +74,12 @@ const REDUNDANCY_THRESHOLD = 0.85;
70
74
  /** Max redundant memories to prune per cycle (gradual, not sudden) */
71
75
  const MAX_REDUNDANCY_PRUNE_PER_CYCLE = 10;
72
76
 
77
+ /** Max confidence drift per consolidation cycle (prevents runaway) */
78
+ const CONFIDENCE_DRIFT_CAP = 0.03;
79
+
80
+ /** Days without recall before confidence starts drifting down */
81
+ const CONFIDENCE_NEGLECT_DAYS = 30;
82
+
73
83
  export interface ConsolidationResult {
74
84
  clustersFound: number;
75
85
  edgesStrengthened: number;
@@ -81,6 +91,7 @@ export interface ConsolidationResult {
81
91
  memoriesForgotten: number;
82
92
  memoriesArchived: number;
83
93
  redundancyPruned: number;
94
+ confidenceAdjusted: number;
84
95
  stagingPromoted: number;
85
96
  stagingDiscarded: number;
86
97
  engramsProcessed: number;
@@ -102,9 +113,10 @@ export class ConsolidationEngine {
102
113
  * Phase 4: Decay — weaken unused edges, prune dead ones
103
114
  * Phase 5: Homeostasis — normalize outgoing edge weights per node
104
115
  * Phase 6: Forget — archive/delete memories never retrieved (age-gated)
116
+ * Phase 6.7: Confidence drift — adjust confidence based on structural signals
105
117
  * Phase 7: Sweep — check staging buffer for resonance
106
118
  */
107
- consolidate(agentId: string): ConsolidationResult {
119
+ async consolidate(agentId: string): Promise<ConsolidationResult> {
108
120
  const result: ConsolidationResult = {
109
121
  clustersFound: 0,
110
122
  edgesStrengthened: 0,
@@ -116,15 +128,29 @@ export class ConsolidationEngine {
116
128
  memoriesForgotten: 0,
117
129
  memoriesArchived: 0,
118
130
  redundancyPruned: 0,
131
+ confidenceAdjusted: 0,
119
132
  stagingPromoted: 0,
120
133
  stagingDiscarded: 0,
121
134
  engramsProcessed: 0,
122
135
  };
123
136
 
124
137
  // --- Phase 1: Replay ---
125
- // Get all active engrams with embeddings
126
- const engrams = this.store.getEngramsByAgent(agentId, 'active')
127
- .filter(e => e.embedding && e.embedding.length > 0);
138
+ // Get all active engrams, backfill missing embeddings
139
+ const allActive = this.store.getEngramsByAgent(agentId, 'active');
140
+ const needsEmbedding = allActive.filter(e => !e.embedding || e.embedding.length === 0);
141
+ if (needsEmbedding.length > 0) {
142
+ try {
143
+ const { embed } = await import('../core/embeddings.js');
144
+ for (const e of needsEmbedding) {
145
+ try {
146
+ const vec = await embed(`${e.concept} ${e.content}`);
147
+ this.store.updateEmbedding(e.id, vec);
148
+ e.embedding = vec;
149
+ } catch { /* non-fatal */ }
150
+ }
151
+ } catch { /* embeddings module unavailable */ }
152
+ }
153
+ const engrams = allActive.filter(e => e.embedding && e.embedding.length > 0);
128
154
 
129
155
  result.engramsProcessed = engrams.length;
130
156
  if (engrams.length < 2) return result;
@@ -169,30 +195,32 @@ export class ConsolidationEngine {
169
195
  }
170
196
  }
171
197
 
172
- // --- Phase 3: Cross-cluster bridge edges ---
173
- // For each pair of clusters, compute centroid similarity. If moderate
174
- // similarity exists but no direct edge, create a low-weight bridge.
175
- // This is what enables cross-topic retrieval to improve over time.
198
+ // --- Phase 3: Direct cross-cluster bridging ---
199
+ // Find the closest pair of memories between each cluster pair and bridge them.
176
200
  if (clusters.length >= 2) {
201
+ const MIN_BRIDGE_SIM = 0.15;
177
202
  let bridges = 0;
178
- const centroids = clusters.map(cluster => this.computeCentroid(cluster));
179
-
180
203
  for (let i = 0; i < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; i++) {
181
204
  for (let j = i + 1; j < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; j++) {
182
- const sim = cosineSimilarity(centroids[i], centroids[j]);
183
- if (sim < BRIDGE_THRESHOLD || sim >= SIMILARITY_THRESHOLD) continue;
184
-
185
- // Find the best representative from each cluster (highest accessCount)
186
- const repA = clusters[i].reduce((best, e) => e.accessCount > best.accessCount ? e : best);
187
- const repB = clusters[j].reduce((best, e) => e.accessCount > best.accessCount ? e : best);
188
-
189
- const existing = this.store.getAssociation(repA.id, repB.id);
190
- if (!existing) {
191
- // Bridge weight proportional to inter-cluster similarity
192
- const bridgeWeight = 0.15 + 0.15 * ((sim - BRIDGE_THRESHOLD) / (SIMILARITY_THRESHOLD - BRIDGE_THRESHOLD));
193
- this.store.upsertAssociation(repA.id, repB.id, bridgeWeight, 'bridge');
194
- bridges++;
195
- result.bridgesCreated++;
205
+ let bestSim = -1;
206
+ let bestA: Engram | null = null;
207
+ let bestB: Engram | null = null;
208
+ for (const a of clusters[i]) {
209
+ if (!a.embedding) continue;
210
+ for (const b of clusters[j]) {
211
+ if (!b.embedding) continue;
212
+ const s = cosineSimilarity(a.embedding, b.embedding);
213
+ if (s > bestSim) { bestSim = s; bestA = a; bestB = b; }
214
+ }
215
+ }
216
+ if (bestA && bestB && bestSim > MIN_BRIDGE_SIM) {
217
+ const existing = this.store.getAssociation(bestA.id, bestB.id);
218
+ if (!existing) {
219
+ this.store.upsertAssociation(bestA.id, bestB.id, bestSim, 'bridge');
220
+ this.store.upsertAssociation(bestB.id, bestA.id, bestSim, 'bridge');
221
+ bridges++;
222
+ result.bridgesCreated++;
223
+ }
196
224
  }
197
225
  }
198
226
  }
@@ -210,13 +238,20 @@ export class ConsolidationEngine {
210
238
  (Date.now() - assoc.lastActivated.getTime()) / (1000 * 60 * 60 * 24);
211
239
  if (daysSince < 0.5) continue; // Skip recently activated
212
240
 
213
- // Confidence-modulated half-life: higher confidence = slower decay (capped at 3x)
214
- // Base: 7 days. Conf 0.5 → 7 days. Conf 0.8 ~15 days. Conf 1.0 → 21 days (3x).
215
- // Cap prevents any edge from becoming immortal.
241
+ // Confidence + access-count modulated half-life (synaptic tagging for edges)
242
+ // Base: 7 days. High confidence (0.8+): up to 21 days.
243
+ // High access count: further extends half-life (log-scaled, capped at 2x boost).
216
244
  const fromConf = engramConfMap.get(assoc.fromEngramId) ?? 0.5;
217
245
  const toConf = engramConfMap.get(assoc.toEngramId) ?? 0.5;
218
246
  const maxConf = Math.max(fromConf, toConf);
219
- const halfLifeDays = Math.min(7 * (1 + 2 * Math.max(0, (maxConf - 0.5) / 0.5)), 21);
247
+ const fromEngram = engrams.find(e => e.id === assoc.fromEngramId);
248
+ const toEngram = engrams.find(e => e.id === assoc.toEngramId);
249
+ const maxAccess = Math.max(fromEngram?.accessCount ?? 0, toEngram?.accessCount ?? 0);
250
+ const accessBoost = Math.min(2.0, 1.0 + 0.5 * Math.log1p(maxAccess));
251
+ const halfLifeDays = Math.min(
252
+ 7 * (1 + 2 * Math.max(0, (maxConf - 0.5) / 0.5)) * accessBoost,
253
+ 42 // Hard cap: 6 weeks max
254
+ );
220
255
 
221
256
  const newWeight = decayAssociation(assoc.weight, daysSince, halfLifeDays);
222
257
  if (newWeight < PRUNE_THRESHOLD) {
@@ -378,6 +413,57 @@ export class ConsolidationEngine {
378
413
  }
379
414
  result.redundancyPruned = redundancyCount;
380
415
 
416
+ // --- Phase 6.7: Confidence drift ---
417
+ // Adjust confidence based on structural signals that emerge from the graph.
418
+ // This makes confidence evolve over time without explicit feedback calls.
419
+ //
420
+ // Three signals:
421
+ // 1. Well-clustered memories (appeared in 1+ clusters) get a small boost
422
+ // — they're integrated into the knowledge graph, likely valuable.
423
+ // 2. Isolated memories (0 edges after consolidation) get a small penalty
424
+ // — nothing connects to them, possibly noise.
425
+ // 3. Neglected memories (not recalled in 30+ days) drift toward 0.3
426
+ // — if the system never needs them, they're probably not important.
427
+ //
428
+ // All adjustments are capped at ±0.03 per cycle to prevent runaway.
429
+ // Confidence is floored at 0.15 (never reaches 0 — retraction handles that).
430
+ // Confidence is capped at 0.85 (only explicit feedback can push above).
431
+ const clusteredIds = new Set<string>();
432
+ for (const cluster of clusters) {
433
+ for (const e of cluster) clusteredIds.add(e.id);
434
+ }
435
+
436
+ for (const engram of engrams) {
437
+ let drift = 0;
438
+ const edgeCount = this.store.countAssociationsFor(engram.id);
439
+ const daysSinceAccess = (Date.now() - engram.lastAccessed.getTime()) / (1000 * 60 * 60 * 24);
440
+
441
+ // Signal 1: Cluster membership → small boost
442
+ if (clusteredIds.has(engram.id)) {
443
+ drift += 0.01;
444
+ }
445
+
446
+ // Signal 2: Zero edges → small penalty
447
+ if (edgeCount === 0) {
448
+ drift -= 0.02;
449
+ }
450
+
451
+ // Signal 3: Long neglect → drift toward 0.3
452
+ if (daysSinceAccess > CONFIDENCE_NEGLECT_DAYS && engram.confidence > 0.3) {
453
+ drift -= 0.01;
454
+ }
455
+
456
+ // Apply with cap
457
+ if (Math.abs(drift) > 0.001) {
458
+ drift = Math.max(-CONFIDENCE_DRIFT_CAP, Math.min(CONFIDENCE_DRIFT_CAP, drift));
459
+ const newConf = Math.max(0.15, Math.min(0.85, engram.confidence + drift));
460
+ if (Math.abs(newConf - engram.confidence) > 0.001) {
461
+ this.store.updateConfidence(engram.id, newConf);
462
+ result.confidenceAdjusted++;
463
+ }
464
+ }
465
+ }
466
+
381
467
  // --- Phase 7: Sweep staging ---
382
468
  const staging = this.store.getEngramsByAgent(agentId, 'staging')
383
469
  .filter(e => e.embedding && e.embedding.length > 0);
@@ -394,8 +480,9 @@ export class ConsolidationEngine {
394
480
  }
395
481
 
396
482
  if (maxSim >= 0.6) {
397
- // Resonates — promote to active
483
+ // Resonates — promote to active with low confidence (barely made it)
398
484
  this.store.updateStage(staged.id, 'active');
485
+ this.store.updateConfidence(staged.id, 0.40);
399
486
  result.stagingPromoted++;
400
487
  } else if (ageMs > 24 * 60 * 60 * 1000) {
401
488
  // Over 24h and no resonance — discard
@@ -413,34 +500,66 @@ export class ConsolidationEngine {
413
500
  * Greedy agglomerative — each memory belongs to at most one cluster.
414
501
  * Clusters of size 2+ are returned (pairs count — they link).
415
502
  */
503
+ /**
504
+ * Diameter-enforced greedy clustering.
505
+ * Single-link entry (cosine ≥ SIMILARITY_THRESHOLD to any member)
506
+ * + complete-link diameter (cosine ≥ MIN_PAIRWISE_COS to ALL members).
507
+ * Prevents chaining where physics→biophysics→cooking = 1 cluster.
508
+ */
416
509
  private findClusters(engrams: Engram[]): Engram[][] {
417
- const assigned = new Set<string>();
510
+ const n = engrams.length;
511
+ if (n < 2) return [];
512
+
513
+ // Precompute pairwise cosine matrix
514
+ const sim: number[][] = Array.from({ length: n }, () => Array(n).fill(0));
515
+ for (let i = 0; i < n; i++) {
516
+ sim[i][i] = 1;
517
+ for (let j = i + 1; j < n; j++) {
518
+ if (!engrams[i].embedding || !engrams[j].embedding) continue;
519
+ const c = cosineSimilarity(engrams[i].embedding!, engrams[j].embedding!);
520
+ sim[i][j] = c;
521
+ sim[j][i] = c;
522
+ }
523
+ }
524
+
525
+ const unassigned = new Set<number>(Array.from({ length: n }, (_, i) => i));
418
526
  const clusters: Engram[][] = [];
419
527
 
420
- // Seed clusters from most-accessed memories (strongest traces)
421
- const sorted = [...engrams].sort((a, b) => b.accessCount - a.accessCount);
528
+ const sortedIdxs = Array.from({ length: n }, (_, i) => i)
529
+ .sort((a, b) => engrams[b].accessCount - engrams[a].accessCount);
530
+
531
+ for (const seedIdx of sortedIdxs) {
532
+ if (!unassigned.has(seedIdx)) continue;
533
+ unassigned.delete(seedIdx);
422
534
 
423
- for (const seed of sorted) {
424
- if (assigned.has(seed.id)) continue;
535
+ const clusterIdxs: number[] = [seedIdx];
536
+ let added = true;
425
537
 
426
- const cluster: Engram[] = [seed];
427
- assigned.add(seed.id);
538
+ while (added) {
539
+ added = false;
540
+ for (const candIdx of Array.from(unassigned)) {
541
+ let links = false;
542
+ for (const m of clusterIdxs) {
543
+ if (sim[candIdx][m] >= SIMILARITY_THRESHOLD) { links = true; break; }
544
+ }
545
+ if (!links) continue;
428
546
 
429
- for (const candidate of sorted) {
430
- if (assigned.has(candidate.id)) continue;
431
- if (!seed.embedding || !candidate.embedding) continue;
547
+ let passesAll = true;
548
+ for (const m of clusterIdxs) {
549
+ if (sim[candIdx][m] < MIN_PAIRWISE_COS) { passesAll = false; break; }
550
+ }
551
+ if (!passesAll) continue;
432
552
 
433
- const sim = cosineSimilarity(seed.embedding, candidate.embedding);
434
- if (sim >= SIMILARITY_THRESHOLD) {
435
- cluster.push(candidate);
436
- assigned.add(candidate.id);
553
+ clusterIdxs.push(candIdx);
554
+ unassigned.delete(candIdx);
555
+ added = true;
437
556
  }
438
557
  }
439
558
 
440
- if (cluster.length >= 2) {
441
- clusters.push(cluster);
559
+ if (clusterIdxs.length >= 2) {
560
+ clusters.push(clusterIdxs.map(i => engrams[i]));
442
561
  } else {
443
- for (const e of cluster) assigned.delete(e.id);
562
+ unassigned.add(seedIdx);
444
563
  }
445
564
  }
446
565
 
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Copyright 2026 Robert Winter / Complete Ideas
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { readFileSync } from 'node:fs';
4
- import { resolve } from 'node:path';
3
+ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
4
+ import { resolve, dirname, basename } from 'node:path';
5
5
  import Fastify from 'fastify';
6
6
 
7
7
  // Load .env file if present (no external dependency)
@@ -32,12 +32,31 @@ import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
32
32
  import { getEmbedder } from './core/embeddings.js';
33
33
  import { getReranker } from './core/reranker.js';
34
34
  import { getExpander } from './core/query-expander.js';
35
+ import { initLogger } from './core/logger.js';
35
36
 
36
37
  const PORT = parseInt(process.env.AWM_PORT ?? '8400', 10);
37
38
  const DB_PATH = process.env.AWM_DB_PATH ?? 'memory.db';
38
39
  const API_KEY = process.env.AWM_API_KEY ?? null;
39
40
 
40
41
  async function main() {
42
+ // Auto-backup: copy DB to backups/ on startup (cheap insurance)
43
+ if (existsSync(DB_PATH)) {
44
+ const dbDir = dirname(resolve(DB_PATH));
45
+ const backupDir = resolve(dbDir, 'backups');
46
+ mkdirSync(backupDir, { recursive: true });
47
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
48
+ const backupPath = resolve(backupDir, `${basename(DB_PATH, '.db')}-${ts}.db`);
49
+ try {
50
+ copyFileSync(resolve(DB_PATH), backupPath);
51
+ console.log(`Backup: ${backupPath}`);
52
+ } catch (err) {
53
+ console.log(`Backup skipped: ${(err as Error).message}`);
54
+ }
55
+ }
56
+
57
+ // Logger — write activity to awm.log alongside the DB
58
+ initLogger(DB_PATH);
59
+
41
60
  // Storage
42
61
  const store = new EngramStore(DB_PATH);
43
62
 
@@ -54,8 +73,8 @@ async function main() {
54
73
  // API
55
74
  const app = Fastify({ logger: true });
56
75
 
57
- // Bearer token auth — only enforced when AWM_API_KEY is set
58
- if (API_KEY) {
76
+ // Bearer token auth — only enforced when AWM_API_KEY is explicitly set and non-empty
77
+ if (API_KEY && API_KEY !== 'NONE' && API_KEY.length > 1) {
59
78
  app.addHook('onRequest', async (req, reply) => {
60
79
  if (req.url === '/health') return; // Health check is always public
61
80
  const bearer = req.headers.authorization;
@@ -83,7 +102,7 @@ async function main() {
83
102
 
84
103
  // Start server
85
104
  await app.listen({ port: PORT, host: '0.0.0.0' });
86
- console.log(`AgentWorkingMemory v0.3.0 listening on port ${PORT}`);
105
+ console.log(`AgentWorkingMemory v0.5.4 listening on port ${PORT}`);
87
106
 
88
107
  // Graceful shutdown
89
108
  const shutdown = () => {