bantamkit-mcp 0.35.7 → 0.35.9
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 +24 -9
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyargparse.js +48 -2
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
package/dist/hookadapter.js
CHANGED
|
@@ -264,9 +264,71 @@ function readLedger(run, sessionId) {
|
|
|
264
264
|
return { reads: {} };
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Temp-and-rename, never a bare `writeFileSync` (job65, J65-4, #167): a reader that lands
|
|
269
|
+
* between a whole-file truncate and its write sees an empty ledger, which `readLedger` reads
|
|
270
|
+
* as "nothing seen". The rename is atomic on both platforms; the name carries the pid so two
|
|
271
|
+
* writers never share a temp file.
|
|
272
|
+
*/
|
|
267
273
|
function writeLedger(run, sessionId, ledger) {
|
|
268
274
|
fs.mkdirSync(run.state, { recursive: true });
|
|
269
|
-
|
|
275
|
+
const file = ledgerPath(run, sessionId);
|
|
276
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
277
|
+
fs.writeFileSync(tmp, JSON.stringify(ledger));
|
|
278
|
+
fs.renameSync(tmp, file);
|
|
279
|
+
}
|
|
280
|
+
/** How long a writer waits for the ledger lock before going ahead without it. */
|
|
281
|
+
const LEDGER_LOCK_WAIT_MS = 2000;
|
|
282
|
+
/** A lock file older than this was left by a hook process that died holding it. */
|
|
283
|
+
const LEDGER_LOCK_STALE_MS = 10_000;
|
|
284
|
+
/** The poll between two attempts at the lock. */
|
|
285
|
+
const LEDGER_LOCK_POLL_MS = 5;
|
|
286
|
+
/**
|
|
287
|
+
* One read-modify-write of the session ledger, under a lock (job65, J65-4, #167).
|
|
288
|
+
*
|
|
289
|
+
* THE RACE THIS CLOSES IS MEASURED, not supposed: the host fires one hook PROCESS per tool
|
|
290
|
+
* call and a parallel tool block fires them at once, so twenty Reads in one block are twenty
|
|
291
|
+
* processes each doing read → add one entry → write the whole file, and the last writer wins.
|
|
292
|
+
* Measured at 64ae11d over 20 distinct files: 6 of 20 entries survived on this side, 16 of 20
|
|
293
|
+
* on the reference (`.shiftwork/notes-job65/J65-4.md`). An atomic write alone cannot close it
|
|
294
|
+
* — the lost entries are lost between the read and the write, not inside the write — so the
|
|
295
|
+
* whole read-modify-write runs under `<ledger>.lock`, taken with `O_EXCL` (the one primitive
|
|
296
|
+
* both platforms and both runtimes share without a dependency). A lock older than
|
|
297
|
+
* `LEDGER_LOCK_STALE_MS` is a dead holder's and is removed; a writer that cannot take the
|
|
298
|
+
* lock within `LEDGER_LOCK_WAIT_MS` goes ahead unlocked, because a hook that fails the tool
|
|
299
|
+
* call over its own cache is worse than a lost entry. `update` returns whether the ledger
|
|
300
|
+
* changed; nothing is written when it did not.
|
|
301
|
+
*/
|
|
302
|
+
function updateLedger(run, sessionId, update) {
|
|
303
|
+
fs.mkdirSync(run.state, { recursive: true });
|
|
304
|
+
const lock = `${ledgerPath(run, sessionId)}.lock`;
|
|
305
|
+
const deadline = Date.now() + LEDGER_LOCK_WAIT_MS;
|
|
306
|
+
let held = false;
|
|
307
|
+
while (!held && Date.now() < deadline) {
|
|
308
|
+
try {
|
|
309
|
+
fs.closeSync(fs.openSync(lock, 'wx'));
|
|
310
|
+
held = true;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
try {
|
|
314
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LEDGER_LOCK_STALE_MS)
|
|
315
|
+
fs.rmSync(lock, { force: true });
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
/* the holder released it between the open and the stat: go round again */
|
|
319
|
+
}
|
|
320
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LEDGER_LOCK_POLL_MS);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const ledger = readLedger(run, sessionId);
|
|
325
|
+
if (update(ledger))
|
|
326
|
+
writeLedger(run, sessionId, ledger);
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
if (held)
|
|
330
|
+
fs.rmSync(lock, { force: true });
|
|
331
|
+
}
|
|
270
332
|
}
|
|
271
333
|
// ------------------------------------- the host's auto-memory directory, and A's export
|
|
272
334
|
//
|
|
@@ -494,6 +556,7 @@ function exportToNative(dir, store) {
|
|
|
494
556
|
bytes: 0,
|
|
495
557
|
indexBytes: 0,
|
|
496
558
|
skipped: [],
|
|
559
|
+
unparsed: [],
|
|
497
560
|
index: 'absent',
|
|
498
561
|
};
|
|
499
562
|
const indexFile = path.join(dir, NATIVE_INDEX);
|
|
@@ -510,8 +573,11 @@ function exportToNative(dir, store) {
|
|
|
510
573
|
let named = seeded;
|
|
511
574
|
const appended = [];
|
|
512
575
|
try {
|
|
513
|
-
const
|
|
514
|
-
const
|
|
576
|
+
const source = new MemoryStore(store);
|
|
577
|
+
const { indexLine } = source.internals();
|
|
578
|
+
const { facts, skipped } = tolerantFacts(source);
|
|
579
|
+
result.unparsed = skipped;
|
|
580
|
+
const ranked = rankBySessionDropRule(facts.map((fact, position) => ({
|
|
515
581
|
fact,
|
|
516
582
|
position,
|
|
517
583
|
line: indexLine(fact),
|
|
@@ -608,8 +674,10 @@ function rankBySessionDropRule(all) {
|
|
|
608
674
|
return [...all].sort((a, b) => decays(a) - decays(b) || cmp(evidence(b), evidence(a)) || cmp(a.name, b.name) || a.position - b.position);
|
|
609
675
|
}
|
|
610
676
|
function cappedIndex(root, header, max) {
|
|
611
|
-
const
|
|
612
|
-
const
|
|
677
|
+
const store = new MemoryStore(root);
|
|
678
|
+
const { indexLine } = store.internals();
|
|
679
|
+
const { facts, skipped } = tolerantFacts(store);
|
|
680
|
+
const all = facts.map((fact, position) => ({
|
|
613
681
|
fact,
|
|
614
682
|
position,
|
|
615
683
|
line: indexLine(fact),
|
|
@@ -631,13 +699,15 @@ function cappedIndex(root, header, max) {
|
|
|
631
699
|
.map((f) => f.line)
|
|
632
700
|
.join('')
|
|
633
701
|
.replace(/\n$/, ''), max);
|
|
634
|
-
const lines = [header(dropped.length ? `${shown.length} of ${all.length}` : String(all.length))];
|
|
702
|
+
const lines = [header(dropped.length ? `${shown.length} of ${all.length}` : String(all.length), all.length)];
|
|
635
703
|
if (body)
|
|
636
704
|
lines.push(body);
|
|
637
705
|
if (dropped.length) {
|
|
638
706
|
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]`);
|
|
639
707
|
}
|
|
640
|
-
|
|
708
|
+
if (skipped.length)
|
|
709
|
+
lines.push(skippedLine(skipped));
|
|
710
|
+
return { block: lines.join('\n'), total: all.length, injected: shown.length, dropped, skipped };
|
|
641
711
|
}
|
|
642
712
|
function countFacts(store) {
|
|
643
713
|
try {
|
|
@@ -647,6 +717,25 @@ function countFacts(store) {
|
|
|
647
717
|
return 0;
|
|
648
718
|
}
|
|
649
719
|
}
|
|
720
|
+
/**
|
|
721
|
+
* Every fact `store` can read, and the files it could not: `MemoryStore.readableFacts`. A
|
|
722
|
+
* store whose `facts/` cannot be LISTED still throws, exactly as before: that is a layer that
|
|
723
|
+
* could not be read, not a fact.
|
|
724
|
+
*/
|
|
725
|
+
function tolerantFacts(store) {
|
|
726
|
+
return store.readableFacts();
|
|
727
|
+
}
|
|
728
|
+
/** "1 fact" / "N facts" (#188). The noun follows the block's TOTAL, the last number shown. */
|
|
729
|
+
function factNoun(total) {
|
|
730
|
+
return total === 1 ? 'fact' : 'facts';
|
|
731
|
+
}
|
|
732
|
+
/** The block's footer when files were left out: their names, never their contents. */
|
|
733
|
+
function skippedLine(skipped) {
|
|
734
|
+
const n = skipped.length;
|
|
735
|
+
return capBytes(`[${n} fact ${n === 1 ? 'file' : 'files'} not read — not a valid fact: ${skipped.map((s) => s.file).join(', ')}; ~/.bantamkit/hooks/hook-log.jsonl has the reason]`, SKIPPED_LINE_MAX);
|
|
736
|
+
}
|
|
737
|
+
/** A footer naming many broken files is still bounded, like everything else this file injects. */
|
|
738
|
+
const SKIPPED_LINE_MAX = 500;
|
|
650
739
|
// ------------------------------------------------------- the stale-install signal
|
|
651
740
|
// Reader 2 and Writer 1 of `docs/superpowers/specs/2026-09-19-stale-version-signal-design.md`.
|
|
652
741
|
// One line at SessionStart when the install this hook can SEE is older than what the package
|
|
@@ -687,8 +776,14 @@ const UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
687
776
|
* nothing this file injects is unbounded. The sentence itself is 137 bytes plus two version
|
|
688
777
|
* numbers; the one component that is not fixed is the operator's home path inside `{path}`, so
|
|
689
778
|
* the cap is generous and exists to bound that, not to trim the sentence.
|
|
779
|
+
*
|
|
780
|
+
* AMENDED 2026-10-15 (job66/#171): 500 → 1024. The line now carries THREE absolute paths (the
|
|
781
|
+
* manifest, `process.execPath`, the kept `cli.js`), and a cap that cuts the line cuts the
|
|
782
|
+
* command the operator is told to run — a truncated command is worse than none. Measured: the
|
|
783
|
+
* conformance bed's home (a long `$TMPDIR` scratch path) put the line past 500 and the printed
|
|
784
|
+
* command was cut before its closing backtick. 1024 still bounds it.
|
|
690
785
|
*/
|
|
691
|
-
const UPDATE_LINE_MAX =
|
|
786
|
+
const UPDATE_LINE_MAX = 1024;
|
|
692
787
|
/**
|
|
693
788
|
* THE SENTENCE. Only in the stale state, silent in the other four.
|
|
694
789
|
*
|
|
@@ -712,8 +807,15 @@ const UPDATE_LINE_MAX = 500;
|
|
|
712
807
|
* machine it was measured on. `node "<cli>"` is what a host is already told to run
|
|
713
808
|
* (`npminstall.keptCli`), needs no PATH and no `.cmd` shim on Windows, and the double quotes
|
|
714
809
|
* hold a home directory with a space in it in zsh, bash, cmd and PowerShell alike.
|
|
810
|
+
*
|
|
811
|
+
* AMENDED 2026-10-15 (job66/#171): "needs no PATH" was true of `{cli}` and false of `node`.
|
|
812
|
+
* Run through `/bin/sh` with `PATH=/usr/bin:/bin` — a GUI-launched host's PATH — the bare
|
|
813
|
+
* `node` answered `exit 127, node: command not found`. `{node}` is `process.execPath`, the
|
|
814
|
+
* absolute interpreter running THIS hook (the one the hook registration launched), quoted
|
|
815
|
+
* like `{cli}`. A quoted first word is a string in PowerShell, which runs it with `& `
|
|
816
|
+
* in front; zsh, bash, sh and cmd run it as printed.
|
|
715
817
|
*/
|
|
716
|
-
const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `node "{cli}" --update`, then reconnect the host.';
|
|
818
|
+
const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `"{node}" "{cli}" --update`, then reconnect the host.';
|
|
717
819
|
/** `{name}` substitution. A missing key is left alone rather than rendered `undefined`. */
|
|
718
820
|
function fillLine(template, values) {
|
|
719
821
|
return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
|
|
@@ -769,6 +871,7 @@ function updateSignal(run) {
|
|
|
769
871
|
installed: kept.version,
|
|
770
872
|
path: kept.path,
|
|
771
873
|
cli: kept.cli,
|
|
874
|
+
node: process.execPath,
|
|
772
875
|
latest,
|
|
773
876
|
}), UPDATE_LINE_MAX);
|
|
774
877
|
}
|
|
@@ -817,10 +920,19 @@ function sessionStart(run, input) {
|
|
|
817
920
|
const cwd = input.cwd || process.cwd();
|
|
818
921
|
const parts = [];
|
|
819
922
|
const profileFacts = countFacts(run.profile);
|
|
820
|
-
let profile = { block: '', total: 0, injected: 0, dropped: [] };
|
|
923
|
+
let profile = { block: '', total: 0, injected: 0, dropped: [], skipped: [] };
|
|
924
|
+
// IN ITS OWN TRY (job65, #119): this block used to sit outside every try, so a profile store
|
|
925
|
+
// the hook could not read at all threw past the arm and the session got NO context — not the
|
|
926
|
+
// project block, not the toolbox line. One unreadable FILE is handled inside `cappedIndex`;
|
|
927
|
+
// this catch is for a store that cannot even be listed, and it costs the profile block only.
|
|
821
928
|
if (profileFacts > 0) {
|
|
822
|
-
|
|
823
|
-
|
|
929
|
+
try {
|
|
930
|
+
profile = cappedIndex(run.profile, (count, total) => `[bantamkit profile memory — ${count} ${factNoun(total)} learned across projects]`, SESSION_INJECT_MAX);
|
|
931
|
+
parts.push(profile.block);
|
|
932
|
+
}
|
|
933
|
+
catch (e) {
|
|
934
|
+
log(run, { event: 'SessionStart', warn: `profile: ${message(e)}` });
|
|
935
|
+
}
|
|
824
936
|
}
|
|
825
937
|
// WHERE THE HOST'S OWN AUTO-MEMORY DIRECTORY IS, resolved ONCE per session and used twice:
|
|
826
938
|
// it decides whether the project index has to be injected at all, and it is where A exports.
|
|
@@ -843,7 +955,7 @@ function sessionStart(run, input) {
|
|
|
843
955
|
const store = discoverProjectStore(cwd);
|
|
844
956
|
if (path.resolve(store) !== path.resolve(run.profile) && countFacts(store) > 0) {
|
|
845
957
|
if (native.dir === null) {
|
|
846
|
-
project = cappedIndex(store, (count) => `[bantamkit project memory — ${count}
|
|
958
|
+
project = cappedIndex(store, (count, total) => `[bantamkit project memory — ${count} ${factNoun(total)}]`, SESSION_INJECT_MAX);
|
|
847
959
|
project.facts = countFacts(store);
|
|
848
960
|
parts.push(project.block);
|
|
849
961
|
}
|
|
@@ -906,8 +1018,16 @@ function sessionStart(run, input) {
|
|
|
906
1018
|
profileFacts,
|
|
907
1019
|
profileInjected: profile.injected,
|
|
908
1020
|
profileDropped: profile.dropped,
|
|
1021
|
+
// #119: the files left out, by name and the store's reason — present only when non-empty,
|
|
1022
|
+
// so a healthy session's record is the shape it always was.
|
|
1023
|
+
...(profile.skipped.length ? { profileSkipped: profile.skipped } : {}),
|
|
909
1024
|
...(project
|
|
910
|
-
? {
|
|
1025
|
+
? {
|
|
1026
|
+
projectFacts: project.facts,
|
|
1027
|
+
projectInjected: project.injected,
|
|
1028
|
+
projectDropped: project.dropped,
|
|
1029
|
+
...(project.skipped.length ? { projectSkipped: project.skipped } : {}),
|
|
1030
|
+
}
|
|
911
1031
|
: {}),
|
|
912
1032
|
dropRule: SESSION_DROP_RULE,
|
|
913
1033
|
// A — the host's auto-memory directory. `nativeBranch` / `nativeTried` are the one line
|
|
@@ -925,6 +1045,7 @@ function sessionStart(run, input) {
|
|
|
925
1045
|
nativeBytes: exported.bytes,
|
|
926
1046
|
nativeIndexBytes: exported.indexBytes,
|
|
927
1047
|
nativeSkipped: exported.skipped,
|
|
1048
|
+
...(exported.unparsed.length ? { nativeUnparsed: exported.unparsed } : {}),
|
|
928
1049
|
nativeIndex: exported.index,
|
|
929
1050
|
...(exported.error ? { nativeError: exported.error } : {}),
|
|
930
1051
|
}
|
|
@@ -1009,17 +1130,26 @@ function userPromptSubmit(run, input) {
|
|
|
1009
1130
|
// prompt, for up to three files — and the rules keyed on that date (compaction's
|
|
1010
1131
|
// stalest-first, the SessionStart drop rule, the Stop dream's store fingerprint) were
|
|
1011
1132
|
// reading this arm's traffic. An explicit `memory_recall` still stamps.
|
|
1012
|
-
const
|
|
1133
|
+
const ask = () => m.recallOutcome(prompt, 3, RECALL_MIN_SCORE_RATIO, false);
|
|
1134
|
+
// ONE UNREADABLE FACT COSTS THAT FACT (job65, #119): the recall reads every layer past the
|
|
1135
|
+
// files it cannot read and names them in `o.skipped`; one recall, healthy or not. A layer
|
|
1136
|
+
// that cannot be LISTED fails exactly as it did.
|
|
1137
|
+
const o = ask();
|
|
1138
|
+
const skipped = o.skipped.map((s) => ({ ...s }));
|
|
1139
|
+
const degraded = skipped.length ? { skipped } : {};
|
|
1013
1140
|
if (o.status !== 'answered') {
|
|
1014
|
-
log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates });
|
|
1141
|
+
log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates, ...degraded });
|
|
1015
1142
|
return;
|
|
1016
1143
|
}
|
|
1017
1144
|
// Only the HEADER line of each hit — `[layer] [name] (type) description`. The body costs
|
|
1018
1145
|
// ~1.5 KB a fact and would be re-sent on every later call; the header is ~150 B and
|
|
1019
1146
|
// tells the model exactly which name to pass to memory_recall if it wants the body.
|
|
1020
|
-
|
|
1147
|
+
// Read off `o.headers`, built from the parsed fields (job65, #155), never by filtering the
|
|
1148
|
+
// reply's lines: a fact BODY line shaped like `[profile] [x] (feedback) ...` used to pass
|
|
1149
|
+
// for a header and be injected as a memory nobody saved.
|
|
1150
|
+
const heads = o.headers.filter((l) => RECALL_HEADER.test(l));
|
|
1021
1151
|
if (heads.length === 0) {
|
|
1022
|
-
log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers' });
|
|
1152
|
+
log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers', ...degraded });
|
|
1023
1153
|
return;
|
|
1024
1154
|
}
|
|
1025
1155
|
// WHAT THIS CONTEXT HAS ALREADY BEEN SHOWN IS NOT SHOWN AGAIN (job64, J64-2). Measured
|
|
@@ -1056,6 +1186,7 @@ function userPromptSubmit(run, input) {
|
|
|
1056
1186
|
session: input.session_id ?? null,
|
|
1057
1187
|
prompt: promptFingerprint(prompt),
|
|
1058
1188
|
suppressed,
|
|
1189
|
+
...degraded,
|
|
1059
1190
|
});
|
|
1060
1191
|
return;
|
|
1061
1192
|
}
|
|
@@ -1094,6 +1225,7 @@ function userPromptSubmit(run, input) {
|
|
|
1094
1225
|
injected,
|
|
1095
1226
|
dropped: fresh.length - injected.length,
|
|
1096
1227
|
suppressed,
|
|
1228
|
+
...degraded,
|
|
1097
1229
|
});
|
|
1098
1230
|
emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } });
|
|
1099
1231
|
}
|
|
@@ -1103,7 +1235,15 @@ function userPromptSubmit(run, input) {
|
|
|
1103
1235
|
// because they never pass through bantamkit's Agent. A PreToolUse hook is the one place
|
|
1104
1236
|
// that does see them. Keyed by transcript (a subagent has its own transcript and has NOT
|
|
1105
1237
|
// seen the parent's read), by path+offset+limit, and by mtime+size so an edit re-arms it.
|
|
1106
|
-
// REFUSES ONCE: the second identical call goes through, so nothing can be hard-blocked
|
|
1238
|
+
// REFUSES ONCE: the second identical call goes through, so nothing can be hard-blocked —
|
|
1239
|
+
// and ONLY once (job65, J65-4, #166): the allowed retry used to write `refused: false`
|
|
1240
|
+
// back, which re-armed the gate and refused every other read (allow, deny, allow, deny, …)
|
|
1241
|
+
// of a file the docs promise is "refused once". The record now keeps `refused: true` until
|
|
1242
|
+
// the signature changes, so the sequence is allow, deny, allow, allow, … and an edit re-arms.
|
|
1243
|
+
// ONLY REGULAR FILES (#190): a directory stats fine, so it was ledgered and its retry refused
|
|
1244
|
+
// with "use the content from that earlier read" — content the host never produced, because
|
|
1245
|
+
// the first Read failed with EISDIR. Anything that is not a regular file is treated like a
|
|
1246
|
+
// missing path: not ledgered, never refused, Read produces its own error.
|
|
1107
1247
|
function preToolUseRead(run, input) {
|
|
1108
1248
|
const ti = input.tool_input ?? {};
|
|
1109
1249
|
const file = typeof ti['file_path'] === 'string' ? ti['file_path'] : '';
|
|
@@ -1116,6 +1256,22 @@ function preToolUseRead(run, input) {
|
|
|
1116
1256
|
catch {
|
|
1117
1257
|
return; // missing file: let Read produce its own error
|
|
1118
1258
|
}
|
|
1259
|
+
if (!st.isFile())
|
|
1260
|
+
return; // a directory/device read has no file content to reuse
|
|
1261
|
+
// NOR A FILE THIS PROCESS MAY NOT READ (job65, #192). `isFile` closed the directory case of
|
|
1262
|
+
// #190 but the ledger still recorded on PreToolUse, before the host's Read could fail: a
|
|
1263
|
+
// `chmod 000` file was ledgered and its retry refused with "use the content from that
|
|
1264
|
+
// earlier read" — content the host never produced. The hook runs as the same user as the
|
|
1265
|
+
// host's Read, so `R_OK` here is the host's answer; a file that fails it is treated like a
|
|
1266
|
+
// missing path: not ledgered, never refused. Chosen over moving the record to
|
|
1267
|
+
// PostToolUse[Read] because it is one check in the arm that already decides, on both
|
|
1268
|
+
// runtimes, and needs no second event to agree with the first (docs/hooks.md, PreToolUse).
|
|
1269
|
+
try {
|
|
1270
|
+
fs.accessSync(file, fs.constants.R_OK);
|
|
1271
|
+
}
|
|
1272
|
+
catch {
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1119
1275
|
const offset = ti['offset'];
|
|
1120
1276
|
const limit = ti['limit'];
|
|
1121
1277
|
// The transcript is the CONTEXT dimension: a subagent has its own transcript and has not
|
|
@@ -1123,14 +1279,39 @@ function preToolUseRead(run, input) {
|
|
|
1123
1279
|
// `preCompact` must filter on it and a path may itself contain the key's delimiter.
|
|
1124
1280
|
const transcript = String(input.transcript_path || input.session_id || '');
|
|
1125
1281
|
const key = `${transcript}|${file}|${offset ?? ''}|${limit ?? ''}`;
|
|
1126
|
-
const ledger = readLedger(run, input.session_id);
|
|
1127
|
-
const prev = ledger.reads[key];
|
|
1128
1282
|
const sig = `${st.mtimeMs}|${st.size}`;
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1283
|
+
const decision = {
|
|
1284
|
+
action: 'record',
|
|
1285
|
+
refusedAt: '',
|
|
1286
|
+
};
|
|
1287
|
+
// The whole read-modify-write under the ledger lock (#167); the decision is emitted after.
|
|
1288
|
+
updateLedger(run, input.session_id, (ledger) => {
|
|
1289
|
+
const prev = ledger.reads[key];
|
|
1290
|
+
if (prev && prev.sig === sig) {
|
|
1291
|
+
prev.count += 1;
|
|
1292
|
+
if (!prev.refused) {
|
|
1293
|
+
prev.refused = true;
|
|
1294
|
+
decision.action = 'refuse';
|
|
1295
|
+
decision.refusedAt = prev.at;
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
decision.action = 'allow-after-refuse-or-change'; // refused once already; every later repeat goes through
|
|
1299
|
+
}
|
|
1300
|
+
return true;
|
|
1301
|
+
}
|
|
1302
|
+
ledger.reads[key] = {
|
|
1303
|
+
sig,
|
|
1304
|
+
at: new Date().toISOString(),
|
|
1305
|
+
count: (prev ? prev.count : 0) + 1,
|
|
1306
|
+
refused: false,
|
|
1307
|
+
transcript,
|
|
1308
|
+
file,
|
|
1309
|
+
};
|
|
1310
|
+
decision.action = prev ? 'allow-after-refuse-or-change' : 'record';
|
|
1311
|
+
return true;
|
|
1312
|
+
});
|
|
1313
|
+
if (decision.action === 'refuse') {
|
|
1314
|
+
const reason = `bantamkit filegraph: ${file}${offset != null ? ` (offset ${offset}${limit != null ? `, limit ${limit}` : ''})` : ''} was already read in this context at ${decision.refusedAt} 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.`;
|
|
1134
1315
|
log(run, { event: 'PreToolUse', action: 'refuse', file, size: st.size });
|
|
1135
1316
|
emit({
|
|
1136
1317
|
hookSpecificOutput: {
|
|
@@ -1141,21 +1322,7 @@ function preToolUseRead(run, input) {
|
|
|
1141
1322
|
});
|
|
1142
1323
|
return;
|
|
1143
1324
|
}
|
|
1144
|
-
|
|
1145
|
-
sig,
|
|
1146
|
-
at: new Date().toISOString(),
|
|
1147
|
-
count: (prev ? prev.count : 0) + 1,
|
|
1148
|
-
refused: false,
|
|
1149
|
-
transcript,
|
|
1150
|
-
file,
|
|
1151
|
-
};
|
|
1152
|
-
writeLedger(run, input.session_id, ledger);
|
|
1153
|
-
log(run, {
|
|
1154
|
-
event: 'PreToolUse',
|
|
1155
|
-
action: prev ? 'allow-after-refuse-or-change' : 'record',
|
|
1156
|
-
file,
|
|
1157
|
-
size: st.size,
|
|
1158
|
-
});
|
|
1325
|
+
log(run, { event: 'PreToolUse', action: decision.action, file, size: st.size });
|
|
1159
1326
|
}
|
|
1160
1327
|
// ---------------------------------------------- PostToolUse → the usage events log
|
|
1161
1328
|
// One line per tool call, for `tools/ledger/tool-usage.mjs` to read when a transcript is gone.
|
|
@@ -2070,7 +2237,7 @@ function stop(run, input) {
|
|
|
2070
2237
|
const toolUses = (text.match(/"type":\s*"tool_use"/g) || []).length;
|
|
2071
2238
|
const saved = (ledger.saved || 0) > 0 ||
|
|
2072
2239
|
/"name":\s*"mcp__bantamkit__memory_save"/.test(text) ||
|
|
2073
|
-
|
|
2240
|
+
transcriptWroteMemory(text);
|
|
2074
2241
|
if (toolUses < STOP_NUDGE_MIN_TOOL_CALLS || saved) {
|
|
2075
2242
|
log(run, { event: 'Stop', action: 'pass', toolUses, saved });
|
|
2076
2243
|
return;
|
|
@@ -2083,6 +2250,51 @@ function stop(run, input) {
|
|
|
2083
2250
|
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.`,
|
|
2084
2251
|
});
|
|
2085
2252
|
}
|
|
2253
|
+
/** The native write tools whose `file_path` can put a fact into a `memory/` directory. */
|
|
2254
|
+
const SAVE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
2255
|
+
/** The path shape of a native memory write: a `.md` under any `memory/` directory. */
|
|
2256
|
+
const SAVE_WRITE_PATH = /[\\/]memory[\\/][\s\S]*\.md$/;
|
|
2257
|
+
/** A line can only hold a native write if it names one of the tools; the rest are never parsed. */
|
|
2258
|
+
const SAVE_LINE_HINT = /"name":\s*"(?:Write|Edit|MultiEdit)"/;
|
|
2259
|
+
/**
|
|
2260
|
+
* Did the transcript WRITE a native memory file (job65, #169)? Only a `tool_use` counts: a
|
|
2261
|
+
* Write/Edit/MultiEdit whose `file_path` is a `.md` under a `memory/` directory. Before this a
|
|
2262
|
+
* regex over the whole text took ANY `"file_path"` under `memory/` — a Read of
|
|
2263
|
+
* `docs/memory/notes.md` included — as a save, and silenced the nudge. (A `memory_save` call
|
|
2264
|
+
* is still recognised by its tool name, as it always was; that half was never the defect.)
|
|
2265
|
+
*
|
|
2266
|
+
* Lines are prefiltered on the tool names and only those are parsed, so a long transcript
|
|
2267
|
+
* costs one regex per line and a `JSON.parse` only where a write could be. A line that does
|
|
2268
|
+
* not parse is not a save.
|
|
2269
|
+
*/
|
|
2270
|
+
function transcriptWroteMemory(text) {
|
|
2271
|
+
for (const line of text.split('\n')) {
|
|
2272
|
+
if (!SAVE_LINE_HINT.test(line))
|
|
2273
|
+
continue;
|
|
2274
|
+
let record;
|
|
2275
|
+
try {
|
|
2276
|
+
record = JSON.parse(line);
|
|
2277
|
+
}
|
|
2278
|
+
catch {
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2281
|
+
const msg = record && typeof record === 'object' ? record.message : undefined;
|
|
2282
|
+
const content = msg && typeof msg === 'object' ? msg.content : undefined;
|
|
2283
|
+
if (!Array.isArray(content))
|
|
2284
|
+
continue;
|
|
2285
|
+
for (const block of content) {
|
|
2286
|
+
if (!block || typeof block !== 'object')
|
|
2287
|
+
continue;
|
|
2288
|
+
const b = block;
|
|
2289
|
+
if (b.type !== 'tool_use' || typeof b.name !== 'string' || !SAVE_WRITE_TOOLS.has(b.name))
|
|
2290
|
+
continue;
|
|
2291
|
+
const fp = b.input && typeof b.input === 'object' ? b.input.file_path : undefined;
|
|
2292
|
+
if (typeof fp === 'string' && SAVE_WRITE_PATH.test(fp))
|
|
2293
|
+
return true;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2086
2298
|
// ---------------------------------------------------------------------- dispatch
|
|
2087
2299
|
/** Read the whole of stdin. One JSON object is the entire input contract. */
|
|
2088
2300
|
async function readStdin() {
|
|
@@ -2109,7 +2321,15 @@ export async function runHook() {
|
|
|
2109
2321
|
input = parsed;
|
|
2110
2322
|
}
|
|
2111
2323
|
catch {
|
|
2112
|
-
|
|
2324
|
+
// THE INPUT IS NOT LOGGED, AT ANY LENGTH (job65, #168): a truncated UserPromptSubmit event
|
|
2325
|
+
// carries the user's prompt, and this line used to keep its first 200 characters. Its
|
|
2326
|
+
// size and a SHA-256 digest are kept instead, the same fingerprint `promptFingerprint`
|
|
2327
|
+
// gives a prompt that did parse, so a bad input can still be recognised twice.
|
|
2328
|
+
log(run, {
|
|
2329
|
+
event: 'parse-error',
|
|
2330
|
+
bytes: Buffer.byteLength(raw),
|
|
2331
|
+
sha256: createHash('sha256').update(raw, 'utf8').digest('hex'),
|
|
2332
|
+
});
|
|
2113
2333
|
return;
|
|
2114
2334
|
}
|
|
2115
2335
|
const ev = input.hook_event_name;
|
package/dist/hostinstall.d.ts
CHANGED
|
@@ -152,8 +152,8 @@ export declare const HOOK_MARKER_VALUE = "hook";
|
|
|
152
152
|
* 5. dated backup, then one atomic write.
|
|
153
153
|
*
|
|
154
154
|
* The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
|
|
155
|
-
* same
|
|
156
|
-
* are produced by one function (RULING Q3.5).
|
|
155
|
+
* same detected indent and literal non-ASCII through `hostJsonText` (#118) — so the bytes this
|
|
156
|
+
* leaves and the bytes `--install` leaves are produced by one function (RULING Q3.5).
|
|
157
157
|
*/
|
|
158
158
|
export declare function installHooks(options?: HookOptions): string;
|
|
159
159
|
/**
|