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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.51.0",
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": {
@@ -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
- const hint = routeHint(ctx.prompt);
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'));
@@ -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
- * kohjuho_resume_kr.pdf 7 pages +20,537 tokens 2,934 per page
27
- * xeoyoung_resume.pdf 5 pages +12,709 tokens 2,542 per page
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
- * 우리은행이력서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
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
- function lastUserTsFromTail(filePath) {
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, ts: null };
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('"type":"user"')) continue;
179
+ if (!line || !line.includes(hint)) continue;
172
180
  try {
173
- const entry = JSON.parse(line);
174
- if (entry.type === 'user' && entry.timestamp) return { found: true, ts: entry.timestamp };
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 user entry: there is none.
180
- if (end === 0) return { found: true, ts: null };
181
- return { found: false, ts: null };
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, ts: null };
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);
@@ -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 = matched.find((r) => r.tier === 'T2');
78
- const t1 = matched.find((r) => r.tier === '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