agent-working-memory 0.14.0 → 0.14.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 (46) hide show
  1. package/README.md +244 -653
  2. package/dist/core/whoami.d.ts +9 -1
  3. package/dist/core/whoami.d.ts.map +1 -1
  4. package/dist/core/whoami.js +12 -2
  5. package/dist/core/whoami.js.map +1 -1
  6. package/dist/engine/activation.d.ts +2 -0
  7. package/dist/engine/activation.d.ts.map +1 -1
  8. package/dist/engine/activation.js +29 -3
  9. package/dist/engine/activation.js.map +1 -1
  10. package/dist/engine/eval.d.ts +15 -0
  11. package/dist/engine/eval.d.ts.map +1 -1
  12. package/dist/engine/eval.js +23 -0
  13. package/dist/engine/eval.js.map +1 -1
  14. package/dist/hooks/sidecar.d.ts +18 -2
  15. package/dist/hooks/sidecar.d.ts.map +1 -1
  16. package/dist/hooks/sidecar.js +30 -4
  17. package/dist/hooks/sidecar.js.map +1 -1
  18. package/dist/mcp.js +71 -14
  19. package/dist/mcp.js.map +1 -1
  20. package/dist/storage/pglite.d.ts +7 -0
  21. package/dist/storage/pglite.d.ts.map +1 -1
  22. package/dist/storage/pglite.js +17 -3
  23. package/dist/storage/pglite.js.map +1 -1
  24. package/dist/storage/postgres.d.ts +7 -0
  25. package/dist/storage/postgres.d.ts.map +1 -1
  26. package/dist/storage/postgres.js +17 -3
  27. package/dist/storage/postgres.js.map +1 -1
  28. package/dist/storage/sqlite.d.ts +12 -0
  29. package/dist/storage/sqlite.d.ts.map +1 -1
  30. package/dist/storage/sqlite.js +24 -3
  31. package/dist/storage/sqlite.js.map +1 -1
  32. package/dist/types/engram.d.ts +40 -0
  33. package/dist/types/engram.d.ts.map +1 -1
  34. package/dist/types/eval.d.ts +2 -0
  35. package/dist/types/eval.d.ts.map +1 -1
  36. package/package.json +20 -2
  37. package/src/core/whoami.ts +11 -1
  38. package/src/engine/activation.ts +29 -3
  39. package/src/engine/eval.ts +34 -0
  40. package/src/hooks/sidecar.ts +50 -6
  41. package/src/mcp.ts +74 -15
  42. package/src/storage/pglite.ts +22 -4
  43. package/src/storage/postgres.ts +22 -4
  44. package/src/storage/sqlite.ts +26 -4
  45. package/src/types/engram.ts +41 -0
  46. package/src/types/eval.ts +3 -1
@@ -27,7 +27,20 @@ export interface SidecarDeps {
27
27
  store: EngramStore;
28
28
  agentId: string;
29
29
  secret: string | null;
30
+ /** Preferred port. If busy, the sidecar walks upward (see `portRange`). */
30
31
  port: number;
32
+ /**
33
+ * 0.14.2: how many consecutive ports to try starting at `port` (default 10).
34
+ * Every Claude Code session spawns its own MCP process, and each one used to
35
+ * ask for exactly 8401; on EADDRINUSE the sidecar logged "hooks disabled" and
36
+ * gave up, so with N concurrent sessions only the first had working hooks —
37
+ * and `memory_whoami` still reported the configured port as if it were bound.
38
+ * Observed 2026-09-11: 4 of 5 live sessions had no sidecar at all.
39
+ * Hook clients probe the same range and match on `/health.agentId`.
40
+ */
41
+ portRange?: number;
42
+ /** Reported by /health so hook clients can prefer the newest build. */
43
+ version?: string;
31
44
  onConsolidate?: (agentId: string, reason: string) => void;
32
45
  /**
33
46
  * 0.12.2: warm recall for hooks. The sidecar runs in the same process as
@@ -157,8 +170,16 @@ function json(res: ServerResponse, status: number, body: Record<string, unknown>
157
170
 
158
171
  const AUTO_CHECKPOINT_INTERVAL_MS = 15 * 60_000; // 15 minutes
159
172
 
160
- export function startSidecar(deps: SidecarDeps): { close: () => void } {
173
+ export interface SidecarHandle {
174
+ close: () => void;
175
+ /** The port actually bound, or null while binding / if every port in range was busy. */
176
+ boundPort: () => number | null;
177
+ }
178
+
179
+ export function startSidecar(deps: SidecarDeps): SidecarHandle {
161
180
  const { store, agentId, secret, port, onConsolidate } = deps;
181
+ const portRange = Math.max(1, deps.portRange ?? 10);
182
+ let bound: number | null = null;
162
183
 
163
184
  const server = createServer(async (req, res) => {
164
185
  // CORS preflight
@@ -170,7 +191,12 @@ export function startSidecar(deps: SidecarDeps): { close: () => void } {
170
191
 
171
192
  // Health check — no auth required
172
193
  if (req.url === '/health' && req.method === 'GET') {
173
- json(res, 200, { status: 'ok', sidecar: true, agentId });
194
+ // port/pid/version let a hook that probes 8401..841x pick the right
195
+ // process: same agentId as its session, newest version on a tie.
196
+ json(res, 200, {
197
+ status: 'ok', sidecar: true, agentId,
198
+ port: bound, pid: process.pid, version: deps.version ?? null,
199
+ });
174
200
  return;
175
201
  }
176
202
 
@@ -340,17 +366,34 @@ export function startSidecar(deps: SidecarDeps): { close: () => void } {
340
366
  json(res, 404, { error: 'Not found' });
341
367
  });
342
368
 
343
- server.listen(port, '127.0.0.1', () => {
344
- console.error(`AWM hook sidecar listening on 127.0.0.1:${port}`);
369
+ // Bind to the first free port in [port, port + portRange). A listen error
370
+ // fires 'error' before 'listening', so we re-listen on the next candidate
371
+ // from the error handler; any other error, or exhausting the range, leaves
372
+ // the sidecar unbound (hooks disabled) but never takes the MCP server down.
373
+ let attempt = 0;
374
+ const tryListen = () => {
375
+ const candidate = port + attempt;
376
+ server.listen(candidate, '127.0.0.1');
377
+ };
378
+ server.on('listening', () => {
379
+ const addr = server.address();
380
+ bound = addr && typeof addr === 'object' ? addr.port : port + attempt;
381
+ const note = bound === port ? '' : ` (preferred ${port} was busy)`;
382
+ console.error(`AWM hook sidecar listening on 127.0.0.1:${bound}${note}`);
345
383
  });
346
-
347
384
  server.on('error', (err: NodeJS.ErrnoException) => {
385
+ if (err.code === 'EADDRINUSE' && attempt + 1 < portRange) {
386
+ attempt++;
387
+ tryListen();
388
+ return;
389
+ }
348
390
  if (err.code === 'EADDRINUSE') {
349
- console.error(`AWM hook sidecar: port ${port} in use, hooks disabled`);
391
+ console.error(`AWM hook sidecar: ports ${port}-${port + portRange - 1} all in use, hooks disabled`);
350
392
  } else {
351
393
  console.error('AWM hook sidecar error:', err.message);
352
394
  }
353
395
  });
396
+ tryListen();
354
397
 
355
398
  // --- Silent auto-checkpoint every 15 minutes ---
356
399
  const autoCheckpointTimer = setInterval(async () => {
@@ -383,5 +426,6 @@ export function startSidecar(deps: SidecarDeps): { close: () => void } {
383
426
  clearInterval(autoCheckpointTimer);
384
427
  server.close();
385
428
  },
429
+ boundPort: () => bound,
386
430
  };
387
431
  }
package/src/mcp.ts CHANGED
@@ -71,7 +71,7 @@ import { evaluateSalience, computeNovelty, computeNoveltyWithMatch } from './cor
71
71
  import { performWrite } from './core/write-pipeline.js';
72
72
  import type { ConsciousState } from './types/checkpoint.js';
73
73
  import type { SalienceEventType } from './core/salience.js';
74
- import type { TaskStatus, TaskPriority } from './types/engram.js';
74
+ import type { TaskStatus, TaskPriority, AbstentionInfo } from './types/engram.js';
75
75
  import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
76
76
  import { embed, getEmbedder } from './core/embeddings.js';
77
77
  import { getReranker } from './core/reranker.js';
@@ -124,6 +124,15 @@ function deriveAgentFromDir(): string {
124
124
  }
125
125
  const AGENT_ID = process.env.AWM_AGENT_ID ?? process.env.WORKER_NAME ?? deriveAgentFromDir();
126
126
  const HOOK_PORT = parseInt(process.env.AWM_HOOK_PORT ?? '8401', 10);
127
+ // 0.14.2: ports to try upward from HOOK_PORT when it is busy (see sidecar.ts).
128
+ const HOOK_PORT_RANGE = Math.max(1, parseInt(process.env.AWM_HOOK_PORT_RANGE ?? '10', 10) || 10);
129
+ // Set once the sidecar starts; whoami reads the bound port from it.
130
+ let sidecarHandle: { boundPort: () => number | null } | null = null;
131
+ function sidecarPortLabel(): string {
132
+ const p = sidecarHandle?.boundPort() ?? null;
133
+ if (p === null) return `not bound (preferred ${HOOK_PORT}; hooks disabled)`;
134
+ return p === HOOK_PORT ? `127.0.0.1:${p}` : `127.0.0.1:${p} (preferred ${HOOK_PORT} was busy)`;
135
+ }
127
136
  const HOOK_SECRET = process.env.AWM_HOOK_SECRET ?? null;
128
137
 
129
138
  initLogger(DB_PATH);
@@ -414,7 +423,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
414
423
  use_expansion: z.boolean().optional().default(true).describe('Expand query with synonyms for better recall (default true)'),
415
424
  memory_type: z.enum(['episodic', 'semantic', 'procedural']).optional().describe('Filter by memory type (omit to search all types)'),
416
425
  workspace: z.string().optional().describe('Search across all agents in this workspace (hive mode). Omit for agent-scoped recall only.'),
417
- require_confidence: z.number().optional().default(0.05).describe('Abstain (return nothing) when recall confidence is below this threshold. Defaults to 0.05 — a LIGHT filter, chosen from measurement: it halves the rate of answering off-topic queries at zero cost to hit rate. Raise it only for push-style use where nobody asked (0.25 is what the prime hook uses). Do NOT raise it for ordinary recall: a miss is expensive, because the agent then reads the codebase instead (~2,106 tokens), so aggressive thresholds measurably destroy value — 0.20+ cut net tokens saved by 25% in tests/abstention-eval. Pass 0 to disable.'),
426
+ require_confidence: z.number().optional().default(0.05).describe('Abstain (return nothing) when recall confidence is below this threshold. Defaults to 0.05 — a LIGHT filter, chosen from measurement: it halves the rate of answering off-topic queries at zero cost to hit rate. Raise it only for push-style use where nobody asked (0.25 is what the prime hook uses). Do NOT raise it for ordinary recall: a miss is expensive, because the agent then reads the codebase instead (~2,106 tokens), so aggressive thresholds measurably destroy value — 0.20+ cut net tokens saved by 25% in tests/abstention-eval. Pass 0 to disable. NOTE: this is NOT min_score — it gates on the SHAPE of the score distribution across the whole result set, not per-result relevance, so memories that pass min_score can still be withheld. When that happens the reply says RECALL ABSTAINED and reports how many were withheld; an empty result is only absence when it does not.'),
418
427
  granularity: z.enum(['full', 'compact', 'auto']).optional().describe('Output granularity (Paper 3: cognitive teaming). "full" (default): no change. "compact": every result carries a short summary field. "auto": confidence-adaptive — top result gets a longer summary when there is a clear winner, otherwise everything is compact for scanning.'),
419
428
  max_tokens: z.number().optional().describe('Token budget for the response. `limit` is a COUNT and is token-blind — 5 results may cost 400 tokens or 4,000. Use this when context is tight: results are packed by value-per-token until the budget is reached, the top-scored match always gets first refusal, and the reply reports what it cost and what was withheld. Omit for no budget (everything is returned, still with accounting).'),
420
429
  },
@@ -430,6 +439,9 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
430
439
  }
431
440
  // Use workspace from param, env var, or omit for agent-scoped
432
441
  const workspace = params.workspace ?? process.env.AWM_WORKSPACE ?? undefined;
442
+ // Set only when a gate WITHHELD results. Distinguishes abstention from absence,
443
+ // which an empty array cannot.
444
+ let abstained: AbstentionInfo | undefined;
433
445
  const results = await activationEngine.activate({
434
446
  agentId: AGENT_ID,
435
447
  context: queryText,
@@ -442,6 +454,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
442
454
  workspace,
443
455
  requireConfidence: params.require_confidence,
444
456
  granularity: params.granularity,
457
+ onAbstain: (info) => { abstained = info; },
445
458
  });
446
459
 
447
460
  // Auto-checkpoint: track recall
@@ -458,12 +471,29 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
458
471
  : '';
459
472
 
460
473
  if (results.length === 0) {
461
- return {
462
- content: [{
463
- type: 'text' as const,
464
- text: 'No relevant memories found.' + peerSuffix,
465
- }],
466
- };
474
+ // Two very different conditions used to render identically as
475
+ // "No relevant memories found." — a claim of absence the system cannot make
476
+ // when a gate withheld matches. Callers read it as absence and stopped
477
+ // looking, for memories scoring well above minScore.
478
+ const text = abstained
479
+ ? [
480
+ `RECALL ABSTAINED — this is NOT "no memories exist".`,
481
+ ``,
482
+ `${abstained.candidates} candidate${abstained.candidates === 1 ? '' : 's'} matched ` +
483
+ `(best score ${abstained.topScore.toFixed(3)}) and ${abstained.candidates === 1 ? 'was' : 'were'} ` +
484
+ `withheld because recall confidence ` +
485
+ `${abstained.confidence !== undefined ? abstained.confidence.toFixed(3) + ' ' : ''}` +
486
+ `fell below your require_confidence of ${abstained.threshold ?? '?'}.`,
487
+ ``,
488
+ `require_confidence is NOT min_score. It gates on the SHAPE of the score` +
489
+ ` distribution across the whole result set, not on how relevant any single` +
490
+ ` memory is — so results that comfortably pass min_score can still be withheld here.`,
491
+ ``,
492
+ `To see them: re-run this query with require_confidence: 0.`,
493
+ `Do not conclude the memories are absent without doing that.`,
494
+ ].join('\n') + peerSuffix
495
+ : 'No relevant memories found.' + peerSuffix;
496
+ return { content: [{ type: 'text' as const, text }] };
467
497
  }
468
498
 
469
499
  // Confidence-adaptive output (Paper 3: cognitive teaming) and D8
@@ -500,10 +530,15 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
500
530
  };
501
531
  }
502
532
 
533
+ // 0.14.3: surface the activation event id once, so memory_feedback can join
534
+ // to this recall. Rendered after the token footer; cheap (one short line).
535
+ const evId = results[0]?.activationEventId;
536
+ const evFooter = evId ? `\n[recall_id: ${evId}]` : '';
537
+
503
538
  return {
504
539
  content: [{
505
540
  type: 'text' as const,
506
- text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens),
541
+ text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens) + evFooter,
507
542
  }],
508
543
  };
509
544
  }
@@ -518,9 +553,19 @@ Always call this after using a recalled memory so the system learns what's valua
518
553
  engram_id: z.string().describe('ID of the memory (from memory_recall results)'),
519
554
  useful: z.boolean().describe('Was this memory actually helpful?'),
520
555
  context: z.string().optional().describe('Brief note on why it was/wasn\'t useful'),
556
+ recall_id: z.string().optional().describe(
557
+ 'The [recall_id: …] printed at the end of the memory_recall output that returned this memory. '
558
+ + 'Pass it so the feedback is joined to that recall; omitted, it defaults to the most recent recall in this session.',
559
+ ),
521
560
  },
522
561
  async (params) => {
523
- await store.logRetrievalFeedback(null, params.engram_id, params.useful, params.context ?? '');
562
+ // 0.14.3: join feedback to the recall that produced it. Explicit recall_id
563
+ // wins; otherwise fall back to the engine's most recent logged activation —
564
+ // the common case is "recall, use, feedback" in one turn. Before this the
565
+ // MCP path hardcoded null (the HTTP route already accepted the id), which
566
+ // is why every retrieval_feedback row in the live store was orphaned.
567
+ const eventId = params.recall_id ?? activationEngine.lastActivationEventId ?? null;
568
+ await store.logRetrievalFeedback(eventId, params.engram_id, params.useful, params.context ?? '');
524
569
 
525
570
  const engram = await store.getEngram(params.engram_id);
526
571
  if (engram) {
@@ -624,7 +669,7 @@ server.tool(
624
669
  `Identify THIS AWM instance — agent id, mode (standalone/hive), backend, store path, code provenance, ports, and the sibling agent spaces present in the same store. Call when unsure which AWM instance or memory space you are talking to.`,
625
670
  {},
626
671
  async () => {
627
- const info = await buildWhoami(store, AGENT_ID, 'mcp');
672
+ const info = await buildWhoami(store, AGENT_ID, 'mcp', sidecarHandle ? sidecarHandle.boundPort() : undefined);
628
673
  return { content: [{ type: 'text', text: formatWhoami(info) }] };
629
674
  },
630
675
  );
@@ -637,6 +682,11 @@ Also shows the activity log path so the user can tail it to see what's happening
637
682
  async () => {
638
683
  const metrics = await evalEngine.computeMetrics(AGENT_ID);
639
684
  const checkpoint = await store.getCheckpoint(AGENT_ID);
685
+ // 0.14.3: outcome numbers, not activity counters. "Edge utility" (share of
686
+ // edges ever activated) was monotone — it could only rise — and read as
687
+ // health when it was not. Latency was a mean over a column that mixes cold
688
+ // loads and stalls (live store: mean 20 s, median 1.6 s); medians only now.
689
+ const usage = await evalEngine.computeUsage(AGENT_ID);
640
690
  const lines = [
641
691
  `Agent: ${AGENT_ID}`,
642
692
  `Active memories: ${metrics.activeEngramCount}`,
@@ -644,9 +694,12 @@ Also shows the activity log path so the user can tail it to see what's happening
644
694
  `Retracted: ${metrics.retractedCount}`,
645
695
  `Avg confidence: ${metrics.avgConfidence.toFixed(3)}`,
646
696
  `Total edges: ${metrics.totalEdges}`,
647
- `Edge utility: ${(metrics.edgeUtilityRate * 100).toFixed(1)}%`,
697
+ ``,
698
+ `Write:recall (30d): 1 : ${usage.recallsPerWrite30d.toFixed(2)} (${usage.writes30d} writes, ${usage.recalls30d} recalls)`,
699
+ `Never recalled: ${(usage.neverRecalledShare * 100).toFixed(0)}% of active memories`,
700
+ `Recall→use (7d): ${usage.feedbackLinked7d > 0 ? (usage.usefulShare7d * 100).toFixed(0) + '% useful of ' + usage.feedbackLinked7d + ' judged' : 'no linked feedback yet'}`,
648
701
  `Activations (24h): ${metrics.activationCount}`,
649
- `Avg latency: ${metrics.avgLatencyMs.toFixed(1)}ms`,
702
+ `Recall latency (24h): p50 ${metrics.p50LatencyMs.toFixed(0)}ms p90 ${metrics.p90LatencyMs.toFixed(0)}ms`,
650
703
  ``,
651
704
  `Session writes: ${checkpoint?.auto.writeCountSinceConsolidation ?? 0}`,
652
705
  `Session recalls: ${checkpoint?.auto.recallCountSinceConsolidation ?? 0}`,
@@ -654,7 +707,10 @@ Also shows the activity log path so the user can tail it to see what's happening
654
707
  `Checkpoint: ${checkpoint?.executionState ? checkpoint.executionState.currentTask : 'none'}`,
655
708
  ``,
656
709
  `Activity log: ${getLogPath() ?? 'not configured'}`,
657
- `Hook sidecar: 127.0.0.1:${HOOK_PORT}`,
710
+ // Report the port actually BOUND, not the one configured. Before 0.14.2
711
+ // this line said 8401 in every session, including the ones whose sidecar
712
+ // had lost the port and silently disabled hooks.
713
+ `Hook sidecar: ${sidecarPortLabel()}`,
658
714
  ];
659
715
 
660
716
  return {
@@ -1315,6 +1371,8 @@ async function main() {
1315
1371
  agentId: AGENT_ID,
1316
1372
  secret: HOOK_SECRET,
1317
1373
  port: HOOK_PORT,
1374
+ portRange: HOOK_PORT_RANGE,
1375
+ version: VERSION,
1318
1376
  // 0.12.2: warm recall for hooks — the sidecar shares this process's
1319
1377
  // activation engine and loaded models, so a UserPromptSubmit hook can get
1320
1378
  // warm-latency recall without any standing server. Trimmed result shape
@@ -1398,7 +1456,8 @@ async function main() {
1398
1456
 
1399
1457
  // Log to stderr (stdout is reserved for MCP protocol)
1400
1458
  console.error(`AgentWorkingMemory MCP server started (agent: ${AGENT_ID}, db: ${DB_PATH})`);
1401
- console.error(`Hook sidecar on 127.0.0.1:${HOOK_PORT}${HOOK_SECRET ? ' (auth enabled)' : ' (no auth — set AWM_HOOK_SECRET)'}`);
1459
+ sidecarHandle = sidecar;
1460
+ console.error(`Hook sidecar preferred 127.0.0.1:${HOOK_PORT} (range ${HOOK_PORT_RANGE})${HOOK_SECRET ? ' (auth enabled)' : ' (no auth — set AWM_HOOK_SECRET)'}`);
1402
1461
 
1403
1462
  // Clean shutdown
1404
1463
  const cleanup = async () => {
@@ -1020,7 +1020,7 @@ export class PGliteEngramStore {
1020
1020
  };
1021
1021
  }
1022
1022
 
1023
- async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p95LatencyMs: number }> {
1023
+ async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p50LatencyMs: number; p90LatencyMs: number; p95LatencyMs: number }> {
1024
1024
  await this.readyPromise;
1025
1025
  // Flush any buffered activation events so stats reflect the latest writes.
1026
1026
  await this.flushActivationEvents();
@@ -1031,17 +1031,35 @@ export class PGliteEngramStore {
1031
1031
  ORDER BY latency_ms ASC`,
1032
1032
  [agentId, since],
1033
1033
  );
1034
- if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
1034
+ if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
1035
1035
  const latencies = result.rows.map((r) => Number(r.latency_ms));
1036
1036
  const total = latencies.reduce((s, l) => s + l, 0);
1037
- const p95Idx = Math.min(Math.floor(latencies.length * 0.95), latencies.length - 1);
1037
+ // 0.14.3: p50/p90 see sqlite.ts for why the mean is not usable here.
1038
+ const pct = (q: number) => latencies[Math.min(Math.floor(latencies.length * q), latencies.length - 1)];
1038
1039
  return {
1039
1040
  count: latencies.length,
1040
1041
  avgLatencyMs: total / latencies.length,
1041
- p95LatencyMs: latencies[p95Idx],
1042
+ p50LatencyMs: pct(0.5),
1043
+ p90LatencyMs: pct(0.9),
1044
+ p95LatencyMs: pct(0.95),
1042
1045
  };
1043
1046
  }
1044
1047
 
1048
+ /** 0.14.3: feedback rows joined to an activation event — see sqlite.ts. */
1049
+ async getLinkedFeedbackStats(agentId: string, windowHours: number = 24 * 7): Promise<{ total: number; useful: number }> {
1050
+ await this.readyPromise;
1051
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1052
+ const result = await this.db.query<any>(
1053
+ `SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = TRUE THEN 1 END) AS useful
1054
+ FROM retrieval_feedback rf
1055
+ JOIN activation_events ae ON ae.id = rf.activation_event_id
1056
+ WHERE ae.agent_id = $1 AND rf.timestamp > $2`,
1057
+ [agentId, since],
1058
+ );
1059
+ const row = result.rows[0] ?? { total: 0, useful: 0 };
1060
+ return { total: Number(row.total), useful: Number(row.useful) };
1061
+ }
1062
+
1045
1063
  async getConsolidatedCount(agentId: string): Promise<number> {
1046
1064
  await this.readyPromise;
1047
1065
  const result = await this.db.query<any>(
@@ -1119,7 +1119,7 @@ export class PostgresEngramStore {
1119
1119
  };
1120
1120
  }
1121
1121
 
1122
- async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p95LatencyMs: number }> {
1122
+ async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p50LatencyMs: number; p90LatencyMs: number; p95LatencyMs: number }> {
1123
1123
  await this.readyPromise;
1124
1124
  // Flush any buffered activation events so stats reflect the latest writes.
1125
1125
  await this.flushActivationEvents();
@@ -1130,17 +1130,35 @@ export class PostgresEngramStore {
1130
1130
  ORDER BY latency_ms ASC`,
1131
1131
  [agentId, since],
1132
1132
  );
1133
- if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
1133
+ if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
1134
1134
  const latencies = result.rows.map((r) => Number(r.latency_ms));
1135
1135
  const total = latencies.reduce((s, l) => s + l, 0);
1136
- const p95Idx = Math.min(Math.floor(latencies.length * 0.95), latencies.length - 1);
1136
+ // 0.14.3: p50/p90 see sqlite.ts for why the mean is not usable here.
1137
+ const pct = (q: number) => latencies[Math.min(Math.floor(latencies.length * q), latencies.length - 1)];
1137
1138
  return {
1138
1139
  count: latencies.length,
1139
1140
  avgLatencyMs: total / latencies.length,
1140
- p95LatencyMs: latencies[p95Idx],
1141
+ p50LatencyMs: pct(0.5),
1142
+ p90LatencyMs: pct(0.9),
1143
+ p95LatencyMs: pct(0.95),
1141
1144
  };
1142
1145
  }
1143
1146
 
1147
+ /** 0.14.3: feedback rows joined to an activation event — see sqlite.ts. */
1148
+ async getLinkedFeedbackStats(agentId: string, windowHours: number = 24 * 7): Promise<{ total: number; useful: number }> {
1149
+ await this.readyPromise;
1150
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1151
+ const result = await this.q<any>(
1152
+ `SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = TRUE THEN 1 END) AS useful
1153
+ FROM retrieval_feedback rf
1154
+ JOIN activation_events ae ON ae.id = rf.activation_event_id
1155
+ WHERE ae.agent_id = $1 AND rf.timestamp > $2`,
1156
+ [agentId, since],
1157
+ );
1158
+ const row = result.rows[0] ?? { total: 0, useful: 0 };
1159
+ return { total: Number(row.total), useful: Number(row.useful) };
1160
+ }
1161
+
1144
1162
  async getConsolidatedCount(agentId: string): Promise<number> {
1145
1163
  await this.readyPromise;
1146
1164
  const result = await this.q<any>(
@@ -1348,7 +1348,7 @@ export class EngramStore {
1348
1348
  }
1349
1349
 
1350
1350
  getActivationStats(agentId: string, windowHours: number = 24): {
1351
- count: number; avgLatencyMs: number; p95LatencyMs: number;
1351
+ count: number; avgLatencyMs: number; p50LatencyMs: number; p90LatencyMs: number; p95LatencyMs: number;
1352
1352
  } {
1353
1353
  const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1354
1354
  const rows = this.db.prepare(`
@@ -1357,17 +1357,39 @@ export class EngramStore {
1357
1357
  ORDER BY latency_ms ASC
1358
1358
  `).all(agentId, since) as { latency_ms: number }[];
1359
1359
 
1360
- if (rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
1360
+ if (rows.length === 0) return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
1361
1361
 
1362
+ // 0.14.3: p50/p90 added. The column mixes warm recalls with cold model loads
1363
+ // and stalls (live store: mean 20 s, median 1.6 s, max 38 min), so the mean
1364
+ // is not a usable number; percentiles are. Rows are already sorted ASC.
1365
+ const pct = (q: number) => rows[Math.min(Math.floor(rows.length * q), rows.length - 1)].latency_ms;
1362
1366
  const total = rows.reduce((s, r) => s + r.latency_ms, 0);
1363
- const p95Index = Math.min(Math.floor(rows.length * 0.95), rows.length - 1);
1364
1367
  return {
1365
1368
  count: rows.length,
1366
1369
  avgLatencyMs: total / rows.length,
1367
- p95LatencyMs: rows[p95Index].latency_ms,
1370
+ p50LatencyMs: pct(0.5),
1371
+ p90LatencyMs: pct(0.9),
1372
+ p95LatencyMs: pct(0.95),
1368
1373
  };
1369
1374
  }
1370
1375
 
1376
+ /**
1377
+ * 0.14.3: feedback rows in the window that are JOINED to an activation event.
1378
+ * Unlinked rows (activation_event_id IS NULL — all 872 rows written before
1379
+ * 0.14.3) are excluded on purpose: this is the recall→use signal, and a row
1380
+ * that cannot be traced to a recall does not measure it.
1381
+ */
1382
+ getLinkedFeedbackStats(agentId: string, windowHours: number = 24 * 7): { total: number; useful: number } {
1383
+ const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
1384
+ const row = this.db.prepare(`
1385
+ SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = 1 THEN 1 END) AS useful
1386
+ FROM retrieval_feedback rf
1387
+ JOIN activation_events ae ON ae.id = rf.activation_event_id
1388
+ WHERE ae.agent_id = ? AND rf.timestamp > ?
1389
+ `).get(agentId, since) as { total: number; useful: number };
1390
+ return { total: row.total, useful: row.useful };
1391
+ }
1392
+
1371
1393
  getConsolidatedCount(agentId: string): number {
1372
1394
  const row = this.db.prepare(
1373
1395
  `SELECT COUNT(*) as cnt FROM engrams WHERE agent_id = ? AND stage = 'consolidated'`
@@ -219,6 +219,13 @@ export interface ActivationResult {
219
219
  * See `src/engine/confidence.ts` for the formula.
220
220
  */
221
221
  confidence?: number;
222
+ /**
223
+ * Id of the activation_events row this recall logged (0.14.3). Same value on
224
+ * every result in the same recall. Hand it back on memory_feedback so the
225
+ * feedback joins to the recall that produced it — before this, the id was
226
+ * generated and dropped, and every retrieval_feedback row was orphaned.
227
+ */
228
+ activationEventId?: string;
222
229
  /**
223
230
  * Confidence-adaptive content preview (Paper 3: cognitive teaming).
224
231
  * Set when the query opts in via `granularity: 'compact' | 'auto'`.
@@ -254,6 +261,22 @@ export interface PhaseScores {
254
261
  */
255
262
  export type QueryMode = 'targeted' | 'exploratory' | 'balanced' | 'auto';
256
263
 
264
+ /** Why a recall returned nothing, when the cause was a gate rather than an empty store. */
265
+ export interface AbstentionInfo {
266
+ /** `confidence` = score-distribution gate (`requireConfidence`).
267
+ * `agreement` = cross-channel agreement gate (`abstentionThreshold`). */
268
+ reason: 'confidence' | 'agreement';
269
+ /** Candidates that survived scoring and were then withheld. Never 0 — a genuinely
270
+ * empty result does not produce an AbstentionInfo at all. */
271
+ candidates: number;
272
+ /** Score of the best withheld candidate. Compare against `minScore`, not against
273
+ * the confidence threshold — they measure different things. */
274
+ topScore: number;
275
+ /** Computed recall confidence, and the threshold it failed. */
276
+ confidence?: number;
277
+ threshold?: number;
278
+ }
279
+
257
280
  export interface ActivationQuery {
258
281
  agentId: string;
259
282
  context: string;
@@ -273,6 +296,15 @@ export interface ActivationQuery {
273
296
  * 0.25 (balanced), 0.40 (aggressive — only return high-confidence recall).
274
297
  */
275
298
  requireConfidence?: number;
299
+ /**
300
+ * Called when a gate withholds results, INSTEAD of silently returning [].
301
+ *
302
+ * An empty array cannot distinguish "nothing matched" from "matches were found
303
+ * and withheld", and callers reliably read the second as the first — including
304
+ * when the withheld results scored well above `minScore`. If you act on an empty
305
+ * recall, wire this up.
306
+ */
307
+ onAbstain?: (info: AbstentionInfo) => void;
276
308
  internal?: boolean; // Skip access count increment, Hebbian update, and event logging (for system calls)
277
309
  spread?: boolean; // R2: when AWM_SPREAD=1, set false to skip iterative spreading activation (connection-discovery uses this so edge-building doesn't recurse)
278
310
  memoryType?: MemoryType; // Filter by memory type (episodic, semantic, procedural)
@@ -285,6 +317,15 @@ export interface ActivationQuery {
285
317
  * something different on every run.
286
318
  */
287
319
  asOf?: number;
320
+ /**
321
+ * 0.14.4: clock for ACT-R decay. Defaults to Date.now(). `asOf` pinned the
322
+ * temporal PARSER but decay still read the wall clock, so a "frozen" eval
323
+ * snapshot scored differently every day — a 300-query benchmark read 70.0%
324
+ * s@1 one evening and 67.0% the next afternoon on byte-identical data and
325
+ * code. Evals must pass the snapshot's own timestamp here. Production leaves
326
+ * it unset.
327
+ */
328
+ now?: number;
288
329
  bm25Only?: boolean; // Skip embedding — fast text-only retrieval for bulk/benchmark scenarios
289
330
  /**
290
331
  * Output granularity (Paper 3: cognitive teaming, Brill 2018 ACT-R collaboration).
package/src/types/eval.ts CHANGED
@@ -54,7 +54,9 @@ export interface EvalMetrics {
54
54
  // Retrieval quality
55
55
  activationCount: number;
56
56
  avgPrecisionAtK: number; // Of returned results, % judged useful
57
- avgLatencyMs: number;
57
+ avgLatencyMs: number; // kept for callers; misleading on the live store (mixes cold loads) — prefer p50/p90
58
+ p50LatencyMs: number; // 0.14.3
59
+ p90LatencyMs: number; // 0.14.3
58
60
  p95LatencyMs: number;
59
61
 
60
62
  // Connection quality