bantamkit-mcp 0.35.2 → 0.35.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.
- package/README.md +106 -5
- package/assets/tools/shiftwork_clock_in.json +13 -1
- package/assets/tools/shiftwork_clock_out.json +64 -9
- package/assets/tools/shiftwork_plan.json +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +273 -6
- package/dist/hookadapter.d.ts +29 -0
- package/dist/hookadapter.js +2009 -0
- package/dist/hostinstall.d.ts +115 -0
- package/dist/hostinstall.js +393 -0
- package/dist/mcp/pyargs.d.ts +39 -0
- package/dist/mcp/pyargs.js +112 -4
- package/dist/mcp/server.js +26 -2
- package/dist/memory/store.js +2 -2
- package/dist/selfupdate.d.ts +4 -1
- package/dist/selfupdate.js +6 -3
- package/dist/shiftwork.d.ts +40 -10
- package/dist/shiftwork.js +146 -36
- package/dist/updatecheck.d.ts +3 -3
- package/dist/updatecheck.js +45 -15
- package/package.json +1 -1
|
@@ -0,0 +1,2009 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code HOOK adapter. One module, every event, dispatched on `hook_event_name`.
|
|
3
|
+
*
|
|
4
|
+
* MOVED HERE 2026-09-20 (job61, J61-2) FROM `tools/hooks/bantamkit-hook.mjs`, AND THE MOVE IS
|
|
5
|
+
* THE POINT. The adapter worked and was unreachable from the product: `tools/` is not in the
|
|
6
|
+
* npm package. Measured at 0.35.3 with `npm pack --dry-run` — 174 files in the tarball,
|
|
7
|
+
* `package.json` declaring `files: ["dist","assets"]`, and zero entries matching `hook`. So an
|
|
8
|
+
* operator who installed bantamkit the only way it is published (`npx bantamkit-mcp`) had no
|
|
9
|
+
* adapter on disk at all, and `docs/hooks.md`'s registration line could not be written without
|
|
10
|
+
* a checkout. Living in `src/` puts it in `dist/`, which ships, and `bantamkit-mcp --hook` is
|
|
11
|
+
* the surface that reaches it.
|
|
12
|
+
*
|
|
13
|
+
* `src/` IS TYPESCRIPT, WHICH IS WHY THIS IS A PORT AND NOT A COPY. `tsconfig.json` sets
|
|
14
|
+
* `include: ["src"]` and no `allowJs`, so a `.mjs` dropped into `src/` would be neither
|
|
15
|
+
* compiled nor copied to `dist/` and would still not ship. There is exactly ONE adapter after
|
|
16
|
+
* this change: `tools/hooks/bantamkit-hook.mjs` is now a four-line shim onto this module, so
|
|
17
|
+
* the registration in every operator's `~/.claude/settings.json` keeps working and there is no
|
|
18
|
+
* second copy to drift.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS EXISTS AT ALL. Measured 2026-08-27 over the host's own MCP logs, last 7 days: 103
|
|
21
|
+
* bantamkit connections, 8 `memory_recall` calls, 3 of them outside this repo. An MCP
|
|
22
|
+
* server is PASSIVE — nothing in the host invokes a tool the model did not decide to call,
|
|
23
|
+
* and the model does not decide to call a recall whose answer already rides free in the
|
|
24
|
+
* system prompt. Hooks are the only deterministic channel the host offers, so the
|
|
25
|
+
* automatic half of the toolbox — recall before the turn, refuse a repeat read, nudge a
|
|
26
|
+
* save before the session ends, compact the index when it is nearly full — lives HERE,
|
|
27
|
+
* at the host boundary, and the MCP tools stay what the model calls when it wants more.
|
|
28
|
+
*
|
|
29
|
+
* THE THREE PROPERTIES, same as the statusline adapter beside it:
|
|
30
|
+
* 1. CHEAP. Loads the memory layer in-process; never starts an MCP server.
|
|
31
|
+
* 2. NEVER LOUD. Every arm ends in exit 0. A hook that throws would be rendered by the
|
|
32
|
+
* host as an error on the user's screen, so every failure is LOGGED and swallowed.
|
|
33
|
+
* 3. MEASURED. Every decision appends one line to `~/.bantamkit/hooks/hook-log.jsonl`
|
|
34
|
+
* (event, action, bytes injected, ms) so the claim "it fires and it is cheap" is a
|
|
35
|
+
* number the user can rerun, not a sentence.
|
|
36
|
+
*
|
|
37
|
+
* THE CONTRACT THE FLAG PINS, and it is deliberately narrow so both runtimes can be compared:
|
|
38
|
+
* one JSON object in on stdin, AT MOST ONE JSON object out on stdout, exit 0 ALWAYS.
|
|
39
|
+
*
|
|
40
|
+
* Registration (user scope, `~/.claude/settings.json`): see `docs/hooks.md`.
|
|
41
|
+
* Pure Node, POSIX + Windows; nothing here shells Python.
|
|
42
|
+
*/
|
|
43
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
import fs from 'node:fs';
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { Memory } from './memory/component.js';
|
|
50
|
+
import { discoverProjectStore, resolveProjectStore } from './memory/layers.js';
|
|
51
|
+
import { DURABLE_TYPES, MemoryStore, pyEqualValue, pyText, tokens } from './memory/store.js';
|
|
52
|
+
import { keptManifest, keptPrefix } from './npminstall.js';
|
|
53
|
+
import { decide as decideUpdate, KEY as UPDATE_KEY, loadRecord, PROGRAM as UPDATE_PROGRAM, recordPath, STATE_AVAILABLE, } from './updatecheck.js';
|
|
54
|
+
/**
|
|
55
|
+
* The directory `p` names, as the KERNEL names it. `realpathSync.native`, not `realpathSync`.
|
|
56
|
+
*
|
|
57
|
+
* Added 2026-09-11 (J47-7); the mechanism is J47-3B's (`6766033`) and the reason is the same.
|
|
58
|
+
* `fs.realpathSync` hands its argument to `path.resolve` before it resolves anything, and
|
|
59
|
+
* `path.resolve` pops `..` LEXICALLY — before the symlink in front of it has been followed —
|
|
60
|
+
* so it throws `ENOENT` on a directory that is there. `realpathSync.native` is libuv's
|
|
61
|
+
* `uv_fs_realpath`, the platform's own `realpath(3)` / `GetFinalPathNameByHandle`: it pops
|
|
62
|
+
* `..` in the kernel's order, agrees with `os.path.realpath` byte for byte, and keeps the
|
|
63
|
+
* macOS `/var` vs `/private/var` resolution every comparison in this file was written for.
|
|
64
|
+
* `statSync` identity (`dev` + `ino`) was the other candidate and was not taken, for J47-3B's
|
|
65
|
+
* reason: `ino` is not dependable on every Windows filesystem, and this file runs there too.
|
|
66
|
+
*
|
|
67
|
+
* A path that is genuinely not on disk cannot be realpathed at all, and `path.resolve` is the
|
|
68
|
+
* fallback — it can only under-report a match, never invent one.
|
|
69
|
+
*/
|
|
70
|
+
function realDir(p) {
|
|
71
|
+
try {
|
|
72
|
+
return fs.realpathSync.native(p);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return path.resolve(p);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const T0 = Date.now();
|
|
79
|
+
// HOME IS RESOLVED, NOT TAKEN AS SPELLED (2026-09-11, J47-7). `os.homedir()` hands back
|
|
80
|
+
// `$HOME` verbatim, and every `path.join` below pops a `..` inside it LEXICALLY. Measured on
|
|
81
|
+
// J47-3B's bed (`link -> <bed>/deep/real`, `HOME=<bed>/link/..`, the real home being
|
|
82
|
+
// `<bed>/deep`): `PROFILE` came out `<bed>/.bantamkit/memory`, a directory nothing had
|
|
83
|
+
// created, so the single-layer guard at the bottom of this file could not answer `true` no
|
|
84
|
+
// matter what predicate it used — and `STATE` put this hook's own log OUTSIDE the home. One
|
|
85
|
+
// kernel-order resolution here fixes both, and for a `HOME` with no symlink and no `..` it
|
|
86
|
+
// is the identity.
|
|
87
|
+
//
|
|
88
|
+
// IT IS READ PER RUN, NOT AT MODULE LOAD. `--hook` is one short-lived process, so the two are
|
|
89
|
+
// the same in production; but `HOME` is also the seam every test in `hooks.test.mjs` and
|
|
90
|
+
// `hookadapter.test.mjs` points at a scratch directory, and a module-level constant would
|
|
91
|
+
// freeze whatever the FIRST import saw. `runHook()` takes the reading once, at the top.
|
|
92
|
+
function homeDir() {
|
|
93
|
+
return realDir(os.homedir());
|
|
94
|
+
}
|
|
95
|
+
/** This module's own directory: `<package>/dist` in an install, `runtime-ts/dist` in a checkout. */
|
|
96
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
97
|
+
/** The built memory layer, beside this module. Was `<repo>/runtime-ts/dist/memory`. */
|
|
98
|
+
const DIST = path.join(HERE, 'memory');
|
|
99
|
+
// Budgets, in BYTES of injected context. SessionStart is paid once; UserPromptSubmit is
|
|
100
|
+
// paid on every later call of the session, which is why it is the small one.
|
|
101
|
+
const SESSION_INJECT_MAX = 3000;
|
|
102
|
+
const PROMPT_INJECT_MAX = 700;
|
|
103
|
+
const PROMPT_MIN_CHARS = 12;
|
|
104
|
+
const STOP_NUDGE_MIN_TOOL_CALLS = 20;
|
|
105
|
+
const COMPACT_AT = 0.9; // index >= 90% of budget → compact …
|
|
106
|
+
const COMPACT_TO = 0.8; // … down to 80%, past the no-op band measured in job40 (C6)
|
|
107
|
+
//
|
|
108
|
+
// AMENDED 2026-09-10 (job46, J46-6). The half-sentence "past the no-op band measured in
|
|
109
|
+
// job40 (C6)" is a record of a defect that is now CLOSED, and it stays because it is why
|
|
110
|
+
// this number exists at all. `docs/porting.md` register item 7 was that band: the degraded
|
|
111
|
+
// report warned at COMPACT_AT and named `compact`, while `compact`'s default target sat at
|
|
112
|
+
// `budget - largest index line`, so between the two the command archived nothing. Both
|
|
113
|
+
// runtimes closed it in job46 (`87cc1f7`, `55575c3`) by measuring the default reserve from
|
|
114
|
+
// the warning line instead of from the budget.
|
|
115
|
+
//
|
|
116
|
+
// COMPACT_TO STAYS, and NOT because the workaround is harmless — it is not. What it was
|
|
117
|
+
// doing was naming a budget this store does not have (0.8 * budget) so that `compact` would
|
|
118
|
+
// aim below the real one. Now that `compact` derives its own floor FROM the budget it is
|
|
119
|
+
// given, that lie COMPOUNDS: the aim became 0.9 * (0.8 * budget) - largest line. MEASURED on
|
|
120
|
+
// a read-only copy of this machine's project store (101 facts, 21819 bytes, largest index
|
|
121
|
+
// line 361, budget 24000), one auto-compaction:
|
|
122
|
+
//
|
|
123
|
+
// before job46, `--budget 19200`, default reserve : target 18839, archived 15
|
|
124
|
+
// after job46, `--budget 19200`, default reserve : target 16918, archived 23
|
|
125
|
+
// today, `--budget 24000 --reserve 4800` : target 19200, archived 13
|
|
126
|
+
//
|
|
127
|
+
// So the arm was quietly archiving eight more of the user's facts per fire than the 80% it
|
|
128
|
+
// advertises, and landing 2379 bytes below the number its own message prints. The fix is not
|
|
129
|
+
// to drop the aim — 80% is the HYSTERESIS that keeps this arm from re-firing on the next
|
|
130
|
+
// save, which matters more now that the default target sits just under the warning line — it
|
|
131
|
+
// is to ask for the aim in the argument that MEANS it. `--reserve` is the documented escape
|
|
132
|
+
// hatch from the new floor (both runtimes, both conformance suites), so the hook now names
|
|
133
|
+
// the real budget and the headroom it wants, and gets `budget - reserve` exactly. That is
|
|
134
|
+
// also why this is the durable spelling: it cannot be moved again by a future change to the
|
|
135
|
+
// DEFAULT reserve, which is exactly what moved it this time.
|
|
136
|
+
// PreCompact's steering, in BYTES. This string is paid TWICE: once as the summariser's
|
|
137
|
+
// `newCustomInstructions`, and once echoed onto the user's screen as
|
|
138
|
+
// `PreCompact [<command>] completed successfully: <output>` — the same bytes are both the
|
|
139
|
+
// steering and the display, and there is no quieter variant (see the channel note below).
|
|
140
|
+
// Unbounded, the arm's own worst case is PRECOMPACT_FILES_MAX absolute paths, and a single
|
|
141
|
+
// malformed unit title put 35,315 B on the user's screen in one probe. 4000 B is the bound:
|
|
142
|
+
// a third more than SESSION_INJECT_MAX, which buys the checkpoint line and the tail on top
|
|
143
|
+
// of a useful slice of the list, and it is paid per compaction rather than per prompt. The
|
|
144
|
+
// FIXED lines are never cut; the file list absorbs the whole trim.
|
|
145
|
+
const PRECOMPACT_STDOUT_MAX = 4000;
|
|
146
|
+
const PRECOMPACT_FILES_MAX = 40;
|
|
147
|
+
const CHECKPOINT_LINE_MAX = 600; // one checkpoint line: an absolute path, a cursor, a title
|
|
148
|
+
// The host kills the WHOLE hook at 10 s (`timeout: 10` in the registration), so the child's
|
|
149
|
+
// bound has to sit under that. A measured consolidation of the 121 facts on this machine
|
|
150
|
+
// took 72 ms, so the headroom is two orders of magnitude.
|
|
151
|
+
//
|
|
152
|
+
// THE ENV OVERRIDE IS A TEST SEAM AND NOTHING ELSE. Without it the timeout is a constant no
|
|
153
|
+
// test can reach, and this repo has been bitten twice by arithmetic in this file that drifted
|
|
154
|
+
// because nothing ran it. With it, `hooks.test.mjs` sets a 1 ms bound and exercises a REAL
|
|
155
|
+
// SIGTERM kill of a real child, which is the only way to show that a slow pass degrades to a
|
|
156
|
+
// log line instead of eating the session.
|
|
157
|
+
function dreamTimeoutMs() {
|
|
158
|
+
return Number(process.env['BANTAMKIT_DREAM_TIMEOUT_MS']) || 8000;
|
|
159
|
+
}
|
|
160
|
+
function newRun() {
|
|
161
|
+
const home = homeDir();
|
|
162
|
+
const state = path.join(home, '.bantamkit', 'hooks');
|
|
163
|
+
return {
|
|
164
|
+
home,
|
|
165
|
+
state,
|
|
166
|
+
log: path.join(state, 'hook-log.jsonl'),
|
|
167
|
+
profile: path.join(home, '.bantamkit', 'memory'),
|
|
168
|
+
dreamState: path.join(state, 'dream-state.json'),
|
|
169
|
+
storeScope: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function log(run, record) {
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync(run.state, { recursive: true });
|
|
175
|
+
fs.appendFileSync(run.log, `${JSON.stringify({ ts: new Date().toISOString(), ms: Date.now() - T0, ...record })}\n`);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* logging is best-effort */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function emit(obj) {
|
|
182
|
+
process.stdout.write(JSON.stringify(obj));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* PLAIN stdout, for the events whose steering the host reads as text rather than as the
|
|
186
|
+
* `hookSpecificOutput` envelope. See `preCompact` for why that distinction is not cosmetic.
|
|
187
|
+
*
|
|
188
|
+
* It used to carry a leading-`{` refusal here. That branch could not fire — every part
|
|
189
|
+
* `preCompact` assembles begins with one of three constant prefixes — and had it ever
|
|
190
|
+
* fired it would have DROPPED the whole steering with no trace, which is the exact failure
|
|
191
|
+
* this arm exists to have fixed. The channel contract it was guarding ("output that does not
|
|
192
|
+
* start with `{` is taken as plain text") is now pinned where it can actually go red: the
|
|
193
|
+
* `hooks.test.mjs` case "the steering never starts with { — for every shape of input the
|
|
194
|
+
* arm accepts", which drives user-controlled strings (a `{braces}` path, a `{odd}.json`
|
|
195
|
+
* checkpoint, a `{C}` cursor) through the real dispatch and judges stdout the way the host
|
|
196
|
+
* does. Returns the number of bytes ACTUALLY written, so the caller's log line cannot claim
|
|
197
|
+
* bytes that never left the process.
|
|
198
|
+
*/
|
|
199
|
+
function emitText(text) {
|
|
200
|
+
const out = String(text).trim();
|
|
201
|
+
if (!out)
|
|
202
|
+
return 0;
|
|
203
|
+
process.stdout.write(out);
|
|
204
|
+
return Buffer.byteLength(out);
|
|
205
|
+
}
|
|
206
|
+
/** Truncate to at most `max` BYTES without splitting a UTF-8 character. */
|
|
207
|
+
function capBytes(text, max) {
|
|
208
|
+
const buf = Buffer.from(String(text), 'utf8');
|
|
209
|
+
if (buf.length <= max)
|
|
210
|
+
return String(text);
|
|
211
|
+
let end = max;
|
|
212
|
+
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80)
|
|
213
|
+
end -= 1; // back off a continuation byte
|
|
214
|
+
return buf.subarray(0, end).toString('utf8');
|
|
215
|
+
}
|
|
216
|
+
function capLines(text, max) {
|
|
217
|
+
if (Buffer.byteLength(text) <= max)
|
|
218
|
+
return text;
|
|
219
|
+
const out = [];
|
|
220
|
+
let size = 0;
|
|
221
|
+
for (const line of text.split('\n')) {
|
|
222
|
+
const n = Buffer.byteLength(line) + 1;
|
|
223
|
+
if (size + n > max)
|
|
224
|
+
break;
|
|
225
|
+
out.push(line);
|
|
226
|
+
size += n;
|
|
227
|
+
}
|
|
228
|
+
return out.join('\n');
|
|
229
|
+
}
|
|
230
|
+
function ledgerPath(run, sessionId) {
|
|
231
|
+
return path.join(run.state, `ledger-${String(sessionId || 'nosession').replace(/[^\w-]/g, '_')}.json`);
|
|
232
|
+
}
|
|
233
|
+
function readLedger(run, sessionId) {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(fs.readFileSync(ledgerPath(run, sessionId), 'utf8'));
|
|
236
|
+
if (!parsed || typeof parsed !== 'object')
|
|
237
|
+
return { reads: {} };
|
|
238
|
+
if (!parsed.reads || typeof parsed.reads !== 'object')
|
|
239
|
+
parsed.reads = {};
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return { reads: {} };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function writeLedger(run, sessionId, ledger) {
|
|
247
|
+
fs.mkdirSync(run.state, { recursive: true });
|
|
248
|
+
fs.writeFileSync(ledgerPath(run, sessionId), JSON.stringify(ledger));
|
|
249
|
+
}
|
|
250
|
+
// ------------------------------------- the host's auto-memory directory, and A's export
|
|
251
|
+
//
|
|
252
|
+
// WHAT THIS REPLACED, AND WHY IT HAD TO GO (2026-09-20, job62 / J62-6). Until this change the
|
|
253
|
+
// question "does the host have an auto-memory store here" was answered by
|
|
254
|
+
//
|
|
255
|
+
// const slug = cwd.replace(/[\\/:]/g, '-');
|
|
256
|
+
// fs.existsSync(path.join(run.home, '.claude', 'projects', slug, 'memory', 'MEMORY.md'))
|
|
257
|
+
//
|
|
258
|
+
// — an incomplete reimplementation of Claude Code's own slug, on the hook path. S0
|
|
259
|
+
// (`.shiftwork/notes-job62/S0-prep-probe.md`) dumped the real resolver out of `2.1.278`:
|
|
260
|
+
//
|
|
261
|
+
// resolveEntry() = CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
|
|
262
|
+
// ?? autoMemoryDirectory from policySettings, flagSettings,
|
|
263
|
+
// [localSettings, projectSettings], userSettings (in that order)
|
|
264
|
+
// ?? defaultPath()
|
|
265
|
+
// defaultPath() = join(<root>, "projects", ok(gitRoot(projectRoot) ?? projectRoot), "memory")
|
|
266
|
+
// ok(e) = k(e).length <= 200 ? k(e) : k(e).slice(0,200) + "-" + base36(hash(e))
|
|
267
|
+
//
|
|
268
|
+
// Three things the old rule is missing, and each one is a wrong answer on a real machine: the
|
|
269
|
+
// 200-character cap with its base36 hash suffix; the four-branch precedence in front of
|
|
270
|
+
// `defaultPath` at all; and — the one that matters most here — the KEY, which is the
|
|
271
|
+
// canonicalized git WORKTREE ROOT and not the session cwd. Job62 itself runs inside a
|
|
272
|
+
// worktree, which is precisely the case the old rule gets wrong.
|
|
273
|
+
//
|
|
274
|
+
// The fix is not a better slug. RULING Q1.2/Q1.6 of `.shiftwork/notes-job62/S1-delivery-path.md`
|
|
275
|
+
// forbids reimplementing `oS`/`ok`/`Gr` at all, because re-deriving a four-branch resolver from
|
|
276
|
+
// a minified binary is the exact instruction/surface drift job60 built its gate to stop. A
|
|
277
|
+
// learns the directory by OBSERVATION WITH VERIFICATION instead, and when no branch answers it
|
|
278
|
+
// exports nothing rather than guessing (RULING Q1.3): a directory at a slug the host does not
|
|
279
|
+
// resolve to is a store nobody reads and nobody prunes.
|
|
280
|
+
//
|
|
281
|
+
// `native_store_root()` in `runtime-py/src/bantamkit/memory/divergence.py` is a DIFFERENT
|
|
282
|
+
// function, off the MCP path, and RULING Q1.6 leaves it exactly where it is.
|
|
283
|
+
/** The file whose presence makes a candidate directory an ANSWER rather than a guess. */
|
|
284
|
+
const NATIVE_INDEX = 'MEMORY.md';
|
|
285
|
+
/** The host's top-precedence branch, and the one on the MCP passthrough allowlist. */
|
|
286
|
+
const NATIVE_OVERRIDE_ENV = 'CLAUDE_COWORK_MEMORY_PATH_OVERRIDE';
|
|
287
|
+
/**
|
|
288
|
+
* The one heading in the host's index that A writes under.
|
|
289
|
+
*
|
|
290
|
+
* A owns this line and nothing else in the file. It does NOT sort its entries into the host's
|
|
291
|
+
* own sections, because the host's taxonomy is the host's to change and a writer that guesses
|
|
292
|
+
* at it has to keep guessing right forever. One heading, appended once, at the end.
|
|
293
|
+
*/
|
|
294
|
+
const NATIVE_SECTION = '## bantamkit';
|
|
295
|
+
/** Names exported per hook run. The binding bound in practice; see `exportToNative`. */
|
|
296
|
+
const NATIVE_EXPORT_MAX = 10;
|
|
297
|
+
/**
|
|
298
|
+
* Bytes A may append to `MEMORY.md` per hook run.
|
|
299
|
+
*
|
|
300
|
+
* This is the budget that means anything, because these are the only bytes the export puts in
|
|
301
|
+
* front of the model: the host injects its index, and reads a fact file only when something
|
|
302
|
+
* asks for it. 2000 B is two thirds of `SESSION_INJECT_MAX`, paid at most once per session and
|
|
303
|
+
* — unlike an injection — never paid again for a name already there.
|
|
304
|
+
*/
|
|
305
|
+
const NATIVE_INDEX_BYTES_MAX = 2000;
|
|
306
|
+
/**
|
|
307
|
+
* A fact name A is willing to join into a path.
|
|
308
|
+
*
|
|
309
|
+
* `MemoryStore.save` enforces `^[a-z0-9][a-z0-9-]*$`, but `facts()` does NOT revalidate on
|
|
310
|
+
* READ — measured 2026-09-20: a hand-written `facts/*.md` whose frontmatter says
|
|
311
|
+
* `name: ../escape` parses and is handed out with that name. So this guard is reachable from
|
|
312
|
+
* disk, and `nativeexport.test.mjs` drives it with exactly that file.
|
|
313
|
+
*/
|
|
314
|
+
const NATIVE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
315
|
+
/** `p` is a regular file this process can read. Not "exists": a directory is not an index. */
|
|
316
|
+
function readableFile(p) {
|
|
317
|
+
try {
|
|
318
|
+
if (!fs.statSync(p).isFile())
|
|
319
|
+
return false;
|
|
320
|
+
fs.accessSync(p, fs.constants.R_OK);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* `os.path.lexists`: something is AT this path, symlink or not.
|
|
329
|
+
*
|
|
330
|
+
* `existsSync` follows links, so a dangling symlink would read as absent and A would write
|
|
331
|
+
* THROUGH it into whatever it points at. A dangling link is an entry A did not create, and
|
|
332
|
+
* "absent" is the only condition under which A writes.
|
|
333
|
+
*/
|
|
334
|
+
function lexists(p) {
|
|
335
|
+
try {
|
|
336
|
+
fs.lstatSync(p);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* RULING Q1.2 — the four branches, first one that ANSWERS wins.
|
|
345
|
+
*
|
|
346
|
+
* 1. `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE`, non-empty → that value IS the directory, with no
|
|
347
|
+
* verification: it is the host's own top-precedence branch, so there is nothing above it
|
|
348
|
+
* that could overrule what A sees.
|
|
349
|
+
* 2. `autoMemoryDirectory` from `<home>/.claude/settings.json` ONLY. A CANDIDATE, never an
|
|
350
|
+
* answer: three higher-precedence sources exist in the host (`policySettings`,
|
|
351
|
+
* `flagSettings`, and the env var) that A cannot read, so the candidate is accepted only
|
|
352
|
+
* when it holds a readable `MEMORY.md`.
|
|
353
|
+
* 3. `dirname(transcript_path)/memory`, with the same `MEMORY.md` test. This is reading back
|
|
354
|
+
* the slug the HOST computed, which is the whole distinction invariant 5 is about.
|
|
355
|
+
* 4. UNKNOWN. No fallback slug, no mkdir.
|
|
356
|
+
*
|
|
357
|
+
* Branch 3 is why A is a rider on B and not a feature of its own (RULING Q1.4): only a hook
|
|
358
|
+
* receives `transcript_path`.
|
|
359
|
+
*/
|
|
360
|
+
function resolveNativeMemory(run, input) {
|
|
361
|
+
const tried = [];
|
|
362
|
+
const override = String(process.env[NATIVE_OVERRIDE_ENV] ?? '').trim();
|
|
363
|
+
if (override)
|
|
364
|
+
return { dir: override, branch: 'env', tried };
|
|
365
|
+
tried.push('env');
|
|
366
|
+
try {
|
|
367
|
+
const raw = fs.readFileSync(path.join(run.home, '.claude', 'settings.json'), 'utf8');
|
|
368
|
+
const settings = JSON.parse(raw);
|
|
369
|
+
const configured = settings['autoMemoryDirectory'];
|
|
370
|
+
if (typeof configured === 'string' && configured.trim()) {
|
|
371
|
+
const dir = configured.trim();
|
|
372
|
+
if (readableFile(path.join(dir, NATIVE_INDEX)))
|
|
373
|
+
return { dir, branch: 'settings', tried };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* no settings file, unreadable, or not JSON — all of them mean "this branch has no answer" */
|
|
378
|
+
}
|
|
379
|
+
tried.push('settings');
|
|
380
|
+
const transcript = String(input.transcript_path ?? '').trim();
|
|
381
|
+
if (transcript) {
|
|
382
|
+
const dir = path.join(path.dirname(transcript), 'memory');
|
|
383
|
+
if (readableFile(path.join(dir, NATIVE_INDEX)))
|
|
384
|
+
return { dir, branch: 'transcript', tried };
|
|
385
|
+
}
|
|
386
|
+
tried.push('transcript');
|
|
387
|
+
return { dir: null, branch: 'unknown', tried };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Whitespace collapsed to single spaces, then trimmed.
|
|
391
|
+
*
|
|
392
|
+
* THE CLASS IS SPELLED OUT rather than `\s`, and the Python half must spell it the same way:
|
|
393
|
+
* `\s` is not the same set in the two languages (JavaScript's includes NBSP, BOM and the
|
|
394
|
+
* Unicode separators; Python's `str` pattern includes its own list), so a description carrying
|
|
395
|
+
* one of those characters would be exported differently by the two runtimes — a divergence
|
|
396
|
+
* invented by a helper nobody would think to compare.
|
|
397
|
+
*/
|
|
398
|
+
function collapseWhitespace(text) {
|
|
399
|
+
return String(text)
|
|
400
|
+
.replace(/[ \t\n\r\f\u000b]+/g, ' ')
|
|
401
|
+
.trim();
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* One exported file, in the host's own auto-memory shape: `name`, `description`,
|
|
405
|
+
* `metadata.type`, and a body.
|
|
406
|
+
*
|
|
407
|
+
* THE BODY IS NOT THE FACT'S BODY, and the unit is titled for it: A exports DESCRIPTIONS. The
|
|
408
|
+
* file is an entry that names where the body lives, for two reasons. The description is what
|
|
409
|
+
* the host injects; and copying a user's fact bodies into a store whose dream rewords and
|
|
410
|
+
* deletes them would be duplicating the user's data into a place bantamkit does not own.
|
|
411
|
+
*
|
|
412
|
+
* `metadata.source: bantamkit` is provenance, so an operator reading this directory can see at
|
|
413
|
+
* a glance which entries are exports. Nothing reads it back — RULING Q1.5 forbids A treating
|
|
414
|
+
* its own writes as state, and the test that deletes both halves and re-runs pins that.
|
|
415
|
+
*
|
|
416
|
+
* The description is a JSON string, which is a valid YAML 1.2 double-quoted scalar, so the
|
|
417
|
+
* escaping needs no YAML writer on either side. `json.dumps(desc, ensure_ascii=False)` agrees
|
|
418
|
+
* with `JSON.stringify` byte for byte on this input.
|
|
419
|
+
*/
|
|
420
|
+
function nativeFactText(name, type, description) {
|
|
421
|
+
return [
|
|
422
|
+
'---',
|
|
423
|
+
`name: ${name}`,
|
|
424
|
+
`description: ${JSON.stringify(description)}`,
|
|
425
|
+
'metadata:',
|
|
426
|
+
' node_type: memory',
|
|
427
|
+
` type: ${type}`,
|
|
428
|
+
' source: bantamkit',
|
|
429
|
+
'---',
|
|
430
|
+
'',
|
|
431
|
+
"Exported from bantamkit's project memory store. The body of this fact is not here: call",
|
|
432
|
+
`\`mcp__bantamkit__memory_recall\` with the name \`${name}\` to read it.`,
|
|
433
|
+
'',
|
|
434
|
+
'Written once, only because the name was absent from this directory. bantamkit never',
|
|
435
|
+
'rewrites and never deletes an entry here, so whatever the host does to this file stands.',
|
|
436
|
+
'',
|
|
437
|
+
].join('\n');
|
|
438
|
+
}
|
|
439
|
+
/** One index line, in the host's own index shape. The em dash is a raw U+2014. */
|
|
440
|
+
function nativeIndexLine(name, description) {
|
|
441
|
+
return `- [${name}](${name}.md) — ${description}\n`;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* A — the export, one way and non-destructive (RULING Q1.5).
|
|
445
|
+
*
|
|
446
|
+
* WRITE A NAME ONLY WHEN IT IS ABSENT. The two halves are gated INDEPENDENTLY: the fact file
|
|
447
|
+
* is written when nothing is at its path, the index line is appended when `](<name>.md)` does
|
|
448
|
+
* not appear in the index. They are independent because job59 measured Claude Code's own dream
|
|
449
|
+
* rewording or removing 8 of 8 foreign index lines and deleting one file, so the two halves
|
|
450
|
+
* really do go missing separately, and A must do exactly the one thing that is missing.
|
|
451
|
+
*
|
|
452
|
+
* A NEVER READS BACK ITS OWN WRITES AS STATE. The presence check above is not "did we export
|
|
453
|
+
* this" — it is "is this name in the host's directory right now", which is the write-when-absent
|
|
454
|
+
* predicate itself. When the dream removes an entry, A puts it back next session; when the
|
|
455
|
+
* dream REWORDS one, A leaves it alone, because the name is still there. Any later unit that
|
|
456
|
+
* wants "have we exported X" must answer it from bantamkit's own store.
|
|
457
|
+
*
|
|
458
|
+
* A NEVER CREATES ANYTHING THE HOST WOULD NOT HAVE. No `mkdir`, and no `MEMORY.md`: when the
|
|
459
|
+
* index is absent (reachable only through branch 1, which accepts its directory unverified)
|
|
460
|
+
* the files are written and no index is conjured into existence.
|
|
461
|
+
*
|
|
462
|
+
* ORDER IS `SESSION_DROP_RULE`, the same order the session block keeps facts in, so a store
|
|
463
|
+
* larger than one run's budget exports its most durable and most recently used facts first and
|
|
464
|
+
* the rest on later sessions. A failed write is caught, recorded and ends the run: an arm whose
|
|
465
|
+
* whole posture is exit 0 does not get to throw, and retrying every remaining name against a
|
|
466
|
+
* directory that just refused one is spending syscalls to learn the same thing again.
|
|
467
|
+
*/
|
|
468
|
+
function exportToNative(dir, store) {
|
|
469
|
+
const result = {
|
|
470
|
+
exported: 0,
|
|
471
|
+
files: 0,
|
|
472
|
+
indexLines: 0,
|
|
473
|
+
bytes: 0,
|
|
474
|
+
indexBytes: 0,
|
|
475
|
+
skipped: [],
|
|
476
|
+
index: 'absent',
|
|
477
|
+
};
|
|
478
|
+
const indexFile = path.join(dir, NATIVE_INDEX);
|
|
479
|
+
let seeded = '';
|
|
480
|
+
if (readableFile(indexFile)) {
|
|
481
|
+
try {
|
|
482
|
+
seeded = fs.readFileSync(indexFile, 'utf8');
|
|
483
|
+
result.index = 'present';
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
/* readable a moment ago, not now: treat it as absent rather than as empty */
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
let named = seeded;
|
|
490
|
+
const appended = [];
|
|
491
|
+
try {
|
|
492
|
+
const { facts, indexLine } = new MemoryStore(store).internals();
|
|
493
|
+
const ranked = rankBySessionDropRule(facts().map((fact, position) => ({
|
|
494
|
+
fact,
|
|
495
|
+
position,
|
|
496
|
+
line: indexLine(fact),
|
|
497
|
+
name: pyText(fact.name),
|
|
498
|
+
})));
|
|
499
|
+
for (const f of ranked) {
|
|
500
|
+
if (result.exported >= NATIVE_EXPORT_MAX)
|
|
501
|
+
break;
|
|
502
|
+
const name = f.name;
|
|
503
|
+
if (!NATIVE_NAME_RE.test(name) || name === 'MEMORY') {
|
|
504
|
+
result.skipped.push(name);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const file = path.join(dir, `${name}.md`);
|
|
508
|
+
const needFile = !lexists(file);
|
|
509
|
+
const needLine = result.index === 'present' && !named.includes(`](${name}.md)`);
|
|
510
|
+
if (!needFile && !needLine)
|
|
511
|
+
continue;
|
|
512
|
+
const description = collapseWhitespace(f.fact.description == null ? '' : pyText(f.fact.description));
|
|
513
|
+
const line = needLine ? nativeIndexLine(name, description) : '';
|
|
514
|
+
if (result.indexBytes + Buffer.byteLength(line) > NATIVE_INDEX_BYTES_MAX)
|
|
515
|
+
break;
|
|
516
|
+
if (needFile) {
|
|
517
|
+
const text = nativeFactText(name, pyText(f.fact.type), description);
|
|
518
|
+
fs.writeFileSync(file, text, { flag: 'wx' });
|
|
519
|
+
result.files += 1;
|
|
520
|
+
result.bytes += Buffer.byteLength(text);
|
|
521
|
+
}
|
|
522
|
+
if (needLine) {
|
|
523
|
+
appended.push(line);
|
|
524
|
+
named += line;
|
|
525
|
+
result.indexLines += 1;
|
|
526
|
+
result.indexBytes += Buffer.byteLength(line);
|
|
527
|
+
result.bytes += Buffer.byteLength(line);
|
|
528
|
+
}
|
|
529
|
+
result.exported += 1;
|
|
530
|
+
}
|
|
531
|
+
if (appended.length) {
|
|
532
|
+
// The heading is emitted only when the file does not already carry it as a whole LINE,
|
|
533
|
+
// so a second session appends under the first session's heading. If the host later moves
|
|
534
|
+
// that heading, A's later lines land at the end of the file rather than under it — a
|
|
535
|
+
// cosmetic limit, accepted, because the alternative is A rewriting the host's index.
|
|
536
|
+
const separator = seeded.endsWith('\n') ? '' : '\n';
|
|
537
|
+
const heading = seeded.split('\n').includes(NATIVE_SECTION) ? '' : `\n${NATIVE_SECTION}\n\n`;
|
|
538
|
+
fs.appendFileSync(indexFile, `${separator}${heading}${appended.join('')}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
result.error = message(e);
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
// The rule a capped session block keeps facts by, in the words the block, the log and
|
|
547
|
+
// `docs/hooks.md` all quote. Until J50-2E (2026-09-12) the block was `capLines` over the
|
|
548
|
+
// store's index text, so what was dropped was whatever sorted LAST in the index — the
|
|
549
|
+
// alphabet, not any property of the fact. Reproduced on this machine's 20-fact profile
|
|
550
|
+
// store: the header said 20, the body carried 15, and the two facts the user had written
|
|
551
|
+
// to say that every job ends measured and that verification comes from a run were among
|
|
552
|
+
// the five nobody was told about. The rule below is the store's own eviction order
|
|
553
|
+
// (`MemoryStore.byEviction`) read backwards — the facts `compact` would archive LAST are the
|
|
554
|
+
// ones a session should see FIRST — so the hook invents no new notion of worth: durable
|
|
555
|
+
// types (`DURABLE_TYPES`) before decaying ones, then the most recent evidence of use
|
|
556
|
+
// (`last_recalled`, falling back to `created`), then name. Inside one class the date is
|
|
557
|
+
// the ONLY signal a fact carries on disk, and `last_recalled` is stamped by the recall path
|
|
558
|
+
// before its own byte cap (J49-B3), so this is a stated rule, not a claim of importance.
|
|
559
|
+
const SESSION_DROP_RULE = 'durable types first, then most recently recalled (else created) first, then name';
|
|
560
|
+
/**
|
|
561
|
+
* The index of `root` as ONE capped block: a header whose number is the number of fact
|
|
562
|
+
* lines IN the block, the fact lines that fit under `max` bytes, and — only when something
|
|
563
|
+
* did not fit — one disclosure line saying how many are missing and where they are named.
|
|
564
|
+
*
|
|
565
|
+
* Selection walks the facts in `SESSION_DROP_RULE` order and keeps each one whose whole
|
|
566
|
+
* index line still fits; a line that does not fit is skipped, never split, and never a
|
|
567
|
+
* barrier for a shorter one after it. The kept lines are then shown in the index's own
|
|
568
|
+
* order, so a block that lost nothing is byte-for-byte the index text, as before. The lines
|
|
569
|
+
* come from `MemoryStore.internals().indexLine`, the same function `indexText` joins, so
|
|
570
|
+
* the block never says a fact differently from the index the runtime would write.
|
|
571
|
+
*
|
|
572
|
+
* `header(count)` is handed `"15 of 20"` when something was dropped and `"20"` when not, so
|
|
573
|
+
* a reader who sees a bare number knows the block is whole.
|
|
574
|
+
*/
|
|
575
|
+
/**
|
|
576
|
+
* `SESSION_DROP_RULE` as a sort, extracted 2026-09-20 (J62-6) so that the session block and
|
|
577
|
+
* A's export cannot come to disagree about which facts matter most.
|
|
578
|
+
*
|
|
579
|
+
* It was inline in `cappedIndex` and had exactly one caller; the export is the second, and a
|
|
580
|
+
* second copy of a four-key comparator is how "the same rule" stops being the same rule.
|
|
581
|
+
*/
|
|
582
|
+
function rankBySessionDropRule(all) {
|
|
583
|
+
const text = (value) => (value == null ? '' : pyText(value));
|
|
584
|
+
const decays = (f) => DURABLE_TYPES.some((durable) => pyEqualValue(f.fact.type, durable)) ? 0 : 1;
|
|
585
|
+
const evidence = (f) => text(f.fact.last_recalled) || text(f.fact.created);
|
|
586
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
587
|
+
return [...all].sort((a, b) => decays(a) - decays(b) || cmp(evidence(b), evidence(a)) || cmp(a.name, b.name) || a.position - b.position);
|
|
588
|
+
}
|
|
589
|
+
function cappedIndex(root, header, max) {
|
|
590
|
+
const { facts, indexLine } = new MemoryStore(root).internals();
|
|
591
|
+
const all = facts().map((fact, position) => ({
|
|
592
|
+
fact,
|
|
593
|
+
position,
|
|
594
|
+
line: indexLine(fact),
|
|
595
|
+
name: pyText(fact.name),
|
|
596
|
+
}));
|
|
597
|
+
const ranked = rankBySessionDropRule(all);
|
|
598
|
+
const kept = new Set();
|
|
599
|
+
let size = 0;
|
|
600
|
+
for (const f of ranked) {
|
|
601
|
+
const n = Buffer.byteLength(f.line);
|
|
602
|
+
if (size + n > max)
|
|
603
|
+
continue;
|
|
604
|
+
kept.add(f);
|
|
605
|
+
size += n;
|
|
606
|
+
}
|
|
607
|
+
const shown = all.filter((f) => kept.has(f));
|
|
608
|
+
const dropped = ranked.filter((f) => !kept.has(f)).map((f) => f.name);
|
|
609
|
+
const body = capLines(shown
|
|
610
|
+
.map((f) => f.line)
|
|
611
|
+
.join('')
|
|
612
|
+
.replace(/\n$/, ''), max);
|
|
613
|
+
const lines = [header(dropped.length ? `${shown.length} of ${all.length}` : String(all.length))];
|
|
614
|
+
if (body)
|
|
615
|
+
lines.push(body);
|
|
616
|
+
if (dropped.length) {
|
|
617
|
+
lines.push(`[${dropped.length} of ${all.length} not shown — the block is capped at ${max} bytes; kept by rule: ${SESSION_DROP_RULE}; ~/.bantamkit/hooks/hook-log.jsonl names the dropped; mcp__bantamkit__memory_recall reads any fact by name]`);
|
|
618
|
+
}
|
|
619
|
+
return { block: lines.join('\n'), total: all.length, injected: shown.length, dropped };
|
|
620
|
+
}
|
|
621
|
+
function countFacts(store) {
|
|
622
|
+
try {
|
|
623
|
+
return fs.readdirSync(path.join(store, 'facts')).filter((n) => n.endsWith('.md')).length;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return 0;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// ------------------------------------------------------- the stale-install signal
|
|
630
|
+
// Reader 2 and Writer 1 of `docs/superpowers/specs/2026-09-19-stale-version-signal-design.md`.
|
|
631
|
+
// One line at SessionStart when the install this hook can SEE is older than what the package
|
|
632
|
+
// index serves, and a detached probe that keeps the record it reads from fresh.
|
|
633
|
+
//
|
|
634
|
+
// NOTHING ON THIS PATH REACHES THE NETWORK. The line is decided from one file on disk; the
|
|
635
|
+
// only thing here that asks a registry anything is `update-probe.mjs`, which is spawned
|
|
636
|
+
// `detached` with `stdio: 'ignore'` and never waited for. That is the whole design: measured
|
|
637
|
+
// 2026-09-19, one registry GET is 0.14-0.46 s on a good network and 10.0 s on a captive one,
|
|
638
|
+
// against a 0.09 s cold server boot, so a fetch anywhere on this function's path would be a
|
|
639
|
+
// visible pause at the top of every session for a fact that is not urgent.
|
|
640
|
+
/**
|
|
641
|
+
* The detached writer, which lives in the CHECKOUT and not in the package.
|
|
642
|
+
*
|
|
643
|
+
* `tools/hooks/update-probe.mjs` is 239 lines that no operator running `npx bantamkit-mcp`
|
|
644
|
+
* has on disk, for the same measured reason this module moved: `files: ["dist","assets"]`.
|
|
645
|
+
* Resolved relative to THIS module, `../../tools/hooks/update-probe.mjs` is the repo path in
|
|
646
|
+
* a checkout (`<repo>/runtime-ts/dist` → `<repo>/tools/hooks`) and is simply absent in an
|
|
647
|
+
* install — where `maybeProbe` already answers `'missing'` and the SessionStart arm is
|
|
648
|
+
* otherwise unchanged. That degradation is DELIBERATE and named rather than hidden: porting
|
|
649
|
+
* the probe into the package is a separate surface with its own network reach, and a unit
|
|
650
|
+
* that quietly gave an npx install a background registry fetch would be the opposite of the
|
|
651
|
+
* "defaults off" rule `--update` is registered under.
|
|
652
|
+
*/
|
|
653
|
+
const UPDATE_PROBE = path.resolve(HERE, '..', '..', 'tools', 'hooks', 'update-probe.mjs');
|
|
654
|
+
/**
|
|
655
|
+
* At most one probe per 24 h, decided from the `checked_at` of the record already in hand.
|
|
656
|
+
*
|
|
657
|
+
* THE TTL LIVES HERE AND NOWHERE ELSE. Neither runtime's reader has one, deliberately: the
|
|
658
|
+
* comparison is between the version that is RUNNING and the version the record last saw, so an
|
|
659
|
+
* old record cannot manufacture a false "you are stale" — if the operator updated since,
|
|
660
|
+
* running >= recorded and the line goes quiet by itself. Freshness is the writer's problem, and
|
|
661
|
+
* this is the writer's side of the fence.
|
|
662
|
+
*/
|
|
663
|
+
const UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
664
|
+
/**
|
|
665
|
+
* The line's byte budget, the same mechanism `SESSION_INJECT_MAX` and `PROMPT_INJECT_MAX` are:
|
|
666
|
+
* nothing this file injects is unbounded. The sentence itself is 137 bytes plus two version
|
|
667
|
+
* numbers; the one component that is not fixed is the operator's home path inside `{path}`, so
|
|
668
|
+
* the cap is generous and exists to bound that, not to trim the sentence.
|
|
669
|
+
*/
|
|
670
|
+
const UPDATE_LINE_MAX = 500;
|
|
671
|
+
/**
|
|
672
|
+
* THE SENTENCE. Only in the stale state, silent in the other four.
|
|
673
|
+
*
|
|
674
|
+
* It is NOT `updatecheck.UPDATE_AVAILABLE` and must not be made into it: that one is the status
|
|
675
|
+
* tool's, prefixed `update:` and naming no path, because a caller of `bantamkit_status` already
|
|
676
|
+
* knows which endpoint answered it. This hook does not — the host may talk to any registered
|
|
677
|
+
* endpoint, and this line names the install it ACTUALLY compared so the claim can be checked.
|
|
678
|
+
* Same reason `_install_source_condition` is the one condition allowed to name a path.
|
|
679
|
+
*
|
|
680
|
+
* `{program}` is `updatecheck.PROGRAM` — the COMMAND, `bantamkit-mcp`, never a package name:
|
|
681
|
+
* the PyPI distribution is `bantamkit` and the npm package is `bantamkit-mcp`, and the command
|
|
682
|
+
* is the one word that is true on both sides.
|
|
683
|
+
*
|
|
684
|
+
* "then reconnect the host" is measured reason 1 in `selfupdate.py:13-20`: a running server
|
|
685
|
+
* keeps serving the code it loaded at startup, so an updated install does nothing for THIS
|
|
686
|
+
* session.
|
|
687
|
+
*/
|
|
688
|
+
const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `{program} --update`, then reconnect the host.';
|
|
689
|
+
/** `{name}` substitution. A missing key is left alone rather than rendered `undefined`. */
|
|
690
|
+
function fillLine(template, values) {
|
|
691
|
+
return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The kept install's `{path, version}`, or `null`.
|
|
695
|
+
*
|
|
696
|
+
* `<homedir>/.bantamkit/mcp/node_modules/bantamkit-mcp/package.json` — the install `--install`
|
|
697
|
+
* makes and a host launches. It is the only install this hook can see; the endpoint the host
|
|
698
|
+
* actually talks to may be another one entirely, which is exactly why the line names this path
|
|
699
|
+
* instead of claiming to speak for all of them. No manifest means no line: a machine with no
|
|
700
|
+
* kept install has nothing here to be stale.
|
|
701
|
+
*
|
|
702
|
+
* `keptPrefix`/`keptManifest` are IMPORTED rather than respelled, and that is the point:
|
|
703
|
+
* `npminstall` owns the kept install's paths and `updatecheck` owns the record path, the
|
|
704
|
+
* record key and the five-state decision. Writing any of those a second time here would be a
|
|
705
|
+
* second thing to keep in step with two runtimes. There is no comparator in this file.
|
|
706
|
+
*/
|
|
707
|
+
function keptInstall(home) {
|
|
708
|
+
const manifest = keptManifest(keptPrefix(home));
|
|
709
|
+
try {
|
|
710
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
711
|
+
const version = parsed && typeof parsed.version === 'string' ? parsed.version.trim() : '';
|
|
712
|
+
return version === '' ? null : { path: manifest, version };
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* The line to inject (or `null`), and what was done about the record. Never throws.
|
|
720
|
+
*
|
|
721
|
+
* ORDER IS DELIBERATE: the record is read ONCE, the line is decided from it, and the probe
|
|
722
|
+
* decision is made from the SAME load. A second read would be a second answer.
|
|
723
|
+
*/
|
|
724
|
+
function updateSignal(run) {
|
|
725
|
+
const file = recordPath(run.home);
|
|
726
|
+
const { source, record } = loadRecord(file);
|
|
727
|
+
let line = null;
|
|
728
|
+
let state = null;
|
|
729
|
+
const kept = keptInstall(run.home);
|
|
730
|
+
if (kept) {
|
|
731
|
+
// `updatecheck.KEY` is `npm` in this runtime, which is the registry the kept install came
|
|
732
|
+
// from — so the key the reader picks is right by construction, not by a choice made here.
|
|
733
|
+
const status = decideUpdate(kept.version, source, record, UPDATE_KEY);
|
|
734
|
+
state = status.state;
|
|
735
|
+
if (state === STATE_AVAILABLE) {
|
|
736
|
+
const entry = record ? record[UPDATE_KEY] : undefined;
|
|
737
|
+
const latest = entry ? String(entry.latest) : '';
|
|
738
|
+
line = capBytes(fillLine(UPDATE_LINE, {
|
|
739
|
+
program: UPDATE_PROGRAM,
|
|
740
|
+
installed: kept.version,
|
|
741
|
+
path: kept.path,
|
|
742
|
+
latest,
|
|
743
|
+
}), UPDATE_LINE_MAX);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return { line, state, probe: maybeProbe(run, record) };
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Fork the probe and forget it, at most once per 24 h. Returns the word the log records.
|
|
750
|
+
*
|
|
751
|
+
* `detached: true` + `stdio: 'ignore'` + `.unref()` is the whole mechanism, and each third of
|
|
752
|
+
* it is load-bearing: `detached` puts the child in its own process group so the host reaping
|
|
753
|
+
* this hook does not reap it, `stdio: 'ignore'` means nothing it might ever print can reach the
|
|
754
|
+
* host's screen or hold a pipe open, and `unref()` releases the event loop so THIS process can
|
|
755
|
+
* exit while the child keeps running. Take any one away and SessionStart waits on a registry.
|
|
756
|
+
*
|
|
757
|
+
* A record that cannot say when it was written is treated as due, not as fresh — the same
|
|
758
|
+
* direction `updatecheck` takes it (an unparseable `checked_at` is not a record), and the safe
|
|
759
|
+
* one: the cost of an extra probe is a detached GET nobody waits for.
|
|
760
|
+
*/
|
|
761
|
+
function maybeProbe(run, record) {
|
|
762
|
+
const stamp = record && typeof record['checked_at'] === 'string' ? record['checked_at'] : null;
|
|
763
|
+
const checkedAt = stamp === null ? NaN : Date.parse(stamp);
|
|
764
|
+
if (Number.isFinite(checkedAt) && Date.now() - checkedAt < UPDATE_TTL_MS)
|
|
765
|
+
return 'fresh';
|
|
766
|
+
if (!fs.existsSync(UPDATE_PROBE))
|
|
767
|
+
return 'missing';
|
|
768
|
+
try {
|
|
769
|
+
const child = spawn(process.execPath, [UPDATE_PROBE], { detached: true, stdio: 'ignore' });
|
|
770
|
+
child.unref();
|
|
771
|
+
return 'spawned';
|
|
772
|
+
}
|
|
773
|
+
catch (e) {
|
|
774
|
+
// A spawn this hook cannot make is a log line, never a session that fails to start.
|
|
775
|
+
log(run, { event: 'SessionStart', warn: `update-probe: ${message(e)}` });
|
|
776
|
+
return 'failed';
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** `String(e.message || e)`, which is what every `catch` in the original wrote by hand. */
|
|
780
|
+
function message(e) {
|
|
781
|
+
if (e instanceof Error)
|
|
782
|
+
return e.message || String(e);
|
|
783
|
+
return String(e);
|
|
784
|
+
}
|
|
785
|
+
// ---------------------------------------------------------------- SessionStart
|
|
786
|
+
function sessionStart(run, input) {
|
|
787
|
+
const cwd = input.cwd || process.cwd();
|
|
788
|
+
const parts = [];
|
|
789
|
+
const profileFacts = countFacts(run.profile);
|
|
790
|
+
let profile = { block: '', total: 0, injected: 0, dropped: [] };
|
|
791
|
+
if (profileFacts > 0) {
|
|
792
|
+
profile = cappedIndex(run.profile, (count) => `[bantamkit profile memory — ${count} facts learned across projects]`, SESSION_INJECT_MAX);
|
|
793
|
+
parts.push(profile.block);
|
|
794
|
+
}
|
|
795
|
+
// WHERE THE HOST'S OWN AUTO-MEMORY DIRECTORY IS, resolved ONCE per session and used twice:
|
|
796
|
+
// it decides whether the project index has to be injected at all, and it is where A exports.
|
|
797
|
+
// SessionStart is the event because it is the one that already had to answer this question,
|
|
798
|
+
// it is paid once per session, and `transcript_path` — branch 3, the only branch that works
|
|
799
|
+
// on a machine the operator has not configured — arrives on it.
|
|
800
|
+
const native = resolveNativeMemory(run, input);
|
|
801
|
+
//
|
|
802
|
+
// A project store that is NOT the profile dir, and one of two things:
|
|
803
|
+
// - the host has no auto-memory store we can find → inject the index, as before;
|
|
804
|
+
// - the host HAS one → export into it instead. Injecting as well would pay twice for the
|
|
805
|
+
// same facts, which is the cost the old `nativeMemoryExists` branch existed to avoid.
|
|
806
|
+
// ONLY THE PROJECT LAYER IS EXPORTED. The native directory is keyed on the host's project
|
|
807
|
+
// root, so a cross-project profile fact placed in it would be copied into every project's
|
|
808
|
+
// store — and the profile index is injected above on every session anyway, so exporting it
|
|
809
|
+
// buys nothing and costs the collision job50/J50-2A already paid for once.
|
|
810
|
+
let project = null;
|
|
811
|
+
let exported = null;
|
|
812
|
+
try {
|
|
813
|
+
const store = discoverProjectStore(cwd);
|
|
814
|
+
if (path.resolve(store) !== path.resolve(run.profile) && countFacts(store) > 0) {
|
|
815
|
+
if (native.dir === null) {
|
|
816
|
+
project = cappedIndex(store, (count) => `[bantamkit project memory — ${count} facts]`, SESSION_INJECT_MAX);
|
|
817
|
+
project.facts = countFacts(store);
|
|
818
|
+
parts.push(project.block);
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
exported = exportToNative(native.dir, store);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
catch (e) {
|
|
826
|
+
log(run, { event: 'SessionStart', warn: message(e) });
|
|
827
|
+
}
|
|
828
|
+
parts.push('[bantamkit] Toolbox is live: mcp__bantamkit__memory_recall reads the body of any fact above; memory_save stores a durable lesson (feedback|user|project|reference — never something derivable from the repo). Repeat reads of an unchanged file are refused once by the filegraph hook; a save nudge fires once at session end when nothing was saved.');
|
|
829
|
+
// LAST, AND ONLY WHEN IT IS TRUE. The toolbox line above is constant boilerplate the agent
|
|
830
|
+
// sees every session; this one appears in one of five states and asks for an action, so it
|
|
831
|
+
// gets the strongest position in the block. Everything above it is unchanged when it is
|
|
832
|
+
// absent, which is the other four states and most sessions.
|
|
833
|
+
let update = { line: null, state: null, probe: 'error' };
|
|
834
|
+
try {
|
|
835
|
+
update = updateSignal(run);
|
|
836
|
+
}
|
|
837
|
+
catch (e) {
|
|
838
|
+
log(run, { event: 'SessionStart', warn: `update-signal: ${message(e)}` });
|
|
839
|
+
}
|
|
840
|
+
if (update.line)
|
|
841
|
+
parts.push(update.line);
|
|
842
|
+
const ctx = parts.join('\n\n');
|
|
843
|
+
if (input.source === 'compact') {
|
|
844
|
+
// context was just rebuilt: earlier reads are gone, so the read ledger must not refuse them
|
|
845
|
+
try {
|
|
846
|
+
fs.unlinkSync(ledgerPath(run, input.session_id));
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
/* none */
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// `profileFacts` keeps its old meaning — files in the store — so older records stay
|
|
853
|
+
// comparable; `profileInjected` / `profileDropped` are what the block carried and did
|
|
854
|
+
// not, and `dropRule` is the order the drop followed. The project trio appears only when
|
|
855
|
+
// a project block was injected at all.
|
|
856
|
+
log(run, {
|
|
857
|
+
event: 'SessionStart',
|
|
858
|
+
source: input.source,
|
|
859
|
+
cwd,
|
|
860
|
+
bytes: Buffer.byteLength(ctx),
|
|
861
|
+
profileFacts,
|
|
862
|
+
profileInjected: profile.injected,
|
|
863
|
+
profileDropped: profile.dropped,
|
|
864
|
+
...(project
|
|
865
|
+
? { projectFacts: project.facts, projectInjected: project.injected, projectDropped: project.dropped }
|
|
866
|
+
: {}),
|
|
867
|
+
dropRule: SESSION_DROP_RULE,
|
|
868
|
+
// A — the host's auto-memory directory. `nativeBranch` / `nativeTried` are the one line
|
|
869
|
+
// RULING Q1.3 asks for when nothing answered: they say WHICH branches were tried, so
|
|
870
|
+
// "bantamkit exported nothing" is never indistinguishable from "bantamkit did not look".
|
|
871
|
+
// The export trio appears only when an export actually ran.
|
|
872
|
+
nativeBranch: native.branch,
|
|
873
|
+
nativeTried: native.tried,
|
|
874
|
+
nativeDir: native.dir,
|
|
875
|
+
...(exported
|
|
876
|
+
? {
|
|
877
|
+
nativeExported: exported.exported,
|
|
878
|
+
nativeFiles: exported.files,
|
|
879
|
+
nativeIndexLines: exported.indexLines,
|
|
880
|
+
nativeBytes: exported.bytes,
|
|
881
|
+
nativeIndexBytes: exported.indexBytes,
|
|
882
|
+
nativeSkipped: exported.skipped,
|
|
883
|
+
nativeIndex: exported.index,
|
|
884
|
+
...(exported.error ? { nativeError: exported.error } : {}),
|
|
885
|
+
}
|
|
886
|
+
: {}),
|
|
887
|
+
storeScope: run.storeScope,
|
|
888
|
+
// The stale-install signal, in three fields so the claim "it fired / it stayed quiet / it
|
|
889
|
+
// asked the registry" is a number the operator can rerun rather than a sentence. `state` is
|
|
890
|
+
// one of `updatecheck`'s five (or `null` when there is no kept install to compare);
|
|
891
|
+
// `probe` is `spawned` / `fresh` / `unbuilt` / `missing` / `failed` / `error`.
|
|
892
|
+
updateState: update.state,
|
|
893
|
+
updateProbe: update.probe,
|
|
894
|
+
updateBytes: Buffer.byteLength(update.line || ''),
|
|
895
|
+
});
|
|
896
|
+
emit({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: ctx } });
|
|
897
|
+
}
|
|
898
|
+
// ------------------------------------------------------------ UserPromptSubmit
|
|
899
|
+
/**
|
|
900
|
+
* One injected recall header, parsed: `[layer] [name] (type) description`.
|
|
901
|
+
*
|
|
902
|
+
* ONE regex, used both to FILTER the reply's lines and to read the fields off them, so the
|
|
903
|
+
* set of lines called headers cannot drift from the set of lines the log describes.
|
|
904
|
+
*/
|
|
905
|
+
const RECALL_HEADER = /^\[([^\]]+)\] \[([^\]]+)\] \(([a-z]+)\) (.*)$/;
|
|
906
|
+
/**
|
|
907
|
+
* The store's own score for one injected header against this prompt.
|
|
908
|
+
*
|
|
909
|
+
* `MemoryStore.recall` computes `score` and throws it away — it returns `Fact[]`, and
|
|
910
|
+
* `recallOutcome` carries counts but no per-fact score, so no runtime API surfaces the
|
|
911
|
+
* number roadmap #6 has to gate on. It does not need to. The score IS
|
|
912
|
+
*
|
|
913
|
+
* |tokens(name + " " + description) ∩ tokens(query)|
|
|
914
|
+
*
|
|
915
|
+
* and all three inputs are here: `tokens` is exported from the runtime's own `store.js`
|
|
916
|
+
* (so this is the shipped tokenizer, not a second copy of it), the query is the prompt, and
|
|
917
|
+
* `name`/`description` are the two fields `Memory.format` interpolated into this very line.
|
|
918
|
+
* Re-deriving it is exact, not an estimate — for every fact `memory_save` writes, whose
|
|
919
|
+
* description is one line by construction. A hand-edited multi-line description would have
|
|
920
|
+
* already broken the header this parses, and would score its first line.
|
|
921
|
+
*/
|
|
922
|
+
function scoreHeader(head, queryTokens) {
|
|
923
|
+
const m = RECALL_HEADER.exec(head);
|
|
924
|
+
if (!m)
|
|
925
|
+
return null;
|
|
926
|
+
const layer = m[1] ?? '';
|
|
927
|
+
const name = m[2] ?? '';
|
|
928
|
+
const type = m[3] ?? '';
|
|
929
|
+
const description = m[4] ?? '';
|
|
930
|
+
let score = 0;
|
|
931
|
+
for (const t of tokens(`${name} ${description}`))
|
|
932
|
+
if (queryTokens.has(t))
|
|
933
|
+
score += 1;
|
|
934
|
+
return { name, layer, type, score };
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* The prompt, as a fingerprint that cannot be read back.
|
|
938
|
+
*
|
|
939
|
+
* THE LOG PERSISTS TO DISK AND THE PROMPTS ARE THE USER'S. Nothing reconstructible goes in:
|
|
940
|
+
* a SHA-256 hex digest and two sizes, and no substring of the prompt at any length. The
|
|
941
|
+
* digest exists to tell two prompts apart and to recognise the same prompt twice — that is
|
|
942
|
+
* all #6 needs from it. It is a one-way function, not a secret: someone holding a GUESS at
|
|
943
|
+
* the prompt can confirm the guess by hashing it. That is inherent to any stable hash, and
|
|
944
|
+
* the alternative — a per-machine salt — would buy nothing here (the guesser has the salt
|
|
945
|
+
* too, it sits in the same home directory) at the cost of digests that stop matching across
|
|
946
|
+
* machines. So: stable, unsalted, and documented rather than dressed up.
|
|
947
|
+
*/
|
|
948
|
+
function promptFingerprint(prompt) {
|
|
949
|
+
return {
|
|
950
|
+
sha256: createHash('sha256').update(prompt, 'utf8').digest('hex'),
|
|
951
|
+
chars: prompt.length,
|
|
952
|
+
bytes: Buffer.byteLength(prompt),
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
function userPromptSubmit(run, input) {
|
|
956
|
+
const prompt = String(input.prompt || '').trim();
|
|
957
|
+
if (prompt.length < PROMPT_MIN_CHARS || prompt.startsWith('/')) {
|
|
958
|
+
log(run, { event: 'UserPromptSubmit', action: 'skip', reason: 'short-or-command' });
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const m = Memory.layered(input.cwd || process.cwd());
|
|
962
|
+
const o = m.recallOutcome(prompt, 3);
|
|
963
|
+
if (o.status !== 'answered') {
|
|
964
|
+
log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
// Only the HEADER line of each hit — `[layer] [name] (type) description`. The body costs
|
|
968
|
+
// ~1.5 KB a fact and would be re-sent on every later call; the header is ~150 B and
|
|
969
|
+
// tells the model exactly which name to pass to memory_recall if it wants the body.
|
|
970
|
+
const heads = o.reply.split('\n').filter((l) => RECALL_HEADER.test(l));
|
|
971
|
+
if (heads.length === 0) {
|
|
972
|
+
log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers' });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const ctx = capLines(`[bantamkit recall — memories that match this prompt; call mcp__bantamkit__memory_recall with {"query":"<name>"} for the body]\n${heads.join('\n')}`, PROMPT_INJECT_MAX);
|
|
976
|
+
// `injected` is read back off `ctx`, NOT off `heads`. The byte cap drops whole lines, so a
|
|
977
|
+
// header that `recallOutcome` picked need not have left the process — and roadmap #6 asks
|
|
978
|
+
// "was an INJECTED name later used", a question a name the model never saw would poison.
|
|
979
|
+
// `hits` keeps its old meaning (headers picked, pre-cap) so the 487 records written before
|
|
980
|
+
// this change stay comparable; `dropped` is the difference the old shape could not show.
|
|
981
|
+
const queryTokens = tokens(prompt);
|
|
982
|
+
const injected = ctx
|
|
983
|
+
.split('\n')
|
|
984
|
+
.map((l) => scoreHeader(l, queryTokens))
|
|
985
|
+
.filter((x) => x !== null);
|
|
986
|
+
log(run, {
|
|
987
|
+
event: 'UserPromptSubmit',
|
|
988
|
+
action: 'inject',
|
|
989
|
+
hits: heads.length,
|
|
990
|
+
bytes: Buffer.byteLength(ctx),
|
|
991
|
+
source: o.source,
|
|
992
|
+
// The join key. Without it an injection record cannot be matched to the transcript that
|
|
993
|
+
// says what the model did next, which is why the 487 pre-existing records answer nothing.
|
|
994
|
+
session: input.session_id ?? null,
|
|
995
|
+
prompt: promptFingerprint(prompt),
|
|
996
|
+
injected,
|
|
997
|
+
dropped: heads.length - injected.length,
|
|
998
|
+
});
|
|
999
|
+
emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } });
|
|
1000
|
+
}
|
|
1001
|
+
// ------------------------------------------------------------- PreToolUse Read
|
|
1002
|
+
// The filegraph, over the OPERATOR's reads. `filegraph.md` measured the mechanism as net
|
|
1003
|
+
// positive and never a loss (cache pays only on a repeat); it could not reach these reads
|
|
1004
|
+
// because they never pass through bantamkit's Agent. A PreToolUse hook is the one place
|
|
1005
|
+
// that does see them. Keyed by transcript (a subagent has its own transcript and has NOT
|
|
1006
|
+
// seen the parent's read), by path+offset+limit, and by mtime+size so an edit re-arms it.
|
|
1007
|
+
// REFUSES ONCE: the second identical call goes through, so nothing can be hard-blocked.
|
|
1008
|
+
function preToolUseRead(run, input) {
|
|
1009
|
+
const ti = input.tool_input ?? {};
|
|
1010
|
+
const file = typeof ti['file_path'] === 'string' ? ti['file_path'] : '';
|
|
1011
|
+
if (!file)
|
|
1012
|
+
return;
|
|
1013
|
+
let st;
|
|
1014
|
+
try {
|
|
1015
|
+
st = fs.statSync(file);
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
return; // missing file: let Read produce its own error
|
|
1019
|
+
}
|
|
1020
|
+
const offset = ti['offset'];
|
|
1021
|
+
const limit = ti['limit'];
|
|
1022
|
+
// The transcript is the CONTEXT dimension: a subagent has its own transcript and has not
|
|
1023
|
+
// seen the parent's reads. It is carried on the record as well as in the key, because
|
|
1024
|
+
// `preCompact` must filter on it and a path may itself contain the key's delimiter.
|
|
1025
|
+
const transcript = String(input.transcript_path || input.session_id || '');
|
|
1026
|
+
const key = `${transcript}|${file}|${offset ?? ''}|${limit ?? ''}`;
|
|
1027
|
+
const ledger = readLedger(run, input.session_id);
|
|
1028
|
+
const prev = ledger.reads[key];
|
|
1029
|
+
const sig = `${st.mtimeMs}|${st.size}`;
|
|
1030
|
+
if (prev && prev.sig === sig && !prev.refused) {
|
|
1031
|
+
prev.refused = true;
|
|
1032
|
+
prev.count += 1;
|
|
1033
|
+
writeLedger(run, input.session_id, ledger);
|
|
1034
|
+
const reason = `bantamkit filegraph: ${file}${offset != null ? ` (offset ${offset}${limit != null ? `, limit ${limit}` : ''})` : ''} was already read in this context at ${prev.at} and is unchanged on disk (same mtime and size). Use the content from that earlier read. If you genuinely need it again, repeat the exact same call — this refusal fires only once per unchanged file.`;
|
|
1035
|
+
log(run, { event: 'PreToolUse', action: 'refuse', file, size: st.size });
|
|
1036
|
+
emit({
|
|
1037
|
+
hookSpecificOutput: {
|
|
1038
|
+
hookEventName: 'PreToolUse',
|
|
1039
|
+
permissionDecision: 'deny',
|
|
1040
|
+
permissionDecisionReason: reason,
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
ledger.reads[key] = {
|
|
1046
|
+
sig,
|
|
1047
|
+
at: new Date().toISOString(),
|
|
1048
|
+
count: (prev ? prev.count : 0) + 1,
|
|
1049
|
+
refused: false,
|
|
1050
|
+
transcript,
|
|
1051
|
+
file,
|
|
1052
|
+
};
|
|
1053
|
+
writeLedger(run, input.session_id, ledger);
|
|
1054
|
+
log(run, {
|
|
1055
|
+
event: 'PreToolUse',
|
|
1056
|
+
action: prev ? 'allow-after-refuse-or-change' : 'record',
|
|
1057
|
+
file,
|
|
1058
|
+
size: st.size,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
// ---------------------------------------------- PostToolUse → the usage events log
|
|
1062
|
+
// One line per tool call, for `tools/ledger/tool-usage.mjs` to read when a transcript is gone.
|
|
1063
|
+
// The transcript is authoritative while it exists; this is the durable copy behind it, and it
|
|
1064
|
+
// is the ONLY record of a session whose transcript the host has since deleted (4 of 110 logged
|
|
1065
|
+
// sessions, measured 2026-09-04).
|
|
1066
|
+
//
|
|
1067
|
+
// `appendFileSync` and not the read-modify-write `writeLedger` above: the host fires one hook
|
|
1068
|
+
// PROCESS per tool call and a parallel tool block fires them concurrently, which loses 40–50 %
|
|
1069
|
+
// of a read-modify-write's records (roadmap-toolbox row 8, follow-up (q)). An append of a line
|
|
1070
|
+
// this size is atomic on both platforms, so this half has no such race.
|
|
1071
|
+
//
|
|
1072
|
+
// Folded in from tool-metrics' `hooks/scripts/log_event.py`; the field names are that file's,
|
|
1073
|
+
// so an events.jsonl written by either program reads in either.
|
|
1074
|
+
function appendUsageEvent(run, input) {
|
|
1075
|
+
const tool = input.tool_name || '?';
|
|
1076
|
+
const ti = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
|
|
1077
|
+
const record = {
|
|
1078
|
+
ts: new Date().toISOString(),
|
|
1079
|
+
session: input.session_id || '',
|
|
1080
|
+
// The HOST's slug, not tool-metrics' — `token-ledger.mjs` uses this same expression.
|
|
1081
|
+
// log_event.py mapped `.` to a dash as well, which the host does not: this machine has
|
|
1082
|
+
// both `-private-tmp-r3-p3.p1ex6K` and `-private-tmp-r3-p3-p1ex6K` as separate project
|
|
1083
|
+
// dirs, so the python slug split one project into two keys under `--group project` and
|
|
1084
|
+
// made `--project` miss the recovered rows entirely.
|
|
1085
|
+
project: String(input.cwd || '').replace(/[\\/:]/g, '-'),
|
|
1086
|
+
tool,
|
|
1087
|
+
server: tool.startsWith('mcp__') && tool.split('__').length >= 3 ? tool.split('__')[1] : 'builtin',
|
|
1088
|
+
// The dedupe key. Two writers append to this file by design and one machine can register
|
|
1089
|
+
// the hook at both user and project scope, so a call can be logged twice; the reader
|
|
1090
|
+
// dedupes on this the same way it dedupes transcript blocks. Rows written before this
|
|
1091
|
+
// field existed simply carry none.
|
|
1092
|
+
tool_use_id: String(input.tool_use_id ?? ''),
|
|
1093
|
+
detail: tool === 'Skill'
|
|
1094
|
+
? String(ti['skill'] ?? '')
|
|
1095
|
+
: tool === 'Agent'
|
|
1096
|
+
? String(ti['subagent_type'] || 'general-purpose')
|
|
1097
|
+
: '',
|
|
1098
|
+
};
|
|
1099
|
+
const dir = process.env['TOOL_METRICS_DIR'] || path.join(os.homedir(), '.claude', 'tool-metrics');
|
|
1100
|
+
const file = path.join(dir, 'events.jsonl');
|
|
1101
|
+
try {
|
|
1102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1103
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`);
|
|
1104
|
+
pruneUsageEvents(run, file);
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
/* the log is a convenience; never fail a tool call over it */
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// The log's ONLY reader is `tools/ledger/tool-usage.mjs`, and it reads ONLY the sessions whose
|
|
1111
|
+
// transcript the host has deleted — 4 of 110 when this was written, holding 115 of 40,873
|
|
1112
|
+
// lines. The other 99.7 % are dead weight that the reader re-materialises on every run, and
|
|
1113
|
+
// now that this arm is matcher-less the file grows once per tool call (~42k/month here).
|
|
1114
|
+
//
|
|
1115
|
+
// So: a line whose session still has a transcript is redundant BY CONSTRUCTION, and dropping
|
|
1116
|
+
// it loses nothing the reader would have used. Above the cap, that is exactly what this drops.
|
|
1117
|
+
//
|
|
1118
|
+
// The cost is paid the right way round. `statSync` runs on every call and is a few
|
|
1119
|
+
// microseconds; the walk and rewrite run only when the file is over the cap, and each prune
|
|
1120
|
+
// puts it far enough under that the next one is thousands of calls away. The walk reads
|
|
1121
|
+
// DIRECTORY ENTRIES, never file contents.
|
|
1122
|
+
const EVENTS_MAX_BYTES = 4_000_000;
|
|
1123
|
+
function pruneUsageEvents(run, file) {
|
|
1124
|
+
let size = 0;
|
|
1125
|
+
try {
|
|
1126
|
+
size = fs.statSync(file).size;
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (size <= EVENTS_MAX_BYTES)
|
|
1132
|
+
return;
|
|
1133
|
+
const projects = path.join(run.home, '.claude', 'projects');
|
|
1134
|
+
const onDisk = new Set();
|
|
1135
|
+
const walk = (dir) => {
|
|
1136
|
+
let entries = [];
|
|
1137
|
+
try {
|
|
1138
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
for (const e of entries) {
|
|
1144
|
+
if (e.isDirectory()) {
|
|
1145
|
+
onDisk.add(e.name);
|
|
1146
|
+
walk(path.join(dir, e.name));
|
|
1147
|
+
}
|
|
1148
|
+
else if (e.name.endsWith('.jsonl')) {
|
|
1149
|
+
onDisk.add(e.name.slice(0, -'.jsonl'.length));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
walk(projects);
|
|
1154
|
+
// A walk that found nothing is an unreadable projects dir, not a machine with no
|
|
1155
|
+
// transcripts. Pruning on that reading would delete the whole log.
|
|
1156
|
+
if (onDisk.size === 0) {
|
|
1157
|
+
log(run, { event: 'PostToolUse', action: 'prune-skipped', reason: 'no transcripts found', size });
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const kept = [];
|
|
1161
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
1162
|
+
if (!line.trim())
|
|
1163
|
+
continue;
|
|
1164
|
+
let record;
|
|
1165
|
+
try {
|
|
1166
|
+
record = JSON.parse(line);
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
kept.push(line); // keep what we cannot judge
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
if (!record?.session || !onDisk.has(String(record.session)))
|
|
1173
|
+
kept.push(line);
|
|
1174
|
+
}
|
|
1175
|
+
const tmp = `${file}.prune-${process.pid}`;
|
|
1176
|
+
fs.writeFileSync(tmp, kept.length ? `${kept.join('\n')}\n` : '');
|
|
1177
|
+
fs.renameSync(tmp, file);
|
|
1178
|
+
log(run, { event: 'PostToolUse', action: 'prune', before: size, after: fs.statSync(file).size, kept: kept.length });
|
|
1179
|
+
}
|
|
1180
|
+
// ---------------------------------------------- the server's actual --index-budget
|
|
1181
|
+
// (and, since J50-1, the server's actual store pin — the walk below is shared by both)
|
|
1182
|
+
// `Memory.layered(cwd)` opens the project store at DEFAULT_INDEX_BUDGET unless told
|
|
1183
|
+
// otherwise, and the MCP server honours `--index-budget N` (`runtime-ts/src/cli.ts`).
|
|
1184
|
+
// A RUNNING server never writes that number down anywhere: `MemoryStore` keeps `indexBudget`
|
|
1185
|
+
// in memory only, so nothing publishes it and this file cannot be made to (that would be a
|
|
1186
|
+
// change inside `runtime-ts/src/memory`, a different layer and a different unit). The one
|
|
1187
|
+
// place the value survives between "the operator configured it" and "this short-lived hook
|
|
1188
|
+
// process needs it" is the SAME configuration a Claude Code session itself reads to decide
|
|
1189
|
+
// which server to start.
|
|
1190
|
+
// `tools/mcpdrift/mcpdrift.py`'s `discover()` already names the three scopes that can change
|
|
1191
|
+
// which server answers a session for a given project directory (user, local, project); this
|
|
1192
|
+
// reads the same three, for the same reason — those are the configs that can actually change
|
|
1193
|
+
// the answer, not a fourth format guessed at.
|
|
1194
|
+
//
|
|
1195
|
+
// WHAT THIS DELIBERATELY DOES NOT COVER: Claude Desktop / Cursor / Copilot configs (this
|
|
1196
|
+
// hook only ever runs under Claude Code, `docs/hooks.md`), enterprise-managed settings, and a
|
|
1197
|
+
// server started by hand outside all three files. None of those were covered before this fix
|
|
1198
|
+
// either — the old code always assumed the default — so leaving them uncovered narrows an
|
|
1199
|
+
// existing gap rather than regressing it.
|
|
1200
|
+
//
|
|
1201
|
+
// SCOPES DO NOT DISAGREE — THEY HAVE A PRECEDENCE, AND THIS FOLLOWS IT. Until 2026-09-06
|
|
1202
|
+
// this collected the DISTINCT `--index-budget` values across the three scopes and refused to
|
|
1203
|
+
// compact ("AMBIGUITY IS A REFUSAL, NOT A GUESS") whenever it found more than one. That
|
|
1204
|
+
// refusal fired on the NORMAL case: a machine with a user-scope default and a project-scope
|
|
1205
|
+
// override is configured, not ambiguous, and the cost was that automatic compaction silently
|
|
1206
|
+
// stopped for that project with nothing but a log line to show for it. Verified against the
|
|
1207
|
+
// host's own documentation on 2026-09-06 rather than reasoned about
|
|
1208
|
+
// (https://code.claude.com/docs/en/mcp, "MCP installation scopes"): when the same server name
|
|
1209
|
+
// is defined in more than one scope Claude Code connects to it ONCE, using the definition
|
|
1210
|
+
// from the highest-precedence source, and the precedence is
|
|
1211
|
+
//
|
|
1212
|
+
// local > project > user
|
|
1213
|
+
//
|
|
1214
|
+
// — local being `~/.claude.json` `.projects[<resolved cwd>].mcpServers`, project being
|
|
1215
|
+
// `<cwd>/.mcp.json`, user being `~/.claude.json` `.mcpServers`.
|
|
1216
|
+
//
|
|
1217
|
+
// THE UNIT OF PRECEDENCE IS THE WHOLE ENTRY, NOT THE FLAG, which is the part that is easy to
|
|
1218
|
+
// get wrong: the same page says fields are NEVER merged across scopes. So the search below is
|
|
1219
|
+
// for the highest-precedence scope that registers `bantamkit` AT ALL, and `--index-budget` is
|
|
1220
|
+
// then read from that entry alone. A local-scope entry with no `--index-budget` therefore
|
|
1221
|
+
// means the DEFAULT, even when a user-scope entry names a number — because the user-scope
|
|
1222
|
+
// entry is not what the session launched. Reading the flag scope-by-scope instead would
|
|
1223
|
+
// reintroduce exactly the wrong-denominator bug this arm exists to fix.
|
|
1224
|
+
//
|
|
1225
|
+
// THERE IS NO AMBIGUOUS CASE LEFT, so there is no refusal branch — dead code shaped like a
|
|
1226
|
+
// safety net is worse than none. Precedence is total over the three scopes, each scope holds
|
|
1227
|
+
// at most one `bantamkit` entry, and each entry yields at most one `--index-budget`. Finding
|
|
1228
|
+
// no entry at all means nothing configures it anywhere this hook can see, and the default is
|
|
1229
|
+
// then the honest answer, not a guess: it is the same default the CLI itself falls back to
|
|
1230
|
+
// when `--index-budget` is absent.
|
|
1231
|
+
//
|
|
1232
|
+
// ONE RESIDUAL, NAMED RATHER THAN GUESSED AT: a project-scope `.mcp.json` server is not
|
|
1233
|
+
// launched until the user approves it, and that answer is recorded in the SAME file, as
|
|
1234
|
+
// `.projects[<cwd>].enabledMcpjsonServers` / `.disabledMcpjsonServers` — both keys are real
|
|
1235
|
+
// and present in this machine's `~/.claude.json`, both empty here. This hook does NOT consult
|
|
1236
|
+
// them, because the pending state (in neither list) is not resolvable from the file and the
|
|
1237
|
+
// disabled state has not been reproduced end to end from a live host. Registered in
|
|
1238
|
+
// `docs/roadmap-toolbox.md` rather than half-implemented.
|
|
1239
|
+
function readJsonSafe(file) {
|
|
1240
|
+
try {
|
|
1241
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1242
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
1243
|
+
? parsed
|
|
1244
|
+
: {};
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
return {};
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
/** Best-effort marker write. A marker that cannot be written costs a repeated dream, not a crash. */
|
|
1251
|
+
function writeJsonSafe(file, value) {
|
|
1252
|
+
try {
|
|
1253
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1254
|
+
fs.writeFileSync(file, JSON.stringify(value));
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
/* the gate degrades to "always fires", which is safe and merely not free */
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
function indexBudgetFromArgs(args) {
|
|
1261
|
+
if (!Array.isArray(args))
|
|
1262
|
+
return undefined;
|
|
1263
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1264
|
+
const a = args[i];
|
|
1265
|
+
if (a === '--index-budget' && i + 1 < args.length) {
|
|
1266
|
+
const n = Number(args[i + 1]);
|
|
1267
|
+
if (Number.isFinite(n))
|
|
1268
|
+
return n;
|
|
1269
|
+
}
|
|
1270
|
+
else if (typeof a === 'string' && a.startsWith('--index-budget=')) {
|
|
1271
|
+
const n = Number(a.slice('--index-budget='.length));
|
|
1272
|
+
if (Number.isFinite(n))
|
|
1273
|
+
return n;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return undefined;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* The `--index-budget` the `bantamkit` registration a session in `cwd` would actually launch
|
|
1280
|
+
* carries — resolved by Claude Code's own MCP scope precedence, `local > project > user`, over
|
|
1281
|
+
* the WHOLE entry. Cheap: two small file reads, and none of it requires the server to be up.
|
|
1282
|
+
*
|
|
1283
|
+
* Returns `{ budget, scope }`. `budget` is `undefined` when the winning entry configures no
|
|
1284
|
+
* `--index-budget` and when no scope registers `bantamkit` at all; `scope` names the entry
|
|
1285
|
+
* that won, or is `null` when none did, so the log says WHICH file the number came from.
|
|
1286
|
+
*/
|
|
1287
|
+
function configuredIndexBudget(run, cwd) {
|
|
1288
|
+
const { entry, scope } = winningRegistration(run, cwd);
|
|
1289
|
+
return entry ? { budget: indexBudgetFromArgs(entry['args']), scope } : { budget: undefined, scope: null };
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* The `bantamkit` registration entry a session in `cwd` actually launched, and the scope it
|
|
1293
|
+
* came from — the walk `configuredIndexBudget` has always done, lifted out (J50-1) so that
|
|
1294
|
+
* `env` is read off the SAME entry as `args`. One walk, two fields; a second walk would be
|
|
1295
|
+
* the second place the precedence rule could be got wrong.
|
|
1296
|
+
*
|
|
1297
|
+
* Returns `{ entry, scope }`, both `null` when no scope registers `bantamkit` at all.
|
|
1298
|
+
*/
|
|
1299
|
+
function winningRegistration(run, cwd) {
|
|
1300
|
+
const repo = path.resolve(cwd);
|
|
1301
|
+
const claudeJson = readJsonSafe(path.join(run.home, '.claude.json'));
|
|
1302
|
+
const mcpJson = readJsonSafe(path.join(repo, '.mcp.json'));
|
|
1303
|
+
const dig = (root, keys) => {
|
|
1304
|
+
let node = root;
|
|
1305
|
+
for (const key of keys) {
|
|
1306
|
+
if (!node || typeof node !== 'object' || Array.isArray(node))
|
|
1307
|
+
return undefined;
|
|
1308
|
+
node = node[key];
|
|
1309
|
+
}
|
|
1310
|
+
return node;
|
|
1311
|
+
};
|
|
1312
|
+
// Highest precedence first. Order is the whole point; do not sort or reorder.
|
|
1313
|
+
const scopes = [
|
|
1314
|
+
['local', dig(claudeJson, ['projects', repo, 'mcpServers', 'bantamkit'])],
|
|
1315
|
+
['project', dig(mcpJson, ['mcpServers', 'bantamkit'])],
|
|
1316
|
+
['user', dig(claudeJson, ['mcpServers', 'bantamkit'])],
|
|
1317
|
+
];
|
|
1318
|
+
for (const [scope, entry] of scopes) {
|
|
1319
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
1320
|
+
continue;
|
|
1321
|
+
return { entry: entry, scope };
|
|
1322
|
+
}
|
|
1323
|
+
return { entry: null, scope: null };
|
|
1324
|
+
}
|
|
1325
|
+
// ------------------------------------------------ the server's actual store pin (J50-1)
|
|
1326
|
+
//
|
|
1327
|
+
// `discoverProjectStore`, `resolveProjectStore` and `Memory.layered` — every store the hook
|
|
1328
|
+
// binds, in every arm — resolve through `pinnedStore()` in `memory/layers.js`, which
|
|
1329
|
+
// reads `process.env.BANTAMKIT_MEMORY_DIR` and outranks the walk. The SERVER sees a
|
|
1330
|
+
// registration's `env` because the host merges it into the server's process before spawning
|
|
1331
|
+
// it; this hook is spawned by the host too, but from the host's OWN environment, and the
|
|
1332
|
+
// registration's `env` never reaches it. So a registration that pins the store had the
|
|
1333
|
+
// server saving into the pinned directory while this hook injected from whatever the walk
|
|
1334
|
+
// found — from any cwd the walk would not have led to the pin, two different stores.
|
|
1335
|
+
//
|
|
1336
|
+
// MEASURED LATENT, NOT LIVE, on 2026-09-12: the only pin on this machine is the bantamkit
|
|
1337
|
+
// repo's LOCAL-scope entry, and it names the directory the walk finds from that cwd anyway
|
|
1338
|
+
// (`.shiftwork/notes-job50/PROBE.md`, B2). The user-scope pin that DID split the two was
|
|
1339
|
+
// removed on 2026-09-11. This closes the shape, not one instance of it.
|
|
1340
|
+
//
|
|
1341
|
+
// THE FIX FEEDS THE ONE RESOLUTION RATHER THAN ADDING A SECOND: the winning entry's value is
|
|
1342
|
+
// applied to this process's environment before any arm runs, and the same `pinnedStore()`
|
|
1343
|
+
// the server runs then sees the same value. Host merge semantics are what decide the edge
|
|
1344
|
+
// cases, and they are `{ ...inherited, ...entry.env }`: a key PRESENT in the winning entry
|
|
1345
|
+
// overrides whatever this process inherited (a blank one included — `pinnedStore` already
|
|
1346
|
+
// reads blank as "no pin", so the server and the hook then both walk), and a key ABSENT from
|
|
1347
|
+
// it leaves the inherited value alone, because that is what the server inherits too. The
|
|
1348
|
+
// whole-entry rule applies exactly as it does to `--index-budget`: a winning entry with no
|
|
1349
|
+
// `env` means the walk, even when a lower scope pins.
|
|
1350
|
+
//
|
|
1351
|
+
// NOT DONE HERE, NAMED RATHER THAN HALF-BUILT: the host expands `${VAR}` and `${VAR:-default}`
|
|
1352
|
+
// in `.mcp.json` `env` values before spawning the server. A pin written that way reaches
|
|
1353
|
+
// `pinnedStore` unexpanded here, and is refused by it as a relative path — loudly, on the
|
|
1354
|
+
// log, never silently as the wrong store. No registration on this machine uses the syntax.
|
|
1355
|
+
function applyRegistrationStorePin(run, cwd) {
|
|
1356
|
+
const { entry, scope } = winningRegistration(run, cwd);
|
|
1357
|
+
const env = entry ? entry['env'] : null;
|
|
1358
|
+
if (!env || typeof env !== 'object' || Array.isArray(env))
|
|
1359
|
+
return;
|
|
1360
|
+
if (!Object.prototype.hasOwnProperty.call(env, 'BANTAMKIT_MEMORY_DIR'))
|
|
1361
|
+
return;
|
|
1362
|
+
const value = env['BANTAMKIT_MEMORY_DIR'];
|
|
1363
|
+
if (typeof value !== 'string')
|
|
1364
|
+
return;
|
|
1365
|
+
process.env['BANTAMKIT_MEMORY_DIR'] = value;
|
|
1366
|
+
run.storeScope = value.trim() === '' ? null : scope;
|
|
1367
|
+
}
|
|
1368
|
+
// ---------------------------------------------- PostToolUse memory_save → compact
|
|
1369
|
+
function postSave(run, input) {
|
|
1370
|
+
const ledger = readLedger(run, input.session_id);
|
|
1371
|
+
ledger.saved = (ledger.saved || 0) + 1;
|
|
1372
|
+
writeLedger(run, input.session_id, ledger);
|
|
1373
|
+
const cwd = input.cwd || process.cwd();
|
|
1374
|
+
const configured = configuredIndexBudget(run, cwd);
|
|
1375
|
+
const budgetSource = configured.budget !== undefined ? 'configured' : 'default';
|
|
1376
|
+
const budgetScope = configured.budget !== undefined ? configured.scope : null;
|
|
1377
|
+
const m = Memory.layered(cwd, configured.budget !== undefined ? { indexBudget: configured.budget } : {});
|
|
1378
|
+
const [bytes, budget] = m.indexAccounting();
|
|
1379
|
+
if (bytes == null || bytes < COMPACT_AT * budget) {
|
|
1380
|
+
log(run, { event: 'PostToolUse', action: 'saved', bytes, budget, budgetSource, budgetScope });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
// The user ruled compaction automatic (2026-08-24). `compact` archives the stalest facts
|
|
1384
|
+
// until the index sits at --budget; aiming at 80% skips the 90–99.2% band where the
|
|
1385
|
+
// default reserve makes it a no-op.
|
|
1386
|
+
//
|
|
1387
|
+
// AMENDED 2026-09-10 (job46, J46-6): the band is closed, and the aim is now asked for as a
|
|
1388
|
+
// RESERVE against the real budget rather than as a fake budget — see COMPACT_TO above for
|
|
1389
|
+
// the measurement. `compact`'s target is `budget - reserve`, so this lands at exactly
|
|
1390
|
+
// `target` instead of at whatever the default reserve makes of a budget it was misled
|
|
1391
|
+
// about. `reserve` is at least 1 for every budget >= 1 (both parsers refuse 0), and
|
|
1392
|
+
// 0.2 * budget is always under the `budget // 2` cap, so neither edge is reachable here.
|
|
1393
|
+
const target = Math.floor(COMPACT_TO * budget);
|
|
1394
|
+
const reserve = budget - target;
|
|
1395
|
+
const r = spawnSync(process.execPath, [
|
|
1396
|
+
path.join(DIST, 'cli.js'),
|
|
1397
|
+
'compact',
|
|
1398
|
+
'--store',
|
|
1399
|
+
m.store.root,
|
|
1400
|
+
'--budget',
|
|
1401
|
+
String(budget),
|
|
1402
|
+
'--reserve',
|
|
1403
|
+
String(reserve),
|
|
1404
|
+
], { encoding: 'utf8', timeout: 8000 });
|
|
1405
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
|
|
1406
|
+
log(run, {
|
|
1407
|
+
event: 'PostToolUse',
|
|
1408
|
+
action: 'auto-compact',
|
|
1409
|
+
bytes,
|
|
1410
|
+
budget,
|
|
1411
|
+
target,
|
|
1412
|
+
reserve,
|
|
1413
|
+
budgetSource,
|
|
1414
|
+
budgetScope,
|
|
1415
|
+
exit: r.status,
|
|
1416
|
+
out: out.slice(0, 400),
|
|
1417
|
+
});
|
|
1418
|
+
emit({
|
|
1419
|
+
hookSpecificOutput: {
|
|
1420
|
+
hookEventName: 'PostToolUse',
|
|
1421
|
+
additionalContext: `[bantamkit] memory index was ${bytes}/${budget} B; auto-compacted to ≤${target} B. ${out.slice(0, 600)}`,
|
|
1422
|
+
},
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
// -------------------------------------------------------------------- PreCompact
|
|
1426
|
+
// Steering for the summariser, derived from state the hook already holds: which files this
|
|
1427
|
+
// context read (from the read ledger) and which shiftwork unit is open. The ledger measured
|
|
1428
|
+
// 160k cache-read tokens per request; a summary that keeps the file list means the rebuilt
|
|
1429
|
+
// context does not re-read them, and the PostCompact reset lets the gate allow the ones it
|
|
1430
|
+
// genuinely needs.
|
|
1431
|
+
//
|
|
1432
|
+
// THE CHANNEL IS PLAIN STDOUT, NOT `hookSpecificOutput`. Verified against the host binary
|
|
1433
|
+
// (`2.1.259`, the PreCompact dispatcher `fK`): the summariser's instructions are
|
|
1434
|
+
//
|
|
1435
|
+
// newCustomInstructions: C.length > 0 ? C.join("\n\n") : undefined
|
|
1436
|
+
// C = results.filter(r => r.succeeded && !r.blocked && r.output.trim().length > 0)
|
|
1437
|
+
// .map(r => r.output.trim())
|
|
1438
|
+
//
|
|
1439
|
+
// — the hook's own trimmed stdout, verbatim. The `hookSpecificOutput` discriminated union
|
|
1440
|
+
// in that build has NO `"PreCompact"` member (its arms are PreToolUse, PostToolUse,
|
|
1441
|
+
// PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied, UserPromptSubmit,
|
|
1442
|
+
// UserPromptExpansion, SessionStart, Setup, Stop, SubagentStart, SubagentStop,
|
|
1443
|
+
// PreModelSwitch, PostModelSwitch, Notification, MessageDisplay, FileChanged, CwdChanged,
|
|
1444
|
+
// Elicitation, ElicitationResult, WorktreeCreate), so emitting that envelope here made the
|
|
1445
|
+
// host reject the output with "Hook JSON output validation failed", set `succeeded` false,
|
|
1446
|
+
// and DROP the text. Between b3625d9 and this change every compaction was unsteered.
|
|
1447
|
+
// Non-JSON stdout is accepted as-is ("Hook output does not start with {, treating as plain
|
|
1448
|
+
// text"), which is why `emitText` refuses a leading brace.
|
|
1449
|
+
//
|
|
1450
|
+
// The cost of the working channel is that the host also echoes the text back to the user as
|
|
1451
|
+
// `PreCompact [<command>] completed successfully: <output>`. There is no quieter variant —
|
|
1452
|
+
// the same string is both the steering and the display — so the arm stays short on purpose.
|
|
1453
|
+
const CHECKPOINT_MAX_BYTES = 4_000_000; // ONE checkpoint file
|
|
1454
|
+
// … and an AGGREGATE, because nothing bounded the COUNT. `.shiftwork` accumulates a
|
|
1455
|
+
// checkpoint per archived job and is never pruned: measured on this repo on 2026-09-04,
|
|
1456
|
+
// 136,885 B across 6 files, 11 KB of it added by a single session's orchestrator. Every one
|
|
1457
|
+
// of them was read and JSON-parsed on every compaction, so the worst case was n × 4 MB.
|
|
1458
|
+
// Candidates are scanned newest-first — the order that already decides the winner — so the
|
|
1459
|
+
// scan stops at the first OPEN one (on this repo: 1 file, 20,417 B, down from 6 and 136,885)
|
|
1460
|
+
// and what a spent budget drops is always the OLDEST, the least likely to be the open one.
|
|
1461
|
+
const CHECKPOINT_SCAN_MAX_BYTES = 1_000_000;
|
|
1462
|
+
const CHECKPOINT_SCAN_MAX_FILES = 64;
|
|
1463
|
+
/**
|
|
1464
|
+
* The structure `assets/schemas/shiftwork-checkpoint.json` requires of the parts this arm
|
|
1465
|
+
* reads: `plan.cursor` is a non-empty string and `plan.units` is a non-empty array of units
|
|
1466
|
+
* carrying `id` and `status`. Deliberately NOT a full schema validation — a hook that loads
|
|
1467
|
+
* a JSON-Schema validator stops being cheap, and a checkpoint that satisfies this shape but
|
|
1468
|
+
* fails the full schema still yields a true steering line.
|
|
1469
|
+
*/
|
|
1470
|
+
function checkpointShape(doc) {
|
|
1471
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
1472
|
+
return null;
|
|
1473
|
+
const plan = doc['plan'];
|
|
1474
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan))
|
|
1475
|
+
return null;
|
|
1476
|
+
const cursor = plan['cursor'];
|
|
1477
|
+
const units = plan['units'];
|
|
1478
|
+
if (typeof cursor !== 'string' || cursor.length === 0)
|
|
1479
|
+
return null;
|
|
1480
|
+
if (!Array.isArray(units) || units.length === 0)
|
|
1481
|
+
return null;
|
|
1482
|
+
const ok = units.every((u) => !!u &&
|
|
1483
|
+
typeof u === 'object' &&
|
|
1484
|
+
typeof u['id'] === 'string' &&
|
|
1485
|
+
typeof u['status'] === 'string');
|
|
1486
|
+
return ok ? { cursor, units: units } : null;
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* The OPEN checkpoint under `<cwd>/.shiftwork`, whatever it is called. Real jobs write named
|
|
1490
|
+
* checkpoints (`checkpoint-readlever.json`, `checkpoint-job41.json`, …), so the old hardcoded
|
|
1491
|
+
* `checkpoint.json` read whichever stale job happened to own that name. Open means: at least
|
|
1492
|
+
* one unit is neither `done` nor `dropped`. Most recently written wins, filename breaks the
|
|
1493
|
+
* tie, so the choice is deterministic. Every failure — no directory, unreadable file,
|
|
1494
|
+
* malformed JSON, wrong shape — is a SKIP, never a throw.
|
|
1495
|
+
*
|
|
1496
|
+
* Returns `null` when there is no `.shiftwork` at all, else the scan's accounting:
|
|
1497
|
+
* `{ winner, scanned, bytes, skipped }` — the caller logs the last three, because a scan
|
|
1498
|
+
* that silently stops on a budget is a scan nobody can audit.
|
|
1499
|
+
*/
|
|
1500
|
+
function openCheckpoint(cwd) {
|
|
1501
|
+
const dir = path.join(cwd, '.shiftwork');
|
|
1502
|
+
let names;
|
|
1503
|
+
try {
|
|
1504
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
1505
|
+
}
|
|
1506
|
+
catch {
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
// stat first (cheap, and the mtime is what orders the scan), read second (the expensive
|
|
1510
|
+
// half, and the one the budget bounds). Same comparator the winner was already chosen by,
|
|
1511
|
+
// so applying it before the read changes which files are READ, never which one wins.
|
|
1512
|
+
const candidates = [];
|
|
1513
|
+
for (const name of names) {
|
|
1514
|
+
const file = path.join(dir, name);
|
|
1515
|
+
try {
|
|
1516
|
+
const st = fs.statSync(file);
|
|
1517
|
+
if (!st.isFile() || st.size > CHECKPOINT_MAX_BYTES)
|
|
1518
|
+
continue;
|
|
1519
|
+
candidates.push({ file, mtimeMs: st.mtimeMs, size: st.size });
|
|
1520
|
+
}
|
|
1521
|
+
catch {
|
|
1522
|
+
/* unreadable: skip */
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
|
|
1526
|
+
let bytes = 0;
|
|
1527
|
+
let read = 0;
|
|
1528
|
+
let winner = null;
|
|
1529
|
+
for (const c of candidates) {
|
|
1530
|
+
// The budget is checked BEFORE each read and never before the first, so the newest
|
|
1531
|
+
// candidate is always considered however large it is (bounded by CHECKPOINT_MAX_BYTES).
|
|
1532
|
+
if (read > 0 && (bytes >= CHECKPOINT_SCAN_MAX_BYTES || read >= CHECKPOINT_SCAN_MAX_FILES))
|
|
1533
|
+
break;
|
|
1534
|
+
let doc;
|
|
1535
|
+
try {
|
|
1536
|
+
doc = JSON.parse(fs.readFileSync(c.file, 'utf8'));
|
|
1537
|
+
}
|
|
1538
|
+
catch {
|
|
1539
|
+
read += 1;
|
|
1540
|
+
bytes += c.size;
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
read += 1;
|
|
1544
|
+
bytes += c.size;
|
|
1545
|
+
const shape = checkpointShape(doc);
|
|
1546
|
+
if (!shape)
|
|
1547
|
+
continue;
|
|
1548
|
+
if (!shape.units.some((u) => u.status !== 'done' && u.status !== 'dropped'))
|
|
1549
|
+
continue;
|
|
1550
|
+
winner = { file: c.file, mtimeMs: c.mtimeMs, ...shape };
|
|
1551
|
+
break; // newest-first: the first open one IS the most recent open one
|
|
1552
|
+
}
|
|
1553
|
+
return { winner, scanned: read, bytes, skipped: candidates.length - read };
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* "Already read in THIS context" is a claim about one transcript, not about one session. The
|
|
1557
|
+
* ledger file is per session, and a session holds the parent's reads and every subagent's:
|
|
1558
|
+
* `preToolUseRead` keys on the transcript for exactly that reason (docs/hooks.md — "a
|
|
1559
|
+
* subagent has its own transcript and is never refused for the parent's read"). Measured
|
|
1560
|
+
* before this filter existed, on this repo's own tree: of the 30 files handed to a parent's
|
|
1561
|
+
* summariser, 7 had been read only by a subagent whose output the parent never saw — so the
|
|
1562
|
+
* summary carried a false premise, and the PostCompact ledger reset cannot undo it because
|
|
1563
|
+
* the summary is already written. The predicate here is the one the refusal arm writes with.
|
|
1564
|
+
*/
|
|
1565
|
+
function transcriptFiles(ledger, transcript) {
|
|
1566
|
+
const me = String(transcript || '');
|
|
1567
|
+
const files = [];
|
|
1568
|
+
const seen = new Set();
|
|
1569
|
+
for (const [key, rec] of Object.entries(ledger.reads || {})) {
|
|
1570
|
+
// Record fields where the entry has them; the key is the fallback for a ledger written
|
|
1571
|
+
// by an older adapter, so an in-flight session degrades quietly instead of losing its list.
|
|
1572
|
+
const parts = key.split('|');
|
|
1573
|
+
const owner = rec?.transcript ?? parts[0];
|
|
1574
|
+
const file = rec?.file ?? parts[1];
|
|
1575
|
+
if (!file || String(owner) !== me || seen.has(file))
|
|
1576
|
+
continue;
|
|
1577
|
+
seen.add(file);
|
|
1578
|
+
files.push(file);
|
|
1579
|
+
}
|
|
1580
|
+
return files;
|
|
1581
|
+
}
|
|
1582
|
+
function preCompact(run, input) {
|
|
1583
|
+
const ledger = readLedger(run, input.session_id);
|
|
1584
|
+
const mine = transcriptFiles(ledger, input.transcript_path || input.session_id);
|
|
1585
|
+
const files = mine.slice(0, PRECOMPACT_FILES_MAX);
|
|
1586
|
+
let scan = null;
|
|
1587
|
+
try {
|
|
1588
|
+
scan = openCheckpoint(input.cwd || process.cwd());
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
scan = null;
|
|
1592
|
+
}
|
|
1593
|
+
const cp = scan && scan.winner;
|
|
1594
|
+
// The FIXED lines are assembled first and are never cut: they carry the instruction, and a
|
|
1595
|
+
// trimmed instruction steers worse than a trimmed list. Whatever budget they leave is what
|
|
1596
|
+
// the file list gets.
|
|
1597
|
+
const fixed = [];
|
|
1598
|
+
if (cp) {
|
|
1599
|
+
// The cursor names THE next unit; the schema keeps it at `plan.cursor`, never top level.
|
|
1600
|
+
const unit = cp.units.find((u) => u.id === cp.cursor);
|
|
1601
|
+
const at = unit
|
|
1602
|
+
? `unit ${unit.id} (${unit.status})${unit.title ? ` — ${unit.title}` : ''}`
|
|
1603
|
+
: `unit ${cp.cursor}, which is not present in plan.units`;
|
|
1604
|
+
fixed.push(`${capBytes(`Open shiftwork checkpoint: ${cp.file}, cursor ${JSON.stringify(cp.cursor)} → ${at}.`, CHECKPOINT_LINE_MAX)} Preserve unit status and the next unit to clock in.`);
|
|
1605
|
+
}
|
|
1606
|
+
fixed.push('Preserve verbatim: every number the user was shown, every decision the user made, and any pending operator step.');
|
|
1607
|
+
const room = PRECOMPACT_STDOUT_MAX - Buffer.byteLength(fixed.join('\n\n')) - 2;
|
|
1608
|
+
let block = files.length
|
|
1609
|
+
? capLines(`Files already read in this context (keep the list; do not re-read unchanged ones after compaction):\n${files.map((f) => `- ${f}`).join('\n')}`, Math.max(0, room))
|
|
1610
|
+
: '';
|
|
1611
|
+
// A header the budget left with no file under it steers nothing and costs bytes.
|
|
1612
|
+
const listed = (block.match(/^- /gm) || []).length;
|
|
1613
|
+
if (listed === 0)
|
|
1614
|
+
block = '';
|
|
1615
|
+
const ctx = [block, ...fixed].filter(Boolean).join('\n\n');
|
|
1616
|
+
const bytes = emitText(ctx);
|
|
1617
|
+
log(run, {
|
|
1618
|
+
event: 'PreCompact',
|
|
1619
|
+
trigger: input.trigger,
|
|
1620
|
+
ledgerFiles: mine.length,
|
|
1621
|
+
capped: files.length,
|
|
1622
|
+
listed,
|
|
1623
|
+
checkpoint: cp ? cp.file : null,
|
|
1624
|
+
cursor: cp ? cp.cursor : null,
|
|
1625
|
+
cpScanned: scan ? scan.scanned : 0,
|
|
1626
|
+
cpSkipped: scan ? scan.skipped : 0,
|
|
1627
|
+
cpBytes: scan ? scan.bytes : 0,
|
|
1628
|
+
bytes, // what LEFT the process, not what was considered
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
// ------------------------------------------------------------------- PostCompact
|
|
1632
|
+
function postCompact(run, input) {
|
|
1633
|
+
try {
|
|
1634
|
+
fs.unlinkSync(ledgerPath(run, input.session_id));
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
/* none */
|
|
1638
|
+
}
|
|
1639
|
+
log(run, { event: 'PostCompact', action: 'ledger-reset' });
|
|
1640
|
+
}
|
|
1641
|
+
// ------------------------------------------------------------------ Stop -> dream
|
|
1642
|
+
// Row 5 of `docs/roadmap-toolbox.md` shipped `memory_dream`'s MECHANISM and left its TRIGGER
|
|
1643
|
+
// open — the row still carries its own `STILL OPEN: the trigger` sentence. Before this arm
|
|
1644
|
+
// the string `dream` did not occur anywhere in this file, so the consolidation ran only when
|
|
1645
|
+
// a model chose to call the tool, which is precisely the operator-only design the user has
|
|
1646
|
+
// already overruled once: `memory_compact` was built as the model's path on refusal and the
|
|
1647
|
+
// user ruled compaction AUTOMATIC (2026-08-24). `postSave` is what that ruling produced, and
|
|
1648
|
+
// this arm is the same move for consolidation.
|
|
1649
|
+
//
|
|
1650
|
+
// AMENDED 2026-09-12 (J50-2A). "THE SAME MOVE" WAS ONE STEP TOO FAR, AND THE USER RULED IT
|
|
1651
|
+
// BACK. `postSave` moves facts inside ONE store. This arm moves facts OUT OF THE PROFILE
|
|
1652
|
+
// STORE, which is machine-wide, and J45 had bounded exactly that cost by making `dry_run`
|
|
1653
|
+
// default to TRUE — a real cross-layer merge was something somebody asked for. Firing the
|
|
1654
|
+
// dream from `Stop` with `dry_run=false` made the mitigation stop existing: every session
|
|
1655
|
+
// that ends inside a project whose store shares a name with the profile store archived the
|
|
1656
|
+
// profile copy, silently, at the end of a turn nobody was watching. Measured on the user's
|
|
1657
|
+
// own machine before this amendment: 14 of 20 profile facts in `~/.bantamkit/memory/archive/`,
|
|
1658
|
+
// and a restore of 4 consumed again at the very next Stop (`.shiftwork/backlog.md` J49-B1).
|
|
1659
|
+
//
|
|
1660
|
+
// THE RULING: THE AUTOMATIC TRIGGER RUNS THE DREAM IN DRY-RUN ONLY. It never writes to any
|
|
1661
|
+
// store. A real merge stays a deliberate call — the `memory_dream` MCP tool with
|
|
1662
|
+
// `dry_run=false`. What the arm keeps is its early-warning value: the log says what a merge
|
|
1663
|
+
// WOULD do, once per change to either layer, and a human decides. The accepted cost is
|
|
1664
|
+
// stated rather than hidden: duplicates across the two layers now ACCUMULATE until somebody
|
|
1665
|
+
// asks. This is not the self-merge of 2026-09-10 (row 13, closed by job47) — that was one
|
|
1666
|
+
// directory bound as two layers; this is the designed cross-layer merge (row 5) firing
|
|
1667
|
+
// without anyone asking for it. The tool is untouched; the TRIGGER is what changed.
|
|
1668
|
+
//
|
|
1669
|
+
// THE EVENT IS `Stop`, AND IT IS NOT THE ONE THE ROW NAMED. The row's spec said "run from a
|
|
1670
|
+
// `SessionEnd`/cron". Re-derived from the user's own `~/.claude/settings.json` on
|
|
1671
|
+
// 2026-09-10, the events that actually reach this hook are
|
|
1672
|
+
//
|
|
1673
|
+
// PostCompact · PostToolUse · PreCompact · PreToolUse[Read]
|
|
1674
|
+
// SessionStart[startup|resume|clear|compact] · Stop · UserPromptSubmit
|
|
1675
|
+
//
|
|
1676
|
+
// and `SessionEnd` is NOT among them. The host supports the event; this registration simply
|
|
1677
|
+
// does not carry it. A `SessionEnd` arm would therefore be INERT until the user edited their
|
|
1678
|
+
// own settings file, and a unit may not edit the user's permission and hook surface. `Stop`
|
|
1679
|
+
// already fires, already reads the per-session ledger, already decides whether to act, and
|
|
1680
|
+
// fires at the END of a turn when no tool call is in flight. The row named a mechanism; the
|
|
1681
|
+
// property it was after is that the consolidation happens without anyone asking, and `Stop`
|
|
1682
|
+
// is the event on this machine that delivers it the moment this merges.
|
|
1683
|
+
//
|
|
1684
|
+
// THE GATE IS THE STORE'S OWN FINGERPRINT, because `Stop` fires every turn and a dream on
|
|
1685
|
+
// every turn is a cost with no benefit. The pass runs only when a `facts/*.md` in either
|
|
1686
|
+
// layer has appeared, vanished, or changed size or mtime since the last dream. A quiet turn
|
|
1687
|
+
// costs one small JSON read, two `readdir`s and one `stat` per fact — measured at ~6 ms over
|
|
1688
|
+
// the 121 facts on this machine, with no store loaded and no subprocess spawned.
|
|
1689
|
+
//
|
|
1690
|
+
// BOTH LAYERS ARE FINGERPRINTED ON PURPOSE. The profile store is machine-wide, so another
|
|
1691
|
+
// project's session can add the very duplicate this session should consolidate. A gate keyed
|
|
1692
|
+
// on THIS session's saves would never see it. For the same reason the marker lives beside the
|
|
1693
|
+
// log in `~/.bantamkit/hooks/`, not in the per-session ledger: the question "has the store
|
|
1694
|
+
// changed since the last dream" outlives any one session.
|
|
1695
|
+
/**
|
|
1696
|
+
* Do two paths name the same directory? REALPATH, not `path.resolve`.
|
|
1697
|
+
*
|
|
1698
|
+
* `resolveProjectStore` hands back a realpath-resolved path and `os.homedir()` does not, so
|
|
1699
|
+
* on macOS the SAME directory arrives as `/private/var/folders/...` from one and
|
|
1700
|
+
* `/var/folders/...` from the other. `path.resolve` normalises `..` and makes a path
|
|
1701
|
+
* absolute; it does not follow symlinks, so it calls those two different and the guard below
|
|
1702
|
+
* would let a self-merge through. Measured: the first version of this guard did exactly that,
|
|
1703
|
+
* and the test that seeds a store under `tmpdir()` is what caught it.
|
|
1704
|
+
*
|
|
1705
|
+
* AMENDED 2026-09-11 (J47-7). Everything above stands as the reason this compares resolved
|
|
1706
|
+
* directories rather than strings, and the fallback's behaviour is unchanged. Two things it
|
|
1707
|
+
* says are now measured wrong.
|
|
1708
|
+
*
|
|
1709
|
+
* (1) THE MECHANISM. `fs.realpathSync` is not the kernel's realpath. It hands its argument to
|
|
1710
|
+
* `path.resolve` first, and `path.resolve` pops `..` LEXICALLY, before the symlink in front of
|
|
1711
|
+
* it has been followed. The comparison is now `realDir` (`realpathSync.native`) for J47-3B's
|
|
1712
|
+
* reasons (`6766033`), which closed the identical defect in the identical predicate in
|
|
1713
|
+
* `runtime-ts`'s `sameDirectory`.
|
|
1714
|
+
*
|
|
1715
|
+
* (2) "A PATH THAT CANNOT BE REALPATHED (IT DOES NOT EXIST YET)" IS THE WRONG DIAGNOSIS, and
|
|
1716
|
+
* it is wrong in the dangerous direction. Measured on J47-3B's bed (`link -> <bed>/deep/real`,
|
|
1717
|
+
* store at `<bed>/deep/.bantamkit/memory`, `HOME=<bed>/link/..`), the throwing argument was
|
|
1718
|
+
* the profile store, and the directory it names EXISTS: `realpathSync` threw because it had
|
|
1719
|
+
* already looked in the wrong place. `resolve` was not "the honest answer" there, it was the
|
|
1720
|
+
* wrong one, and this function answered `false` for one directory spelled two ways — so the
|
|
1721
|
+
* trigger below launched the dream it exists to skip.
|
|
1722
|
+
*
|
|
1723
|
+
* AND ON THAT BED THE `..` NEVER REACHED THIS FUNCTION, which is why repairing only this
|
|
1724
|
+
* predicate did not flip the decision and is worth writing down: `path.join` at the top of the
|
|
1725
|
+
* file had already popped it, so the profile path arrived here as `<bed>/.bantamkit/memory`, a
|
|
1726
|
+
* directory nothing had created — a real instance of the sentence above, arrived at by the
|
|
1727
|
+
* defect rather than by anyone's intent. The repair is therefore at BOTH ends: `HOME` is
|
|
1728
|
+
* resolved before it is joined, and this predicate no longer lets `path`'s lexical `..` decide.
|
|
1729
|
+
*/
|
|
1730
|
+
function samePath(a, b) {
|
|
1731
|
+
return realDir(a) === realDir(b);
|
|
1732
|
+
}
|
|
1733
|
+
function storeFingerprint(roots) {
|
|
1734
|
+
const h = createHash('sha256');
|
|
1735
|
+
for (const root of roots) {
|
|
1736
|
+
h.update(`\u0000${root}\u0000`);
|
|
1737
|
+
let names = [];
|
|
1738
|
+
try {
|
|
1739
|
+
names = fs
|
|
1740
|
+
.readdirSync(path.join(root, 'facts'))
|
|
1741
|
+
.filter((n) => n.endsWith('.md'))
|
|
1742
|
+
.sort();
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
/* a layer with no facts/ contributes its name and nothing else */
|
|
1746
|
+
}
|
|
1747
|
+
for (const n of names) {
|
|
1748
|
+
let st;
|
|
1749
|
+
try {
|
|
1750
|
+
st = fs.statSync(path.join(root, 'facts', n));
|
|
1751
|
+
}
|
|
1752
|
+
catch {
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
h.update(`${n}\u0000${st.size}\u0000${st.mtimeMs}\u0000`);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
return h.digest('hex');
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* PREVIEW the consolidation, at most once per change to either layer, in a bounded child
|
|
1762
|
+
* process. Since J50-2A the child runs `dreamOutcome(true)` — a dry run — so nothing this
|
|
1763
|
+
* arm does moves a file; see the amendment in the header above for the ruling and the cost.
|
|
1764
|
+
*
|
|
1765
|
+
* NOTHING IS EMITTED. `stop` may answer the host with `decision: 'block'`, and two JSON
|
|
1766
|
+
* objects on one stdout is not a protocol — so this arm reports only into the hook log.
|
|
1767
|
+
*
|
|
1768
|
+
* THE CHILD IS `node -e`, NOT A CLI SUBCOMMAND, because there is no `dream` subcommand to
|
|
1769
|
+
* call: `memory_dream` is an MCP tool over `Memory.dreamOutcome`, and the memory CLI's
|
|
1770
|
+
* choices are status/lint/compact/archived/archive/restore. Adding one would be a runtime
|
|
1771
|
+
* change in both runtimes plus a conformance case — a different layer and a different unit.
|
|
1772
|
+
* The child gets `postSave`'s discipline: a `spawnSync` with a timeout, everything logged,
|
|
1773
|
+
* and any failure degrading to a log line rather than an exception.
|
|
1774
|
+
*
|
|
1775
|
+
* WHAT IS LOGGED IS WHAT THE PASS ACTUALLY FOUND, parsed out of the child's stdout — `status`,
|
|
1776
|
+
* `changes`, the index before and projected, and the counts it WOULD merge and consume. J46-6
|
|
1777
|
+
* measured the cost of the other habit: a log record whose fields are computed BEFORE the
|
|
1778
|
+
* spawn is vacuous, and its own first repair of that was itself vacuous for exactly that
|
|
1779
|
+
* reason.
|
|
1780
|
+
*
|
|
1781
|
+
* THE LOG LINE IS A DIFFERENT ACTION WITH DIFFERENT FIELD NAMES, ON PURPOSE. A dry run's line
|
|
1782
|
+
* is `action: 'dream-preview'` carrying `dryRun: true`, `wouldMerge` and `wouldConsume`; the
|
|
1783
|
+
* line a real merge wrote was `action: 'dream'` with `merged` and `consumed`. The log is the
|
|
1784
|
+
* only place a human sees this arm, and "would have merged 14" must never read as "merged 14"
|
|
1785
|
+
* — so the preview carries NO field named `merged` at all, rather than the same field under a
|
|
1786
|
+
* flag a reader could miss.
|
|
1787
|
+
*/
|
|
1788
|
+
function maybeDream(run, input) {
|
|
1789
|
+
const cwd = input.cwd || process.cwd();
|
|
1790
|
+
let projectRoot;
|
|
1791
|
+
try {
|
|
1792
|
+
projectRoot = resolveProjectStore(cwd).path;
|
|
1793
|
+
}
|
|
1794
|
+
catch (e) {
|
|
1795
|
+
log(run, { event: 'Stop', action: 'dream-skip', reason: 'unresolved-store', error: message(e) });
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
// THE TWO LAYERS MUST BE TWO DIRECTORIES, AND MEASURED ON 2026-09-10 THEY ARE NOT ALWAYS.
|
|
1799
|
+
// `resolveProjectStore` WALKS UP from the cwd, so a session whose cwd has no project store
|
|
1800
|
+
// above it resolves the profile store itself as the "project" store — `~/.bantamkit/memory`
|
|
1801
|
+
// bound as BOTH layers. `dream` then merges that store with ITSELF: every fact matches
|
|
1802
|
+
// itself by name, is merged into itself, and the "profile copy" — the same file — is
|
|
1803
|
+
// archived, which empties `facts/`.
|
|
1804
|
+
//
|
|
1805
|
+
// THIS IS NOT HYPOTHETICAL. It happened to the user's real profile store while this arm was
|
|
1806
|
+
// being written: a live `Stop` in a session running outside any project consolidated 20 of
|
|
1807
|
+
// 20 facts into the archive with `indexBefore == indexAfter == 3974`, the giveaway that the
|
|
1808
|
+
// "project" index and the profile index were one number because they were one store. The
|
|
1809
|
+
// facts were restored from `archive/` (`dream` is reversible by design, which is the only
|
|
1810
|
+
// reason that was recoverable) and this guard is why it cannot recur.
|
|
1811
|
+
//
|
|
1812
|
+
// AMENDED 2026-09-11 (J47-7). "THE GUARD LIVES HERE, IN THE TRIGGER, NOT IN `dream`" IS NO
|
|
1813
|
+
// LONGER TRUE. It is kept anyway, deliberately: `Memory.layered` now refuses to bind one
|
|
1814
|
+
// directory as two layers in BOTH runtimes — `_same_directory` in Python (J47-1, `0844ccb`),
|
|
1815
|
+
// `sameDirectory` in Node (J47-2, `a3afb7c`, corrected by J47-3B, `6766033`) — and the
|
|
1816
|
+
// refusal is gated by conformance cases (J47-3 `ecaf427`, J47-3B `1bd1a43`). This guard
|
|
1817
|
+
// costs one realpath compare, it refuses BEFORE a child process is spawned rather than
|
|
1818
|
+
// inside it, and a working refusal is not deleted on the strength of a change that shipped
|
|
1819
|
+
// the same day.
|
|
1820
|
+
//
|
|
1821
|
+
// AND UNTIL J47-7 IT WAS NOT ANSWERING CORRECTLY, which is the other half of that unit and
|
|
1822
|
+
// the reason the redundancy was worth having. On J47-3B's bed this trigger fired the dream
|
|
1823
|
+
// it exists to skip — measured, the hook's own log line was
|
|
1824
|
+
// {"event":"Stop","action":"dream","status":"no-profile-layer","merged":0,"consumed":0}
|
|
1825
|
+
// i.e. the MECHANISM refused and the TRIGGER did not: defence in depth working in the
|
|
1826
|
+
// direction nobody planned for. Two things were wrong and both are repaired above — `HOME`
|
|
1827
|
+
// is resolved in the kernel's order before it is joined (`path.join` was popping the `..`
|
|
1828
|
+
// lexically), and `samePath` no longer uses `fs.realpathSync`, which pops `..` the same way.
|
|
1829
|
+
if (samePath(projectRoot, run.profile)) {
|
|
1830
|
+
log(run, { event: 'Stop', action: 'dream-skip', reason: 'single-layer', root: projectRoot });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
const roots = [projectRoot, run.profile];
|
|
1834
|
+
const fingerprint = storeFingerprint(roots);
|
|
1835
|
+
const prior = readJsonSafe(run.dreamState);
|
|
1836
|
+
if (prior['fingerprint'] === fingerprint) {
|
|
1837
|
+
log(run, { event: 'Stop', action: 'dream-skip', reason: 'unchanged', fingerprint: fingerprint.slice(0, 12) });
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
const script = `(async () => {
|
|
1841
|
+
const { Memory } = await import(${JSON.stringify(path.join(DIST, 'component.js'))});
|
|
1842
|
+
// DRY RUN, by the ruling of 2026-09-12 (J50-2A). Passing \`false\` here is what archived
|
|
1843
|
+
// the user's profile facts; \`true\` is the J45 default and the only value this arm may pass.
|
|
1844
|
+
const o = Memory.layered(process.argv[1]).dreamOutcome(true);
|
|
1845
|
+
process.stdout.write(JSON.stringify({ status: o.status, dryRun: o.dryRun, merged: o.merged, consumed: o.consumed,
|
|
1846
|
+
absolutised: o.absolutised, superseded: o.superseded, changes: o.result ? o.result.changes : 0,
|
|
1847
|
+
indexBefore: o.indexBefore, indexAfter: o.indexAfter, budget: o.budget }));
|
|
1848
|
+
})().catch((e) => { process.stderr.write(String(e && e.stack || e)); process.exit(1); });`;
|
|
1849
|
+
const t = Date.now();
|
|
1850
|
+
const r = spawnSync(process.execPath, ['-e', script, cwd], {
|
|
1851
|
+
encoding: 'utf8',
|
|
1852
|
+
timeout: dreamTimeoutMs(),
|
|
1853
|
+
});
|
|
1854
|
+
const ms = Date.now() - t;
|
|
1855
|
+
let outcome = null;
|
|
1856
|
+
try {
|
|
1857
|
+
outcome = JSON.parse(r.stdout || '');
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
/* a child that died has no JSON to give */
|
|
1861
|
+
}
|
|
1862
|
+
if (outcome === null) {
|
|
1863
|
+
// A failed or timed-out pass must NOT record the new fingerprint: the next Stop should
|
|
1864
|
+
// try again rather than treat an unconsolidated store as already dreamt.
|
|
1865
|
+
// `timedOut` AND NOT `signal`. `spawnSync` reports the timeout kill as an `ETIMEDOUT`
|
|
1866
|
+
// error on every platform, whereas `signal` is a POSIX notion: on Windows the kill is
|
|
1867
|
+
// `TerminateProcess` and there is no SIGTERM to report. `signal` is kept because it is
|
|
1868
|
+
// informative where it exists, but the field that MEANS "the bound stopped this" is the
|
|
1869
|
+
// portable one, and it is the one anything asserting on this arm should read.
|
|
1870
|
+
const err = r.error;
|
|
1871
|
+
log(run, {
|
|
1872
|
+
event: 'Stop',
|
|
1873
|
+
action: 'dream-failed',
|
|
1874
|
+
ms,
|
|
1875
|
+
exit: r.status,
|
|
1876
|
+
signal: r.signal ?? null,
|
|
1877
|
+
timedOut: err !== undefined && err.code === 'ETIMEDOUT',
|
|
1878
|
+
error: `${r.stderr || ''}`.trim().slice(0, 400) || String(err?.message || ''),
|
|
1879
|
+
});
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
// THE MARKER ADVANCES AFTER A DRY RUN, DELIBERATELY. It answers "has either layer changed
|
|
1883
|
+
// since the last look", never "is the store consolidated" — the status it records is the
|
|
1884
|
+
// pass's own (`previewed`, `nothing-to-consolidate`, ...), so a preview cannot mark anything
|
|
1885
|
+
// as merged. NOT advancing it would spawn a child on every Stop for as long as one duplicate
|
|
1886
|
+
// exists, which is the every-turn cost the header rules out; advancing it keeps the cadence
|
|
1887
|
+
// the arm always had — one report per change to either layer — and the deliberate
|
|
1888
|
+
// `memory_dream` merge that finally consumes the duplicate moves a file, so it re-arms the
|
|
1889
|
+
// gate by itself and the next Stop reports the store clean.
|
|
1890
|
+
//
|
|
1891
|
+
// The fingerprint is still recomputed AFTER the pass, as it was when the pass could move
|
|
1892
|
+
// files. For a dry run the two numbers must be equal, and `storeMoved` on the log line says
|
|
1893
|
+
// whether they were: a preview that moved anything is the bug this unit closed, come back.
|
|
1894
|
+
const after = storeFingerprint(roots);
|
|
1895
|
+
writeJsonSafe(run.dreamState, {
|
|
1896
|
+
fingerprint: after,
|
|
1897
|
+
at: new Date().toISOString(),
|
|
1898
|
+
status: outcome.status,
|
|
1899
|
+
dryRun: true,
|
|
1900
|
+
});
|
|
1901
|
+
log(run, {
|
|
1902
|
+
event: 'Stop',
|
|
1903
|
+
action: 'dream-preview',
|
|
1904
|
+
ms,
|
|
1905
|
+
dryRun: true,
|
|
1906
|
+
status: outcome.status,
|
|
1907
|
+
wouldMerge: outcome.merged,
|
|
1908
|
+
wouldConsume: outcome.consumed,
|
|
1909
|
+
wouldAbsolutise: outcome.absolutised,
|
|
1910
|
+
wouldSupersede: outcome.superseded,
|
|
1911
|
+
changes: outcome.changes,
|
|
1912
|
+
indexBefore: outcome.indexBefore,
|
|
1913
|
+
indexProjected: outcome.indexAfter,
|
|
1914
|
+
budget: outcome.budget,
|
|
1915
|
+
storeMoved: after !== fingerprint,
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
// -------------------------------------------------------------------------- Stop
|
|
1919
|
+
// The experience collector. Once per session, when the session did real work and nothing
|
|
1920
|
+
// durable was written, hand the turn back with one instruction. The host's `type:prompt`
|
|
1921
|
+
// hook could judge this with a model call; a grep over the transcript is free.
|
|
1922
|
+
function stop(run, input) {
|
|
1923
|
+
if (input.stop_hook_active)
|
|
1924
|
+
return;
|
|
1925
|
+
const ledger = readLedger(run, input.session_id);
|
|
1926
|
+
if (ledger.stopNudged)
|
|
1927
|
+
return;
|
|
1928
|
+
let text = '';
|
|
1929
|
+
try {
|
|
1930
|
+
text = fs.readFileSync(String(input.transcript_path ?? ''), 'utf8');
|
|
1931
|
+
}
|
|
1932
|
+
catch {
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const toolUses = (text.match(/"type":\s*"tool_use"/g) || []).length;
|
|
1936
|
+
const saved = (ledger.saved || 0) > 0 ||
|
|
1937
|
+
/"name":\s*"mcp__bantamkit__memory_save"/.test(text) ||
|
|
1938
|
+
/"file_path":"[^"]*[\\/]memory[\\/][^"]*\.md"/.test(text);
|
|
1939
|
+
if (toolUses < STOP_NUDGE_MIN_TOOL_CALLS || saved) {
|
|
1940
|
+
log(run, { event: 'Stop', action: 'pass', toolUses, saved });
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
ledger.stopNudged = true;
|
|
1944
|
+
writeLedger(run, input.session_id, ledger);
|
|
1945
|
+
log(run, { event: 'Stop', action: 'nudge', toolUses });
|
|
1946
|
+
emit({
|
|
1947
|
+
decision: 'block',
|
|
1948
|
+
reason: `bantamkit: this session made ${toolUses} tool calls and saved no memory. Before stopping, decide whether anything durable was learned that is NOT derivable from the repo, git history, or docs — a correction or preference the user stated (feedback), a fact about ongoing work or a decision (project), a URL/ticket/dashboard (reference). If so, call mcp__bantamkit__memory_save for each (at most 3, description written as the words a future query would use). If nothing qualifies, stop with one line saying so. This nudge fires once per session.`,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
// ---------------------------------------------------------------------- dispatch
|
|
1952
|
+
/** Read the whole of stdin. One JSON object is the entire input contract. */
|
|
1953
|
+
async function readStdin() {
|
|
1954
|
+
let raw = '';
|
|
1955
|
+
for await (const chunk of process.stdin)
|
|
1956
|
+
raw += chunk;
|
|
1957
|
+
return raw;
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* `bantamkit-mcp --hook`: ONE JSON object in, AT MOST ONE JSON object out, and the caller
|
|
1961
|
+
* exits 0 whatever happens.
|
|
1962
|
+
*
|
|
1963
|
+
* NOTHING HERE THROWS PAST THE CALLER. `cli.ts` wraps this in the same `catch → log → 0` the
|
|
1964
|
+
* standalone script's last line was, because a hook that exits non-zero or writes a stack to
|
|
1965
|
+
* stderr is rendered by the host as an error on the user's screen.
|
|
1966
|
+
*/
|
|
1967
|
+
export async function runHook() {
|
|
1968
|
+
const run = newRun();
|
|
1969
|
+
const raw = await readStdin();
|
|
1970
|
+
let input = {};
|
|
1971
|
+
try {
|
|
1972
|
+
const parsed = JSON.parse(raw || '{}');
|
|
1973
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
1974
|
+
input = parsed;
|
|
1975
|
+
}
|
|
1976
|
+
catch {
|
|
1977
|
+
log(run, { event: 'parse-error', raw: raw.slice(0, 200) });
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const ev = input.hook_event_name;
|
|
1981
|
+
// Before ANY arm binds a store: the winning registration's `BANTAMKIT_MEMORY_DIR`, applied
|
|
1982
|
+
// to this process so `pinnedStore()` sees what the server sees (J50-1). Two small file
|
|
1983
|
+
// reads, measured at 0.6 ms on this machine's 164 kB `~/.claude.json`.
|
|
1984
|
+
applyRegistrationStorePin(run, input.cwd || process.cwd());
|
|
1985
|
+
switch (ev) {
|
|
1986
|
+
case 'SessionStart':
|
|
1987
|
+
return sessionStart(run, input);
|
|
1988
|
+
case 'UserPromptSubmit':
|
|
1989
|
+
return userPromptSubmit(run, input);
|
|
1990
|
+
case 'PreToolUse':
|
|
1991
|
+
return input.tool_name === 'Read' ? preToolUseRead(run, input) : undefined;
|
|
1992
|
+
case 'PostToolUse':
|
|
1993
|
+
appendUsageEvent(run, input); // every tool, not just bantamkit's — it is a usage denominator
|
|
1994
|
+
return input.tool_name === 'mcp__bantamkit__memory_save' ? postSave(run, input) : undefined;
|
|
1995
|
+
case 'PreCompact':
|
|
1996
|
+
return preCompact(run, input);
|
|
1997
|
+
case 'PostCompact':
|
|
1998
|
+
return postCompact(run, input);
|
|
1999
|
+
case 'Stop':
|
|
2000
|
+
maybeDream(run, input); // gated on the store changing; logs only, never emits
|
|
2001
|
+
return stop(run, input);
|
|
2002
|
+
default:
|
|
2003
|
+
log(run, { event: ev, action: 'ignored' });
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/** The `catch` half of the contract, so `cli.ts` states it in one line. */
|
|
2007
|
+
export function logHookFailure(e) {
|
|
2008
|
+
log(newRun(), { event: 'error', error: String((e instanceof Error && e.stack) || e) });
|
|
2009
|
+
}
|