aiopt 0.3.4 → 0.3.6
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.md +40 -0
- package/dist/cli.js +94 -17
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,46 @@ AIOpt is a **pre-deploy cost accident guardrail** for LLM changes.
|
|
|
16
16
|
|
|
17
17
|
No server. No upload. No account. No payments inside the CLI.
|
|
18
18
|
|
|
19
|
+
## Why AIOpt exists (in one breath)
|
|
20
|
+
LLM cost accidents don’t feel like “a bug” — they feel like **nothing… until your bill spikes**.
|
|
21
|
+
AIOpt makes cost visible **before merge** and gives you a **single dashboard** to sanity‑check usage.
|
|
22
|
+
|
|
23
|
+
Psychology (why teams actually use it):
|
|
24
|
+
- **Loss aversion:** preventing a surprise $200–$2,000 bill is more motivating than saving $3.
|
|
25
|
+
- **Default bias:** one command + dashboard means it gets used.
|
|
26
|
+
- **Immediate feedback:** PR checks + a clear verdict reduces decision fatigue.
|
|
27
|
+
|
|
28
|
+
## Fastest path (no docs)
|
|
29
|
+
### 1) Open the dashboard
|
|
30
|
+
```bash
|
|
31
|
+
npx --yes aiopt@latest dashboard --auto --port 3010
|
|
32
|
+
# open http://127.0.0.1:3010/
|
|
33
|
+
```
|
|
34
|
+
What happens automatically:
|
|
35
|
+
- If `usage.jsonl` is missing, AIOpt **auto-collects** OpenClaw session usage (local-only).
|
|
36
|
+
- If reports are missing, AIOpt **auto-runs** scan+guard once so the dashboard is never empty.
|
|
37
|
+
|
|
38
|
+
### 2) CI / PR gate (merge blocking)
|
|
39
|
+
```bash
|
|
40
|
+
npx --yes aiopt@latest gate
|
|
41
|
+
```
|
|
42
|
+
- exit `0`: OK
|
|
43
|
+
- exit `1`: policy violations found (top3 file:line)
|
|
44
|
+
|
|
45
|
+
## AI-friendly (for coding agents)
|
|
46
|
+
If you are an AI agent helping a developer:
|
|
47
|
+
- Prefer running **dashboard first** to show a single truth surface.
|
|
48
|
+
- Then use:
|
|
49
|
+
- `aiopt scan` to generate `report.json` / `report.md`
|
|
50
|
+
- `aiopt gate` to produce a merge decision (exit 0/1)
|
|
51
|
+
- `aiopt fix --apply` to propose safe patches (retry cap + cheap default model routing)
|
|
52
|
+
|
|
53
|
+
Machine-readable outputs:
|
|
54
|
+
- `aiopt-output/report.json` (stable summary)
|
|
55
|
+
- `aiopt-output/aiopt.sarif` (PR annotations)
|
|
56
|
+
- `aiopt-output/aiopt.patch` (autofix suggestions)
|
|
57
|
+
|
|
58
|
+
|
|
19
59
|
## 3-line quickstart (Guardrail)
|
|
20
60
|
```bash
|
|
21
61
|
npx aiopt install --force
|
package/dist/cli.js
CHANGED
|
@@ -656,7 +656,7 @@ var require_package = __commonJS({
|
|
|
656
656
|
"package.json"(exports2, module2) {
|
|
657
657
|
module2.exports = {
|
|
658
658
|
name: "aiopt",
|
|
659
|
-
version: "0.3.
|
|
659
|
+
version: "0.3.6",
|
|
660
660
|
description: "Pre-deploy LLM cost accident guardrail (serverless local CLI)",
|
|
661
661
|
bin: {
|
|
662
662
|
aiopt: "dist/cli.js"
|
|
@@ -1817,6 +1817,8 @@ async function startDashboard(cwd, opts) {
|
|
|
1817
1817
|
const file = (name) => import_path12.default.join(outDir, name);
|
|
1818
1818
|
let lastCollect = null;
|
|
1819
1819
|
let lastCollectError = null;
|
|
1820
|
+
let lastAutoGen = null;
|
|
1821
|
+
let lastAutoGenError = null;
|
|
1820
1822
|
function ensureUsageFile() {
|
|
1821
1823
|
try {
|
|
1822
1824
|
const usagePath = file("usage.jsonl");
|
|
@@ -1828,7 +1830,45 @@ async function startDashboard(cwd, opts) {
|
|
|
1828
1830
|
lastCollectError = String(e?.message || e || "collect failed");
|
|
1829
1831
|
}
|
|
1830
1832
|
}
|
|
1833
|
+
function loadRateTable2() {
|
|
1834
|
+
const p = import_path12.default.join(__dirname, "..", "rates", "rate_table.json");
|
|
1835
|
+
return JSON.parse(import_fs11.default.readFileSync(p, "utf8"));
|
|
1836
|
+
}
|
|
1837
|
+
function readEvents(usagePath) {
|
|
1838
|
+
if (!import_fs11.default.existsSync(usagePath)) return [];
|
|
1839
|
+
return isCsvPath(usagePath) ? readCsv(usagePath) : readJsonl(usagePath);
|
|
1840
|
+
}
|
|
1841
|
+
function ensureScanAndGuard() {
|
|
1842
|
+
const did = [];
|
|
1843
|
+
try {
|
|
1844
|
+
const usagePath = file("usage.jsonl");
|
|
1845
|
+
const rt = loadRateTable2();
|
|
1846
|
+
const needScan = !import_fs11.default.existsSync(file("report.json")) || !import_fs11.default.existsSync(file("report.md"));
|
|
1847
|
+
if (needScan) {
|
|
1848
|
+
const events = readEvents(usagePath);
|
|
1849
|
+
const { analysis, savings, policy, meta } = analyze(rt, events);
|
|
1850
|
+
policy.generated_from.input = usagePath;
|
|
1851
|
+
writeOutputs(outDir, analysis, savings, policy, { ...meta, cwd, cliVersion: "dashboard" });
|
|
1852
|
+
did.push("scan");
|
|
1853
|
+
}
|
|
1854
|
+
const needGuard = !import_fs11.default.existsSync(file("guard-last.txt")) || !import_fs11.default.existsSync(file("guard-last.json"));
|
|
1855
|
+
if (needGuard) {
|
|
1856
|
+
const events = readEvents(usagePath);
|
|
1857
|
+
const r = runGuard(rt, { baselineEvents: events, candidate: {} });
|
|
1858
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1859
|
+
import_fs11.default.writeFileSync(file("guard-last.txt"), r.message);
|
|
1860
|
+
import_fs11.default.writeFileSync(file("guard-last.json"), JSON.stringify({ ts, exitCode: r.exitCode }, null, 2));
|
|
1861
|
+
import_fs11.default.appendFileSync(file("guard-history.jsonl"), JSON.stringify({ ts, exitCode: r.exitCode, mode: "dashboard", baseline: usagePath, candidate: null }) + "\n");
|
|
1862
|
+
did.push("guard");
|
|
1863
|
+
}
|
|
1864
|
+
if (did.length) lastAutoGen = { ts: (/* @__PURE__ */ new Date()).toISOString(), did };
|
|
1865
|
+
lastAutoGenError = null;
|
|
1866
|
+
} catch (e) {
|
|
1867
|
+
lastAutoGenError = String(e?.message || e || "auto-gen failed");
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1831
1870
|
ensureUsageFile();
|
|
1871
|
+
ensureScanAndGuard();
|
|
1832
1872
|
function readOrNull(p) {
|
|
1833
1873
|
try {
|
|
1834
1874
|
if (!import_fs11.default.existsSync(p)) return null;
|
|
@@ -1914,7 +1954,11 @@ async function startDashboard(cwd, opts) {
|
|
|
1914
1954
|
<div class="mini" id="baseDir">base: \u2014</div>
|
|
1915
1955
|
<div class="mini" id="missingHint" style="margin-top:4px">checking files\u2026</div>
|
|
1916
1956
|
</div>
|
|
1917
|
-
<div class="pill"><span class="dot"></span> local-only \xB7 reads <span class="k">./aiopt-output</span> \xB7 <span id="live" class="muted">live: off</span
|
|
1957
|
+
<div class="pill"><span class="dot"></span> local-only \xB7 reads <span class="k">./aiopt-output</span> \xB7 <span id="live" class="muted">live: off</span>
|
|
1958
|
+
<span class="muted">\xB7</span>
|
|
1959
|
+
<button id="btnRefresh" style="all:unset; cursor:pointer; padding:2px 8px; border:1px solid rgba(255,255,255,.16); border-radius:999px; background:rgba(255,255,255,.04); font-size:12px">Refresh</button>
|
|
1960
|
+
<button id="btnLive" style="all:unset; cursor:pointer; padding:2px 8px; border:1px solid rgba(255,255,255,.16); border-radius:999px; background:rgba(255,255,255,.04); font-size:12px">Live: Off</button>
|
|
1961
|
+
</div>
|
|
1918
1962
|
</div>
|
|
1919
1963
|
|
|
1920
1964
|
<div class="grid">
|
|
@@ -2031,7 +2075,7 @@ async function load(){
|
|
|
2031
2075
|
const timer = setTimeout(()=>{
|
|
2032
2076
|
const el = document.getElementById('missingHint');
|
|
2033
2077
|
if(el && el.textContent && el.textContent.includes('checking')){
|
|
2034
|
-
el.textContent = 'still loading\u2026 (if this
|
|
2078
|
+
el.textContent = 'still loading\u2026 (if this does not change, refresh. If it persists: run aiopt quickstart --demo or aiopt scan)';
|
|
2035
2079
|
}
|
|
2036
2080
|
}, 1500);
|
|
2037
2081
|
|
|
@@ -2072,8 +2116,8 @@ async function load(){
|
|
|
2072
2116
|
|
|
2073
2117
|
const histTxt = await fetch('/api/guard-history.jsonl', { cache: 'no-store' }).then(r=>r.ok?r.text():null).catch(()=>null);
|
|
2074
2118
|
if(histTxt){
|
|
2075
|
-
const
|
|
2076
|
-
|
|
2119
|
+
const NL = String.fromCharCode(10);
|
|
2120
|
+
const lines = histTxt.trim().split(NL).filter(Boolean).slice(-15).reverse();
|
|
2077
2121
|
const pretty = [];
|
|
2078
2122
|
for(const l of lines){
|
|
2079
2123
|
try{
|
|
@@ -2085,8 +2129,7 @@ async function load(){
|
|
|
2085
2129
|
pretty.push(badge.padEnd(5) + ' ' + mode.padEnd(9) + ' ' + ts);
|
|
2086
2130
|
}catch{pretty.push(l)}
|
|
2087
2131
|
}
|
|
2088
|
-
document.getElementById('guardHist').textContent = pretty.join(
|
|
2089
|
-
');
|
|
2132
|
+
document.getElementById('guardHist').textContent = pretty.join(NL);
|
|
2090
2133
|
} else {
|
|
2091
2134
|
document.getElementById('guardHist').textContent = '(no guard-history.jsonl yet \u2014 run: aiopt guard)';
|
|
2092
2135
|
}
|
|
@@ -2096,7 +2139,13 @@ async function load(){
|
|
|
2096
2139
|
const total = reportJson.summary && reportJson.summary.total_cost_usd;
|
|
2097
2140
|
const sav = reportJson.summary && reportJson.summary.estimated_savings_usd;
|
|
2098
2141
|
document.getElementById('totalCost').textContent = 'total: ' + money(total);
|
|
2099
|
-
|
|
2142
|
+
|
|
2143
|
+
// UX: if savings is ~0, explicitly say it's normal (not broken).
|
|
2144
|
+
const sNum = Number(sav || 0);
|
|
2145
|
+
const savingsText = (Number.isFinite(sNum) && sNum <= 0.01)
|
|
2146
|
+
? 'savings: $0 (no obvious waste found)'
|
|
2147
|
+
: ('savings: ' + money(sav));
|
|
2148
|
+
document.getElementById('savings').textContent = savingsText;
|
|
2100
2149
|
renderBars(document.getElementById('byModel'), reportJson.top && reportJson.top.by_model);
|
|
2101
2150
|
renderBars(document.getElementById('byFeature'), reportJson.top && reportJson.top.by_feature);
|
|
2102
2151
|
document.getElementById('scanMeta').textContent = 'confidence=' + (reportJson.confidence || '\u2014') + ' \xB7 generated_at=' + (reportJson.generated_at || '\u2014');
|
|
@@ -2131,8 +2180,7 @@ async function load(){
|
|
|
2131
2180
|
const dollars = ('$' + (Math.round((Number(b.cost)||0)*100)/100).toFixed(2));
|
|
2132
2181
|
return String(label).padEnd(5) + ' ' + String(dollars).padStart(9) + ' (' + (b.calls||0) + ' calls)';
|
|
2133
2182
|
});
|
|
2134
|
-
document.getElementById('liveText').textContent = rows.join(
|
|
2135
|
-
');
|
|
2183
|
+
document.getElementById('liveText').textContent = rows.join(String.fromCharCode(10));
|
|
2136
2184
|
|
|
2137
2185
|
const liveEl = document.getElementById('live');
|
|
2138
2186
|
if(liveEl){
|
|
@@ -2168,8 +2216,7 @@ async function load(){
|
|
|
2168
2216
|
const dollars = ('$' + (Math.round((Number(b.cost)||0)*100)/100).toFixed(2));
|
|
2169
2217
|
return String(label).padEnd(7) + ' ' + String(dollars).padStart(9) + ' (' + (b.calls||0) + ' calls)';
|
|
2170
2218
|
});
|
|
2171
|
-
document.getElementById('trend').textContent = rows.join(
|
|
2172
|
-
');
|
|
2219
|
+
document.getElementById('trend').textContent = rows.join(String.fromCharCode(10));
|
|
2173
2220
|
} else {
|
|
2174
2221
|
document.getElementById('trend').textContent = '(no 7d data yet)';
|
|
2175
2222
|
document.getElementById('trendSvg').innerHTML = '';
|
|
@@ -2179,11 +2226,36 @@ async function load(){
|
|
|
2179
2226
|
document.getElementById('scan').textContent = reportMd || '(no report.md yet \u2014 run: aiopt scan)';
|
|
2180
2227
|
}
|
|
2181
2228
|
|
|
2182
|
-
//
|
|
2229
|
+
// Default: no auto-refresh (resource-friendly). User can refresh on demand.
|
|
2230
|
+
let liveTimer = null;
|
|
2231
|
+
|
|
2232
|
+
function setLive(on){
|
|
2233
|
+
const btn = document.getElementById('btnLive');
|
|
2234
|
+
const liveEl = document.getElementById('live');
|
|
2235
|
+
if(!btn || !liveEl) return;
|
|
2236
|
+
|
|
2237
|
+
if(on){
|
|
2238
|
+
btn.textContent = 'Live: On';
|
|
2239
|
+
liveEl.textContent = 'live: on';
|
|
2240
|
+
if(liveTimer) clearInterval(liveTimer);
|
|
2241
|
+
liveTimer = setInterval(()=>{ load(); }, 5000);
|
|
2242
|
+
} else {
|
|
2243
|
+
btn.textContent = 'Live: Off';
|
|
2244
|
+
liveEl.textContent = 'live: off';
|
|
2245
|
+
if(liveTimer) clearInterval(liveTimer);
|
|
2246
|
+
liveTimer = null;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
document.getElementById('btnRefresh')?.addEventListener('click', ()=>{ load(); });
|
|
2251
|
+
document.getElementById('btnLive')?.addEventListener('click', ()=>{
|
|
2252
|
+
const on = !liveTimer;
|
|
2253
|
+
setLive(on);
|
|
2254
|
+
load();
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
setLive(false);
|
|
2183
2258
|
load();
|
|
2184
|
-
setInterval(()=>{ load(); }, 2000);
|
|
2185
|
-
const liveEl = document.getElementById('live');
|
|
2186
|
-
if(liveEl) liveEl.textContent = 'live: on (polling)';
|
|
2187
2259
|
</script>
|
|
2188
2260
|
</body>
|
|
2189
2261
|
</html>`;
|
|
@@ -2198,10 +2270,11 @@ if(liveEl) liveEl.textContent = 'live: on (polling)';
|
|
|
2198
2270
|
const name = url.replace("/api/", "");
|
|
2199
2271
|
if (name === "_meta") {
|
|
2200
2272
|
ensureUsageFile();
|
|
2273
|
+
ensureScanAndGuard();
|
|
2201
2274
|
const expected = ["guard-last.txt", "guard-last.json", "report.json", "report.md", "usage.jsonl", "guard-history.jsonl"];
|
|
2202
2275
|
const missing = expected.filter((f) => !import_fs11.default.existsSync(file(f)));
|
|
2203
2276
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
2204
|
-
res.end(JSON.stringify({ baseDir: cwd, outDir, missing, collect: lastCollect, collectError: lastCollectError }, null, 2));
|
|
2277
|
+
res.end(JSON.stringify({ baseDir: cwd, outDir, missing, collect: lastCollect, collectError: lastCollectError, autoGen: lastAutoGen, autoGenError: lastAutoGenError }, null, 2));
|
|
2205
2278
|
return;
|
|
2206
2279
|
}
|
|
2207
2280
|
const allow = /* @__PURE__ */ new Set([
|
|
@@ -2215,6 +2288,7 @@ if(liveEl) liveEl.textContent = 'live: on (polling)';
|
|
|
2215
2288
|
"live-60m.json"
|
|
2216
2289
|
]);
|
|
2217
2290
|
if (name === "usage.jsonl" || name === "usage-summary.json" || name === "live-60m.json") ensureUsageFile();
|
|
2291
|
+
if (name === "report.json" || name === "report.md" || name === "guard-last.txt" || name === "guard-last.json" || name === "guard-history.jsonl") ensureScanAndGuard();
|
|
2218
2292
|
if (!allow.has(name)) {
|
|
2219
2293
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2220
2294
|
res.end("not found");
|
|
@@ -2304,6 +2378,9 @@ var init_dashboard = __esm({
|
|
|
2304
2378
|
import_fs11 = __toESM(require("fs"));
|
|
2305
2379
|
import_path12 = __toESM(require("path"));
|
|
2306
2380
|
init_collect();
|
|
2381
|
+
init_scan();
|
|
2382
|
+
init_io();
|
|
2383
|
+
init_guard();
|
|
2307
2384
|
}
|
|
2308
2385
|
});
|
|
2309
2386
|
|