@bglocation/tune-context 2.0.0 → 2.1.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +82 -0
- package/README.md +13 -7
- package/bin/tune-context.mjs +33 -7
- package/hooks/context-reminder.mjs +77 -11
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bglocation",
|
|
3
|
-
"description": "Context-engineering tooling for Claude Code from bglocation
|
|
3
|
+
"description": "Context-engineering tooling for Claude Code from bglocation \u2014 token-saving doctrine, configurators and semantic-search wiring.",
|
|
4
4
|
"owner": {
|
|
5
5
|
"name": "Szymon Walczak",
|
|
6
6
|
"url": "https://bglocation.dev"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "tune-context",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.1.0",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Szymon Walczak",
|
|
16
16
|
"url": "https://bglocation.dev"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "tune-context",
|
|
3
3
|
"displayName": "tune-context",
|
|
4
4
|
"description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.1.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Szymon Walczak",
|
|
8
8
|
"url": "https://bglocation.dev"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,88 @@ All notable changes to `@bglocation/tune-context` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.1.0] — 2026-07-26
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The context reminder no longer goes silent after a badly-timed crossing.**
|
|
13
|
+
It now fires a second and final time at **twice** the threshold (240k by
|
|
14
|
+
default, or 2× `TUNE_CONTEXT_REMIND_TOKENS`). The reminder tells Claude to stay
|
|
15
|
+
quiet mid-task — correctly, since only the model can see whether a refactor is
|
|
16
|
+
half-finished — but fire-once was consumed the moment the *model* read it, and
|
|
17
|
+
re-arming required a compaction. Measured on one real session: it fired at
|
|
18
|
+
~166k, Claude stayed silent mid-task, and nothing spoke again until ~346k, 2.9×
|
|
19
|
+
the threshold. There is deliberately no third notice: this is the only hook
|
|
20
|
+
that *adds* tokens to the context it exists to shrink, so the cost stays
|
|
21
|
+
bounded at one extra note per cycle.
|
|
22
|
+
- The text sent to the model now states plainly that the user cannot see the
|
|
23
|
+
reminder, so silence means they never learn about it.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- A `systemMessage` alongside the existing `additionalContext`, carrying the size
|
|
28
|
+
and `/compact` in one line. **Best-effort only** — see below.
|
|
29
|
+
|
|
30
|
+
### Notes on measurement
|
|
31
|
+
|
|
32
|
+
The intended fix was to show the user a line directly, removing the model's
|
|
33
|
+
monopoly on whether they hear about a heavy session at all. A two-marker probe
|
|
34
|
+
settled what the docs could not:
|
|
35
|
+
|
|
36
|
+
- `systemMessage` does **not** render for `UserPromptSubmit` (measured in the
|
|
37
|
+
VSCode extension), despite the settings schema promising "Display a message to
|
|
38
|
+
the user (all hooks)". The probe's `additionalContext` marker did arrive, which
|
|
39
|
+
is what makes this a clean negative rather than a probe that failed to load.
|
|
40
|
+
- `systemMessage` does **not** enter the model's context, so it costs no tokens.
|
|
41
|
+
|
|
42
|
+
It is therefore emitted but depended on by nothing, and other clients were not
|
|
43
|
+
measured. The escalation above is what actually closes the gap.
|
|
44
|
+
|
|
45
|
+
The same measurement caught a change that would have made things worse: the
|
|
46
|
+
planned model-facing text said "the user has already been shown this, do not
|
|
47
|
+
repeat it". Shipped unmeasured, it would have suppressed the only channel that
|
|
48
|
+
works.
|
|
49
|
+
|
|
50
|
+
## [2.0.1] — 2026-07-26
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **The npm-installed CLI did nothing at all in 2.0.0.** `npm i -g` puts the
|
|
55
|
+
`bin` entry on PATH as a symlink named `tune-context`, with no extension, but
|
|
56
|
+
2.0.0's entry-point guard tested `process.argv[1].endsWith('tune-context.mjs')`.
|
|
57
|
+
That is false for the symlink, so `main()` never ran: every command —
|
|
58
|
+
`init`, `--version`, `--help` — exited 0 without printing a byte or touching a
|
|
59
|
+
file. Now compares resolved real paths, which holds for the npm symlink, a
|
|
60
|
+
direct `.mjs` invocation, the extensionless `bin/tune-context` wrapper (still
|
|
61
|
+
exactly one run, not two) and `import` from a test.
|
|
62
|
+
- Only the npm channel was affected. Plugin installs call `bin/tune-context`,
|
|
63
|
+
which invokes `main()` itself, and the hooks are always registered by literal
|
|
64
|
+
path — both kept working.
|
|
65
|
+
|
|
66
|
+
### Testing
|
|
67
|
+
|
|
68
|
+
- The suite spawned `.../bin/tune-context.mjs` everywhere, which is the one shape
|
|
69
|
+
no user ever runs; that is why 119 green tests said nothing about a completely
|
|
70
|
+
dead CLI. Added a test that installs the binary the way npm does — through an
|
|
71
|
+
extensionless symlink — and asserts a silent exit 0 is a failure.
|
|
72
|
+
|
|
73
|
+
### Upgrading from 2.0.0 (npm channel)
|
|
74
|
+
|
|
75
|
+
Upgrading is not enough on its own. If you ran `tune-context init` under 2.0.0
|
|
76
|
+
it exited 0 and looked like it worked, but **nothing was installed** — so
|
|
77
|
+
`~/.claude/hooks/context-reminder.mjs` is missing and the `UserPromptSubmit`
|
|
78
|
+
hook was never registered. Re-run it after upgrading:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm i -g @bglocation/tune-context@2.0.1
|
|
82
|
+
tune-context --version # must print 2.0.1 — silence means the upgrade did not take
|
|
83
|
+
tune-context init
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `init` is idempotent and additive: it never removes a hook or permission
|
|
87
|
+
entry, and never touches content outside the managed blocks. Plugin-only
|
|
88
|
+
installs were unaffected and need nothing.
|
|
89
|
+
|
|
8
90
|
## [2.0.0] — 2026-07-25
|
|
9
91
|
|
|
10
92
|
Three hardening epics' worth of work plus the first new feature since 1.0: a
|
package/README.md
CHANGED
|
@@ -170,15 +170,21 @@ supplies the missing measurement: it reads the tail of the session transcript,
|
|
|
170
170
|
sums the tokens actually re-sent, and once that passes **120,000** it injects a
|
|
171
171
|
single short note suggesting `/compact`.
|
|
172
172
|
|
|
173
|
-
- **
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
- **The model picks the moment.** The
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
- **The note goes to Claude, not to your screen.** Hooks can only reach you
|
|
174
|
+
through the model — a `systemMessage` on this event was measured and does not
|
|
175
|
+
render — so what you actually see is Claude bringing it up in its reply.
|
|
176
|
+
- **The model picks the moment.** The note tells Claude to stay quiet mid-task
|
|
177
|
+
and raise it at the first natural break: the hook can measure size, but only
|
|
178
|
+
the model can see whether you're mid-refactor.
|
|
179
|
+
- **Twice per crossing at most.** Once at the threshold, once more at **240,000**
|
|
180
|
+
if the context keeps growing — because a note that lands mid-task can be
|
|
181
|
+
correctly absorbed and then forgotten. Measured on one session: it fired at
|
|
182
|
+
166k, Claude stayed silent, and nothing spoke again until 346k. The second
|
|
183
|
+
notice halves that blind spot; there is deliberately no third.
|
|
179
184
|
- **Raise it for a large context window:** `export TUNE_CONTEXT_REMIND_TOKENS=400000`.
|
|
180
185
|
120k is ~60% of a standard 200k window; the transcript doesn't record the window
|
|
181
|
-
size, so this can't be auto-scaled.
|
|
186
|
+
size, so this can't be auto-scaled. Both levels scale with the override — the
|
|
187
|
+
second notice is always at twice the threshold.
|
|
182
188
|
- **Turn it off:** delete the `UserPromptSubmit` entry from `settings.json`.
|
|
183
189
|
|
|
184
190
|
Depth lives in the `phase-workflow` and `token-efficiency` skills (both installed
|
package/bin/tune-context.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// "No model in the loop" is the feature here, not a gap — but the gap has to be
|
|
17
17
|
// stated, or a CLI user cannot know what they did not get. Run the skill
|
|
18
18
|
// (/tune-context) for the full pass.
|
|
19
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { claudeDirs, detectExistingConfig, detectMcpServers, detectRejectedMcpServers, detectStack } from '../cli/detect.mjs';
|
|
@@ -173,11 +173,37 @@ export function main() {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
/**
|
|
177
|
+
* True only when THIS file is the process entry point, so importing it for
|
|
178
|
+
* parseArgs/main (tests, or the extensionless bin/tune-context wrapper) never
|
|
179
|
+
* runs the CLI as a side effect of the import.
|
|
180
|
+
*
|
|
181
|
+
* Deliberately NOT a filename-suffix test on argv[1]. npm installs the
|
|
182
|
+
* package.json `bin` entry as a symlink named `tune-context` — no extension —
|
|
183
|
+
* so `argv[1].endsWith('tune-context.mjs')` was false for every real npm-channel
|
|
184
|
+
* invocation: main() never ran and the CLI exited 0 in total silence. That
|
|
185
|
+
* shipped in 2.0.0 (see the symlink test in test/bin-tune-context.test.mjs);
|
|
186
|
+
* the whole suite missed it because tests and smoke:init both spawn the full
|
|
187
|
+
* `.../bin/tune-context.mjs` path, which is the one shape a user never uses.
|
|
188
|
+
*
|
|
189
|
+
* Comparing resolved real paths holds for all four shapes: direct `.mjs`
|
|
190
|
+
* invocation, the npm symlink (resolves THROUGH the link to this file), the
|
|
191
|
+
* extensionless wrapper (a different real path, so this stays false and the
|
|
192
|
+
* wrapper's own main() call is the single entry — no double run), and `import`
|
|
193
|
+
* from a test (argv[1] is the test runner). realpathSync on both sides also
|
|
194
|
+
* survives a symlinked package dir, as `npm link` and nvm both produce.
|
|
195
|
+
*/
|
|
196
|
+
function isEntryPoint() {
|
|
197
|
+
if (!process.argv[1]) return false;
|
|
198
|
+
try {
|
|
199
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
200
|
+
} catch {
|
|
201
|
+
// argv[1] can name something unresolvable (deleted, or a bare `-`); a CLI
|
|
202
|
+
// that cannot identify its own entry point must stay silent, not crash.
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isEntryPoint()) {
|
|
182
208
|
main();
|
|
183
209
|
}
|
|
@@ -137,31 +137,94 @@ function pruneState(state, now) {
|
|
|
137
137
|
return state;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// TASK-080 fallback, chosen by the PO after measurement killed the preferred
|
|
141
|
+
// design (see userNotice()): a SECOND and final notice at twice the threshold.
|
|
142
|
+
//
|
|
143
|
+
// The problem it solves is measured, not hypothetical. Fire-once is consumed
|
|
144
|
+
// when the model reads the reminder, and the reminder itself tells the model to
|
|
145
|
+
// stay quiet mid-task — correctly, since only it can see whether a refactor is
|
|
146
|
+
// half-finished. In session c9329adb that combination cost the user everything
|
|
147
|
+
// between ~166k and ~346k: one firing, silently absorbed, and no re-arm until a
|
|
148
|
+
// compaction that nobody had suggested.
|
|
149
|
+
//
|
|
150
|
+
// Why 2x and not "every +N tokens": periodic nagging would break the rule this
|
|
151
|
+
// hook lives under — it is the only hook that ADDS tokens to the context it
|
|
152
|
+
// exists to shrink. One extra notice per cycle is a bounded, one-off cost that
|
|
153
|
+
// halves the worst-case blind spot; an unbounded series is the feature eating
|
|
154
|
+
// itself. Deliberately NOT a third level.
|
|
155
|
+
const ESCALATION_MULTIPLIER = 2;
|
|
156
|
+
|
|
157
|
+
/** How many notices this session has earned at `total`: 0, 1 or 2. */
|
|
158
|
+
function levelFor(total, threshold) {
|
|
159
|
+
if (total >= threshold * ESCALATION_MULTIPLIER) return 2;
|
|
160
|
+
if (total >= threshold) return 1;
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
140
164
|
/**
|
|
141
165
|
* Pure decision: given the previous reading for this session and the current
|
|
142
166
|
* one, should a reminder fire, and what is the session's next state?
|
|
143
167
|
*
|
|
144
|
-
* Fires
|
|
145
|
-
* costs tokens on the very prompt it
|
|
146
|
-
* that nagged every turn would be
|
|
168
|
+
* Fires at most TWICE per crossing (threshold, then 2x threshold), never on
|
|
169
|
+
* every prompt above the line. A reminder costs tokens on the very prompt it
|
|
170
|
+
* appears in, so a context-efficiency tool that nagged every turn would be
|
|
171
|
+
* spending the thing it claims to save.
|
|
147
172
|
*/
|
|
148
173
|
export function decide(prev, total, threshold) {
|
|
149
|
-
|
|
174
|
+
// `fired: true` is how state written before TASK-080 recorded a single
|
|
175
|
+
// firing; read it as level 1 so an in-flight session whose hook is upgraded
|
|
176
|
+
// mid-run is not re-notified from scratch.
|
|
177
|
+
let reached = typeof prev?.levels === 'number' ? prev.levels : prev?.fired === true ? 1 : 0;
|
|
150
178
|
// A large drop means the context was compacted/cleared: arm again so the
|
|
151
179
|
// 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)
|
|
180
|
+
if (prev && typeof prev.last === 'number' && total < prev.last * REARM_DROP_RATIO) reached = 0;
|
|
181
|
+
|
|
182
|
+
const level = levelFor(total, threshold);
|
|
183
|
+
const remind = level > reached;
|
|
184
|
+
return { remind, escalated: remind && level === 2, next: { last: total, levels: Math.max(reached, level), ts: Date.now() } };
|
|
185
|
+
}
|
|
153
186
|
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
/**
|
|
188
|
+
* BEST-EFFORT user-facing channel (`systemMessage`). Emitted because it is free
|
|
189
|
+
* and correct per the hook schema — NOT because it is known to be shown.
|
|
190
|
+
*
|
|
191
|
+
* Measured 2026-07-26, TASK-080, with a two-marker probe: `systemMessage` does
|
|
192
|
+
* not reach the model's context (so it costs no tokens), and it did not appear
|
|
193
|
+
* on screen either, in the VSCode extension, for a UserPromptSubmit hook. The
|
|
194
|
+
* same probe's `additionalContext` marker arrived, which is what makes that a
|
|
195
|
+
* clean negative rather than a probe that failed to load. Other clients were
|
|
196
|
+
* not measured, so this stays: a line that costs nothing and may render
|
|
197
|
+
* somewhere else. **Nothing may depend on it** — in particular reminderText()
|
|
198
|
+
* must never tell the model the user has already seen the number.
|
|
199
|
+
*
|
|
200
|
+
* One sentence, deliberately: the size is the one fact a user cannot get
|
|
201
|
+
* anywhere else, and /compact is the one action.
|
|
202
|
+
*/
|
|
203
|
+
export function userNotice(total) {
|
|
204
|
+
const k = Math.round(total / 1000);
|
|
205
|
+
return `[tune-context] Context is at ~${k}k tokens — consider /compact when the current thread closes.`;
|
|
156
206
|
}
|
|
157
207
|
|
|
158
|
-
|
|
208
|
+
/**
|
|
209
|
+
* The channel that actually works (`additionalContext`): the model reads it and
|
|
210
|
+
* relays it. The timing judgement stays here, because only the model can tell
|
|
211
|
+
* whether the current thread is finished — but see the second sentence: it must
|
|
212
|
+
* relay eventually, because staying silent forever means the user never learns.
|
|
213
|
+
*/
|
|
214
|
+
export function reminderText(total, { escalated = false } = {}) {
|
|
159
215
|
const k = Math.round(total / 1000);
|
|
160
216
|
return [
|
|
161
217
|
`[tune-context] This session is now carrying ~${k}k tokens of context, and every further turn re-sends all of it.`,
|
|
218
|
+
// The measured failure this sentence exists to prevent: at ~166k the model
|
|
219
|
+
// stayed quiet mid-task and simply never came back to it, so the user first
|
|
220
|
+
// heard about the cost at ~346k.
|
|
221
|
+
'The user CANNOT see this reminder — it reaches you alone, so if you never mention it they will never know.',
|
|
162
222
|
'If the current work thread is finished: suggest /compact (or /clear when switching to unrelated work).',
|
|
163
|
-
'If you are mid-task:
|
|
223
|
+
'If you are mid-task: stay quiet now, but raise it at the first natural break rather than dropping it.',
|
|
164
224
|
'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.',
|
|
225
|
+
// Kept short on purpose: it rides ON TOP of the full reminder, inside the
|
|
226
|
+
// same <700 char budget.
|
|
227
|
+
...(escalated ? ['This is the second and final automatic notice — nothing more fires until a compaction.'] : []),
|
|
165
228
|
].join(' ');
|
|
166
229
|
}
|
|
167
230
|
|
|
@@ -182,7 +245,7 @@ function main() {
|
|
|
182
245
|
const session = input.session_id || 'unknown';
|
|
183
246
|
const path = stateFile();
|
|
184
247
|
const state = readState(path);
|
|
185
|
-
const { remind, next } = decide(state[session], total, thresholdTokens());
|
|
248
|
+
const { remind, escalated, next } = decide(state[session], total, thresholdTokens());
|
|
186
249
|
|
|
187
250
|
state[session] = next;
|
|
188
251
|
try {
|
|
@@ -194,11 +257,14 @@ function main() {
|
|
|
194
257
|
}
|
|
195
258
|
|
|
196
259
|
if (!remind) return;
|
|
260
|
+
// additionalContext is the channel that is known to arrive; systemMessage
|
|
261
|
+
// is emitted alongside it as a free best-effort (see userNotice()).
|
|
197
262
|
process.stdout.write(
|
|
198
263
|
JSON.stringify({
|
|
264
|
+
systemMessage: userNotice(total),
|
|
199
265
|
hookSpecificOutput: {
|
|
200
266
|
hookEventName: 'UserPromptSubmit',
|
|
201
|
-
additionalContext: reminderText(total),
|
|
267
|
+
additionalContext: reminderText(total, { escalated }),
|
|
202
268
|
},
|
|
203
269
|
}),
|
|
204
270
|
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bglocation/tune-context",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Context-efficiency configurator for Claude Code
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Context-efficiency configurator for Claude Code \u2014 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",
|
|
7
7
|
"author": "Szymon Walczak",
|