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)