agent-working-memory 0.14.5 → 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.
@@ -14,6 +14,7 @@ import { randomBytes } from 'node:crypto';
14
14
  import { homedir as osHomedir } from 'node:os';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import type { SetupContext } from './types.js';
17
+ import { deriveAgentFromDir } from '../core/agent-id.js';
17
18
 
18
19
  const __filename = fileURLToPath(import.meta.url);
19
20
  const __dirname = dirname(__filename);
@@ -47,20 +48,48 @@ export function resolveHookSecret(dbPath: string): string {
47
48
  return secret;
48
49
  }
49
50
 
50
- /** Build environment variables for the MCP server process. */
51
+ /**
52
+ * Retrieval settings `awm setup` turns on for a new install (0.14.6).
53
+ *
54
+ * All three ship default-off in the engine so an existing process never changes
55
+ * behaviour on upgrade, but the README has recommended them together since 0.14.0:
56
+ * second-stage rerank by the cross-encoder's own score, a 400-char rerank window
57
+ * on the densest query-term region (25% → 87.5% on long memories), and tags in the
58
+ * rerank passage (+7.4pp). A value the user already has in their config wins.
59
+ */
60
+ export const RECOMMENDED_ENV: Readonly<Record<string, string>> = {
61
+ AWM_RERANK2: '1',
62
+ AWM_RERANK_WINDOW: 'query',
63
+ AWM_RERANK_TAGS: '1',
64
+ };
65
+
66
+ /** Keys `awm setup` owns outright; everything else in an existing env block is the user's. */
67
+ export const OWNED_ENV_KEYS = ['AWM_DB_PATH', 'AWM_AGENT_ID', 'AWM_HOOK_PORT', 'AWM_HOOK_PORT_RANGE', 'AWM_HOOK_SECRET'] as const;
68
+
69
+ /**
70
+ * Build environment variables for the MCP server process.
71
+ *
72
+ * Layering, lowest to highest: recommended defaults → whatever the user's current
73
+ * entry already carries (rerank flags they tuned, AWM_WORKSPACE, backend choice…)
74
+ * → the keys setup owns. Before 0.14.6 the entry was replaced wholesale, which
75
+ * dropped hand-set flags on every re-run.
76
+ */
51
77
  export function buildEnvVars(
52
78
  dbPath: string,
53
79
  agentId: string,
54
80
  hookPort: string,
55
81
  hookSecret: string,
56
82
  isWindows: boolean,
83
+ opts: { hookPortRange?: string; existing?: Record<string, string> | null } = {},
57
84
  ): Record<string, string> {
58
- return {
59
- AWM_DB_PATH: isWindows ? dbPath.replace(/\\/g, '/') : dbPath,
85
+ const owned: Record<string, string> = {
86
+ AWM_DB_PATH: isWindows ? dbPath.split('\\').join('/') : dbPath,
60
87
  AWM_AGENT_ID: agentId,
61
88
  AWM_HOOK_PORT: hookPort,
62
89
  AWM_HOOK_SECRET: hookSecret,
63
90
  };
91
+ if (opts.hookPortRange && opts.hookPortRange !== '10') owned.AWM_HOOK_PORT_RANGE = opts.hookPortRange;
92
+ return { ...RECOMMENDED_ENV, ...(opts.existing ?? {}), ...owned };
64
93
  }
65
94
 
66
95
  /**
@@ -92,12 +121,26 @@ export function resolveMcpCommand(ctx: SetupContext): {
92
121
  };
93
122
  }
94
123
 
95
- /** Build a full SetupContext from parsed CLI flags. */
124
+ /**
125
+ * Build a full SetupContext from parsed CLI flags.
126
+ *
127
+ * Precedence for every owned value: an explicit flag → the value in the entry a
128
+ * previous `awm setup` wrote (`existingEnv`) → the default. So re-running setup to
129
+ * pick up new hooks or guidance never repoints the database, renames the agent or
130
+ * moves the sidecar port underneath a working install.
131
+ *
132
+ * Global default agent id is `work` (was `claude` before 0.14.6): it matches what
133
+ * the server itself resolves to when AWM_AGENT_ID is unset (see core/agent-id.ts),
134
+ * so a config with and without the key name the same pool.
135
+ */
96
136
  export function buildSetupContext(opts: {
97
137
  agentId?: string;
98
138
  dbPath?: string | null;
99
139
  isGlobal: boolean;
100
- hookPort: string;
140
+ hookPort?: string;
141
+ hookPortRange?: string;
142
+ installPrime?: boolean;
143
+ existingEnv?: Record<string, string> | null;
101
144
  }): SetupContext {
102
145
  const cwd = process.cwd();
103
146
  const projectName = basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-');
@@ -106,11 +149,16 @@ export function buildSetupContext(opts: {
106
149
  const mcpDist = join(packageRoot, 'dist', 'mcp.js');
107
150
  const hasDist = existsSync(mcpDist);
108
151
  const isWindows = process.platform === 'win32';
109
-
110
- const agentId = opts.agentId ?? (opts.isGlobal ? 'claude' : projectName);
111
- const dbPath = resolveDbPath(packageRoot, opts.dbPath);
152
+ const existing = opts.existingEnv ?? null;
153
+
154
+ const agentId = opts.agentId
155
+ ?? existing?.AWM_AGENT_ID
156
+ ?? (opts.isGlobal ? deriveAgentFromDir('') : projectName);
157
+ const dbPath = resolveDbPath(packageRoot, opts.dbPath ?? existing?.AWM_DB_PATH ?? null);
158
+ const hookPort = opts.hookPort ?? existing?.AWM_HOOK_PORT ?? '8401';
159
+ const hookPortRange = opts.hookPortRange ?? existing?.AWM_HOOK_PORT_RANGE ?? '10';
112
160
  const hookSecret = resolveHookSecret(dbPath);
113
- const envVars = buildEnvVars(dbPath, agentId, opts.hookPort, hookSecret, isWindows);
161
+ const envVars = buildEnvVars(dbPath, agentId, hookPort, hookSecret, isWindows, { hookPortRange, existing });
114
162
 
115
163
  return {
116
164
  cwd,
@@ -122,16 +170,21 @@ export function buildSetupContext(opts: {
122
170
  mcpScript,
123
171
  hasDist,
124
172
  hookSecret,
125
- hookPort: opts.hookPort,
173
+ hookPort,
174
+ hookPortRange,
175
+ installPrime: opts.installPrime ?? true,
126
176
  isGlobal: opts.isGlobal,
127
177
  isWindows,
128
178
  envVars,
129
179
  };
130
180
  }
131
181
 
132
- /** Home directory. */
182
+ /**
183
+ * Home directory. `AWM_SETUP_HOME` overrides it so `awm setup` / `awm doctor` can be
184
+ * exercised against a scratch tree (tests do this; so does a cautious upgrade).
185
+ */
133
186
  export function homedir(): string {
134
- return osHomedir();
187
+ return process.env.AWM_SETUP_HOME || osHomedir();
135
188
  }
136
189
 
137
190
  // ─── Instruction content ────────────────────────────────
@@ -282,7 +335,12 @@ you write them; AWM stays current because every agent reads + writes the same st
282
335
  than one filesystem search.
283
336
  4. **As you learn things**: call \`memory_write\` proactively. Don't batch.
284
337
  5. **Finishing a task**: call \`memory_task_end\` with a summary.
285
- 6. **Auto-checkpoint** is handled by hooks (compaction, session-end, 15-min timer). No action needed.
338
+ 6. **Hooks do the rest.** \`awm setup\` installs three: a **prime** hook that recalls against
339
+ each prompt and injects what clears confidence (or nothing), and checkpoint hooks on
340
+ compaction and session end (plus a 15-min timer). Primed context arrives labelled
341
+ \`[class · age]\` — treat it by the volatility rubric, not as user input. If a turn
342
+ arrives with no primed context, that is a signal too: recall explicitly before
343
+ asserting anything.
286
344
 
287
345
  ### Write memory when:
288
346
  - A project decision is made or changed
@@ -478,10 +536,9 @@ When it isn't:
478
536
  return nothing, the memory probably isn't there — read the code instead of
479
537
  burning more recalls.
480
538
 
481
- ### Recall tuning (0.8.x — opt-in parameters for higher-quality recall)
482
- Default \`memory_recall\` is tuned for the common case. The 0.8.x recall pipeline
483
- exposes four opt-in parameters that change the cost/quality tradeoff. Use them
484
- when the default doesn't match what you actually need.
539
+ ### Recall tuning — opt-in parameters
540
+ Default \`memory_recall\` is tuned for the common case. Four opt-in parameters change
541
+ the cost/quality tradeoff. Use them when the default doesn't match what you need.
485
542
 
486
543
  - **\`granularity: 'compact'\`** — every result carries a 200-char \`summary\`
487
544
  field with a query-aware snippet (the densest window of query terms in the
@@ -492,30 +549,37 @@ when the default doesn't match what you actually need.
492
549
  winner, it gets a longer summary while the rest are compact. If confidence
493
550
  is uniform across results, everything is compact. Use when you don't know
494
551
  in advance whether one result will dominate.
495
- - **\`require_confidence: 0.10 | 0.25 | 0.40\`** — opt-in abstention. AWM
496
- returns \`[]\` instead of low-confidence noise. Use when you're about to ACT
497
- on the recalled fact (grounding a decision, citing the memory verbatim,
498
- contradicting a prior assumption). Thresholds: \`0.10\` strict — only abstain
499
- on garbage; \`0.25\` balanced; \`0.40\` aggressive — prefer "I don't know"
500
- over "best of bad." When abstention fires (empty result), treat it as a
501
- signal — either the memory genuinely isn't there (read the code) or your
502
- query missed (reformulate). Don't retry without the threshold.
552
+ - **\`require_confidence\`** — abstention. The default (0.05) is already a light
553
+ gate: when the top results don't stand out from the rest, recall returns
554
+ nothing and the reply says **\`RECALL ABSTAINED\`** with the count it withheld.
555
+ An empty result *without* that line is genuine absence. The gate is on the
556
+ SHAPE of the score distribution, not relevance — so raising it silences
557
+ specific questions with one clear winner first (measured: 0.25 abstained on
558
+ two identifier queries scoring 0.26–0.38 while passing a vague one at 0.92).
559
+ Leave it alone for ordinary recall; \`0.10\` is the ceiling that still helps for
560
+ push-style use where nobody asked. When abstention fires, reformulate once
561
+ with the exact identifier, then read the code — don't retry with a lower
562
+ threshold to force an answer.
503
563
  - **\`workspace: "<name>"\`** — hive-mode recall across all agents in the
504
564
  workspace. Use when other agents may have written canonical knowledge you
505
565
  need. Default is agent-scoped (your own memories only). Can also be set
506
- globally via the \`AWM_WORKSPACE\` env var.
566
+ globally via the \`AWM_WORKSPACE\` env var. Memories live in the agent pool
567
+ they were written to; if a session lands in an unexpected pool, \`memory_whoami\`
568
+ says which one before you conclude the memory is missing.
507
569
 
508
570
  ### Keep memory fresh
509
571
  - After recalling a memory, if you observe the real state is different → call
510
572
  \`memory_supersede\` immediately with the corrected version.
511
- - After using a recalled memory: call \`memory_feedback\` (useful/not-useful) so the
512
- activation engine learns what's valuable.
573
+ - After using a recalled memory: call \`memory_feedback\` (useful/not-useful) **with the
574
+ \`recall_id\`** printed at the end of the recall output (\`[recall_id: …]\`). That id
575
+ joins the feedback to the recall that produced it, which is what lets AWM learn
576
+ which recalls were worth their tokens; feedback without it is a bare vote.
513
577
  - If you discover a memory is factually wrong: \`memory_retract\` to remove it.
514
578
  - **If you bypass AWM (file-memory, in-context notes, "I'll just remember"), the memory
515
579
  drifts out of date. The system relies on you to keep it current. This is the #1
516
580
  failure mode.**
517
581
 
518
- ### Cognition recipes — YOU do the thinking, AWM keeps the result (0.11.x)
582
+ ### Cognition recipes — YOU do the thinking, AWM keeps the result
519
583
  AWM contains no LLM. When memory needs real thinking — distilling a repeatable
520
584
  procedure, reflecting on a failure — AWM hands YOU a versioned recipe (prompt +
521
585
  strict output shape) and you run it as a SEPARATE focused pass, then write the
@@ -533,7 +597,7 @@ result back as an ordinary memory with provenance.
533
597
  - Re-deriving the same skill name reinforces the existing memory instead of
534
598
  duplicating it, so don't fear writing a skill you may have written before.
535
599
 
536
- ### Content fade — write-and-forget is safe (0.8.x)
600
+ ### Content fade — write-and-forget is safe
537
601
  Un-recalled engrams gradually fade their content while preserving cue pathways
538
602
  (concept + tags + embedding stay intact). This is Paper 1 — storage
539
603
  degradation. Practical implications:
@@ -548,7 +612,7 @@ degradation. Practical implications:
548
612
  its fade clock. Frequently-recalled memories stay full-fidelity automatically.
549
613
  - **Supersede is the right tool for stale facts.** When you observe a memory
550
614
  is outdated, call \`memory_supersede\` — the new version inherits the old
551
- one's coherent associations (counter-narrative replacement, 0.8.x) so cue
615
+ one's coherent associations (counter-narrative replacement) so cue
552
616
  pathways carry forward to the replacement.
553
617
 
554
618
  ### Example — good vs bad memory_write
@@ -600,7 +664,7 @@ comprehension cost. This is output-only: it never changes the data or your memor
600
664
  - Don't bother for small outputs — it only compresses when the saving is worthwhile
601
665
  and falls back to plain JSON if TOON wouldn't reproduce the data exactly.
602
666
 
603
- ### Backend (SQLite vs PGlite, 0.8.x)
667
+ ### Backend (SQLite vs PGlite)
604
668
  AWM ships two storage backends. The installer picks SQLite by default; both
605
669
  are functionally equivalent for cognitive workloads, but differ in operational
606
670
  guarantees:
@@ -620,15 +684,26 @@ For the comparison table (recall quality parity, BM25 vs \`ts_rank_cd\`,
620
684
  multi-process guarantees), see \`docs/pglite-feature-parity.md\`.
621
685
 
622
686
  ### Diagnostics / escape hatches (env vars, only if you know why)
623
- The 0.7.6→0.7.14 work cut recall latency from 11s to ~300ms. The 0.8.x work
624
- added the write-path rewrite (per-write 300+ ms → under 10ms) and PGlite
625
- parity tuning. Each optimization is gated by an env-var so it can be disabled
626
- for A/B testing if a regression appears in your workload:
627
-
628
- Recall pipeline (0.7.x):
687
+ Each optimisation is gated by an env var so it can be disabled for A/B testing if a
688
+ regression appears in your workload. \`memory_whoami\` prints the active fingerprint.
689
+
690
+ Retrieval (set ON by \`awm setup\` since 0.14.6 — the measured-good configuration):
691
+ - \`AWM_RERANK2=1\` — second-stage rerank by the cross-encoder's own score.
692
+ - \`AWM_RERANK_WINDOW=query\` — 400-char rerank window on the densest query-term
693
+ region instead of the prefix (25% → 87.5% on long memories).
694
+ - \`AWM_RERANK_TAGS=1\` — tags fed into the rerank passage (+7.4pp s@1).
695
+
696
+ Hook sidecar (0.14.2):
697
+ - \`AWM_HOOK_PORT=8401\` / \`AWM_HOOK_PORT_RANGE=10\` — each MCP process binds the
698
+ first free port in the range, so several sessions can run at once. The shipped
699
+ hooks find their own session's sidecar by probing the range and matching
700
+ \`agentId\` on \`/health\`; \`memory_whoami\` reports the port actually bound.
701
+
702
+ Recall pipeline:
629
703
  - \`AWM_DISABLE_POOL_FILTER=1\` — disables the candidate pool reduction
630
704
  pre-filter in recall. Reverts to scoring all active candidates.
631
- - \`AWM_ENTITY_INDEX_FETCH=1\` — see "Entity index" above (0.12.x, default off).
705
+ - \`AWM_ENTITY_INDEX_FETCH=1\` — see "Entity index" above (default off; measured
706
+ no effect on the real-store benchmark, 0.14.5).
632
707
  - \`AWM_DISABLE_SLIM_CACHE=1\` — disables the in-memory slim cache.
633
708
  Reverts to per-recall SQL fetch + Buffer→Float32Array conversion.
634
709
  - \`AWM_DISABLE_RERANK_SKIP=1\` — disables the cross-encoder skip on
@@ -636,8 +711,8 @@ Recall pipeline (0.7.x):
636
711
  - \`AWM_DISABLE_EXPANSION_CACHE=1\` — disables the query expansion skip
637
712
  heuristic + LRU cache. Forces every recall through flan-t5-small.
638
713
 
639
- Write pipeline + lifecycle (0.8.x, plus 0.12.x telemetry):
640
- - \`AWM_SLOW_WRITE_MS=250\` (0.12.x) — any write slower than this logs one stderr
714
+ Write pipeline + lifecycle:
715
+ - \`AWM_SLOW_WRITE_MS=250\` — any write slower than this logs one stderr
641
716
  line with a phase-time breakdown (embed/novelty/persist, event-loop lag,
642
717
  embed-model cold-load ms). \`0\` disables. Useful for diagnosing why a session's
643
718
  first write/recall feels slow.
@@ -653,7 +728,7 @@ Write pipeline + lifecycle (0.8.x, plus 0.12.x telemetry):
653
728
  - \`AWM_GRANULARITY_FULL_LEN=1000\` — char budget for the top result in
654
729
  \`granularity: 'auto'\` mode when there's a clear winner.
655
730
 
656
- PGlite backend (0.8.x):
731
+ PGlite backend:
657
732
  - \`AWM_PGLITE_BM25_M=1\` — multiplier on PGlite \`ts_rank_cd\` to calibrate
658
733
  against SQLite FTS5 BM25 distribution. M=1 (default) is passthrough;
659
734
  higher M boosts PGlite scores at the cost of recall-ranking precision
@@ -0,0 +1,370 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Claude Code hook scripts shipped by `awm setup` (0.14.6).
6
+ *
7
+ * WHY SCRIPTS AND NOT INLINE CURL
8
+ * -------------------------------
9
+ * Until 0.14.5 `awm setup` wrote PreCompact/SessionEnd hooks as one-line curl
10
+ * commands with the sidecar port and the bearer secret baked into
11
+ * settings.json. Three things broke, each silently:
12
+ *
13
+ * 1. The port was fixed (8401, falling back to 8402). Since 0.14.2 every MCP
14
+ * process binds the first free port in 8401..8410, so a session that lands
15
+ * on 8403 had no hooks at all — and with two pools open, the fallback
16
+ * posted a work session's checkpoint into the personal pool's process.
17
+ * 2. The secret was a literal in settings.json. When the secret file was
18
+ * regenerated the hooks got 401s, and `curl -sf` hid them. Observed twice.
19
+ * 3. There was no prime hook. The only mechanism that injects memory without
20
+ * the model choosing to call recall was a documentation page.
21
+ *
22
+ * These scripts resolve everything at run time: which agent this session is
23
+ * (from the MCP config that governs its cwd), which sidecar belongs to that
24
+ * agent (by probing the port range and reading /health), and the secret (from
25
+ * the same config, or the secret file next to the database). Nothing here is
26
+ * baked in at setup time except a fallback record in ~/.claude/hooks/awm-hooks.json.
27
+ *
28
+ * Every script fails OPEN: any problem prints nothing and exits 0. A hook that
29
+ * blocks a prompt or a compaction is worse than no hook.
30
+ *
31
+ * The JavaScript bodies deliberately avoid template literals so they can live
32
+ * inside these TypeScript template strings without escaping.
33
+ */
34
+
35
+ /** Bumped when any shipped script changes; written into awm-hooks.json and the
36
+ * script headers so `awm doctor` can tell a stale install from a current one. */
37
+ export const AWM_HOOKS_VERSION = '0.14.6';
38
+
39
+ /** File whose presence turns the prime hook off without uninstalling it. */
40
+ export const PRIME_DISABLE_FILE = 'awm-prime.disabled';
41
+
42
+ const HEADER = (name: string, purpose: string) => `#!/usr/bin/env node
43
+ // ${name} — installed by \`awm setup\` (hooks v${AWM_HOOKS_VERSION}). ${purpose}
44
+ // Re-running \`awm setup\` rewrites this file; put local changes elsewhere.
45
+ // Fails open: on any problem it prints nothing and exits 0.
46
+ `;
47
+
48
+ // ─── awm-find-sidecar.cjs ────────────────────────────────────────────────────
49
+
50
+ export const FIND_SIDECAR_SCRIPT = HEADER(
51
+ 'awm-find-sidecar.cjs',
52
+ 'Shared resolver: which AWM sidecar belongs to THIS session.',
53
+ ) + `
54
+ // Since AWM 0.14.2 each MCP process binds the first free port in
55
+ // [AWM_HOOK_PORT, +AWM_HOOK_PORT_RANGE), so "the sidecar is on 8401" is not a
56
+ // safe assumption: with N sessions open there are N sidecars and the one on
57
+ // 8401 may serve a different agent pool. This module (1) works out which agent
58
+ // a session started in \`cwd\` belongs to, from the MCP config that governs that
59
+ // directory, (2) probes the whole range in parallel (local, ~10 ms), and
60
+ // (3) returns the responder whose agentId matches — newest version first, then
61
+ // lowest port. No match → null, and callers fail open.
62
+ 'use strict';
63
+ const fs = require('fs');
64
+ const os = require('os');
65
+ const path = require('path');
66
+ const http = require('http');
67
+
68
+ const SERVER_NAME = 'agent-working-memory';
69
+ const PROBE_TIMEOUT_MS = 400;
70
+
71
+ function norm(p) { return String(p || '').replace(/\\\\/g, '/').replace(/\\/+$/, '').toLowerCase(); }
72
+ function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return null; } }
73
+ function readText(p) { try { return fs.readFileSync(p, 'utf8').trim(); } catch (e) { return ''; } }
74
+
75
+ // MIRROR of deriveAgentFromDir() in src/core/agent-id.ts — the server's own
76
+ // fallback when AWM_AGENT_ID is unset. Kept in sync by tests/adapters/hook-scripts.test.ts.
77
+ function deriveAgentFromDir(dir) {
78
+ const d = String(dir || '').replace(/\\\\/g, '/');
79
+ if (/\\/AgentSynapse\\//i.test(d)) return 'work';
80
+ return /\\/Personal-Projects(\\/|$)/i.test(d) ? 'personal' : 'work';
81
+ }
82
+
83
+ // The AWM entry inside one MCP config file, if any. ~/.claude.json keeps
84
+ // project-scoped servers under projects[<path>].mcpServers.
85
+ function awmEntry(json, cwd) {
86
+ if (!json) return null;
87
+ if (json.mcpServers && json.mcpServers[SERVER_NAME]) return json.mcpServers[SERVER_NAME];
88
+ if (json.projects && cwd) {
89
+ for (const k of Object.keys(json.projects)) {
90
+ if (norm(k) !== norm(cwd)) continue;
91
+ const s = json.projects[k] && json.projects[k].mcpServers;
92
+ if (s && s[SERVER_NAME]) return s[SERVER_NAME];
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Resolve the configuration that governs a session started in \`cwd\`:
100
+ * { agentId, secret, port, range, dbPath, source }.
101
+ *
102
+ * Order: the nearest .mcp.json / .claude/mcp.json walking up from cwd, then
103
+ * ~/.mcp.json, then ~/.claude.json, then the record \`awm setup\` left in
104
+ * ~/.claude/hooks/awm-hooks.json. Process env always wins for a given key.
105
+ */
106
+ function resolveConfig(cwd) {
107
+ cwd = cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
108
+ const home = os.homedir();
109
+ const candidates = [];
110
+ let dir = path.resolve(cwd);
111
+ for (let i = 0; i < 16; i++) {
112
+ candidates.push(path.join(dir, '.mcp.json'), path.join(dir, '.claude', 'mcp.json'));
113
+ const parent = path.dirname(dir);
114
+ if (parent === dir) break;
115
+ dir = parent;
116
+ }
117
+ candidates.push(path.join(home, '.mcp.json'), path.join(home, '.claude.json'));
118
+
119
+ let entry = null, source = null;
120
+ for (const c of candidates) {
121
+ const e = awmEntry(readJson(c), cwd);
122
+ if (e) { entry = e; source = c; break; }
123
+ }
124
+ const env = (entry && entry.env) || {};
125
+ const fallback = readJson(path.join(home, '.claude', 'hooks', 'awm-hooks.json')) || {};
126
+
127
+ const agentId = process.env.AWM_AGENT_ID || env.AWM_AGENT_ID
128
+ || (entry ? deriveAgentFromDir(cwd) : (fallback.agentId || deriveAgentFromDir(cwd)));
129
+ const dbPath = env.AWM_DB_PATH || fallback.dbPath || null;
130
+ let secret = process.env.AWM_HOOK_SECRET || env.AWM_HOOK_SECRET || '';
131
+ if (!secret && dbPath) secret = readText(path.join(path.dirname(dbPath), '.awm-hook-secret'));
132
+ if (!secret && fallback.secretPath) secret = readText(fallback.secretPath);
133
+ const port = parseInt(process.env.AWM_HOOK_PORT || env.AWM_HOOK_PORT || fallback.hookPort || '8401', 10) || 8401;
134
+ const range = Math.max(1, parseInt(process.env.AWM_HOOK_PORT_RANGE || env.AWM_HOOK_PORT_RANGE || fallback.hookPortRange || '10', 10) || 10);
135
+ return { agentId: agentId, secret: secret, port: port, range: range, dbPath: dbPath, source: source };
136
+ }
137
+
138
+ function probe(port) {
139
+ return new Promise(function (resolve) {
140
+ const req = http.get({ host: '127.0.0.1', port: port, path: '/health', timeout: PROBE_TIMEOUT_MS }, function (res) {
141
+ let data = '';
142
+ res.on('data', function (c) { data += c; });
143
+ res.on('end', function () {
144
+ try {
145
+ const j = JSON.parse(data);
146
+ resolve(j && j.sidecar ? { port: port, agentId: j.agentId || null, version: j.version || '0.0.0', pid: j.pid || null } : null);
147
+ } catch (e) { resolve(null); }
148
+ });
149
+ });
150
+ req.on('error', function () { resolve(null); });
151
+ req.on('timeout', function () { req.destroy(); resolve(null); });
152
+ });
153
+ }
154
+
155
+ function versionKey(v) { return String(v || '0').split('.').map(function (n) { return parseInt(n, 10) || 0; }); }
156
+ function newer(a, b) {
157
+ const ka = versionKey(a), kb = versionKey(b);
158
+ for (let i = 0; i < Math.max(ka.length, kb.length); i++) {
159
+ const d = (ka[i] || 0) - (kb[i] || 0);
160
+ if (d) return d > 0;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /** Every live sidecar in the configured range, regardless of agent. */
166
+ async function listSidecars(cfg) {
167
+ const ports = [];
168
+ for (let i = 0; i < cfg.range; i++) ports.push(cfg.port + i);
169
+ return (await Promise.all(ports.map(probe))).filter(Boolean);
170
+ }
171
+
172
+ /**
173
+ * The sidecar for this session, or null. Resolves to
174
+ * { sidecar: {port, agentId, version, pid} | null, config, all: [...] }.
175
+ */
176
+ async function findSidecar(cwd) {
177
+ const cfg = resolveConfig(cwd);
178
+ const all = await listSidecars(cfg);
179
+ const mine = all.filter(function (s) { return s.agentId === cfg.agentId; });
180
+ mine.sort(function (a, b) { return newer(a.version, b.version) ? -1 : newer(b.version, a.version) ? 1 : a.port - b.port; });
181
+ return { sidecar: mine[0] || null, config: cfg, all: all };
182
+ }
183
+
184
+ /** Optional trace for troubleshooting: AWM_HOOK_DEBUG=1 appends to ~/.claude/hooks/awm-hooks.log. */
185
+ function debugLog(hook, fields) {
186
+ if (!process.env.AWM_HOOK_DEBUG) return;
187
+ try {
188
+ const line = Object.assign({ ts: new Date().toISOString(), hook: hook }, fields);
189
+ fs.appendFileSync(path.join(os.homedir(), '.claude', 'hooks', 'awm-hooks.log'), JSON.stringify(line) + '\\n');
190
+ } catch (e) { /* never matters */ }
191
+ }
192
+
193
+ module.exports = { resolveConfig, findSidecar, listSidecars, deriveAgentFromDir, debugLog, PROBE_TIMEOUT_MS };
194
+ `;
195
+
196
+ // ─── awm-checkpoint.cjs ──────────────────────────────────────────────────────
197
+
198
+ export const CHECKPOINT_HOOK_SCRIPT = HEADER(
199
+ 'awm-checkpoint.cjs',
200
+ 'PreCompact / SessionEnd: checkpoint this session into ITS agent\'s AWM.',
201
+ ) + `
202
+ // Forwards the whole hook payload (hook_event_name, transcript_path, cwd,
203
+ // session_id) to POST /hooks/checkpoint on the sidecar that belongs to this
204
+ // session's agent. transcript_path matters: the sidecar parses it for the
205
+ // current task and active files, so the checkpoint is a real one, not a stub.
206
+ 'use strict';
207
+ const http = require('http');
208
+
209
+ let finder = null;
210
+ try { finder = require('./awm-find-sidecar.cjs'); } catch (e) { process.exit(0); }
211
+
212
+ const TIMEOUT_MS = 5000;
213
+
214
+ let raw = '';
215
+ process.stdin.on('data', function (d) { raw += d; });
216
+ process.stdin.on('end', function () {
217
+ let payload = {};
218
+ try { payload = JSON.parse(raw || '{}'); } catch (e) { payload = {}; }
219
+ if (!payload.hook_event_name) payload.hook_event_name = 'PreCompact';
220
+ main(payload).catch(function () { process.exit(0); });
221
+ });
222
+ // A hook with nothing on stdin must still exit.
223
+ process.stdin.on('error', function () { process.exit(0); });
224
+
225
+ async function main(payload) {
226
+ const found = await finder.findSidecar(payload.cwd);
227
+ const sc = found.sidecar;
228
+ if (!sc) {
229
+ finder.debugLog('checkpoint', { event: payload.hook_event_name, agent: found.config.agentId, sidecar: null, live: found.all.length });
230
+ process.exit(0);
231
+ }
232
+ const body = JSON.stringify(payload);
233
+ const req = http.request({
234
+ host: '127.0.0.1', port: sc.port, path: '/hooks/checkpoint', method: 'POST',
235
+ headers: {
236
+ 'Content-Type': 'application/json',
237
+ 'Content-Length': Buffer.byteLength(body),
238
+ 'Authorization': 'Bearer ' + found.config.secret,
239
+ },
240
+ timeout: TIMEOUT_MS,
241
+ }, function (res) {
242
+ res.resume();
243
+ res.on('end', function () {
244
+ finder.debugLog('checkpoint', { event: payload.hook_event_name, agent: found.config.agentId, port: sc.port, status: res.statusCode });
245
+ process.exit(0);
246
+ });
247
+ });
248
+ req.on('error', function () { process.exit(0); });
249
+ req.on('timeout', function () { req.destroy(); process.exit(0); });
250
+ req.end(body);
251
+ }
252
+ `;
253
+
254
+ // ─── awm-prime.cjs ───────────────────────────────────────────────────────────
255
+
256
+ export const PRIME_HOOK_SCRIPT = HEADER(
257
+ 'awm-prime.cjs',
258
+ 'UserPromptSubmit: prime the turn with relevant memories, or stay silent.',
259
+ ) + `
260
+ // The PRIME phase of the AWM-native harness (docs/patterns/awm-native-harness.md).
261
+ // Posts the prompt to POST /hooks/prime on this session's sidecar and injects the
262
+ // returned text as additionalContext. The sidecar decides what to inject, formats
263
+ // it, abstains when confidence is low and caps the token budget — see
264
+ // src/hooks/prime.ts — so this file stays thin.
265
+ //
266
+ // Turn it off without uninstalling: create ~/.claude/hooks/${PRIME_DISABLE_FILE}
267
+ // (an empty file). Remove the file to turn it back on. \`awm doctor\` reports which.
268
+ 'use strict';
269
+ const fs = require('fs');
270
+ const os = require('os');
271
+ const path = require('path');
272
+ const http = require('http');
273
+
274
+ let finder = null;
275
+ try { finder = require('./awm-find-sidecar.cjs'); } catch (e) { process.exit(0); }
276
+
277
+ // A slow AWM must never delay a prompt. Warm recall with rerank measures 1.4–1.8 s
278
+ // on a 30k-memory store; 4 s leaves margin. Connection refused returns in ~100 ms.
279
+ const TIMEOUT_MS = 4000;
280
+ const MIN_PROMPT_LEN = 15; // skip "yes" / "ok" / "go on" turns
281
+ // Set-level confidence below which the sidecar injects nothing. 0.10 rather than
282
+ // the 0.25 once documented as "balanced": measured 2026-09-11, 0.25 silenced
283
+ // specific prompts with one clear winner (top score 0.26–0.38) while passing a
284
+ // vague one at 0.92. The gate is on the shape of the score distribution, not on
285
+ // relevance, so specific questions are exactly the ones a high threshold loses.
286
+ const MIN_CONFIDENCE = 0.10;
287
+ const DISABLE_FILE = path.join(os.homedir(), '.claude', 'hooks', '${PRIME_DISABLE_FILE}');
288
+
289
+ let raw = '';
290
+ process.stdin.on('data', function (d) { raw += d; });
291
+ process.stdin.on('end', function () {
292
+ let payload = {};
293
+ try { payload = JSON.parse(raw || '{}'); } catch (e) { process.exit(0); }
294
+ main(payload).catch(function () { process.exit(0); });
295
+ });
296
+ process.stdin.on('error', function () { process.exit(0); });
297
+
298
+ function extractPrompt(j) {
299
+ for (const key of ['prompt', 'message', 'user_prompt', 'text', 'input']) {
300
+ if (typeof j[key] === 'string' && j[key].trim()) return j[key];
301
+ }
302
+ return null;
303
+ }
304
+
305
+ async function main(payload) {
306
+ if (fs.existsSync(DISABLE_FILE)) process.exit(0);
307
+ const prompt = extractPrompt(payload);
308
+ if (!prompt || prompt.trim().length < MIN_PROMPT_LEN) process.exit(0);
309
+
310
+ const found = await finder.findSidecar(payload.cwd);
311
+ const sc = found.sidecar;
312
+ if (!sc) {
313
+ finder.debugLog('prime', { agent: found.config.agentId, sidecar: null, live: found.all.length });
314
+ process.exit(0);
315
+ }
316
+
317
+ const body = JSON.stringify({
318
+ hook_event_name: 'UserPromptSubmit',
319
+ session_id: payload.session_id,
320
+ cwd: payload.cwd,
321
+ prompt: prompt,
322
+ minConfidence: MIN_CONFIDENCE,
323
+ });
324
+ const req = http.request({
325
+ host: '127.0.0.1', port: sc.port, path: '/hooks/prime', method: 'POST',
326
+ headers: {
327
+ 'Content-Type': 'application/json',
328
+ 'Content-Length': Buffer.byteLength(body),
329
+ 'Authorization': 'Bearer ' + found.config.secret,
330
+ },
331
+ timeout: TIMEOUT_MS,
332
+ }, function (res) {
333
+ let data = '';
334
+ res.on('data', function (c) { data += c; });
335
+ res.on('end', function () {
336
+ let out = null;
337
+ try { out = JSON.parse(data); } catch (e) { out = null; }
338
+ const inject = out && typeof out.inject === 'string' ? out.inject : '';
339
+ finder.debugLog('prime', { agent: found.config.agentId, port: sc.port, status: res.statusCode, kept: out && out.kept, total: out && out.total, reason: out && out.reason });
340
+ if (res.statusCode === 200 && inject) {
341
+ process.stdout.write(JSON.stringify({
342
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: inject },
343
+ }));
344
+ }
345
+ process.exit(0);
346
+ });
347
+ });
348
+ req.on('error', function () { process.exit(0); });
349
+ req.on('timeout', function () { req.destroy(); process.exit(0); });
350
+ req.end(body);
351
+ }
352
+ `;
353
+
354
+ /** Everything `awm setup` places in ~/.claude/hooks/. */
355
+ export const HOOK_SCRIPTS: ReadonlyArray<{ file: string; source: string }> = [
356
+ { file: 'awm-find-sidecar.cjs', source: FIND_SIDECAR_SCRIPT },
357
+ { file: 'awm-checkpoint.cjs', source: CHECKPOINT_HOOK_SCRIPT },
358
+ { file: 'awm-prime.cjs', source: PRIME_HOOK_SCRIPT },
359
+ ];
360
+
361
+ /** The fallback record the finder reads when no MCP config governs a cwd. */
362
+ export interface HooksRecord {
363
+ version: string;
364
+ agentId: string;
365
+ dbPath: string;
366
+ secretPath: string;
367
+ hookPort: string;
368
+ hookPortRange: string;
369
+ installedAt: string;
370
+ }