sprag-cli 3.40.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * savings-ledger — per-run delegation saving events with timestamps.
3
+ *
4
+ * model-rules.json stores `savedUsd` as a scan-window snapshot per rule, which
5
+ * is right for rule-health but cannot answer "how much did routing save this
6
+ * week / this month / ever". This ledger keeps one event per subagent run,
7
+ * keyed by the run's transcript path so re-scans over overlapping windows
8
+ * upsert instead of double-counting.
9
+ *
10
+ * File: <userDataDir>/delegation-ledger.json
11
+ * { "version": 2,
12
+ * "events": { "<run path>": { "ts", "usd", "rule", "from", "to" } } }
13
+ *
14
+ * `from`/`to` are the models the routing decision moved between, and `usd` is
15
+ * the price difference between them for this run's tokens. Version 1 priced
16
+ * every downgraded subagent run against the session's priciest model, which
17
+ * credited the tool for runs no rule of its own had routed; those events are
18
+ * discarded rather than migrated, since the number they carry cannot be
19
+ * recomputed without a rescan (which route-scan does anyway).
20
+ *
21
+ * Best-effort like every other state file here: an unreadable ledger reads as
22
+ * empty, and the statusline renders totals of 0 as "no chip".
23
+ */
24
+
25
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { userDataDir } from './paths.js';
28
+
29
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
30
+ const MONTH_MS = 30 * 24 * 3600 * 1000;
31
+
32
+ export function ledgerPath() {
33
+ return join(userDataDir(), 'delegation-ledger.json');
34
+ }
35
+
36
+ export const LEDGER_VERSION = 2;
37
+
38
+ export function loadLedger() {
39
+ try {
40
+ const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
41
+ if (!data || typeof data.events !== 'object' || data.events === null) {
42
+ return { version: LEDGER_VERSION, events: {} };
43
+ }
44
+ // Pre-v2 events were priced against a different counterfactual — drop them
45
+ // instead of mixing two meanings into one total. The next scan rebuilds
46
+ // whatever is still attributable.
47
+ if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
48
+ return data;
49
+ } catch {
50
+ return { version: LEDGER_VERSION, events: {} };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Upsert saving events. `events` is an array of
56
+ * { key, ts, usd, rule, from, to } where `key` is the run's transcript path
57
+ * (unique per subagent run) and `from`/`to` name the models the routing
58
+ * decision moved between. Zero-saving runs are skipped — they carry no
59
+ * information the totals care about.
60
+ */
61
+ export function recordDelegationEvents(events) {
62
+ if (!Array.isArray(events) || events.length === 0) return;
63
+ const data = loadLedger();
64
+ data.version = LEDGER_VERSION;
65
+ let changed = false;
66
+ for (const e of events) {
67
+ if (!e || !e.key || !(Number(e.usd) > 0) || !Number.isFinite(e.ts)) continue;
68
+ const prev = data.events[e.key];
69
+ const usd = Math.round(Number(e.usd) * 10000) / 10000;
70
+ if (prev && prev.ts === e.ts && prev.usd === usd) continue;
71
+ data.events[e.key] = {
72
+ ts: e.ts,
73
+ usd,
74
+ ...(e.rule ? { rule: e.rule } : {}),
75
+ ...(e.from ? { from: e.from } : {}),
76
+ ...(e.to ? { to: e.to } : {}),
77
+ };
78
+ changed = true;
79
+ }
80
+ if (!changed) return;
81
+ try {
82
+ const dir = userDataDir();
83
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
84
+ writeFileSync(ledgerPath(), JSON.stringify(data) + '\n');
85
+ } catch {
86
+ // statusline totals just stay stale until the next successful scan
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Family name of a model id, with the version dropped: `claude-opus-5` and
92
+ * `claude-opus-4-5-20251101-v1:0` both read as `opus`.
93
+ *
94
+ * Versions move constantly, and on a statusline the digits are noise — what
95
+ * the reader wants is the shape of the trade ("opus work now runs on haiku").
96
+ * Falls back to the id itself so an unmapped name is visible rather than
97
+ * silently folded into another family.
98
+ */
99
+ export function modelFamily(model) {
100
+ const m = String(model || '').toLowerCase();
101
+ for (const f of ['fable', 'mythos', 'opus', 'sonnet', 'haiku']) {
102
+ if (m.includes(f)) return f;
103
+ }
104
+ return String(model || '?');
105
+ }
106
+
107
+ /**
108
+ * Rolling totals: last 7 days, last 30 days, and lifetime, plus `pairs` — the
109
+ * lifetime rollup by family-level model change, priciest first. `now` is
110
+ * injectable for tests. Never throws — an unreadable ledger yields zeros.
111
+ */
112
+ export function delegationSavedTotals(now = Date.now()) {
113
+ const empty = () => ({ week: 0, month: 0, total: 0, pairs: [] });
114
+ const totals = empty();
115
+ const byPair = new Map();
116
+ try {
117
+ for (const e of Object.values(loadLedger().events)) {
118
+ const usd = Number(e.usd) || 0;
119
+ if (usd <= 0) continue;
120
+ totals.total += usd;
121
+ if (Number.isFinite(e.ts)) {
122
+ if (now - e.ts <= WEEK_MS) totals.week += usd;
123
+ if (now - e.ts <= MONTH_MS) totals.month += usd;
124
+ }
125
+ if (!e.from || !e.to) continue;
126
+ const key = `${modelFamily(e.from)}→${modelFamily(e.to)}`;
127
+ const p = byPair.get(key) || { from: modelFamily(e.from), to: modelFamily(e.to), runs: 0, usd: 0 };
128
+ p.runs += 1;
129
+ p.usd += usd;
130
+ byPair.set(key, p);
131
+ }
132
+ } catch {
133
+ return empty();
134
+ }
135
+ totals.pairs = [...byPair.values()].sort((a, b) => b.usd - a.usd);
136
+ return totals;
137
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * seed-rules — the curated starter set, offered one rule at a time.
3
+ *
4
+ * Two things reach a fresh install with nothing behind them: the model-fitting
5
+ * ratchet (`ratchet-model.md`), which is empty until route-scan has gathered
6
+ * enough of the user's own history to propose a candidate, and the hand-written
7
+ * ratchet (`ratchet.md`), which only ever grows from mistakes the user has
8
+ * already made. So a brand-new install delegates nothing and remembers nothing,
9
+ * and stays that way for days — exactly the period where the savings would
10
+ * matter most.
11
+ *
12
+ * This module closes that gap with presets bundled in the package:
13
+ * presets/model-rules.json → tier-delegation rules (the model ratchet)
14
+ * presets/ratchet-rules.json → field-tested mistake rules (the global ratchet)
15
+ *
16
+ * Nothing is written without the user agreeing to that specific rule. The
17
+ * SessionStart hook lists what is pending and tells the model to ask one rule at
18
+ * a time; each answer is recorded in the state dir, so a declined rule stays
19
+ * declined across sessions and upgrades, and a preset added in a later release
20
+ * shows up as the only pending item rather than re-asking the whole set.
21
+ *
22
+ * State file: <stateDir>/seed-state.json
23
+ * { decided: { "<id>": { action: 'accepted' | 'skipped', at, scope? } } }
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
27
+ import { join, dirname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { createHash } from 'node:crypto';
30
+ import { userDataDir } from './paths.js';
31
+ import { userLanguage } from './config.js';
32
+ import {
33
+ addModelRule,
34
+ composeRuleText,
35
+ loadModelRules,
36
+ modelRuleBaseText,
37
+ syncAllFiles,
38
+ } from './model-rules.js';
39
+ import {
40
+ findProjectRoot,
41
+ harnessListRules,
42
+ harnessPromote,
43
+ presetRuleEntries,
44
+ } from './harness.js';
45
+
46
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
47
+
48
+ export function seedStatePath() {
49
+ return join(userDataDir(), 'seed-state.json');
50
+ }
51
+
52
+ export function loadSeedState() {
53
+ try {
54
+ const data = JSON.parse(readFileSync(seedStatePath(), 'utf8'));
55
+ return data && typeof data.decided === 'object' && data.decided ? data : { decided: {} };
56
+ } catch {
57
+ return { decided: {} };
58
+ }
59
+ }
60
+
61
+ export function saveSeedState(state) {
62
+ const dir = userDataDir();
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+ writeFileSync(seedStatePath(), JSON.stringify(state, null, 2) + '\n');
65
+ }
66
+
67
+ /** Bundled tier-delegation presets (empty array when the file is unreadable). */
68
+ export function modelPresets() {
69
+ try {
70
+ const data = JSON.parse(readFileSync(join(packageRoot, 'presets', 'model-rules.json'), 'utf8'));
71
+ return Array.isArray(data.presets) ? data.presets : [];
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Ratchet-text presets, each with an id derived from its own text. Index-based
79
+ * ids would renumber the moment a rule is inserted above them, silently moving
80
+ * a user's "no" onto a different rule.
81
+ */
82
+ export function ratchetPresets(lang = userLanguage()) {
83
+ return presetRuleEntries().map((r) => ({
84
+ // Hashed from the Korean text in both languages: the id has to survive a
85
+ // reworded translation, or a user who declined a rule would be asked again
86
+ // the next time its English copy changes.
87
+ id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
88
+ text: lang === 'ko' ? r.ko : r.en,
89
+ ko: r.ko,
90
+ }));
91
+ }
92
+
93
+ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').trim();
94
+
95
+ /**
96
+ * Everything still worth asking about: presets the user has not answered and
97
+ * that are not already covered by a rule they have.
98
+ *
99
+ * "Already covered" is deliberately loose for model presets — any registered
100
+ * rule for the same tier and category wins, whatever its scope or agent. A user
101
+ * whose own route-scan already promoted that shape does not need ours, and
102
+ * offering it anyway would read as the tool failing to notice its own state.
103
+ */
104
+ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() } = {}) {
105
+ const { decided } = loadSeedState();
106
+ const registered = (() => {
107
+ try {
108
+ return loadModelRules().rules;
109
+ } catch {
110
+ return [];
111
+ }
112
+ })();
113
+ const haveModel = new Set(registered.map((r) => `${r.tier}|${r.category}`));
114
+
115
+ const haveText = new Set();
116
+ for (const scope of ['global', 'project']) {
117
+ try {
118
+ for (const r of harnessListRules({ root, scope }).rules) haveText.add(stripDate(r.text));
119
+ } catch { /* a missing ratchet file just means nothing is registered yet */ }
120
+ }
121
+
122
+ const out = [];
123
+ for (const p of modelPresets()) {
124
+ if (decided[p.id]) continue;
125
+ if (haveModel.has(`${p.tier}|${p.category}`)) continue;
126
+ out.push({
127
+ kind: 'model',
128
+ id: p.id,
129
+ tier: p.tier,
130
+ label: lang === 'ko' ? p.label : p.labelEn,
131
+ agent: p.agent,
132
+ ruleText: composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
133
+ preset: p,
134
+ });
135
+ }
136
+ for (const p of ratchetPresets(lang)) {
137
+ if (decided[p.id]) continue;
138
+ // Either language counts as already registered: `harness pull` may have
139
+ // written the other one.
140
+ if (haveText.has(stripDate(p.text)) || haveText.has(stripDate(p.ko))) continue;
141
+ out.push({ kind: 'ratchet', id: p.id, ruleText: p.text, preset: p });
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** One pending seed by id, or null. Ids are matched exactly. */
147
+ export function findSeed(id, opts = {}) {
148
+ return pendingSeeds(opts).find((s) => s.id === id) || null;
149
+ }
150
+
151
+ function recordDecision(id, action, extra = {}) {
152
+ const state = loadSeedState();
153
+ state.decided[id] = { action, at: new Date().toISOString().slice(0, 10), ...extra };
154
+ saveSeedState(state);
155
+ }
156
+
157
+ /**
158
+ * Register one preset. Scope is the caller's to decide — the same requirement
159
+ * the ratchet itself puts on promote, because a rule in the wrong scope is
160
+ * either noise in every other project or missing from the one that needed it.
161
+ *
162
+ * Returns { id, kind, scope, path?, rule } or null when the id is not pending.
163
+ */
164
+ export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(), lang = userLanguage() } = {}) {
165
+ const seed = findSeed(id, { lang, root });
166
+ if (!seed) return null;
167
+
168
+ if (seed.kind === 'ratchet') {
169
+ const res = harnessPromote(seed.ruleText, { root, scope });
170
+ recordDecision(id, 'accepted', { scope });
171
+ return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
172
+ }
173
+
174
+ const p = seed.preset;
175
+ const today = new Date().toISOString().slice(0, 10);
176
+ // A project-scope rule has to carry the munged project key, or the next scan
177
+ // cannot match episode stats to it and the rule never updates. route-scan is
178
+ // imported lazily: it pulls in the transcript parser, which is far too heavy
179
+ // for the session-start path that only lists pending seeds.
180
+ let project = null;
181
+ if (scope === 'project') {
182
+ const { mungeProjectPath } = await import('./route-scan.js');
183
+ project = mungeProjectPath(root);
184
+ }
185
+ const entry = addModelRule({
186
+ // Namespaced away from scan signatures (`T1|run|<project>`) so a later
187
+ // promote of the user's own candidate for the same shape is a separate
188
+ // entry rather than a silent overwrite.
189
+ signature: `${p.tier}|${p.category}|preset`,
190
+ tier: p.tier,
191
+ category: p.category,
192
+ label: p.label,
193
+ labelEn: p.labelEn,
194
+ agent: p.agent,
195
+ scope,
196
+ targetRoot: scope === 'project' ? root : null,
197
+ project,
198
+ rule: modelRuleBaseText(p, lang),
199
+ example: p.example,
200
+ exampleEn: p.exampleEn,
201
+ count: 0,
202
+ budget: p.budget || null,
203
+ // Marks the rule as seeded rather than measured, so the rendered file does
204
+ // not report someone else's recurrence count as this user's evidence.
205
+ origin: 'preset',
206
+ promotedAt: today,
207
+ lastSeen: today,
208
+ });
209
+ const written = syncAllFiles();
210
+ recordDecision(id, 'accepted', { scope });
211
+ return { id, kind: 'model', scope, paths: written, rule: composeRuleText(entry.rule, entry, lang) };
212
+ }
213
+
214
+ /** Record a "no" so the rule is never offered again (until `seed reset`). */
215
+ export function skipSeed(id, opts = {}) {
216
+ const seed = findSeed(id, opts);
217
+ if (!seed) return null;
218
+ recordDecision(id, 'skipped');
219
+ return { id, kind: seed.kind, rule: seed.ruleText };
220
+ }
221
+
222
+ /** Forget every recorded answer — the full set becomes pending again. */
223
+ export function resetSeeds() {
224
+ const state = loadSeedState();
225
+ const count = Object.keys(state.decided).length;
226
+ saveSeedState({ decided: {} });
227
+ return count;
228
+ }
229
+
230
+ /**
231
+ * The SessionStart block. Returns null when nothing is pending, which is the
232
+ * steady state — every session after the offers are answered pays nothing.
233
+ *
234
+ * The model is told to ask one rule at a time because that is what the user
235
+ * asked for, and because a single "register all of these?" question invites a
236
+ * yes to rules the user never read.
237
+ */
238
+ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot() } = {}) {
239
+ const pending = pendingSeeds({ lang, root });
240
+ if (pending.length === 0) return null;
241
+ const models = pending.filter((s) => s.kind === 'model');
242
+ const fixes = pending.filter((s) => s.kind === 'ratchet');
243
+ const lines = [];
244
+ if (lang === 'ko') {
245
+ lines.push(`[claude-token-saver seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
246
+ lines.push('사용자에게 **한 건씩 순서대로** 보여 주고 등록할지 물어보십시오. 한 번에 전체 등록을 권하지 말고, 답을 받은 뒤 바로 아래 명령을 실행하십시오.');
247
+ if (models.length > 0) {
248
+ lines.push(` · 모델 피팅 룰 ${models.length}건 — 등록하면 해당 유형 작업이 더 싼 서브에이전트로 위임됩니다.`);
249
+ for (const s of models) {
250
+ lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
251
+ lines.push(` "${s.ruleText}"`);
252
+ }
253
+ }
254
+ if (fixes.length > 0) {
255
+ lines.push(` · 랫쳇 룰 ${fixes.length}건 — 같은 실수를 반복하지 않도록 세션마다 읽히는 규칙입니다.`);
256
+ for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
257
+ }
258
+ lines.push(' 등록: claude-token-saver seed accept <id> --global|--project # 적용 범위는 반드시 사용자에게 확인');
259
+ lines.push(' 거절: claude-token-saver seed skip <id> # 다시 묻지 않습니다');
260
+ lines.push(' (사용자가 "전부 등록"이라고 답하면 `seed accept all --global` 을 쓸 수 있습니다)');
261
+ } else {
262
+ lines.push(`[claude-token-saver seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
263
+ lines.push('Walk the user through them **one at a time** and ask about each. Do not push the whole set at once; run the matching command as soon as they answer.');
264
+ if (models.length > 0) {
265
+ lines.push(` · ${models.length} model-fitting rule(s) — once registered, that kind of work goes to a cheaper subagent.`);
266
+ for (const s of models) {
267
+ lines.push(` [${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`);
268
+ lines.push(` "${s.ruleText}"`);
269
+ }
270
+ }
271
+ if (fixes.length > 0) {
272
+ lines.push(` · ${fixes.length} ratchet rule(s) — read at the start of every session so the same mistake is not repeated.`);
273
+ for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
274
+ }
275
+ lines.push(' register: claude-token-saver seed accept <id> --global|--project # ALWAYS confirm the scope with the user');
276
+ lines.push(' decline: claude-token-saver seed skip <id> # never offered again');
277
+ lines.push(' (if the user says "register them all", `seed accept all --global` does that)');
278
+ }
279
+ return lines.join('\n');
280
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Parsed-session cache — keyed by (path, mtimeMs, size).
3
+ *
4
+ * The statusline re-runs the whole report pipeline on every refresh
5
+ * (`refreshInterval=5` is what `install` configures), and without a cache
6
+ * that means re-reading every session JSONL in the window line by line.
7
+ * Measured on a 217MB / 226-file 30-day window: 3.0s per refresh, of which
8
+ * effectively all is spent re-parsing files that cannot have changed —
9
+ * between two refreshes only the CURRENT session's file grows.
10
+ *
11
+ * Session transcripts are append-only and never rewritten, so (mtimeMs, size)
12
+ * is a sound identity for "this file's parse result is still valid". A stale
13
+ * or corrupt cache is never fatal: every read path falls back to a full parse.
14
+ *
15
+ * Only the aggregate summary is stored, never the per-request array — that is
16
+ * internal to the parser's aggregation and no consumer reads it (see
17
+ * parseAllSessions, which drops it on the fresh-parse path too so cache hits
18
+ * and misses return identically shaped objects).
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { userDataDir } from './paths.js';
24
+ import { debug } from './debug.js';
25
+
26
+ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
27
+ // Bump when the cached summary's shape changes — old entries are dropped
28
+ // wholesale rather than migrated.
29
+ // 2: sessions carry `gatewayObserved`. Entries written by version 1 lack it,
30
+ // and a missing flag reads as "not a gateway" — the wrong default for exactly
31
+ // the users the flag exists for.
32
+ // 3: version 2's serialize() never actually wrote `gatewayObserved`, so every
33
+ // v2 entry lacks the flag it was bumped for. Bumped again to discard them.
34
+ const CACHE_VERSION = 3;
35
+ // Entries for transcripts this old are pruned on write. Keeps the file
36
+ // bounded without an existence check per entry (which would cost the syscalls
37
+ // the cache exists to avoid).
38
+ const PRUNE_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
39
+
40
+ /**
41
+ * @typedef {object} SessionSummary
42
+ * @property {string|null} sessionId
43
+ * @property {string} filePath
44
+ * @property {Date|null} startTime
45
+ * @property {Date|null} endTime
46
+ * @property {number} requestCount
47
+ * @property {object} totals
48
+ * @property {number} maxContextPerRequest
49
+ * @property {string} model
50
+ * @property {string} [projectDir]
51
+ */
52
+
53
+ /** Dates don't survive JSON — store epoch ms, rehydrate on read. */
54
+ function serialize(session) {
55
+ return {
56
+ sessionId: session.sessionId,
57
+ startTime: session.startTime ? session.startTime.getTime() : null,
58
+ endTime: session.endTime ? session.endTime.getTime() : null,
59
+ requestCount: session.requestCount,
60
+ totals: session.totals,
61
+ maxContextPerRequest: session.maxContextPerRequest,
62
+ model: session.model,
63
+ gatewayObserved: !!session.gatewayObserved,
64
+ };
65
+ }
66
+
67
+ function deserialize(stored, filePath, projectDir) {
68
+ return {
69
+ sessionId: stored.sessionId ?? null,
70
+ filePath,
71
+ projectDir,
72
+ startTime: typeof stored.startTime === 'number' ? new Date(stored.startTime) : null,
73
+ endTime: typeof stored.endTime === 'number' ? new Date(stored.endTime) : null,
74
+ requestCount: stored.requestCount || 0,
75
+ totals: stored.totals,
76
+ maxContextPerRequest: stored.maxContextPerRequest || 0,
77
+ model: stored.model || 'unknown',
78
+ gatewayObserved: !!stored.gatewayObserved,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Load the cache into a plain object. Any problem (missing file, bad JSON,
84
+ * version bump) yields an empty cache — the caller just re-parses.
85
+ *
86
+ * @returns {{ entries: Record<string, {mtimeMs:number,size:number,s:object}> }}
87
+ */
88
+ export function loadCache() {
89
+ try {
90
+ if (!existsSync(CACHE_PATH)) return { entries: {} };
91
+ const data = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
92
+ if (!data || data.version !== CACHE_VERSION || !data.entries || typeof data.entries !== 'object') {
93
+ return { entries: {} };
94
+ }
95
+ return { entries: data.entries };
96
+ } catch (e) {
97
+ debug('session-cache:load', e);
98
+ return { entries: {} };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Look up a parsed session for a discovered file.
104
+ *
105
+ * @param {{entries:object}} cache
106
+ * @param {{path:string, size:number, mtime:number, projectDir:string}} file
107
+ * @returns {SessionSummary|null} null on miss
108
+ */
109
+ export function getCached(cache, file) {
110
+ const e = cache.entries[file.path];
111
+ if (!e || e.mtimeMs !== file.mtime || e.size !== file.size || !e.s) return null;
112
+ try {
113
+ return deserialize(e.s, file.path, file.projectDir);
114
+ } catch (err) {
115
+ debug('session-cache:deserialize', err);
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Record a freshly parsed session against the stat values observed BEFORE the
122
+ * parse. Keying on the pre-parse stat is deliberate: if the file grew while we
123
+ * were reading it, the entry is keyed to a size that will never be seen again,
124
+ * so the next run re-parses. The opposite (keying on the post-parse stat)
125
+ * would let a hit serve a summary that missed the tail.
126
+ */
127
+ export function putCached(cache, file, session) {
128
+ cache.entries[file.path] = {
129
+ mtimeMs: file.mtime,
130
+ size: file.size,
131
+ s: serialize(session),
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Atomically persist the cache (tmp + rename), pruning long-dead transcripts.
137
+ * Concurrent statusline refreshes are expected — rename is atomic on POSIX and
138
+ * on Windows for same-volume replaces, so a reader never sees a partial file.
139
+ * Best-effort: a write failure just means the next run re-parses.
140
+ */
141
+ export function saveCache(cache) {
142
+ try {
143
+ const dir = userDataDir();
144
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
145
+ const cutoff = Date.now() - PRUNE_AFTER_MS;
146
+ const entries = {};
147
+ for (const [path, e] of Object.entries(cache.entries)) {
148
+ if (e && typeof e.mtimeMs === 'number' && e.mtimeMs >= cutoff) entries[path] = e;
149
+ }
150
+ const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
151
+ writeFileSync(tmp, JSON.stringify({ version: CACHE_VERSION, entries }) + '\n');
152
+ renameSync(tmp, CACHE_PATH);
153
+ } catch (e) {
154
+ debug('session-cache:save', e); // best-effort — never block a report
155
+ }
156
+ }
157
+
158
+ export function sessionCachePath() {
159
+ return CACHE_PATH;
160
+ }