claude-token-saver 3.5.0 → 3.5.2

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
@@ -572,6 +572,7 @@ async function main() {
572
572
  // stay fast) — reads the cache, kicks a detached refresh when stale, and
573
573
  // prints delegation-candidate context for the new session.
574
574
  if (hasFlag('--hook')) {
575
+ const hookCtx = readStdinJson() || {};
575
576
  let cache = rs.readRouteScan();
576
577
  if (await rs.shouldRescan(cache)) {
577
578
  try {
@@ -615,7 +616,18 @@ async function main() {
615
616
  }
616
617
  lines.push(' 제거: claude-token-saver route-scan rules rm <N>');
617
618
  }
619
+ lines.push('위 내용은 사용자의 첫 메시지 주제와 무관하더라도(단순 인사여도) 첫 응답 말미에 `※ [claude-token-saver]` 라벨을 달아 요약 브리핑하세요 — 사용자가 물어볼 때까지 기다리지 말 것.');
618
620
  console.log(lines.join('\n'));
621
+ // Record what was actually briefed so the UserPromptSubmit brief hook
622
+ // suppresses exactly these — a candidate landing after this read (e.g.
623
+ // the detached rescan above finishing) still gets briefed next prompt.
624
+ try {
625
+ const { seedSessionBriefed } = await import('../src/brief.js');
626
+ seedSessionBriefed(hookCtx.session_id, [
627
+ ...open.map((c) => `route|${c.signature}`),
628
+ ...reviewRules.map((r) => `health|${r.signature}|${r.scope}`),
629
+ ]);
630
+ } catch { /* best-effort — worst case is one duplicate brief */ }
619
631
  return;
620
632
  }
621
633
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
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 CHANGED
@@ -14,8 +14,15 @@
14
14
  * transcript_path) and the "already briefed" markers are keyed by session_id.
15
15
  *
16
16
  * State: <stateDir>/brief-state.json
17
- * { sessions: { [session_id]: { ts, ctxTier, seeded, briefed: [signature] } } }
17
+ * { sessions: { [session_id]: { ts, ctxTier, briefed: [signature] } } }
18
18
  * Sessions untouched for 7 days are pruned on every write.
19
+ *
20
+ * Division of labor with the SessionStart hook: SessionStart prints the
21
+ * session-start snapshot and records the signatures it actually briefed via
22
+ * seedSessionBriefed(); this hook emits anything NOT in that record. A
23
+ * candidate that lands after the SessionStart read (e.g. the detached rescan
24
+ * finishing between session start and the first prompt) is therefore briefed
25
+ * on the next prompt instead of being lost for the whole session.
19
26
  */
20
27
 
21
28
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
@@ -109,20 +116,34 @@ function ctxTierOf(pct) {
109
116
 
110
117
  const fmtK = (n) => `${Math.round(n / 1000)}k`;
111
118
 
119
+ /**
120
+ * Called by the SessionStart hook after it prints the session-start snapshot:
121
+ * records the signatures it actually briefed so runBrief suppresses exactly
122
+ * those and nothing more. Signature format matches runBrief:
123
+ * `route|<sig>` / `health|<sig>|<scope>`.
124
+ */
125
+ export function seedSessionBriefed(sessionId, signatures, now = Date.now()) {
126
+ if (!sessionId || !Array.isArray(signatures) || signatures.length === 0) return;
127
+ const state = loadState();
128
+ const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
129
+ s.briefed = [...new Set([...(s.briefed || []), ...signatures])];
130
+ s.ts = now;
131
+ state.sessions[sessionId] = s;
132
+ saveState(state, now);
133
+ }
134
+
112
135
  /**
113
136
  * Compute the briefing for one prompt-submit event. Returns the text to
114
137
  * inject, or null when nothing new happened. Mutates + persists state.
115
138
  *
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.
139
+ * Route/rule-health signatures already briefed by the SessionStart hook
140
+ * (recorded via seedSessionBriefed) are skipped; every other fresh signature
141
+ * is emitted, including on the session's first event.
121
142
  */
122
143
  export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
123
144
  if (!sessionId) return null;
124
145
  const state = loadState();
125
- const s = state.sessions[sessionId] || { ctxTier: 0, seeded: false, briefed: [] };
146
+ const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
126
147
  const items = [];
127
148
 
128
149
  // ── context tier crossing (per-session) ──
@@ -158,16 +179,12 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
158
179
  briefed.add(sig);
159
180
  fresh.push(['health', r]);
160
181
  }
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
- }
182
+ for (const [kind, x] of fresh) {
183
+ items.push(kind === 'route'
184
+ ? `새 위임 후보가 감지되었습니다 — "${x.label}" 유형 ${x.count}회 반복(statusline의 route? R${x.id} 칩). 등록: claude-token-saver harness promote R${x.id} --project|--global (적용 범위는 사용자에게 확인) / 무시: route-scan dismiss ${x.id}`
185
+ : `승인된 위임 룰의 최근 에러율이 기준(20%)을 넘었습니다 — "${x.label}" (statusline의 rule-health 칩). 조건 좁히기/제거를 사용자와 상의하세요: claude-token-saver route-scan rules`);
167
186
  }
168
- // First event: session-start snapshot is SessionStart's job — swallow it.
169
187
  s.briefed = [...briefed];
170
- s.seeded = true;
171
188
  } catch { /* caches unreadable — ctx briefing above still applies */ }
172
189
 
173
190
  s.ts = now;
package/src/installer.js CHANGED
@@ -223,6 +223,11 @@ export function installSessionStartHook() {
223
223
  }
224
224
 
225
225
  settings.hooks = settings.hooks || {};
226
+ // A present-but-non-array value is schema-invalid, but it's the user's
227
+ // data — back off instead of silently replacing it.
228
+ if (settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
229
+ return { path: file, action: 'skipped', reason: 'hooks.SessionStart is not an array — fix settings.json manually' };
230
+ }
226
231
  const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
227
232
  const already = list.some((m) =>
228
233
  Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('route-scan --hook')),
@@ -260,6 +265,11 @@ export function installBriefHook() {
260
265
  }
261
266
 
262
267
  settings.hooks = settings.hooks || {};
268
+ // A present-but-non-array value is schema-invalid, but it's the user's
269
+ // data — back off instead of silently replacing it.
270
+ if (settings.hooks.UserPromptSubmit !== undefined && !Array.isArray(settings.hooks.UserPromptSubmit)) {
271
+ return { path: file, action: 'skipped', reason: 'hooks.UserPromptSubmit is not an array — fix settings.json manually' };
272
+ }
263
273
  const list = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
264
274
  const already = list.some((m) =>
265
275
  Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('brief --hook')),