sprag-cli 3.51.0 → 3.52.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/package.json +1 -1
- package/src/commands/brief.js +6 -2
- package/src/doc2md-ledger.cjs +3 -3
- package/src/parser.js +50 -9
- package/src/route-inject.js +53 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.52.0",
|
|
4
4
|
"description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/brief.js
CHANGED
|
@@ -34,8 +34,12 @@ export async function run({ hasFlag }) {
|
|
|
34
34
|
// the match here removes both steps. Silent unless it is sure — see
|
|
35
35
|
// src/route-inject.js.
|
|
36
36
|
try {
|
|
37
|
-
const { routeHint } = await import('../route-inject.js');
|
|
38
|
-
|
|
37
|
+
const { routeHint, sessionModelRank } = await import('../route-inject.js');
|
|
38
|
+
// The payload names the session but not its model, so the rank comes from
|
|
39
|
+
// the transcript: without it a Sonnet session is told to delegate to
|
|
40
|
+
// Sonnet, which saves nothing.
|
|
41
|
+
const sessionRank = sessionModelRank({ transcriptPath: ctx.transcript_path });
|
|
42
|
+
const hint = routeHint(ctx.prompt, { sessionRank });
|
|
39
43
|
if (hint) parts.push(hint);
|
|
40
44
|
} catch (e) { debug('brief:route-hint', e); }
|
|
41
45
|
if (parts.length) console.log(parts.join('\n'));
|
package/src/doc2md-ledger.cjs
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* `claude --print --input-format stream-json` with and without the file as a
|
|
24
24
|
* document block. The control turn cost 42,204 tokens, twice, to the token.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* report-a.pdf 7 pages +20,537 tokens 2,934 per page
|
|
27
|
+
* report-b.pdf 5 pages +12,709 tokens 2,542 per page
|
|
28
28
|
*
|
|
29
29
|
* A PDF is read whole: the model answered from its contents. Converting one
|
|
30
30
|
* to text is worth three to four times its own size.
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* outweigh the text many times over:
|
|
39
39
|
*
|
|
40
40
|
* aws-summit-seoul.pptx 2.1MB of slide XML ~540,429 tokens 23.8× the conversion
|
|
41
|
-
*
|
|
41
|
+
* 사업계획서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
|
|
42
42
|
*
|
|
43
43
|
* So the baseline for these formats is the body markup the converter read,
|
|
44
44
|
* measured per file rather than assumed from a ratio. It is a real number for
|
package/src/parser.js
CHANGED
|
@@ -149,12 +149,20 @@ export async function parseSessionFile(filePath) {
|
|
|
149
149
|
const TAIL_CHUNK = 64 * 1024;
|
|
150
150
|
const TAIL_CAP = 4 * 1024 * 1024;
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Walk a session JSONL backwards in bounded chunks, newest line first, and
|
|
154
|
+
* return the first non-null value `visit` produces. `hint` is a cheap substring
|
|
155
|
+
* test that skips parsing lines that cannot match.
|
|
156
|
+
*
|
|
157
|
+
* `found: false` means the cap was reached before the answer or the start of
|
|
158
|
+
* the file: the caller decides whether to fall back to a full scan or give up.
|
|
159
|
+
*/
|
|
160
|
+
function scanTail(filePath, hint, visit) {
|
|
153
161
|
let fd = null;
|
|
154
162
|
try {
|
|
155
163
|
fd = openSync(filePath, 'r');
|
|
156
164
|
const size = fstatSync(fd).size;
|
|
157
|
-
if (size === 0) return { found: true,
|
|
165
|
+
if (size === 0) return { found: true, value: null };
|
|
158
166
|
let end = size;
|
|
159
167
|
let carry = '';
|
|
160
168
|
while (end > 0 && size - end < TAIL_CAP) {
|
|
@@ -168,24 +176,57 @@ function lastUserTsFromTail(filePath) {
|
|
|
168
176
|
carry = start > 0 ? lines.shift() : '';
|
|
169
177
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
170
178
|
const line = lines[i];
|
|
171
|
-
if (!line || !line.includes(
|
|
179
|
+
if (!line || !line.includes(hint)) continue;
|
|
172
180
|
try {
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
181
|
+
const value = visit(JSON.parse(line));
|
|
182
|
+
if (value != null) return { found: true, value };
|
|
175
183
|
} catch { /* partial or malformed line — keep walking back */ }
|
|
176
184
|
}
|
|
177
185
|
end = start;
|
|
178
186
|
}
|
|
179
|
-
// Reached the start of the file without a
|
|
180
|
-
if (end === 0) return { found: true,
|
|
181
|
-
return { found: false,
|
|
187
|
+
// Reached the start of the file without a match: there is none.
|
|
188
|
+
if (end === 0) return { found: true, value: null };
|
|
189
|
+
return { found: false, value: null };
|
|
182
190
|
} catch {
|
|
183
|
-
return { found: false,
|
|
191
|
+
return { found: false, value: null };
|
|
184
192
|
} finally {
|
|
185
193
|
if (fd !== null) try { closeSync(fd); } catch { /* already closed */ }
|
|
186
194
|
}
|
|
187
195
|
}
|
|
188
196
|
|
|
197
|
+
function lastUserTsFromTail(filePath) {
|
|
198
|
+
const { found, value } = scanTail(filePath, '"type":"user"', (entry) =>
|
|
199
|
+
(entry.type === 'user' && entry.timestamp) || null);
|
|
200
|
+
return { found, ts: value };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Model id of the most recent assistant turn in a session JSONL.
|
|
205
|
+
*
|
|
206
|
+
* The UserPromptSubmit payload names the session but not its model, and the
|
|
207
|
+
* statusline snapshot is a single file shared by every session on the machine —
|
|
208
|
+
* with two sessions open, the last one to tick decides what it says. The
|
|
209
|
+
* transcript is the only per-session answer, so a caller that must not confuse
|
|
210
|
+
* two concurrent sessions reads it here first.
|
|
211
|
+
*
|
|
212
|
+
* Tail-only by design: this runs inside a prompt hook, so a session whose last
|
|
213
|
+
* 4MB carries no assistant turn returns null rather than re-reading the file.
|
|
214
|
+
* `<synthetic>` is skipped for the same reason session-records.js skips it —
|
|
215
|
+
* it marks a locally generated stub, not a model that answered.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} filePath absolute path to the session JSONL
|
|
218
|
+
* @returns {string|null}
|
|
219
|
+
*/
|
|
220
|
+
export function getSessionModel(filePath) {
|
|
221
|
+
if (!filePath) return null;
|
|
222
|
+
const { value } = scanTail(filePath, '"model"', (entry) => {
|
|
223
|
+
if (entry.type !== 'assistant') return null;
|
|
224
|
+
const model = entry.message && entry.message.model;
|
|
225
|
+
return typeof model === 'string' && model && model !== '<synthetic>' ? model : null;
|
|
226
|
+
});
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
189
230
|
export async function getLastUserMessageTime(filePath) {
|
|
190
231
|
let lastUserTs = null;
|
|
191
232
|
const tail = lastUserTsFromTail(filePath);
|
package/src/route-inject.js
CHANGED
|
@@ -25,10 +25,14 @@
|
|
|
25
25
|
* A hint that fires on the wrong request costs more than one that stays quiet.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
28
|
+
import { categorize, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
|
|
29
29
|
import { loadModelRules, budgetCapPhrase } from './model-rules.js';
|
|
30
30
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
31
31
|
import { userLanguage } from './config.js';
|
|
32
|
+
import { aliasForRole, resolveModelAlias } from './model-alias.js';
|
|
33
|
+
import { isRecognizedModelId, modelRank } from './cost.js';
|
|
34
|
+
import { loadRecentSnapshot } from './caps-cache.js';
|
|
35
|
+
import { getSessionModel } from './parser.js';
|
|
32
36
|
|
|
33
37
|
const PASTE_MIN_LINES = 8;
|
|
34
38
|
const LOG_SHAPE_RE = /^\s+at\s|\b(error|exception|traceback|warn(ing)?|fatal)\b|^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/im;
|
|
@@ -40,6 +44,38 @@ export function looksPasted(text) {
|
|
|
40
44
|
/** Shortest text worth classifying; below this a prompt is an ack, not a task. */
|
|
41
45
|
const MIN_LEN = 8;
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Price rank of whatever model is answering this session, or null when it
|
|
49
|
+
* cannot be established.
|
|
50
|
+
*
|
|
51
|
+
* Only a recognized id counts. `modelRank()` prices an unknown string as Sonnet
|
|
52
|
+
* so a cost estimate always has an answer, but here that guess would silence
|
|
53
|
+
* every T1 hint behind a gateway whose house alias (`prod-large`) names no
|
|
54
|
+
* Claude family — so an unrecognized id returns null and the hint keeps its
|
|
55
|
+
* previous behaviour instead.
|
|
56
|
+
*
|
|
57
|
+
* Sources, most trustworthy first: the session's own transcript, the gateway
|
|
58
|
+
* model environment, and finally the statusline snapshot, which is shared by
|
|
59
|
+
* every session on the machine and therefore only consulted last.
|
|
60
|
+
*
|
|
61
|
+
* @returns {number|null}
|
|
62
|
+
*/
|
|
63
|
+
export function sessionModelRank({ env = process.env, transcriptPath = null, snapshot } = {}) {
|
|
64
|
+
const rankOf = (raw) => {
|
|
65
|
+
if (!raw) return null;
|
|
66
|
+
if (isRecognizedModelId(raw)) return modelRank(raw);
|
|
67
|
+
// An opaque gateway id can still be priced through profile-map.json.
|
|
68
|
+
const mapped = resolveModelAlias(raw, { env });
|
|
69
|
+
return isRecognizedModelId(mapped) ? modelRank(mapped) : null;
|
|
70
|
+
};
|
|
71
|
+
const fromTranscript = rankOf(getSessionModel(transcriptPath));
|
|
72
|
+
if (fromTranscript !== null) return fromTranscript;
|
|
73
|
+
const fromEnv = rankOf(aliasForRole('main', env));
|
|
74
|
+
if (fromEnv !== null) return fromEnv;
|
|
75
|
+
const snap = snapshot === undefined ? loadRecentSnapshot() : snapshot;
|
|
76
|
+
return rankOf(snap && snap.model);
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
/**
|
|
44
80
|
* @param {string} text the prompt just submitted
|
|
45
81
|
* @param {object} [opts]
|
|
@@ -48,9 +84,12 @@ const MIN_LEN = 8;
|
|
|
48
84
|
* @param {string} [opts.root] project root used to look for a subagent's .md.
|
|
49
85
|
* The hint names the agent when that file exists and only its model tier when
|
|
50
86
|
* it does not, so a caller that needs a predictable phrase pins this.
|
|
87
|
+
* @param {number|null} [opts.sessionRank] price rank of the model reading the
|
|
88
|
+
* hint (see sessionModelRank). null means unknown, and an unknown session
|
|
89
|
+
* model filters nothing.
|
|
51
90
|
* @returns {string|null} the line to inject, or null to stay quiet
|
|
52
91
|
*/
|
|
53
|
-
export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
|
|
92
|
+
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null } = {}) {
|
|
54
93
|
const t = String(text || '').trim();
|
|
55
94
|
if (t.length < MIN_LEN) return null;
|
|
56
95
|
// Judgement and irreversible work stay on the top tier. This check comes
|
|
@@ -73,9 +112,19 @@ export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
|
|
|
73
112
|
const all = rules || loadModelRules().rules || [];
|
|
74
113
|
const matched = all.filter((r) => r && r.category === cat.id);
|
|
75
114
|
if (!matched.length) return null;
|
|
115
|
+
// A rule only saves anything when its target tier is cheaper than the model
|
|
116
|
+
// reading the hint. A T1 rule states "delegate to model: sonnet", which in a
|
|
117
|
+
// Sonnet session instructs a delegation worth nothing — and contradicts the
|
|
118
|
+
// registry's own clause that a session already at the target tier does not
|
|
119
|
+
// delegate. So drop the tiers this session cannot profit from; if that leaves
|
|
120
|
+
// nothing (a haiku session), stay quiet.
|
|
121
|
+
const usable = sessionRank === null
|
|
122
|
+
? matched
|
|
123
|
+
: matched.filter((r) => worthDelegating(r.tier, sessionRank));
|
|
124
|
+
if (!usable.length) return null;
|
|
76
125
|
|
|
77
|
-
const t2 =
|
|
78
|
-
const t1 =
|
|
126
|
+
const t2 = usable.find((r) => r.tier === 'T2');
|
|
127
|
+
const t1 = usable.find((r) => r.tier === 'T1');
|
|
79
128
|
const ko = lang === 'ko';
|
|
80
129
|
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
81
130
|
// `root` is threaded through to agentPhrase, which names the subagent only
|