From 0d6ff931344ae2bc8f0899651f6f3acc0c37d9d9 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 23:02:24 +0000 Subject: [PATCH] repl: :cost meta surface (Phase 7 commit #5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing reporter of the per-session accumulator. Three shapes: :cost one-line summary (calls / tokens / cost) :cost detail per-model + per-category breakdown :cost reset zero the meter; clears warn flags All read-only against ctx.usage_totals; no broker calls. R6 — annotation uses the per-slot is_local sticky flag, NOT a fragile cost==0 heuristic. Summary line classifies: cloud only -> "cost=$X.XXXXXX" cloud + local mix -> "cost=$X.XXXXXX (cloud only; local: tokens but no cost field)" local only -> "cost=$X.XXXXXX (local only; no cost field)" R7 — :cost detail rows sort by (cost desc, model asc, category asc). Three-level key for deterministic output across equal-cost rows (table.sort is unstable; identical costs would otherwise reorder). R10 — all dollar values use $%.6f formatting. Sub-cent precision is critical: a Haiku call can cost $0.000028; $%.4f would round it to $0.0000 — indistinguishable from local $0. Column width widened to %-26s to fit fully-qualified cloud model names (e.g. "anthropic/claude-haiku-4.5" = 25 chars). E2E verified against live cloud + local broker: :cost (empty session) -> "0 calls, $0.000000" ...after mixed-mode session... :cost -> "5 calls, prompt=472 / completion=26 tokens, cost=$0.000377 (cloud only; local: tokens but no cost field)" :cost detail -> 4 rows: main cloud $0.000219, probe cloud $0.000128, delegate cloud $0.000030, main local $0.000000 (local). Sort by cost desc within model. :cost reset -> "cost meter reset"; subsequent :cost shows zeros. All 5 categories appeared in the same session: main (twice — cloud + local), delegate, probe (x2 from :safety check). Warn-threshold firing already verified in commit #3 + #4. HELP gains 3 :cost lines. Regression: test_safety 87/87, test_router_model 31/31, repl loads. Co-Authored-By: Claude Opus 4.7 (1M context) --- repl.lua | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/repl.lua b/repl.lua index 311965b..366cf13 100644 --- a/repl.lua +++ b/repl.lua @@ -185,6 +185,9 @@ Meta commands: :tree off clear the [project] block :diff [] git diff -> inject as [diff ...] exec_output examples: :diff :diff --cached :diff main..feature + :cost summary of session token/cost usage + :cost detail per-model + per-category breakdown + :cost reset zero the cost meter (also clears warn flags) :highlight [on|off|status] toggle tree-sitter syntax highlighting on assistant code fences (requires external tree-sitter CLI + @@ -2024,6 +2027,78 @@ function M.run(config) -- Phase 6: :diff meta — `git diff ` (B1-clean), appends as -- [diff ]\n exec_output. Reads cwd at invocation -- time (R6: differs from :tree's scan-time cwd capture). Empty + -- Phase 7: :cost meta — read-only reporter of ctx.usage_totals. + -- :cost summary line + -- :cost detail per-model + per-category breakdown + -- :cost reset zero the meter (also clears warn flags) + -- R7 sort: (cost desc, model asc, category asc) — table.sort is + -- unstable, so the 3-level key ensures deterministic output. + -- R10: $%.6f for sub-cent precision (cloud costs can be ~3e-05). + -- R6: annotation uses the per-slot is_local sticky flag rather + -- than a fragile cost==0 heuristic. + meta.cost = function(args) + local sub = ((args or ""):match("^%s*(%S*)") or ""):lower() + if sub == "reset" then + ctx:reset_usage() + renderer.status("cost meter reset") + return + end + local total_cost = ctx:total_cost() + local total_p, total_c = ctx:total_tokens() + local has_local, has_cloud = false, false + for _, m in pairs(ctx.usage_totals or {}) do + for _, c in pairs(m) do + if c.is_local then has_local = true end + if c.cost > 0 then has_cloud = true end + end + end + local label + if has_local and has_cloud then + label = "(cloud only; local: tokens but no cost field)" + elseif has_local and not has_cloud then + label = "(local only; no cost field)" + else + label = "" + end + if sub == "" then + local calls = 0 + for _, m in pairs(ctx.usage_totals or {}) do + for _, c in pairs(m) do calls = calls + c.calls end + end + renderer.status(("session usage: %d calls, prompt=%d / completion=%d tokens, cost=$%.6f %s"):format( + calls, total_p, total_c, total_cost, label)) + return + end + if sub == "detail" then + local rows = {} + for model, cats in pairs(ctx.usage_totals or {}) do + for category, c in pairs(cats) do + rows[#rows + 1] = { + model = model, category = category, + prompt = c.prompt, completion = c.completion, + calls = c.calls, cost = c.cost, + is_local = c.is_local, + } + end + end + if #rows == 0 then renderer.status("(no usage recorded)"); return end + -- R7: 3-level deterministic sort + table.sort(rows, function(a, b) + if a.cost ~= b.cost then return a.cost > b.cost end + if a.model ~= b.model then return a.model < b.model end + return a.category < b.category + end) + renderer.status(("session usage detail (total=$%.6f, %d/%d tokens):"):format( + total_cost, total_p, total_c)) + for _, r in ipairs(rows) do + io.write((" %-26s %-18s %3d calls, %6d / %6d tokens, $%.6f%s\n"):format( + r.model, r.category, r.calls, r.prompt, r.completion, r.cost, + r.is_local and " (local)" or "")) + end + return + end + renderer.status("usage: :cost [detail|reset]") + end -- diff or git failure emits status and skips — never pollutes -- context with empty or error noise. meta.diff = function(args)