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 +18 -0
- package/package.json +1 -1
- package/src/brief.js +183 -0
- package/src/installer.js +37 -0
- package/src/route-scan.js +16 -7
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
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
|
|
119
|
-
* keywords stay silent
|
|
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
|
-
|
|
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
|
-
|
|
196
|
+
if (pool?.fallback) return CATEGORIES.find((c) => c.id === pool.fallback);
|
|
197
|
+
return null;
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
function isSkippable(text) {
|