diff --git a/context.lua b/context.lua index 391d413..ffa02e0 100644 --- a/context.lua +++ b/context.lua @@ -62,6 +62,16 @@ function M.new(opts) -- max_chars overrides) live on _project_opts for :tree refresh. project = nil, _project_opts = nil, + -- Phase 7 (docs/PHASE7.md): cost/usage accumulator. Keyed as + -- usage_totals[model_name][category] -> { prompt, completion, + -- calls, cost, is_local }. is_local (R6) is a sticky flag + -- set when ANY recorded usage for the slot had cost==nil + -- (preserves local-vs-cloud-zero distinction for :cost detail + -- annotation). cost_warn_state (R4) carries per-threshold + -- one-shot flags so warn_at_dollars firing doesn't suppress + -- warn_at_tokens. Both survive :reset (R8 parity). + usage_totals = {}, + cost_warn_state = { dollars = false, tokens = false }, }, Context) end @@ -356,10 +366,71 @@ function Context:estimate_tokens() return math.floor(n / 4) end +-- Phase 7: cost/usage accumulator helpers. +-- +-- Context:add_usage(model_name, category, usage) +-- Increment the (model, category) slot. usage is the payload from +-- broker.lua's on_delta("usage", ...): { prompt_tokens, completion_ +-- tokens, total_tokens, cost (nil for local per R6), model, category }. +-- We use the model_name + category args (not the payload fields) +-- because the caller may want to normalize (e.g., key by req_cfg +-- alias rather than model_cfg.model). +function Context:add_usage(model_name, category, usage) + model_name = model_name or "?" + category = category or "main" + self.usage_totals = self.usage_totals or {} + local m = self.usage_totals[model_name] or {} + local c = m[category] or { + prompt = 0, completion = 0, calls = 0, cost = 0, + -- R6: sticky flag; set once any nil-cost usage lands here. + is_local = false, + } + c.prompt = c.prompt + (usage.prompt_tokens or 0) + c.completion = c.completion + (usage.completion_tokens or 0) + c.calls = c.calls + 1 + if usage.cost == nil then + c.is_local = true -- preserves local-vs-cloud-zero per R6 + else + c.cost = c.cost + usage.cost + end + m[category] = c + self.usage_totals[model_name] = m +end + +function Context:total_cost() + local total = 0 + for _, m in pairs(self.usage_totals or {}) do + for _, c in pairs(m) do total = total + (c.cost or 0) end + end + return total +end + +-- Returns (prompt_tokens, completion_tokens) summed across all slots. +function Context:total_tokens() + local p, comp = 0, 0 + for _, m in pairs(self.usage_totals or {}) do + for _, c in pairs(m) do + p = p + (c.prompt or 0) + comp = comp + (c.completion or 0) + end + end + return p, comp +end + +-- :cost reset path — zero accumulator AND clear per-threshold one-shot flags. +function Context:reset_usage() + self.usage_totals = {} + self.cost_warn_state = { dollars = false, tokens = false } +end + function Context:reset() self.turns = {} self.pending_exec_output = nil self.summary = nil + -- R8 parity: usage_totals + cost_warn_state preserved (matches + -- memory_items + project — "ambient context survives a user- + -- driven conversation reset"). Use :reset_usage to zero the + -- cost meter explicitly. end return M