@bglocation/tune-context 2.3.0 → 2.4.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.
@@ -13,9 +13,11 @@
13
13
  // boundary": a hook is a plain script with no model access, so it cannot know
14
14
  // that a work thread just ended. Size is measurable; a phase boundary is a
15
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.
16
+ // and leaves the timing judgment to the model — the first reminder explicitly
17
+ // tells it to stay quiet mid-task. That deference is finite, though: it is
18
+ // exactly what the LADDER below withdraws rung by rung, because a session that
19
+ // never finds a "natural break" is the one paying the most. The phase-boundary
20
+ // half is the `phase-workflow` skill's job, model-side, where judgment lives.
19
21
  //
20
22
  // The number is real, not a proxy: Claude Code records per-request `usage` in
21
23
  // the transcript, and input_tokens + cache_read_input_tokens +
@@ -137,43 +139,93 @@ function pruneState(state, now) {
137
139
  return state;
138
140
  }
139
141
 
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
+ // The notice ladder, as multipliers of the threshold: 120k / 180k / 240k by
143
+ // default (TASK-080 added the second rung, TASK-084 the middle one).
142
144
  //
143
145
  // The problem it solves is measured, not hypothetical. Fire-once is consumed
144
146
  // when the model reads the reminder, and the reminder itself tells the model to
145
147
  // stay quiet mid-task — correctly, since only it can see whether a refactor is
146
148
  // half-finished. In session c9329adb that combination cost the user everything
147
149
  // between ~166k and ~346k: one firing, silently absorbed, and no re-arm until a
148
- // compaction that nobody had suggested.
150
+ // compaction that nobody had suggested. Each rung halves the worst-case blind
151
+ // spot: one rung left 120k of silence, two left 120k above the threshold, three
152
+ // leave 60k.
149
153
  //
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;
154
+ // Why a fixed ladder and not "every +N tokens": periodic nagging would break the
155
+ // rule this hook lives under — it is the only hook that ADDS tokens to the
156
+ // context it exists to shrink. Three notices per cycle is O(1) and measurable:
157
+ // the text is ~600 chars ≈ 150 tokens, so the third rung costs ~0.1% of the
158
+ // context it appears in. An unbounded series is the feature eating itself; a
159
+ // bounded rung is not. (PO decision 2026-07-28, TASK-084 — the 1.5x point comes
160
+ // from use: 120k is often too early to close a thread, 240k so far that a
161
+ // session has to be dragged out to reach it.)
162
+ //
163
+ // Multipliers rather than absolute numbers so TUNE_CONTEXT_REMIND_TOKENS scales
164
+ // the whole ladder, as the README promises. A hardcoded 180_000 would fall
165
+ // BELOW the first rung the moment someone raises the threshold for a 1M window.
166
+ //
167
+ // Each rung carries its own wording, in ONE structure, deliberately. The
168
+ // escalation IS that sentence tightening — repeating the level-1 text three
169
+ // times would just be the same coin flipped again, which is what would make a
170
+ // third rung not worth its tokens. Splitting trigger from wording into two
171
+ // parallel structures would let them drift: a fourth multiplier with no fourth
172
+ // sentence falls back to the level-1 text, so the LOUDEST rung would print the
173
+ // SOFTEST wording and nothing would fail. One array makes that unrepresentable.
174
+ //
175
+ // The graded sentence REPLACES the mid-task line rather than riding on top of
176
+ // it, and that is a budget fact rather than taste: the rest of the reminder is
177
+ // 490 chars against a <700 ceiling, and the old append-a-sentence shape had
178
+ // already spent that down to 21 chars of headroom (measured 592 / 679 before
179
+ // TASK-084). Swapping keeps every rung inside budget — 592 / 615 / 606.
180
+ const LADDER = [
181
+ {
182
+ at: 1,
183
+ timing: 'If you are mid-task: stay quiet now, but raise it at the first natural break rather than dropping it.',
184
+ },
185
+ {
186
+ at: 1.5,
187
+ timing:
188
+ 'If you are mid-task: raise it at the next pause anyway — this is no longer early, and silence now costs the user every turn.',
189
+ },
190
+ {
191
+ at: 2,
192
+ timing:
193
+ 'Final automatic notice — say it this turn whatever you are in the middle of; nothing more fires until a compaction.',
194
+ },
195
+ ];
156
196
 
157
- /** How many notices this session has earned at `total`: 0, 1 or 2. */
197
+ /** How many notices this session has earned at `total`: 0..LADDER.length. */
158
198
  function levelFor(total, threshold) {
159
- if (total >= threshold * ESCALATION_MULTIPLIER) return 2;
160
- if (total >= threshold) return 1;
161
- return 0;
199
+ let level = 0;
200
+ for (const rung of LADDER) {
201
+ if (total >= threshold * rung.at) level++;
202
+ }
203
+ return level;
162
204
  }
163
205
 
164
206
  /**
165
207
  * Pure decision: given the previous reading for this session and the current
166
208
  * one, should a reminder fire, and what is the session's next state?
167
209
  *
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.
210
+ * Fires at most once per rung of LEVEL_MULTIPLIERS, never on every prompt above
211
+ * the line. A reminder costs tokens on the very prompt it appears in, so a
212
+ * context-efficiency tool that nagged every turn would be spending the thing it
213
+ * claims to save.
214
+ *
215
+ * `level` rides out with the decision because the TEXT is graded by it — see
216
+ * reminderText(). Crossing several rungs in one turn (a 100k tool result) fires
217
+ * once, at the rung actually reached: no level is owed retroactively.
172
218
  */
173
219
  export function decide(prev, total, threshold) {
174
220
  // `fired: true` is how state written before TASK-080 recorded a single
175
221
  // firing; read it as level 1 so an in-flight session whose hook is upgraded
176
222
  // mid-run is not re-notified from scratch.
223
+ //
224
+ // A pre-TASK-084 `levels: 2` means "crossed 2x", which on the new ladder is
225
+ // level 3 — so a session live across that upgrade earns exactly ONE extra
226
+ // notice, then goes quiet for good. Accepted rather than fixed: telling old-2
227
+ // from new-2 needs a ladder version in the state, a permanent cost for one
228
+ // notice in sessions that happen to span an upgrade.
177
229
  let reached = typeof prev?.levels === 'number' ? prev.levels : prev?.fired === true ? 1 : 0;
178
230
  // A large drop means the context was compacted/cleared: arm again so the
179
231
  // NEXT time it grows past the threshold the user hears about it.
@@ -181,7 +233,7 @@ export function decide(prev, total, threshold) {
181
233
 
182
234
  const level = levelFor(total, threshold);
183
235
  const remind = level > reached;
184
- return { remind, escalated: remind && level === 2, next: { last: total, levels: Math.max(reached, level), ts: Date.now() } };
236
+ return { remind, level, next: { last: total, levels: Math.max(reached, level), ts: Date.now() } };
185
237
  }
186
238
 
187
239
  /**
@@ -207,11 +259,15 @@ export function userNotice(total) {
207
259
 
208
260
  /**
209
261
  * 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.
262
+ * relays it. The timing judgement is the model's on the FIRST rung, because only
263
+ * it can tell whether the current thread is finished — but that exemption is
264
+ * what LADDER spends: by the last rung the text asks it to speak regardless,
265
+ * since staying silent forever means the user never learns at all.
266
+ *
267
+ * `level` defaults to 1, so the level-1 text is byte-identical to every release
268
+ * since TASK-079 and a caller that does not care about rungs keeps working.
213
269
  */
214
- export function reminderText(total, { escalated = false } = {}) {
270
+ export function reminderText(total, { level = 1 } = {}) {
215
271
  const k = Math.round(total / 1000);
216
272
  return [
217
273
  `[tune-context] This session is now carrying ~${k}k tokens of context, and every further turn re-sends all of it.`,
@@ -220,11 +276,10 @@ export function reminderText(total, { escalated = false } = {}) {
220
276
  // heard about the cost at ~346k.
221
277
  'The user CANNOT see this reminder — it reaches you alone, so if you never mention it they will never know.',
222
278
  'If the current work thread is finished: suggest /compact (or /clear when switching to unrelated work).',
223
- 'If you are mid-task: stay quiet now, but raise it at the first natural break rather than dropping it.',
279
+ // Position matters: the graded sentence stays 4th of 5, so level 1 is
280
+ // unchanged from what shipped.
281
+ (LADDER[level - 1] ?? LADDER[0]).timing,
224
282
  '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.'] : []),
228
283
  ].join(' ');
229
284
  }
230
285
 
@@ -245,7 +300,7 @@ function main() {
245
300
  const session = input.session_id || 'unknown';
246
301
  const path = stateFile();
247
302
  const state = readState(path);
248
- const { remind, escalated, next } = decide(state[session], total, thresholdTokens());
303
+ const { remind, level, next } = decide(state[session], total, thresholdTokens());
249
304
 
250
305
  state[session] = next;
251
306
  try {
@@ -264,7 +319,7 @@ function main() {
264
319
  systemMessage: userNotice(total),
265
320
  hookSpecificOutput: {
266
321
  hookEventName: 'UserPromptSubmit',
267
- additionalContext: reminderText(total, { escalated }),
322
+ additionalContext: reminderText(total, { level }),
268
323
  },
269
324
  }),
270
325
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bglocation/tune-context",
3
- "version": "2.3.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.",
3
+ "version": "2.4.0",
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",
7
7
  "author": "Szymon Walczak",
@@ -166,10 +166,14 @@ covering the detected build/test/lint commands.
166
166
  It reads the **tail** of the session transcript (`transcript_path`, present on
167
167
  every hook payload), sums `input_tokens + cache_read + cache_creation` — the
168
168
  context actually re-sent — and once that crosses a PO-frozen threshold
169
- (120k, override `TUNE_CONTEXT_REMIND_TOKENS`) injects one short reminder to
170
- consider `/compact`. Fires **once per crossing**, re-arming only after a
171
- compaction-sized drop: the reminder costs tokens on the prompt it appears in,
172
- so nagging every turn would spend exactly what the tool saves. It reminds on
169
+ (120k, override `TUNE_CONTEXT_REMIND_TOKENS`) injects a short reminder to
170
+ consider `/compact`. Fires at most **three times per crossing**, on a ladder
171
+ of 1×/1.5×/2× the threshold (120k/180k/240k), re-arming only after a
172
+ compaction-sized drop. Each rung tightens the wording — wait for a natural
173
+ break, then raise it at the next pause, then say it this turn — because a
174
+ note absorbed mid-task is otherwise never repeated. Bounded at three on
175
+ purpose: the reminder costs tokens on the prompt it appears in, so nagging
176
+ every turn would spend exactly what the tool saves. It reminds on
173
177
  SIZE, not on "phase boundary" — size is measurable from the transcript, a
174
178
  phase boundary is a judgment a hook (no model access) cannot make, so the
175
179
  reminder hands that timing call to the model and `phase-workflow`.