diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-03 13:12:21 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-03 13:12:21 +0200 |
| commit | 9518a5442576268a04e7dbfcddeb2457add2f189 (patch) | |
| tree | 573e474d8ce9b742f9a905deefebeb9309024391 | |
| parent | bd1eb9ef9b3b89ff5825eb8bf9b2e978167c9ad0 (diff) | |
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | crates/dinggerd/src/cost.rs | 72 | ||||
| -rwxr-xr-x | tests/pi-live.sh | 4 |
3 files changed, 72 insertions, 10 deletions
@@ -132,8 +132,10 @@ than a poll is still a completion. in: a fork (`/fork`, `pi --fork`) copies its parent's replies into its own file, and summing every file would count them again (in one month here, $1,024 of $2,991). The totals include agents since closed or - started over. Amounts are what pi prices each reply at (its model - configuration); Claude Code records no prices, so it is not counted. + started over. Amounts are what pi prices each call at (its model + configuration), counted as pi's footer counts them: replies, compaction + and branch summaries, and other recorded calls. Claude Code records no + prices, so it is not counted. - **Context use** (pi): how full each agent's context window is, the number pi's own footer shows. The panel and search draw it as a small meter at the right edge, after the cost (gray; yellow above 70% and red diff --git a/crates/dinggerd/src/cost.rs b/crates/dinggerd/src/cost.rs index 45d8bbe..ad65f32 100644 --- a/crates/dinggerd/src/cost.rs +++ b/crates/dinggerd/src/cost.rs @@ -2,7 +2,9 @@ //! starts at 00:00 UTC, a week on Monday, a month on its 1st). //! //! pi records each reply's cost in its session file (`message.usage.cost. -//! total`, at `message.timestamp`). A thread of its own reads every session +//! total`, at `message.timestamp`), and that of other calls (compaction and +//! branch summaries, `usage` entries) in the entry's own `usage`: dingger +//! counts what pi's footer counts. A thread of its own reads every session //! file changed this period, then only what was appended, and counts each //! reply once: a fork (`/fork`, `--fork`) copies its parent's entries into a //! new file unchanged, so a reply is known by its entry id and time, and @@ -116,10 +118,17 @@ pub struct Ledger { #[derive(Deserialize)] struct Entry { + #[serde(default, rename = "type")] + kind: Option<String>, #[serde(default)] id: Option<String>, + /// ISO 8601, in UTC ("2026-11-01T08:30:00.123Z"). + #[serde(default)] + timestamp: Option<String>, #[serde(default)] message: Option<Message>, + #[serde(default)] + usage: Option<Usage>, } #[derive(Deserialize)] @@ -150,20 +159,44 @@ struct Header { timestamp: Option<String>, } -/// A reply's cost, its time, and its id ("" for old entries without one). +/// A priced call's cost, its time, and its entry id ("" for old entries +/// without one): what pi's footer counts in a session's cost. fn reply(line: &[u8]) -> Option<(String, i64, f64)> { - // Most lines are not replies; a cost is rare enough to look for first. + // Most lines are not priced; a cost is rare enough to look for first. if !line.windows(6).any(|w| w == b"\"cost\"") { return None; } let entry: Entry = serde_json::from_slice(line).ok()?; - let message = entry.message?; - if message.role.as_deref() != Some("assistant") { + let at_entry = || entry.timestamp.as_deref().and_then(iso_millis); + let (usage, at) = match entry.kind.as_deref()? { + "message" => { + let message = entry.message?; + if !matches!(message.role.as_deref(), Some("assistant" | "toolResult")) { + return None; + } + let at = message.timestamp.or_else(at_entry)?; + (message.usage?, at) + } + "usage" | "compaction" | "branch_summary" => (entry.usage?, at_entry()?), + _ => return None, + }; + let cost = usage.cost?.total?; + (cost > 0.0 && cost.is_finite()).then(|| (entry.id.unwrap_or_default(), at, cost)) +} + +/// "2026-11-01T08:30:00.123Z" (UTC, as pi writes it) in milliseconds. +fn iso_millis(text: &str) -> Option<i64> { + let (date, time) = text.strip_suffix('Z')?.split_once('T')?; + let mut date = date.splitn(3, '-').map(|part| part.parse::<i64>().ok()); + let (year, month, day) = (date.next()??, date.next()??, date.next()??); + let (hms, fraction) = time.split_once('.').unwrap_or((time, "0")); + let mut hms = hms.splitn(3, ':').map(|part| part.parse::<i64>().ok()); + let (hour, minute, second) = (hms.next()??, hms.next()??, hms.next()??); + let millis: i64 = format!("{fraction:0<3}")[..3].parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { return None; } - let cost = message.usage?.cost?.total?; - let at = message.timestamp?; - (cost > 0.0 && cost.is_finite()).then(|| (entry.id.unwrap_or_default(), at, cost)) + Some(days_from_civil(year, month as u32, day as u32) * DAY_MS + ((hour * 60 + minute) * 60 + second) * 1000 + millis) } fn header_time(path: &Path) -> String { @@ -484,6 +517,29 @@ mod tests { } #[test] + fn counts_what_pis_footer_counts() { + let at = ms("2026-11-01T08:30"); + let iso = "2026-11-01T08:30:00.000Z"; + let usage = |kind: &str| format!("{{\"type\":\"{kind}\",\"id\":\"{kind}1\",\"timestamp\":\"{iso}\",\"usage\":{{\"cost\":{{\"total\":0.5}}}}}}"); + for kind in ["usage", "compaction", "branch_summary"] { + assert_eq!(reply(usage(kind).as_bytes()), Some((format!("{kind}1"), at, 0.5)), "{kind}"); + } + let tool = format!("{{\"type\":\"message\",\"id\":\"t1\",\"message\":{{\"role\":\"toolResult\",\"timestamp\":{at},\"usage\":{{\"cost\":{{\"total\":0.25}}}}}}}}"); + assert_eq!(reply(tool.as_bytes()), Some(("t1".into(), at, 0.25))); + assert_eq!(reply(line("a1", at, 2.0).as_bytes()), Some(("a1".into(), at, 2.0))); + // Not session entries (an event log saved alongside): pi's footer + // does not count them. + let event = format!("{{\"type\":\"message_end\",\"message\":{{\"role\":\"assistant\",\"timestamp\":{at},\"usage\":{{\"cost\":{{\"total\":2}}}}}}}}"); + assert_eq!(reply(event.as_bytes()), None); + assert_eq!(reply(usage("custom").as_bytes()), None); + assert_eq!(iso_millis("2026-11-01T08:30:00.5Z"), Some(at + 500)); + assert_eq!(iso_millis("2026-11-01T08:30:00Z"), Some(at)); + for bad in ["2026-11-01T08:30:00", "2026-13-01T08:30:00Z", "x"] { + assert_eq!(iso_millis(bad), None, "{bad}"); + } + } + + #[test] fn dollars_as_an_option() { assert_eq!(option_value(0.0), ""); assert_eq!(option_value(0.004), ""); diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 6083be4..8e01e74 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -249,6 +249,10 @@ expect "context: known again after a reply" "ctx | grep -cE '^[0-9]+$'" 1 30 expect "context: pi's number again" same_as_footer same 30 expect "the menu: the percentage last" "\"$MENU\" --print | grep -F 'main · ctx' | cut -f3 | grep -oE ' [0-9]+%$'" " $(ctx)%" expect "the panel: cost, then context" "\"$MENU\" --print-panel --full | grep -F 'main · ctx' | cut -f4 | grep -oE '^\\\$[0-9.]+ [0-9]+%$' | grep -c ' $(ctx)%'" 1 +# Its cost, compaction included, as pi's footer counts it ("$16.000"). +footer_cost() { t capture-pane -p -t main:ctx | grep -E '/400' | grep -oE '\$[0-9.]+' | tail -1 | tr -d '$'; } +same_cost() { python3 -c "import sys; a,b=sys.argv[1:]; print('same' if a and b and abs(float(a)-float(b))<0.005 else f'dingger [{a}] footer [{b}]')" "$(t show -pv -t main:ctx @dingger_cost 2>/dev/null)" "$(footer_cost)"; } +expect "cost: compaction counted, as pi's footer counts" same_cost same 30 t kill-window -t main:ctx echo "2. screen detection (pi without the extension)" |
