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 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.4",
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></div>
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 doesn't change, refresh. If it persists: run aiopt quickstart --demo or aiopt scan)';
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 lines = histTxt.trim().split('
2076
- ').filter(Boolean).slice(-15).reverse();
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
- document.getElementById('savings').textContent = 'savings: ' + money(sav);
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
- // Auto-refresh (simple polling): updates the dashboard as files change.
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