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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +449 -12
- package/autonomy/run.sh +94 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +242 -0
- package/dashboard/static/cost.html +274 -0
- package/dashboard/static/index.html +94 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/R3-COST-OBSERVABILITY-DESIGN.md +147 -0
- package/docs/R7-ZERO-CONFIG-FIRST-RUN-PLAN.md +137 -0
- package/loki-ts/dist/loki.js +144 -144
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
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"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
92
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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>
|