agent-working-memory 0.14.5 → 0.15.4

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 (54) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +131 -122
  3. package/dist/adapters/claude-code.d.ts +9 -0
  4. package/dist/adapters/claude-code.d.ts.map +1 -1
  5. package/dist/adapters/claude-code.js +235 -132
  6. package/dist/adapters/claude-code.js.map +1 -1
  7. package/dist/adapters/common.d.ts +47 -5
  8. package/dist/adapters/common.d.ts.map +1 -1
  9. package/dist/adapters/common.js +111 -41
  10. package/dist/adapters/common.js.map +1 -1
  11. package/dist/adapters/hook-scripts.d.ts +54 -0
  12. package/dist/adapters/hook-scripts.d.ts.map +1 -0
  13. package/dist/adapters/hook-scripts.js +340 -0
  14. package/dist/adapters/hook-scripts.js.map +1 -0
  15. package/dist/adapters/types.d.ts +13 -3
  16. package/dist/adapters/types.d.ts.map +1 -1
  17. package/dist/cli.js +74 -6
  18. package/dist/cli.js.map +1 -1
  19. package/dist/core/agent-id.d.ts +19 -0
  20. package/dist/core/agent-id.d.ts.map +1 -0
  21. package/dist/core/agent-id.js +28 -0
  22. package/dist/core/agent-id.js.map +1 -0
  23. package/dist/core/write-pipeline.js +17 -0
  24. package/dist/core/write-pipeline.js.map +1 -1
  25. package/dist/hooks/prime.d.ts +10 -4
  26. package/dist/hooks/prime.d.ts.map +1 -1
  27. package/dist/hooks/prime.js +16 -6
  28. package/dist/hooks/prime.js.map +1 -1
  29. package/dist/mcp.js +5 -12
  30. package/dist/mcp.js.map +1 -1
  31. package/dist/storage/factory.d.ts.map +1 -1
  32. package/dist/storage/factory.js +20 -8
  33. package/dist/storage/factory.js.map +1 -1
  34. package/package.json +100 -90
  35. package/plugin/.claude-plugin/plugin.json +34 -0
  36. package/plugin/README.md +36 -0
  37. package/plugin/bin/awm-mcp-launcher.cjs +109 -0
  38. package/plugin/hooks/awm-checkpoint.cjs +55 -0
  39. package/plugin/hooks/awm-db-mutation-reminder.cjs +25 -0
  40. package/plugin/hooks/awm-find-sidecar.cjs +145 -0
  41. package/plugin/hooks/awm-prime.cjs +97 -0
  42. package/plugin/hooks/hooks.json +66 -0
  43. package/plugin/skills/awm-memory/SKILL.md +445 -0
  44. package/src/adapters/claude-code.ts +224 -126
  45. package/src/adapters/common.ts +119 -42
  46. package/src/adapters/hook-scripts.ts +370 -0
  47. package/src/adapters/types.ts +14 -3
  48. package/src/cli.ts +76 -5
  49. package/src/core/agent-id.ts +28 -0
  50. package/src/core/write-pipeline.ts +18 -0
  51. package/src/hooks/prime.ts +22 -7
  52. package/src/mcp.ts +5 -11
  53. package/src/plugin/awm-mcp-launcher.cjs +109 -0
  54. package/src/storage/factory.ts +19 -7
@@ -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', 'AWM_CLIENT'] 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; client?: string } = {},
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,28 @@ 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;
144
+ /** Which surface this install is for — stamped onto every write as a `client=` tag. */
145
+ client?: string;
101
146
  }): SetupContext {
102
147
  const cwd = process.cwd();
103
148
  const projectName = basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-');
@@ -106,11 +151,16 @@ export function buildSetupContext(opts: {
106
151
  const mcpDist = join(packageRoot, 'dist', 'mcp.js');
107
152
  const hasDist = existsSync(mcpDist);
108
153
  const isWindows = process.platform === 'win32';
109
-
110
- const agentId = opts.agentId ?? (opts.isGlobal ? 'claude' : projectName);
111
- const dbPath = resolveDbPath(packageRoot, opts.dbPath);
154
+ const existing = opts.existingEnv ?? null;
155
+
156
+ const agentId = opts.agentId
157
+ ?? existing?.AWM_AGENT_ID
158
+ ?? (opts.isGlobal ? deriveAgentFromDir('') : projectName);
159
+ const dbPath = resolveDbPath(packageRoot, opts.dbPath ?? existing?.AWM_DB_PATH ?? null);
160
+ const hookPort = opts.hookPort ?? existing?.AWM_HOOK_PORT ?? '8401';
161
+ const hookPortRange = opts.hookPortRange ?? existing?.AWM_HOOK_PORT_RANGE ?? '10';
112
162
  const hookSecret = resolveHookSecret(dbPath);
113
- const envVars = buildEnvVars(dbPath, agentId, opts.hookPort, hookSecret, isWindows);
163
+ const envVars = buildEnvVars(dbPath, agentId, hookPort, hookSecret, isWindows, { hookPortRange, existing, client: opts.client });
114
164
 
115
165
  return {
116
166
  cwd,
@@ -122,16 +172,21 @@ export function buildSetupContext(opts: {
122
172
  mcpScript,
123
173
  hasDist,
124
174
  hookSecret,
125
- hookPort: opts.hookPort,
175
+ hookPort,
176
+ hookPortRange,
177
+ installPrime: opts.installPrime ?? true,
126
178
  isGlobal: opts.isGlobal,
127
179
  isWindows,
128
180
  envVars,
129
181
  };
130
182
  }
131
183
 
132
- /** Home directory. */
184
+ /**
185
+ * Home directory. `AWM_SETUP_HOME` overrides it so `awm setup` / `awm doctor` can be
186
+ * exercised against a scratch tree (tests do this; so does a cautious upgrade).
187
+ */
133
188
  export function homedir(): string {
134
- return osHomedir();
189
+ return process.env.AWM_SETUP_HOME || osHomedir();
135
190
  }
136
191
 
137
192
  // ─── Instruction content ────────────────────────────────
@@ -282,7 +337,12 @@ you write them; AWM stays current because every agent reads + writes the same st
282
337
  than one filesystem search.
283
338
  4. **As you learn things**: call \`memory_write\` proactively. Don't batch.
284
339
  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.
340
+ 6. **Hooks do the rest.** \`awm setup\` installs three: a **prime** hook that recalls against
341
+ each prompt and injects what clears confidence (or nothing), and checkpoint hooks on
342
+ compaction and session end (plus a 15-min timer). Primed context arrives labelled
343
+ \`[class · age]\` — treat it by the volatility rubric, not as user input. If a turn
344
+ arrives with no primed context, that is a signal too: recall explicitly before
345
+ asserting anything.
286
346
 
287
347
  ### Write memory when:
288
348
  - A project decision is made or changed
@@ -478,10 +538,9 @@ When it isn't:
478
538
  return nothing, the memory probably isn't there — read the code instead of
479
539
  burning more recalls.
480
540
 
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.
541
+ ### Recall tuning — opt-in parameters
542
+ Default \`memory_recall\` is tuned for the common case. Four opt-in parameters change
543
+ the cost/quality tradeoff. Use them when the default doesn't match what you need.
485
544
 
486
545
  - **\`granularity: 'compact'\`** — every result carries a 200-char \`summary\`
487
546
  field with a query-aware snippet (the densest window of query terms in the
@@ -492,30 +551,37 @@ when the default doesn't match what you actually need.
492
551
  winner, it gets a longer summary while the rest are compact. If confidence
493
552
  is uniform across results, everything is compact. Use when you don't know
494
553
  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.
554
+ - **\`require_confidence\`** — abstention. The default (0.05) is already a light
555
+ gate: when the top results don't stand out from the rest, recall returns
556
+ nothing and the reply says **\`RECALL ABSTAINED\`** with the count it withheld.
557
+ An empty result *without* that line is genuine absence. The gate is on the
558
+ SHAPE of the score distribution, not relevance — so raising it silences
559
+ specific questions with one clear winner first (measured: 0.25 abstained on
560
+ two identifier queries scoring 0.26–0.38 while passing a vague one at 0.92).
561
+ Leave it alone for ordinary recall; \`0.10\` is the ceiling that still helps for
562
+ push-style use where nobody asked. When abstention fires, reformulate once
563
+ with the exact identifier, then read the code — don't retry with a lower
564
+ threshold to force an answer.
503
565
  - **\`workspace: "<name>"\`** — hive-mode recall across all agents in the
504
566
  workspace. Use when other agents may have written canonical knowledge you
505
567
  need. Default is agent-scoped (your own memories only). Can also be set
506
- globally via the \`AWM_WORKSPACE\` env var.
568
+ globally via the \`AWM_WORKSPACE\` env var. Memories live in the agent pool
569
+ they were written to; if a session lands in an unexpected pool, \`memory_whoami\`
570
+ says which one before you conclude the memory is missing.
507
571
 
508
572
  ### Keep memory fresh
509
573
  - After recalling a memory, if you observe the real state is different → call
510
574
  \`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.
575
+ - After using a recalled memory: call \`memory_feedback\` (useful/not-useful) **with the
576
+ \`recall_id\`** printed at the end of the recall output (\`[recall_id: …]\`). That id
577
+ joins the feedback to the recall that produced it, which is what lets AWM learn
578
+ which recalls were worth their tokens; feedback without it is a bare vote.
513
579
  - If you discover a memory is factually wrong: \`memory_retract\` to remove it.
514
580
  - **If you bypass AWM (file-memory, in-context notes, "I'll just remember"), the memory
515
581
  drifts out of date. The system relies on you to keep it current. This is the #1
516
582
  failure mode.**
517
583
 
518
- ### Cognition recipes — YOU do the thinking, AWM keeps the result (0.11.x)
584
+ ### Cognition recipes — YOU do the thinking, AWM keeps the result
519
585
  AWM contains no LLM. When memory needs real thinking — distilling a repeatable
520
586
  procedure, reflecting on a failure — AWM hands YOU a versioned recipe (prompt +
521
587
  strict output shape) and you run it as a SEPARATE focused pass, then write the
@@ -533,7 +599,7 @@ result back as an ordinary memory with provenance.
533
599
  - Re-deriving the same skill name reinforces the existing memory instead of
534
600
  duplicating it, so don't fear writing a skill you may have written before.
535
601
 
536
- ### Content fade — write-and-forget is safe (0.8.x)
602
+ ### Content fade — write-and-forget is safe
537
603
  Un-recalled engrams gradually fade their content while preserving cue pathways
538
604
  (concept + tags + embedding stay intact). This is Paper 1 — storage
539
605
  degradation. Practical implications:
@@ -548,7 +614,7 @@ degradation. Practical implications:
548
614
  its fade clock. Frequently-recalled memories stay full-fidelity automatically.
549
615
  - **Supersede is the right tool for stale facts.** When you observe a memory
550
616
  is outdated, call \`memory_supersede\` — the new version inherits the old
551
- one's coherent associations (counter-narrative replacement, 0.8.x) so cue
617
+ one's coherent associations (counter-narrative replacement) so cue
552
618
  pathways carry forward to the replacement.
553
619
 
554
620
  ### Example — good vs bad memory_write
@@ -600,7 +666,7 @@ comprehension cost. This is output-only: it never changes the data or your memor
600
666
  - Don't bother for small outputs — it only compresses when the saving is worthwhile
601
667
  and falls back to plain JSON if TOON wouldn't reproduce the data exactly.
602
668
 
603
- ### Backend (SQLite vs PGlite, 0.8.x)
669
+ ### Backend (SQLite vs PGlite)
604
670
  AWM ships two storage backends. The installer picks SQLite by default; both
605
671
  are functionally equivalent for cognitive workloads, but differ in operational
606
672
  guarantees:
@@ -620,15 +686,26 @@ For the comparison table (recall quality parity, BM25 vs \`ts_rank_cd\`,
620
686
  multi-process guarantees), see \`docs/pglite-feature-parity.md\`.
621
687
 
622
688
  ### 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):
689
+ Each optimisation is gated by an env var so it can be disabled for A/B testing if a
690
+ regression appears in your workload. \`memory_whoami\` prints the active fingerprint.
691
+
692
+ Retrieval (set ON by \`awm setup\` since 0.14.6 — the measured-good configuration):
693
+ - \`AWM_RERANK2=1\` — second-stage rerank by the cross-encoder's own score.
694
+ - \`AWM_RERANK_WINDOW=query\` — 400-char rerank window on the densest query-term
695
+ region instead of the prefix (25% → 87.5% on long memories).
696
+ - \`AWM_RERANK_TAGS=1\` — tags fed into the rerank passage (+7.4pp s@1).
697
+
698
+ Hook sidecar (0.14.2):
699
+ - \`AWM_HOOK_PORT=8401\` / \`AWM_HOOK_PORT_RANGE=10\` — each MCP process binds the
700
+ first free port in the range, so several sessions can run at once. The shipped
701
+ hooks find their own session's sidecar by probing the range and matching
702
+ \`agentId\` on \`/health\`; \`memory_whoami\` reports the port actually bound.
703
+
704
+ Recall pipeline:
629
705
  - \`AWM_DISABLE_POOL_FILTER=1\` — disables the candidate pool reduction
630
706
  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).
707
+ - \`AWM_ENTITY_INDEX_FETCH=1\` — see "Entity index" above (default off; measured
708
+ no effect on the real-store benchmark, 0.14.5).
632
709
  - \`AWM_DISABLE_SLIM_CACHE=1\` — disables the in-memory slim cache.
633
710
  Reverts to per-recall SQL fetch + Buffer→Float32Array conversion.
634
711
  - \`AWM_DISABLE_RERANK_SKIP=1\` — disables the cross-encoder skip on
@@ -636,8 +713,8 @@ Recall pipeline (0.7.x):
636
713
  - \`AWM_DISABLE_EXPANSION_CACHE=1\` — disables the query expansion skip
637
714
  heuristic + LRU cache. Forces every recall through flan-t5-small.
638
715
 
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
716
+ Write pipeline + lifecycle:
717
+ - \`AWM_SLOW_WRITE_MS=250\` — any write slower than this logs one stderr
641
718
  line with a phase-time breakdown (embed/novelty/persist, event-loop lag,
642
719
  embed-model cold-load ms). \`0\` disables. Useful for diagnosing why a session's
643
720
  first write/recall feels slow.
@@ -653,7 +730,7 @@ Write pipeline + lifecycle (0.8.x, plus 0.12.x telemetry):
653
730
  - \`AWM_GRANULARITY_FULL_LEN=1000\` — char budget for the top result in
654
731
  \`granularity: 'auto'\` mode when there's a clear winner.
655
732
 
656
- PGlite backend (0.8.x):
733
+ PGlite backend:
657
734
  - \`AWM_PGLITE_BM25_M=1\` — multiplier on PGlite \`ts_rank_cd\` to calibrate
658
735
  against SQLite FTS5 BM25 distribution. M=1 (default) is passthrough;
659
736
  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
+ }