agent-working-memory 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -112,7 +116,7 @@ export class ConsolidationEngine {
112
116
  * Phase 6.7: Confidence drift — adjust confidence based on structural signals
113
117
  * Phase 7: Sweep — check staging buffer for resonance
114
118
  */
115
- consolidate(agentId: string): ConsolidationResult {
119
+ async consolidate(agentId: string): Promise<ConsolidationResult> {
116
120
  const result: ConsolidationResult = {
117
121
  clustersFound: 0,
118
122
  edgesStrengthened: 0,
@@ -131,9 +135,22 @@ export class ConsolidationEngine {
131
135
  };
132
136
 
133
137
  // --- Phase 1: Replay ---
134
- // Get all active engrams with embeddings
135
- const engrams = this.store.getEngramsByAgent(agentId, 'active')
136
- .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);
137
154
 
138
155
  result.engramsProcessed = engrams.length;
139
156
  if (engrams.length < 2) return result;
@@ -178,30 +195,32 @@ export class ConsolidationEngine {
178
195
  }
179
196
  }
180
197
 
181
- // --- Phase 3: Cross-cluster bridge edges ---
182
- // For each pair of clusters, compute centroid similarity. If moderate
183
- // similarity exists but no direct edge, create a low-weight bridge.
184
- // 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.
185
200
  if (clusters.length >= 2) {
201
+ const MIN_BRIDGE_SIM = 0.15;
186
202
  let bridges = 0;
187
- const centroids = clusters.map(cluster => this.computeCentroid(cluster));
188
-
189
203
  for (let i = 0; i < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; i++) {
190
204
  for (let j = i + 1; j < clusters.length && bridges < MAX_BRIDGE_EDGES_PER_CYCLE; j++) {
191
- const sim = cosineSimilarity(centroids[i], centroids[j]);
192
- if (sim < BRIDGE_THRESHOLD || sim >= SIMILARITY_THRESHOLD) continue;
193
-
194
- // Find the best representative from each cluster (highest accessCount)
195
- const repA = clusters[i].reduce((best, e) => e.accessCount > best.accessCount ? e : best);
196
- const repB = clusters[j].reduce((best, e) => e.accessCount > best.accessCount ? e : best);
197
-
198
- const existing = this.store.getAssociation(repA.id, repB.id);
199
- if (!existing) {
200
- // Bridge weight proportional to inter-cluster similarity
201
- const bridgeWeight = 0.15 + 0.15 * ((sim - BRIDGE_THRESHOLD) / (SIMILARITY_THRESHOLD - BRIDGE_THRESHOLD));
202
- this.store.upsertAssociation(repA.id, repB.id, bridgeWeight, 'bridge');
203
- bridges++;
204
- 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
+ }
205
224
  }
206
225
  }
207
226
  }
@@ -219,13 +238,20 @@ export class ConsolidationEngine {
219
238
  (Date.now() - assoc.lastActivated.getTime()) / (1000 * 60 * 60 * 24);
220
239
  if (daysSince < 0.5) continue; // Skip recently activated
221
240
 
222
- // Confidence-modulated half-life: higher confidence = slower decay (capped at 3x)
223
- // Base: 7 days. Conf 0.5 → 7 days. Conf 0.8 ~15 days. Conf 1.0 → 21 days (3x).
224
- // 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).
225
244
  const fromConf = engramConfMap.get(assoc.fromEngramId) ?? 0.5;
226
245
  const toConf = engramConfMap.get(assoc.toEngramId) ?? 0.5;
227
246
  const maxConf = Math.max(fromConf, toConf);
228
- 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
+ );
229
255
 
230
256
  const newWeight = decayAssociation(assoc.weight, daysSince, halfLifeDays);
231
257
  if (newWeight < PRUNE_THRESHOLD) {
@@ -474,34 +500,66 @@ export class ConsolidationEngine {
474
500
  * Greedy agglomerative — each memory belongs to at most one cluster.
475
501
  * Clusters of size 2+ are returned (pairs count — they link).
476
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
+ */
477
509
  private findClusters(engrams: Engram[]): Engram[][] {
478
- 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));
479
526
  const clusters: Engram[][] = [];
480
527
 
481
- // Seed clusters from most-accessed memories (strongest traces)
482
- 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);
483
530
 
484
- for (const seed of sorted) {
485
- if (assigned.has(seed.id)) continue;
531
+ for (const seedIdx of sortedIdxs) {
532
+ if (!unassigned.has(seedIdx)) continue;
533
+ unassigned.delete(seedIdx);
486
534
 
487
- const cluster: Engram[] = [seed];
488
- assigned.add(seed.id);
535
+ const clusterIdxs: number[] = [seedIdx];
536
+ let added = true;
489
537
 
490
- for (const candidate of sorted) {
491
- if (assigned.has(candidate.id)) continue;
492
- if (!seed.embedding || !candidate.embedding) continue;
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;
546
+
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;
493
552
 
494
- const sim = cosineSimilarity(seed.embedding, candidate.embedding);
495
- if (sim >= SIMILARITY_THRESHOLD) {
496
- cluster.push(candidate);
497
- assigned.add(candidate.id);
553
+ clusterIdxs.push(candIdx);
554
+ unassigned.delete(candIdx);
555
+ added = true;
498
556
  }
499
557
  }
500
558
 
501
- if (cluster.length >= 2) {
502
- clusters.push(cluster);
559
+ if (clusterIdxs.length >= 2) {
560
+ clusters.push(clusterIdxs.map(i => engrams[i]));
503
561
  } else {
504
- for (const e of cluster) assigned.delete(e.id);
562
+ unassigned.add(seedIdx);
505
563
  }
506
564
  }
507
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 = () => {