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.
- package/README.md +118 -122
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +234 -131
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/common.d.ts +44 -5
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +111 -41
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/hook-scripts.d.ts +54 -0
- package/dist/adapters/hook-scripts.d.ts.map +1 -0
- package/dist/adapters/hook-scripts.js +340 -0
- package/dist/adapters/hook-scripts.js.map +1 -0
- package/dist/adapters/types.d.ts +13 -3
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cli.js +21 -5
- package/dist/cli.js.map +1 -1
- package/dist/core/agent-id.d.ts +19 -0
- package/dist/core/agent-id.d.ts.map +1 -0
- package/dist/core/agent-id.js +28 -0
- package/dist/core/agent-id.js.map +1 -0
- package/dist/hooks/prime.d.ts +10 -4
- package/dist/hooks/prime.d.ts.map +1 -1
- package/dist/hooks/prime.js +16 -6
- package/dist/hooks/prime.js.map +1 -1
- package/dist/mcp.js +5 -12
- package/dist/mcp.js.map +1 -1
- package/package.json +94 -90
- package/src/adapters/claude-code.ts +223 -125
- package/src/adapters/common.ts +117 -42
- package/src/adapters/hook-scripts.ts +370 -0
- package/src/adapters/types.ts +14 -3
- package/src/cli.ts +19 -5
- package/src/core/agent-id.ts +28 -0
- package/src/hooks/prime.ts +22 -7
- package/src/mcp.ts +5 -11
package/src/adapters/common.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
59
|
-
AWM_DB_PATH: isWindows ? 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
|
-
/**
|
|
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
|
|
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
|
-
|
|
111
|
-
const
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
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. **
|
|
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
|
|
482
|
-
Default \`memory_recall\` is tuned for the common case.
|
|
483
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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)
|
|
512
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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 (
|
|
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
|
|
640
|
-
- \`AWM_SLOW_WRITE_MS=250\`
|
|
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
|
|
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
|
+
}
|