agent-working-memory 0.14.1 → 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 +18 -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 +43 -7
  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 +16 -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 +18 -3
  39. package/src/engine/eval.ts +34 -0
  40. package/src/hooks/sidecar.ts +50 -6
  41. package/src/mcp.ts +45 -7
  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 +16 -0
  46. package/src/types/eval.ts +3 -1
package/src/mcp.ts CHANGED
@@ -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);
@@ -521,10 +530,15 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
521
530
  };
522
531
  }
523
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
+
524
538
  return {
525
539
  content: [{
526
540
  type: 'text' as const,
527
- text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens),
541
+ text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens) + evFooter,
528
542
  }],
529
543
  };
530
544
  }
@@ -539,9 +553,19 @@ Always call this after using a recalled memory so the system learns what's valua
539
553
  engram_id: z.string().describe('ID of the memory (from memory_recall results)'),
540
554
  useful: z.boolean().describe('Was this memory actually helpful?'),
541
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
+ ),
542
560
  },
543
561
  async (params) => {
544
- 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 ?? '');
545
569
 
546
570
  const engram = await store.getEngram(params.engram_id);
547
571
  if (engram) {
@@ -645,7 +669,7 @@ server.tool(
645
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.`,
646
670
  {},
647
671
  async () => {
648
- const info = await buildWhoami(store, AGENT_ID, 'mcp');
672
+ const info = await buildWhoami(store, AGENT_ID, 'mcp', sidecarHandle ? sidecarHandle.boundPort() : undefined);
649
673
  return { content: [{ type: 'text', text: formatWhoami(info) }] };
650
674
  },
651
675
  );
@@ -658,6 +682,11 @@ Also shows the activity log path so the user can tail it to see what's happening
658
682
  async () => {
659
683
  const metrics = await evalEngine.computeMetrics(AGENT_ID);
660
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);
661
690
  const lines = [
662
691
  `Agent: ${AGENT_ID}`,
663
692
  `Active memories: ${metrics.activeEngramCount}`,
@@ -665,9 +694,12 @@ Also shows the activity log path so the user can tail it to see what's happening
665
694
  `Retracted: ${metrics.retractedCount}`,
666
695
  `Avg confidence: ${metrics.avgConfidence.toFixed(3)}`,
667
696
  `Total edges: ${metrics.totalEdges}`,
668
- `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'}`,
669
701
  `Activations (24h): ${metrics.activationCount}`,
670
- `Avg latency: ${metrics.avgLatencyMs.toFixed(1)}ms`,
702
+ `Recall latency (24h): p50 ${metrics.p50LatencyMs.toFixed(0)}ms p90 ${metrics.p90LatencyMs.toFixed(0)}ms`,
671
703
  ``,
672
704
  `Session writes: ${checkpoint?.auto.writeCountSinceConsolidation ?? 0}`,
673
705
  `Session recalls: ${checkpoint?.auto.recallCountSinceConsolidation ?? 0}`,
@@ -675,7 +707,10 @@ Also shows the activity log path so the user can tail it to see what's happening
675
707
  `Checkpoint: ${checkpoint?.executionState ? checkpoint.executionState.currentTask : 'none'}`,
676
708
  ``,
677
709
  `Activity log: ${getLogPath() ?? 'not configured'}`,
678
- `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()}`,
679
714
  ];
680
715
 
681
716
  return {
@@ -1336,6 +1371,8 @@ async function main() {
1336
1371
  agentId: AGENT_ID,
1337
1372
  secret: HOOK_SECRET,
1338
1373
  port: HOOK_PORT,
1374
+ portRange: HOOK_PORT_RANGE,
1375
+ version: VERSION,
1339
1376
  // 0.12.2: warm recall for hooks — the sidecar shares this process's
1340
1377
  // activation engine and loaded models, so a UserPromptSubmit hook can get
1341
1378
  // warm-latency recall without any standing server. Trimmed result shape
@@ -1419,7 +1456,8 @@ async function main() {
1419
1456
 
1420
1457
  // Log to stderr (stdout is reserved for MCP protocol)
1421
1458
  console.error(`AgentWorkingMemory MCP server started (agent: ${AGENT_ID}, db: ${DB_PATH})`);
1422
- 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)'}`);
1423
1461
 
1424
1462
  // Clean shutdown
1425
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'`.
@@ -310,6 +317,15 @@ export interface ActivationQuery {
310
317
  * something different on every run.
311
318
  */
312
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;
313
329
  bm25Only?: boolean; // Skip embedding — fast text-only retrieval for bulk/benchmark scenarios
314
330
  /**
315
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