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.
- package/README.md +1 -1
- 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 +9 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +170 -48
- 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 +141 -96
- 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 +166 -47
- package/src/index.ts +24 -5
- package/src/mcp.ts +1013 -971
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
|
|
|
@@ -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
|
|
126
|
-
const
|
|
127
|
-
|
|
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:
|
|
173
|
-
//
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
214
|
-
// Base: 7 days.
|
|
215
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
535
|
+
const clusterIdxs: number[] = [seedIdx];
|
|
536
|
+
let added = true;
|
|
425
537
|
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
assigned.add(candidate.id);
|
|
553
|
+
clusterIdxs.push(candIdx);
|
|
554
|
+
unassigned.delete(candIdx);
|
|
555
|
+
added = true;
|
|
437
556
|
}
|
|
438
557
|
}
|
|
439
558
|
|
|
440
|
-
if (
|
|
441
|
-
clusters.push(
|
|
559
|
+
if (clusterIdxs.length >= 2) {
|
|
560
|
+
clusters.push(clusterIdxs.map(i => engrams[i]));
|
|
442
561
|
} else {
|
|
443
|
-
|
|
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.
|
|
105
|
+
console.log(`AgentWorkingMemory v0.5.4 listening on port ${PORT}`);
|
|
87
106
|
|
|
88
107
|
// Graceful shutdown
|
|
89
108
|
const shutdown = () => {
|