Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,31 @@ The project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.3.0] - 2026-07-13
## [0.3.1] - 2026-07-14

### Fixed

- Miscounted unified-diff hunk headers (`@@ -a,b +c,d @@` where the line counts
disagree with the body) are now recomputed from the actual lines instead of
failing the local patch contract. This was the dominant cause of the
expensive full-batch `contract_retry` re-draft: models frequently get the
range counts wrong even when the change itself is correct, and each rejection
re-ran the entire agentic pass, wasting roughly 50–80% of a turn's tokens and
doubling its wall-clock time.
- Patch hunks whose context or removed lines drift on leading/trailing
whitespace or indentation are now located with a whitespace-insensitive match
and canonicalized back to the exact source text, rather than being rejected.
- Both fixes live in the shared `loopbiotic_patch` normalizer/applier, so they
apply to every backend (Codex, Claude, Ollama, generic, and stdio); the
whitespace tolerance is mirrored in the editor's Lua patch applier. Added
lines are never altered, and genuine content mismatches and ambiguous
relocations are still rejected.

### Changed

- The card window's token line now splits usage into input (with the cached
portion in parentheses) and output, and shows an estimated cost per turn and
per session using per-model pricing.

### Changed

Expand Down
52 changes: 46 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ members = [
resolver = "3"

[workspace.package]
version = "0.3.0"
version = "0.3.1"
edition = "2024"
authors = ["Dorian"]
license = "MIT"
Expand Down
14 changes: 11 additions & 3 deletions lua/loopbiotic/apply.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
local M = {}

-- Two lines match if they are equal ignoring leading/trailing whitespace.
-- Mirrors the daemon's tolerant patch matching so a hunk whose context drifted
-- on indentation still applies against the live buffer.
local function line_matches(a, b)
return vim.trim(a or "") == vim.trim(b or "")
end

function M.patch(file_patch)
local buf = M.buffer(file_patch.file)

Expand Down Expand Up @@ -43,12 +50,13 @@ function M.apply_diff(source, diff)

for _, line in ipairs(hunk.lines) do
if line.kind == "context" then
if output[offset + 1] ~= line.text then
if not line_matches(output[offset + 1], line.text) then
error("Patch context changed while applying")
end
-- Keep the buffer's real line, not the model's whitespace-drifted copy.
offset = offset + 1
elseif line.kind == "remove" then
if output[offset + 1] ~= line.text then
if not line_matches(output[offset + 1], line.text) then
error("Patch context changed while applying")
end
table.remove(output, offset + 1)
Expand Down Expand Up @@ -132,7 +140,7 @@ end

function M.matches_at(source, start, expected)
for index, line in ipairs(expected) do
if source[start + index] ~= line then
if not line_matches(source[start + index], line) then
return false
end
end
Expand Down
64 changes: 27 additions & 37 deletions lua/loopbiotic/card.lua
Original file line number Diff line number Diff line change
Expand Up @@ -281,54 +281,44 @@ function M.tokens(lines)
end

table.insert(lines, "")
local turn_raw = tonumber(usage.total_tokens) or 0
local turn_cached = tonumber(usage.cached_input_tokens) or 0
local turn_suffix = usage.estimated and " estimated" or ""
if turn_cached > 0 then
table.insert(lines, string.format(
"Turn %s raw · %s cached · %s non-cached%s",
turn_raw,
turn_cached,
math.max(turn_raw - turn_cached, 0),
turn_suffix
))
else
table.insert(lines, string.format("Turn %s tokens%s", turn_raw, turn_suffix))

local pricing = require("loopbiotic.pricing")
local model = state.backend_model

-- One usage row: input (with cached in parentheses) · output · cost.
local function usage_line(label, u, estimated)
local input = tonumber(u.input_tokens) or 0
local cached = tonumber(u.cached_input_tokens) or 0
local output = tonumber(u.output_tokens) or 0
local text = string.format("%s in %s (%s cached) · out %s", label, input, cached, output)
local cost = pricing.format(pricing.cost(u, model))
if cost then
text = text .. " · " .. cost
end
if estimated then
text = text .. " · est"
end
table.insert(lines, text)
end

usage_line("Turn ", usage, usage.estimated)

local total = state.token_usage
if total and (tonumber(total.total_tokens) or 0) ~= (tonumber(usage.total_tokens) or 0) then
usage_line("Total", total, total.estimated)
end

if total then
local budget = tonumber(config.values.backend.token_budget) or 0
local used = total.total_tokens or 0
local cached = tonumber(total.cached_input_tokens) or 0
local used = tonumber(total.total_tokens) or 0
if budget > 0 then
if cached > 0 then
table.insert(lines, string.format(
"Total %s/%s raw · %s cached · %s non-cached",
used,
budget,
cached,
math.max(used - cached, 0)
))
else
table.insert(lines, string.format("Total %s/%s tokens", used, budget))
end
table.insert(lines, string.format("Budget %s/%s tokens", used, budget))
if used >= budget then
table.insert(lines, "Warning Session token budget exceeded")
end
elseif used ~= usage.total_tokens then
if cached > 0 then
table.insert(lines, string.format(
"Total %s raw · %s cached · %s non-cached",
used,
cached,
math.max(used - cached, 0)
))
else
table.insert(lines, string.format("Total %s tokens", used))
end
end
end

local report = state.context_report
if report and report.enabled then
table.insert(lines, string.format(
Expand Down
76 changes: 76 additions & 0 deletions lua/loopbiotic/pricing.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
-- Token pricing for the card's billing line.
--
-- Rates are USD per 1,000,000 tokens. `cached_input` is the prompt-cache READ
-- rate (~10x cheaper than fresh input), which is why a tool-heavy turn that
-- reads tens of thousands of cached tokens still costs very little. Override or
-- extend via `pricing` in setup(), keyed by model id.
local M = {}

M.rates = {
["claude-opus-4-8"] = { input = 5.0, cached_input = 0.5, output = 25.0 },
["claude-opus-4-7"] = { input = 5.0, cached_input = 0.5, output = 25.0 },
["claude-opus-4-6"] = { input = 5.0, cached_input = 0.5, output = 25.0 },
["claude-sonnet-5"] = { input = 3.0, cached_input = 0.3, output = 15.0 },
["claude-sonnet-4-6"] = { input = 3.0, cached_input = 0.3, output = 15.0 },
["claude-haiku-4-5"] = { input = 1.0, cached_input = 0.1, output = 5.0 },
["haiku"] = { input = 1.0, cached_input = 0.1, output = 5.0 },
}

-- Resolve the rate for a model id: exact match, then user overrides, then a
-- substring match so labels like "codex/gpt-5.1" or dated suffixes still hit.
function M.rate_for(model)
if not model or model == "" then
return nil
end

local ok, config = pcall(require, "loopbiotic.config")
local overrides = ok and config.values and config.values.pricing or nil
if overrides and overrides[model] then
return overrides[model]
end
if M.rates[model] then
return M.rates[model]
end
if overrides then
for id, rate in pairs(overrides) do
if model:find(id, 1, true) then
return rate
end
end
end
for id, rate in pairs(M.rates) do
if model:find(id, 1, true) then
return rate
end
end

return nil
end

-- Estimated USD cost for one usage record. `input_tokens` is the full input
-- (cached + fresh); the cached slice bills at the cheaper cache-read rate.
function M.cost(usage, model)
local rate = M.rate_for(model)
if not rate then
return nil
end

local input = tonumber(usage.input_tokens) or 0
local cached = tonumber(usage.cached_input_tokens) or 0
local output = tonumber(usage.output_tokens) or 0
local fresh = math.max(input - cached, 0)

return fresh * rate.input / 1e6 + cached * rate.cached_input / 1e6 + output * rate.output / 1e6
end

function M.format(cost)
if not cost then
return nil
end
if cost < 0.01 then
return string.format("$%.4f", cost)
end
return string.format("$%.2f", cost)
end

return M
2 changes: 1 addition & 1 deletion lua/loopbiotic/version.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
return {
plugin = "0.3.0",
plugin = "0.3.1",
protocol = 8,
}
Loading