agent-working-memory 0.14.1 → 0.14.6

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 (76) hide show
  1. package/README.md +240 -653
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +234 -131
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/common.d.ts +44 -5
  6. package/dist/adapters/common.d.ts.map +1 -1
  7. package/dist/adapters/common.js +111 -41
  8. package/dist/adapters/common.js.map +1 -1
  9. package/dist/adapters/hook-scripts.d.ts +54 -0
  10. package/dist/adapters/hook-scripts.d.ts.map +1 -0
  11. package/dist/adapters/hook-scripts.js +340 -0
  12. package/dist/adapters/hook-scripts.js.map +1 -0
  13. package/dist/adapters/types.d.ts +13 -3
  14. package/dist/adapters/types.d.ts.map +1 -1
  15. package/dist/cli.js +21 -5
  16. package/dist/cli.js.map +1 -1
  17. package/dist/core/agent-id.d.ts +19 -0
  18. package/dist/core/agent-id.d.ts.map +1 -0
  19. package/dist/core/agent-id.js +28 -0
  20. package/dist/core/agent-id.js.map +1 -0
  21. package/dist/core/whoami.d.ts +9 -1
  22. package/dist/core/whoami.d.ts.map +1 -1
  23. package/dist/core/whoami.js +12 -2
  24. package/dist/core/whoami.js.map +1 -1
  25. package/dist/engine/activation.d.ts +2 -0
  26. package/dist/engine/activation.d.ts.map +1 -1
  27. package/dist/engine/activation.js +18 -3
  28. package/dist/engine/activation.js.map +1 -1
  29. package/dist/engine/eval.d.ts +15 -0
  30. package/dist/engine/eval.d.ts.map +1 -1
  31. package/dist/engine/eval.js +23 -0
  32. package/dist/engine/eval.js.map +1 -1
  33. package/dist/hooks/prime.d.ts +10 -4
  34. package/dist/hooks/prime.d.ts.map +1 -1
  35. package/dist/hooks/prime.js +16 -6
  36. package/dist/hooks/prime.js.map +1 -1
  37. package/dist/hooks/sidecar.d.ts +18 -2
  38. package/dist/hooks/sidecar.d.ts.map +1 -1
  39. package/dist/hooks/sidecar.js +30 -4
  40. package/dist/hooks/sidecar.js.map +1 -1
  41. package/dist/mcp.js +48 -19
  42. package/dist/mcp.js.map +1 -1
  43. package/dist/storage/pglite.d.ts +7 -0
  44. package/dist/storage/pglite.d.ts.map +1 -1
  45. package/dist/storage/pglite.js +17 -3
  46. package/dist/storage/pglite.js.map +1 -1
  47. package/dist/storage/postgres.d.ts +7 -0
  48. package/dist/storage/postgres.d.ts.map +1 -1
  49. package/dist/storage/postgres.js +17 -3
  50. package/dist/storage/postgres.js.map +1 -1
  51. package/dist/storage/sqlite.d.ts +12 -0
  52. package/dist/storage/sqlite.d.ts.map +1 -1
  53. package/dist/storage/sqlite.js +24 -3
  54. package/dist/storage/sqlite.js.map +1 -1
  55. package/dist/types/engram.d.ts +16 -0
  56. package/dist/types/engram.d.ts.map +1 -1
  57. package/dist/types/eval.d.ts +2 -0
  58. package/dist/types/eval.d.ts.map +1 -1
  59. package/package.json +94 -72
  60. package/src/adapters/claude-code.ts +223 -125
  61. package/src/adapters/common.ts +117 -42
  62. package/src/adapters/hook-scripts.ts +370 -0
  63. package/src/adapters/types.ts +14 -3
  64. package/src/cli.ts +19 -5
  65. package/src/core/agent-id.ts +28 -0
  66. package/src/core/whoami.ts +11 -1
  67. package/src/engine/activation.ts +18 -3
  68. package/src/engine/eval.ts +34 -0
  69. package/src/hooks/prime.ts +22 -7
  70. package/src/hooks/sidecar.ts +50 -6
  71. package/src/mcp.ts +50 -18
  72. package/src/storage/pglite.ts +22 -4
  73. package/src/storage/postgres.ts +22 -4
  74. package/src/storage/sqlite.ts +26 -4
  75. package/src/types/engram.ts +16 -0
  76. package/src/types/eval.ts +3 -1
@@ -28,8 +28,12 @@ export interface SetupContext {
28
28
  hasDist: boolean;
29
29
  /** Hook sidecar secret token */
30
30
  hookSecret: string;
31
- /** Hook sidecar port */
31
+ /** Hook sidecar port (preferred; the sidecar walks upward from here when busy) */
32
32
  hookPort: string;
33
+ /** How many ports upward from hookPort the sidecar may try (0.14.2) */
34
+ hookPortRange: string;
35
+ /** Install the UserPromptSubmit prime hook (0.14.6; `--no-prime` turns it off) */
36
+ installPrime: boolean;
33
37
  /** Whether to use global scope */
34
38
  isGlobal: boolean;
35
39
  /** Windows platform */
@@ -70,6 +74,13 @@ export interface CLIAdapter {
70
74
  /** Configure hooks (if supported). Returns action summary. */
71
75
  writeHooks(ctx: SetupContext, skip: boolean): string;
72
76
 
73
- /** Validate that the setup is healthy. */
74
- diagnose(ctx: SetupContext): DiagnosticResult[];
77
+ /** Validate that the setup is healthy. May probe the network, hence possibly async. */
78
+ diagnose(ctx: SetupContext): DiagnosticResult[] | Promise<DiagnosticResult[]>;
79
+
80
+ /**
81
+ * The env block of an AWM server entry this adapter previously wrote, if any.
82
+ * `awm setup` re-runs preserve these values unless a flag overrides them, so an
83
+ * upgrade never repoints the database or renames the agent (0.14.6).
84
+ */
85
+ readExistingEnv?(isGlobal: boolean, cwd: string): Record<string, string> | null;
75
86
  }
package/src/cli.ts CHANGED
@@ -74,7 +74,10 @@ Setup options:
74
74
  --no-instructions Skip instruction file (CLAUDE.md, AGENTS.md, etc.)
75
75
  --no-claude-md Alias for --no-instructions
76
76
  --no-hooks Skip hook installation
77
- --hook-port PORT Sidecar port for hooks (default: 8401)
77
+ --no-prime Skip the UserPromptSubmit prime hook (auto-inject memories per prompt)
78
+ --hook-port PORT Preferred sidecar port (default: 8401; each session walks upward when busy)
79
+ --hook-port-range N Ports to try upward from --hook-port (default: 10)
80
+ Re-running setup keeps your existing agent id, db path, port and any env you added.
78
81
 
79
82
  Examples:
80
83
  awm setup --global Claude Code, global (recommended)
@@ -95,7 +98,9 @@ async function setup() {
95
98
  let skipInstructions = false;
96
99
  let isGlobal = false;
97
100
  let skipHooks = false;
98
- let hookPort = '8401';
101
+ let installPrime = true;
102
+ let hookPort: string | undefined;
103
+ let hookPortRange: string | undefined;
99
104
 
100
105
  for (let i = 1; i < args.length; i++) {
101
106
  if (args[i] === '--agent-id' && args[i + 1]) {
@@ -106,8 +111,12 @@ async function setup() {
106
111
  skipInstructions = true;
107
112
  } else if (args[i] === '--no-hooks') {
108
113
  skipHooks = true;
114
+ } else if (args[i] === '--no-prime') {
115
+ installPrime = false;
109
116
  } else if (args[i] === '--hook-port' && args[i + 1]) {
110
117
  hookPort = args[++i];
118
+ } else if (args[i] === '--hook-port-range' && args[i + 1]) {
119
+ hookPortRange = args[++i];
111
120
  } else if (args[i] === '--global') {
112
121
  isGlobal = true;
113
122
  } else if (!args[i].startsWith('--')) {
@@ -133,8 +142,10 @@ async function setup() {
133
142
  isGlobal = true;
134
143
  }
135
144
 
136
- // Build context
137
- const ctx = buildSetupContext({ agentId, dbPath, isGlobal, hookPort });
145
+ // Build context. A previous install's values survive unless a flag overrides them
146
+ // (0.14.6) — an upgrade must never repoint the database or rename the agent.
147
+ const existingEnv = adapter.readExistingEnv?.(isGlobal, process.cwd()) ?? null;
148
+ const ctx = buildSetupContext({ agentId, dbPath, isGlobal, hookPort, hookPortRange, installPrime, existingEnv });
138
149
 
139
150
  // Run adapter
140
151
  const configAction = adapter.writeMcpConfig(ctx);
@@ -157,6 +168,7 @@ AWM configured for ${adapter.name}${isGlobal ? ' (global)' : ''}
157
168
  Next steps:
158
169
  1. Restart ${adapter.name} to pick up the MCP server
159
170
  2. Memory tools will appear automatically${adapter.id === 'codex' ? ' (verify with /mcp)' : ''}
171
+ 3. \`awm doctor\` checks the install and lists the live sidecars
160
172
  `.trim());
161
173
  }
162
174
 
@@ -197,7 +209,9 @@ async function doctor() {
197
209
  }
198
210
 
199
211
  console.log(` ${adapter.name}:`);
200
- const results = adapter.diagnose(ctx);
212
+ const existingEnv = adapter.readExistingEnv?.(true, process.cwd()) ?? null;
213
+ const ctx = buildSetupContext({ isGlobal: true, existingEnv });
214
+ const results = await adapter.diagnose(ctx);
201
215
  for (const r of results) {
202
216
  const icon = r.status === 'ok' ? '+' : r.status === 'warn' ? '~' : 'x';
203
217
  console.log(` [${icon}] ${r.check}: ${r.message}`);
@@ -0,0 +1,28 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Fallback agent identity when AWM_AGENT_ID / WORKER_NAME are unset.
6
+ *
7
+ * Derived from the project directory so a plain `claude` launch still binds to
8
+ * the right store: `Personal-Projects` → 'personal'; everything else → 'work'.
9
+ * The AWM package itself lives under Personal-Projects, so an AgentSynapse
10
+ * checkout is guarded first — a stray server cwd must not mis-bind.
11
+ *
12
+ * ONE definition, three consumers that must agree:
13
+ * - src/mcp.ts — what the server actually binds to
14
+ * - src/adapters/common.ts — what `awm setup` reports and seeds
15
+ * - the shipped hook scripts (src/adapters/hook-scripts.ts) carry a
16
+ * line-for-line JavaScript mirror, because a hook cannot import from dist.
17
+ * tests/adapters/hook-scripts.test.ts asserts the mirror agrees with this.
18
+ */
19
+ export function deriveAgentFromDir(dir: string): string {
20
+ const d = String(dir ?? '').replace(/\\/g, '/');
21
+ if (/\/AgentSynapse\//i.test(d)) return 'work';
22
+ return /\/Personal-Projects(\/|$)/i.test(d) ? 'personal' : 'work';
23
+ }
24
+
25
+ /** The agent id a server process would resolve to for `dir`, honouring the env first. */
26
+ export function resolveAgentId(dir: string, env: NodeJS.ProcessEnv = process.env): string {
27
+ return env.AWM_AGENT_ID ?? env.WORKER_NAME ?? deriveAgentFromDir(dir);
28
+ }
@@ -59,6 +59,14 @@ export async function buildWhoami(
59
59
  store: WhoamiStore,
60
60
  agentId: string,
61
61
  surface: 'mcp' | 'http',
62
+ /**
63
+ * 0.14.2: the port the hook sidecar actually BOUND, from the running handle.
64
+ * Before this, whoami echoed AWM_HOOK_PORT from the environment — so a session
65
+ * whose sidecar had lost the port fight and disabled hooks still reported
66
+ * "hookSidecar=8401". Pass `null` for "tried and failed"; omit (undefined)
67
+ * for surfaces that have no sidecar, which falls back to the env value.
68
+ */
69
+ boundHookPort?: number | null,
62
70
  ): Promise<WhoamiInfo> {
63
71
  const coordination = process.env.AWM_COORDINATION === 'true' || process.env.AWM_COORDINATION === '1';
64
72
  let siblings: string[] = [];
@@ -80,7 +88,9 @@ export async function buildWhoami(
80
88
  pid: process.pid,
81
89
  ports: {
82
90
  http: Number.isFinite(httpPort) && httpPort > 0 ? httpPort : null,
83
- hookSidecar: Number.isFinite(hookPort) && hookPort > 0 ? hookPort : null,
91
+ hookSidecar: boundHookPort !== undefined
92
+ ? boundHookPort
93
+ : (Number.isFinite(hookPort) && hookPort > 0 ? hookPort : null),
84
94
  },
85
95
  recall: { fingerprint: recallConfigFingerprint(), flags: activeRecallConfig() },
86
96
  siblingAgents: siblings,
@@ -502,8 +502,11 @@ export class ActivationEngine {
502
502
  // top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
503
503
  // cheap (<5ms total).
504
504
  const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
505
+ // 0.14.4: decay clock is overridable (query.now) so evals against a frozen
506
+ // snapshot are reproducible across days. See ActivationQuery.now.
507
+ const nowMs = query.now ?? Date.now();
505
508
  const scored = candidates.map(engram => {
506
- const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
509
+ const ageDays = (nowMs - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
507
510
  const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
508
511
 
509
512
  // --- Text relevance (keyword signals) ---
@@ -638,7 +641,7 @@ export class ActivationEngine {
638
641
  candidateMap.set(r.engram.id, r.engram);
639
642
  // Score the new candidate
640
643
  const engram = r.engram;
641
- const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
644
+ const ageDays = (nowMs - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
642
645
  const associations = await this.store.getAssociationsFor(engram.id);
643
646
  const cTokens = tokenize(engram.concept);
644
647
  const ctTokens = tokenize(engram.content);
@@ -1222,8 +1225,9 @@ export class ActivationEngine {
1222
1225
 
1223
1226
  // Log activation event for eval
1224
1227
  const latencyMs = performance.now() - startTime;
1228
+ const eventId = randomUUID();
1225
1229
  await this.store.logActivationEvent({
1226
- id: randomUUID(),
1230
+ id: eventId,
1227
1231
  agentId: query.agentId,
1228
1232
  timestamp: new Date(),
1229
1233
  context: query.context,
@@ -1232,11 +1236,22 @@ export class ActivationEngine {
1232
1236
  latencyMs,
1233
1237
  engramIds: activatedIds,
1234
1238
  });
1239
+ // 0.14.3: expose the event id so callers can hand it back on
1240
+ // memory_feedback. Before this, the id was generated here and dropped —
1241
+ // every one of the 872 retrieval_feedback rows in the live store has
1242
+ // activation_event_id = NULL, so feedback could never be joined to the
1243
+ // recall it judged. Stamped on each result (same value across the set,
1244
+ // like `confidence`) so the MCP layer can render it once in the footer.
1245
+ for (const r of results) r.activationEventId = eventId;
1246
+ this.lastActivationEventId = eventId;
1235
1247
  }
1236
1248
 
1237
1249
  return results;
1238
1250
  }
1239
1251
 
1252
+ /** Id of the most recent non-internal activation this engine logged (0.14.3). */
1253
+ lastActivationEventId: string | null = null;
1254
+
1240
1255
  /**
1241
1256
  * Multi-graph traversal (MAGMA-inspired).
1242
1257
  *
@@ -75,6 +75,8 @@ export class EvalEngine {
75
75
  activationCount: activationStats.count,
76
76
  avgPrecisionAtK: precision,
77
77
  avgLatencyMs: activationStats.avgLatencyMs,
78
+ p50LatencyMs: activationStats.p50LatencyMs,
79
+ p90LatencyMs: activationStats.p90LatencyMs,
78
80
  p95LatencyMs: activationStats.p95LatencyMs,
79
81
 
80
82
  totalEdges: allAssociations.length,
@@ -104,4 +106,36 @@ export class EvalEngine {
104
106
  Math.max(activeEngrams.length + retractedEngrams.length, 1),
105
107
  };
106
108
  }
109
+
110
+ /**
111
+ * 0.14.3: outcome-shaped usage numbers for memory_stats. Each of these can
112
+ * move in BOTH directions, unlike edge utility. Cheap: one pass over the
113
+ * agent's engrams plus two count queries.
114
+ */
115
+ async computeUsage(agentId: string): Promise<UsageMetrics> {
116
+ const active = await this.store.getEngramsByAgent(agentId, 'active');
117
+ const since30 = new Date(Date.now() - 30 * 24 * 3600_000);
118
+ const writes30d = active.filter(e => e.createdAt >= since30).length;
119
+ const neverRecalled = active.filter(e => e.accessCount === 0).length;
120
+ const act30 = await this.store.getActivationStats(agentId, 30 * 24);
121
+ const fb = await this.store.getLinkedFeedbackStats(agentId, 7 * 24);
122
+ return {
123
+ writes30d,
124
+ recalls30d: act30.count,
125
+ recallsPerWrite30d: writes30d > 0 ? act30.count / writes30d : 0,
126
+ neverRecalledShare: active.length > 0 ? neverRecalled / active.length : 0,
127
+ feedbackLinked7d: fb.total,
128
+ usefulShare7d: fb.total > 0 ? fb.useful / fb.total : 0,
129
+ };
130
+ }
131
+ }
132
+
133
+ export interface UsageMetrics {
134
+ writes30d: number;
135
+ recalls30d: number;
136
+ recallsPerWrite30d: number;
137
+ neverRecalledShare: number;
138
+ /** feedback rows in the window that carry an activation_event_id (0.14.3+) */
139
+ feedbackLinked7d: number;
140
+ usefulShare7d: number;
107
141
  }
@@ -36,7 +36,12 @@ import { packRecallByBudget, estimateTokens, type PackedRecall } from '../core/t
36
36
 
37
37
  /** Shape returned by the sidecar's `activate` dependency. */
38
38
  export interface PrimeCandidate {
39
- engram: { id: string; concept: string; content: string; memoryClass?: string; validTo?: string | null };
39
+ engram: {
40
+ id: string; concept: string; content: string; memoryClass?: string;
41
+ validTo?: string | null;
42
+ /** Used for the age label; Date from the store, string over HTTP. */
43
+ createdAt?: Date | string | null;
44
+ };
40
45
  score: number;
41
46
  summary?: string;
42
47
  confidence?: number;
@@ -64,21 +69,31 @@ export interface PrimeResult {
64
69
  export const PRIME_DEFAULTS = {
65
70
  maxTokens: 600,
66
71
  /**
67
- * 0.25 = the "balanced" threshold AWM's own recall docs recommend for
68
- * acting on a memory. Priming is acting on it without being asked, so it
69
- * should not be looser than the value the docs suggest for deliberate use.
72
+ * 0.10 (was 0.25 until 0.14.6). The confidence this gates on describes the
73
+ * SHAPE of the score distribution — how far the top results stand out from
74
+ * the rest — not relevance. Measured 2026-09-11 on a 30k-memory store: at
75
+ * 0.25 two specific prompts with one clear winner (top score 0.26 and 0.38)
76
+ * were silenced while a vague prompt passed at 0.92. Those specific prompts
77
+ * are exactly what priming exists for. 0.10 passed all three and still
78
+ * abstains on the garbage case (confidence ≈ 0.05).
70
79
  */
71
- minConfidence: 0.25,
80
+ minConfidence: 0.10,
72
81
  minScore: 0.10,
73
82
  } as const;
74
83
 
75
84
  /** One injected line. Deliberately terser than the MCP recall format — this is
76
85
  * context the agent didn't ask for, so it should read as a brief note, not a
77
86
  * report. The id is retained so the agent can act on it (feedback, supersede). */
78
- function formatPrimeLine(c: PrimeCandidate): string {
87
+ function formatPrimeLine(c: PrimeCandidate, now: number = Date.now()): string {
79
88
  const body = c.summary ?? c.engram.content;
80
89
  const validity = c.engram.validTo ? ` [valid until ${c.engram.validTo}]` : '';
81
- return `- ${c.engram.concept}${validity} [${c.engram.id}]: ${body}`;
90
+ // Class + age make the volatility rubric mechanical: a 90-day-old working
91
+ // memory reads differently from a 2-day-old canonical one, and the agent
92
+ // should not have to look either up before deciding how much to trust it.
93
+ const cls = c.engram.memoryClass ?? 'working';
94
+ const created = c.engram.createdAt ? new Date(c.engram.createdAt as any).getTime() : NaN;
95
+ const age = Number.isFinite(created) ? ` · ${Math.max(0, Math.round((now - created) / 86_400_000))}d` : '';
96
+ return `- [${cls}${age}] ${c.engram.concept}${validity} [${c.engram.id}]: ${body}`;
82
97
  }
83
98
 
84
99
  /**
@@ -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
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import { readFileSync } from 'node:fs';
33
+ import { deriveAgentFromDir as deriveAgentFromDirShared } from './core/agent-id.js';
33
34
  import { resolve, basename } from 'node:path';
34
35
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
35
36
 
@@ -111,19 +112,21 @@ const BACKEND: StoreBackend = getConfiguredBackend();
111
112
  const DB_PATH = process.env.AWM_DB_PATH ?? (BACKEND === 'pglite' ? 'memory-pglite' : 'memory.db');
112
113
 
113
114
  // Fallback agent selection when AWM_AGENT_ID/WORKER_NAME are unset: derive from
114
- // the project directory so plain `claude` launches still bind to the right
115
- // store. Personal-Projects -> 'personal'; everything else -> 'work' (the
116
- // primary store). MUST stay in sync with the SessionStart hook
117
- // (~/.claude/hooks/awm-session-start.ps1) so the hook's restore and the
118
- // server's reads/writes never diverge. Guard the AWM package's own path
119
- // (it lives under Personal-Projects) so a stray server cwd can't mis-bind.
120
- function deriveAgentFromDir(): string {
121
- const dir = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replace(/\\/g, '/');
122
- if (/\/AgentSynapse\//i.test(dir)) return 'work';
123
- return /\/Personal-Projects(\/|$)/i.test(dir) ? 'personal' : 'work';
124
- }
115
+ // the project directory (core/agent-id.ts — one definition shared with `awm setup`
116
+ // and mirrored in the shipped hook scripts, so the server, the installer and the
117
+ // hooks always name the same pool for a given directory).
118
+ const deriveAgentFromDir = () => deriveAgentFromDirShared(process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
125
119
  const AGENT_ID = process.env.AWM_AGENT_ID ?? process.env.WORKER_NAME ?? deriveAgentFromDir();
126
120
  const HOOK_PORT = parseInt(process.env.AWM_HOOK_PORT ?? '8401', 10);
121
+ // 0.14.2: ports to try upward from HOOK_PORT when it is busy (see sidecar.ts).
122
+ const HOOK_PORT_RANGE = Math.max(1, parseInt(process.env.AWM_HOOK_PORT_RANGE ?? '10', 10) || 10);
123
+ // Set once the sidecar starts; whoami reads the bound port from it.
124
+ let sidecarHandle: { boundPort: () => number | null } | null = null;
125
+ function sidecarPortLabel(): string {
126
+ const p = sidecarHandle?.boundPort() ?? null;
127
+ if (p === null) return `not bound (preferred ${HOOK_PORT}; hooks disabled)`;
128
+ return p === HOOK_PORT ? `127.0.0.1:${p}` : `127.0.0.1:${p} (preferred ${HOOK_PORT} was busy)`;
129
+ }
127
130
  const HOOK_SECRET = process.env.AWM_HOOK_SECRET ?? null;
128
131
 
129
132
  initLogger(DB_PATH);
@@ -521,10 +524,15 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
521
524
  };
522
525
  }
523
526
 
527
+ // 0.14.3: surface the activation event id once, so memory_feedback can join
528
+ // to this recall. Rendered after the token footer; cheap (one short line).
529
+ const evId = results[0]?.activationEventId;
530
+ const evFooter = evId ? `\n[recall_id: ${evId}]` : '';
531
+
524
532
  return {
525
533
  content: [{
526
534
  type: 'text' as const,
527
- text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens),
535
+ text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens) + evFooter,
528
536
  }],
529
537
  };
530
538
  }
@@ -539,9 +547,19 @@ Always call this after using a recalled memory so the system learns what's valua
539
547
  engram_id: z.string().describe('ID of the memory (from memory_recall results)'),
540
548
  useful: z.boolean().describe('Was this memory actually helpful?'),
541
549
  context: z.string().optional().describe('Brief note on why it was/wasn\'t useful'),
550
+ recall_id: z.string().optional().describe(
551
+ 'The [recall_id: …] printed at the end of the memory_recall output that returned this memory. '
552
+ + 'Pass it so the feedback is joined to that recall; omitted, it defaults to the most recent recall in this session.',
553
+ ),
542
554
  },
543
555
  async (params) => {
544
- await store.logRetrievalFeedback(null, params.engram_id, params.useful, params.context ?? '');
556
+ // 0.14.3: join feedback to the recall that produced it. Explicit recall_id
557
+ // wins; otherwise fall back to the engine's most recent logged activation —
558
+ // the common case is "recall, use, feedback" in one turn. Before this the
559
+ // MCP path hardcoded null (the HTTP route already accepted the id), which
560
+ // is why every retrieval_feedback row in the live store was orphaned.
561
+ const eventId = params.recall_id ?? activationEngine.lastActivationEventId ?? null;
562
+ await store.logRetrievalFeedback(eventId, params.engram_id, params.useful, params.context ?? '');
545
563
 
546
564
  const engram = await store.getEngram(params.engram_id);
547
565
  if (engram) {
@@ -645,7 +663,7 @@ server.tool(
645
663
  `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
664
  {},
647
665
  async () => {
648
- const info = await buildWhoami(store, AGENT_ID, 'mcp');
666
+ const info = await buildWhoami(store, AGENT_ID, 'mcp', sidecarHandle ? sidecarHandle.boundPort() : undefined);
649
667
  return { content: [{ type: 'text', text: formatWhoami(info) }] };
650
668
  },
651
669
  );
@@ -658,6 +676,11 @@ Also shows the activity log path so the user can tail it to see what's happening
658
676
  async () => {
659
677
  const metrics = await evalEngine.computeMetrics(AGENT_ID);
660
678
  const checkpoint = await store.getCheckpoint(AGENT_ID);
679
+ // 0.14.3: outcome numbers, not activity counters. "Edge utility" (share of
680
+ // edges ever activated) was monotone — it could only rise — and read as
681
+ // health when it was not. Latency was a mean over a column that mixes cold
682
+ // loads and stalls (live store: mean 20 s, median 1.6 s); medians only now.
683
+ const usage = await evalEngine.computeUsage(AGENT_ID);
661
684
  const lines = [
662
685
  `Agent: ${AGENT_ID}`,
663
686
  `Active memories: ${metrics.activeEngramCount}`,
@@ -665,9 +688,12 @@ Also shows the activity log path so the user can tail it to see what's happening
665
688
  `Retracted: ${metrics.retractedCount}`,
666
689
  `Avg confidence: ${metrics.avgConfidence.toFixed(3)}`,
667
690
  `Total edges: ${metrics.totalEdges}`,
668
- `Edge utility: ${(metrics.edgeUtilityRate * 100).toFixed(1)}%`,
691
+ ``,
692
+ `Write:recall (30d): 1 : ${usage.recallsPerWrite30d.toFixed(2)} (${usage.writes30d} writes, ${usage.recalls30d} recalls)`,
693
+ `Never recalled: ${(usage.neverRecalledShare * 100).toFixed(0)}% of active memories`,
694
+ `Recall→use (7d): ${usage.feedbackLinked7d > 0 ? (usage.usefulShare7d * 100).toFixed(0) + '% useful of ' + usage.feedbackLinked7d + ' judged' : 'no linked feedback yet'}`,
669
695
  `Activations (24h): ${metrics.activationCount}`,
670
- `Avg latency: ${metrics.avgLatencyMs.toFixed(1)}ms`,
696
+ `Recall latency (24h): p50 ${metrics.p50LatencyMs.toFixed(0)}ms p90 ${metrics.p90LatencyMs.toFixed(0)}ms`,
671
697
  ``,
672
698
  `Session writes: ${checkpoint?.auto.writeCountSinceConsolidation ?? 0}`,
673
699
  `Session recalls: ${checkpoint?.auto.recallCountSinceConsolidation ?? 0}`,
@@ -675,7 +701,10 @@ Also shows the activity log path so the user can tail it to see what's happening
675
701
  `Checkpoint: ${checkpoint?.executionState ? checkpoint.executionState.currentTask : 'none'}`,
676
702
  ``,
677
703
  `Activity log: ${getLogPath() ?? 'not configured'}`,
678
- `Hook sidecar: 127.0.0.1:${HOOK_PORT}`,
704
+ // Report the port actually BOUND, not the one configured. Before 0.14.2
705
+ // this line said 8401 in every session, including the ones whose sidecar
706
+ // had lost the port and silently disabled hooks.
707
+ `Hook sidecar: ${sidecarPortLabel()}`,
679
708
  ];
680
709
 
681
710
  return {
@@ -1336,6 +1365,8 @@ async function main() {
1336
1365
  agentId: AGENT_ID,
1337
1366
  secret: HOOK_SECRET,
1338
1367
  port: HOOK_PORT,
1368
+ portRange: HOOK_PORT_RANGE,
1369
+ version: VERSION,
1339
1370
  // 0.12.2: warm recall for hooks — the sidecar shares this process's
1340
1371
  // activation engine and loaded models, so a UserPromptSubmit hook can get
1341
1372
  // warm-latency recall without any standing server. Trimmed result shape
@@ -1419,7 +1450,8 @@ async function main() {
1419
1450
 
1420
1451
  // Log to stderr (stdout is reserved for MCP protocol)
1421
1452
  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)'}`);
1453
+ sidecarHandle = sidecar;
1454
+ 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
1455
 
1424
1456
  // Clean shutdown
1425
1457
  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>(