agent-working-memory 0.7.16 → 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 (200) hide show
  1. package/README.md +156 -15
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +2 -16
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/codex.d.ts.map +1 -1
  6. package/dist/adapters/codex.js +2 -11
  7. package/dist/adapters/codex.js.map +1 -1
  8. package/dist/adapters/common.d.ts +18 -0
  9. package/dist/adapters/common.d.ts.map +1 -1
  10. package/dist/adapters/common.js +254 -141
  11. package/dist/adapters/common.js.map +1 -1
  12. package/dist/adapters/cursor.d.ts.map +1 -1
  13. package/dist/adapters/cursor.js +2 -15
  14. package/dist/adapters/cursor.js.map +1 -1
  15. package/dist/adapters/http.d.ts.map +1 -1
  16. package/dist/adapters/http.js +28 -34
  17. package/dist/adapters/http.js.map +1 -1
  18. package/dist/api/routes.d.ts +1 -1
  19. package/dist/api/routes.d.ts.map +1 -1
  20. package/dist/api/routes.js +280 -113
  21. package/dist/api/routes.js.map +1 -1
  22. package/dist/cli/migrate.d.ts +42 -0
  23. package/dist/cli/migrate.d.ts.map +1 -0
  24. package/dist/cli/migrate.js +268 -0
  25. package/dist/cli/migrate.js.map +1 -0
  26. package/dist/cli.js +45 -1
  27. package/dist/cli.js.map +1 -1
  28. package/dist/coordination/circuit-breaker.d.ts +24 -0
  29. package/dist/coordination/circuit-breaker.d.ts.map +1 -0
  30. package/dist/coordination/circuit-breaker.js +70 -0
  31. package/dist/coordination/circuit-breaker.js.map +1 -0
  32. package/dist/coordination/failure-modes.d.ts +21 -0
  33. package/dist/coordination/failure-modes.d.ts.map +1 -0
  34. package/dist/coordination/failure-modes.js +49 -0
  35. package/dist/coordination/failure-modes.js.map +1 -0
  36. package/dist/coordination/peer-decisions.js +7 -7
  37. package/dist/coordination/routes.d.ts.map +1 -1
  38. package/dist/coordination/routes.js +44 -1
  39. package/dist/coordination/routes.js.map +1 -1
  40. package/dist/coordination/schema.d.ts.map +1 -1
  41. package/dist/coordination/schema.js +18 -0
  42. package/dist/coordination/schema.js.map +1 -1
  43. package/dist/coordination/schemas.d.ts +9 -9
  44. package/dist/coordination/stale.d.ts +9 -1
  45. package/dist/coordination/stale.d.ts.map +1 -1
  46. package/dist/coordination/stale.js +53 -9
  47. package/dist/coordination/stale.js.map +1 -1
  48. package/dist/core/embeddings.d.ts +16 -10
  49. package/dist/core/embeddings.d.ts.map +1 -1
  50. package/dist/core/embeddings.js +54 -38
  51. package/dist/core/embeddings.js.map +1 -1
  52. package/dist/core/ml-worker-entry.d.ts +17 -0
  53. package/dist/core/ml-worker-entry.d.ts.map +1 -0
  54. package/dist/core/ml-worker-entry.js +193 -0
  55. package/dist/core/ml-worker-entry.js.map +1 -0
  56. package/dist/core/ml-worker.d.ts +59 -0
  57. package/dist/core/ml-worker.d.ts.map +1 -0
  58. package/dist/core/ml-worker.js +253 -0
  59. package/dist/core/ml-worker.js.map +1 -0
  60. package/dist/core/query-expander.d.ts +13 -15
  61. package/dist/core/query-expander.d.ts.map +1 -1
  62. package/dist/core/query-expander.js +48 -54
  63. package/dist/core/query-expander.js.map +1 -1
  64. package/dist/core/reranker.d.ts +9 -19
  65. package/dist/core/reranker.d.ts.map +1 -1
  66. package/dist/core/reranker.js +33 -35
  67. package/dist/core/reranker.js.map +1 -1
  68. package/dist/core/salience.d.ts +32 -5
  69. package/dist/core/salience.d.ts.map +1 -1
  70. package/dist/core/salience.js +201 -28
  71. package/dist/core/salience.js.map +1 -1
  72. package/dist/core/write-pipeline.d.ts +128 -0
  73. package/dist/core/write-pipeline.d.ts.map +1 -0
  74. package/dist/core/write-pipeline.js +405 -0
  75. package/dist/core/write-pipeline.js.map +1 -0
  76. package/dist/engine/activation.d.ts +2 -2
  77. package/dist/engine/activation.d.ts.map +1 -1
  78. package/dist/engine/activation.js +238 -115
  79. package/dist/engine/activation.js.map +1 -1
  80. package/dist/engine/confidence.d.ts +62 -0
  81. package/dist/engine/confidence.d.ts.map +1 -0
  82. package/dist/engine/confidence.js +100 -0
  83. package/dist/engine/confidence.js.map +1 -0
  84. package/dist/engine/connections.d.ts +47 -8
  85. package/dist/engine/connections.d.ts.map +1 -1
  86. package/dist/engine/connections.js +83 -22
  87. package/dist/engine/connections.js.map +1 -1
  88. package/dist/engine/consolidation-scheduler.d.ts +46 -10
  89. package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
  90. package/dist/engine/consolidation-scheduler.js +170 -50
  91. package/dist/engine/consolidation-scheduler.js.map +1 -1
  92. package/dist/engine/consolidation.d.ts +16 -2
  93. package/dist/engine/consolidation.d.ts.map +1 -1
  94. package/dist/engine/consolidation.js +143 -44
  95. package/dist/engine/consolidation.js.map +1 -1
  96. package/dist/engine/eval.d.ts +2 -2
  97. package/dist/engine/eval.d.ts.map +1 -1
  98. package/dist/engine/eval.js +9 -9
  99. package/dist/engine/eval.js.map +1 -1
  100. package/dist/engine/eviction.d.ts +4 -4
  101. package/dist/engine/eviction.d.ts.map +1 -1
  102. package/dist/engine/eviction.js +15 -15
  103. package/dist/engine/eviction.js.map +1 -1
  104. package/dist/engine/retraction.d.ts +93 -5
  105. package/dist/engine/retraction.d.ts.map +1 -1
  106. package/dist/engine/retraction.js +238 -19
  107. package/dist/engine/retraction.js.map +1 -1
  108. package/dist/engine/staging.d.ts +1 -1
  109. package/dist/engine/staging.d.ts.map +1 -1
  110. package/dist/engine/staging.js +3 -3
  111. package/dist/engine/staging.js.map +1 -1
  112. package/dist/hooks/sidecar.d.ts +1 -1
  113. package/dist/hooks/sidecar.d.ts.map +1 -1
  114. package/dist/hooks/sidecar.js +4 -4
  115. package/dist/hooks/sidecar.js.map +1 -1
  116. package/dist/index.js +48 -31
  117. package/dist/index.js.map +1 -1
  118. package/dist/mcp.js +110 -155
  119. package/dist/mcp.js.map +1 -1
  120. package/dist/storage/factory.d.ts +55 -0
  121. package/dist/storage/factory.d.ts.map +1 -0
  122. package/dist/storage/factory.js +135 -0
  123. package/dist/storage/factory.js.map +1 -0
  124. package/dist/storage/pglite-schema.d.ts +20 -0
  125. package/dist/storage/pglite-schema.d.ts.map +1 -0
  126. package/dist/storage/pglite-schema.js +165 -0
  127. package/dist/storage/pglite-schema.js.map +1 -0
  128. package/dist/storage/pglite.d.ts +187 -0
  129. package/dist/storage/pglite.d.ts.map +1 -0
  130. package/dist/storage/pglite.js +1114 -0
  131. package/dist/storage/pglite.js.map +1 -0
  132. package/dist/storage/sqlite.d.ts +117 -0
  133. package/dist/storage/sqlite.d.ts.map +1 -1
  134. package/dist/storage/sqlite.js +357 -8
  135. package/dist/storage/sqlite.js.map +1 -1
  136. package/dist/storage/store.d.ts +63 -0
  137. package/dist/storage/store.d.ts.map +1 -0
  138. package/dist/storage/store.js +27 -0
  139. package/dist/storage/store.js.map +1 -0
  140. package/dist/types/engram.d.ts +92 -2
  141. package/dist/types/engram.d.ts.map +1 -1
  142. package/dist/types/engram.js.map +1 -1
  143. package/package.json +3 -1
  144. package/src/adapters/claude-code.ts +218 -234
  145. package/src/adapters/codex.ts +252 -262
  146. package/src/adapters/common.ts +411 -284
  147. package/src/adapters/cursor.ts +135 -150
  148. package/src/adapters/http.ts +93 -100
  149. package/src/adapters/index.ts +31 -31
  150. package/src/adapters/types.ts +75 -75
  151. package/src/api/index.ts +3 -3
  152. package/src/api/routes.ts +367 -120
  153. package/src/cli/migrate.ts +307 -0
  154. package/src/cli.ts +37 -1
  155. package/src/coordination/circuit-breaker.ts +83 -0
  156. package/src/coordination/events.ts +90 -90
  157. package/src/coordination/failure-modes.ts +50 -0
  158. package/src/coordination/peer-decisions.ts +105 -105
  159. package/src/coordination/plugin-loader.ts +60 -60
  160. package/src/coordination/plugin.ts +44 -44
  161. package/src/coordination/routes.ts +56 -1
  162. package/src/coordination/schema.ts +13 -0
  163. package/src/coordination/stale.ts +80 -11
  164. package/src/coordination/types.ts +311 -311
  165. package/src/coordination/write-mutex.ts +69 -69
  166. package/src/core/auto-tagger.ts +168 -168
  167. package/src/core/decay.ts +63 -63
  168. package/src/core/embeddings.ts +110 -93
  169. package/src/core/index.ts +5 -5
  170. package/src/core/logger.ts +36 -36
  171. package/src/core/ml-worker-entry.ts +194 -0
  172. package/src/core/ml-worker.ts +281 -0
  173. package/src/core/query-expander.ts +122 -128
  174. package/src/core/reranker.ts +119 -125
  175. package/src/core/salience.ts +200 -33
  176. package/src/core/write-pipeline.ts +521 -0
  177. package/src/engine/activation.ts +235 -109
  178. package/src/engine/confidence.ts +120 -0
  179. package/src/engine/connections.ts +162 -103
  180. package/src/engine/consolidation-scheduler.ts +242 -125
  181. package/src/engine/consolidation.ts +138 -45
  182. package/src/engine/eval.ts +102 -102
  183. package/src/engine/eviction.ts +101 -101
  184. package/src/engine/index.ts +8 -8
  185. package/src/engine/retraction.ts +366 -116
  186. package/src/engine/staging.ts +74 -74
  187. package/src/hooks/sidecar.ts +5 -5
  188. package/src/index.ts +42 -28
  189. package/src/mcp.ts +105 -152
  190. package/src/storage/factory.ts +147 -0
  191. package/src/storage/index.ts +3 -3
  192. package/src/storage/pglite-schema.ts +166 -0
  193. package/src/storage/pglite.ts +1363 -0
  194. package/src/storage/sqlite.ts +402 -7
  195. package/src/storage/store.ts +80 -0
  196. package/src/types/agent.ts +67 -67
  197. package/src/types/checkpoint.ts +46 -46
  198. package/src/types/engram.ts +101 -3
  199. package/src/types/eval.ts +100 -100
  200. package/src/types/index.ts +6 -6
@@ -25,10 +25,11 @@ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from
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';
28
+ import { computeRecallConfidence } from './confidence.js';
28
29
  import type {
29
30
  Engram, ActivationResult, ActivationQuery, Association, PhaseScores, QueryMode,
30
31
  } from '../types/index.js';
31
- import type { EngramStore } from '../storage/sqlite.js';
32
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
32
33
 
33
34
  // ─── Query-adaptive pipeline parameters ───────────────────────────
34
35
 
@@ -181,11 +182,12 @@ export class ActivationEngine {
181
182
  const useReranker = query.useReranker ?? true;
182
183
  const useExpansion = query.useExpansion ?? true;
183
184
  const abstentionThreshold = query.abstentionThreshold ?? 0;
185
+ const requireConfidence = query.requireConfidence ?? 0;
184
186
  const adaptive = resolveAdaptiveParams(query);
185
187
 
186
188
  // Resolve workspace scope: if workspace is set, search across all agents in that workspace
187
189
  const agentIds = query.workspace
188
- ? this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
190
+ ? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
189
191
  : [query.agentId];
190
192
  const isWorkspaceScoped = agentIds.length > 1;
191
193
 
@@ -194,7 +196,7 @@ export class ActivationEngine {
194
196
  const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
195
197
  if (pronounPattern.test(queryContext)) {
196
198
  try {
197
- const recentEntities = this.store.getEngramsByAgents(agentIds, 'active')
199
+ const recentEntities = (await this.store.getEngramsByAgents(agentIds, 'active'))
198
200
  .sort((a, b) => b.accessCount - a.accessCount)
199
201
  .slice(0, 10)
200
202
  .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
@@ -234,9 +236,9 @@ export class ActivationEngine {
234
236
  // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
235
237
  const keywordQuery = Array.from(tokenize(query.context)).join(' ');
236
238
  const bm25Keyword = keywordQuery.length > 2
237
- ? this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
239
+ ? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
238
240
  : [];
239
- const bm25Expanded = this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
241
+ const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
240
242
 
241
243
  // Merge: take the best BM25 score per engram from either pass
242
244
  const bm25ScoreMap = new Map<string, number>();
@@ -257,78 +259,73 @@ export class ActivationEngine {
257
259
  // Used for cosine sim + adaptive z-score stats + cheap pool filter.
258
260
  // Pass 2: full fetch ONLY on the survivors that pass the filter.
259
261
  //
260
- // Why: phase-breakdown spike (2026-05-08, post-0.7.7) showed `SELECT * FROM
261
- // engrams WHERE agent_id = ?` over 10K rows costs 440ms (40% of recall) due
262
- // to row materialization of content/tags/JSON-blob columns the filter pass
263
- // doesn't read. Slim fetch trims the per-row payload to the three fields
264
- // the filter actually uses.
265
- const slimActive = this.store.getEngramsByAgentsSlim(
266
- agentIds,
267
- query.includeStaging ? undefined : 'active',
268
- query.includeRetracted ?? false
269
- );
270
-
271
- // Tokenize query once (used by filter + scoring)
262
+ // AWM 0.8.x Native vector search refactor (2026-05-25): the prior slim
263
+ // fetch path materialized ALL active engrams (id + concept + embedding)
264
+ // for in-process cosine + z-score gating. On PGlite that meant parsing
265
+ // 11K embedding vectors per recall (~200-500ms). Replaced with native
266
+ // vector search: PGlite uses pgvector + ivfflat (O(log N)); SQLite uses
267
+ // its slim cache + JS cosine (same as before but encapsulated). Z-score
268
+ // normalization is replaced with a mode-adaptive raw-cosine floor —
269
+ // simpler, faster, model-tuned for BGE-small embeddings.
270
+ const VECTOR_TOP_K = Math.max(50, limit * 5);
271
+
272
+ // Tokenize query once (used by scoring)
272
273
  const queryTokens = tokenize(query.context);
273
274
 
274
- // Phase 3a: Compute raw cosine similarities on slim pool for adaptive normalization
275
+ // Phase 3a: native vector search across agents top-K by cosine.
276
+ // Apply a candidate floor — BGE-small unit-norm vectors typically cluster
277
+ // around 0.30-0.40 even for unrelated text, so we need a floor that
278
+ // distinguishes "related" from "noise" without throwing out genuine
279
+ // related-but-not-identical matches.
280
+ //
281
+ // Tuning: targeted=0.40, exploratory=0.30. Earlier 0.55/0.45 floors were
282
+ // too aggressive — they dropped Recall@5 on the 200-fact eval corpus
283
+ // from 0.80 → 0.46 (verified 2026-05-26). BGE-small cosines for genuine
284
+ // related matches commonly land 0.42-0.55, so a 0.55 floor cut them
285
+ // entirely. The vectorMatch scoring floor (0.50 targeted / 0.35 exploratory)
286
+ // still suppresses low-confidence matches in the final score.
287
+ // Env override: AWM_SIM_CANDIDATE_FLOOR_TARGETED, AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY.
288
+ const SIM_CANDIDATE_FLOOR = adaptive.zScoreGate > 0.5
289
+ ? Number(process.env.AWM_SIM_CANDIDATE_FLOOR_TARGETED ?? 0.40)
290
+ : Number(process.env.AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY ?? 0.30);
275
291
  const rawCosineSims = new Map<string, number>();
292
+ const vectorHits: Engram[] = [];
276
293
  if (queryEmbedding) {
277
- for (const e of slimActive) {
278
- if (e.embedding) {
279
- rawCosineSims.set(e.id, cosineSimilarity(queryEmbedding, e.embedding));
294
+ for (const aid of agentIds) {
295
+ try {
296
+ const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
297
+ for (const h of hits) {
298
+ // pgvector cosine distance: 0 = identical, 2 = opposite.
299
+ // For unit-norm BGE vectors, distance ≈ 1 - cosineSimilarity.
300
+ const sim = 1 - h.distance;
301
+ // pgvector returns sorted ASC by distance (DESC by sim) — break once
302
+ // we drop below the candidate floor; all subsequent hits will too.
303
+ if (sim < SIM_CANDIDATE_FLOOR) break;
304
+ if (!rawCosineSims.has(h.engram.id)) {
305
+ rawCosineSims.set(h.engram.id, sim);
306
+ vectorHits.push(h.engram);
307
+ }
308
+ }
309
+ } catch {
310
+ // Vector search unavailable — fall back to BM25-only ranking
280
311
  }
281
312
  }
282
313
  }
283
314
 
284
- // Compute distribution stats for model-agnostic normalization
285
- const simValues = Array.from(rawCosineSims.values());
286
- const simMean = simValues.length > 0
287
- ? simValues.reduce((a, b) => a + b, 0) / simValues.length : 0;
288
- const rawStdDev = simValues.length > 1
289
- ? Math.sqrt(simValues.reduce((sum, s) => sum + (s - simMean) ** 2, 0) / simValues.length) : 0.15;
290
- // Floor stddev at 0.10 to prevent z-score inflation with small candidate pools
291
- const simStdDev = Math.max(rawStdDev, 0.10);
292
-
293
- // Determine survivor IDs from the slim pool using the same survival criteria
294
- // (BM25 hit / cosine z-score / concept-jaccard) plus all BM25-ranked candidates
295
- // (which already came in fully-hydrated and may not be in slimActive's stage filter).
296
- const poolFilterEnabled = process.env.AWM_DISABLE_POOL_FILTER !== '1';
315
+ // Survivors = BM25 candidates vector candidates.
316
+ // The slim-fetch jaccard-fallback path is dropped: in practice it surfaced
317
+ // <1% of candidates that BM25 + vector missed, and on PGlite cost more
318
+ // than the candidates were worth. Concept jaccard signal still contributes
319
+ // via textMatch in scoring.
297
320
  const survivorIds = new Set<string>();
298
- // Always include BM25-ranked engrams (they came pre-hydrated)
299
321
  for (const r of bm25Ranked) survivorIds.add(r.engram.id);
322
+ for (const e of vectorHits) survivorIds.add(e.id);
300
323
 
301
- if (poolFilterEnabled) {
302
- for (const e of slimActive) {
303
- if (survivorIds.has(e.id)) continue;
304
- const bm25 = bm25ScoreMap.get(e.id) ?? 0;
305
- if (bm25 > 0) { survivorIds.add(e.id); continue; }
306
- const sim = rawCosineSims.get(e.id);
307
- if (sim !== undefined) {
308
- const z = (sim - simMean) / simStdDev;
309
- if (z > adaptive.zScoreGate) { survivorIds.add(e.id); continue; }
310
- }
311
- // Cheap concept jaccard
312
- const ct = tokenize(e.concept);
313
- if (ct.size === 0) continue;
314
- let overlap = 0;
315
- for (const w of ct) if (queryTokens.has(w)) overlap++;
316
- if (overlap > 0) survivorIds.add(e.id);
317
- }
318
- } else {
319
- // Filter disabled — include all slim active engrams
320
- for (const e of slimActive) survivorIds.add(e.id);
321
- }
322
-
323
- // Pass 2: hydrate full Engram rows ONLY for survivors that aren't already loaded
324
+ // Hydrate full engrams. BM25 and vector hits arrive pre-hydrated; nothing
325
+ // else needs fetching in the normal path.
324
326
  const candidateMap = new Map<string, Engram>();
325
327
  for (const r of bm25Ranked) candidateMap.set(r.engram.id, r.engram);
326
- const idsToHydrate = Array.from(survivorIds).filter(id => !candidateMap.has(id));
327
- if (idsToHydrate.length > 0) {
328
- for (const e of this.store.getEngramsByIds(idsToHydrate)) {
329
- candidateMap.set(e.id, e);
330
- }
331
- }
328
+ for (const e of vectorHits) candidateMap.set(e.id, e);
332
329
  let candidates = Array.from(candidateMap.values());
333
330
 
334
331
  // Filter by memory type if specified
@@ -349,7 +346,7 @@ export class ActivationEngine {
349
346
  // Graph walk still needs full Association objects, but it operates on the
350
347
  // top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
351
348
  // cheap (<5ms total).
352
- const assocStats = this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
349
+ const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
353
350
  const scored = candidates.map(engram => {
354
351
  const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
355
352
  const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
@@ -375,14 +372,16 @@ export class ActivationEngine {
375
372
  const keywordMatch = Math.min(Math.max(bm25Score, jaccardScore) + conceptBonus, 1.0);
376
373
 
377
374
  // --- Vector similarity (semantic signal) ---
378
- // Two-stage: absolute floor prevents noise, then z-score ranks within matches.
379
- // z-gate adapts to query mode: targeted uses strict gate (0.8), exploratory relaxes (0.3).
375
+ // AWM 0.8.x: model-tuned raw-cosine floor in place of z-score normalization.
376
+ // For BGE-small unit-norm vectors: unrelated ~0.3, related 0.5-0.7, near-duplicate 0.85+.
377
+ // Floor adapts to query mode: targeted=0.50 (stricter), exploratory=0.35 (looser).
380
378
  let vectorMatch = 0;
381
379
  const rawSim = rawCosineSims.get(engram.id);
382
- if (rawSim !== undefined) {
383
- const zScore = (rawSim - simMean) / simStdDev;
384
- if (zScore > adaptive.zScoreGate) {
385
- vectorMatch = Math.min(1, (zScore - adaptive.zScoreGate) / 2.0);
380
+ if (rawSim !== undefined && rawSim > 0) {
381
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
382
+ if (rawSim > SIM_FLOOR) {
383
+ // Map [SIM_FLOOR, 1.0] → [0, 1] linearly with cap at 1.0.
384
+ vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
386
385
  }
387
386
  }
388
387
 
@@ -462,14 +461,14 @@ export class ActivationEngine {
462
461
  // Take top 5 feedback terms and re-search
463
462
  const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
464
463
  if (extraTerms) {
465
- const feedbackBM25 = this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
464
+ const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
466
465
  for (const r of feedbackBM25) {
467
466
  if (!candidateMap.has(r.engram.id)) {
468
467
  candidateMap.set(r.engram.id, r.engram);
469
468
  // Score the new candidate
470
469
  const engram = r.engram;
471
470
  const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
472
- const associations = this.store.getAssociationsFor(engram.id);
471
+ const associations = await this.store.getAssociationsFor(engram.id);
473
472
  const cTokens = tokenize(engram.concept);
474
473
  const ctTokens = tokenize(engram.content);
475
474
  const cJac = jaccard(queryTokens, cTokens);
@@ -479,9 +478,9 @@ export class ActivationEngine {
479
478
  const km = Math.min(Math.max(r.bm25Score, jSc) + cOvlp * 0.3, 1.0);
480
479
  let vm = 0;
481
480
  const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
482
- if (rs) {
483
- const z = (rs - simMean) / simStdDev;
484
- if (z > adaptive.zScoreGate) vm = Math.min(1, (z - adaptive.zScoreGate) / 2.0);
481
+ if (rs > 0) {
482
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
483
+ if (rs > SIM_FLOOR) vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
485
484
  }
486
485
  const tm = km > 0 && vm > 0
487
486
  ? 0.5 * Math.max(km, vm) + 0.3 * Math.min(km, vm) + 0.2 * (km * vm)
@@ -498,8 +497,19 @@ export class ActivationEngine {
498
497
  }
499
498
 
500
499
  // Phase 3.7: Entity-Bridge boost — boost scored candidates that share entity tags
501
- // with the most query-relevant result. Only bridge from the single best text-match
502
- // to avoid pulling in unrelated entities from tangentially-matching results.
500
+ // with the most query-relevant result. The original intent: surface candidates
501
+ // that DON'T match the query text directly but share entities with the top
502
+ // text-match anchor ("she said something" → bridge to entities of the recent
503
+ // speaker named "she"). This is LATERAL relevance, not direct relevance.
504
+ //
505
+ // 2026-05-26: Added textMatch gate. Without it, when many engrams share entity
506
+ // tags AND have similar text matches (e.g., 10 near-clones of the same concept),
507
+ // the bridge boost would push the 8 non-anchor clones above the 2 anchors —
508
+ // an inversion of the genuine top match. The eval Retrieval suite caught this
509
+ // (Recall@5 0.80 → 0.46). Gate: only boost candidates whose textMatch is
510
+ // meaningfully below the anchor's. They actually need the lateral boost.
511
+ // Env override: AWM_DISABLE_ENTITY_BRIDGE=1 to skip this phase entirely.
512
+ if (!process.env.AWM_DISABLE_ENTITY_BRIDGE)
503
513
  {
504
514
  // Find the result with the highest textMatch (most query-relevant, not just highest score)
505
515
  // Gate: only bridge when anchor has meaningful text relevance (> 0.15)
@@ -547,6 +557,17 @@ export class ActivationEngine {
547
557
  }
548
558
 
549
559
  if (entityTags.size > 0) {
560
+ // Anchor's textMatch sets the scale. Bridge boost magnitude is
561
+ // proportional to the gap between anchor and candidate textMatch:
562
+ // - candidate near anchor (a near-clone of the anchor) → small gap → near-zero boost
563
+ // - candidate far below anchor (genuine lateral relevance) → large gap → full boost
564
+ // Without this scaling, dense same-concept corpora flip the genuine
565
+ // top-1 below its 8-9 near-clones (eval Recall@5 0.80 → 0.46 verified
566
+ // 2026-05-26). The scaling keeps the lateral-relevance behavior
567
+ // (which is what helps the AB test) without inverting the genuine
568
+ // text-match winner.
569
+ const anchorTextMax = bridgeAnchors[0].phaseScores.textMatch;
570
+
550
571
  for (const item of scored) {
551
572
  if (anchorIds.has(item.engram.id)) continue;
552
573
 
@@ -557,11 +578,17 @@ export class ActivationEngine {
557
578
  }
558
579
 
559
580
  if (sharedEntities > 0) {
560
- // Flat bridge boost per shared entity
561
- const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4);
562
- item.score += bridgeBoost;
563
- item.phaseScores.composite += bridgeBoost;
564
- item.phaseScores.graphBoost += bridgeBoost;
581
+ // Gap scaling: 1.0 when candidateText << anchorText, 0 when equal.
582
+ // Clamped to [0, 1]. Anchors with textMatch ≤ 0 fall back to flat boost.
583
+ const gapScale = anchorTextMax > 0
584
+ ? Math.max(0, Math.min(1, (anchorTextMax - item.phaseScores.textMatch) / anchorTextMax))
585
+ : 1;
586
+ const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4) * gapScale;
587
+ if (bridgeBoost > 0) {
588
+ item.score += bridgeBoost;
589
+ item.phaseScores.composite += bridgeBoost;
590
+ item.phaseScores.graphBoost += bridgeBoost;
591
+ }
565
592
  }
566
593
  }
567
594
  }
@@ -572,7 +599,7 @@ export class ActivationEngine {
572
599
  // Only walk from engrams that had text relevance (composite > 0 pre-walk)
573
600
  const sorted = scored.sort((a, b) => b.score - a.score);
574
601
  const topN = sorted.slice(0, limit * 3);
575
- this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
602
+ await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
576
603
 
577
604
  // Phase 6: Initial filter and sort for re-ranking pool
578
605
  const pool = topN
@@ -668,8 +695,9 @@ export class ActivationEngine {
668
695
  ? rerankerScores[0] - rerankerScores[1]
669
696
  : rerankerScores[0];
670
697
 
671
- const maxRawCosine = queryEmbedding && simValues.length > 0
672
- ? Math.max(...simValues)
698
+ const cosineSimValues = Array.from(rawCosineSims.values());
699
+ const maxRawCosine = queryEmbedding && cosineSimValues.length > 0
700
+ ? Math.max(...cosineSimValues)
673
701
  : 1.0;
674
702
 
675
703
  // Required-channels for hard abstention:
@@ -677,8 +705,11 @@ export class ActivationEngine {
677
705
  // default: 2 of 3 — precision-first
678
706
  const requiredChannels = abstentionThreshold > 0 ? 3 : 2;
679
707
 
680
- // Hard abstention: fewer than required channels agree AND semantic drift is high
681
- if (channelsAgreeing < requiredChannels && maxRawCosine < (simMean + simStdDev * 1.5)) {
708
+ // Hard abstention: fewer than required channels agree AND semantic match weak.
709
+ // After the 2.0.x vector refactor we no longer compute z-score; threshold
710
+ // on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
711
+ const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
712
+ if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
682
713
  return [];
683
714
  }
684
715
 
@@ -716,24 +747,119 @@ export class ActivationEngine {
716
747
  }
717
748
  }
718
749
 
719
- // Phase 9: Final sort, limit, explain
720
- const results: ActivationResult[] = rerankPool
721
- .sort((a, b) => b.score - a.score)
750
+ // Phase 9: Final sort, limit, explain, attach confidence
751
+ const finalRanked = rerankPool.sort((a, b) => b.score - a.score);
752
+ const topScoresForConfidence = finalRanked.slice(0, 10).map(r => r.score);
753
+ const { confidence } = computeRecallConfidence(topScoresForConfidence);
754
+
755
+ // Opt-in confidence-based abstention. When the caller sets
756
+ // `requireConfidence`, we return [] if the score-distribution shape
757
+ // indicates a low-quality recall (noisy or best-of-bad-bunch).
758
+ // Independent of the channel-agreement abstention earlier — that path
759
+ // requires the reranker; this one uses just the final composite scores.
760
+ if (requireConfidence > 0 && confidence < requireConfidence) {
761
+ return [];
762
+ }
763
+
764
+ // Confidence-adaptive output granularity (Paper 3: cognitive teaming).
765
+ // 'full' → no summary (default, current behavior).
766
+ // 'compact' → every result gets a short summary (COMPACT_LEN chars).
767
+ // 'auto' → if confidence ≥ AUTO_THRESHOLD: top result gets a full-length
768
+ // summary, lower-ranked results get compact summaries. If
769
+ // confidence is lower, all results get compact summaries.
770
+ const granularity = query.granularity ?? 'full';
771
+ const COMPACT_LEN = Number(process.env.AWM_GRANULARITY_COMPACT_LEN ?? 200);
772
+ const FULL_LEN = Number(process.env.AWM_GRANULARITY_FULL_LEN ?? 1000);
773
+ const AUTO_THRESHOLD = Number(process.env.AWM_GRANULARITY_AUTO_THRESHOLD ?? 0.4);
774
+
775
+ // Snippet token list — reuse the activation queryTokens (Set<string>),
776
+ // filtered to ≥2 chars to drop noise. queryTokens was tokenized at line
777
+ // 273 via tokenize() with stopword stripping already applied.
778
+ const snippetTokens = Array.from(queryTokens).filter(t => t.length >= 2);
779
+
780
+ // Find the densest window of `len` chars in `content` that contains the
781
+ // most query-token matches. Falls back to head if no tokens match.
782
+ const summaryFor = (content: string, len: number): string => {
783
+ if (content.length <= len) return content;
784
+ if (snippetTokens.length === 0) return content.slice(0, len).trimEnd() + '…';
785
+
786
+ const lower = content.toLowerCase();
787
+ const hits: number[] = [];
788
+ for (const tok of snippetTokens) {
789
+ let from = 0;
790
+ while (true) {
791
+ const idx = lower.indexOf(tok, from);
792
+ if (idx < 0) break;
793
+ hits.push(idx);
794
+ from = idx + tok.length;
795
+ }
796
+ }
797
+ if (hits.length === 0) return content.slice(0, len).trimEnd() + '…';
798
+ hits.sort((a, b) => a - b);
799
+
800
+ // Find the window of size `len` that contains the most hits, by
801
+ // sliding a window anchored on each hit.
802
+ let bestStart = hits[0];
803
+ let bestCount = 0;
804
+ for (let i = 0; i < hits.length; i++) {
805
+ const start = Math.max(0, hits[i] - Math.floor(len / 4));
806
+ let count = 0;
807
+ for (let j = i; j < hits.length; j++) {
808
+ if (hits[j] - start < len) count++;
809
+ else break;
810
+ }
811
+ if (count > bestCount) {
812
+ bestCount = count;
813
+ bestStart = start;
814
+ }
815
+ }
816
+
817
+ // Reserve characters for the ellipses we're about to add so the final
818
+ // string stays within `len`. Without this, both '…' prefix + suffix
819
+ // would push the snippet to len+2 chars.
820
+ const hasPrefix = bestStart > 0;
821
+ const tentativeEnd = Math.min(content.length, bestStart + len);
822
+ const hasSuffix = tentativeEnd < content.length;
823
+ const reserveForEllipses = (hasPrefix ? 1 : 0) + (hasSuffix ? 1 : 0);
824
+ const bodyLen = Math.max(0, len - reserveForEllipses);
825
+ const end = Math.min(content.length, bestStart + bodyLen);
826
+ const startAdj = Math.max(0, end - bodyLen);
827
+ let snippet = content.slice(startAdj, end);
828
+ if (startAdj > 0) snippet = '…' + snippet.trimStart();
829
+ if (end < content.length) snippet = snippet.trimEnd() + '…';
830
+ return snippet;
831
+ };
832
+
833
+ const results: ActivationResult[] = finalRanked
722
834
  .slice(0, limit)
723
- .map(r => ({
724
- engram: r.engram,
725
- score: r.score,
726
- phaseScores: r.phaseScores,
727
- why: this.explain(r.phaseScores, r.engram, r.associations),
728
- associations: r.associations,
729
- }));
835
+ .map((r, idx) => {
836
+ let summary: string | undefined;
837
+ if (granularity === 'compact') {
838
+ summary = summaryFor(r.engram.content, COMPACT_LEN);
839
+ } else if (granularity === 'auto') {
840
+ if (confidence >= AUTO_THRESHOLD && idx === 0) {
841
+ summary = summaryFor(r.engram.content, FULL_LEN);
842
+ } else {
843
+ summary = summaryFor(r.engram.content, COMPACT_LEN);
844
+ }
845
+ }
846
+ return {
847
+ engram: r.engram,
848
+ score: r.score,
849
+ phaseScores: r.phaseScores,
850
+ why: this.explain(r.phaseScores, r.engram, r.associations),
851
+ associations: r.associations,
852
+ confidence,
853
+ ...(summary !== undefined && { summary }),
854
+ };
855
+ });
730
856
 
731
857
  const activatedIds = results.map(r => r.engram.id);
732
858
 
733
859
  // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
734
860
  if (!query.internal) {
735
861
  for (const id of activatedIds) {
736
- this.store.touchEngram(id);
862
+ await this.store.touchEngram(id);
737
863
  }
738
864
  this.coActivationBuffer.pushBatch(activatedIds);
739
865
  // Validation-gated Hebbian: defer strengthening until feedback arrives
@@ -748,7 +874,7 @@ export class ActivationEngine {
748
874
 
749
875
  // Log activation event for eval
750
876
  const latencyMs = performance.now() - startTime;
751
- this.store.logActivationEvent({
877
+ await this.store.logActivationEvent({
752
878
  id: randomUUID(),
753
879
  agentId: query.agentId,
754
880
  timestamp: new Date(),
@@ -785,12 +911,12 @@ export class ActivationEngine {
785
911
  entity: 0.15, // bridge edges
786
912
  };
787
913
 
788
- private graphWalk(
914
+ private async graphWalk(
789
915
  scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
790
916
  maxDepth: number,
791
917
  hopPenalty: number,
792
918
  beamWidth: number = 15
793
- ): void {
919
+ ): Promise<void> {
794
920
  const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
795
921
  const MAX_TOTAL_BOOST = 0.25;
796
922
 
@@ -826,7 +952,7 @@ export class ActivationEngine {
826
952
 
827
953
  const associations = item.associations.length > 0
828
954
  ? item.associations
829
- : this.store.getAssociationsFor(item.engram.id);
955
+ : await this.store.getAssociationsFor(item.engram.id);
830
956
 
831
957
  // Filter to only edges of this sub-graph type
832
958
  const relevantEdges = associations.filter(a => edgeTypes.includes(a.type));
@@ -885,24 +1011,24 @@ export class ActivationEngine {
885
1011
  * Called by memory_feedback — only strengthens when retrieval was useful.
886
1012
  * This prevents hub toxicity from noisy co-retrieval (Kairos-inspired).
887
1013
  */
888
- resolveHebbianFeedback(engramId: string, useful: boolean): number {
1014
+ async resolveHebbianFeedback(engramId: string, useful: boolean): Promise<number> {
889
1015
  const { pairs, signal } = this.validationGate.resolveFeedback(engramId, useful);
890
1016
  let updated = 0;
891
1017
 
892
1018
  for (const [a, b] of pairs) {
893
- const existing = this.store.getAssociation(a, b) ?? this.store.getAssociation(b, a);
1019
+ const existing = (await this.store.getAssociation(a, b)) ?? (await this.store.getAssociation(b, a));
894
1020
  const currentWeight = existing?.weight ?? 0.1;
895
1021
 
896
1022
  if (signal > 0) {
897
1023
  // Positive feedback → strengthen
898
1024
  const newWeight = strengthenAssociation(currentWeight, signal);
899
- this.store.upsertAssociation(a, b, newWeight, 'hebbian');
900
- this.store.upsertAssociation(b, a, newWeight, 'hebbian');
1025
+ await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
1026
+ await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
901
1027
  } else {
902
1028
  // Negative feedback → slight weakening (decay by signal magnitude)
903
1029
  const newWeight = Math.max(0.001, currentWeight * (1 + signal)); // signal is -0.3
904
- this.store.upsertAssociation(a, b, newWeight, 'hebbian');
905
- this.store.upsertAssociation(b, a, newWeight, 'hebbian');
1030
+ await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
1031
+ await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
906
1032
  }
907
1033
  updated++;
908
1034
  }
@@ -0,0 +1,120 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Retrieval confidence — score-distribution-aware signal that complements
5
+ * the per-result `score`. The shape of the result set carries information
6
+ * the raw scores do not:
7
+ *
8
+ * - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
9
+ * - Noisy recall: many similar scores, flat distribution, weak floor.
10
+ * - "Best of bad bunch": sharp cliff but the cliff sits below a usable
11
+ * floor — the system found a winner among uninteresting candidates.
12
+ *
13
+ * Research grounding:
14
+ * - Geifman & El-Yaniv, "Selective Classification for Deep Neural
15
+ * Networks" (NeurIPS 2017): abstaining improves precision on confused
16
+ * inputs more than recalibrating thresholds.
17
+ * - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
18
+ * score-distribution shape predicts retrieval quality better than
19
+ * top-1 score in isolation.
20
+ * - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
21
+ * Lectures, 2010): post-retrieval predictors — sharpness, depth of
22
+ * score drop — correlate with TREC topic difficulty.
23
+ *
24
+ * AWM 0.8.5 integration: confidence is computed once per recall after
25
+ * final scoring and attached to every `ActivationResult`. Consumers may
26
+ * use it however they like (display, abstention, paired retrieval).
27
+ * Default behavior of recall is unchanged — confidence is data, not a
28
+ * gate, in PR-1.
29
+ *
30
+ * Configurable via env vars (initial weights tuned to favour sharpness):
31
+ * AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
32
+ * AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
33
+ * AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
34
+ */
35
+
36
+ export interface RecallConfidence {
37
+ /** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
38
+ confidence: number;
39
+ /** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
40
+ sharpness: number;
41
+ /** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
42
+ cliff: number;
43
+ /** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
44
+ floor: number;
45
+ }
46
+
47
+ const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
48
+ const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
49
+ const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
50
+
51
+ /**
52
+ * Compute recall confidence from an ordered (descending) array of result scores.
53
+ *
54
+ * Returns a confidence near 0 when:
55
+ * - Empty result set (no winner)
56
+ * - Flat distribution (sharpness ~1, cliff ~0)
57
+ * - Low absolute scores (floor low — "best of bad bunch")
58
+ *
59
+ * Returns a confidence near 1 when:
60
+ * - top-1 dominates (sharpness >> 1)
61
+ * - Sharp cliff after top-1 (cliff close to 1)
62
+ * - top-1 is itself a strong absolute match (floor close to 1)
63
+ *
64
+ * Edge cases:
65
+ * - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
66
+ * to dominate). Confidence anchored entirely by floor.
67
+ * - 0 results: all zero, confidence = 0.
68
+ */
69
+ export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
70
+ if (scoresDesc.length === 0) {
71
+ return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
72
+ }
73
+
74
+ const top1 = scoresDesc[0];
75
+
76
+ // Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
77
+ // range under normal use, but be defensive.
78
+ const floor = Math.max(0, Math.min(1, top1));
79
+
80
+ // Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
81
+ let sharpness = 0;
82
+ if (scoresDesc.length >= 2) {
83
+ const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
84
+ const mean = window.reduce((s, v) => s + v, 0) / window.length;
85
+ if (mean > 0) {
86
+ const ratio = top1 / mean; // typically in [1, K]
87
+ sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
88
+ }
89
+ }
90
+
91
+ // Cliff: how steep is the drop from top-1 to the K-th candidate?
92
+ // Use top-10 (or last available). If only 1 result, no cliff to measure.
93
+ let cliff = 0;
94
+ if (scoresDesc.length >= 2 && top1 > 0) {
95
+ const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
96
+ cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
97
+ }
98
+
99
+ // Geometric blend — any near-zero component pulls confidence down.
100
+ // Add a tiny epsilon so log/zero doesn't collapse the whole signal when
101
+ // a result is genuinely sharp but the cliff is computed off only 2-3
102
+ // candidates (cliff small even for confident recalls).
103
+ const EPS = 0.05;
104
+ const s = sharpness + EPS;
105
+ const c = cliff + EPS;
106
+ const f = floor + EPS;
107
+
108
+ // Weighted geometric mean: prod(x_i ^ w_i)
109
+ const logConf =
110
+ SHARPNESS_W * Math.log(s)
111
+ + CLIFF_W * Math.log(c)
112
+ + FLOOR_W * Math.log(f);
113
+ const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
114
+ // Subtract epsilon contribution so the floor of confidence is ~0 when all
115
+ // signals are zero (rather than the value of EPS).
116
+ const rawConf = Math.exp(logConf / totalW) - EPS;
117
+ const confidence = Math.max(0, Math.min(1, rawConf));
118
+
119
+ return { confidence, sharpness, cliff, floor };
120
+ }