agent-working-memory 0.6.1 → 0.7.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.
@@ -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 {
package/src/mcp.ts CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { readFileSync } from 'node:fs';
28
28
  import { resolve } from 'node:path';
29
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
29
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
30
30
 
31
31
  // Load .env file if present (no external dependency)
32
32
  try {
@@ -113,64 +113,64 @@ consolidationScheduler.start();
113
113
  // Coordination DB handle — set when AWM_COORDINATION=true, used by memory_write for decision propagation
114
114
  let coordDb: import('better-sqlite3').Database | null = null;
115
115
 
116
- const server = new McpServer({
117
- name: 'agent-working-memory',
118
- version: '0.6.0',
119
- });
120
-
121
- server.registerResource(
122
- 'awm-overview',
123
- 'awm://server/overview',
124
- {
125
- title: 'AWM Overview',
126
- description: 'AgentWorkingMemory MCP server metadata and discovery notes',
127
- mimeType: 'text/markdown',
128
- },
129
- async () => ({
130
- contents: [{
131
- uri: 'awm://server/overview',
132
- text: [
133
- '# Agent Working Memory',
134
- '',
135
- `Agent: ${AGENT_ID}`,
136
- `DB: ${DB_PATH}`,
137
- `Coordination: ${process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1' ? 'enabled' : 'disabled'}`,
138
- '',
139
- 'This MCP server primarily exposes tools such as `memory_restore`, `memory_recall`, `memory_write`, and task/checkpoint operations.',
140
- 'The resources below exist so generic MCP clients can discover the server through `resources/list` and `resources/templates/list`.',
141
- ].join('\n'),
142
- mimeType: 'text/markdown',
143
- }],
144
- })
145
- );
146
-
147
- server.registerResource(
148
- 'awm-memory-template',
149
- new ResourceTemplate('awm://memory/{id}', { list: undefined }),
150
- {
151
- title: 'AWM Memory By ID',
152
- description: 'Metadata resource template for a memory identifier',
153
- mimeType: 'text/markdown',
154
- },
155
- async (_uri, variables) => ({
156
- contents: [{
157
- uri: `awm://memory/${variables.id ?? ''}`,
158
- text: [
159
- '# AWM Memory Reference',
160
- '',
161
- `Requested memory id: ${variables.id ?? ''}`,
162
- '',
163
- 'Use the AWM memory tools for actual retrieval and mutation:',
164
- '- `memory_recall` for cognitive retrieval',
165
- '- `memory_restore` for session state',
166
- '- `memory_feedback`, `memory_retract`, `memory_supersede` for memory maintenance',
167
- ].join('\n'),
168
- mimeType: 'text/markdown',
169
- }],
170
- })
171
- );
172
-
173
- // --- Auto-classification for memory types ---
116
+ const server = new McpServer({
117
+ name: 'agent-working-memory',
118
+ version: '0.6.0',
119
+ });
120
+
121
+ server.registerResource(
122
+ 'awm-overview',
123
+ 'awm://server/overview',
124
+ {
125
+ title: 'AWM Overview',
126
+ description: 'AgentWorkingMemory MCP server metadata and discovery notes',
127
+ mimeType: 'text/markdown',
128
+ },
129
+ async () => ({
130
+ contents: [{
131
+ uri: 'awm://server/overview',
132
+ text: [
133
+ '# Agent Working Memory',
134
+ '',
135
+ `Agent: ${AGENT_ID}`,
136
+ `DB: ${DB_PATH}`,
137
+ `Coordination: ${process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1' ? 'enabled' : 'disabled'}`,
138
+ '',
139
+ 'This MCP server primarily exposes tools such as `memory_restore`, `memory_recall`, `memory_write`, and task/checkpoint operations.',
140
+ 'The resources below exist so generic MCP clients can discover the server through `resources/list` and `resources/templates/list`.',
141
+ ].join('\n'),
142
+ mimeType: 'text/markdown',
143
+ }],
144
+ })
145
+ );
146
+
147
+ server.registerResource(
148
+ 'awm-memory-template',
149
+ new ResourceTemplate('awm://memory/{id}', { list: undefined }),
150
+ {
151
+ title: 'AWM Memory By ID',
152
+ description: 'Metadata resource template for a memory identifier',
153
+ mimeType: 'text/markdown',
154
+ },
155
+ async (_uri, variables) => ({
156
+ contents: [{
157
+ uri: `awm://memory/${variables.id ?? ''}`,
158
+ text: [
159
+ '# AWM Memory Reference',
160
+ '',
161
+ `Requested memory id: ${variables.id ?? ''}`,
162
+ '',
163
+ 'Use the AWM memory tools for actual retrieval and mutation:',
164
+ '- `memory_recall` for cognitive retrieval',
165
+ '- `memory_restore` for session state',
166
+ '- `memory_feedback`, `memory_retract`, `memory_supersede` for memory maintenance',
167
+ ].join('\n'),
168
+ mimeType: 'text/markdown',
169
+ }],
170
+ })
171
+ );
172
+
173
+ // --- Auto-classification for memory types ---
174
174
 
175
175
  function classifyMemoryType(content: string): 'episodic' | 'semantic' | 'procedural' | 'unclassified' {
176
176
  const lower = content.toLowerCase();
@@ -378,6 +378,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
378
378
  use_reranker: z.boolean().optional().default(true).describe('Use cross-encoder re-ranking for better relevance (default true)'),
379
379
  use_expansion: z.boolean().optional().default(true).describe('Expand query with synonyms for better recall (default true)'),
380
380
  memory_type: z.enum(['episodic', 'semantic', 'procedural']).optional().describe('Filter by memory type (omit to search all types)'),
381
+ workspace: z.string().optional().describe('Search across all agents in this workspace (hive mode). Omit for agent-scoped recall only.'),
381
382
  },
382
383
  async (params) => {
383
384
  const queryText = params.query ?? params.context;
@@ -389,6 +390,8 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
389
390
  }],
390
391
  };
391
392
  }
393
+ // Use workspace from param, env var, or omit for agent-scoped
394
+ const workspace = params.workspace ?? process.env.AWM_WORKSPACE ?? undefined;
392
395
  const results = await activationEngine.activate({
393
396
  agentId: AGENT_ID,
394
397
  context: queryText,
@@ -398,6 +401,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
398
401
  useReranker: params.use_reranker,
399
402
  useExpansion: params.use_expansion,
400
403
  memoryType: params.memory_type,
404
+ workspace,
401
405
  });
402
406
 
403
407
  // Auto-checkpoint: track recall
@@ -456,10 +460,13 @@ Always call this after using a recalled memory so the system learns what's valua
456
460
  store.updateConfidence(engram.id, engram.confidence + delta);
457
461
  }
458
462
 
463
+ // Validation-gated Hebbian: resolve pending co-activation pairs for this engram
464
+ const hebbianUpdated = activationEngine.resolveHebbianFeedback(params.engram_id, params.useful);
465
+
459
466
  return {
460
467
  content: [{
461
468
  type: 'text' as const,
462
- text: `Feedback: ${params.useful ? '+useful' : '-not useful'}`,
469
+ text: `Feedback: ${params.useful ? '+useful' : '-not useful'}${hebbianUpdated > 0 ? ` (${hebbianUpdated} association${hebbianUpdated > 1 ? 's' : ''} ${params.useful ? 'strengthened' : 'weakened'})` : ''}`,
463
470
  }],
464
471
  };
465
472
  }
@@ -673,6 +680,7 @@ Use this at the start of every session or after compaction to pick up where you
673
680
  minScore: 0.05,
674
681
  useReranker: true,
675
682
  useExpansion: true,
683
+ workspace: process.env.AWM_WORKSPACE ?? undefined,
676
684
  });
677
685
  recalledMemories = results.map(r => ({
678
686
  id: r.engram.id,
@@ -983,6 +991,7 @@ This ensures your state is saved before you start, and primes recall with releva
983
991
  minScore: 0.05,
984
992
  useReranker: true,
985
993
  useExpansion: true,
994
+ workspace: process.env.AWM_WORKSPACE ?? undefined,
986
995
  });
987
996
 
988
997
  if (results.length > 0) {
@@ -329,6 +329,83 @@ export class EngramStore {
329
329
  return (this.db.prepare(query).all(...params) as any[]).map(r => this.rowToEngram(r));
330
330
  }
331
331
 
332
+ /**
333
+ * Get engrams across multiple agents (workspace-scoped recall).
334
+ * Used when workspace mode is enabled for hive memory sharing.
335
+ */
336
+ getEngramsByAgents(agentIds: string[], stage?: EngramStage, includeRetracted: boolean = false): Engram[] {
337
+ if (agentIds.length === 0) return [];
338
+ if (agentIds.length === 1) return this.getEngramsByAgent(agentIds[0], stage, includeRetracted);
339
+
340
+ const placeholders = agentIds.map(() => '?').join(',');
341
+ let query = `SELECT * FROM engrams WHERE agent_id IN (${placeholders})`;
342
+ const params: any[] = [...agentIds];
343
+
344
+ if (stage) {
345
+ query += ' AND stage = ?';
346
+ params.push(stage);
347
+ }
348
+ if (!includeRetracted) {
349
+ query += ' AND retracted = 0';
350
+ }
351
+
352
+ return (this.db.prepare(query).all(...params) as any[]).map(r => this.rowToEngram(r));
353
+ }
354
+
355
+ /**
356
+ * BM25 search across multiple agents (workspace-scoped).
357
+ */
358
+ searchBM25WithRankMultiAgent(agentIds: string[], query: string, limit: number = 10): { engram: Engram; bm25Score: number }[] {
359
+ if (agentIds.length === 0) return [];
360
+ if (agentIds.length === 1) return this.searchBM25WithRank(agentIds[0], query, limit);
361
+
362
+ const sanitized = query
363
+ .replace(/[^\w\s]/g, '')
364
+ .split(/\s+/)
365
+ .filter(w => w.length > 1)
366
+ .map(w => `"${w}"`)
367
+ .join(' OR ');
368
+
369
+ if (!sanitized) return [];
370
+
371
+ try {
372
+ const placeholders = agentIds.map(() => '?').join(',');
373
+ const rows = this.db.prepare(`
374
+ SELECT e.*, rank FROM engrams e
375
+ JOIN engrams_fts ON e.rowid = engrams_fts.rowid
376
+ WHERE engrams_fts MATCH ? AND e.agent_id IN (${placeholders}) AND e.retracted = 0
377
+ ORDER BY rank
378
+ LIMIT ?
379
+ `).all(sanitized, ...agentIds, limit) as any[];
380
+
381
+ return rows.map(r => ({
382
+ engram: this.rowToEngram(r),
383
+ bm25Score: Math.abs(r.rank ?? 0) / (1 + Math.abs(r.rank ?? 0)),
384
+ }));
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Get all distinct agent IDs that share a workspace (requires coord_agents table).
392
+ * Returns just the queried agentId if coordination tables don't exist.
393
+ */
394
+ getWorkspaceAgentIds(agentId: string, workspace: string): string[] {
395
+ try {
396
+ const rows = this.db.prepare(
397
+ `SELECT DISTINCT id FROM coord_agents WHERE workspace = ? AND status != 'dead'`
398
+ ).all(workspace) as Array<{ id: string }>;
399
+ const ids = rows.map(r => r.id);
400
+ // Ensure the querying agent is always included
401
+ if (!ids.includes(agentId)) ids.push(agentId);
402
+ return ids;
403
+ } catch {
404
+ // No coordination tables — fall back to single agent
405
+ return [agentId];
406
+ }
407
+ }
408
+
332
409
  /**
333
410
  * Touch an engram: increment access count, update last_accessed, and
334
411
  * nudge confidence upward. Each retrieval is weak evidence the memory
@@ -186,6 +186,7 @@ export interface ActivationQuery {
186
186
  internal?: boolean; // Skip access count increment, Hebbian update, and event logging (for system calls)
187
187
  memoryType?: MemoryType; // Filter by memory type (episodic, semantic, procedural)
188
188
  mode?: QueryMode; // Pipeline mode — 'auto' by default
189
+ workspace?: string; // Search across all agents in this workspace (hive mode). If unset, agent-scoped only.
189
190
  }
190
191
 
191
192
  /**