claude-token-saver 3.4.2 → 3.5.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/bin/cli.js CHANGED
@@ -400,6 +400,7 @@ async function main() {
400
400
  const r = installAll({ force });
401
401
  print('skill', r.skill);
402
402
  print('SessionStart hook (route-scan)', r.sessionStartHook);
403
+ print('UserPromptSubmit hook (brief)', r.briefHook);
403
404
  {
404
405
  const s = r.statusline;
405
406
  const verb = s.action === 'exists' ? 'already configured (refreshInterval=5)'
@@ -492,6 +493,23 @@ async function main() {
492
493
  // claude-token-saver route-scan dismiss <N> # mute candidate R<N>
493
494
  // Promote a candidate to a ratchet rule (scope is always explicit):
494
495
  // claude-token-saver harness promote R<N> --project|--global
496
+ // brief --hook — UserPromptSubmit hook mode: per-session, change-triggered
497
+ // briefing of state the statusline can only chip (ctx tier crossings,
498
+ // mid-session route/rule-health changes). Silent when nothing changed.
499
+ if (args[0] === 'brief') {
500
+ if (!hasFlag('--hook')) {
501
+ console.error('Usage: claude-token-saver brief --hook (UserPromptSubmit hook mode)');
502
+ process.exit(1);
503
+ }
504
+ const ctx = readStdinJson() || {};
505
+ try {
506
+ const { runBrief } = await import('../src/brief.js');
507
+ const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path });
508
+ if (out) console.log(out);
509
+ } catch { /* briefing is best-effort — never block a prompt */ }
510
+ return;
511
+ }
512
+
495
513
  if (args[0] === 'route-scan') {
496
514
  const rs = await import('../src/route-scan.js');
497
515
  const { userLanguage } = await import('../src/config.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.4.2",
3
+ "version": "3.5.0",
4
4
  "description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/brief.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * brief — UserPromptSubmit hook: per-session, change-triggered briefing.
3
+ *
4
+ * The statusline can only show chips (`ctx 82%`, `route? R1`, `rule-health`),
5
+ * and the model cannot see the statusline at all — so a mid-session state
6
+ * change is invisible to the conversation unless a hook injects it. This
7
+ * module runs on every prompt submit, compares the CURRENT session's state
8
+ * against what was already briefed for that session, and emits a short
9
+ * briefing instruction only when something NEW crossed a threshold. No
10
+ * change → completely silent (zero context cost).
11
+ *
12
+ * Per-session by design (user requirement): context size is a property of
13
+ * one session's transcript, so both the measurement (from this session's
14
+ * transcript_path) and the "already briefed" markers are keyed by session_id.
15
+ *
16
+ * State: <stateDir>/brief-state.json
17
+ * { sessions: { [session_id]: { ts, ctxTier, seeded, briefed: [signature] } } }
18
+ * Sessions untouched for 7 days are pruned on every write.
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+
25
+ // Context tiers as a fraction of the session's context window. Tier 1 warns
26
+ // (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
27
+ // ever hears about each tier once, and only on upward crossings.
28
+ export const CTX_TIERS = [
29
+ { tier: 1, pct: 0.8 },
30
+ { tier: 2, pct: 0.95 },
31
+ ];
32
+ // Requests above this input size can only exist on a 1M window.
33
+ const WINDOW_1M_MIN_INPUT = 250_000;
34
+ const PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
35
+ const TAIL_BYTES = 256 * 1024;
36
+
37
+ function stateDir() {
38
+ if (process.platform === 'win32') {
39
+ return join(process.env.APPDATA || homedir(), 'claude-token-saver');
40
+ }
41
+ if (process.platform === 'darwin') {
42
+ return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
43
+ }
44
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
45
+ return join(xdg, 'claude-token-saver');
46
+ }
47
+
48
+ export function briefStatePath() {
49
+ return join(stateDir(), 'brief-state.json');
50
+ }
51
+
52
+ function loadState() {
53
+ try {
54
+ const s = JSON.parse(readFileSync(briefStatePath(), 'utf8'));
55
+ return s && typeof s.sessions === 'object' ? s : { sessions: {} };
56
+ } catch {
57
+ return { sessions: {} };
58
+ }
59
+ }
60
+
61
+ function saveState(state, now) {
62
+ for (const [id, s] of Object.entries(state.sessions)) {
63
+ if (!s?.ts || now - s.ts > PRUNE_MS) delete state.sessions[id];
64
+ }
65
+ const dir = stateDir();
66
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
67
+ writeFileSync(briefStatePath(), JSON.stringify(state) + '\n');
68
+ }
69
+
70
+ /**
71
+ * Last request's input size for THIS session, from the transcript tail.
72
+ * Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
73
+ */
74
+ export function sessionCtx(transcriptPath) {
75
+ let size;
76
+ try { size = statSync(transcriptPath).size; } catch { return null; }
77
+ const start = Math.max(0, size - TAIL_BYTES);
78
+ const buf = Buffer.alloc(size - start);
79
+ let fd;
80
+ try {
81
+ fd = openSync(transcriptPath, 'r');
82
+ readSync(fd, buf, 0, buf.length, start);
83
+ } catch {
84
+ return null;
85
+ } finally {
86
+ if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ }
87
+ }
88
+ const lines = buf.toString('utf8').split('\n');
89
+ let input = null;
90
+ let maxInput = 0;
91
+ for (const line of lines) {
92
+ if (!line.includes('"usage"')) continue;
93
+ let e; try { e = JSON.parse(line); } catch { continue; }
94
+ const u = e?.message?.usage;
95
+ if (!u) continue;
96
+ const total = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
97
+ if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
98
+ }
99
+ if (input == null) return null;
100
+ const window = maxInput > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
101
+ return { input, window, pct: input / window };
102
+ }
103
+
104
+ function ctxTierOf(pct) {
105
+ let t = 0;
106
+ for (const { tier, pct: p } of CTX_TIERS) if (pct >= p) t = tier;
107
+ return t;
108
+ }
109
+
110
+ const fmtK = (n) => `${Math.round(n / 1000)}k`;
111
+
112
+ /**
113
+ * Compute the briefing for one prompt-submit event. Returns the text to
114
+ * inject, or null when nothing new happened. Mutates + persists state.
115
+ *
116
+ * On a session's FIRST event, route/rule-health signatures are seeded as
117
+ * already-briefed WITHOUT emitting them — the SessionStart hook covered the
118
+ * session-start snapshot; this hook only owns what changes mid-session.
119
+ * Context tiers are NOT seeded: a session that starts (or resumes) already
120
+ * past a threshold still deserves the warning once.
121
+ */
122
+ export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
123
+ if (!sessionId) return null;
124
+ const state = loadState();
125
+ const s = state.sessions[sessionId] || { ctxTier: 0, seeded: false, briefed: [] };
126
+ const items = [];
127
+
128
+ // ── context tier crossing (per-session) ──
129
+ const ctx = transcriptPath ? sessionCtx(transcriptPath) : null;
130
+ if (ctx) {
131
+ const tier = ctxTierOf(ctx.pct);
132
+ if (tier > (s.ctxTier || 0)) {
133
+ const winLabel = ctx.window >= 1_000_000 ? '1M' : '200k';
134
+ items.push(tier === 2
135
+ ? `이 세션의 컨텍스트가 ${winLabel} 창의 95%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 곧 자동 압축으로 맥락 손실이 생길 수 있으니, 진행 중인 작업을 일단락하고 새 세션을 시작하는 편이 좋습니다.`
136
+ : `이 세션의 컨텍스트가 ${winLabel} 창의 80%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 이후 요청은 비용이 커지는 구간입니다 — 작업이 일단락되면 새 세션 시작을 권합니다.`);
137
+ s.ctxTier = tier;
138
+ }
139
+ }
140
+
141
+ // ── mid-session route-scan / rule-health changes (global state, briefed
142
+ // at most once per session per signature) ──
143
+ try {
144
+ const rs = await import('./route-scan.js');
145
+ const mr = await import('./model-rules.js');
146
+ const briefed = new Set(s.briefed || []);
147
+ const fresh = [];
148
+ for (const c of rs.openCandidates(rs.readRouteScan())) {
149
+ const sig = `route|${c.signature}`;
150
+ if (briefed.has(sig)) continue;
151
+ briefed.add(sig);
152
+ fresh.push(['route', c]);
153
+ }
154
+ for (const r of mr.loadModelRules().rules) {
155
+ if (r.status !== 'review') continue;
156
+ const sig = `health|${r.signature}|${r.scope}`;
157
+ if (briefed.has(sig)) continue;
158
+ briefed.add(sig);
159
+ fresh.push(['health', r]);
160
+ }
161
+ if (s.seeded) {
162
+ for (const [kind, x] of fresh) {
163
+ items.push(kind === 'route'
164
+ ? `새 위임 후보가 감지되었습니다 — "${x.label}" 유형 ${x.count}회 반복(statusline의 route? R${x.id} 칩). 등록: claude-token-saver harness promote R${x.id} --project|--global (적용 범위는 사용자에게 확인) / 무시: route-scan dismiss ${x.id}`
165
+ : `승인된 위임 룰의 최근 에러율이 기준(20%)을 넘었습니다 — "${x.label}" (statusline의 rule-health 칩). 조건 좁히기/제거를 사용자와 상의하세요: claude-token-saver route-scan rules`);
166
+ }
167
+ }
168
+ // First event: session-start snapshot is SessionStart's job — swallow it.
169
+ s.briefed = [...briefed];
170
+ s.seeded = true;
171
+ } catch { /* caches unreadable — ctx briefing above still applies */ }
172
+
173
+ s.ts = now;
174
+ state.sessions[sessionId] = s;
175
+ saveState(state, now);
176
+
177
+ if (items.length === 0) return null;
178
+ const lines = [
179
+ '[claude-token-saver 브리핑] 아래 상태 변화를 사용자에게 알려주세요. 진행 중인 답변 흐름을 끊지 말고, 답변 말미에 `※ [claude-token-saver]` 라벨을 달아 각 항목을 1~2줄로 요약해 전달하면 됩니다 (이 브리핑은 항목당 한 번만 주입됩니다):',
180
+ ];
181
+ for (const it of items) lines.push(`- ${it}`);
182
+ return lines.join('\n');
183
+ }
package/src/installer.js CHANGED
@@ -238,11 +238,48 @@ export function installSessionStartHook() {
238
238
  return { path: file, action: 'created' };
239
239
  }
240
240
 
241
+ // Registers the UserPromptSubmit hook that briefs mid-session state changes
242
+ // (ctx tier crossings, new route candidates, rule-health flips) into the
243
+ // conversation — the model cannot see statusline chips, so without this a
244
+ // change that happens mid-session goes unexplained until the user asks.
245
+ // Silent (no output, zero context cost) when nothing changed. Idempotent.
246
+ const BRIEF_HOOK_COMMAND = 'claude-token-saver brief --hook';
247
+
248
+ export function installBriefHook() {
249
+ const dir = claudeUserDir();
250
+ const file = join(dir, 'settings.json');
251
+ mkdirSync(dir, { recursive: true });
252
+
253
+ let settings = {};
254
+ if (existsSync(file)) {
255
+ try {
256
+ settings = JSON.parse(readFileSync(file, 'utf8'));
257
+ } catch (e) {
258
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
259
+ }
260
+ }
261
+
262
+ settings.hooks = settings.hooks || {};
263
+ const list = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
264
+ const already = list.some((m) =>
265
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('brief --hook')),
266
+ );
267
+ if (already) return { path: file, action: 'exists' };
268
+
269
+ list.push({
270
+ hooks: [{ type: 'command', command: BRIEF_HOOK_COMMAND, timeout: 10 }],
271
+ });
272
+ settings.hooks.UserPromptSubmit = list;
273
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
274
+ return { path: file, action: 'created' };
275
+ }
276
+
241
277
  export function installAll({ force = false } = {}) {
242
278
  return {
243
279
  skill: installSkill({ force }),
244
280
  statusline: installStatusline({ force }),
245
281
  sessionStartHook: installSessionStartHook(),
282
+ briefHook: installBriefHook(),
246
283
  legacy: removeLegacyCommand(),
247
284
  };
248
285
  }
package/src/route-scan.js CHANGED
@@ -115,8 +115,10 @@ const MIN_BEHAVIOR_CALLS = 2; // fewer calls than this → too little behavior t
115
115
 
116
116
  /**
117
117
  * Narrow the candidate categories from the episode's tool mix.
118
- * Returns an array of category ids (ordered first is the default when
119
- * keywords stay silent), or null when behavior is inconclusive.
118
+ * Returns { ids, fallback } ids are the candidate categories, fallback is
119
+ * the id to use when keywords stay silent (null = keywords are REQUIRED, an
120
+ * id-less episode is not a delegation candidate). Returns null when behavior
121
+ * is inconclusive.
120
122
  */
121
123
  export function behaviorPool(toolCounts) {
122
124
  let run = 0, lookup = 0, write = 0, total = 0;
@@ -127,9 +129,15 @@ export function behaviorPool(toolCounts) {
127
129
  else if (WRITE_TOOLS.has(name)) write += n;
128
130
  }
129
131
  if (total < MIN_BEHAVIOR_CALLS) return null;
130
- if (run > lookup + write) return ['run'];
131
- if (lookup > run + write) return ['explore', 'read', 'check', 'translate'];
132
- return null; // mixed / write-heavy no reliable verdict, keywords decide
132
+ if (run > lookup + write) return { ids: ['run'], fallback: 'run' };
133
+ if (lookup > run + write) return { ids: ['explore', 'read', 'check', 'translate'], fallback: 'explore' };
134
+ // Write-dominant episodes are EDITING work, not delegable lookups without
135
+ // this they leak into read/explore via generic keywords ("설명이 필요해보이는데"
136
+ // + Edit×5 landed in read/T1). Only translate legitimately writes, so it is
137
+ // the sole candidate — and only with explicit translate keywords (no
138
+ // silent fallback: an editing episode is not a delegation candidate).
139
+ if (write > run + lookup) return { ids: ['translate'], fallback: null };
140
+ return null; // mixed — no reliable verdict, keywords decide
133
141
  }
134
142
 
135
143
  /** Weighted keyword score for one category (0 when it has no kw table). */
@@ -176,7 +184,7 @@ export function categorize(text, toolCounts) {
176
184
  if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
177
185
  const pool = behaviorPool(toolCounts);
178
186
  const eligible = pool
179
- ? pool.map((id) => CATEGORIES.find((c) => c.id === id))
187
+ ? pool.ids.map((id) => CATEGORIES.find((c) => c.id === id))
180
188
  : CATEGORIES.filter((c) => c.kw);
181
189
  let best = null;
182
190
  let bestScore = 0;
@@ -185,7 +193,8 @@ export function categorize(text, toolCounts) {
185
193
  if (s > bestScore) { best = c; bestScore = s; }
186
194
  }
187
195
  if (best) return best;
188
- return pool ? eligible[0] : null;
196
+ if (pool?.fallback) return CATEGORIES.find((c) => c.id === pool.fallback);
197
+ return null;
189
198
  }
190
199
 
191
200
  function isSkippable(text) {