ultracost 0.2.1 → 0.3.1
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/CHANGELOG.md +67 -1
- package/NOTICE +16 -3
- package/README.md +101 -14
- package/bin/cli.js +514 -117
- package/docs/ESTIMATES.md +24 -0
- package/docs/PUBLISHING.md +41 -34
- package/docs/architecture.md +19 -1
- package/docs/policy.md +25 -2
- package/package.json +1 -1
- package/src/classify.js +125 -0
- package/src/cost.js +54 -0
- package/src/detect.js +93 -0
- package/src/estimate.js +18 -0
- package/src/guard.js +244 -166
- package/src/index.js +7 -1
- package/src/lexer.js +227 -0
- package/src/log.js +20 -13
- package/src/loop.js +143 -0
- package/src/paths.js +10 -0
- package/src/policy.js +14 -0
- package/src/render.js +211 -0
- package/src/rules.js +17 -5
- package/src/transcript.js +186 -0
- package/templates/hooks/reinject.mjs +21 -18
- package/templates/hooks/workflow-gate.mjs +51 -45
- package/templates/policy.default.json +15 -2
package/src/lexer.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Minimal zero-dependency JavaScript tokenizer. NOT a full parser: it emits a flat
|
|
2
|
+
// token stream rich enough to (a) locate real `agent(...)` / `agent?.(...)` call sites
|
|
3
|
+
// (never inside strings, template literals, or comments), (b) read an options object
|
|
4
|
+
// literal's `model`/`effort` values and detect spreads, and (c) recover the literal
|
|
5
|
+
// text of a possibly-concatenated prompt argument. It resolves the regex-vs-divide
|
|
6
|
+
// ambiguity, optional chaining, and nested template substitutions.
|
|
7
|
+
|
|
8
|
+
export const TT = {
|
|
9
|
+
NAME: 'name', // identifier or keyword
|
|
10
|
+
PUNCT: 'punct', // operator / punctuation
|
|
11
|
+
STRING: 'string', // '...' or "..."
|
|
12
|
+
TEMPLATE: 'template', // `...` — value is the cooked text, or null when it has a ${} substitution
|
|
13
|
+
NUMBER: 'number',
|
|
14
|
+
REGEX: 'regex'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const isIdStart = (ch) =>
|
|
18
|
+
ch === '_' || ch === '$' ||
|
|
19
|
+
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
|
|
20
|
+
ch.charCodeAt(0) > 127;
|
|
21
|
+
const isIdPart = (ch) => isIdStart(ch) || (ch >= '0' && ch <= '9');
|
|
22
|
+
const isDigit = (ch) => ch >= '0' && ch <= '9';
|
|
23
|
+
const isWS = (ch) =>
|
|
24
|
+
ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' ||
|
|
25
|
+
ch === '\f' || ch === '\v' || ch === '\u00a0' || ch === '\ufeff';
|
|
26
|
+
|
|
27
|
+
// Keywords after which a `/` starts a REGEX (they expect an expression next). After
|
|
28
|
+
// any other name (identifier, this/true/null/...) a `/` is DIVISION.
|
|
29
|
+
const KEYWORD_PREFIX = new Set([
|
|
30
|
+
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
|
|
31
|
+
'throw', 'do', 'else', 'yield', 'await', 'case'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const PUNCT3 = new Set(['...', '===', '!==', '**=', '<<=', '>>=', '&&=', '||=', '??=', '>>>']);
|
|
35
|
+
const PUNCT2 = new Set([
|
|
36
|
+
'?.', '=>', '==', '!=', '<=', '>=', '&&', '||', '??',
|
|
37
|
+
'+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '++', '--', '**', '<<', '>>'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function decodeEscapes(inner) {
|
|
41
|
+
if (inner.indexOf('\\') === -1) return inner;
|
|
42
|
+
let out = '';
|
|
43
|
+
for (let i = 0; i < inner.length; i++) {
|
|
44
|
+
if (inner[i] !== '\\') { out += inner[i]; continue; }
|
|
45
|
+
const c = inner[++i];
|
|
46
|
+
if (c === 'n') out += '\n';
|
|
47
|
+
else if (c === 't') out += '\t';
|
|
48
|
+
else if (c === 'r') out += '\r';
|
|
49
|
+
else if (c === 'b') out += '\b';
|
|
50
|
+
else if (c === 'f') out += '\f';
|
|
51
|
+
else if (c === 'v') out += '\v';
|
|
52
|
+
else if (c === '0') out += '\0';
|
|
53
|
+
else if (c === 'x') { out += String.fromCharCode(parseInt(inner.substr(i + 1, 2), 16) || 0); i += 2; }
|
|
54
|
+
else if (c === 'u') {
|
|
55
|
+
if (inner[i + 1] === '{') {
|
|
56
|
+
const e = inner.indexOf('}', i);
|
|
57
|
+
out += String.fromCodePoint(parseInt(inner.slice(i + 2, e), 16) || 0);
|
|
58
|
+
i = e;
|
|
59
|
+
} else { out += String.fromCharCode(parseInt(inner.substr(i + 1, 4), 16) || 0); i += 4; }
|
|
60
|
+
} else if (c === '\n') { /* line continuation: drop */ }
|
|
61
|
+
else if (c !== undefined) out += c;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function tokenize(src) {
|
|
67
|
+
const tokens = [];
|
|
68
|
+
const n = src.length;
|
|
69
|
+
let i = 0;
|
|
70
|
+
let prev = null;
|
|
71
|
+
const push = (t) => { tokens.push(t); prev = t; };
|
|
72
|
+
|
|
73
|
+
const regexAllowed = () => {
|
|
74
|
+
if (!prev) return true;
|
|
75
|
+
if (prev.type === TT.NUMBER || prev.type === TT.STRING || prev.type === TT.TEMPLATE || prev.type === TT.REGEX) return false;
|
|
76
|
+
if (prev.type === TT.NAME) return KEYWORD_PREFIX.has(prev.value);
|
|
77
|
+
if (prev.type === TT.PUNCT) return !(prev.value === ')' || prev.value === ']' || prev.value === '}');
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
while (i < n) {
|
|
82
|
+
const ch = src[i];
|
|
83
|
+
if (isWS(ch)) { i++; continue; }
|
|
84
|
+
|
|
85
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
86
|
+
i += 2; while (i < n && src[i] !== '\n') i++; continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
89
|
+
i += 2; while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; i = Math.min(n, i + 2); continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ch === "'" || ch === '"') {
|
|
93
|
+
const start = i; const q = ch; i++;
|
|
94
|
+
let inner = '';
|
|
95
|
+
while (i < n) {
|
|
96
|
+
const c = src[i];
|
|
97
|
+
if (c === '\\') { inner += c + (src[i + 1] ?? ''); i += 2; continue; }
|
|
98
|
+
if (c === q) { i++; break; }
|
|
99
|
+
if (c === '\n') break; // unterminated
|
|
100
|
+
inner += c; i++;
|
|
101
|
+
}
|
|
102
|
+
push({ type: TT.STRING, value: decodeEscapes(inner), quote: q, start, end: i });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (ch === '`') {
|
|
107
|
+
const start = i; i++;
|
|
108
|
+
let hasSub = false;
|
|
109
|
+
let cooked = '';
|
|
110
|
+
while (i < n) {
|
|
111
|
+
const c = src[i];
|
|
112
|
+
if (c === '\\') { cooked += c + (src[i + 1] ?? ''); i += 2; continue; }
|
|
113
|
+
if (c === '`') { i++; break; }
|
|
114
|
+
if (c === '$' && src[i + 1] === '{') {
|
|
115
|
+
hasSub = true; i += 2;
|
|
116
|
+
let depth = 1;
|
|
117
|
+
while (i < n && depth > 0) {
|
|
118
|
+
const d = src[i];
|
|
119
|
+
if (d === '{') { depth++; i++; }
|
|
120
|
+
else if (d === '}') { depth--; i++; }
|
|
121
|
+
else if (d === '`') { i = skipTemplate(src, i, n); }
|
|
122
|
+
else if (d === "'" || d === '"') { i = skipString(src, i, n); }
|
|
123
|
+
else i++;
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
cooked += c; i++;
|
|
128
|
+
}
|
|
129
|
+
push({ type: TT.TEMPLATE, value: hasSub ? null : decodeEscapes(cooked), hasSub, start, end: i });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ch === '/') {
|
|
134
|
+
if (regexAllowed()) {
|
|
135
|
+
const start = i; i++;
|
|
136
|
+
let inClass = false;
|
|
137
|
+
while (i < n) {
|
|
138
|
+
const c = src[i];
|
|
139
|
+
if (c === '\\') { i += 2; continue; }
|
|
140
|
+
if (c === '[') inClass = true;
|
|
141
|
+
else if (c === ']') inClass = false;
|
|
142
|
+
else if (c === '/' && !inClass) { i++; break; }
|
|
143
|
+
else if (c === '\n') break;
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
while (i < n && isIdPart(src[i])) i++; // flags
|
|
147
|
+
push({ type: TT.REGEX, value: src.slice(start, i), start, end: i });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// otherwise fall through: `/` or `/=` is a division/assignment punctuator
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isDigit(ch) || (ch === '.' && isDigit(src[i + 1]))) {
|
|
154
|
+
const start = i; i++;
|
|
155
|
+
while (i < n) {
|
|
156
|
+
const c = src[i];
|
|
157
|
+
if ((c === '+' || c === '-')) { if (src[i - 1] === 'e' || src[i - 1] === 'E') { i++; continue; } break; }
|
|
158
|
+
if (isIdPart(c) || c === '.') { i++; continue; }
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
push({ type: TT.NUMBER, value: src.slice(start, i), start, end: i });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isIdStart(ch)) {
|
|
166
|
+
const start = i; i++;
|
|
167
|
+
while (i < n && isIdPart(src[i])) i++;
|
|
168
|
+
push({ type: TT.NAME, value: src.slice(start, i), start, end: i });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const three = src.slice(i, i + 3);
|
|
173
|
+
if (PUNCT3.has(three)) { push({ type: TT.PUNCT, value: three, start: i, end: i + 3 }); i += 3; continue; }
|
|
174
|
+
const two = src.slice(i, i + 2);
|
|
175
|
+
if (PUNCT2.has(two)) { push({ type: TT.PUNCT, value: two, start: i, end: i + 2 }); i += 2; continue; }
|
|
176
|
+
push({ type: TT.PUNCT, value: ch, start: i, end: i + 1 }); i += 1;
|
|
177
|
+
}
|
|
178
|
+
return tokens;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Skip a single- or double-quoted string starting at `from` (the quote). Returns the
|
|
182
|
+
// index just past the closing quote.
|
|
183
|
+
function skipString(src, from, n) {
|
|
184
|
+
const q = src[from];
|
|
185
|
+
let i = from + 1;
|
|
186
|
+
while (i < n) {
|
|
187
|
+
if (src[i] === '\\') { i += 2; continue; }
|
|
188
|
+
if (src[i] === q || src[i] === '\n') { return i + 1; }
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
return i;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Skip a template literal starting at `from` (the backtick), including any nested
|
|
195
|
+
// ${...} substitutions and nested templates. Returns the index past the closing tick.
|
|
196
|
+
function skipTemplate(src, from, n) {
|
|
197
|
+
let i = from + 1;
|
|
198
|
+
while (i < n) {
|
|
199
|
+
const c = src[i];
|
|
200
|
+
if (c === '\\') { i += 2; continue; }
|
|
201
|
+
if (c === '`') return i + 1;
|
|
202
|
+
if (c === '$' && src[i + 1] === '{') {
|
|
203
|
+
i += 2; let depth = 1;
|
|
204
|
+
while (i < n && depth > 0) {
|
|
205
|
+
const d = src[i];
|
|
206
|
+
if (d === '{') { depth++; i++; }
|
|
207
|
+
else if (d === '}') { depth--; i++; }
|
|
208
|
+
else if (d === '`') { i = skipTemplate(src, i, n); }
|
|
209
|
+
else if (d === "'" || d === '"') { i = skipString(src, i, n); }
|
|
210
|
+
else i++;
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
return i;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 1-based line and column for a source index (for findings).
|
|
220
|
+
export function lineColAt(src, index) {
|
|
221
|
+
let line = 1;
|
|
222
|
+
let last = 0;
|
|
223
|
+
for (let i = 0; i < index && i < src.length; i++) {
|
|
224
|
+
if (src[i] === '\n') { line++; last = i + 1; }
|
|
225
|
+
}
|
|
226
|
+
return { line, column: index - last + 1 };
|
|
227
|
+
}
|
package/src/log.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
1
|
+
import { color, bold, dim } from './render.js';
|
|
4
2
|
|
|
3
|
+
// Back-compatible styling surface. `c.*` keeps the names the CLI already uses, now
|
|
4
|
+
// backed by the brand palette in render.js (truecolor with 256/16/no-color fallback).
|
|
5
5
|
export const c = {
|
|
6
|
-
bold
|
|
7
|
-
dim
|
|
8
|
-
red:
|
|
9
|
-
green:
|
|
10
|
-
yellow:
|
|
11
|
-
cyan:
|
|
6
|
+
bold,
|
|
7
|
+
dim,
|
|
8
|
+
red: color.red,
|
|
9
|
+
green: color.green,
|
|
10
|
+
yellow: color.amber,
|
|
11
|
+
cyan: color.cyan,
|
|
12
|
+
violet: color.violet,
|
|
13
|
+
magenta: color.magenta,
|
|
14
|
+
amber: color.amber,
|
|
15
|
+
slate: color.slate
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
export const log = (msg = '') => console.log(msg);
|
|
15
|
-
export const ok = (msg) => log(`${
|
|
16
|
-
export const warn = (msg) => log(`${
|
|
17
|
-
export const err = (msg) => log(`${
|
|
18
|
-
export const info = (msg) => log(
|
|
19
|
+
export const ok = (msg) => log(`${color.green('✓')} ${msg}`);
|
|
20
|
+
export const warn = (msg) => log(`${color.amber('!')} ${msg}`);
|
|
21
|
+
export const err = (msg) => log(`${color.red('✗')} ${msg}`);
|
|
22
|
+
export const info = (msg) => log(dim(msg));
|
|
23
|
+
|
|
24
|
+
// Re-export the full render kit so callers can `import { panel, columns, bar } from './log.js'`.
|
|
25
|
+
export * from './render.js';
|
package/src/loop.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { CALIBRATION_PATH, LEDGER_PATH } from './paths.js';
|
|
4
|
+
import { costFromUsage, modelPrice, totalTokens } from './cost.js';
|
|
5
|
+
import { tierOfModel } from './classify.js';
|
|
6
|
+
|
|
7
|
+
// The closed loop: turn the per-stage token sums from transcript.js into reconciled
|
|
8
|
+
// cost (actual vs an all-opus baseline), a self-calibrating token prior, and a
|
|
9
|
+
// persisted savings ledger. All offline; reuses the cost model in cost.js.
|
|
10
|
+
|
|
11
|
+
const median = (arr) => {
|
|
12
|
+
if (!arr.length) return 0;
|
|
13
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
14
|
+
const m = Math.floor(s.length / 2);
|
|
15
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function ensureDir(file) {
|
|
19
|
+
const d = dirname(file);
|
|
20
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Effective input tokens — fold cache reads/writes into an input-equivalent at the
|
|
24
|
+
// policy's multipliers so the estimator's single input number prices realistically.
|
|
25
|
+
function effectiveInput(u, policy) {
|
|
26
|
+
const mult = policy?.estimation?.cacheMultipliers || { cacheRead: 0.1, cacheWrite: 1.25 };
|
|
27
|
+
return (u.input_tokens || 0) +
|
|
28
|
+
(u.cache_read_input_tokens || 0) * (mult.cacheRead ?? 0.1) +
|
|
29
|
+
(u.cache_creation_input_tokens || 0) * (mult.cacheWrite ?? 1.25);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reconcile one workflow run: actual cost per stage at its real model vs the all-opus
|
|
33
|
+
// baseline (the same tokens re-priced at opus rates).
|
|
34
|
+
export function reconcileRun(run, policy) {
|
|
35
|
+
const opusPrice = modelPrice('opus', policy);
|
|
36
|
+
const stages = run.stages.map((s) => {
|
|
37
|
+
const price = modelPrice(s.model, policy);
|
|
38
|
+
return {
|
|
39
|
+
...s,
|
|
40
|
+
tier: tierOfModel(s.model),
|
|
41
|
+
tokens: totalTokens(s.usage),
|
|
42
|
+
actualCost: costFromUsage(s.usage, price, policy),
|
|
43
|
+
opusCost: costFromUsage(s.usage, opusPrice, policy)
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
const actual = stages.reduce((n, s) => n + s.actualCost, 0);
|
|
47
|
+
const allOpus = stages.reduce((n, s) => n + s.opusCost, 0);
|
|
48
|
+
return {
|
|
49
|
+
wfId: run.wfId,
|
|
50
|
+
dir: run.dir,
|
|
51
|
+
project: run.project,
|
|
52
|
+
ts: run.mtime ? new Date(run.mtime).toISOString() : null,
|
|
53
|
+
stages,
|
|
54
|
+
totals: {
|
|
55
|
+
actual,
|
|
56
|
+
allOpus,
|
|
57
|
+
saved: allOpus - actual,
|
|
58
|
+
savedPct: allOpus ? Math.round((1 - actual / allOpus) * 100) : 0,
|
|
59
|
+
tokens: stages.reduce((n, s) => n + s.tokens, 0)
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build a calibrated token prior from real runs. tokencast-style: drop per-stage
|
|
65
|
+
// outliers (> 3x or < 0.2x the median total) before taking medians.
|
|
66
|
+
export function calibrationFromRuns(runs, policy) {
|
|
67
|
+
const stages = runs.flatMap((r) => r.stages || []);
|
|
68
|
+
let recs = stages
|
|
69
|
+
.map((s) => ({ inT: effectiveInput(s.usage, policy), outT: s.usage.output_tokens || 0, tot: totalTokens(s.usage), model: s.model }))
|
|
70
|
+
.filter((r) => r.tot > 0);
|
|
71
|
+
if (!recs.length) return null;
|
|
72
|
+
const medTot = median(recs.map((r) => r.tot));
|
|
73
|
+
recs = recs.filter((r) => r.tot <= medTot * 3 && r.tot >= medTot * 0.2);
|
|
74
|
+
if (!recs.length) return null;
|
|
75
|
+
|
|
76
|
+
const tokensPerStage = { input: Math.round(median(recs.map((r) => r.inT))), output: Math.round(median(recs.map((r) => r.outT))) };
|
|
77
|
+
const perModel = {};
|
|
78
|
+
for (const k of ['opus', 'sonnet']) {
|
|
79
|
+
const ms = recs.filter((r) => String(r.model || '').toLowerCase().includes(k));
|
|
80
|
+
if (ms.length) perModel[k] = { input: Math.round(median(ms.map((r) => r.inT))), output: Math.round(median(ms.map((r) => r.outT))), samples: ms.length };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
_asOf: new Date().toISOString().slice(0, 10),
|
|
84
|
+
runs: runs.length,
|
|
85
|
+
samples: recs.length,
|
|
86
|
+
droppedOutliers: stages.length - recs.length,
|
|
87
|
+
tokensPerStage,
|
|
88
|
+
perModel
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readCalibration() {
|
|
93
|
+
if (!existsSync(CALIBRATION_PATH)) return null;
|
|
94
|
+
try { return JSON.parse(readFileSync(CALIBRATION_PATH, 'utf8')); } catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function writeCalibration(cal) {
|
|
98
|
+
ensureDir(CALIBRATION_PATH);
|
|
99
|
+
writeFileSync(CALIBRATION_PATH, JSON.stringify(cal, null, 2) + '\n');
|
|
100
|
+
return CALIBRATION_PATH;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Return a shallow policy clone whose estimator token prior comes from calibration
|
|
104
|
+
// (if present). estimate.js stays pure — the CLI/gate opt in via this.
|
|
105
|
+
export function applyCalibration(policy, cal = readCalibration()) {
|
|
106
|
+
if (!cal || !cal.tokensPerStage) return policy;
|
|
107
|
+
return { ...policy, estimation: { ...policy.estimation, tokensPerStage: cal.tokensPerStage }, _calibrated: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function ledgerRead() {
|
|
111
|
+
if (!existsSync(LEDGER_PATH)) return [];
|
|
112
|
+
try {
|
|
113
|
+
return readFileSync(LEDGER_PATH, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
114
|
+
} catch { return []; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reconcile every run and upsert one ledger line per workflow id (idempotent — re-running
|
|
118
|
+
// does not double-count). Returns the full, deduped, date-sorted ledger.
|
|
119
|
+
export function ledgerSync(runs, policy) {
|
|
120
|
+
const byId = new Map(ledgerRead().map((e) => [e.wfId, e]));
|
|
121
|
+
for (const run of runs) {
|
|
122
|
+
const r = reconcileRun(run, policy);
|
|
123
|
+
byId.set(r.wfId, {
|
|
124
|
+
wfId: r.wfId,
|
|
125
|
+
project: r.project,
|
|
126
|
+
ts: r.ts,
|
|
127
|
+
stages: r.stages.length,
|
|
128
|
+
actual: r.totals.actual,
|
|
129
|
+
allOpus: r.totals.allOpus,
|
|
130
|
+
saved: r.totals.saved,
|
|
131
|
+
savedPct: r.totals.savedPct,
|
|
132
|
+
tokens: r.totals.tokens
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const entries = [...byId.values()].sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
|
|
136
|
+
ensureDir(LEDGER_PATH);
|
|
137
|
+
writeFileSync(LEDGER_PATH, entries.map((e) => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : ''));
|
|
138
|
+
return entries;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function spentToday(entries = ledgerRead(), day = new Date().toISOString().slice(0, 10)) {
|
|
142
|
+
return entries.filter((e) => String(e.ts).slice(0, 10) === day).reduce((n, e) => n + (e.actual || 0), 0);
|
|
143
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -12,10 +12,20 @@ export const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR
|
|
|
12
12
|
: join(HOME, '.claude');
|
|
13
13
|
export const CLAUDE_MD = join(CLAUDE_DIR, 'CLAUDE.md');
|
|
14
14
|
export const SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
|
15
|
+
export const SETTINGS_LOCAL = join(CLAUDE_DIR, 'settings.local.json');
|
|
15
16
|
export const ULTRACOST_DIR = join(CLAUDE_DIR, 'ultracost');
|
|
16
17
|
export const POLICY_PATH = join(ULTRACOST_DIR, 'policy.json');
|
|
17
18
|
export const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
|
|
18
19
|
|
|
20
|
+
// Local data the closed-loop commands persist (calibration priors + savings ledger).
|
|
21
|
+
export const CALIBRATION_PATH = join(ULTRACOST_DIR, 'calibration.json');
|
|
22
|
+
export const LEDGER_PATH = join(ULTRACOST_DIR, 'ledger.jsonl');
|
|
23
|
+
|
|
24
|
+
// Plugin delivery: Claude Code caches the plugin under plugins/cache/<owner>/<name>/<version>/.
|
|
25
|
+
export const PLUGINS_DIR = join(CLAUDE_DIR, 'plugins');
|
|
26
|
+
export const PLUGIN_CACHE_DIR = join(PLUGINS_DIR, 'cache', 'ultracost', 'ultracost');
|
|
27
|
+
export const PLUGIN_ID = 'ultracost@ultracost';
|
|
28
|
+
|
|
19
29
|
export const HOOK_PATH = join(ULTRACOST_DIR, 'reinject.mjs');
|
|
20
30
|
|
|
21
31
|
export const DEFAULT_POLICY = join(ROOT, 'templates', 'policy.default.json');
|
package/src/policy.js
CHANGED
|
@@ -49,6 +49,16 @@ export function normalize(input) {
|
|
|
49
49
|
p.estimation.tokensPerStage ??= { input: 2000, output: 1200 };
|
|
50
50
|
p.estimation.effortOutputMultiplier ??= { low: 0.4, medium: 1, high: 1.8, xhigh: 3, max: 4 };
|
|
51
51
|
p.estimation.assumedFanout ??= 5;
|
|
52
|
+
p.estimation.cacheMultipliers ??= { cacheRead: 0.1, cacheWrite: 1.25 };
|
|
53
|
+
|
|
54
|
+
p.classify ??= {};
|
|
55
|
+
p.classify.keywords ??= {};
|
|
56
|
+
p.classify.keywords.opus ??= [];
|
|
57
|
+
p.classify.keywords.sonnet ??= [];
|
|
58
|
+
|
|
59
|
+
p.budget ??= {};
|
|
60
|
+
p.budget.perRun ??= null;
|
|
61
|
+
p.budget.perDay ??= null;
|
|
52
62
|
|
|
53
63
|
const errors = [];
|
|
54
64
|
if (!p.tiers[p.default]) errors.push(`default tier "${p.default}" is not defined in tiers`);
|
|
@@ -61,6 +71,10 @@ export function normalize(input) {
|
|
|
61
71
|
errors.push(`tier "${name}" uses model "${t.model}" which is listed in neverUse`);
|
|
62
72
|
}
|
|
63
73
|
}
|
|
74
|
+
for (const k of ['perRun', 'perDay']) {
|
|
75
|
+
const v = p.budget[k];
|
|
76
|
+
if (v !== null && !(typeof v === 'number' && v >= 0)) errors.push(`budget.${k} must be a non-negative number or null`);
|
|
77
|
+
}
|
|
64
78
|
if (errors.length) throw new Error('Invalid policy:\n - ' + errors.join('\n - '));
|
|
65
79
|
return p;
|
|
66
80
|
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Zero-dependency terminal rendering kit. Uses only Node stdlib: getColorDepth for
|
|
2
|
+
// capability detection, stripVTControlCharacters + Intl.Segmenter for width-aware
|
|
3
|
+
// alignment, and hand-rolled ANSI for truecolor/256/16 with graceful downsample.
|
|
4
|
+
// Honors NO_COLOR and FORCE_COLOR. No npm dependencies (a hard project constraint).
|
|
5
|
+
|
|
6
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
7
|
+
|
|
8
|
+
// ultracost brand palette — ported from scripts/generate-architecture-svg.py so the
|
|
9
|
+
// CLI matches the docs/architecture diagram.
|
|
10
|
+
export const COLORS = {
|
|
11
|
+
violet: '#a78bfa',
|
|
12
|
+
magenta: '#e879f9',
|
|
13
|
+
pink: '#f472b6',
|
|
14
|
+
cyan: '#22d3ee',
|
|
15
|
+
lilac: '#c4b5fd',
|
|
16
|
+
amber: '#fbbf24',
|
|
17
|
+
green: '#34d399',
|
|
18
|
+
red: '#fb7185',
|
|
19
|
+
clay: '#d97757',
|
|
20
|
+
slate: '#94a3b8'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function colorDepth() {
|
|
24
|
+
if (process.env.NO_COLOR !== undefined) return 1;
|
|
25
|
+
const fc = process.env.FORCE_COLOR;
|
|
26
|
+
if (fc !== undefined) {
|
|
27
|
+
if (fc === '0' || fc === 'false') return 1;
|
|
28
|
+
if (fc === '1' || fc === 'true') return 4;
|
|
29
|
+
if (fc === '2') return 8;
|
|
30
|
+
return 24;
|
|
31
|
+
}
|
|
32
|
+
if (!process.stdout || !process.stdout.isTTY) return 1;
|
|
33
|
+
try { return process.stdout.getColorDepth(); } catch { return 4; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const supportsColor = () => colorDepth() > 1;
|
|
37
|
+
|
|
38
|
+
function hexToRgb(hex) {
|
|
39
|
+
const h = hex.replace('#', '');
|
|
40
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rgbTo256(r, g, b) {
|
|
44
|
+
if (r === g && g === b) {
|
|
45
|
+
if (r < 8) return 16;
|
|
46
|
+
if (r > 248) return 231;
|
|
47
|
+
return Math.round(((r - 8) / 247) * 24) + 232;
|
|
48
|
+
}
|
|
49
|
+
return 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rgbTo16(r, g, b) {
|
|
53
|
+
const bit = (v) => (v > 110 ? 1 : 0);
|
|
54
|
+
let code = 30 + (bit(r) | (bit(g) << 1) | (bit(b) << 2));
|
|
55
|
+
if ((r + g + b) / 3 > 150) code += 60;
|
|
56
|
+
return code;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Wrap a string in a truecolor/256/16 foreground escape appropriate to the terminal.
|
|
60
|
+
export function paint(str, hex) {
|
|
61
|
+
const d = colorDepth();
|
|
62
|
+
if (d <= 1) return String(str);
|
|
63
|
+
const [r, g, b] = hexToRgb(hex);
|
|
64
|
+
if (d >= 24) return `\x1b[38;2;${r};${g};${b}m${str}\x1b[39m`;
|
|
65
|
+
if (d >= 8) return `\x1b[38;5;${rgbTo256(r, g, b)}m${str}\x1b[39m`;
|
|
66
|
+
return `\x1b[${rgbTo16(r, g, b)}m${str}\x1b[39m`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sgr = (open, close) => (s) => (supportsColor() ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
70
|
+
export const bold = sgr(1, 22);
|
|
71
|
+
export const dim = sgr(2, 22);
|
|
72
|
+
export const italic = sgr(3, 23);
|
|
73
|
+
export const underline = sgr(4, 24);
|
|
74
|
+
|
|
75
|
+
// Named brand colorizers, e.g. color.violet('text').
|
|
76
|
+
export const color = Object.fromEntries(Object.entries(COLORS).map(([k, hex]) => [k, (s) => paint(s, hex)]));
|
|
77
|
+
|
|
78
|
+
// A left-to-right two-stop gradient across a string (truecolor only; else solid start).
|
|
79
|
+
export function gradient(str, startHex, endHex) {
|
|
80
|
+
const s = String(str);
|
|
81
|
+
if (colorDepth() < 24) return paint(s, startHex);
|
|
82
|
+
const [r1, g1, b1] = hexToRgb(startHex);
|
|
83
|
+
const [r2, g2, b2] = hexToRgb(endHex);
|
|
84
|
+
const chars = [...s];
|
|
85
|
+
const n = Math.max(1, chars.length - 1);
|
|
86
|
+
return chars
|
|
87
|
+
.map((ch, i) => {
|
|
88
|
+
const t = i / n;
|
|
89
|
+
const r = Math.round(r1 + (r2 - r1) * t);
|
|
90
|
+
const g = Math.round(g1 + (g2 - g1) * t);
|
|
91
|
+
const b = Math.round(b1 + (b2 - b1) * t);
|
|
92
|
+
return `\x1b[38;2;${r};${g};${b}m${ch}`;
|
|
93
|
+
})
|
|
94
|
+
.join('') + '\x1b[39m';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
98
|
+
const WIDE = [
|
|
99
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
|
|
100
|
+
[0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
|
|
101
|
+
[0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff],
|
|
102
|
+
[0x20000, 0x3fffd]
|
|
103
|
+
];
|
|
104
|
+
const isWide = (cp) => WIDE.some(([a, b]) => cp >= a && cp <= b);
|
|
105
|
+
|
|
106
|
+
// Display width that ignores ANSI escapes and counts wide/emoji graphemes as 2.
|
|
107
|
+
export function displayWidth(str) {
|
|
108
|
+
const plain = stripVTControlCharacters(String(str));
|
|
109
|
+
let w = 0;
|
|
110
|
+
for (const { segment } of segmenter.segment(plain)) {
|
|
111
|
+
const cp = segment.codePointAt(0);
|
|
112
|
+
if (cp === 0) continue;
|
|
113
|
+
w += segment.length > 1 || isWide(cp) ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
return w;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function truncate(str, max, ellipsis = '…') {
|
|
119
|
+
if (displayWidth(str) <= max) return String(str);
|
|
120
|
+
let out = '';
|
|
121
|
+
let w = 0;
|
|
122
|
+
for (const { segment } of segmenter.segment(stripVTControlCharacters(String(str)))) {
|
|
123
|
+
const cw = segment.length > 1 || isWide(segment.codePointAt(0)) ? 2 : 1;
|
|
124
|
+
if (w + cw > max - 1) break;
|
|
125
|
+
out += segment;
|
|
126
|
+
w += cw;
|
|
127
|
+
}
|
|
128
|
+
return out + ellipsis;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function pad(str, width, align = 'left') {
|
|
132
|
+
const s = String(str);
|
|
133
|
+
const gap = Math.max(0, width - displayWidth(s));
|
|
134
|
+
if (align === 'right') return ' '.repeat(gap) + s;
|
|
135
|
+
if (align === 'center') {
|
|
136
|
+
const l = Math.floor(gap / 2);
|
|
137
|
+
return ' '.repeat(l) + s + ' '.repeat(gap - l);
|
|
138
|
+
}
|
|
139
|
+
return s + ' '.repeat(gap);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A proportional bar built from eighth-blocks for sub-cell precision.
|
|
143
|
+
export function bar(value, max, width = 24, hex = COLORS.green) {
|
|
144
|
+
const frac = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
|
|
145
|
+
const units = frac * width;
|
|
146
|
+
const full = Math.floor(units);
|
|
147
|
+
const rem = units - full;
|
|
148
|
+
const eighths = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
|
|
149
|
+
const tip = rem > 0 ? eighths[Math.round(rem * 8)] || '' : '';
|
|
150
|
+
const filled = '█'.repeat(full) + tip;
|
|
151
|
+
const used = full + (tip ? 1 : 0);
|
|
152
|
+
return paint(filled, hex) + dim('░'.repeat(Math.max(0, width - used)));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
156
|
+
export function sparkline(values, hex) {
|
|
157
|
+
const nums = values.filter((v) => Number.isFinite(v));
|
|
158
|
+
if (!nums.length) return '';
|
|
159
|
+
const min = Math.min(...nums);
|
|
160
|
+
const max = Math.max(...nums);
|
|
161
|
+
const span = max - min || 1;
|
|
162
|
+
const s = values
|
|
163
|
+
.map((v) => (Number.isFinite(v) ? SPARK[Math.min(7, Math.floor(((v - min) / span) * 7.999))] : ' '))
|
|
164
|
+
.join('');
|
|
165
|
+
return hex ? paint(s, hex) : s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const hr = (width, hex = COLORS.slate, ch = '─') => paint(ch.repeat(Math.max(0, width)), hex);
|
|
169
|
+
|
|
170
|
+
// A rounded panel with an optional title. `lines` is an array of already-styled rows.
|
|
171
|
+
export function panel(lines, { title = '', hex = COLORS.violet, pad: padX = 1, minWidth = 0 } = {}) {
|
|
172
|
+
const body = Array.isArray(lines) ? lines : String(lines).split('\n');
|
|
173
|
+
const inner = Math.max(minWidth, displayWidth(title) + 2, ...body.map((l) => displayWidth(l)));
|
|
174
|
+
const w = inner + padX * 2;
|
|
175
|
+
const sp = ' '.repeat(padX);
|
|
176
|
+
const top = title
|
|
177
|
+
? paint('╭─ ', hex) + bold(title) + ' ' + paint('─'.repeat(Math.max(0, w - displayWidth(title) - 3)) + '╮', hex)
|
|
178
|
+
: paint('╭' + '─'.repeat(w) + '╮', hex);
|
|
179
|
+
const mid = body.map((l) => paint('│', hex) + sp + pad(l, inner) + sp + paint('│', hex));
|
|
180
|
+
const bot = paint('╰' + '─'.repeat(w) + '╯', hex);
|
|
181
|
+
return [top, ...mid, bot].join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Aligned columns (no grid borders). rows: array of arrays of (possibly styled) cells.
|
|
185
|
+
export function columns(rows, { align = [], gap = 2, head = null, indent = 0 } = {}) {
|
|
186
|
+
const all = head ? [head, ...rows] : rows;
|
|
187
|
+
const cols = Math.max(0, ...all.map((r) => r.length));
|
|
188
|
+
const widths = [];
|
|
189
|
+
for (let c = 0; c < cols; c++) widths[c] = Math.max(0, ...all.map((r) => displayWidth(r[c] ?? '')));
|
|
190
|
+
const pre = ' '.repeat(indent);
|
|
191
|
+
const sep = ' '.repeat(gap);
|
|
192
|
+
const renderRow = (r) =>
|
|
193
|
+
pre + r.map((cell, c) => pad(cell ?? '', widths[c], align[c] || 'left')).join(sep).replace(/\s+$/, '');
|
|
194
|
+
const out = [];
|
|
195
|
+
if (head) {
|
|
196
|
+
out.push(pre + head.map((cell, c) => bold(pad(cell ?? '', widths[c], align[c] || 'left'))).join(sep).replace(/\s+$/, ''));
|
|
197
|
+
}
|
|
198
|
+
for (const r of rows) out.push(renderRow(r));
|
|
199
|
+
return out.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const symbols = {
|
|
203
|
+
ok: '✓',
|
|
204
|
+
warn: '!',
|
|
205
|
+
err: '✗',
|
|
206
|
+
bullet: '•',
|
|
207
|
+
arrow: '→',
|
|
208
|
+
dot: '●',
|
|
209
|
+
pin: '●',
|
|
210
|
+
none: '○'
|
|
211
|
+
};
|