claude-token-saver 3.4.3 → 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.3",
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
  }