sprag-cli 3.58.1 → 3.60.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.
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * reply-language — catches an English reply to a Korean prompt.
5
+ *
6
+ * The model sometimes answers a Korean prompt in English, typically right
7
+ * after a long stretch of English work (commit messages, release notes). The
8
+ * slip is decidable by counting letters, so it gets a Stop hook instead of
9
+ * another sentence in the guidance.
10
+ *
11
+ * The thresholds were measured on 2,204 Korean-prompt turns from 14 days of
12
+ * real transcripts: 8 flagged, all 8 true positives, no false positives.
13
+ * Loosening any of them reopens false positives on replies that are Korean
14
+ * prose around code and English identifiers, so change them only with a new
15
+ * measurement.
16
+ *
17
+ * CommonJS, no dependencies: this runs on every Stop, so it must stay cheap.
18
+ */
19
+
20
+ const { openSync, readSync, fstatSync, closeSync } = require('node:fs');
21
+
22
+ // Only the tail of the transcript matters, and transcripts grow to hundreds
23
+ // of megabytes. If the last real prompt is further back than this, the turn is
24
+ // a long agentic run and we skip it rather than pay for reading it all.
25
+ const TAIL_BYTES = 2 * 1024 * 1024;
26
+
27
+ const ENGLISH_REQUESTED = /영어로|영문으로|in English/i;
28
+
29
+ // A user entry that is not something the person typed.
30
+ const NOT_A_PROMPT_PREFIX = ['<command-name', '<local-command', '<command-message', '<task-notification', 'Caveat:'];
31
+
32
+ /**
33
+ * Strip what is legitimately not prose in the reader's language: injected
34
+ * reminders, code, URLs and paths. Code and paths are Latin by nature, so
35
+ * counting them would flag every Korean reply that quotes a command.
36
+ */
37
+ function cleanText(t) {
38
+ return String(t ?? '')
39
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
40
+ .replace(/```[\s\S]*?```/g, ' ')
41
+ .replace(/`[^`]*`/g, ' ')
42
+ .replace(/https?:\/\/\S+/g, ' ')
43
+ .replace(/(?:~|\.{0,2})?\/[\w.\-/]+/g, ' ');
44
+ }
45
+
46
+ function letterCounts(text) {
47
+ const cleaned = cleanText(text);
48
+ const h = (cleaned.match(/[가-힣]/g) || []).length;
49
+ const l = (cleaned.match(/[A-Za-z]/g) || []).length;
50
+ return { h, l, ratio: h + l === 0 ? 0 : h / (h + l) };
51
+ }
52
+
53
+ function isKoreanPrompt(text) {
54
+ const { h, ratio } = letterCounts(text);
55
+ return h >= 4 && ratio >= 0.3;
56
+ }
57
+
58
+ function isEnglishReply(text) {
59
+ const { l, ratio } = letterCounts(text);
60
+ return l >= 80 && ratio < 0.15;
61
+ }
62
+
63
+ function textBlocks(content) {
64
+ if (typeof content === 'string') return content;
65
+ if (!Array.isArray(content)) return '';
66
+ return content
67
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
68
+ .map((b) => b.text)
69
+ .join('\n');
70
+ }
71
+
72
+ /**
73
+ * The text of a real user prompt, or null when the entry is a tool result, a
74
+ * meta entry, or machinery posing as a user message.
75
+ */
76
+ function realPromptText(entry) {
77
+ if (entry.type !== 'user' || entry.isMeta) return null;
78
+ const content = entry.message?.content;
79
+ if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) return null;
80
+ if (typeof content !== 'string' && !Array.isArray(content)) return null;
81
+ const text = textBlocks(content);
82
+ const head = text.trimStart();
83
+ if (NOT_A_PROMPT_PREFIX.some((p) => head.startsWith(p))) return null;
84
+ if (text.includes('[Subagent hand-back]') || text.includes('<agent-message')) return null;
85
+ return text;
86
+ }
87
+
88
+ /**
89
+ * The last turn in a list of JSONL lines: the final real user prompt and the
90
+ * assistant text the user sees after it. A tool_result resets the reply,
91
+ * because text written before a tool call is narration, not the answer.
92
+ * Returns null when there is no prompt. Unparsable lines and sidechain
93
+ * (subagent) entries are skipped.
94
+ */
95
+ function lastTurn(lines) {
96
+ let prompt = null;
97
+ let reply = [];
98
+ for (const line of lines) {
99
+ if (!line) continue;
100
+ let entry;
101
+ try {
102
+ entry = JSON.parse(line);
103
+ } catch {
104
+ continue;
105
+ }
106
+ if (!entry || typeof entry !== 'object' || entry.isSidechain) continue;
107
+ if (entry.type === 'user') {
108
+ const content = entry.message?.content;
109
+ if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) {
110
+ reply = [];
111
+ continue;
112
+ }
113
+ const text = realPromptText(entry);
114
+ if (text !== null) {
115
+ prompt = text;
116
+ reply = [];
117
+ }
118
+ } else if (entry.type === 'assistant') {
119
+ const text = textBlocks(entry.message?.content);
120
+ if (text) reply.push(text);
121
+ }
122
+ }
123
+ if (prompt === null) return null;
124
+ return { prompt, reply: reply.join('\n') };
125
+ }
126
+
127
+ function readTailLines(path) {
128
+ const fd = openSync(path, 'r');
129
+ try {
130
+ const size = fstatSync(fd).size;
131
+ const start = Math.max(0, size - TAIL_BYTES);
132
+ const buf = Buffer.alloc(size - start);
133
+ let got = 0;
134
+ while (got < buf.length) {
135
+ const n = readSync(fd, buf, got, buf.length - got, start + got);
136
+ if (n <= 0) break;
137
+ got += n;
138
+ }
139
+ const lines = buf.subarray(0, got).toString('utf8').split('\n');
140
+ // A window that starts mid-file begins inside a line, so drop that stub.
141
+ if (start > 0) lines.shift();
142
+ return lines;
143
+ } finally {
144
+ closeSync(fd);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * null when the reply is fine (or cannot be judged), otherwise the measured
150
+ * ratios so a caller can report them. Never throws.
151
+ */
152
+ function checkReplyLanguage(transcriptPath) {
153
+ try {
154
+ if (!transcriptPath) return null;
155
+ const turn = lastTurn(readTailLines(transcriptPath));
156
+ if (!turn) return null;
157
+ if (ENGLISH_REQUESTED.test(turn.prompt)) return null;
158
+ if (!isKoreanPrompt(turn.prompt) || !isEnglishReply(turn.reply)) return null;
159
+ const u = letterCounts(turn.prompt);
160
+ const r = letterCounts(turn.reply);
161
+ return { userHangulRatio: u.ratio, replyHangulRatio: r.ratio, replyLatin: r.l };
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ module.exports = { checkReplyLanguage, cleanText, isKoreanPrompt, isEnglishReply, lastTurn };
@@ -22,10 +22,16 @@
22
22
  * - the text trips EDIT_RE (implement, add, fix …): editing is not delegable
23
23
  * however much the sentence also says "install" or "check"
24
24
  * - no registered rule covers that category
25
+ * - the request is an explanation question ("어떻게 동작해", "뭐야", "how does
26
+ * …") whose only category evidence is one weight-1 keyword. "sprag 설치만으로
27
+ * … 도구에서 어떻게 쓰이는거지?" scored run on the bare word 설치 and was told
28
+ * to hand a question about how the tool behaves to a command-running haiku.
29
+ * Answering it takes the main model's knowledge, not a command. The read
30
+ * category is exempt: explaining is what it is for.
25
31
  * A hint that fires on the wrong request costs more than one that stays quiet.
26
32
  */
27
33
 
28
- import { categorize, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
34
+ import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
29
35
  import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
30
36
  import { agentPhrase, agentPhraseEn } from './agents.js';
31
37
  import { userLanguage } from './config.js';
@@ -44,6 +50,34 @@ export function looksPasted(text) {
44
50
  /** Shortest text worth classifying; below this a prompt is an ack, not a task. */
45
51
  const MIN_LEN = 8;
46
52
 
53
+ /**
54
+ * A question that asks for an explanation ("어떻게 쓰이는거지?", "뭐야", "how
55
+ * does it work"). "왜" is not here: ESCALATE_RE already routes it to the top
56
+ * tier. Kept to the shapes that ask HOW or WHAT something is — a bare "?" or a
57
+ * sentence-final "-지?" is too common in ordinary commands to count.
58
+ */
59
+ const EXPLAIN_Q_RE = /어떻게\s*(?:쓰|사용|동작|작동|해야|하면|하는|하지|하죠|되는\s*(?:거|건|걸|것))|어떤\s*식으로|어떨\s*때|뭐야|뭐지|무엇|무슨\s*(?:의미|뜻)|\bhow\s+(?:does|do)\b|\bwhat\s+(?:is|does)\b/i;
60
+ /**
61
+ * "어떻게 돼 / 됐 / 되고 있" asks for the current state of something, which is
62
+ * a check request however it is phrased. EXPLAIN_Q_RE does not list these, and
63
+ * this guard keeps it that way if "어떻게 되는 거" is ever matched next to one.
64
+ */
65
+ const STATE_Q_RE = /어떻게\s*(?:돼|됐|됬|되어\s*있|되고\s*있)/;
66
+ /** The category rests on exactly one weight-1 keyword. */
67
+ const WEAK_SCORE = 1;
68
+ /**
69
+ * The category whose job is to explain. Its weight-1 keywords (설명, 알려줘,
70
+ * 뭐야 …) share one pattern, so a question shape adds no evidence against it,
71
+ * and the registered rule names "4번 보정이 뭐야" as its own example.
72
+ */
73
+ const EXPLAINING_CATEGORY = 'read';
74
+
75
+ /** True for a question that asks how something works or what it is. */
76
+ export function isExplainQuestion(text) {
77
+ const t = String(text || '');
78
+ return EXPLAIN_Q_RE.test(t) && !STATE_Q_RE.test(t);
79
+ }
80
+
47
81
  /**
48
82
  * Price rank of whatever model is answering this session, or null when it
49
83
  * cannot be established.
@@ -98,8 +132,14 @@ export function routeMatch(text, { rules, sessionRank = null } = {}) {
98
132
  // delegable, and with no tool calls yet only the wording can say so.
99
133
  if (EDIT_RE.test(t)) return null;
100
134
 
101
- const cat = categorize(t, null);
102
- if (!cat) return null;
135
+ const scored = categorizeScored(t, null);
136
+ if (!scored) return null;
137
+ const cat = scored.cat;
138
+ // A category carried by one weak keyword is a guess, and an explanation
139
+ // question is the case where the guess is most often wrong: "설치" inside a
140
+ // question about how a tool works is not a request to install anything.
141
+ // Two or more points (a command, or several keywords) keep the old verdict.
142
+ if (scored.score === WEAK_SCORE && cat.id !== EXPLAINING_CATEGORY && isExplainQuestion(t)) return null;
103
143
  // Length alone makes a paste offline, where the tool mix confirms it. At
104
144
  // prompt time a long text is as often a written spec as a pasted log, and a
105
145
  // spec is not haiku work — so it must also look pasted: many lines, or the
package/src/route-scan.js CHANGED
@@ -29,6 +29,7 @@ import { modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from '.
29
29
  import { sessionCost } from './claude-price.js';
30
30
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
31
31
  import { modelRuleBaseText } from './model-rules.js';
32
+ import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
32
33
 
33
34
  // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
34
35
  // T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
@@ -235,7 +236,21 @@ export function mungeProjectPath(p) {
235
236
  * id); no behavior AND no keyword hit → null (nothing delegable to name).
236
237
  */
237
238
  export function categorize(text, toolCounts) {
238
- if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
239
+ const scored = categorizeScored(text, toolCounts);
240
+ return scored ? scored.cat : null;
241
+ }
242
+
243
+ /**
244
+ * categorize() plus the winning category's keyword score, for a caller that
245
+ * must tell a request the keywords claim firmly from one that a single
246
+ * weight-1 word dragged into a category. `score` is 0 when the category came
247
+ * from the paste gate or from the tool-mix fallback, where no keyword won.
248
+ * categorize() delegates here, so the two can never disagree on the category.
249
+ *
250
+ * @returns {{cat: object, score: number}|null}
251
+ */
252
+ export function categorizeScored(text, toolCounts) {
253
+ if (text.length >= PASTE_MIN_LEN) return { cat: CATEGORIES.find((c) => c.id === 'paste'), score: 0 };
239
254
  const pool = behaviorPool(toolCounts);
240
255
  const eligible = pool
241
256
  ? pool.ids.map((id) => CATEGORIES.find((c) => c.id === id))
@@ -246,8 +261,8 @@ export function categorize(text, toolCounts) {
246
261
  const s = keywordScore(c, text);
247
262
  if (s > bestScore) { best = c; bestScore = s; }
248
263
  }
249
- if (best) return best;
250
- if (pool?.fallback) return CATEGORIES.find((c) => c.id === pool.fallback);
264
+ if (best) return { cat: best, score: bestScore };
265
+ if (pool?.fallback) return { cat: CATEGORIES.find((c) => c.id === pool.fallback), score: 0 };
251
266
  return null;
252
267
  }
253
268
 
@@ -465,6 +480,35 @@ export function windowBytes(files) {
465
480
  return total;
466
481
  }
467
482
 
483
+ /**
484
+ * Fold one measured delegation into its aggregate (`stats`: key → { runs,
485
+ * errRuns, outTokens, savedUsd }).
486
+ *
487
+ * The two kinds of figure answer different questions. runs / errRuns / outTokens
488
+ * feed rule-health, which judges whether a rule keeps failing, so they cover the
489
+ * whole scan window whatever `sprag saved reset` did. savedUsd is money the user
490
+ * zeroed on purpose: it counts only runs dated at or after `resetMark` (the
491
+ * routing mark), by the same isBeforeReset the ledger totals use. The date is
492
+ * the one the ledger stamps on its event (end, else start), so a run that was
493
+ * still going when the user reset lands on the same side in both totals. A run
494
+ * with no usable time cannot be shown to postdate the reset, so it is left out
495
+ * too. An unpriced run (non-finite `saved`) adds nothing either way.
496
+ *
497
+ * @param {Map<string, object>} stats
498
+ * @param {string} key "tier|category|project"
499
+ * @param {{startedAt: number|null, endedAt?: number|null, out?: number}} run
500
+ * @param {number|null} saved price difference for this run
501
+ * @param {number|null} [resetMark] ms timestamp of the routing reset, or null
502
+ */
503
+ export function tallyDelegatedRun(stats, key, run, saved, resetMark = null) {
504
+ const d = stats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
505
+ d.runs += 1;
506
+ if (isFailedRun(run)) d.errRuns += 1;
507
+ d.outTokens += run.out || 0;
508
+ if (Number.isFinite(saved) && !isBeforeReset(run.endedAt ?? run.startedAt, resetMark)) d.savedUsd += saved;
509
+ stats.set(key, d);
510
+ }
511
+
468
512
  export async function runRouteScan({ days = 14 } = {}) {
469
513
  const files = await discoverSessionFiles({ days });
470
514
 
@@ -599,14 +643,10 @@ export async function runRouteScan({ days = 14 } = {}) {
599
643
  const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
600
644
  let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
601
645
  const unresolvedModels = new Set();
602
- const bumpDelegated = (key, run, saved) => {
603
- const d = delegatedStats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
604
- d.runs += 1;
605
- if (isFailedRun(run)) d.errRuns += 1;
606
- d.outTokens += run.out || 0;
607
- if (Number.isFinite(saved)) d.savedUsd += saved; // unpriced runs add nothing
608
- delegatedStats.set(key, d);
609
- };
646
+ // `sprag saved reset routing` zeroes the money, not the rule-health sample:
647
+ // see tallyDelegatedRun. readResetMarks never throws (no file = no reset).
648
+ const routingMark = readResetMarks(userDataDir()).routing;
649
+ const bumpDelegated = (key, run, saved) => tallyDelegatedRun(delegatedStats, key, run, saved, routingMark);
610
650
  // Ledger events feed the statusline's weekly/monthly "Routing saved"
611
651
  // totals. Keyed by run transcript path so overlapping re-scans upsert the
612
652
  // same event instead of double-counting it.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * saved-reset — a stopwatch reset for the lifetime "saved" counters.
3
+ *
4
+ * The ledgers are append-only evidence, so a reset must not delete anything.
5
+ * Instead this file records one timestamp per counter ("mark"), and every
6
+ * totals function counts only events at or after its mark. Undo pops the
7
+ * latest reset and puts the previous marks back, so a reset is always
8
+ * reversible and the underlying ledger data is never touched.
9
+ *
10
+ * File: <userDataDir>/saved-reset.json
11
+ * { "version": 1,
12
+ * "marks": { "routing", "doc2md", "codex-routing", "codex-docs" }, // ms | null
13
+ * "history": [ { "at", "scopes": [...], "prev": { "<scope>": ms | null } } ] }
14
+ *
15
+ * CJS because doc2md-ledger.cjs reads it; ESM code imports it the same way.
16
+ * Every caller passes the data directory, so tests can point at a temp dir.
17
+ *
18
+ * Best-effort like every other state file here: a missing or unreadable file
19
+ * reads as "never reset", so a broken marker can only over-count, never hide
20
+ * events.
21
+ */
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const VERSION = 1;
27
+ const SCOPES = ['routing', 'doc2md', 'codex-routing', 'codex-docs'];
28
+ /** Cap on remembered resets, so the file cannot grow without bound. */
29
+ const MAX_HISTORY = 50;
30
+
31
+ const emptyMarks = () => Object.fromEntries(SCOPES.map((s) => [s, null]));
32
+
33
+ function resetPath(dir) {
34
+ return path.join(dir, 'saved-reset.json');
35
+ }
36
+
37
+ const markOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
38
+
39
+ function readState(dir) {
40
+ try {
41
+ const data = JSON.parse(fs.readFileSync(resetPath(dir), 'utf8'));
42
+ if (!data || typeof data !== 'object' || data.version !== VERSION) return { marks: emptyMarks(), history: [] };
43
+ const marks = emptyMarks();
44
+ for (const s of SCOPES) marks[s] = markOrNull(data.marks && data.marks[s]);
45
+ const history = Array.isArray(data.history)
46
+ ? data.history.filter((h) => h && Array.isArray(h.scopes) && h.prev && typeof h.prev === 'object')
47
+ : [];
48
+ return { marks, history };
49
+ } catch {
50
+ return { marks: emptyMarks(), history: [] };
51
+ }
52
+ }
53
+
54
+ /** Atomic write: a temp file renamed over the target, so a reader never sees half a file. */
55
+ function writeState(dir, state) {
56
+ const file = resetPath(dir);
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const tmp = `${file}.${process.pid}.tmp`;
59
+ const text = JSON.stringify({ version: VERSION, marks: state.marks, history: state.history }) + '\n';
60
+ try {
61
+ fs.writeFileSync(tmp, text);
62
+ fs.renameSync(tmp, file);
63
+ } catch {
64
+ try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
65
+ fs.writeFileSync(file, text);
66
+ }
67
+ }
68
+
69
+ /** Current marks. Never throws; a missing or corrupt file means no reset. */
70
+ function readResetMarks(dir) {
71
+ return readState(dir).marks;
72
+ }
73
+
74
+ /**
75
+ * Reset the given scopes to `now`. Returns the new marks. Unknown scopes are
76
+ * ignored; the caller validates user input.
77
+ */
78
+ function resetSaved(scopes, { dir, now = Date.now() } = {}) {
79
+ const state = readState(dir);
80
+ const valid = [...new Set(scopes)].filter((s) => SCOPES.includes(s));
81
+ if (valid.length === 0) return state.marks;
82
+ const prev = {};
83
+ for (const s of valid) {
84
+ prev[s] = state.marks[s];
85
+ state.marks[s] = now;
86
+ }
87
+ state.history.push({ at: now, scopes: valid, prev });
88
+ if (state.history.length > MAX_HISTORY) state.history.splice(0, state.history.length - MAX_HISTORY);
89
+ writeState(dir, state);
90
+ return state.marks;
91
+ }
92
+
93
+ /**
94
+ * Undo the latest reset. Returns { scopes, marks } with the restored marks,
95
+ * or null when there is nothing to undo.
96
+ */
97
+ function undoReset({ dir } = {}) {
98
+ const state = readState(dir);
99
+ const last = state.history.pop();
100
+ if (!last) return null;
101
+ for (const s of last.scopes) {
102
+ if (SCOPES.includes(s)) state.marks[s] = markOrNull(last.prev[s]);
103
+ }
104
+ writeState(dir, state);
105
+ return { scopes: last.scopes, marks: state.marks };
106
+ }
107
+
108
+ /**
109
+ * True when an event at `ts` is excluded by `mark`: a mark exists and the
110
+ * event is older than it. An event with no usable timestamp cannot be shown
111
+ * to postdate the reset, so it is excluded too.
112
+ */
113
+ function isBeforeReset(ts, mark) {
114
+ if (mark === null || mark === undefined) return false;
115
+ return !Number.isFinite(ts) || ts < mark;
116
+ }
117
+
118
+ module.exports = { SCOPES, resetPath, readResetMarks, resetSaved, undoReset, isBeforeReset };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * savings-ledger — per-run delegation saving events with timestamps.
3
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
4
+ * model-rules.json stores `savedUsd` as a scan-window snapshot per rule (only
5
+ * the runs dated after the last `sprag saved reset routing`), which is right
6
+ * for rule-health but cannot answer "how much did routing save this
6
7
  * week / this month / ever". This ledger keeps one event per subagent run,
7
8
  * keyed by the run's transcript path so re-scans over overlapping windows
8
9
  * upsert instead of double-counting.
@@ -26,6 +27,7 @@ import { readFileSync } from 'node:fs';
26
27
  import { join } from 'node:path';
27
28
  import { userDataDir } from './paths.js';
28
29
  import { writeStateFile } from './state-file.js';
30
+ import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
29
31
 
30
32
  const WEEK_MS = 7 * 24 * 3600 * 1000;
31
33
  const MONTH_MS = 30 * 24 * 3600 * 1000;
@@ -140,13 +142,19 @@ export function modelFamily(model) {
140
142
  * Rolling totals: last 7 days, last 30 days, and lifetime, plus `pairs` — the
141
143
  * lifetime rollup by family-level model change, priciest first. `now` is
142
144
  * injectable for tests. Never throws — an unreadable ledger yields zeros.
145
+ *
146
+ * Events before the `routing` reset mark (`sprag saved reset`) are skipped
147
+ * everywhere, and `since` reports the mark (null when never reset). `dir` is
148
+ * the user data directory the mark is read from.
143
149
  */
144
- export function delegationSavedTotals(now = Date.now()) {
145
- const empty = () => ({ week: 0, month: 0, total: 0, pairs: [] });
150
+ export function delegationSavedTotals(now = Date.now(), dir = userDataDir()) {
151
+ const since = readResetMarks(dir).routing;
152
+ const empty = () => ({ week: 0, month: 0, total: 0, pairs: [], since });
146
153
  const totals = empty();
147
154
  const byPair = new Map();
148
155
  try {
149
156
  for (const e of Object.values(loadLedger().events)) {
157
+ if (isBeforeReset(e.ts, since)) continue;
150
158
  const usd = Number(e.usd);
151
159
  if (!Number.isFinite(usd)) continue;
152
160
  totals.total += usd;
@@ -18,9 +18,10 @@
18
18
  * and misses return identically shaped objects).
19
19
  */
20
20
 
21
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
21
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
23
  import { userDataDir } from './paths.js';
24
+ import { writeViaTmp, runHousekeeping, sweepStaleTmp } from './state-file.js';
24
25
  import { debug } from './debug.js';
25
26
 
26
27
  const CACHE_PATH = join(userDataDir(), 'session-cache.json');
@@ -185,12 +186,19 @@ export function saveCache(cache) {
185
186
  for (const [path, e] of Object.entries(cache.entries)) {
186
187
  if (e && typeof e.mtimeMs === 'number' && e.mtimeMs >= cutoff) entries[path] = e;
187
188
  }
188
- const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
189
- writeFileSync(tmp, JSON.stringify({ version: CACHE_VERSION, entries }) + '\n');
190
- renameSync(tmp, CACHE_PATH);
189
+ writeViaTmp(`${CACHE_PATH}.${process.pid}.tmp`, CACHE_PATH, JSON.stringify({ version: CACHE_VERSION, entries }) + '\n');
191
190
  } catch (e) {
192
191
  debug('session-cache:save', e); // best-effort — never block a report
193
192
  }
193
+ // This runs on nearly every statusline render, so it is the natural place to
194
+ // notice temp files that killed processes left in the data directory. The
195
+ // sweep itself runs at most once a day (see runHousekeeping).
196
+ try {
197
+ const dir = userDataDir();
198
+ runHousekeeping(dir, { 'stale-tmp': () => sweepStaleTmp(dir) });
199
+ } catch (e) {
200
+ debug('session-cache:housekeeping', e);
201
+ }
194
202
  }
195
203
 
196
204
  export function sessionCachePath() {
package/src/state-file.js CHANGED
@@ -11,8 +11,11 @@
11
11
  * that does not parse is copied aside before it is replaced.
12
12
  */
13
13
 
14
- import { writeFileSync, renameSync, readFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
15
- import { dirname } from 'node:path';
14
+ import {
15
+ writeFileSync, renameSync, readFileSync, existsSync, mkdirSync, copyFileSync, rmSync,
16
+ readdirSync, lstatSync, unlinkSync,
17
+ } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
16
19
 
17
20
  /** Where an unreadable state file is kept. One slot: the first copy holds the data worth recovering. */
18
21
  export const corruptCopyPath = (file) => `${file}.corrupt`;
@@ -41,3 +44,97 @@ export function writeStateFile(file, text) {
41
44
  writeFileSync(file, text);
42
45
  }
43
46
  }
47
+
48
+ /**
49
+ * Write `data` to `tmp`, then rename it over `file`. A failure at either step
50
+ * removes `tmp` before the error goes on, so a full disk or a rename refused
51
+ * by the OS does not leave a stray `<file>.<pid>.tmp` behind. The caller picks
52
+ * `tmp` (pid, uuid, ...) and decides what a failure means; this only makes
53
+ * sure the failure cleans up after itself. `unlink` errors are ignored: the
54
+ * original error is the one worth reporting.
55
+ */
56
+ export function writeViaTmp(tmp, file, data, options) {
57
+ try {
58
+ writeFileSync(tmp, data, options);
59
+ renameSync(tmp, file);
60
+ } catch (e) {
61
+ try { rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
62
+ throw e;
63
+ }
64
+ }
65
+
66
+ /** A temp file older than this was left by a process that died mid-write. */
67
+ export const STALE_TMP_AGE_MS = 60 * 60 * 1000;
68
+ /** `session-cache.json.52038.tmp`: the `<state file>.<pid>.tmp` shape the writers above use. */
69
+ const PID_TMP_NAME = /^.+\.json\.\d+\.tmp$/;
70
+
71
+ /**
72
+ * Remove temp files that a killed process left in `dir` (top level only).
73
+ *
74
+ * Only `<name>.json.<digits>.tmp` older than an hour goes: a younger one may
75
+ * belong to a writer that is between its write and its rename right now, and
76
+ * anything with another shape is not ours to judge. Symlinks are skipped
77
+ * rather than followed.
78
+ *
79
+ * @returns {number} how many files were removed
80
+ */
81
+ export function sweepStaleTmp(dir, { now = Date.now(), maxAgeMs = STALE_TMP_AGE_MS } = {}) {
82
+ let names;
83
+ try { names = readdirSync(dir); } catch { return 0; }
84
+ let removed = 0;
85
+ for (const name of names) {
86
+ if (!PID_TMP_NAME.test(name)) continue;
87
+ const path = join(dir, name);
88
+ try {
89
+ const st = lstatSync(path);
90
+ if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
91
+ unlinkSync(path);
92
+ removed += 1;
93
+ } catch { /* already gone, or not ours to remove */ }
94
+ }
95
+ return removed;
96
+ }
97
+
98
+ const HOUSEKEEPING_FILE = 'housekeeping.json';
99
+ export const HOUSEKEEPING_INTERVAL_MS = 24 * 60 * 60 * 1000;
100
+
101
+ /**
102
+ * Run directory clean-up jobs at most once per `intervalMs` each, however
103
+ * often this is called. Callers sit on hot paths (the statusline saves its
104
+ * cache on nearly every render; the Codex panel writes state every couple of
105
+ * seconds), and a `readdir` there would cost far more than the files it
106
+ * removes. The last run of each job is kept in `housekeeping.json` beside the
107
+ * data: one small read per call, and the directory is listed only when a job
108
+ * is actually due.
109
+ *
110
+ * The run is stamped BEFORE the job starts. A job that throws, or a stamp that
111
+ * cannot be written, then costs one skipped day rather than a retry on every
112
+ * call. A stamp that cannot be written skips the jobs entirely for the same
113
+ * reason.
114
+ *
115
+ * @param {string} dir
116
+ * @param {Record<string, () => void>} jobs - name -> job; names key the stamps
117
+ * @returns {string[]} names of the jobs that ran
118
+ */
119
+ export function runHousekeeping(dir, jobs, { now = Date.now(), intervalMs = HOUSEKEEPING_INTERVAL_MS } = {}) {
120
+ const file = join(dir, HOUSEKEEPING_FILE);
121
+ let marks = {};
122
+ try {
123
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
124
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) marks = parsed;
125
+ } catch { /* first run, or an unreadable stamp file: everything is due */ }
126
+ // A stamp in the future (clock moved back) would otherwise silence the job
127
+ // until the clock catches up; treat it as due.
128
+ const due = Object.keys(jobs).filter((name) => {
129
+ const age = now - Number(marks[name]);
130
+ return !(Number.isFinite(age) && age >= 0 && age < intervalMs);
131
+ });
132
+ if (!due.length) return [];
133
+ const next = { ...marks };
134
+ for (const name of due) next[name] = now;
135
+ try { writeStateFile(file, JSON.stringify(next) + '\n'); } catch { return []; }
136
+ for (const name of due) {
137
+ try { jobs[name](); } catch { /* clean-up is best effort */ }
138
+ }
139
+ return due;
140
+ }