loki-mode 7.10.1 → 7.12.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/autonomy/run.sh CHANGED
@@ -4121,6 +4121,69 @@ generate_proof_of_run() {
4121
4121
  return 0
4122
4122
  }
4123
4123
 
4124
+ # print_ttfv_next_steps: R7 zero-config first-run "what next / go deeper"
4125
+ # message. The wording MUST match what actually ran, so it branches on the mode:
4126
+ # - brief: a one-line brief ran on the lightweight profile (council off,
4127
+ # simple tier, capped iterations). Proof contains diffs, cost, time
4128
+ # (council verdicts are absent because the council was disabled).
4129
+ # - repo: a no-arg in-repo run analyzed the codebase and ran at full depth
4130
+ # (council on). Proof contains diffs, cost, time, and council
4131
+ # verdicts.
4132
+ # This function only prints; the caller owns the TTY gate. Never fails the run.
4133
+ # Usage: print_ttfv_next_steps <mode> <result>
4134
+ print_ttfv_next_steps() {
4135
+ local mode="${1:-}"
4136
+ local result="${2:-0}"
4137
+ local loki_dir="${TARGET_DIR:-.}/.loki"
4138
+ local proofs_dir="$loki_dir/proofs"
4139
+
4140
+ echo ""
4141
+ echo "============================================================"
4142
+ if [ "$result" = "0" ]; then
4143
+ echo " First pass complete. Here is what you have:"
4144
+ else
4145
+ echo " First pass ended early. Here is what was produced:"
4146
+ fi
4147
+ echo "============================================================"
4148
+ echo ""
4149
+ echo " What I did:"
4150
+ if [ "$mode" = "brief" ]; then
4151
+ echo " - Worked from your one-line brief on a fast, lightweight first"
4152
+ echo " pass (council off, simple tier, capped iterations)."
4153
+ echo " - Generated a proof-of-run (diffs, cost, time)."
4154
+ else
4155
+ echo " - Analyzed your codebase and generated a PRD, then ran a full"
4156
+ echo " first pass (council on, full RARV-C depth)."
4157
+ echo " - Generated a proof-of-run (diffs, cost, time, council verdicts)."
4158
+ fi
4159
+ echo ""
4160
+ echo " See the visible artifact (proof-of-run):"
4161
+ if [ -d "$proofs_dir" ]; then
4162
+ local latest
4163
+ latest=$(ls -1t "$proofs_dir" 2>/dev/null | head -1)
4164
+ if [ -n "$latest" ]; then
4165
+ echo " loki proof open $latest"
4166
+ echo " (or open $proofs_dir/$latest/index.html)"
4167
+ else
4168
+ echo " loki proof list"
4169
+ fi
4170
+ else
4171
+ echo " loki proof list"
4172
+ fi
4173
+ echo ""
4174
+ if [ "$mode" = "brief" ]; then
4175
+ echo " Go deeper (full RARV-C depth, council-gated):"
4176
+ echo " loki start # continue / harden this project"
4177
+ echo " loki start ./prd.md # build from a full PRD"
4178
+ else
4179
+ echo " Next steps:"
4180
+ echo " loki start ./prd.md # build from a full PRD"
4181
+ echo " loki start \"<one line>\" # fast first pass from a brief"
4182
+ fi
4183
+ echo ""
4184
+ return 0
4185
+ }
4186
+
4124
4187
  track_iteration_complete() {
4125
4188
  local iteration="$1"
4126
4189
  local exit_code="${2:-0}"
@@ -8416,6 +8479,28 @@ BUDGETUPD_EOF
8416
8479
  BUDGETUPD_EOF
8417
8480
  fi
8418
8481
 
8482
+ # Anti-surprise-cost warn (R3): when spend crosses 80% of the cap but is
8483
+ # still under 100%, log a warning and emit an event. Does NOT pause: the
8484
+ # warn is the transparency the user wants BEFORE the hard cap stops them.
8485
+ # Read-time classification only; budget.json schema is unchanged.
8486
+ local warn
8487
+ warn=$(python3 -c "
8488
+ import sys
8489
+ try:
8490
+ cost = float(sys.argv[1]); limit = float(sys.argv[2])
8491
+ print(1 if (limit > 0 and 0.80 * limit <= cost < limit) else 0)
8492
+ except (ValueError, IndexError):
8493
+ print(0)
8494
+ " "$current_cost" "$BUDGET_LIMIT" 2>/dev/null || echo "0")
8495
+ if [[ "$warn" == "1" ]]; then
8496
+ log_warn "BUDGET WARNING: \$${current_cost} is at or above 80% of cap \$${BUDGET_LIMIT}. Run continues; hard-stop at 100%."
8497
+ emit_event_json "budget_warning" \
8498
+ "limit=${BUDGET_LIMIT}" \
8499
+ "current=${current_cost}" \
8500
+ "threshold_percent=80" \
8501
+ "iteration=${ITERATION_COUNT:-0}"
8502
+ fi
8503
+
8419
8504
  return 1
8420
8505
  }
8421
8506
 
@@ -13313,6 +13398,15 @@ main() {
13313
13398
  generate_proof_of_run "$result" || true
13314
13399
  fi
13315
13400
 
13401
+ # R7 (zero-config first run): "what next / go deeper" framing. Only when the
13402
+ # CLI flagged this as a TTFV first run and stdout is a TTY, so it stays
13403
+ # silent in CI / pipes and never fires for normal PRD runs. The wording
13404
+ # branches on the mode (brief = lightweight first pass; repo = full-depth
13405
+ # codebase analysis) so the message always matches what actually ran.
13406
+ if [ -n "${LOKI_TTFV:-}" ] && [ -t 1 ]; then
13407
+ print_ttfv_next_steps "${LOKI_TTFV}" "$result" || true
13408
+ fi
13409
+
13316
13410
  # Create PR from agent branch if branch protection was enabled
13317
13411
  create_session_pr
13318
13412
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.10.1"
10
+ __version__ = "7.12.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -459,6 +459,7 @@ async def _push_loki_state_loop() -> None:
459
459
  """
460
460
  last_mtime: float = 0.0
461
461
  _last_skill_hash: str = "" # Track skill-session state changes
462
+ _last_budget_status: str = "" # Track budget-status transitions (R3)
462
463
  while True:
463
464
  try:
464
465
  if not manager.active_connections:
@@ -469,6 +470,26 @@ async def _push_loki_state_loop() -> None:
469
470
  state_file = loki_dir / "dashboard-state.json"
470
471
  _session_file = loki_dir / "session.json"
471
472
 
473
+ # R3 anti-surprise-cost: proactively push a budget_status message
474
+ # when spend crosses a threshold (ok -> warn -> exceeded), so a user
475
+ # who is not watching the terminal sees the 80% warning in any open
476
+ # dashboard page BEFORE the hard stop at 100%. Reuses the existing
477
+ # WebSocket broadcast path (manager.broadcast); no second channel.
478
+ # Sent on transition (independent of the dashboard-state.json mtime
479
+ # gate) because budget can cross 80% while that file is unchanged.
480
+ try:
481
+ _budget = _compute_budget_snapshot(loki_dir)
482
+ _bstatus = _budget.get("status", "none")
483
+ if _bstatus in ("warn", "exceeded") and _bstatus != _last_budget_status:
484
+ await manager.broadcast({
485
+ "type": "budget_status",
486
+ "data": _budget,
487
+ })
488
+ # Track every status so a return to ok/none re-arms the warn push.
489
+ _last_budget_status = _bstatus
490
+ except (OSError, ValueError, KeyError):
491
+ pass
492
+
472
493
  _broadcast_sent = False
473
494
 
474
495
  if state_file.exists():
@@ -4551,6 +4572,214 @@ async def get_budget():
4551
4572
  }
4552
4573
 
4553
4574
 
4575
+ # Budget warn threshold: surface a "warn" status before the hard cap so users
4576
+ # are not surprised by a bill. Matches the runtime warn in run.sh
4577
+ # check_budget_limit() and budget.ts (warn at 80%, hard-stop at 100%).
4578
+ _BUDGET_WARN_FRACTION = 0.80
4579
+
4580
+
4581
+ def _budget_status(used: float, limit: Optional[float]) -> str:
4582
+ """Classify budget usage. Read-time only; no state mutation.
4583
+
4584
+ Returns one of: "none" (no limit set), "ok" (<80%), "warn" (>=80% and
4585
+ <100%), "exceeded" (>=100%). The warn band is the anti-surprise wedge:
4586
+ the user sees it BEFORE the hard cap pauses the run.
4587
+ """
4588
+ if limit is None or limit <= 0:
4589
+ return "none"
4590
+ if used >= limit:
4591
+ return "exceeded"
4592
+ if used >= _BUDGET_WARN_FRACTION * limit:
4593
+ return "warn"
4594
+ return "ok"
4595
+
4596
+
4597
+ def _compute_budget_snapshot(loki_dir: _Path) -> dict:
4598
+ """Read-time budget snapshot shared by /api/cost/timeline and the WS push.
4599
+
4600
+ Single source of truth so the proactive WebSocket broadcast and the pull
4601
+ endpoint never disagree. "used" is the current run's spend (sum of the live
4602
+ .loki/metrics/efficiency/iteration-*.json records, mirroring
4603
+ check_budget_limit in run.sh). The cap comes from budget.json, falling back
4604
+ to the LOKI_BUDGET_LIMIT env var. No state is mutated.
4605
+ """
4606
+ efficiency_dir = loki_dir / "metrics" / "efficiency"
4607
+ budget_file = loki_dir / "metrics" / "budget.json"
4608
+
4609
+ current_total = 0.0
4610
+ if efficiency_dir.exists():
4611
+ for eff_file in sorted(efficiency_dir.glob("iteration-*.json")):
4612
+ data = _safe_json_read(eff_file, default=None)
4613
+ if not isinstance(data, dict):
4614
+ continue
4615
+ inp = data.get("input_tokens", 0) or 0
4616
+ out = data.get("output_tokens", 0) or 0
4617
+ model = str(data.get("model", "sonnet")).lower()
4618
+ cost = data.get("cost_usd")
4619
+ if cost is None:
4620
+ cost = _calculate_model_cost(model, inp, out)
4621
+ else:
4622
+ try:
4623
+ cost = float(cost)
4624
+ except (TypeError, ValueError):
4625
+ cost = 0.0
4626
+ current_total += cost
4627
+
4628
+ budget_limit = None
4629
+ if budget_file.exists():
4630
+ bdata = _safe_json_read(budget_file, default=None)
4631
+ if isinstance(bdata, dict):
4632
+ budget_limit = bdata.get("limit") or bdata.get("budget_limit")
4633
+ if budget_limit is None:
4634
+ env_limit = os.environ.get("LOKI_BUDGET_LIMIT", "")
4635
+ if env_limit:
4636
+ try:
4637
+ budget_limit = float(env_limit)
4638
+ except ValueError:
4639
+ budget_limit = None
4640
+ if budget_limit is not None:
4641
+ try:
4642
+ budget_limit = float(budget_limit)
4643
+ except (TypeError, ValueError):
4644
+ budget_limit = None
4645
+
4646
+ used = round(current_total, 6)
4647
+ if budget_limit is not None and budget_limit > 0:
4648
+ remaining = max(0.0, budget_limit - used)
4649
+ percent_used = round((used / budget_limit) * 100, 2)
4650
+ else:
4651
+ remaining = None
4652
+ percent_used = None
4653
+ status = _budget_status(used, budget_limit)
4654
+
4655
+ return {
4656
+ "limit": budget_limit,
4657
+ "used": used,
4658
+ "remaining": round(remaining, 6) if remaining is not None else None,
4659
+ "percent_used": percent_used,
4660
+ "status": status,
4661
+ "warn_threshold_percent": int(_BUDGET_WARN_FRACTION * 100),
4662
+ "exceeded": status == "exceeded",
4663
+ }
4664
+
4665
+
4666
+ @app.get("/api/cost/timeline")
4667
+ async def get_cost_timeline():
4668
+ """Cost over time: intra-run per-iteration series + per-run history.
4669
+
4670
+ Two honest series, distinct sources (see docs/R3-COST-OBSERVABILITY-DESIGN.md):
4671
+ - current_run: from .loki/metrics/efficiency/iteration-*.json. This dir is
4672
+ wiped at the start of every run (run.sh), so it only ever holds the
4673
+ CURRENT run's iterations. Used for the intra-run cumulative line.
4674
+ - runs: from .loki/proofs/<run_id>/proof.json (persistent, one per run).
4675
+ This is the real per-run/per-project "cost over time" history.
4676
+
4677
+ Budget status is computed at read time (no budget.json schema change) and
4678
+ classifies into ok/warn/exceeded so the UI can warn at 80% before the cap.
4679
+ Cost is never fabricated: when nothing was recorded, cost_recorded is False
4680
+ and totals are honestly null rather than a misleading $0.00.
4681
+ """
4682
+ loki_dir = _get_loki_dir()
4683
+ efficiency_dir = loki_dir / "metrics" / "efficiency"
4684
+
4685
+ # --- current run: per-iteration series from efficiency/ -----------------
4686
+ iterations: list = []
4687
+ current_total = 0.0
4688
+ cost_recorded = False
4689
+ if efficiency_dir.exists():
4690
+ records = []
4691
+ for eff_file in sorted(efficiency_dir.glob("iteration-*.json")):
4692
+ data = _safe_json_read(eff_file, default=None)
4693
+ if not isinstance(data, dict):
4694
+ continue
4695
+ records.append(data)
4696
+ # Sort by numeric iteration when present, else by filename order.
4697
+ def _iter_key(d):
4698
+ try:
4699
+ return int(d.get("iteration", 0))
4700
+ except (TypeError, ValueError):
4701
+ return 0
4702
+ records.sort(key=_iter_key)
4703
+ cumulative = 0.0
4704
+ for data in records:
4705
+ cost_recorded = True
4706
+ inp = data.get("input_tokens", 0) or 0
4707
+ out = data.get("output_tokens", 0) or 0
4708
+ model = str(data.get("model", "sonnet")).lower()
4709
+ cost = data.get("cost_usd")
4710
+ if cost is None:
4711
+ cost = _calculate_model_cost(model, inp, out)
4712
+ else:
4713
+ try:
4714
+ cost = float(cost)
4715
+ except (TypeError, ValueError):
4716
+ cost = 0.0
4717
+ cumulative += cost
4718
+ iterations.append({
4719
+ "iteration": data.get("iteration"),
4720
+ "timestamp": data.get("timestamp"),
4721
+ "model": model,
4722
+ "phase": data.get("phase", "unknown"),
4723
+ "provider": data.get("provider"),
4724
+ "input_tokens": inp,
4725
+ "output_tokens": out,
4726
+ "cost_usd": round(cost, 6),
4727
+ "cumulative_usd": round(cumulative, 6),
4728
+ })
4729
+ current_total = cumulative
4730
+
4731
+ # --- per-run history: from .loki/proofs/*/proof.json --------------------
4732
+ runs: list = []
4733
+ project_total = 0.0
4734
+ proofs_dir = _proofs_dir()
4735
+ try:
4736
+ entries = sorted(proofs_dir.iterdir())
4737
+ except (OSError, FileNotFoundError):
4738
+ entries = []
4739
+ for entry in entries:
4740
+ if not entry.is_dir():
4741
+ continue
4742
+ data = _safe_json_read(entry / "proof.json", default=None)
4743
+ if not isinstance(data, dict):
4744
+ continue
4745
+ run_cost = (data.get("cost") or {}).get("usd")
4746
+ run_cost_num = None
4747
+ if run_cost is not None:
4748
+ try:
4749
+ run_cost_num = float(run_cost)
4750
+ project_total += run_cost_num
4751
+ except (TypeError, ValueError):
4752
+ run_cost_num = None
4753
+ runs.append({
4754
+ "run_id": data.get("run_id", entry.name),
4755
+ "generated_at": data.get("generated_at"),
4756
+ "model": (data.get("provider") or {}).get("model"),
4757
+ "cost_usd": round(run_cost_num, 6) if run_cost_num is not None else None,
4758
+ "files_changed": (data.get("files_changed") or {}).get("count"),
4759
+ "final_verdict": (data.get("council") or {}).get("final_verdict"),
4760
+ })
4761
+ runs.sort(key=lambda x: (x.get("generated_at") or ""), reverse=True)
4762
+
4763
+ # --- budget block (read-time status; no mutation) -----------------------
4764
+ # Shared snapshot so the pull endpoint and the proactive WS push agree.
4765
+ # Budget "used" is the current run's spend (mirrors check_budget_limit,
4766
+ # which sums the live efficiency dir against the cap). The per-project
4767
+ # history total is reported separately as project_total_usd.
4768
+ budget = _compute_budget_snapshot(loki_dir)
4769
+
4770
+ return {
4771
+ "current_run": {
4772
+ "iterations": iterations,
4773
+ "total_usd": round(current_total, 6) if cost_recorded else None,
4774
+ "cost_recorded": cost_recorded,
4775
+ },
4776
+ "runs": runs,
4777
+ "runs_count": len(runs),
4778
+ "project_total_usd": round(project_total, 6) if runs else 0.0,
4779
+ "budget": budget,
4780
+ }
4781
+
4782
+
4554
4783
  # =============================================================================
4555
4784
  # Pricing API
4556
4785
  # =============================================================================
@@ -6428,6 +6657,19 @@ async def serve_favicon():
6428
6657
  return Response(status_code=404)
6429
6658
 
6430
6659
 
6660
+ # Serve the self-contained cost + observability panel (R3). Zero-build
6661
+ # standalone page that fetches /api/cost/timeline. Mirrors the proofs.html
6662
+ # pattern: works without the SPA build.
6663
+ @app.get("/cost", include_in_schema=False)
6664
+ async def serve_cost_panel():
6665
+ """Serve the standalone cost + observability HTML panel."""
6666
+ if STATIC_DIR:
6667
+ cost_path = os.path.join(STATIC_DIR, "cost.html")
6668
+ if os.path.isfile(cost_path):
6669
+ return FileResponse(cost_path, media_type="text/html")
6670
+ return Response(status_code=404)
6671
+
6672
+
6431
6673
  # Serve index.html or standalone HTML for root
6432
6674
  @app.get("/", include_in_schema=False)
6433
6675
  async def serve_index():
@@ -0,0 +1,274 @@
1
+ <!DOCTYPE html>
2
+ <!--
3
+ Loki Mode - Cost + observability panel (R3, zero-build standalone).
4
+
5
+ Self-contained: all CSS + JS inlined, no external resources. Fetches
6
+ /api/cost/timeline and renders project total, a budget gauge that warns at
7
+ 80% (before the hard cap at 100%), per-run cost history, model-routing
8
+ breakdown, and an inline-SVG cumulative-cost line for the current run.
9
+
10
+ Anti-surprise-cost wedge: cost is shown transparently. Uncollected cost is
11
+ shown as "not recorded", never a fabricated $0.00.
12
+ -->
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1">
17
+ <title>Loki Mode - Cost and Observability</title>
18
+ <style>
19
+ :root {
20
+ --bg: #0f1115; --panel: #171a21; --panel-2: #1d2129; --border: #2a2f3a;
21
+ --text: #e7e9ee; --muted: #9aa1ad; --faint: #6b7280; --accent: #6f7bf7;
22
+ --green: #34d399; --red: #f87171; --amber: #fbbf24;
23
+ --mono: ui-monospace, "SF Mono", "Menlo", "Consolas", monospace;
24
+ --sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans); line-height: 1.5; }
28
+ a { color: var(--accent); text-decoration: none; }
29
+ a:hover { text-decoration: underline; }
30
+ .wrap { max-width: 960px; margin: 0 auto; padding: 40px 20px 80px; }
31
+ .head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
32
+ h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.3px; margin: 0; }
33
+ h2 { font-size: 15px; font-weight: 600; color: var(--muted); margin: 30px 0 12px; text-transform: uppercase; letter-spacing: 0.5px; }
34
+ .head a { font-size: 13px; }
35
+ .sub { color: var(--muted); font-size: 14px; margin: 0 0 26px; }
36
+ .cards { display: flex; gap: 14px; flex-wrap: wrap; }
37
+ .card { flex: 1 1 200px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
38
+ .card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
39
+ .card .val { font-family: var(--mono); font-size: 26px; font-weight: 650; margin-top: 6px; }
40
+ .card .note { color: var(--faint); font-size: 12px; margin-top: 4px; }
41
+ .gauge { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px; margin-top: 14px; }
42
+ .gauge .top { display: flex; justify-content: space-between; align-items: baseline; }
43
+ .gauge .pct { font-family: var(--mono); font-weight: 650; font-size: 18px; }
44
+ .bar { position: relative; height: 14px; background: var(--panel-2); border-radius: 8px; margin: 12px 0 6px; overflow: hidden; }
45
+ .bar .fill { height: 100%; border-radius: 8px; transition: width .3s; }
46
+ .bar .warnline { position: absolute; top: -3px; bottom: -3px; width: 2px; background: var(--amber); left: 80%; }
47
+ .fill.ok { background: var(--green); }
48
+ .fill.warn { background: var(--amber); }
49
+ .fill.exceeded { background: var(--red); }
50
+ .status { font-size: 13px; margin-top: 8px; }
51
+ .status.ok { color: var(--green); }
52
+ .status.warn { color: var(--amber); }
53
+ .status.exceeded { color: var(--red); }
54
+ .status.none { color: var(--muted); }
55
+ .cap-note { color: var(--faint); font-size: 12px; margin-top: 6px; }
56
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
57
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
58
+ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
59
+ td.num, th.num { text-align: right; font-family: var(--mono); }
60
+ .badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--border); }
61
+ .b-approve { color: var(--green); border-color: rgba(52,211,153,0.4); }
62
+ .b-reject { color: var(--red); border-color: rgba(248,113,113,0.4); }
63
+ .b-concern { color: var(--amber); border-color: rgba(251,191,36,0.4); }
64
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
65
+ .empty { color: var(--muted); background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 24px; text-align: center; }
66
+ .empty code { font-family: var(--mono); color: var(--text); background: var(--panel-2); padding: 2px 6px; border-radius: 5px; }
67
+ .mono { font-family: var(--mono); }
68
+ .muted { color: var(--muted); }
69
+ svg { display: block; width: 100%; height: 140px; }
70
+ .modelrow { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
71
+ .modelrow .name { width: 130px; font-size: 13px; }
72
+ .modelrow .mbar { flex: 1; height: 10px; background: var(--panel-2); border-radius: 6px; overflow: hidden; }
73
+ .modelrow .mbar .mfill { height: 100%; background: var(--accent); border-radius: 6px; }
74
+ .modelrow .mval { width: 90px; text-align: right; font-family: var(--mono); font-size: 13px; }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <div class="wrap">
79
+ <div class="head">
80
+ <h1>Cost and Observability</h1>
81
+ <a href="/">Back to dashboard</a>
82
+ </div>
83
+ <p class="sub">Transparent cost: per-run and per-project spend, model routing, token burn, and budget caps that warn before they stop. No surprise bills.</p>
84
+ <div id="content"><p class="sub">Loading...</p></div>
85
+ </div>
86
+ <script>
87
+ (function () {
88
+ "use strict";
89
+ function esc(s) {
90
+ s = (s === null || s === undefined) ? "" : String(s);
91
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
92
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
93
+ }
94
+ function fmtUsd(n) {
95
+ if (n === null || n === undefined) return "not recorded";
96
+ n = Number(n);
97
+ if (!isFinite(n)) return "not recorded";
98
+ var s = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
99
+ if (s.indexOf(".") === -1) s += ".00";
100
+ else if (s.split(".")[1].length === 1) s += "0";
101
+ return "$" + s;
102
+ }
103
+ function badgeClass(v) {
104
+ v = String(v || "").toUpperCase();
105
+ if (v.indexOf("APPROVE") === 0 || v === "PASS" || v === "PASSED") return "b-approve";
106
+ if (v.indexOf("REJECT") === 0 || v.indexOf("BLOCK") === 0 || v === "FAIL") return "b-reject";
107
+ if (v.indexOf("CONCERN") === 0) return "b-concern";
108
+ return "";
109
+ }
110
+ function statusText(st) {
111
+ if (st === "exceeded") return "Budget cap reached. The run is paused to prevent a surprise bill.";
112
+ if (st === "warn") return "Approaching budget cap (80% or more used). Warning only, the run continues.";
113
+ if (st === "ok") return "Within budget.";
114
+ return "No budget cap set. Set LOKI_BUDGET_LIMIT to cap spend.";
115
+ }
116
+
117
+ function renderBudget(b) {
118
+ if (!b) return "";
119
+ var html = '<h2>Budget</h2>';
120
+ if (b.limit === null || b.limit === undefined) {
121
+ html += '<div class="panel"><div class="status none">' + statusText("none") + '</div>' +
122
+ '<div class="cap-note">When a cap is set, Loki warns at 80% and hard-stops at 100%.</div></div>';
123
+ return html;
124
+ }
125
+ var pct = (b.percent_used === null || b.percent_used === undefined) ? 0 : Number(b.percent_used);
126
+ var fillPct = Math.max(0, Math.min(100, pct));
127
+ var st = esc(b.status || "ok");
128
+ html += '<div class="gauge">' +
129
+ '<div class="top"><span class="muted">' + fmtUsd(b.used) + ' of ' + fmtUsd(b.limit) + ' used</span>' +
130
+ '<span class="pct">' + pct.toFixed(1) + '%</span></div>' +
131
+ '<div class="bar"><div class="fill ' + st + '" style="width:' + fillPct + '%"></div>' +
132
+ '<div class="warnline" title="80% warn threshold"></div></div>' +
133
+ '<div class="status ' + st + '">' + statusText(b.status) + '</div>' +
134
+ '<div class="cap-note">Remaining: ' + fmtUsd(b.remaining) +
135
+ '. Warns at ' + esc(b.warn_threshold_percent) + '%, hard-stops at 100%.</div>' +
136
+ '</div>';
137
+ return html;
138
+ }
139
+
140
+ function renderModelBreakdown(runs) {
141
+ // Aggregate per-run cost by model for routing visibility.
142
+ var byModel = {};
143
+ var total = 0;
144
+ for (var i = 0; i < runs.length; i++) {
145
+ var m = runs[i].model || "unknown";
146
+ var c = runs[i].cost_usd;
147
+ if (c === null || c === undefined) continue;
148
+ c = Number(c);
149
+ if (!isFinite(c)) continue;
150
+ byModel[m] = (byModel[m] || 0) + c;
151
+ total += c;
152
+ }
153
+ var keys = Object.keys(byModel);
154
+ if (keys.length === 0) return "";
155
+ keys.sort(function (a, b) { return byModel[b] - byModel[a]; });
156
+ var html = '<h2>Model routing (by spend)</h2><div class="panel">';
157
+ for (var k = 0; k < keys.length; k++) {
158
+ var name = keys[k];
159
+ var val = byModel[name];
160
+ var w = total > 0 ? (val / total * 100) : 0;
161
+ html += '<div class="modelrow"><span class="name mono">' + esc(name) + '</span>' +
162
+ '<span class="mbar"><span class="mfill" style="width:' + w.toFixed(1) + '%"></span></span>' +
163
+ '<span class="mval">' + fmtUsd(val) + '</span></div>';
164
+ }
165
+ html += '</div>';
166
+ return html;
167
+ }
168
+
169
+ function renderCurrentRun(cr) {
170
+ var html = '<h2>Current run (cost over iterations)</h2>';
171
+ if (!cr || !cr.cost_recorded || !cr.iterations || cr.iterations.length === 0) {
172
+ html += '<div class="empty">No iteration cost recorded for the current run yet.' +
173
+ ' Cost appears once a run produces efficiency records.</div>';
174
+ return html;
175
+ }
176
+ var its = cr.iterations;
177
+ // Inline SVG cumulative line.
178
+ var W = 900, H = 140, pad = 8;
179
+ var maxCum = 0;
180
+ for (var i = 0; i < its.length; i++) {
181
+ var cv = Number(its[i].cumulative_usd) || 0;
182
+ if (cv > maxCum) maxCum = cv;
183
+ }
184
+ if (maxCum <= 0) maxCum = 1;
185
+ var pts = [];
186
+ for (var j = 0; j < its.length; j++) {
187
+ var x = its.length === 1 ? W / 2 : pad + (j / (its.length - 1)) * (W - 2 * pad);
188
+ var y = H - pad - ((Number(its[j].cumulative_usd) || 0) / maxCum) * (H - 2 * pad);
189
+ pts.push(x.toFixed(1) + "," + y.toFixed(1));
190
+ }
191
+ var poly = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none">' +
192
+ '<polyline fill="none" stroke="#6f7bf7" stroke-width="2" points="' + pts.join(" ") + '"/>' +
193
+ '</svg>';
194
+ html += '<div class="panel"><div class="muted" style="font-size:13px;margin-bottom:6px;">' +
195
+ 'Cumulative spend this run: <span class="mono">' + fmtUsd(cr.total_usd) + '</span></div>' +
196
+ poly + '</div>';
197
+ // Per-iteration table.
198
+ var rows = "";
199
+ for (var r = 0; r < its.length; r++) {
200
+ var it = its[r];
201
+ rows += '<tr><td class="num mono">' + esc(it.iteration) + '</td>' +
202
+ '<td class="mono">' + esc(it.model) + '</td>' +
203
+ '<td>' + esc(it.phase) + '</td>' +
204
+ '<td class="num">' + esc(it.input_tokens) + '</td>' +
205
+ '<td class="num">' + esc(it.output_tokens) + '</td>' +
206
+ '<td class="num mono">' + fmtUsd(it.cost_usd) + '</td>' +
207
+ '<td class="num mono">' + fmtUsd(it.cumulative_usd) + '</td></tr>';
208
+ }
209
+ html += '<table style="margin-top:14px;"><thead><tr>' +
210
+ '<th class="num">Iter</th><th>Model</th><th>Phase</th>' +
211
+ '<th class="num">Input tok</th><th class="num">Output tok</th>' +
212
+ '<th class="num">Cost</th><th class="num">Cumulative</th>' +
213
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
214
+ return html;
215
+ }
216
+
217
+ function renderRuns(runs) {
218
+ var html = '<h2>Run history (per-run cost)</h2>';
219
+ if (!runs || runs.length === 0) {
220
+ html += '<div class="empty">No completed runs yet. Per-run cost history' +
221
+ ' comes from proof-of-run artifacts (<code>.loki/proofs/</code>).</div>';
222
+ return html;
223
+ }
224
+ var rows = "";
225
+ for (var i = 0; i < runs.length; i++) {
226
+ var p = runs[i];
227
+ var verdict = p.final_verdict ?
228
+ '<span class="badge ' + badgeClass(p.final_verdict) + '">' + esc(p.final_verdict) + '</span>' : '';
229
+ rows += '<tr><td class="mono">' + esc(p.run_id) + '</td>' +
230
+ '<td class="muted">' + esc(p.generated_at || "") + '</td>' +
231
+ '<td class="mono">' + esc(p.model || "") + '</td>' +
232
+ '<td class="num">' + (p.files_changed === null || p.files_changed === undefined ? "" : esc(p.files_changed)) + '</td>' +
233
+ '<td>' + verdict + '</td>' +
234
+ '<td class="num mono">' + fmtUsd(p.cost_usd) + '</td></tr>';
235
+ }
236
+ html += '<table><thead><tr>' +
237
+ '<th>Run</th><th>When</th><th>Model</th><th class="num">Files</th>' +
238
+ '<th>Verdict</th><th class="num">Cost</th>' +
239
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
240
+ return html;
241
+ }
242
+
243
+ function render(d) {
244
+ var c = document.getElementById("content");
245
+ var b = d.budget || {};
246
+ var cards = '<div class="cards">' +
247
+ '<div class="card"><div class="label">Project total</div>' +
248
+ '<div class="val">' + fmtUsd(d.project_total_usd) + '</div>' +
249
+ '<div class="note">' + esc(d.runs_count) + ' run(s) recorded</div></div>' +
250
+ '<div class="card"><div class="label">Current run</div>' +
251
+ '<div class="val">' + fmtUsd(d.current_run ? d.current_run.total_usd : null) + '</div>' +
252
+ '<div class="note">' + (d.current_run && d.current_run.iterations ? d.current_run.iterations.length : 0) + ' iteration(s)</div></div>' +
253
+ '<div class="card"><div class="label">Budget status</div>' +
254
+ '<div class="val ' + esc(b.status || "none") + '" style="font-size:20px;text-transform:capitalize;">' + esc(b.status || "none") + '</div>' +
255
+ '<div class="note">' + (b.limit ? fmtUsd(b.used) + ' / ' + fmtUsd(b.limit) : "no cap set") + '</div></div>' +
256
+ '</div>';
257
+ c.innerHTML = cards +
258
+ renderBudget(d.budget) +
259
+ renderCurrentRun(d.current_run) +
260
+ renderModelBreakdown(d.runs || []) +
261
+ renderRuns(d.runs);
262
+ }
263
+ function renderError(msg) {
264
+ document.getElementById("content").innerHTML =
265
+ '<div class="empty">Could not load cost data. ' + esc(msg || "") + "</div>";
266
+ }
267
+ fetch("/api/cost/timeline", { headers: { "Accept": "application/json" } })
268
+ .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
269
+ .then(function (d) { render(d || {}); })
270
+ .catch(function (e) { renderError(e && e.message); });
271
+ })();
272
+ </script>
273
+ </body>
274
+ </html>