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.
- package/README.md +399 -358
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +11 -14
- package/dist/api/routes.js.map +1 -1
- package/dist/core/embeddings.d.ts +5 -1
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +6 -2
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/salience.d.ts +14 -0
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +34 -0
- package/dist/core/salience.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +83 -21
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
- package/dist/engine/consolidation-scheduler.js +3 -3
- package/dist/engine/consolidation-scheduler.js.map +1 -1
- package/dist/engine/consolidation.d.ts +7 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +116 -47
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +132 -103
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/src/api/routes.ts +11 -14
- package/src/core/embeddings.ts +6 -2
- package/src/core/salience.ts +51 -0
- package/src/engine/activation.ts +90 -21
- package/src/engine/consolidation-scheduler.ts +3 -3
- package/src/engine/consolidation.ts +104 -46
- package/src/index.ts +24 -5
- package/src/mcp.ts +1013 -988
package/src/core/embeddings.ts
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Embedding Engine — local vector embeddings via transformers.js
|
|
5
5
|
*
|
|
6
|
-
* Default:
|
|
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/
|
|
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
|
|
package/src/core/salience.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/engine/activation.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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(
|
|
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
|
|
108
|
-
//
|
|
109
|
-
const
|
|
110
|
-
const
|
|
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
|
|
194
|
-
// High-confidence memories
|
|
195
|
-
// Default exponent: 0.5.
|
|
196
|
-
const
|
|
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
|
|
405
|
-
//
|
|
406
|
-
if (
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
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.
|
|
482
|
+
item.score *= 0.4;
|
|
412
483
|
}
|
|
413
484
|
}
|
|
414
485
|
}
|
|
415
486
|
|
|
416
|
-
//
|
|
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
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
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:
|
|
182
|
-
//
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
223
|
-
// Base: 7 days.
|
|
224
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
482
|
-
|
|
528
|
+
const sortedIdxs = Array.from({ length: n }, (_, i) => i)
|
|
529
|
+
.sort((a, b) => engrams[b].accessCount - engrams[a].accessCount);
|
|
483
530
|
|
|
484
|
-
for (const
|
|
485
|
-
if (
|
|
531
|
+
for (const seedIdx of sortedIdxs) {
|
|
532
|
+
if (!unassigned.has(seedIdx)) continue;
|
|
533
|
+
unassigned.delete(seedIdx);
|
|
486
534
|
|
|
487
|
-
const
|
|
488
|
-
|
|
535
|
+
const clusterIdxs: number[] = [seedIdx];
|
|
536
|
+
let added = true;
|
|
489
537
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
assigned.add(candidate.id);
|
|
553
|
+
clusterIdxs.push(candIdx);
|
|
554
|
+
unassigned.delete(candIdx);
|
|
555
|
+
added = true;
|
|
498
556
|
}
|
|
499
557
|
}
|
|
500
558
|
|
|
501
|
-
if (
|
|
502
|
-
clusters.push(
|
|
559
|
+
if (clusterIdxs.length >= 2) {
|
|
560
|
+
clusters.push(clusterIdxs.map(i => engrams[i]));
|
|
503
561
|
} else {
|
|
504
|
-
|
|
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.
|
|
105
|
+
console.log(`AgentWorkingMemory v0.5.4 listening on port ${PORT}`);
|
|
87
106
|
|
|
88
107
|
// Graceful shutdown
|
|
89
108
|
const shutdown = () => {
|