@bglocation/tune-context 1.1.0 → 2.0.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +160 -0
- package/README.md +92 -7
- package/bin/tune-context +11 -0
- package/bin/tune-context.mjs +109 -15
- package/cli/detect.mjs +92 -16
- package/cli/generate.mjs +6 -0
- package/cli/sync-doctrine.mjs +11 -2
- package/cli/verify.mjs +50 -5
- package/eval/adoption-report.mjs +91 -1
- package/hooks/context-reminder.mjs +212 -0
- package/hooks/hooks.json +11 -0
- package/hooks/pre-compact-snapshot.mjs +68 -6
- package/package.json +1 -1
- package/skills/token-efficiency/SKILL.md +3 -1
- package/skills/tune-context/SKILL.md +58 -15
- package/skills/tune-context/templates/settings.json.tmpl +11 -10
package/cli/generate.mjs
CHANGED
|
@@ -76,6 +76,12 @@ function templateInnerBlock(templatePath) {
|
|
|
76
76
|
const raw = readFileSync(templatePath, 'utf8');
|
|
77
77
|
const start = raw.indexOf('<!-- >>> tune-context (managed) >>>');
|
|
78
78
|
const end = raw.indexOf('<!-- <<< tune-context (managed) <<< -->');
|
|
79
|
+
// A missing marker makes `start`/`end` -1; indexOf('\n', -1) silently treats
|
|
80
|
+
// -1 as 0, which used to return garbage sliced from the top of the file
|
|
81
|
+
// instead of failing. A broken template must not become a broken CLAUDE.md.
|
|
82
|
+
if (start === -1 || end === -1 || end < start) {
|
|
83
|
+
throw new Error(`template ${templatePath} is missing the tune-context managed-block markers`);
|
|
84
|
+
}
|
|
79
85
|
const startLineEnd = raw.indexOf('\n', start) + 1;
|
|
80
86
|
return raw.slice(startLineEnd, end).trimEnd();
|
|
81
87
|
}
|
package/cli/sync-doctrine.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Mirrors this package's skills/agents/hooks into a Claude Code config dir
|
|
2
|
-
// (~/.claude by default). Idempotent
|
|
3
|
-
//
|
|
2
|
+
// (~/.claude by default). Idempotent for content that still exists at the
|
|
3
|
+
// source: each entry is removed then recopied, so a changed file/directory
|
|
4
|
+
// shape can't leave old siblings behind inside THAT entry. This is an
|
|
5
|
+
// OVERLAY, not a mirror, across runs: the outer loop is `readdirSync(from)`,
|
|
6
|
+
// so an entry no longer present at the source is simply never visited again —
|
|
7
|
+
// it is NOT deleted from the destination (review finding, TASK-077; verified
|
|
8
|
+
// empirically, not assumed. See the "KNOWN GAP" test in
|
|
9
|
+
// test/cli-sync-doctrine.test.mjs). A prune pass would need to diff against
|
|
10
|
+
// what THIS tool previously wrote, since claudeDir may also hold a user's own
|
|
11
|
+
// unrelated skills/agents/hooks that must never be touched — deliberately not
|
|
12
|
+
// attempted here without that design decision.
|
|
4
13
|
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
5
14
|
import { dirname, join } from 'node:path';
|
|
6
15
|
|
package/cli/verify.mjs
CHANGED
|
@@ -24,15 +24,31 @@ function extractHookPath(command) {
|
|
|
24
24
|
return m ? (m[1] ?? m[2]) : null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Expand $HOME / ${HOME}
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
27
|
+
// Expand $HOME / ${HOME} inside a hook COMMAND STRING the way it resolves at
|
|
28
|
+
// run time (process.env.HOME, falling back to homedir() if HOME is somehow
|
|
29
|
+
// unset) — the literal shell variable baked into the *default*, non-override
|
|
30
|
+
// hook command form (hooksDirForTemplate() in cli/generate.mjs). UNRELATED to
|
|
31
|
+
// CLAUDE_CONFIG_DIR: that rule lives solely in claudeBaseDir()
|
|
32
|
+
// (hooks/config-dir.mjs, TASK-074) and governs which directory claudeDir
|
|
33
|
+
// itself resolves to, a different axis from $HOME shell-expansion. A
|
|
34
|
+
// different notion of "home" on the two sides of the comparison below would
|
|
35
|
+
// itself manufacture a mismatch and fire a false alarm on a correct install.
|
|
31
36
|
function expandHome(p) {
|
|
32
37
|
const home = process.env.HOME || homedir();
|
|
33
38
|
return p.replace(/\$\{HOME\}/g, home).replace(/\$HOME/g, home);
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// TASK-072: signature of the PLUGIN channel's own hook registration turning up
|
|
42
|
+
// where a settings.json entry is being checked. `${CLAUDE_PLUGIN_ROOT}` is
|
|
43
|
+
// Claude Code's substitution for a plugin's OWN hooks.json — it is never
|
|
44
|
+
// expanded for a command sitting in settings.json, so resolving/existsSync-ing
|
|
45
|
+
// it here would always read as "missing", which would be a lie: the plugin
|
|
46
|
+
// channel may be working perfectly well from Claude Code's side. Commands
|
|
47
|
+
// matching this are pulled out of the resolvability check below and reported
|
|
48
|
+
// separately, always informational — we can tell the command SHAPE is
|
|
49
|
+
// present, never whether the plugin is actually enabled (Option B, TASK-072).
|
|
50
|
+
const PLUGIN_PATH_HINT = /\$\{CLAUDE_PLUGIN_ROOT\}|[\\/]plugins[\\/]/;
|
|
51
|
+
|
|
36
52
|
/**
|
|
37
53
|
* @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
|
|
38
54
|
*/
|
|
@@ -48,9 +64,38 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
|
|
|
48
64
|
const settings = readJson(userSettingsPath);
|
|
49
65
|
for (const { event, scriptName } of expectedHookCommands) {
|
|
50
66
|
const entries = settings?.hooks?.[event] || [];
|
|
51
|
-
const
|
|
67
|
+
const allCommands = entries.flatMap((g) => (g.hooks || []).map((h) => h.command)).filter(Boolean);
|
|
52
68
|
const installedPath = resolve(join(claudeDir, 'hooks', scriptName));
|
|
53
69
|
|
|
70
|
+
// Classify by DESTINATION, not by spelling: a command that resolves to
|
|
71
|
+
// the very file we installed is the CLI registration, whatever its path
|
|
72
|
+
// happens to look like. Without this, PLUGIN_PATH_HINT's loose
|
|
73
|
+
// `/plugins/` arm swallowed a correct install whose config dir merely
|
|
74
|
+
// sits under such a path (e.g. CLAUDE_CONFIG_DIR=/opt/plugins/claude) —
|
|
75
|
+
// it was dropped from `commands`, then reported "not found in
|
|
76
|
+
// settings.json", i.e. a false RED on a perfect install, plus a plugin
|
|
77
|
+
// note for a plugin that isn't there (review finding, EPIC-008).
|
|
78
|
+
// `${CLAUDE_PLUGIN_ROOT}` stays correctly classified: it is literal text
|
|
79
|
+
// in settings.json (Claude Code only expands it for a plugin's own
|
|
80
|
+
// hooks.json), so it can never resolve to the installed file.
|
|
81
|
+
const isInstalledTarget = (c) => {
|
|
82
|
+
const p = extractHookPath(c);
|
|
83
|
+
return p !== null && resolve(expandHome(p)) === installedPath;
|
|
84
|
+
};
|
|
85
|
+
const isPluginShaped = (c) => PLUGIN_PATH_HINT.test(c) && !isInstalledTarget(c);
|
|
86
|
+
|
|
87
|
+
// Report the plugin-channel shape separately and up front — see
|
|
88
|
+
// PLUGIN_PATH_HINT above — then exclude it from the resolvability check
|
|
89
|
+
// below so it can never be misread as a dead/stale leftover.
|
|
90
|
+
for (const pluginCmd of allCommands.filter((c) => c.includes(scriptName) && isPluginShaped(c))) {
|
|
91
|
+
checks.push({
|
|
92
|
+
name: `hooks.${event}: ${scriptName} also has a plugin-channel registration`,
|
|
93
|
+
ok: true,
|
|
94
|
+
detail: `${pluginCmd} — if the tune-context plugin is also enabled, every ${event} event logs twice; see README ("Measuring adoption") for the re-read-churn effect this has on adoption-report`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const commands = allCommands.filter((c) => !isPluginShaped(c));
|
|
98
|
+
|
|
54
99
|
// Match by the basename of each command's target path — ALL of them, not
|
|
55
100
|
// just the first. mergeHooks is a set keyed by command, so a path change
|
|
56
101
|
// (e.g. adopting CLAUDE_CONFIG_DIR) appends a new entry next to the old
|
package/eval/adoption-report.mjs
CHANGED
|
@@ -30,6 +30,29 @@ const DOCTRINE_SKILLS = new Set([
|
|
|
30
30
|
// Churn is exempt — a within-session symptom, readable at any n.
|
|
31
31
|
const MIN_SESSIONS_FOR_VERDICT = 3;
|
|
32
32
|
|
|
33
|
+
// Two hook processes spawned for the SAME Claude Code event (a script
|
|
34
|
+
// registered on both the plugin channel and settings.json — TASK-072) write
|
|
35
|
+
// near-identical records milliseconds apart. FROZEN WITH PO (2026-07-25,
|
|
36
|
+
// TASK-072; same anti-bias rule as MIN_SESSIONS_FOR_VERDICT above, TASK-062).
|
|
37
|
+
//
|
|
38
|
+
// Measured, not guessed: the gap distribution of same-key adjacent pairs in
|
|
39
|
+
// the real 2026-07-24 log is cleanly bimodal —
|
|
40
|
+
// ≤14ms : 159 pairs (duplicate dispatch: 0,1,2,3,4,5,6,7,8,14ms)
|
|
41
|
+
// 15-359ms: 0 pairs (empty)
|
|
42
|
+
// ≥360ms : 288 pairs (continuous out to 10s — genuine repeated activity)
|
|
43
|
+
// 100ms sits in the empty band, with maximum margin on both sides. A wider
|
|
44
|
+
// window (the 1000ms first proposed here) reaches past the gap and swallows 8
|
|
45
|
+
// real pairs — suppressing true churn, the exact failure this must avoid.
|
|
46
|
+
const DUPLICATE_LOG_WINDOW_MS = 100;
|
|
47
|
+
|
|
48
|
+
// Share of all records that must look like duplicate dispatches before the
|
|
49
|
+
// churn verdict is withheld. FROZEN WITH PO (2026-07-25, TASK-072).
|
|
50
|
+
// A doubled registration doubles EVERY event, so the signature is always a
|
|
51
|
+
// large share (28% in the real log — 159 pairs of 559 records), never a
|
|
52
|
+
// stray one. Requiring a share rather than a single pair keeps a couple of
|
|
53
|
+
// coincidentally-fast events from silencing a whole log's verdict.
|
|
54
|
+
const MIN_DUPLICATE_SHARE = 0.1;
|
|
55
|
+
|
|
33
56
|
function defaultLogPath() {
|
|
34
57
|
return join(claudeBaseDir(), 'tune-context', 'adoption.jsonl');
|
|
35
58
|
}
|
|
@@ -64,6 +87,46 @@ export function parseLog(text) {
|
|
|
64
87
|
return out;
|
|
65
88
|
}
|
|
66
89
|
|
|
90
|
+
// Two (or more) records sharing (session, tool, lever, detail) within
|
|
91
|
+
// DUPLICATE_LOG_WINDOW_MS of each other look like the SAME real event logged
|
|
92
|
+
// twice — the signature of a hook registered on two channels at once
|
|
93
|
+
// (TASK-072), not genuine repeated activity. Runs over every lever, not just
|
|
94
|
+
// reads: a doubled hook doubles all of them equally.
|
|
95
|
+
//
|
|
96
|
+
// Records with an empty `detail` are SKIPPED, and skipped on both sides of
|
|
97
|
+
// the ratio (see `assessable`). classify() gives every non-search Bash call
|
|
98
|
+
// `detail: null`, so they all collapse onto a single key — and Claude Code
|
|
99
|
+
// fires parallel tool calls within one turn, whose hooks land milliseconds
|
|
100
|
+
// apart. Two genuinely different Bash commands would then read as a duplicate
|
|
101
|
+
// pair. Measured on a clean simulated log, parallel Bash alone reached 13.9%
|
|
102
|
+
// and tripped MIN_DUPLICATE_SHARE — a false "undecidable" over a perfectly
|
|
103
|
+
// readable log. Restricting to detail-bearing records removes that vector
|
|
104
|
+
// (0% on the same simulation) and still detects the real thing with a wide
|
|
105
|
+
// margin: 28.9% on the frozen snapshot, 42.8% on a 1605-record live log.
|
|
106
|
+
// Deduplicating the counts themselves stays out of scope (TASK-072): we
|
|
107
|
+
// withhold the verdict, we don't guess which record was "real".
|
|
108
|
+
function countDuplicateRegistrationPairs(records) {
|
|
109
|
+
const groups = new Map();
|
|
110
|
+
let assessable = 0;
|
|
111
|
+
for (const r of records) {
|
|
112
|
+
const t = Date.parse(r.ts);
|
|
113
|
+
if (Number.isNaN(t)) continue;
|
|
114
|
+
if (!r.detail) continue; // ambiguous key — see above
|
|
115
|
+
assessable++;
|
|
116
|
+
const key = `${r.session ?? ''}\u0000${r.tool ?? ''}\u0000${r.lever ?? ''}\u0000${r.detail}`;
|
|
117
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
118
|
+
groups.get(key).push(t);
|
|
119
|
+
}
|
|
120
|
+
let pairs = 0;
|
|
121
|
+
for (const times of groups.values()) {
|
|
122
|
+
times.sort((a, b) => a - b);
|
|
123
|
+
for (let i = 1; i < times.length; i++) {
|
|
124
|
+
if (times[i] - times[i - 1] <= DUPLICATE_LOG_WINDOW_MS) pairs++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { pairs, assessable };
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
export function aggregate(records) {
|
|
68
131
|
const search = { rag: 0, grepTool: 0, shell: 0, glob: 0 };
|
|
69
132
|
const skills = new Map();
|
|
@@ -140,6 +203,13 @@ export function aggregate(records) {
|
|
|
140
203
|
}
|
|
141
204
|
}
|
|
142
205
|
|
|
206
|
+
// Both sides of this ratio come from the same population — only records
|
|
207
|
+
// whose duplicate status is actually assessable (see the function's note).
|
|
208
|
+
// Using records.length as the denominator instead would let a Bash-heavy
|
|
209
|
+
// log dilute a genuine doubling below the floor.
|
|
210
|
+
const { pairs: dedupPairs, assessable: dedupAssessable } = countDuplicateRegistrationPairs(records);
|
|
211
|
+
const dedupShare = dedupAssessable > 0 ? dedupPairs / dedupAssessable : 0;
|
|
212
|
+
|
|
143
213
|
return {
|
|
144
214
|
total: records.length,
|
|
145
215
|
sessions: sessionSet.size,
|
|
@@ -155,6 +225,16 @@ export function aggregate(records) {
|
|
|
155
225
|
reReadFiles,
|
|
156
226
|
redundantReads,
|
|
157
227
|
reReadRate: reads > 0 ? redundantReads / reads : null,
|
|
228
|
+
// TASK-072: near-duplicate records within DUPLICATE_LOG_WINDOW_MS,
|
|
229
|
+
// consistent with a doubled hook registration. dedupSuspected gates the
|
|
230
|
+
// churn VERDICT line in formatReport — the raw counts above stay as-is
|
|
231
|
+
// (deduplicating them would be a guess, out of scope). Gated on a SHARE,
|
|
232
|
+
// not a single pair: doubling affects every event, so a genuine doubled
|
|
233
|
+
// log is always well past the floor, while a couple of coincidentally
|
|
234
|
+
// fast events must not silence an otherwise readable verdict.
|
|
235
|
+
dedupPairs,
|
|
236
|
+
dedupShare,
|
|
237
|
+
dedupSuspected: dedupShare >= MIN_DUPLICATE_SHARE,
|
|
158
238
|
// Doctrine applied as *method* — narrow reads, contracts-first edits,
|
|
159
239
|
// delegation — leaves no `Skill` tool call, so the skills verdict below
|
|
160
240
|
// can't see it (same blind spot the report already admits for caveman).
|
|
@@ -273,7 +353,17 @@ export function formatReport(a) {
|
|
|
273
353
|
// on MIN_SESSIONS_FOR_VERDICT. But zero reads is zero churn signal: report
|
|
274
354
|
// n/a, never "elevated" — that would be a false ✗ over no data (same
|
|
275
355
|
// "say only what the sample supports" rule as the verdict above).
|
|
276
|
-
if (a.
|
|
356
|
+
if (a.dedupSuspected) {
|
|
357
|
+
// TASK-072: withhold the verdict rather than print a fabricated ✓/~ —
|
|
358
|
+
// a doubled hook registration inflates re-read churn without any real
|
|
359
|
+
// re-reading happening. "Only say what the data supports" (TASK-070).
|
|
360
|
+
L.push(
|
|
361
|
+
` ~ re-read churn: undecidable — ${a.dedupPairs} near-duplicate event pair(s),` +
|
|
362
|
+
` ${pct(a.dedupShare)} of comparable records, consistent with the hook being` +
|
|
363
|
+
' registered twice (plugin + settings.json). Every event logs twice, so churn' +
|
|
364
|
+
' can\'t be read off this log; see README ("Measuring adoption").',
|
|
365
|
+
);
|
|
366
|
+
} else if (a.reReadRate === null) {
|
|
277
367
|
L.push(' · re-read churn: n/a — no reads logged yet.');
|
|
278
368
|
} else {
|
|
279
369
|
L.push(
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UserPromptSubmit hook: measures how much context this session is actually
|
|
3
|
+
// carrying and, once it crosses a threshold, injects a short reminder that it
|
|
4
|
+
// may be time to shed some. See tasks/TASK-079.
|
|
5
|
+
//
|
|
6
|
+
// Why a hook has to do this at all: the model cannot see its own context
|
|
7
|
+
// usage — nothing in a turn tells it how many tokens it is re-sending. The
|
|
8
|
+
// user can see a number but has no reason to watch it. Claude Code exposes no
|
|
9
|
+
// timer and no context-threshold event (verified against the hooks docs), so
|
|
10
|
+
// the only place the measurement can come from is the transcript.
|
|
11
|
+
//
|
|
12
|
+
// Honest scope, and the reason this reminds on SIZE rather than on "phase
|
|
13
|
+
// boundary": a hook is a plain script with no model access, so it cannot know
|
|
14
|
+
// that a work thread just ended. Size is measurable; a phase boundary is a
|
|
15
|
+
// judgment. This hook therefore supplies the measurement the model can't see,
|
|
16
|
+
// and leaves the timing judgment to the model — the reminder explicitly tells
|
|
17
|
+
// it to stay quiet mid-task. The phase-boundary half is the `phase-workflow`
|
|
18
|
+
// skill's job, model-side, where judgment actually lives.
|
|
19
|
+
//
|
|
20
|
+
// The number is real, not a proxy: Claude Code records per-request `usage` in
|
|
21
|
+
// the transcript, and input_tokens + cache_read_input_tokens +
|
|
22
|
+
// cache_creation_input_tokens is exactly the context that was re-sent.
|
|
23
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { claudeBaseDir } from './config-dir.mjs';
|
|
26
|
+
|
|
27
|
+
// PO decision 2026-07-25 (TASK-079), frozen per the TASK-062 anti-bias rule:
|
|
28
|
+
// a threshold that judges the session's own weight is the PO's call, not the
|
|
29
|
+
// model's. Chosen from a measured 12.6h session (peak 547k tokens, 3
|
|
30
|
+
// compactions): with fire-once-per-crossing, 80k/100k/120k/150k all produce
|
|
31
|
+
// the SAME 4 reminders, so the choice is about how early the warning lands,
|
|
32
|
+
// not about noise. 120k is ~60% of a standard 200k window and sits clearly
|
|
33
|
+
// below the ~165k where that session's user compacted by hand.
|
|
34
|
+
//
|
|
35
|
+
// Override for a larger window (e.g. Opus 1M) — the transcript does NOT carry
|
|
36
|
+
// the context-window size (checked: no such field), so this cannot be
|
|
37
|
+
// auto-scaled and an absolute number is the honest interface.
|
|
38
|
+
const DEFAULT_THRESHOLD_TOKENS = 120_000;
|
|
39
|
+
|
|
40
|
+
// A drop to below this fraction of the previous reading means the context was
|
|
41
|
+
// compacted or cleared, so the reminder re-arms. Deliberately loose: normal
|
|
42
|
+
// turn-to-turn variation is a few percent, while a compaction cuts context by
|
|
43
|
+
// 60-95% (measured: 166k->58k, 541k->23k, 165k->48k).
|
|
44
|
+
const REARM_DROP_RATIO = 0.6;
|
|
45
|
+
|
|
46
|
+
// Read only the tail: transcripts reach tens of MB (the session this was
|
|
47
|
+
// built from was 9.4MB after 12.6h) and this runs on EVERY prompt, so parsing
|
|
48
|
+
// the whole file would make the tool's own overhead grow with session length —
|
|
49
|
+
// precisely the cost curve it exists to fight. The last entries are all we
|
|
50
|
+
// need, and 256KB comfortably spans several of them.
|
|
51
|
+
const TAIL_BYTES = 256 * 1024;
|
|
52
|
+
|
|
53
|
+
export function thresholdTokens(env = process.env) {
|
|
54
|
+
const raw = env.TUNE_CONTEXT_REMIND_TOKENS;
|
|
55
|
+
if (raw === undefined) return DEFAULT_THRESHOLD_TOKENS;
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
// A malformed override must not silently disable the reminder, nor crash a
|
|
58
|
+
// prompt — fall back to the frozen default.
|
|
59
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_THRESHOLD_TOKENS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readStdin() {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(readFileSync(0, 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Last TAIL_BYTES of a file as text, without reading what comes before it. */
|
|
71
|
+
function readTail(path, bytes = TAIL_BYTES) {
|
|
72
|
+
const size = statSync(path).size;
|
|
73
|
+
const start = Math.max(0, size - bytes);
|
|
74
|
+
const length = size - start;
|
|
75
|
+
if (length === 0) return '';
|
|
76
|
+
const buf = Buffer.allocUnsafe(length);
|
|
77
|
+
const fd = openSync(path, 'r');
|
|
78
|
+
try {
|
|
79
|
+
readSync(fd, buf, 0, length, start);
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
}
|
|
83
|
+
return buf.toString('utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Context size (tokens) of the most recent request recorded in the transcript,
|
|
88
|
+
* or null when the transcript has no usable entry yet.
|
|
89
|
+
*
|
|
90
|
+
* Scans backwards so the newest reading wins, and tolerates a partial first
|
|
91
|
+
* line: a tail read almost always starts mid-line, and that fragment simply
|
|
92
|
+
* fails JSON.parse and is skipped.
|
|
93
|
+
*/
|
|
94
|
+
export function latestContextTokens(transcriptText) {
|
|
95
|
+
const lines = transcriptText.split('\n');
|
|
96
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
97
|
+
const line = lines[i].trim();
|
|
98
|
+
if (!line) continue;
|
|
99
|
+
let entry;
|
|
100
|
+
try {
|
|
101
|
+
entry = JSON.parse(line);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const usage = entry?.message?.usage;
|
|
106
|
+
if (!usage) continue;
|
|
107
|
+
const total =
|
|
108
|
+
(usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
|
|
109
|
+
// Guard against the tiny synthetic entries Claude Code also writes.
|
|
110
|
+
if (total > 0) return total;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stateFile() {
|
|
116
|
+
return join(claudeBaseDir(), 'tune-context', 'context-reminder.json');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readState(path) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
122
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
123
|
+
} catch {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// One entry per session id; sessions go stale the moment they end, so old
|
|
129
|
+
// entries are dropped on write rather than accumulating forever (the same
|
|
130
|
+
// hygiene TASK-076 gave state/).
|
|
131
|
+
const STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
132
|
+
|
|
133
|
+
function pruneState(state, now) {
|
|
134
|
+
for (const [key, value] of Object.entries(state)) {
|
|
135
|
+
if (!value || typeof value.ts !== 'number' || now - value.ts > STATE_MAX_AGE_MS) delete state[key];
|
|
136
|
+
}
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pure decision: given the previous reading for this session and the current
|
|
142
|
+
* one, should a reminder fire, and what is the session's next state?
|
|
143
|
+
*
|
|
144
|
+
* Fires ONCE per crossing, not on every prompt above the threshold. A reminder
|
|
145
|
+
* costs tokens on the very prompt it appears in, so a context-efficiency tool
|
|
146
|
+
* that nagged every turn would be spending the thing it claims to save.
|
|
147
|
+
*/
|
|
148
|
+
export function decide(prev, total, threshold) {
|
|
149
|
+
let fired = prev?.fired === true;
|
|
150
|
+
// A large drop means the context was compacted/cleared: arm again so the
|
|
151
|
+
// NEXT time it grows past the threshold the user hears about it.
|
|
152
|
+
if (prev && typeof prev.last === 'number' && total < prev.last * REARM_DROP_RATIO) fired = false;
|
|
153
|
+
|
|
154
|
+
const remind = !fired && total >= threshold;
|
|
155
|
+
return { remind, next: { last: total, fired: fired || remind, ts: Date.now() } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function reminderText(total) {
|
|
159
|
+
const k = Math.round(total / 1000);
|
|
160
|
+
return [
|
|
161
|
+
`[tune-context] This session is now carrying ~${k}k tokens of context, and every further turn re-sends all of it.`,
|
|
162
|
+
'If the current work thread is finished: suggest /compact (or /clear when switching to unrelated work).',
|
|
163
|
+
'If you are mid-task: say nothing now and raise it when the thread closes.',
|
|
164
|
+
'The PreCompact hook preserves branch, dirty files and recent commits across a compaction — judgment calls and open threads are NOT preserved, so write those down first.',
|
|
165
|
+
].join(' ');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Never fails a prompt: this runs on EVERY UserPromptSubmit, so a broken or
|
|
169
|
+
// unreadable transcript, an unwritable state dir or a malformed payload must
|
|
170
|
+
// cost the user nothing. Same contract as adoption-log.mjs and (since
|
|
171
|
+
// TASK-076) pre-compact-snapshot.mjs. Silence is the correct failure mode —
|
|
172
|
+
// emitting nothing simply means no reminder this turn.
|
|
173
|
+
function main() {
|
|
174
|
+
try {
|
|
175
|
+
const input = readStdin();
|
|
176
|
+
const transcriptPath = input.transcript_path;
|
|
177
|
+
if (!transcriptPath) return;
|
|
178
|
+
|
|
179
|
+
const total = latestContextTokens(readTail(transcriptPath));
|
|
180
|
+
if (total === null) return;
|
|
181
|
+
|
|
182
|
+
const session = input.session_id || 'unknown';
|
|
183
|
+
const path = stateFile();
|
|
184
|
+
const state = readState(path);
|
|
185
|
+
const { remind, next } = decide(state[session], total, thresholdTokens());
|
|
186
|
+
|
|
187
|
+
state[session] = next;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
190
|
+
writeFileSync(path, JSON.stringify(pruneState(state, Date.now())));
|
|
191
|
+
} catch {
|
|
192
|
+
// Unwritable state only costs us the once-per-crossing memory; the
|
|
193
|
+
// reminder itself is still correct for this turn.
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!remind) return;
|
|
197
|
+
process.stdout.write(
|
|
198
|
+
JSON.stringify({
|
|
199
|
+
hookSpecificOutput: {
|
|
200
|
+
hookEventName: 'UserPromptSubmit',
|
|
201
|
+
additionalContext: reminderText(total),
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
// non-fatal by design — never block a prompt over a reminder
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (process.argv[1] && process.argv[1].endsWith('context-reminder.mjs')) {
|
|
211
|
+
main();
|
|
212
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
// capture what the filesystem/git already know (branch, dirty files, recent
|
|
10
10
|
// commits) — not decisions or open threads from the conversation. It is a
|
|
11
11
|
// safety net against losing that mechanical state, not a summary.
|
|
12
|
+
//
|
|
13
|
+
// Claude Code also has a PostCompact event. Staying on PreCompact +
|
|
14
|
+
// SessionStart(compact) is deliberate, not an oversight (TASK-078): this pair
|
|
15
|
+
// is what TASK-056/061 proved out with a smoke-tested round trip, and nothing
|
|
16
|
+
// since has needed PostCompact's different timing.
|
|
12
17
|
import { execFileSync } from 'node:child_process';
|
|
13
|
-
import {
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
14
20
|
import { join } from 'node:path';
|
|
15
21
|
import { claudeBaseDir } from './config-dir.mjs';
|
|
16
22
|
|
|
@@ -34,9 +40,50 @@ function git(cwd, args) {
|
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
// Hash suffix makes this injective. The slug alone collapses distinct paths
|
|
44
|
+
// that normalize the same way (/home/a/b and /home/a-b both slug to
|
|
45
|
+
// "home-a-b"), which let two projects share one file — and SessionStart
|
|
46
|
+
// (compact) would reinject one project's git state into an unrelated
|
|
47
|
+
// session's compaction (TASK-076). Slug stays first for human readability;
|
|
48
|
+
// the hash is what actually prevents the collision. Renaming the file shape
|
|
49
|
+
// orphans pre-existing snapshots — acceptable, they're one-compaction-lived —
|
|
50
|
+
// noted in CHANGELOG per the task.
|
|
37
51
|
export function stateFilePath(cwd) {
|
|
38
52
|
const safe = cwd.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
39
|
-
|
|
53
|
+
const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 8);
|
|
54
|
+
return join(claudeBaseDir(), 'tune-context', 'state', `${safe}-${hash}.md`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// PO decision 2026-07-25 (TASK-076): age-based, 30 days. One file per distinct
|
|
58
|
+
// cwd (stateFilePath above) means every worktree or temp checkout a user ever
|
|
59
|
+
// compacted in gets a permanent file, not just abandoned projects — and this
|
|
60
|
+
// file is rewritten on every compaction of a still-active project, so mtime
|
|
61
|
+
// age is an exact recency signal, not a proxy.
|
|
62
|
+
const MAX_SNAPSHOT_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
// Self-contained like git() above: never throws, so a missing/unreadable
|
|
65
|
+
// state/ directory or an unremovable file can't take the write down with it.
|
|
66
|
+
// `keep` is excluded from consideration outright, not just by age, so the
|
|
67
|
+
// snapshot this run just wrote can never be pruned regardless of mtime/clock
|
|
68
|
+
// edge cases (no reliance on "the fresh write's mtime beats the cutoff").
|
|
69
|
+
export function pruneStateDir(dir, keep) {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const cutoff = Date.now() - MAX_SNAPSHOT_AGE_MS;
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.isFile()) continue;
|
|
79
|
+
const path = join(dir, entry.name);
|
|
80
|
+
if (path === keep) continue;
|
|
81
|
+
try {
|
|
82
|
+
if (statSync(path).mtimeMs < cutoff) unlinkSync(path);
|
|
83
|
+
} catch {
|
|
84
|
+
// gone already, or unremovable — not this hook's problem
|
|
85
|
+
}
|
|
86
|
+
}
|
|
40
87
|
}
|
|
41
88
|
|
|
42
89
|
export function buildSnapshot(input) {
|
|
@@ -61,11 +108,26 @@ export function buildSnapshot(input) {
|
|
|
61
108
|
return lines.join('\n') + '\n';
|
|
62
109
|
}
|
|
63
110
|
|
|
111
|
+
// Never fails the host tool call: this snapshot is a safety net for a
|
|
112
|
+
// compaction, not a critical feature, so its own failure can't be louder than
|
|
113
|
+
// the context loss it guards against. An unwritable state dir (container,
|
|
114
|
+
// read-only volume, misdirected CLAUDE_CONFIG_DIR) must not surface as a
|
|
115
|
+
// nonzero exit at the exact moment the session is under context pressure.
|
|
116
|
+
// Deliberately no console.error — hooks don't write to stdout/stderr
|
|
117
|
+
// (transcript noise) — a silent safety-net failure is cheaper than a loud
|
|
118
|
+
// one. Mirrors hooks/adoption-log.mjs's main(), which this file previously
|
|
119
|
+
// lacked parity with (TASK-076).
|
|
64
120
|
function main() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
try {
|
|
122
|
+
const input = readStdin();
|
|
123
|
+
const cwd = input.cwd || process.cwd();
|
|
124
|
+
const path = stateFilePath(cwd);
|
|
125
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
126
|
+
writeFileSync(path, buildSnapshot(input));
|
|
127
|
+
pruneStateDir(join(path, '..'), path);
|
|
128
|
+
} catch {
|
|
129
|
+
// non-fatal by design — never block a compaction over a snapshot write
|
|
130
|
+
}
|
|
69
131
|
}
|
|
70
132
|
|
|
71
133
|
if (process.argv[1] && process.argv[1].endsWith('pre-compact-snapshot.mjs')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bglocation/tune-context",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Context-efficiency configurator for Claude Code — token-saving doctrine packaged as skills, plus a tune-context configurator that scaffolds a lean CLAUDE.md, doctrine skills, subagents, settings, hooks and MCP wiring.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,9 @@ comments in the domain vocabulary someone would *search* with — better contrac
|
|
|
45
45
|
## 6. Measure, don't believe
|
|
46
46
|
If the tool logs usage (`.rag/usage.jsonl` → `rag-usage`), review it
|
|
47
47
|
periodically: is semantic search actually replacing grep? Promote or drop levers
|
|
48
|
-
on data, not faith.
|
|
48
|
+
on data, not faith. If `tune-context` is set up, `node eval/adoption-report.mjs`
|
|
49
|
+
covers the rest of this doctrine the same way — search lever, skills, subagents,
|
|
50
|
+
re-read churn — as a behavioral proxy (tool calls), not token cost.
|
|
49
51
|
|
|
50
52
|
## In a new project
|
|
51
53
|
Set up semantic search proactively — `npx -p @bglocation/code-search-mcp
|