agent-working-memory 0.6.1 → 0.7.1

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 (51) hide show
  1. package/README.md +27 -8
  2. package/dist/adapters/common.d.ts.map +1 -1
  3. package/dist/adapters/common.js +9 -1
  4. package/dist/adapters/common.js.map +1 -1
  5. package/dist/api/routes.d.ts.map +1 -1
  6. package/dist/api/routes.js +108 -10
  7. package/dist/api/routes.js.map +1 -1
  8. package/dist/cli.js +103 -103
  9. package/dist/core/auto-tagger.d.ts +29 -0
  10. package/dist/core/auto-tagger.d.ts.map +1 -0
  11. package/dist/core/auto-tagger.js +139 -0
  12. package/dist/core/auto-tagger.js.map +1 -0
  13. package/dist/core/hebbian.d.ts +25 -1
  14. package/dist/core/hebbian.d.ts.map +1 -1
  15. package/dist/core/hebbian.js +71 -3
  16. package/dist/core/hebbian.js.map +1 -1
  17. package/dist/core/query-expander.d.ts.map +1 -1
  18. package/dist/core/query-expander.js.map +1 -1
  19. package/dist/core/reranker.d.ts.map +1 -1
  20. package/dist/core/reranker.js.map +1 -1
  21. package/dist/engine/activation.d.ts +22 -4
  22. package/dist/engine/activation.d.ts.map +1 -1
  23. package/dist/engine/activation.js +136 -73
  24. package/dist/engine/activation.js.map +1 -1
  25. package/dist/engine/consolidation.d.ts +1 -0
  26. package/dist/engine/consolidation.d.ts.map +1 -1
  27. package/dist/engine/consolidation.js +149 -9
  28. package/dist/engine/consolidation.js.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/mcp.js +123 -84
  31. package/dist/mcp.js.map +1 -1
  32. package/dist/storage/sqlite.d.ts +17 -0
  33. package/dist/storage/sqlite.d.ts.map +1 -1
  34. package/dist/storage/sqlite.js +73 -0
  35. package/dist/storage/sqlite.js.map +1 -1
  36. package/dist/types/engram.d.ts +2 -0
  37. package/dist/types/engram.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/adapters/common.ts +9 -1
  40. package/src/api/routes.ts +723 -600
  41. package/src/cli.ts +719 -719
  42. package/src/core/auto-tagger.ts +168 -0
  43. package/src/core/hebbian.ts +84 -3
  44. package/src/core/query-expander.ts +0 -1
  45. package/src/core/reranker.ts +0 -1
  46. package/src/engine/activation.ts +136 -70
  47. package/src/engine/consolidation.ts +165 -9
  48. package/src/index.ts +199 -199
  49. package/src/mcp.ts +1134 -1099
  50. package/src/storage/sqlite.ts +77 -0
  51. package/src/types/engram.ts +2 -0
@@ -0,0 +1,168 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Auto-Tagger — generates meta-tags for memories at write time.
5
+ *
6
+ * Meta-tags serve as categorical signals that boost BM25 recall.
7
+ * They're indexed in FTS5 alongside concept/content/tags, so queries
8
+ * that match a category tag get better BM25 scores.
9
+ *
10
+ * Three sources of tags:
11
+ * 1. Content analysis — extract topics, entities, categories from the text
12
+ * 2. Context propagation — inherit relevant tags from related memories
13
+ * 3. Type classification — fact/experience/belief/entity markers
14
+ *
15
+ * Design: lightweight heuristics, no LLM calls. Tags are additive —
16
+ * they enrich the existing tag set without replacing user-provided tags.
17
+ */
18
+
19
+ /**
20
+ * Extract meta-tags from memory content using keyword patterns.
21
+ * Returns tags prefixed with 'cat:' to distinguish from user tags.
22
+ */
23
+ export function extractMetaTags(concept: string, content: string): string[] {
24
+ const tags: string[] = [];
25
+ const text = `${concept} ${content}`.toLowerCase();
26
+
27
+ // --- Category tags (broad topic classification) ---
28
+
29
+ // People / personal
30
+ if (/\b(i |my |i'm |i've |we |our |me )\b/.test(text)) {
31
+ tags.push('cat:personal');
32
+ }
33
+
34
+ // Work / professional
35
+ if (/\b(work|job|office|meeting|project|team|manager|colleague|career|salary|hired)\b/.test(text)) {
36
+ tags.push('cat:work');
37
+ }
38
+
39
+ // Technology / computing
40
+ if (/\b(code|programming|software|database|api|server|deploy|bug|git|typescript|python|react|node)\b/.test(text)) {
41
+ tags.push('cat:tech');
42
+ }
43
+
44
+ // Health / wellness
45
+ if (/\b(health|doctor|exercise|yoga|gym|diet|sleep|meditation|therapy|medicine|symptom)\b/.test(text)) {
46
+ tags.push('cat:health');
47
+ }
48
+
49
+ // Finance / money
50
+ if (/\b(money|budget|savings|invest|salary|cost|price|payment|bank|credit|expense|coupon|store|bought|purchased)\b/.test(text)) {
51
+ tags.push('cat:finance');
52
+ }
53
+
54
+ // Home / living
55
+ if (/\b(home|house|apartment|room|kitchen|bedroom|furniture|garden|repair|renovation|neighbor|moved)\b/.test(text)) {
56
+ tags.push('cat:home');
57
+ }
58
+
59
+ // Travel / location
60
+ if (/\b(travel|trip|vacation|flight|hotel|restaurant|city|country|visited|downtown|park)\b/.test(text)) {
61
+ tags.push('cat:location');
62
+ }
63
+
64
+ // Education / learning
65
+ if (/\b(school|university|college|degree|course|class|study|learn|graduate|student|teacher|exam)\b/.test(text)) {
66
+ tags.push('cat:education');
67
+ }
68
+
69
+ // Social / relationships
70
+ if (/\b(friend|family|partner|spouse|child|parent|sibling|birthday|party|dinner|wedding|date)\b/.test(text)) {
71
+ tags.push('cat:social');
72
+ }
73
+
74
+ // Hobbies / entertainment
75
+ if (/\b(music|movie|book|game|sport|hobby|play|concert|theater|playlist|podcast|guitar|tennis|yoga|painting)\b/.test(text)) {
76
+ tags.push('cat:hobby');
77
+ }
78
+
79
+ // Shopping / consumer
80
+ if (/\b(bought|purchased|ordered|shop|store|amazon|target|walmart|coupon|sale|discount|delivery)\b/.test(text)) {
81
+ tags.push('cat:shopping');
82
+ }
83
+
84
+ // Food / cooking
85
+ if (/\b(cook|recipe|restaurant|meal|food|dinner|lunch|breakfast|coffee|tea|bake|kitchen)\b/.test(text)) {
86
+ tags.push('cat:food');
87
+ }
88
+
89
+ // Pets / animals
90
+ if (/\b(pet|dog|cat|animal|vet|shelter|walk|breed)\b/.test(text)) {
91
+ tags.push('cat:pets');
92
+ }
93
+
94
+ // Time markers
95
+ if (/\b(yesterday|today|last week|last month|tomorrow|next week|this morning|this evening|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/.test(text)) {
96
+ tags.push('cat:temporal');
97
+ }
98
+
99
+ // --- Entity extraction (simple noun phrase patterns) ---
100
+
101
+ // Proper nouns (capitalized words that aren't sentence starters)
102
+ const properNouns = content.match(/(?:^|\.\s+)?([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/g);
103
+ if (properNouns) {
104
+ const unique = [...new Set(properNouns.map(n => n.trim()).filter(n => n.length > 2 && n.length < 30))];
105
+ for (const noun of unique.slice(0, 5)) {
106
+ tags.push(`entity:${noun}`);
107
+ }
108
+ }
109
+
110
+ // --- Knowledge type tags ---
111
+
112
+ // Preference (I like/prefer/enjoy/love/hate)
113
+ if (/\b(i like|i prefer|i enjoy|i love|i hate|my favorite|i don't like)\b/.test(text)) {
114
+ tags.push('cat:preference');
115
+ }
116
+
117
+ // Fact (declarative statements about identity/attributes)
118
+ if (/\b(my name is|i am a|i work at|i live in|i graduated|my birthday|i was born)\b/.test(text)) {
119
+ tags.push('cat:identity');
120
+ }
121
+
122
+ // Plan / intention
123
+ if (/\b(i plan to|i'm going to|i want to|i'm thinking of|i'm considering|next week i|planning to)\b/.test(text)) {
124
+ tags.push('cat:plan');
125
+ }
126
+
127
+ // Experience / event
128
+ if (/\b(i went|i visited|i attended|i tried|i saw|i heard|i found|i discovered)\b/.test(text)) {
129
+ tags.push('cat:experience');
130
+ }
131
+
132
+ return tags;
133
+ }
134
+
135
+ /**
136
+ * Propagate relevant tags from related memories to a new memory.
137
+ * Called after connection engine links the new memory to existing ones.
138
+ *
139
+ * Strategy: inherit meta-tags from strongly connected neighbors,
140
+ * but only tags that appear in 2+ neighbors (consensus filtering).
141
+ */
142
+ export function propagateTagsFromNeighbors(
143
+ existingTags: string[],
144
+ neighborTagSets: string[][],
145
+ ): string[] {
146
+ if (neighborTagSets.length === 0) return [];
147
+
148
+ // Count meta-tag occurrences across neighbors
149
+ const tagCounts = new Map<string, number>();
150
+ for (const tags of neighborTagSets) {
151
+ for (const tag of tags) {
152
+ if (tag.startsWith('cat:') || tag.startsWith('entity:')) {
153
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
154
+ }
155
+ }
156
+ }
157
+
158
+ // Only propagate tags that appear in 2+ neighbors (consensus)
159
+ const propagated: string[] = [];
160
+ const existingSet = new Set(existingTags);
161
+ for (const [tag, count] of tagCounts) {
162
+ if (count >= 2 && !existingSet.has(tag)) {
163
+ propagated.push(tag);
164
+ }
165
+ }
166
+
167
+ return propagated.slice(0, 5); // Cap to avoid tag bloat
168
+ }
@@ -30,14 +30,25 @@ export function strengthenAssociation(
30
30
 
31
31
  /**
32
32
  * Weaken an association weight due to lack of co-activation.
33
- * Called periodically by the connection engine.
33
+ * Uses power-law decay (DASH model) instead of exponential.
34
+ * Power law has a longer tail — old but valuable associations
35
+ * don't vanish as aggressively as exponential decay.
36
+ *
37
+ * DASH: weight = initial × (1 + t/scale)^(-exponent)
38
+ * vs exponential: weight = initial × 0.5^(t/halfLife)
39
+ *
40
+ * At 7 days: power-law retains ~58% vs exponential 50%
41
+ * At 30 days: power-law retains ~32% vs exponential 6%
42
+ * At 90 days: power-law retains ~20% vs exponential 0.02%
34
43
  */
35
44
  export function decayAssociation(
36
45
  currentWeight: number,
37
46
  daysSinceActivation: number,
38
- halfLife: number = 7.0 // days
47
+ halfLife: number = 7.0 // scale parameter (days)
39
48
  ): number {
40
- const decayFactor = Math.pow(0.5, daysSinceActivation / halfLife);
49
+ // Power-law decay: (1 + t/scale)^(-exponent)
50
+ const exponent = 0.8; // Controls steepness — 0.8 gives a good balance
51
+ const decayFactor = Math.pow(1 + daysSinceActivation / halfLife, -exponent);
41
52
  return Math.max(currentWeight * decayFactor, MIN_WEIGHT);
42
53
  }
43
54
 
@@ -91,3 +102,73 @@ export class CoActivationBuffer {
91
102
  this.buffer = [];
92
103
  }
93
104
  }
105
+
106
+ /**
107
+ * Validation-gated Hebbian buffer (Kairos-inspired).
108
+ *
109
+ * Instead of strengthening associations immediately on co-activation,
110
+ * pairs are held pending until feedback arrives. This prevents hub toxicity
111
+ * from noisy co-retrieval — edges only strengthen when the retrieval was
112
+ * actually useful.
113
+ *
114
+ * - Positive feedback → strengthen the pending pairs
115
+ * - Negative feedback → slightly weaken them
116
+ * - No feedback within GATE_TIMEOUT_MS → discard (neutral)
117
+ */
118
+ const GATE_TIMEOUT_MS = 60_000; // 60 seconds to receive feedback
119
+
120
+ interface PendingHebbianUpdate {
121
+ pairs: [string, string][];
122
+ engramIds: string[];
123
+ timestamp: number;
124
+ }
125
+
126
+ export class ValidationGatedBuffer {
127
+ private pending: PendingHebbianUpdate[] = [];
128
+
129
+ /** Record co-activated pairs as pending (awaiting feedback validation) */
130
+ addPending(engramIds: string[], pairs: [string, string][]): void {
131
+ this.pending.push({ pairs, engramIds, timestamp: Date.now() });
132
+ // Evict expired entries
133
+ const cutoff = Date.now() - GATE_TIMEOUT_MS;
134
+ this.pending = this.pending.filter(p => p.timestamp > cutoff);
135
+ }
136
+
137
+ /**
138
+ * Resolve pending updates for an engram that received feedback.
139
+ * Returns pairs to strengthen (positive) or weaken (negative).
140
+ */
141
+ resolveFeedback(engramId: string, useful: boolean): { pairs: [string, string][]; signal: number } {
142
+ const cutoff = Date.now() - GATE_TIMEOUT_MS;
143
+ const matching: [string, string][] = [];
144
+
145
+ // Find all pending updates that include this engram and are still within the gate window
146
+ this.pending = this.pending.filter(p => {
147
+ if (p.timestamp < cutoff) return false; // expired
148
+ if (p.engramIds.includes(engramId)) {
149
+ matching.push(...p.pairs);
150
+ return false; // consumed
151
+ }
152
+ return true; // keep
153
+ });
154
+
155
+ // Deduplicate pairs
156
+ const seen = new Set<string>();
157
+ const unique: [string, string][] = [];
158
+ for (const [a, b] of matching) {
159
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
160
+ if (!seen.has(key)) {
161
+ seen.add(key);
162
+ unique.push([a, b]);
163
+ }
164
+ }
165
+
166
+ return {
167
+ pairs: unique,
168
+ signal: useful ? 1.0 : -0.3, // positive = full strengthen, negative = slight weaken
169
+ };
170
+ }
171
+
172
+ /** Get count of pending updates (for stats) */
173
+ get pendingCount(): number { return this.pending.length; }
174
+ }
@@ -15,7 +15,6 @@
15
15
  import { pipeline, type Text2TextGenerationPipeline } from '@huggingface/transformers';
16
16
 
17
17
  const MODEL_ID = 'Xenova/flan-t5-small';
18
-
19
18
  let instance: Text2TextGenerationPipeline | null = null;
20
19
  let initPromise: Promise<Text2TextGenerationPipeline> | null = null;
21
20
 
@@ -23,7 +23,6 @@ import {
23
23
 
24
24
  const DEFAULT_MODEL = 'Xenova/ms-marco-MiniLM-L-6-v2';
25
25
  const MODEL_ID = process.env.AWM_RERANKER_MODEL || DEFAULT_MODEL;
26
-
27
26
  let tokenizer: PreTrainedTokenizer | null = null;
28
27
  let model: PreTrainedModel | null = null;
29
28
  let initPromise: Promise<void> | null = null;
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { randomUUID } from 'node:crypto';
23
23
  import { baseLevelActivation, softplus } from '../core/decay.js';
24
- import { strengthenAssociation, CoActivationBuffer } from '../core/hebbian.js';
24
+ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from '../core/hebbian.js';
25
25
  import { embed, cosineSimilarity } from '../core/embeddings.js';
26
26
  import { rerank } from '../core/reranker.js';
27
27
  import { expandQuery } from '../core/query-expander.js';
@@ -163,10 +163,12 @@ function jaccard(a: Set<string>, b: Set<string>): number {
163
163
  export class ActivationEngine {
164
164
  private store: EngramStore;
165
165
  private coActivationBuffer: CoActivationBuffer;
166
+ readonly validationGate: ValidationGatedBuffer;
166
167
 
167
168
  constructor(store: EngramStore) {
168
169
  this.store = store;
169
170
  this.coActivationBuffer = new CoActivationBuffer(50);
171
+ this.validationGate = new ValidationGatedBuffer();
170
172
  }
171
173
 
172
174
  /**
@@ -181,14 +183,18 @@ export class ActivationEngine {
181
183
  const abstentionThreshold = query.abstentionThreshold ?? 0;
182
184
  const adaptive = resolveAdaptiveParams(query);
183
185
 
186
+ // Resolve workspace scope: if workspace is set, search across all agents in that workspace
187
+ const agentIds = query.workspace
188
+ ? this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
189
+ : [query.agentId];
190
+ const isWorkspaceScoped = agentIds.length > 1;
191
+
184
192
  // Phase -1: Coref expansion — if query has pronouns, append recent entity names
185
- // Helps conversational recall where "she/he/they/it" refers to a named entity.
186
193
  let queryContext = query.context;
187
194
  const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
188
195
  if (pronounPattern.test(queryContext)) {
189
- // Pull recent entity names from the agent's most-accessed memories
190
196
  try {
191
- const recentEntities = this.store.getEngramsByAgent(query.agentId, 'active')
197
+ const recentEntities = this.store.getEngramsByAgents(agentIds, 'active')
192
198
  .sort((a, b) => b.accessCount - a.accessCount)
193
199
  .slice(0, 10)
194
200
  .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
@@ -228,9 +234,9 @@ export class ActivationEngine {
228
234
  // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
229
235
  const keywordQuery = Array.from(tokenize(query.context)).join(' ');
230
236
  const bm25Keyword = keywordQuery.length > 2
231
- ? this.store.searchBM25WithRank(query.agentId, keywordQuery, limit * 3)
237
+ ? this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
232
238
  : [];
233
- const bm25Expanded = this.store.searchBM25WithRank(query.agentId, searchContext, limit * 3);
239
+ const bm25Expanded = this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
234
240
 
235
241
  // Merge: take the best BM25 score per engram from either pass
236
242
  const bm25ScoreMap = new Map<string, number>();
@@ -246,8 +252,8 @@ export class ActivationEngine {
246
252
  engram, bm25Score: bm25ScoreMap.get(id) ?? 0,
247
253
  }));
248
254
 
249
- const allActive = this.store.getEngramsByAgent(
250
- query.agentId,
255
+ const allActive = this.store.getEngramsByAgents(
256
+ agentIds,
251
257
  query.includeStaging ? undefined : 'active',
252
258
  query.includeRetracted ?? false
253
259
  );
@@ -402,7 +408,7 @@ export class ActivationEngine {
402
408
  // Take top 5 feedback terms and re-search
403
409
  const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
404
410
  if (extraTerms) {
405
- const feedbackBM25 = this.store.searchBM25WithRank(query.agentId, `${searchContext} ${extraTerms}`, limit * 2);
411
+ const feedbackBM25 = this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
406
412
  for (const r of feedbackBM25) {
407
413
  if (!candidateMap.has(r.engram.id)) {
408
414
  candidateMap.set(r.engram.id, r.engram);
@@ -632,13 +638,21 @@ export class ActivationEngine {
632
638
 
633
639
  const activatedIds = results.map(r => r.engram.id);
634
640
 
635
- // Side effects: touch, co-activate, Hebbian update (skip for internal/system calls)
641
+ // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
636
642
  if (!query.internal) {
637
643
  for (const id of activatedIds) {
638
644
  this.store.touchEngram(id);
639
645
  }
640
646
  this.coActivationBuffer.pushBatch(activatedIds);
641
- this.updateHebbianWeights();
647
+ // Validation-gated Hebbian: defer strengthening until feedback arrives
648
+ const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
649
+ const seen = new Set<string>();
650
+ const uniquePairs: [string, string][] = [];
651
+ for (const [a, b] of pairs) {
652
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
653
+ if (!seen.has(key)) { seen.add(key); uniquePairs.push([a, b]); }
654
+ }
655
+ this.validationGate.addPending(activatedIds, uniquePairs);
642
656
 
643
657
  // Log activation event for eval
644
658
  const latencyMs = performance.now() - startTime;
@@ -658,10 +672,27 @@ export class ActivationEngine {
658
672
  }
659
673
 
660
674
  /**
661
- * Beam search graph walk — replaces naive BFS.
662
- * Scores paths (not just nodes), uses query-dependent edge filtering,
663
- * and supports deeper exploration with focused beams.
675
+ * Multi-graph traversal (MAGMA-inspired).
676
+ *
677
+ * Instead of one beam search over all edge types, runs independent traversals
678
+ * per graph type with specialized scoring, then fuses the boosts.
679
+ *
680
+ * Four sub-graphs:
681
+ * - Semantic (connection + hebbian edges) → standard weight-based walk
682
+ * - Temporal (temporal edges) → recency-weighted (favor recent connections)
683
+ * - Causal (causal edges) → full weight walk (causal links are high-value)
684
+ * - Entity (bridge edges) → entity-tag-weighted walk
685
+ *
686
+ * Each sub-graph contributes independently to the final graph boost,
687
+ * weighted by configurable per-graph weights.
664
688
  */
689
+ private static readonly GRAPH_WEIGHTS = {
690
+ semantic: 0.40, // connection + hebbian
691
+ temporal: 0.20, // temporal edges
692
+ causal: 0.25, // causal edges (high-value signal)
693
+ entity: 0.15, // bridge edges
694
+ };
695
+
665
696
  private graphWalk(
666
697
  scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
667
698
  maxDepth: number,
@@ -670,85 +701,120 @@ export class ActivationEngine {
670
701
  ): void {
671
702
  const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
672
703
  const MAX_TOTAL_BOOST = 0.25;
673
- const BEAM_WIDTH = beamWidth;
674
704
 
675
- // Seed the beam with high-scoring, text-relevant items
676
- const beam = scored
677
- .filter(item => item.phaseScores.textMatch >= 0.15)
678
- .sort((a, b) => b.score - a.score)
679
- .slice(0, BEAM_WIDTH);
705
+ // Define which edge types belong to each sub-graph
706
+ const graphTypes: Record<string, string[]> = {
707
+ semantic: ['connection', 'hebbian'],
708
+ temporal: ['temporal'],
709
+ causal: ['causal'],
710
+ entity: ['bridge'],
711
+ };
712
+
713
+ // Run independent traversals per sub-graph, accumulate boosts
714
+ const boostAccum = new Map<string, number>(); // engramId → total boost
680
715
 
681
- // Track which engrams have been explored (avoid cycles)
682
- const explored = new Set<string>();
716
+ for (const [graphName, edgeTypes] of Object.entries(graphTypes)) {
717
+ const graphWeight = ActivationEngine.GRAPH_WEIGHTS[graphName as keyof typeof ActivationEngine.GRAPH_WEIGHTS];
718
+ const subBeamWidth = Math.max(3, Math.ceil(beamWidth * graphWeight));
683
719
 
684
- for (let depth = 0; depth < maxDepth; depth++) {
685
- const nextBeam: typeof beam = [];
720
+ // Seed beam
721
+ const beam = scored
722
+ .filter(item => item.phaseScores.textMatch >= 0.15)
723
+ .sort((a, b) => b.score - a.score)
724
+ .slice(0, subBeamWidth);
725
+
726
+ const explored = new Set<string>();
727
+
728
+ for (let depth = 0; depth < maxDepth; depth++) {
729
+ const nextBeam: typeof beam = [];
686
730
 
687
- for (const item of beam) {
688
- if (explored.has(item.engram.id)) continue;
689
- explored.add(item.engram.id);
731
+ for (const item of beam) {
732
+ if (explored.has(item.engram.id)) continue;
733
+ explored.add(item.engram.id);
690
734
 
691
- // Get associations for depth > 0, fetch from store if not in scored set
692
- const associations = item.associations.length > 0
693
- ? item.associations
694
- : this.store.getAssociationsFor(item.engram.id);
735
+ const associations = item.associations.length > 0
736
+ ? item.associations
737
+ : this.store.getAssociationsFor(item.engram.id);
695
738
 
696
- for (const assoc of associations) {
697
- const neighborId = assoc.fromEngramId === item.engram.id
698
- ? assoc.toEngramId
699
- : assoc.fromEngramId;
739
+ // Filter to only edges of this sub-graph type
740
+ const relevantEdges = associations.filter(a => edgeTypes.includes(a.type));
700
741
 
701
- if (explored.has(neighborId)) continue;
742
+ for (const assoc of relevantEdges) {
743
+ const neighborId = assoc.fromEngramId === item.engram.id
744
+ ? assoc.toEngramId
745
+ : assoc.fromEngramId;
702
746
 
703
- const neighbor = scoreMap.get(neighborId);
704
- if (!neighbor) continue;
747
+ if (explored.has(neighborId)) continue;
748
+ const neighbor = scoreMap.get(neighborId);
749
+ if (!neighbor) continue;
705
750
 
706
- // Query-dependent edge filtering: neighbor must have SOME relevance
707
- // (textMatch > 0.05 for deeper hops, relaxed from 0.1)
708
- const relevanceFloor = depth === 0 ? 0.1 : 0.05;
709
- if (neighbor.phaseScores.textMatch < relevanceFloor) continue;
751
+ const relevanceFloor = depth === 0 ? 0.1 : 0.05;
752
+ if (neighbor.phaseScores.textMatch < relevanceFloor) continue;
710
753
 
711
- // Skip if neighbor already at boost cap
712
- if (neighbor.phaseScores.graphBoost >= MAX_TOTAL_BOOST) continue;
754
+ // Path score with graph-type-specific weighting
755
+ const normalizedWeight = Math.min(assoc.weight, 5.0) / 5.0;
756
+ let pathScore = item.score * normalizedWeight * Math.pow(hopPenalty, depth + 1);
713
757
 
714
- // Path score: source score * edge weight * hop penalty^(depth+1)
715
- const normalizedWeight = Math.min(assoc.weight, 5.0) / 5.0;
716
- const pathScore = item.score * normalizedWeight * Math.pow(hopPenalty, depth + 1);
758
+ // Causal edges get a 2x boost they represent verified reasoning chains
759
+ if (graphName === 'causal') pathScore *= 2.0;
717
760
 
718
- const boost = Math.min(pathScore, 0.15, MAX_TOTAL_BOOST - neighbor.phaseScores.graphBoost);
719
- if (boost > 0.001) {
720
- neighbor.score += boost;
721
- neighbor.phaseScores.graphBoost += boost;
722
- nextBeam.push(neighbor);
761
+ // Weight by sub-graph importance
762
+ const boost = Math.min(pathScore * graphWeight, 0.15);
763
+ if (boost > 0.001) {
764
+ boostAccum.set(neighborId, (boostAccum.get(neighborId) ?? 0) + boost);
765
+ nextBeam.push(neighbor);
766
+ }
723
767
  }
724
768
  }
769
+
770
+ if (nextBeam.length === 0) break;
771
+ beam.length = 0;
772
+ beam.push(...nextBeam
773
+ .sort((a, b) => b.score - a.score)
774
+ .slice(0, subBeamWidth)
775
+ );
725
776
  }
777
+ }
726
778
 
727
- // Prune beam for next depth level
728
- if (nextBeam.length === 0) break;
729
- beam.length = 0;
730
- beam.push(...nextBeam
731
- .sort((a, b) => b.score - a.score)
732
- .slice(0, BEAM_WIDTH)
733
- );
779
+ // Apply fused boosts to scored items
780
+ for (const [engramId, totalBoost] of boostAccum) {
781
+ const item = scoreMap.get(engramId);
782
+ if (!item) continue;
783
+ const capped = Math.min(totalBoost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
784
+ if (capped > 0.001) {
785
+ item.score += capped;
786
+ item.phaseScores.graphBoost += capped;
787
+ }
734
788
  }
735
789
  }
736
790
 
737
- private updateHebbianWeights(): void {
738
- const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
739
- // Deduplicate pairs to prevent repeated strengthening
740
- const seen = new Set<string>();
741
- for (const [a, b] of pairs) {
742
- const key = a < b ? `${a}:${b}` : `${b}:${a}`;
743
- if (seen.has(key)) continue;
744
- seen.add(key);
791
+ /**
792
+ * Resolve validation-gated Hebbian update for a specific engram.
793
+ * Called by memory_feedback only strengthens when retrieval was useful.
794
+ * This prevents hub toxicity from noisy co-retrieval (Kairos-inspired).
795
+ */
796
+ resolveHebbianFeedback(engramId: string, useful: boolean): number {
797
+ const { pairs, signal } = this.validationGate.resolveFeedback(engramId, useful);
798
+ let updated = 0;
745
799
 
800
+ for (const [a, b] of pairs) {
746
801
  const existing = this.store.getAssociation(a, b) ?? this.store.getAssociation(b, a);
747
802
  const currentWeight = existing?.weight ?? 0.1;
748
- const newWeight = strengthenAssociation(currentWeight);
749
- this.store.upsertAssociation(a, b, newWeight, 'hebbian');
750
- this.store.upsertAssociation(b, a, newWeight, 'hebbian');
803
+
804
+ if (signal > 0) {
805
+ // Positive feedback → strengthen
806
+ const newWeight = strengthenAssociation(currentWeight, signal);
807
+ this.store.upsertAssociation(a, b, newWeight, 'hebbian');
808
+ this.store.upsertAssociation(b, a, newWeight, 'hebbian');
809
+ } else {
810
+ // Negative feedback → slight weakening (decay by signal magnitude)
811
+ const newWeight = Math.max(0.001, currentWeight * (1 + signal)); // signal is -0.3
812
+ this.store.upsertAssociation(a, b, newWeight, 'hebbian');
813
+ this.store.upsertAssociation(b, a, newWeight, 'hebbian');
814
+ }
815
+ updated++;
751
816
  }
817
+ return updated;
752
818
  }
753
819
 
754
820
  private explain(phases: PhaseScores, engram: Engram, associations: Association[]): string {