sprag-cli 3.59.0 → 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.
- package/README.ko.md +17 -0
- package/README.md +23 -0
- package/bin/cli.js +51 -3
- package/package.json +1 -1
- package/src/caps-cache.js +3 -3
- package/src/codex-brief.js +4 -5
- package/src/codex-cache-policy.js +3 -3
- package/src/codex-delegation.js +3 -3
- package/src/codex-doc2md-ledger.js +3 -3
- package/src/codex-hooks.js +3 -3
- package/src/codex-installer.js +3 -3
- package/src/codex-ledger.js +4 -5
- package/src/codex-panel-session.js +3 -3
- package/src/codex-panel-state.js +51 -5
- package/src/codex-route-scan.js +3 -3
- package/src/commands/compact-window.js +6 -2
- package/src/commands/install.js +4 -1
- package/src/commands/korean.js +69 -3
- package/src/commands/route-scan.js +6 -4
- package/src/commands/saved.js +40 -1
- package/src/ctx-window-cache.js +14 -0
- package/src/formatters/statusline.js +167 -18
- package/src/harness-analyzer.cjs +8 -2
- package/src/harness.js +32 -23
- package/src/installer.js +52 -0
- package/src/model-alias.js +3 -3
- package/src/model-rules.js +3 -1
- package/src/preset-ratchet.js +3 -3
- package/src/reply-language.cjs +167 -0
- package/src/route-inject.js +43 -3
- package/src/route-scan.js +51 -11
- package/src/savings-ledger.js +3 -2
- package/src/session-cache.js +12 -4
- package/src/state-file.js +99 -2
- package/src/statusline-data.js +97 -0
- package/src/update-check.js +46 -6
|
@@ -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 };
|
package/src/route-inject.js
CHANGED
|
@@ -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 {
|
|
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
|
|
102
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
package/src/savings-ledger.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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.
|
package/src/session-cache.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* and misses return identically shaped objects).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync,
|
|
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
|
-
|
|
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 {
|
|
15
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline data helpers that need more than a number from the payload.
|
|
3
|
+
*
|
|
4
|
+
* bin/cli.js assembles the report data inline and cannot be imported (it runs
|
|
5
|
+
* on load), so the two derivations below live here where a test can reach them.
|
|
6
|
+
* Both read only what the render has already loaded or one small JSON file the
|
|
7
|
+
* statusline reads anyway; neither scans a transcript.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { modelFamily } from './savings-ledger.js';
|
|
12
|
+
import { loadModelRules } from './model-rules.js';
|
|
13
|
+
|
|
14
|
+
/** Longest family name shown before it is cut. Keeps an opaque gateway id from taking the line. */
|
|
15
|
+
const FAMILY_MAX = 14;
|
|
16
|
+
|
|
17
|
+
const shortFamily = (name) => (name.length > FAMILY_MAX ? `${name.slice(0, FAMILY_MAX - 1)}…` : name);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The subagent runs of the session the statusline is drawing for.
|
|
21
|
+
*
|
|
22
|
+
* The parser already attaches each session's subagent runs (`subagentRuns`,
|
|
23
|
+
* one entry per run with its own model), so this only finds the right session
|
|
24
|
+
* among those loaded and tallies them by model family. The session is matched
|
|
25
|
+
* by transcript path first, then by session id, because Claude Code reports
|
|
26
|
+
* both and either can be absent or spelled differently (a symlinked home).
|
|
27
|
+
*
|
|
28
|
+
* The runs are those the analysis window kept, so a session older than the
|
|
29
|
+
* window (`sprag mode` default is one day) reports only its recent runs.
|
|
30
|
+
*
|
|
31
|
+
* Families come from `modelFamily`, which returns an unmapped model id as it
|
|
32
|
+
* is rather than folding it into a neighbour; the name is shortened here so an
|
|
33
|
+
* ARN cannot flood the line.
|
|
34
|
+
*
|
|
35
|
+
* @param {Array} sessions parseAllSessions result
|
|
36
|
+
* @param {{ transcriptPath?: string|null, sessionId?: string|null }} [ids]
|
|
37
|
+
* @returns {{ total: number, families: Array<{ family: string, runs: number }> }|null}
|
|
38
|
+
* null when the session cannot be found or ran no subagents
|
|
39
|
+
*/
|
|
40
|
+
export function sessionSubagentSummary(sessions, { transcriptPath = null, sessionId = null } = {}) {
|
|
41
|
+
if (!Array.isArray(sessions) || (!transcriptPath && !sessionId)) return null;
|
|
42
|
+
let hit = null;
|
|
43
|
+
if (transcriptPath) {
|
|
44
|
+
const want = resolve(transcriptPath);
|
|
45
|
+
hit = sessions.find((s) => s && s.filePath && resolve(s.filePath) === want) || null;
|
|
46
|
+
}
|
|
47
|
+
if (!hit && sessionId) {
|
|
48
|
+
// A resumed conversation can leave more than one file under one id; the
|
|
49
|
+
// most recently active is the one still being written.
|
|
50
|
+
const end = (s) => (s.endTime ? new Date(s.endTime).getTime() || 0 : 0);
|
|
51
|
+
hit = sessions
|
|
52
|
+
.filter((s) => s && s.sessionId === sessionId)
|
|
53
|
+
.sort((a, b) => end(b) - end(a))[0] || null;
|
|
54
|
+
}
|
|
55
|
+
const runs = hit && Array.isArray(hit.subagentRuns) ? hit.subagentRuns : [];
|
|
56
|
+
if (runs.length === 0) return null;
|
|
57
|
+
const byFamily = new Map();
|
|
58
|
+
for (const r of runs) {
|
|
59
|
+
const name = shortFamily(modelFamily(r && r.model));
|
|
60
|
+
byFamily.set(name, (byFamily.get(name) || 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
const families = [...byFamily.entries()]
|
|
63
|
+
.map(([family, n]) => ({ family, runs: n }))
|
|
64
|
+
.sort((a, b) => b.runs - a.runs || a.family.localeCompare(b.family));
|
|
65
|
+
return { total: runs.length, families };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many delegation rules apply in this project, and how many of those are
|
|
70
|
+
* flagged for review.
|
|
71
|
+
*
|
|
72
|
+
* "Apply" is the same test the `rule-health` warning uses: a global rule, or a
|
|
73
|
+
* project rule whose target is this root. A rule switched `off` is not counted.
|
|
74
|
+
* Review is `status === 'review'`, which is what model-rules.js renders as its
|
|
75
|
+
* `⚠ rule-health` marker (the marker itself is a local closure there, so the
|
|
76
|
+
* condition is restated rather than imported).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} projectRoot
|
|
79
|
+
* @returns {{ total: number, review: number }}
|
|
80
|
+
*/
|
|
81
|
+
export function delegationRuleStats(projectRoot) {
|
|
82
|
+
let rules = [];
|
|
83
|
+
try {
|
|
84
|
+
rules = loadModelRules().rules;
|
|
85
|
+
} catch {
|
|
86
|
+
return { total: 0, review: 0 };
|
|
87
|
+
}
|
|
88
|
+
let total = 0;
|
|
89
|
+
let review = 0;
|
|
90
|
+
for (const r of rules) {
|
|
91
|
+
if (!r || r.status === 'off') continue;
|
|
92
|
+
if (r.scope !== 'global' && r.targetRoot !== projectRoot) continue;
|
|
93
|
+
total += 1;
|
|
94
|
+
if (r.status === 'review') review += 1;
|
|
95
|
+
}
|
|
96
|
+
return { total, review };
|
|
97
|
+
}
|