agent-working-memory 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/dist/api/index.d.ts +2 -0
  4. package/dist/api/index.d.ts.map +1 -0
  5. package/dist/api/index.js +2 -0
  6. package/dist/api/index.js.map +1 -0
  7. package/dist/api/routes.d.ts +53 -0
  8. package/dist/api/routes.d.ts.map +1 -0
  9. package/dist/api/routes.js +388 -0
  10. package/dist/api/routes.js.map +1 -0
  11. package/dist/cli.d.ts +12 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +245 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/core/decay.d.ts +36 -0
  16. package/dist/core/decay.d.ts.map +1 -0
  17. package/dist/core/decay.js +38 -0
  18. package/dist/core/decay.js.map +1 -0
  19. package/dist/core/embeddings.d.ts +33 -0
  20. package/dist/core/embeddings.d.ts.map +1 -0
  21. package/dist/core/embeddings.js +76 -0
  22. package/dist/core/embeddings.js.map +1 -0
  23. package/dist/core/hebbian.d.ts +38 -0
  24. package/dist/core/hebbian.d.ts.map +1 -0
  25. package/dist/core/hebbian.js +74 -0
  26. package/dist/core/hebbian.js.map +1 -0
  27. package/dist/core/index.d.ts +4 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/index.js +4 -0
  30. package/dist/core/index.js.map +1 -0
  31. package/dist/core/query-expander.d.ts +24 -0
  32. package/dist/core/query-expander.d.ts.map +1 -0
  33. package/dist/core/query-expander.js +58 -0
  34. package/dist/core/query-expander.js.map +1 -0
  35. package/dist/core/reranker.d.ts +25 -0
  36. package/dist/core/reranker.d.ts.map +1 -0
  37. package/dist/core/reranker.js +75 -0
  38. package/dist/core/reranker.js.map +1 -0
  39. package/dist/core/salience.d.ts +30 -0
  40. package/dist/core/salience.d.ts.map +1 -0
  41. package/dist/core/salience.js +81 -0
  42. package/dist/core/salience.js.map +1 -0
  43. package/dist/engine/activation.d.ts +38 -0
  44. package/dist/engine/activation.d.ts.map +1 -0
  45. package/dist/engine/activation.js +516 -0
  46. package/dist/engine/activation.js.map +1 -0
  47. package/dist/engine/connections.d.ts +31 -0
  48. package/dist/engine/connections.d.ts.map +1 -0
  49. package/dist/engine/connections.js +74 -0
  50. package/dist/engine/connections.js.map +1 -0
  51. package/dist/engine/consolidation-scheduler.d.ts +31 -0
  52. package/dist/engine/consolidation-scheduler.d.ts.map +1 -0
  53. package/dist/engine/consolidation-scheduler.js +115 -0
  54. package/dist/engine/consolidation-scheduler.js.map +1 -0
  55. package/dist/engine/consolidation.d.ts +62 -0
  56. package/dist/engine/consolidation.d.ts.map +1 -0
  57. package/dist/engine/consolidation.js +368 -0
  58. package/dist/engine/consolidation.js.map +1 -0
  59. package/dist/engine/eval.d.ts +22 -0
  60. package/dist/engine/eval.d.ts.map +1 -0
  61. package/dist/engine/eval.js +79 -0
  62. package/dist/engine/eval.js.map +1 -0
  63. package/dist/engine/eviction.d.ts +29 -0
  64. package/dist/engine/eviction.d.ts.map +1 -0
  65. package/dist/engine/eviction.js +86 -0
  66. package/dist/engine/eviction.js.map +1 -0
  67. package/dist/engine/index.d.ts +7 -0
  68. package/dist/engine/index.d.ts.map +1 -0
  69. package/dist/engine/index.js +7 -0
  70. package/dist/engine/index.js.map +1 -0
  71. package/dist/engine/retraction.d.ts +32 -0
  72. package/dist/engine/retraction.d.ts.map +1 -0
  73. package/dist/engine/retraction.js +77 -0
  74. package/dist/engine/retraction.js.map +1 -0
  75. package/dist/engine/staging.d.ts +33 -0
  76. package/dist/engine/staging.d.ts.map +1 -0
  77. package/dist/engine/staging.js +63 -0
  78. package/dist/engine/staging.js.map +1 -0
  79. package/dist/index.d.ts +2 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +95 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/mcp.d.ts +24 -0
  84. package/dist/mcp.d.ts.map +1 -0
  85. package/dist/mcp.js +532 -0
  86. package/dist/mcp.js.map +1 -0
  87. package/dist/storage/index.d.ts +2 -0
  88. package/dist/storage/index.d.ts.map +1 -0
  89. package/dist/storage/index.js +2 -0
  90. package/dist/storage/index.js.map +1 -0
  91. package/dist/storage/sqlite.d.ts +116 -0
  92. package/dist/storage/sqlite.d.ts.map +1 -0
  93. package/dist/storage/sqlite.js +750 -0
  94. package/dist/storage/sqlite.js.map +1 -0
  95. package/dist/types/agent.d.ts +30 -0
  96. package/dist/types/agent.d.ts.map +1 -0
  97. package/dist/types/agent.js +23 -0
  98. package/dist/types/agent.js.map +1 -0
  99. package/dist/types/checkpoint.d.ts +50 -0
  100. package/dist/types/checkpoint.d.ts.map +1 -0
  101. package/dist/types/checkpoint.js +8 -0
  102. package/dist/types/checkpoint.js.map +1 -0
  103. package/dist/types/engram.d.ts +165 -0
  104. package/dist/types/engram.d.ts.map +1 -0
  105. package/dist/types/engram.js +8 -0
  106. package/dist/types/engram.js.map +1 -0
  107. package/dist/types/eval.d.ts +84 -0
  108. package/dist/types/eval.d.ts.map +1 -0
  109. package/dist/types/eval.js +11 -0
  110. package/dist/types/eval.js.map +1 -0
  111. package/dist/types/index.d.ts +5 -0
  112. package/dist/types/index.d.ts.map +1 -0
  113. package/dist/types/index.js +5 -0
  114. package/dist/types/index.js.map +1 -0
  115. package/package.json +55 -0
  116. package/src/api/index.ts +1 -0
  117. package/src/api/routes.ts +528 -0
  118. package/src/cli.ts +260 -0
  119. package/src/core/decay.ts +61 -0
  120. package/src/core/embeddings.ts +82 -0
  121. package/src/core/hebbian.ts +91 -0
  122. package/src/core/index.ts +3 -0
  123. package/src/core/query-expander.ts +64 -0
  124. package/src/core/reranker.ts +99 -0
  125. package/src/core/salience.ts +95 -0
  126. package/src/engine/activation.ts +577 -0
  127. package/src/engine/connections.ts +101 -0
  128. package/src/engine/consolidation-scheduler.ts +123 -0
  129. package/src/engine/consolidation.ts +443 -0
  130. package/src/engine/eval.ts +100 -0
  131. package/src/engine/eviction.ts +99 -0
  132. package/src/engine/index.ts +6 -0
  133. package/src/engine/retraction.ts +98 -0
  134. package/src/engine/staging.ts +72 -0
  135. package/src/index.ts +100 -0
  136. package/src/mcp.ts +635 -0
  137. package/src/storage/index.ts +1 -0
  138. package/src/storage/sqlite.ts +893 -0
  139. package/src/types/agent.ts +65 -0
  140. package/src/types/checkpoint.ts +44 -0
  141. package/src/types/engram.ts +194 -0
  142. package/src/types/eval.ts +98 -0
  143. package/src/types/index.ts +4 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Salience Filter — decides what's worth remembering.
3
+ *
4
+ * Codex feedback incorporated:
5
+ * - Persists raw feature scores for auditability
6
+ * - Returns reason codes for explainability
7
+ * - Thresholds are tunable per agent
8
+ * - Deterministic heuristics first, LLM augmentation optional
9
+ */
10
+
11
+ import type { SalienceFeatures } from '../types/index.js';
12
+
13
+ export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation';
14
+
15
+ export interface SalienceInput {
16
+ content: string;
17
+ eventType?: SalienceEventType;
18
+ surprise?: number;
19
+ decisionMade?: boolean;
20
+ causalDepth?: number;
21
+ resolutionEffort?: number;
22
+ }
23
+
24
+ export interface SalienceResult {
25
+ score: number;
26
+ disposition: 'active' | 'staging' | 'discard';
27
+ features: SalienceFeatures;
28
+ reasonCodes: string[];
29
+ }
30
+
31
+ /**
32
+ * Weights for the salience scoring formula.
33
+ */
34
+ const WEIGHTS = {
35
+ surprise: 0.3,
36
+ decision: 0.25,
37
+ causalDepth: 0.25,
38
+ resolutionEffort: 0.2,
39
+ };
40
+
41
+ /**
42
+ * Rule-based salience scorer with full audit trail.
43
+ */
44
+ export function evaluateSalience(
45
+ input: SalienceInput,
46
+ activeThreshold: number = 0.4,
47
+ stagingThreshold: number = 0.2
48
+ ): SalienceResult {
49
+ const features: SalienceFeatures = {
50
+ surprise: input.surprise ?? 0,
51
+ decisionMade: input.decisionMade ?? false,
52
+ causalDepth: input.causalDepth ?? 0,
53
+ resolutionEffort: input.resolutionEffort ?? 0,
54
+ eventType: input.eventType ?? 'observation',
55
+ };
56
+
57
+ const reasonCodes: string[] = [];
58
+
59
+ // Score components
60
+ const surpriseScore = WEIGHTS.surprise * features.surprise;
61
+ const decisionScore = WEIGHTS.decision * (features.decisionMade ? 1.0 : 0);
62
+ const causalScore = WEIGHTS.causalDepth * features.causalDepth;
63
+ const effortScore = WEIGHTS.resolutionEffort * features.resolutionEffort;
64
+
65
+ if (features.surprise > 0.5) reasonCodes.push('high_surprise');
66
+ if (features.decisionMade) reasonCodes.push('decision_point');
67
+ if (features.causalDepth > 0.5) reasonCodes.push('causal_insight');
68
+ if (features.resolutionEffort > 0.5) reasonCodes.push('high_effort_resolution');
69
+
70
+ // Event type bonus
71
+ let typeBonus = 0;
72
+ switch (features.eventType) {
73
+ case 'decision': typeBonus = 0.15; reasonCodes.push('event:decision'); break;
74
+ case 'friction': typeBonus = 0.2; reasonCodes.push('event:friction'); break;
75
+ case 'surprise': typeBonus = 0.25; reasonCodes.push('event:surprise'); break;
76
+ case 'causal': typeBonus = 0.2; reasonCodes.push('event:causal'); break;
77
+ case 'observation': break;
78
+ }
79
+
80
+ const score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + typeBonus, 1.0);
81
+
82
+ let disposition: 'active' | 'staging' | 'discard';
83
+ if (score >= activeThreshold) {
84
+ disposition = 'active';
85
+ reasonCodes.push('disposition:active');
86
+ } else if (score >= stagingThreshold) {
87
+ disposition = 'staging';
88
+ reasonCodes.push('disposition:staging');
89
+ } else {
90
+ disposition = 'discard';
91
+ reasonCodes.push('disposition:discard');
92
+ }
93
+
94
+ return { score, disposition, features, reasonCodes };
95
+ }
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Activation Pipeline — the core retrieval engine.
3
+ *
4
+ * 10-phase cognitive retrieval pipeline:
5
+ * 0. Query expansion (flan-t5-small synonym generation)
6
+ * 1. Vector embedding (MiniLM 384d)
7
+ * 2. Parallel retrieval (FTS5/BM25 + vector pool)
8
+ * 3. Scoring (BM25, Jaccard, z-score vector, entity-bridge boost)
9
+ * 4. Rocchio pseudo-relevance feedback (expand + re-search BM25)
10
+ * 5. ACT-R temporal decay
11
+ * 6. Hebbian boost (co-activation strength)
12
+ * 7. Composite scoring with confidence gating
13
+ * 8. Beam search graph walk (depth 2, hop penalty)
14
+ * 9. Cross-encoder reranking (ms-marco-MiniLM, adaptive blend)
15
+ * 10. Abstention gate
16
+ *
17
+ * Logs activation events for eval metrics.
18
+ */
19
+
20
+ import { randomUUID } from 'node:crypto';
21
+ import { baseLevelActivation, softplus } from '../core/decay.js';
22
+ import { strengthenAssociation, CoActivationBuffer } from '../core/hebbian.js';
23
+ import { embed, cosineSimilarity } from '../core/embeddings.js';
24
+ import { rerank } from '../core/reranker.js';
25
+ import { expandQuery } from '../core/query-expander.js';
26
+ import type {
27
+ Engram, ActivationResult, ActivationQuery, Association, PhaseScores,
28
+ } from '../types/index.js';
29
+ import type { EngramStore } from '../storage/sqlite.js';
30
+
31
+ /**
32
+ * Common English stopwords — filtered from similarity calculations.
33
+ * These words carry no semantic signal for memory retrieval.
34
+ */
35
+ const STOPWORDS = new Set([
36
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had',
37
+ 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'been', 'from', 'that',
38
+ 'this', 'with', 'they', 'will', 'each', 'make', 'like', 'then', 'than',
39
+ 'them', 'some', 'what', 'when', 'where', 'which', 'who', 'how', 'use',
40
+ 'into', 'does', 'also', 'just', 'more', 'over', 'such', 'only', 'very',
41
+ 'about', 'after', 'being', 'between', 'could', 'during', 'before',
42
+ 'should', 'would', 'their', 'there', 'these', 'those', 'through',
43
+ 'because', 'using', 'other',
44
+ ]);
45
+
46
+ function tokenize(text: string): Set<string> {
47
+ return new Set(
48
+ text.toLowerCase()
49
+ .split(/\s+/)
50
+ .filter(w => w.length > 2 && !STOPWORDS.has(w))
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Jaccard similarity between two word sets: |intersection| / |union|
56
+ */
57
+ function jaccard(a: Set<string>, b: Set<string>): number {
58
+ if (a.size === 0 || b.size === 0) return 0;
59
+ let intersection = 0;
60
+ for (const w of a) {
61
+ if (b.has(w)) intersection++;
62
+ }
63
+ const union = a.size + b.size - intersection;
64
+ return union > 0 ? intersection / union : 0;
65
+ }
66
+
67
+ export class ActivationEngine {
68
+ private store: EngramStore;
69
+ private coActivationBuffer: CoActivationBuffer;
70
+
71
+ constructor(store: EngramStore) {
72
+ this.store = store;
73
+ this.coActivationBuffer = new CoActivationBuffer(50);
74
+ }
75
+
76
+ /**
77
+ * Activate — retrieve the most cognitively relevant engrams for a context.
78
+ */
79
+ async activate(query: ActivationQuery): Promise<ActivationResult[]> {
80
+ const startTime = performance.now();
81
+ const limit = query.limit ?? 10;
82
+ const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
83
+ const useReranker = query.useReranker ?? true;
84
+ const useExpansion = query.useExpansion ?? true;
85
+ const abstentionThreshold = query.abstentionThreshold ?? 0;
86
+
87
+ // Phase 0: Query expansion — add related terms to improve BM25 recall
88
+ let searchContext = query.context;
89
+ if (useExpansion) {
90
+ try {
91
+ searchContext = await expandQuery(query.context);
92
+ } catch {
93
+ // Expansion unavailable — use original query
94
+ }
95
+ }
96
+
97
+ // Phase 1: Embed original query for vector similarity (original, not expanded)
98
+ let queryEmbedding: number[] | null = null;
99
+ try {
100
+ queryEmbedding = await embed(query.context);
101
+ } catch {
102
+ // Embedding unavailable — fall back to text-only matching
103
+ }
104
+
105
+ // Phase 2: Parallel retrieval — BM25 with rank scores + all active engrams
106
+ // Use expanded query for BM25 (more terms = better keyword recall)
107
+ const bm25Ranked = this.store.searchBM25WithRank(query.agentId, searchContext, limit * 3);
108
+ const bm25ScoreMap = new Map(bm25Ranked.map(r => [r.engram.id, r.bm25Score]));
109
+
110
+ const allActive = this.store.getEngramsByAgent(
111
+ query.agentId,
112
+ query.includeStaging ? undefined : 'active',
113
+ query.includeRetracted ?? false
114
+ );
115
+
116
+ // Merge candidates (deduplicate)
117
+ const candidateMap = new Map<string, Engram>();
118
+ for (const r of bm25Ranked) candidateMap.set(r.engram.id, r.engram);
119
+ for (const e of allActive) candidateMap.set(e.id, e);
120
+ const candidates = Array.from(candidateMap.values());
121
+
122
+ if (candidates.length === 0) return [];
123
+
124
+ // Tokenize query once
125
+ const queryTokens = tokenize(query.context);
126
+
127
+ // Phase 3a: Compute raw cosine similarities for adaptive normalization
128
+ const rawCosineSims = new Map<string, number>();
129
+ if (queryEmbedding) {
130
+ for (const engram of candidates) {
131
+ if (engram.embedding) {
132
+ rawCosineSims.set(engram.id, cosineSimilarity(queryEmbedding, engram.embedding));
133
+ }
134
+ }
135
+ }
136
+
137
+ // Compute distribution stats for model-agnostic normalization
138
+ const simValues = Array.from(rawCosineSims.values());
139
+ const simMean = simValues.length > 0
140
+ ? simValues.reduce((a, b) => a + b, 0) / simValues.length : 0;
141
+ const rawStdDev = simValues.length > 1
142
+ ? Math.sqrt(simValues.reduce((sum, s) => sum + (s - simMean) ** 2, 0) / simValues.length) : 0.15;
143
+ // Floor stddev at 0.10 to prevent z-score inflation with small candidate pools
144
+ const simStdDev = Math.max(rawStdDev, 0.10);
145
+
146
+ // Phase 3b: Score each candidate with per-phase breakdown
147
+ const scored = candidates.map(engram => {
148
+ const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
149
+ const associations = this.store.getAssociationsFor(engram.id);
150
+
151
+ // --- Text relevance (keyword signals) ---
152
+
153
+ // Signal 1: BM25 continuous score (0-1, from FTS5 rank)
154
+ const bm25Score = bm25ScoreMap.get(engram.id) ?? 0;
155
+
156
+ // Signal 2: Jaccard similarity with stopword filtering
157
+ const conceptTokens = tokenize(engram.concept);
158
+ const contentTokens = tokenize(engram.content);
159
+ const conceptJaccard = jaccard(queryTokens, conceptTokens);
160
+ const contentJaccard = jaccard(queryTokens, contentTokens);
161
+ const jaccardScore = 0.6 * conceptJaccard + 0.4 * contentJaccard;
162
+
163
+ // Signal 3: Concept exact match bonus (up to 0.3)
164
+ const conceptOverlap = conceptTokens.size > 0
165
+ ? [...conceptTokens].filter(w => queryTokens.has(w)).length / conceptTokens.size
166
+ : 0;
167
+ const conceptBonus = conceptOverlap * 0.3;
168
+
169
+ const keywordMatch = Math.min(Math.max(bm25Score, jaccardScore) + conceptBonus, 1.0);
170
+
171
+ // --- Vector similarity (semantic signal) ---
172
+ // Two-stage: absolute floor prevents noise, then z-score ranks within matches.
173
+ // Stage 1: Raw cosine must exceed mean + 1 stddev (absolute relevance gate)
174
+ // Stage 2: Z-score maps relative position to 0-1 for ranking quality
175
+ let vectorMatch = 0;
176
+ const rawSim = rawCosineSims.get(engram.id);
177
+ if (rawSim !== undefined) {
178
+ const zScore = (rawSim - simMean) / simStdDev;
179
+ // Gate: must be at least 1 stddev above mean to be considered a match
180
+ if (zScore > 1.0) {
181
+ // Map z=1..3 → 0..1 linearly
182
+ vectorMatch = Math.min(1, (zScore - 1.0) / 2.0);
183
+ }
184
+ }
185
+
186
+ // Combined text match: best of keyword and vector signals
187
+ const textMatch = Math.max(keywordMatch, vectorMatch);
188
+
189
+ // --- Temporal signals ---
190
+
191
+ // ACT-R decay — confidence-modulated
192
+ // High-confidence memories (confirmed useful via feedback) decay slower.
193
+ // Default exponent: 0.5. At confidence 0.8+: 0.3 (much slower decay).
194
+ const decayExponent = 0.5 - 0.2 * Math.max(0, (engram.confidence - 0.5) / 0.5);
195
+ const decayScore = baseLevelActivation(engram.accessCount, ageDays, decayExponent);
196
+
197
+ // Hebbian boost from associations — capped to prevent popular memories
198
+ // from dominating regardless of query relevance
199
+ const rawHebbian = associations.length > 0
200
+ ? associations.reduce((sum, a) => sum + a.weight, 0) / associations.length
201
+ : 0;
202
+ const hebbianBoost = Math.min(rawHebbian, 0.5);
203
+
204
+ // Centrality signal — well-connected memories (high weighted degree)
205
+ // get a small boost. This makes consolidation edges matter for retrieval.
206
+ // Log-scaled to prevent hub domination: 10 edges ≈ 0.05 boost, 50 ≈ 0.08
207
+ const weightedDegree = associations.reduce((sum, a) => sum + a.weight, 0);
208
+ const centralityBoost = associations.length > 0
209
+ ? Math.min(0.1, 0.03 * Math.log1p(weightedDegree))
210
+ : 0;
211
+
212
+ // Confidence gate — multiplicative quality signal
213
+ const confidenceGate = engram.confidence;
214
+
215
+ // Feedback bonus — memories confirmed useful via explicit feedback get a
216
+ // direct additive boost. Models how a senior dev "just knows" certain things
217
+ // are important. Confidence > 0.6 means at least 2+ positive feedbacks.
218
+ // Scales: conf 0.6→0.03, 0.7→0.06, 0.8→0.09, 1.0→0.15
219
+ const feedbackBonus = engram.confidence > 0.55
220
+ ? Math.min(0.15, 0.3 * Math.max(0, engram.confidence - 0.5))
221
+ : 0;
222
+
223
+ // --- Composite score: relevance-gated additive ---
224
+ // Text relevance must be present for temporal signals to contribute.
225
+ // Without text relevance, a memory shouldn't activate regardless of recency.
226
+ // Temporal contribution scales with text relevance (weak match = weak temporal boost).
227
+ const temporalNorm = Math.min(softplus(decayScore + hebbianBoost), 3.0) / 3.0;
228
+ const relevanceGate = textMatch > 0.1 ? textMatch : 0.0; // Proportional gate
229
+ const composite = (0.6 * textMatch + 0.4 * temporalNorm * relevanceGate + centralityBoost * relevanceGate + feedbackBonus * relevanceGate) * confidenceGate;
230
+
231
+ const phaseScores: PhaseScores = {
232
+ textMatch,
233
+ vectorMatch,
234
+ decayScore,
235
+ hebbianBoost,
236
+ graphBoost: 0, // Filled in phase 5
237
+ confidenceGate,
238
+ composite,
239
+ rerankerScore: 0, // Filled in phase 7
240
+ };
241
+
242
+ return { engram, score: composite, phaseScores, associations };
243
+ });
244
+
245
+ // Phase 3.5: Rocchio pseudo-relevance feedback — expand query with top result terms
246
+ // then re-search BM25 to find candidates that keyword search missed
247
+ const preSorted = scored.sort((a, b) => b.score - a.score);
248
+ const topForFeedback = preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1);
249
+ if (topForFeedback.length > 0) {
250
+ const feedbackTerms = new Set<string>();
251
+ for (const item of topForFeedback) {
252
+ const tokens = tokenize(item.engram.content);
253
+ for (const t of tokens) {
254
+ if (!queryTokens.has(t) && t.length >= 4) feedbackTerms.add(t);
255
+ }
256
+ }
257
+ // Take top 5 feedback terms and re-search
258
+ const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
259
+ if (extraTerms) {
260
+ const feedbackBM25 = this.store.searchBM25WithRank(query.agentId, `${searchContext} ${extraTerms}`, limit * 2);
261
+ for (const r of feedbackBM25) {
262
+ if (!candidateMap.has(r.engram.id)) {
263
+ candidateMap.set(r.engram.id, r.engram);
264
+ // Score the new candidate
265
+ const engram = r.engram;
266
+ const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
267
+ const associations = this.store.getAssociationsFor(engram.id);
268
+ const cTokens = tokenize(engram.concept);
269
+ const ctTokens = tokenize(engram.content);
270
+ const cJac = jaccard(queryTokens, cTokens);
271
+ const ctJac = jaccard(queryTokens, ctTokens);
272
+ const jSc = 0.6 * cJac + 0.4 * ctJac;
273
+ const cOvlp = cTokens.size > 0 ? [...cTokens].filter(w => queryTokens.has(w)).length / cTokens.size : 0;
274
+ const km = Math.min(Math.max(r.bm25Score, jSc) + cOvlp * 0.3, 1.0);
275
+ let vm = 0;
276
+ const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
277
+ if (rs) {
278
+ const z = (rs - simMean) / simStdDev;
279
+ if (z > 1.0) vm = Math.min(1, (z - 1.0) / 2.0);
280
+ }
281
+ const tm = Math.max(km, vm);
282
+ const ds = baseLevelActivation(engram.accessCount, ageDays);
283
+ const rh = associations.length > 0 ? Math.min(associations.reduce((s, a) => s + a.weight, 0) / associations.length, 0.5) : 0;
284
+ const tn = Math.min(softplus(ds + rh), 3.0) / 3.0;
285
+ const rg = tm > 0.1 ? tm : 0.0;
286
+ const comp = (0.6 * tm + 0.4 * tn * rg) * engram.confidence;
287
+ scored.push({ engram, score: comp, phaseScores: { textMatch: tm, vectorMatch: vm, decayScore: ds, hebbianBoost: rh, graphBoost: 0, confidenceGate: engram.confidence, composite: comp, rerankerScore: 0 }, associations });
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // Phase 3.7: Entity-Bridge boost — boost scored candidates that share entity tags
294
+ // with the most query-relevant result. Only bridge from the single best text-match
295
+ // to avoid pulling in unrelated entities from tangentially-matching results.
296
+ {
297
+ // Find the result with the highest textMatch (most query-relevant, not just highest score)
298
+ // Gate: only bridge when anchor has meaningful text relevance (> 0.15)
299
+ // Adaptive: scale bridge boost inversely with candidate pool size to prevent
300
+ // over-boosting in large memory pools where many items share entity tags
301
+ const sortedByTextMatch = scored
302
+ .filter(r => r.phaseScores.textMatch > 0.15)
303
+ .sort((a, b) => b.phaseScores.textMatch - a.phaseScores.textMatch);
304
+
305
+ // Bridge from top 2 text-matched results (IDF handles weighting)
306
+ const bridgeAnchors = sortedByTextMatch.slice(0, 2);
307
+
308
+ if (bridgeAnchors.length > 0) {
309
+ const entityTags = new Set<string>();
310
+ const anchorIds = new Set(bridgeAnchors.map(r => r.engram.id));
311
+
312
+ for (const item of bridgeAnchors) {
313
+ for (const tag of item.engram.tags) {
314
+ const t = tag.toLowerCase();
315
+ // Skip non-entity tags: turn IDs, session tags, dialogue IDs, generic speaker labels
316
+ if (/^t\d+$/.test(t) || t.startsWith('session-') || t.startsWith('dia_') || t.length < 3) continue;
317
+ if (/^speaker\d*$/.test(t)) continue; // Generic speaker labels are too broad
318
+ entityTags.add(t);
319
+ }
320
+ }
321
+
322
+ // Document frequency filter: remove tags appearing in >30% of items (too common)
323
+ // This prevents speaker names in 2-person conversations from being used as bridges
324
+ if (entityTags.size > 0 && scored.length > 10) {
325
+ const tagFreqs = new Map<string, number>();
326
+ for (const item of scored) {
327
+ const seen = new Set<string>();
328
+ for (const tag of item.engram.tags) {
329
+ const t = tag.toLowerCase();
330
+ if (entityTags.has(t) && !seen.has(t)) {
331
+ seen.add(t);
332
+ tagFreqs.set(t, (tagFreqs.get(t) ?? 0) + 1);
333
+ }
334
+ }
335
+ }
336
+ const maxFreq = scored.length * 0.30;
337
+ for (const [tag, freq] of tagFreqs) {
338
+ if (freq > maxFreq) entityTags.delete(tag);
339
+ }
340
+ }
341
+
342
+ if (entityTags.size > 0) {
343
+ for (const item of scored) {
344
+ if (anchorIds.has(item.engram.id)) continue;
345
+
346
+ const engramTags = new Set(item.engram.tags.map((t: string) => t.toLowerCase()));
347
+ let sharedEntities = 0;
348
+ for (const et of entityTags) {
349
+ if (engramTags.has(et)) sharedEntities++;
350
+ }
351
+
352
+ if (sharedEntities > 0) {
353
+ // Flat bridge boost per shared entity
354
+ const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4);
355
+ item.score += bridgeBoost;
356
+ item.phaseScores.composite += bridgeBoost;
357
+ item.phaseScores.graphBoost += bridgeBoost;
358
+ }
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ // Phase 4+5: Graph walk — boost engrams connected to high-scoring ones
365
+ // Only walk from engrams that had text relevance (composite > 0 pre-walk)
366
+ const sorted = scored.sort((a, b) => b.score - a.score);
367
+ const topN = sorted.slice(0, limit * 3);
368
+ this.graphWalk(topN, 2, 0.3);
369
+
370
+ // Phase 6: Initial filter and sort for re-ranking pool
371
+ const pool = topN
372
+ .filter(r => r.score >= minScore)
373
+ .sort((a, b) => b.score - a.score);
374
+
375
+ // Phase 7: Cross-encoder re-ranking — scores (query, passage) pairs directly
376
+ // Widens the pool to find relevant results that keyword matching missed
377
+ const rerankPool = pool.slice(0, Math.max(limit * 3, 30));
378
+
379
+ if (useReranker && rerankPool.length > 0) {
380
+ try {
381
+ const passages = rerankPool.map(r =>
382
+ `${r.engram.concept}: ${r.engram.content}`
383
+ );
384
+ const rerankResults = await rerank(query.context, passages);
385
+
386
+ // Adaptive reranker blend (Codex recommendation):
387
+ // When BM25/text signals are strong, trust them more; when weak, lean on reranker.
388
+ const bm25Max = Math.max(...rerankPool.map(r => r.phaseScores.textMatch));
389
+ const rerankWeight = Math.min(0.7, Math.max(0.3, 0.3 + 0.4 * (1 - bm25Max)));
390
+ const compositeWeight = 1 - rerankWeight;
391
+
392
+ for (const rr of rerankResults) {
393
+ const item = rerankPool[rr.index];
394
+ item.phaseScores.rerankerScore = rr.score;
395
+ item.score = compositeWeight * item.phaseScores.composite + rerankWeight * rr.score;
396
+ }
397
+ } catch {
398
+ // Re-ranker unavailable — keep original scores
399
+ }
400
+ }
401
+
402
+ // Phase 8a: Semantic drift penalty — if no candidate has meaningful vector match
403
+ // (none exceeded 1 stddev above mean), the query is likely off-topic.
404
+ if (queryEmbedding && rerankPool.length > 0) {
405
+ const maxVectorSim = Math.max(...rerankPool.map(r => r.phaseScores.vectorMatch));
406
+ if (maxVectorSim < 0.05) {
407
+ // Query is semantically distant from everything — apply drift penalty
408
+ for (const item of rerankPool) {
409
+ item.score *= 0.5;
410
+ }
411
+ }
412
+ }
413
+
414
+ // Phase 8b: Entropy gating — if top-5 reranker scores are flat (low variance),
415
+ // the reranker can't distinguish relevant from irrelevant. Abstain.
416
+ if (abstentionThreshold > 0 && rerankPool.length >= 3) {
417
+ const topRerankerScores = rerankPool
418
+ .map(r => r.phaseScores.rerankerScore)
419
+ .sort((a, b) => b - a)
420
+ .slice(0, 5);
421
+ const maxScore = topRerankerScores[0];
422
+ const meanScore = topRerankerScores.reduce((s, v) => s + v, 0) / topRerankerScores.length;
423
+ const variance = topRerankerScores.reduce((s, v) => s + (v - meanScore) ** 2, 0) / topRerankerScores.length;
424
+
425
+ // Abstain if: top score below threshold OR scores are flat (low discrimination)
426
+ if (maxScore < abstentionThreshold || (maxScore < 0.5 && variance < 0.01)) {
427
+ return [];
428
+ }
429
+ }
430
+
431
+ // Phase 9: Final sort, limit, explain
432
+ const results: ActivationResult[] = rerankPool
433
+ .sort((a, b) => b.score - a.score)
434
+ .slice(0, limit)
435
+ .map(r => ({
436
+ engram: r.engram,
437
+ score: r.score,
438
+ phaseScores: r.phaseScores,
439
+ why: this.explain(r.phaseScores, r.engram, r.associations),
440
+ associations: r.associations,
441
+ }));
442
+
443
+ const activatedIds = results.map(r => r.engram.id);
444
+
445
+ // Side effects: touch, co-activate, Hebbian update (skip for internal/system calls)
446
+ if (!query.internal) {
447
+ for (const id of activatedIds) {
448
+ this.store.touchEngram(id);
449
+ }
450
+ this.coActivationBuffer.pushBatch(activatedIds);
451
+ this.updateHebbianWeights();
452
+
453
+ // Log activation event for eval
454
+ const latencyMs = performance.now() - startTime;
455
+ this.store.logActivationEvent({
456
+ id: randomUUID(),
457
+ agentId: query.agentId,
458
+ timestamp: new Date(),
459
+ context: query.context,
460
+ resultsReturned: results.length,
461
+ topScore: results.length > 0 ? results[0].score : 0,
462
+ latencyMs,
463
+ engramIds: activatedIds,
464
+ });
465
+ }
466
+
467
+ return results;
468
+ }
469
+
470
+ /**
471
+ * Beam search graph walk — replaces naive BFS.
472
+ * Scores paths (not just nodes), uses query-dependent edge filtering,
473
+ * and supports deeper exploration with focused beams.
474
+ */
475
+ private graphWalk(
476
+ scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
477
+ maxDepth: number,
478
+ hopPenalty: number
479
+ ): void {
480
+ const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
481
+ const MAX_TOTAL_BOOST = 0.25; // Slightly higher cap for beam search (deeper paths earn it)
482
+ const BEAM_WIDTH = 15;
483
+
484
+ // Seed the beam with high-scoring, text-relevant items
485
+ const beam = scored
486
+ .filter(item => item.phaseScores.textMatch >= 0.15)
487
+ .sort((a, b) => b.score - a.score)
488
+ .slice(0, BEAM_WIDTH);
489
+
490
+ // Track which engrams have been explored (avoid cycles)
491
+ const explored = new Set<string>();
492
+
493
+ for (let depth = 0; depth < maxDepth; depth++) {
494
+ const nextBeam: typeof beam = [];
495
+
496
+ for (const item of beam) {
497
+ if (explored.has(item.engram.id)) continue;
498
+ explored.add(item.engram.id);
499
+
500
+ // Get associations — for depth > 0, fetch from store if not in scored set
501
+ const associations = item.associations.length > 0
502
+ ? item.associations
503
+ : this.store.getAssociationsFor(item.engram.id);
504
+
505
+ for (const assoc of associations) {
506
+ const neighborId = assoc.fromEngramId === item.engram.id
507
+ ? assoc.toEngramId
508
+ : assoc.fromEngramId;
509
+
510
+ if (explored.has(neighborId)) continue;
511
+
512
+ const neighbor = scoreMap.get(neighborId);
513
+ if (!neighbor) continue;
514
+
515
+ // Query-dependent edge filtering: neighbor must have SOME relevance
516
+ // (textMatch > 0.05 for deeper hops, relaxed from 0.1)
517
+ const relevanceFloor = depth === 0 ? 0.1 : 0.05;
518
+ if (neighbor.phaseScores.textMatch < relevanceFloor) continue;
519
+
520
+ // Skip if neighbor already at boost cap
521
+ if (neighbor.phaseScores.graphBoost >= MAX_TOTAL_BOOST) continue;
522
+
523
+ // Path score: source score * edge weight * hop penalty^(depth+1)
524
+ const normalizedWeight = Math.min(assoc.weight, 5.0) / 5.0;
525
+ const pathScore = item.score * normalizedWeight * Math.pow(hopPenalty, depth + 1);
526
+
527
+ const boost = Math.min(pathScore, 0.15, MAX_TOTAL_BOOST - neighbor.phaseScores.graphBoost);
528
+ if (boost > 0.001) {
529
+ neighbor.score += boost;
530
+ neighbor.phaseScores.graphBoost += boost;
531
+ nextBeam.push(neighbor);
532
+ }
533
+ }
534
+ }
535
+
536
+ // Prune beam for next depth level
537
+ if (nextBeam.length === 0) break;
538
+ beam.length = 0;
539
+ beam.push(...nextBeam
540
+ .sort((a, b) => b.score - a.score)
541
+ .slice(0, BEAM_WIDTH)
542
+ );
543
+ }
544
+ }
545
+
546
+ private updateHebbianWeights(): void {
547
+ const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
548
+ // Deduplicate pairs to prevent repeated strengthening
549
+ const seen = new Set<string>();
550
+ for (const [a, b] of pairs) {
551
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
552
+ if (seen.has(key)) continue;
553
+ seen.add(key);
554
+
555
+ const existing = this.store.getAssociation(a, b) ?? this.store.getAssociation(b, a);
556
+ const currentWeight = existing?.weight ?? 0.1;
557
+ const newWeight = strengthenAssociation(currentWeight);
558
+ this.store.upsertAssociation(a, b, newWeight, 'hebbian');
559
+ this.store.upsertAssociation(b, a, newWeight, 'hebbian');
560
+ }
561
+ }
562
+
563
+ private explain(phases: PhaseScores, engram: Engram, associations: Association[]): string {
564
+ const parts: string[] = [];
565
+ parts.push(`composite=${phases.composite.toFixed(3)}`);
566
+ if (phases.textMatch > 0) parts.push(`text=${phases.textMatch.toFixed(2)}`);
567
+ if (phases.vectorMatch > 0) parts.push(`vector=${phases.vectorMatch.toFixed(2)}`);
568
+ parts.push(`decay=${phases.decayScore.toFixed(2)}`);
569
+ if (phases.hebbianBoost > 0) parts.push(`hebbian=${phases.hebbianBoost.toFixed(2)}`);
570
+ if (phases.graphBoost > 0) parts.push(`graph=${phases.graphBoost.toFixed(2)}`);
571
+ if (phases.rerankerScore > 0) parts.push(`reranker=${phases.rerankerScore.toFixed(2)}`);
572
+ parts.push(`conf=${phases.confidenceGate.toFixed(2)}`);
573
+ parts.push(`access=${engram.accessCount}`);
574
+ if (associations.length > 0) parts.push(`edges=${associations.length}`);
575
+ return parts.join(' | ');
576
+ }
577
+ }