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.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- 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
|
+
}
|