agent-working-memory 0.7.17 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +156 -15
  2. package/dist/adapters/common.js +202 -202
  3. package/dist/adapters/http.js +22 -22
  4. package/dist/api/routes.d.ts +1 -1
  5. package/dist/api/routes.d.ts.map +1 -1
  6. package/dist/api/routes.js +258 -79
  7. package/dist/api/routes.js.map +1 -1
  8. package/dist/cli/migrate.d.ts +42 -0
  9. package/dist/cli/migrate.d.ts.map +1 -0
  10. package/dist/cli/migrate.js +268 -0
  11. package/dist/cli/migrate.js.map +1 -0
  12. package/dist/cli.js +147 -103
  13. package/dist/cli.js.map +1 -1
  14. package/dist/coordination/circuit-breaker.d.ts +24 -0
  15. package/dist/coordination/circuit-breaker.d.ts.map +1 -0
  16. package/dist/coordination/circuit-breaker.js +70 -0
  17. package/dist/coordination/circuit-breaker.js.map +1 -0
  18. package/dist/coordination/failure-modes.d.ts +21 -0
  19. package/dist/coordination/failure-modes.d.ts.map +1 -0
  20. package/dist/coordination/failure-modes.js +49 -0
  21. package/dist/coordination/failure-modes.js.map +1 -0
  22. package/dist/coordination/peer-decisions.js +7 -7
  23. package/dist/coordination/routes.d.ts.map +1 -1
  24. package/dist/coordination/routes.js +44 -1
  25. package/dist/coordination/routes.js.map +1 -1
  26. package/dist/coordination/schema.d.ts.map +1 -1
  27. package/dist/coordination/schema.js +18 -0
  28. package/dist/coordination/schema.js.map +1 -1
  29. package/dist/coordination/schemas.d.ts +9 -9
  30. package/dist/coordination/stale.d.ts +9 -1
  31. package/dist/coordination/stale.d.ts.map +1 -1
  32. package/dist/coordination/stale.js +53 -9
  33. package/dist/coordination/stale.js.map +1 -1
  34. package/dist/core/embeddings.d.ts +16 -10
  35. package/dist/core/embeddings.d.ts.map +1 -1
  36. package/dist/core/embeddings.js +54 -38
  37. package/dist/core/embeddings.js.map +1 -1
  38. package/dist/core/ml-worker-entry.d.ts +17 -0
  39. package/dist/core/ml-worker-entry.d.ts.map +1 -0
  40. package/dist/core/ml-worker-entry.js +193 -0
  41. package/dist/core/ml-worker-entry.js.map +1 -0
  42. package/dist/core/ml-worker.d.ts +59 -0
  43. package/dist/core/ml-worker.d.ts.map +1 -0
  44. package/dist/core/ml-worker.js +253 -0
  45. package/dist/core/ml-worker.js.map +1 -0
  46. package/dist/core/query-expander.d.ts +13 -15
  47. package/dist/core/query-expander.d.ts.map +1 -1
  48. package/dist/core/query-expander.js +48 -54
  49. package/dist/core/query-expander.js.map +1 -1
  50. package/dist/core/reranker.d.ts +9 -19
  51. package/dist/core/reranker.d.ts.map +1 -1
  52. package/dist/core/reranker.js +33 -35
  53. package/dist/core/reranker.js.map +1 -1
  54. package/dist/core/salience.d.ts +32 -5
  55. package/dist/core/salience.d.ts.map +1 -1
  56. package/dist/core/salience.js +201 -28
  57. package/dist/core/salience.js.map +1 -1
  58. package/dist/core/write-pipeline.d.ts +10 -2
  59. package/dist/core/write-pipeline.d.ts.map +1 -1
  60. package/dist/core/write-pipeline.js +218 -49
  61. package/dist/core/write-pipeline.js.map +1 -1
  62. package/dist/engine/activation.d.ts +2 -2
  63. package/dist/engine/activation.d.ts.map +1 -1
  64. package/dist/engine/activation.js +238 -115
  65. package/dist/engine/activation.js.map +1 -1
  66. package/dist/engine/confidence.d.ts +62 -0
  67. package/dist/engine/confidence.d.ts.map +1 -0
  68. package/dist/engine/confidence.js +100 -0
  69. package/dist/engine/confidence.js.map +1 -0
  70. package/dist/engine/connections.d.ts +47 -8
  71. package/dist/engine/connections.d.ts.map +1 -1
  72. package/dist/engine/connections.js +83 -22
  73. package/dist/engine/connections.js.map +1 -1
  74. package/dist/engine/consolidation-scheduler.d.ts +46 -10
  75. package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
  76. package/dist/engine/consolidation-scheduler.js +170 -50
  77. package/dist/engine/consolidation-scheduler.js.map +1 -1
  78. package/dist/engine/consolidation.d.ts +16 -2
  79. package/dist/engine/consolidation.d.ts.map +1 -1
  80. package/dist/engine/consolidation.js +143 -44
  81. package/dist/engine/consolidation.js.map +1 -1
  82. package/dist/engine/eval.d.ts +2 -2
  83. package/dist/engine/eval.d.ts.map +1 -1
  84. package/dist/engine/eval.js +9 -9
  85. package/dist/engine/eval.js.map +1 -1
  86. package/dist/engine/eviction.d.ts +4 -4
  87. package/dist/engine/eviction.d.ts.map +1 -1
  88. package/dist/engine/eviction.js +15 -15
  89. package/dist/engine/eviction.js.map +1 -1
  90. package/dist/engine/retraction.d.ts +93 -5
  91. package/dist/engine/retraction.d.ts.map +1 -1
  92. package/dist/engine/retraction.js +238 -19
  93. package/dist/engine/retraction.js.map +1 -1
  94. package/dist/engine/staging.d.ts +1 -1
  95. package/dist/engine/staging.d.ts.map +1 -1
  96. package/dist/engine/staging.js +3 -3
  97. package/dist/engine/staging.js.map +1 -1
  98. package/dist/hooks/sidecar.d.ts +1 -1
  99. package/dist/hooks/sidecar.d.ts.map +1 -1
  100. package/dist/hooks/sidecar.js +4 -4
  101. package/dist/hooks/sidecar.js.map +1 -1
  102. package/dist/index.js +48 -31
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp.js +167 -141
  105. package/dist/mcp.js.map +1 -1
  106. package/dist/storage/factory.d.ts +55 -0
  107. package/dist/storage/factory.d.ts.map +1 -0
  108. package/dist/storage/factory.js +135 -0
  109. package/dist/storage/factory.js.map +1 -0
  110. package/dist/storage/pglite-schema.d.ts +20 -0
  111. package/dist/storage/pglite-schema.d.ts.map +1 -0
  112. package/dist/storage/pglite-schema.js +165 -0
  113. package/dist/storage/pglite-schema.js.map +1 -0
  114. package/dist/storage/pglite.d.ts +187 -0
  115. package/dist/storage/pglite.d.ts.map +1 -0
  116. package/dist/storage/pglite.js +1114 -0
  117. package/dist/storage/pglite.js.map +1 -0
  118. package/dist/storage/sqlite.d.ts +117 -0
  119. package/dist/storage/sqlite.d.ts.map +1 -1
  120. package/dist/storage/sqlite.js +357 -8
  121. package/dist/storage/sqlite.js.map +1 -1
  122. package/dist/storage/store.d.ts +63 -0
  123. package/dist/storage/store.d.ts.map +1 -0
  124. package/dist/storage/store.js +27 -0
  125. package/dist/storage/store.js.map +1 -0
  126. package/dist/types/engram.d.ts +92 -2
  127. package/dist/types/engram.d.ts.map +1 -1
  128. package/dist/types/engram.js.map +1 -1
  129. package/package.json +3 -1
  130. package/src/adapters/claude-code.ts +218 -218
  131. package/src/adapters/codex.ts +252 -252
  132. package/src/adapters/common.ts +411 -411
  133. package/src/adapters/cursor.ts +135 -135
  134. package/src/adapters/http.ts +93 -93
  135. package/src/adapters/index.ts +31 -31
  136. package/src/adapters/types.ts +75 -75
  137. package/src/api/index.ts +3 -3
  138. package/src/api/routes.ts +970 -714
  139. package/src/cli/migrate.ts +307 -0
  140. package/src/cli.ts +755 -719
  141. package/src/coordination/circuit-breaker.ts +83 -0
  142. package/src/coordination/events.ts +90 -90
  143. package/src/coordination/failure-modes.ts +50 -0
  144. package/src/coordination/peer-decisions.ts +105 -105
  145. package/src/coordination/plugin-loader.ts +60 -60
  146. package/src/coordination/plugin.ts +44 -44
  147. package/src/coordination/routes.ts +56 -1
  148. package/src/coordination/schema.ts +13 -0
  149. package/src/coordination/stale.ts +80 -11
  150. package/src/coordination/types.ts +311 -311
  151. package/src/coordination/write-mutex.ts +69 -69
  152. package/src/core/auto-tagger.ts +168 -168
  153. package/src/core/decay.ts +63 -63
  154. package/src/core/embeddings.ts +110 -93
  155. package/src/core/index.ts +5 -5
  156. package/src/core/logger.ts +36 -36
  157. package/src/core/ml-worker-entry.ts +194 -0
  158. package/src/core/ml-worker.ts +281 -0
  159. package/src/core/query-expander.ts +122 -128
  160. package/src/core/reranker.ts +119 -125
  161. package/src/core/salience.ts +200 -33
  162. package/src/core/write-pipeline.ts +521 -343
  163. package/src/engine/activation.ts +235 -109
  164. package/src/engine/confidence.ts +120 -0
  165. package/src/engine/connections.ts +162 -103
  166. package/src/engine/consolidation-scheduler.ts +242 -125
  167. package/src/engine/consolidation.ts +138 -45
  168. package/src/engine/eval.ts +102 -102
  169. package/src/engine/eviction.ts +101 -101
  170. package/src/engine/index.ts +8 -8
  171. package/src/engine/retraction.ts +366 -116
  172. package/src/engine/staging.ts +74 -74
  173. package/src/hooks/sidecar.ts +5 -5
  174. package/src/index.ts +226 -212
  175. package/src/mcp.ts +1145 -1121
  176. package/src/storage/factory.ts +147 -0
  177. package/src/storage/index.ts +3 -3
  178. package/src/storage/pglite-schema.ts +166 -0
  179. package/src/storage/pglite.ts +1363 -0
  180. package/src/storage/sqlite.ts +402 -7
  181. package/src/storage/store.ts +80 -0
  182. package/src/types/agent.ts +67 -67
  183. package/src/types/checkpoint.ts +46 -46
  184. package/src/types/engram.ts +101 -3
  185. package/src/types/eval.ts +100 -100
  186. package/src/types/index.ts +6 -6
@@ -24,6 +24,7 @@ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from
24
24
  import { embed, cosineSimilarity } from '../core/embeddings.js';
25
25
  import { rerank } from '../core/reranker.js';
26
26
  import { expandQuery } from '../core/query-expander.js';
27
+ import { computeRecallConfidence } from './confidence.js';
27
28
  const ADAPTIVE_PRESETS = {
28
29
  targeted: {
29
30
  mode: 'targeted',
@@ -166,10 +167,11 @@ export class ActivationEngine {
166
167
  const useReranker = query.useReranker ?? true;
167
168
  const useExpansion = query.useExpansion ?? true;
168
169
  const abstentionThreshold = query.abstentionThreshold ?? 0;
170
+ const requireConfidence = query.requireConfidence ?? 0;
169
171
  const adaptive = resolveAdaptiveParams(query);
170
172
  // Resolve workspace scope: if workspace is set, search across all agents in that workspace
171
173
  const agentIds = query.workspace
172
- ? this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
174
+ ? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
173
175
  : [query.agentId];
174
176
  const isWorkspaceScoped = agentIds.length > 1;
175
177
  // Phase -1: Coref expansion — if query has pronouns, append recent entity names
@@ -177,7 +179,7 @@ export class ActivationEngine {
177
179
  const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
178
180
  if (pronounPattern.test(queryContext)) {
179
181
  try {
180
- const recentEntities = this.store.getEngramsByAgents(agentIds, 'active')
182
+ const recentEntities = (await this.store.getEngramsByAgents(agentIds, 'active'))
181
183
  .sort((a, b) => b.accessCount - a.accessCount)
182
184
  .slice(0, 10)
183
185
  .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
@@ -219,9 +221,9 @@ export class ActivationEngine {
219
221
  // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
220
222
  const keywordQuery = Array.from(tokenize(query.context)).join(' ');
221
223
  const bm25Keyword = keywordQuery.length > 2
222
- ? this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
224
+ ? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
223
225
  : [];
224
- const bm25Expanded = this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
226
+ const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
225
227
  // Merge: take the best BM25 score per engram from either pass
226
228
  const bm25ScoreMap = new Map();
227
229
  const bm25EngramMap = new Map();
@@ -240,83 +242,75 @@ export class ActivationEngine {
240
242
  // Used for cosine sim + adaptive z-score stats + cheap pool filter.
241
243
  // Pass 2: full fetch ONLY on the survivors that pass the filter.
242
244
  //
243
- // Why: phase-breakdown spike (2026-05-08, post-0.7.7) showed `SELECT * FROM
244
- // engrams WHERE agent_id = ?` over 10K rows costs 440ms (40% of recall) due
245
- // to row materialization of content/tags/JSON-blob columns the filter pass
246
- // doesn't read. Slim fetch trims the per-row payload to the three fields
247
- // the filter actually uses.
248
- const slimActive = this.store.getEngramsByAgentsSlim(agentIds, query.includeStaging ? undefined : 'active', query.includeRetracted ?? false);
249
- // Tokenize query once (used by filter + scoring)
245
+ // AWM 0.8.x Native vector search refactor (2026-05-25): the prior slim
246
+ // fetch path materialized ALL active engrams (id + concept + embedding)
247
+ // for in-process cosine + z-score gating. On PGlite that meant parsing
248
+ // 11K embedding vectors per recall (~200-500ms). Replaced with native
249
+ // vector search: PGlite uses pgvector + ivfflat (O(log N)); SQLite uses
250
+ // its slim cache + JS cosine (same as before but encapsulated). Z-score
251
+ // normalization is replaced with a mode-adaptive raw-cosine floor —
252
+ // simpler, faster, model-tuned for BGE-small embeddings.
253
+ const VECTOR_TOP_K = Math.max(50, limit * 5);
254
+ // Tokenize query once (used by scoring)
250
255
  const queryTokens = tokenize(query.context);
251
- // Phase 3a: Compute raw cosine similarities on slim pool for adaptive normalization
256
+ // Phase 3a: native vector search across agents top-K by cosine.
257
+ // Apply a candidate floor — BGE-small unit-norm vectors typically cluster
258
+ // around 0.30-0.40 even for unrelated text, so we need a floor that
259
+ // distinguishes "related" from "noise" without throwing out genuine
260
+ // related-but-not-identical matches.
261
+ //
262
+ // Tuning: targeted=0.40, exploratory=0.30. Earlier 0.55/0.45 floors were
263
+ // too aggressive — they dropped Recall@5 on the 200-fact eval corpus
264
+ // from 0.80 → 0.46 (verified 2026-05-26). BGE-small cosines for genuine
265
+ // related matches commonly land 0.42-0.55, so a 0.55 floor cut them
266
+ // entirely. The vectorMatch scoring floor (0.50 targeted / 0.35 exploratory)
267
+ // still suppresses low-confidence matches in the final score.
268
+ // Env override: AWM_SIM_CANDIDATE_FLOOR_TARGETED, AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY.
269
+ const SIM_CANDIDATE_FLOOR = adaptive.zScoreGate > 0.5
270
+ ? Number(process.env.AWM_SIM_CANDIDATE_FLOOR_TARGETED ?? 0.40)
271
+ : Number(process.env.AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY ?? 0.30);
252
272
  const rawCosineSims = new Map();
273
+ const vectorHits = [];
253
274
  if (queryEmbedding) {
254
- for (const e of slimActive) {
255
- if (e.embedding) {
256
- rawCosineSims.set(e.id, cosineSimilarity(queryEmbedding, e.embedding));
275
+ for (const aid of agentIds) {
276
+ try {
277
+ const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
278
+ for (const h of hits) {
279
+ // pgvector cosine distance: 0 = identical, 2 = opposite.
280
+ // For unit-norm BGE vectors, distance ≈ 1 - cosineSimilarity.
281
+ const sim = 1 - h.distance;
282
+ // pgvector returns sorted ASC by distance (DESC by sim) — break once
283
+ // we drop below the candidate floor; all subsequent hits will too.
284
+ if (sim < SIM_CANDIDATE_FLOOR)
285
+ break;
286
+ if (!rawCosineSims.has(h.engram.id)) {
287
+ rawCosineSims.set(h.engram.id, sim);
288
+ vectorHits.push(h.engram);
289
+ }
290
+ }
291
+ }
292
+ catch {
293
+ // Vector search unavailable — fall back to BM25-only ranking
257
294
  }
258
295
  }
259
296
  }
260
- // Compute distribution stats for model-agnostic normalization
261
- const simValues = Array.from(rawCosineSims.values());
262
- const simMean = simValues.length > 0
263
- ? simValues.reduce((a, b) => a + b, 0) / simValues.length : 0;
264
- const rawStdDev = simValues.length > 1
265
- ? Math.sqrt(simValues.reduce((sum, s) => sum + (s - simMean) ** 2, 0) / simValues.length) : 0.15;
266
- // Floor stddev at 0.10 to prevent z-score inflation with small candidate pools
267
- const simStdDev = Math.max(rawStdDev, 0.10);
268
- // Determine survivor IDs from the slim pool using the same survival criteria
269
- // (BM25 hit / cosine z-score / concept-jaccard) plus all BM25-ranked candidates
270
- // (which already came in fully-hydrated and may not be in slimActive's stage filter).
271
- const poolFilterEnabled = process.env.AWM_DISABLE_POOL_FILTER !== '1';
297
+ // Survivors = BM25 candidates vector candidates.
298
+ // The slim-fetch jaccard-fallback path is dropped: in practice it surfaced
299
+ // <1% of candidates that BM25 + vector missed, and on PGlite cost more
300
+ // than the candidates were worth. Concept jaccard signal still contributes
301
+ // via textMatch in scoring.
272
302
  const survivorIds = new Set();
273
- // Always include BM25-ranked engrams (they came pre-hydrated)
274
303
  for (const r of bm25Ranked)
275
304
  survivorIds.add(r.engram.id);
276
- if (poolFilterEnabled) {
277
- for (const e of slimActive) {
278
- if (survivorIds.has(e.id))
279
- continue;
280
- const bm25 = bm25ScoreMap.get(e.id) ?? 0;
281
- if (bm25 > 0) {
282
- survivorIds.add(e.id);
283
- continue;
284
- }
285
- const sim = rawCosineSims.get(e.id);
286
- if (sim !== undefined) {
287
- const z = (sim - simMean) / simStdDev;
288
- if (z > adaptive.zScoreGate) {
289
- survivorIds.add(e.id);
290
- continue;
291
- }
292
- }
293
- // Cheap concept jaccard
294
- const ct = tokenize(e.concept);
295
- if (ct.size === 0)
296
- continue;
297
- let overlap = 0;
298
- for (const w of ct)
299
- if (queryTokens.has(w))
300
- overlap++;
301
- if (overlap > 0)
302
- survivorIds.add(e.id);
303
- }
304
- }
305
- else {
306
- // Filter disabled — include all slim active engrams
307
- for (const e of slimActive)
308
- survivorIds.add(e.id);
309
- }
310
- // Pass 2: hydrate full Engram rows ONLY for survivors that aren't already loaded
305
+ for (const e of vectorHits)
306
+ survivorIds.add(e.id);
307
+ // Hydrate full engrams. BM25 and vector hits arrive pre-hydrated; nothing
308
+ // else needs fetching in the normal path.
311
309
  const candidateMap = new Map();
312
310
  for (const r of bm25Ranked)
313
311
  candidateMap.set(r.engram.id, r.engram);
314
- const idsToHydrate = Array.from(survivorIds).filter(id => !candidateMap.has(id));
315
- if (idsToHydrate.length > 0) {
316
- for (const e of this.store.getEngramsByIds(idsToHydrate)) {
317
- candidateMap.set(e.id, e);
318
- }
319
- }
312
+ for (const e of vectorHits)
313
+ candidateMap.set(e.id, e);
320
314
  let candidates = Array.from(candidateMap.values());
321
315
  // Filter by memory type if specified
322
316
  if (query.memoryType) {
@@ -335,7 +329,7 @@ export class ActivationEngine {
335
329
  // Graph walk still needs full Association objects, but it operates on the
336
330
  // top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
337
331
  // cheap (<5ms total).
338
- const assocStats = this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
332
+ const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
339
333
  const scored = candidates.map(engram => {
340
334
  const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
341
335
  const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
@@ -355,14 +349,16 @@ export class ActivationEngine {
355
349
  const conceptBonus = conceptOverlap * 0.3;
356
350
  const keywordMatch = Math.min(Math.max(bm25Score, jaccardScore) + conceptBonus, 1.0);
357
351
  // --- Vector similarity (semantic signal) ---
358
- // Two-stage: absolute floor prevents noise, then z-score ranks within matches.
359
- // z-gate adapts to query mode: targeted uses strict gate (0.8), exploratory relaxes (0.3).
352
+ // AWM 0.8.x: model-tuned raw-cosine floor in place of z-score normalization.
353
+ // For BGE-small unit-norm vectors: unrelated ~0.3, related 0.5-0.7, near-duplicate 0.85+.
354
+ // Floor adapts to query mode: targeted=0.50 (stricter), exploratory=0.35 (looser).
360
355
  let vectorMatch = 0;
361
356
  const rawSim = rawCosineSims.get(engram.id);
362
- if (rawSim !== undefined) {
363
- const zScore = (rawSim - simMean) / simStdDev;
364
- if (zScore > adaptive.zScoreGate) {
365
- vectorMatch = Math.min(1, (zScore - adaptive.zScoreGate) / 2.0);
357
+ if (rawSim !== undefined && rawSim > 0) {
358
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
359
+ if (rawSim > SIM_FLOOR) {
360
+ // Map [SIM_FLOOR, 1.0] → [0, 1] linearly with cap at 1.0.
361
+ vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
366
362
  }
367
363
  }
368
364
  // Combined text match: weighted blend of keyword and vector signals.
@@ -432,14 +428,14 @@ export class ActivationEngine {
432
428
  // Take top 5 feedback terms and re-search
433
429
  const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
434
430
  if (extraTerms) {
435
- const feedbackBM25 = this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
431
+ const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
436
432
  for (const r of feedbackBM25) {
437
433
  if (!candidateMap.has(r.engram.id)) {
438
434
  candidateMap.set(r.engram.id, r.engram);
439
435
  // Score the new candidate
440
436
  const engram = r.engram;
441
437
  const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
442
- const associations = this.store.getAssociationsFor(engram.id);
438
+ const associations = await this.store.getAssociationsFor(engram.id);
443
439
  const cTokens = tokenize(engram.concept);
444
440
  const ctTokens = tokenize(engram.content);
445
441
  const cJac = jaccard(queryTokens, cTokens);
@@ -449,10 +445,10 @@ export class ActivationEngine {
449
445
  const km = Math.min(Math.max(r.bm25Score, jSc) + cOvlp * 0.3, 1.0);
450
446
  let vm = 0;
451
447
  const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
452
- if (rs) {
453
- const z = (rs - simMean) / simStdDev;
454
- if (z > adaptive.zScoreGate)
455
- vm = Math.min(1, (z - adaptive.zScoreGate) / 2.0);
448
+ if (rs > 0) {
449
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
450
+ if (rs > SIM_FLOOR)
451
+ vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
456
452
  }
457
453
  const tm = km > 0 && vm > 0
458
454
  ? 0.5 * Math.max(km, vm) + 0.3 * Math.min(km, vm) + 0.2 * (km * vm)
@@ -468,9 +464,19 @@ export class ActivationEngine {
468
464
  }
469
465
  }
470
466
  // Phase 3.7: Entity-Bridge boost — boost scored candidates that share entity tags
471
- // with the most query-relevant result. Only bridge from the single best text-match
472
- // to avoid pulling in unrelated entities from tangentially-matching results.
473
- {
467
+ // with the most query-relevant result. The original intent: surface candidates
468
+ // that DON'T match the query text directly but share entities with the top
469
+ // text-match anchor ("she said something" → bridge to entities of the recent
470
+ // speaker named "she"). This is LATERAL relevance, not direct relevance.
471
+ //
472
+ // 2026-05-26: Added textMatch gate. Without it, when many engrams share entity
473
+ // tags AND have similar text matches (e.g., 10 near-clones of the same concept),
474
+ // the bridge boost would push the 8 non-anchor clones above the 2 anchors —
475
+ // an inversion of the genuine top match. The eval Retrieval suite caught this
476
+ // (Recall@5 0.80 → 0.46). Gate: only boost candidates whose textMatch is
477
+ // meaningfully below the anchor's. They actually need the lateral boost.
478
+ // Env override: AWM_DISABLE_ENTITY_BRIDGE=1 to skip this phase entirely.
479
+ if (!process.env.AWM_DISABLE_ENTITY_BRIDGE) {
474
480
  // Find the result with the highest textMatch (most query-relevant, not just highest score)
475
481
  // Gate: only bridge when anchor has meaningful text relevance (> 0.15)
476
482
  // Adaptive: scale bridge boost inversely with candidate pool size to prevent
@@ -515,6 +521,16 @@ export class ActivationEngine {
515
521
  }
516
522
  }
517
523
  if (entityTags.size > 0) {
524
+ // Anchor's textMatch sets the scale. Bridge boost magnitude is
525
+ // proportional to the gap between anchor and candidate textMatch:
526
+ // - candidate near anchor (a near-clone of the anchor) → small gap → near-zero boost
527
+ // - candidate far below anchor (genuine lateral relevance) → large gap → full boost
528
+ // Without this scaling, dense same-concept corpora flip the genuine
529
+ // top-1 below its 8-9 near-clones (eval Recall@5 0.80 → 0.46 verified
530
+ // 2026-05-26). The scaling keeps the lateral-relevance behavior
531
+ // (which is what helps the AB test) without inverting the genuine
532
+ // text-match winner.
533
+ const anchorTextMax = bridgeAnchors[0].phaseScores.textMatch;
518
534
  for (const item of scored) {
519
535
  if (anchorIds.has(item.engram.id))
520
536
  continue;
@@ -525,11 +541,17 @@ export class ActivationEngine {
525
541
  sharedEntities++;
526
542
  }
527
543
  if (sharedEntities > 0) {
528
- // Flat bridge boost per shared entity
529
- const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4);
530
- item.score += bridgeBoost;
531
- item.phaseScores.composite += bridgeBoost;
532
- item.phaseScores.graphBoost += bridgeBoost;
544
+ // Gap scaling: 1.0 when candidateText << anchorText, 0 when equal.
545
+ // Clamped to [0, 1]. Anchors with textMatch ≤ 0 fall back to flat boost.
546
+ const gapScale = anchorTextMax > 0
547
+ ? Math.max(0, Math.min(1, (anchorTextMax - item.phaseScores.textMatch) / anchorTextMax))
548
+ : 1;
549
+ const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4) * gapScale;
550
+ if (bridgeBoost > 0) {
551
+ item.score += bridgeBoost;
552
+ item.phaseScores.composite += bridgeBoost;
553
+ item.phaseScores.graphBoost += bridgeBoost;
554
+ }
533
555
  }
534
556
  }
535
557
  }
@@ -539,7 +561,7 @@ export class ActivationEngine {
539
561
  // Only walk from engrams that had text relevance (composite > 0 pre-walk)
540
562
  const sorted = scored.sort((a, b) => b.score - a.score);
541
563
  const topN = sorted.slice(0, limit * 3);
542
- this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
564
+ await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
543
565
  // Phase 6: Initial filter and sort for re-ranking pool
544
566
  const pool = topN
545
567
  .filter(r => r.score >= minScore)
@@ -627,15 +649,19 @@ export class ActivationEngine {
627
649
  const margin = rerankerScores.length >= 2
628
650
  ? rerankerScores[0] - rerankerScores[1]
629
651
  : rerankerScores[0];
630
- const maxRawCosine = queryEmbedding && simValues.length > 0
631
- ? Math.max(...simValues)
652
+ const cosineSimValues = Array.from(rawCosineSims.values());
653
+ const maxRawCosine = queryEmbedding && cosineSimValues.length > 0
654
+ ? Math.max(...cosineSimValues)
632
655
  : 1.0;
633
656
  // Required-channels for hard abstention:
634
657
  // abstention-explicit (caller passed abstentionThreshold > 0): 3 of 3
635
658
  // default: 2 of 3 — precision-first
636
659
  const requiredChannels = abstentionThreshold > 0 ? 3 : 2;
637
- // Hard abstention: fewer than required channels agree AND semantic drift is high
638
- if (channelsAgreeing < requiredChannels && maxRawCosine < (simMean + simStdDev * 1.5)) {
660
+ // Hard abstention: fewer than required channels agree AND semantic match weak.
661
+ // After the 2.0.x vector refactor we no longer compute z-score; threshold
662
+ // on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
663
+ const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
664
+ if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
639
665
  return [];
640
666
  }
641
667
  // Soft penalty: only 1 channel agrees or margin is thin
@@ -668,22 +694,119 @@ export class ActivationEngine {
668
694
  item.score *= 0.15; // Severe down-rank — successor should dominate
669
695
  }
670
696
  }
671
- // Phase 9: Final sort, limit, explain
672
- const results = rerankPool
673
- .sort((a, b) => b.score - a.score)
697
+ // Phase 9: Final sort, limit, explain, attach confidence
698
+ const finalRanked = rerankPool.sort((a, b) => b.score - a.score);
699
+ const topScoresForConfidence = finalRanked.slice(0, 10).map(r => r.score);
700
+ const { confidence } = computeRecallConfidence(topScoresForConfidence);
701
+ // Opt-in confidence-based abstention. When the caller sets
702
+ // `requireConfidence`, we return [] if the score-distribution shape
703
+ // indicates a low-quality recall (noisy or best-of-bad-bunch).
704
+ // Independent of the channel-agreement abstention earlier — that path
705
+ // requires the reranker; this one uses just the final composite scores.
706
+ if (requireConfidence > 0 && confidence < requireConfidence) {
707
+ return [];
708
+ }
709
+ // Confidence-adaptive output granularity (Paper 3: cognitive teaming).
710
+ // 'full' → no summary (default, current behavior).
711
+ // 'compact' → every result gets a short summary (COMPACT_LEN chars).
712
+ // 'auto' → if confidence ≥ AUTO_THRESHOLD: top result gets a full-length
713
+ // summary, lower-ranked results get compact summaries. If
714
+ // confidence is lower, all results get compact summaries.
715
+ const granularity = query.granularity ?? 'full';
716
+ const COMPACT_LEN = Number(process.env.AWM_GRANULARITY_COMPACT_LEN ?? 200);
717
+ const FULL_LEN = Number(process.env.AWM_GRANULARITY_FULL_LEN ?? 1000);
718
+ const AUTO_THRESHOLD = Number(process.env.AWM_GRANULARITY_AUTO_THRESHOLD ?? 0.4);
719
+ // Snippet token list — reuse the activation queryTokens (Set<string>),
720
+ // filtered to ≥2 chars to drop noise. queryTokens was tokenized at line
721
+ // 273 via tokenize() with stopword stripping already applied.
722
+ const snippetTokens = Array.from(queryTokens).filter(t => t.length >= 2);
723
+ // Find the densest window of `len` chars in `content` that contains the
724
+ // most query-token matches. Falls back to head if no tokens match.
725
+ const summaryFor = (content, len) => {
726
+ if (content.length <= len)
727
+ return content;
728
+ if (snippetTokens.length === 0)
729
+ return content.slice(0, len).trimEnd() + '…';
730
+ const lower = content.toLowerCase();
731
+ const hits = [];
732
+ for (const tok of snippetTokens) {
733
+ let from = 0;
734
+ while (true) {
735
+ const idx = lower.indexOf(tok, from);
736
+ if (idx < 0)
737
+ break;
738
+ hits.push(idx);
739
+ from = idx + tok.length;
740
+ }
741
+ }
742
+ if (hits.length === 0)
743
+ return content.slice(0, len).trimEnd() + '…';
744
+ hits.sort((a, b) => a - b);
745
+ // Find the window of size `len` that contains the most hits, by
746
+ // sliding a window anchored on each hit.
747
+ let bestStart = hits[0];
748
+ let bestCount = 0;
749
+ for (let i = 0; i < hits.length; i++) {
750
+ const start = Math.max(0, hits[i] - Math.floor(len / 4));
751
+ let count = 0;
752
+ for (let j = i; j < hits.length; j++) {
753
+ if (hits[j] - start < len)
754
+ count++;
755
+ else
756
+ break;
757
+ }
758
+ if (count > bestCount) {
759
+ bestCount = count;
760
+ bestStart = start;
761
+ }
762
+ }
763
+ // Reserve characters for the ellipses we're about to add so the final
764
+ // string stays within `len`. Without this, both '…' prefix + suffix
765
+ // would push the snippet to len+2 chars.
766
+ const hasPrefix = bestStart > 0;
767
+ const tentativeEnd = Math.min(content.length, bestStart + len);
768
+ const hasSuffix = tentativeEnd < content.length;
769
+ const reserveForEllipses = (hasPrefix ? 1 : 0) + (hasSuffix ? 1 : 0);
770
+ const bodyLen = Math.max(0, len - reserveForEllipses);
771
+ const end = Math.min(content.length, bestStart + bodyLen);
772
+ const startAdj = Math.max(0, end - bodyLen);
773
+ let snippet = content.slice(startAdj, end);
774
+ if (startAdj > 0)
775
+ snippet = '…' + snippet.trimStart();
776
+ if (end < content.length)
777
+ snippet = snippet.trimEnd() + '…';
778
+ return snippet;
779
+ };
780
+ const results = finalRanked
674
781
  .slice(0, limit)
675
- .map(r => ({
676
- engram: r.engram,
677
- score: r.score,
678
- phaseScores: r.phaseScores,
679
- why: this.explain(r.phaseScores, r.engram, r.associations),
680
- associations: r.associations,
681
- }));
782
+ .map((r, idx) => {
783
+ let summary;
784
+ if (granularity === 'compact') {
785
+ summary = summaryFor(r.engram.content, COMPACT_LEN);
786
+ }
787
+ else if (granularity === 'auto') {
788
+ if (confidence >= AUTO_THRESHOLD && idx === 0) {
789
+ summary = summaryFor(r.engram.content, FULL_LEN);
790
+ }
791
+ else {
792
+ summary = summaryFor(r.engram.content, COMPACT_LEN);
793
+ }
794
+ }
795
+ return {
796
+ engram: r.engram,
797
+ score: r.score,
798
+ phaseScores: r.phaseScores,
799
+ why: this.explain(r.phaseScores, r.engram, r.associations),
800
+ associations: r.associations,
801
+ confidence,
802
+ ...(summary !== undefined && { summary }),
803
+ };
804
+ });
682
805
  const activatedIds = results.map(r => r.engram.id);
683
806
  // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
684
807
  if (!query.internal) {
685
808
  for (const id of activatedIds) {
686
- this.store.touchEngram(id);
809
+ await this.store.touchEngram(id);
687
810
  }
688
811
  this.coActivationBuffer.pushBatch(activatedIds);
689
812
  // Validation-gated Hebbian: defer strengthening until feedback arrives
@@ -700,7 +823,7 @@ export class ActivationEngine {
700
823
  this.validationGate.addPending(activatedIds, uniquePairs);
701
824
  // Log activation event for eval
702
825
  const latencyMs = performance.now() - startTime;
703
- this.store.logActivationEvent({
826
+ await this.store.logActivationEvent({
704
827
  id: randomUUID(),
705
828
  agentId: query.agentId,
706
829
  timestamp: new Date(),
@@ -734,7 +857,7 @@ export class ActivationEngine {
734
857
  causal: 0.25, // causal edges (high-value signal)
735
858
  entity: 0.15, // bridge edges
736
859
  };
737
- graphWalk(scored, maxDepth, hopPenalty, beamWidth = 15) {
860
+ async graphWalk(scored, maxDepth, hopPenalty, beamWidth = 15) {
738
861
  const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
739
862
  const MAX_TOTAL_BOOST = 0.25;
740
863
  // Define which edge types belong to each sub-graph
@@ -763,7 +886,7 @@ export class ActivationEngine {
763
886
  explored.add(item.engram.id);
764
887
  const associations = item.associations.length > 0
765
888
  ? item.associations
766
- : this.store.getAssociationsFor(item.engram.id);
889
+ : await this.store.getAssociationsFor(item.engram.id);
767
890
  // Filter to only edges of this sub-graph type
768
891
  const relevantEdges = associations.filter(a => edgeTypes.includes(a.type));
769
892
  for (const assoc of relevantEdges) {
@@ -817,23 +940,23 @@ export class ActivationEngine {
817
940
  * Called by memory_feedback — only strengthens when retrieval was useful.
818
941
  * This prevents hub toxicity from noisy co-retrieval (Kairos-inspired).
819
942
  */
820
- resolveHebbianFeedback(engramId, useful) {
943
+ async resolveHebbianFeedback(engramId, useful) {
821
944
  const { pairs, signal } = this.validationGate.resolveFeedback(engramId, useful);
822
945
  let updated = 0;
823
946
  for (const [a, b] of pairs) {
824
- const existing = this.store.getAssociation(a, b) ?? this.store.getAssociation(b, a);
947
+ const existing = (await this.store.getAssociation(a, b)) ?? (await this.store.getAssociation(b, a));
825
948
  const currentWeight = existing?.weight ?? 0.1;
826
949
  if (signal > 0) {
827
950
  // Positive feedback → strengthen
828
951
  const newWeight = strengthenAssociation(currentWeight, signal);
829
- this.store.upsertAssociation(a, b, newWeight, 'hebbian');
830
- this.store.upsertAssociation(b, a, newWeight, 'hebbian');
952
+ await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
953
+ await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
831
954
  }
832
955
  else {
833
956
  // Negative feedback → slight weakening (decay by signal magnitude)
834
957
  const newWeight = Math.max(0.001, currentWeight * (1 + signal)); // signal is -0.3
835
- this.store.upsertAssociation(a, b, newWeight, 'hebbian');
836
- this.store.upsertAssociation(b, a, newWeight, 'hebbian');
958
+ await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
959
+ await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
837
960
  }
838
961
  updated++;
839
962
  }