From 624abcaa56ddf20f71599fa499a8049700827fe8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:19:19 -0300 Subject: [PATCH 1/2] feat(chat): count the conversation written again to the cache apart A turn after a pause, or right after a restart, sends the whole conversation again because the prompt cache expired. Claude Code reports it as cache_creation_input_tokens, next to what is really new, so a short turn showed tens of thousands of tokens. The daemon now follows each model request and keeps, as `reloaded`, what the conversation already held and did not come from the cache (spec 8.7). --- app/src/lib/protocol.gen.ts | 5 + crates/botloft-core/src/protocol/usage.rs | 5 + crates/botloft-store/src/usage.rs | 9 +- crates/botloftd/src/chat/events.rs | 7 +- .../src/{context.rs => context/mod.rs} | 31 ++++- crates/botloftd/src/context/reload.rs | 126 ++++++++++++++++++ crates/botloftd/tests/token_reload.rs | 70 ++++++++++ crates/botloftd/tests/usage.rs | 4 +- docs/spec.md | 9 +- 9 files changed, 251 insertions(+), 15 deletions(-) rename crates/botloftd/src/{context.rs => context/mod.rs} (91%) create mode 100644 crates/botloftd/src/context/reload.rs create mode 100644 crates/botloftd/tests/token_reload.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 4dcde12d..756a998c 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -760,6 +760,11 @@ input: number, * New input also written to the prompt cache (`cache_creation_input_tokens`). */ cacheWrite: number, +/** + * Of the cache write, the conversation from before written again because + * the cache had expired: the daemon's estimate, 0 in older turns. + */ +reloaded: number, /** * Input read back from the prompt cache (`cache_read_input_tokens`): * mostly the conversation so far, and much cheaper than new input. diff --git a/crates/botloft-core/src/protocol/usage.rs b/crates/botloft-core/src/protocol/usage.rs index 094be101..d0be44e2 100644 --- a/crates/botloft-core/src/protocol/usage.rs +++ b/crates/botloft-core/src/protocol/usage.rs @@ -15,6 +15,10 @@ pub struct TokenUsage { pub input: u64, /// New input also written to the prompt cache (`cache_creation_input_tokens`). pub cache_write: u64, + /// Of the cache write, the conversation from before written again because + /// the cache had expired: the daemon's estimate, 0 in older turns. + #[serde(default)] + pub reloaded: u64, /// Input read back from the prompt cache (`cache_read_input_tokens`): /// mostly the conversation so far, and much cheaper than new input. pub cache_read: u64, @@ -26,6 +30,7 @@ impl TokenUsage { pub fn add(&mut self, other: &Self) { self.input += other.input; self.cache_write += other.cache_write; + self.reloaded += other.reloaded; self.cache_read += other.cache_read; self.output += other.output; } diff --git a/crates/botloft-store/src/usage.rs b/crates/botloft-store/src/usage.rs index cc019d66..41e9380d 100644 --- a/crates/botloft-store/src/usage.rs +++ b/crates/botloft-store/src/usage.rs @@ -19,13 +19,15 @@ impl Store { SUM(json_extract(c.data, '$.tokens.input')), \ SUM(json_extract(c.data, '$.tokens.cacheWrite')), \ SUM(json_extract(c.data, '$.tokens.cacheRead')), \ - SUM(json_extract(c.data, '$.tokens.output')) AS output \ + SUM(json_extract(c.data, '$.tokens.output')) AS output, \ + COALESCE(SUM(json_extract(c.data, '$.tokens.reloaded')), 0) AS reloaded \ FROM chat_items c JOIN bots b ON b.id = c.bot_id \ WHERE c.kind = 'turn' AND c.created_at >= ?1 \ AND json_type(c.data, '$.tokens') = 'object' \ GROUP BY b.id \ ORDER BY SUM(json_extract(c.data, '$.tokens.input')) \ - + SUM(json_extract(c.data, '$.tokens.cacheWrite')) + output DESC, b.name", + + SUM(json_extract(c.data, '$.tokens.cacheWrite')) - reloaded + output DESC, \ + b.name", )?; let rows = stmt.query_map(params![since], |row| { Ok(BotTokens { @@ -37,6 +39,7 @@ impl Store { tokens: TokenUsage { input: count(row, 5)?, cache_write: count(row, 6)?, + reloaded: count(row, 9)?, cache_read: count(row, 7)?, output: count(row, 8)?, }, @@ -77,6 +80,7 @@ mod tests { TokenUsage { input, cache_write: 100, + reloaded: 40, cache_read: 5000, output, } @@ -107,6 +111,7 @@ mod tests { TokenUsage { input: 4, cache_write: 200, + reloaded: 80, cache_read: 10_000, output: 6, } diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 2fb3939c..1063594a 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -209,6 +209,7 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { daemon.supervisor.session_missing(bot, generation); return; } + let reloaded = context::reloaded(daemon, bot); let failed = event["is_error"].as_bool() == Some(true); let error = failed.then(|| { event["terminal_reason"] @@ -224,7 +225,7 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { bot, ChatBody::Turn(TurnItem { duration_ms: event["duration_ms"].as_u64().unwrap_or_default(), - tokens: tokens(&event["usage"]), + tokens: tokens(&event["usage"], reloaded), error, }), ); @@ -236,7 +237,8 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { /// The turn's tokens from the `usage` of a `result`: the sum of the turn's /// requests, not of the whole session like `modelUsage` (spec 8.7, 19). -fn tokens(usage: &Value) -> Option { +/// `reloaded` is the daemon's own count, part of the cache write. +fn tokens(usage: &Value, reloaded: u64) -> Option { if !usage.is_object() { return None; } @@ -244,6 +246,7 @@ fn tokens(usage: &Value) -> Option { Some(TokenUsage { input: count("input_tokens"), cache_write: count("cache_creation_input_tokens"), + reloaded: reloaded.min(count("cache_creation_input_tokens")), cache_read: count("cache_read_input_tokens"), output: count("output_tokens"), }) diff --git a/crates/botloftd/src/context.rs b/crates/botloftd/src/context/mod.rs similarity index 91% rename from crates/botloftd/src/context.rs rename to crates/botloftd/src/context/mod.rs index 66be4485..7b307e01 100644 --- a/crates/botloftd/src/context.rs +++ b/crates/botloftd/src/context/mod.rs @@ -3,6 +3,8 @@ //! when a turn ends and after a compaction, and follows the usage of each //! model request in between. Nothing is stored; a new daemon asks again. +mod reload; + use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; @@ -34,6 +36,7 @@ struct Entry { size: Option, /// A compaction is running, or waits behind the turn in progress. compacting: bool, + reload: reload::Tracker, } struct Size { @@ -115,6 +118,7 @@ pub(crate) fn answered(daemon: &Daemon, bot: &BotId, body: &Value) { let at = daemon.clock.now_ms(); debug!(bot = %bot, used, window, "context usage"); update(daemon, bot, |entry| { + entry.reload.measured(used); entry.size = Some(Size { used, window, @@ -144,6 +148,7 @@ pub(crate) fn used(daemon: &Daemon, bot: &BotId, message: &Value) { } let at = daemon.clock.now_ms(); update(daemon, bot, |entry| { + entry.reload.request(message["id"].as_str(), usage); if let Some(size) = &mut entry.size && size.used != used { @@ -166,7 +171,10 @@ pub(crate) fn status(daemon: &Daemon, bot: &BotId, event: &Value) { /// `system/compact_boundary`: the conversation is now a summary. The chat /// says so, and Claude Code is asked for the new size. pub(crate) fn compacted(daemon: &Daemon, bot: &BotId, event: &Value) { - update(daemon, bot, |entry| entry.compacting = false); + update(daemon, bot, |entry| { + entry.compacting = false; + entry.reload.restart(); + }); let (code, text) = if event["compact_metadata"]["trigger"] == "auto" { ( NoticeCode::AutoCompacted, @@ -226,12 +234,25 @@ pub(crate) fn turn_ended(daemon: &Daemon, bot: &BotId, event: &Value) -> bool { compaction } +/// How much of the turn that ended was the conversation written again to +/// the prompt cache (spec 8.7). +pub(crate) fn reloaded(daemon: &Daemon, bot: &BotId) -> u64 { + let mut reloaded = 0; + update(daemon, bot, |entry| reloaded = entry.reload.take()); + reloaded +} + /// A process started. A new conversation has a size of its own, not known -/// until the process tells. +/// until the process tells. A turn the old process left open counts +/// nothing for the next. pub(crate) fn process_started(daemon: &Daemon, bot: &BotId, resumed: bool) { - if !resumed { - update(daemon, bot, |entry| entry.size = None); - } + update(daemon, bot, |entry| { + if !resumed { + entry.size = None; + entry.reload.restart(); + } + entry.reload.take(); + }); } /// The process is gone, and a compaction it had not finished with it. diff --git a/crates/botloftd/src/context/reload.rs b/crates/botloftd/src/context/reload.rs new file mode 100644 index 00000000..0ad9cd7a --- /dev/null +++ b/crates/botloftd/src/context/reload.rs @@ -0,0 +1,126 @@ +//! How much of a turn's cache write was the conversation sent again (spec +//! 8.7). Each model request sends the whole conversation; what the prompt +//! cache still had comes back as `cache_read_input_tokens`. When the cache +//! expired, because the bot sat idle or its process restarted, the part of +//! the conversation it no longer had is written again and counts as +//! `cache_creation_input_tokens`, next to what is really new. + +use serde_json::Value; + +#[derive(Default)] +pub(super) struct Tracker { + /// What the conversation's last model request sent, or the size Claude + /// Code told before any request was seen. `None` for a conversation + /// that has not been measured, such as one that just began. + sent: Option, + /// The message of the last request seen: one request can print several + /// `assistant` lines with the same usage. + message: Option, + /// Summed over the turn in progress. + turn: u64, +} + +impl Tracker { + /// One model request of the conversation, with its `usage`. + pub(super) fn request(&mut self, message: Option<&str>, usage: &Value) { + let count = |key: &str| usage[key].as_u64().unwrap_or_default(); + let (input, written, read) = ( + count("input_tokens"), + count("cache_creation_input_tokens"), + count("cache_read_input_tokens"), + ); + if message.is_some() && message == self.message.as_deref() { + return; + } + self.message = message.map(str::to_owned); + if let Some(before) = self.sent { + // What was already there and did not come from the cache. + self.turn += before.saturating_sub(read).min(written); + } + self.sent = Some(input + written + read); + } + + /// Claude Code told how big the conversation is. A request knows better, + /// so this only fills in for a conversation with none seen yet. + pub(super) fn measured(&mut self, size: u64) { + self.sent.get_or_insert(size); + } + + /// The conversation began again, as a new one or as a summary. + pub(super) fn restart(&mut self) { + self.sent = None; + self.message = None; + } + + /// The turn ended: what it wrote again, and a new count for the next. + pub(super) fn take(&mut self) -> u64 { + std::mem::take(&mut self.turn) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn usage(input: u64, written: u64, read: u64) -> Value { + json!({ + "input_tokens": input, + "cache_creation_input_tokens": written, + "cache_read_input_tokens": read, + "output_tokens": 50, + }) + } + + #[test] + fn a_warm_cache_writes_nothing_again() { + let mut tracker = Tracker::default(); + tracker.measured(40_000); + tracker.request(Some("msg_1"), &usage(2, 600, 40_000)); + tracker.request(Some("msg_2"), &usage(2, 3_000, 40_602)); + assert_eq!(tracker.take(), 0); + } + + #[test] + fn an_expired_cache_writes_the_conversation_again() { + let mut tracker = Tracker::default(); + tracker.measured(72_000); + // Only Claude Code's own instructions were still cached. + tracker.request(Some("msg_1"), &usage(2, 52_600, 20_000)); + assert_eq!(tracker.take(), 52_000, "the new 600 are not counted"); + // Nothing carries over to the next turn. + tracker.request(Some("msg_2"), &usage(2, 400, 72_602)); + assert_eq!(tracker.take(), 0); + } + + #[test] + fn a_request_counts_once_whatever_lines_it_prints() { + let mut tracker = Tracker::default(); + tracker.measured(30_000); + let cold = usage(2, 30_500, 0); + tracker.request(Some("msg_1"), &cold); + tracker.request(Some("msg_1"), &cold); + assert_eq!(tracker.take(), 30_000); + } + + #[test] + fn a_conversation_not_measured_counts_nothing_again() { + let mut tracker = Tracker::default(); + tracker.request(Some("msg_1"), &usage(3, 21_000, 0)); + assert_eq!(tracker.take(), 0, "a new conversation is all new"); + tracker.restart(); + tracker.request(Some("msg_2"), &usage(3, 9_000, 0)); + assert_eq!(tracker.take(), 0, "a summary is new too"); + } + + #[test] + fn a_size_told_later_does_not_replace_a_request() { + let mut tracker = Tracker::default(); + tracker.request(Some("msg_1"), &usage(2, 1_000, 40_000)); + // Told at the end of the turn, with the reply in it. + tracker.measured(41_900); + tracker.request(Some("msg_2"), &usage(2, 1_500, 41_002)); + assert_eq!(tracker.take(), 0); + } +} diff --git a/crates/botloftd/tests/token_reload.rs b/crates/botloftd/tests/token_reload.rs new file mode 100644 index 00000000..9f403a68 --- /dev/null +++ b/crates/botloftd/tests/token_reload.rs @@ -0,0 +1,70 @@ +//! The conversation written again to the prompt cache after it expired +//! (spec 8.7): counted apart from what a turn really added. + +mod common; + +use botloft_core::protocol::{BotState, ChatBody, TokenUsage}; +use common::context::{holds, settle}; +use common::stream; +use common::supervised::{Setup, setup}; +use serde_json::{Value, json}; + +fn request(id: &str, written: u64, read: u64) -> Value { + json!({ + "type": "assistant", "parent_tool_use_id": null, + "message": { "id": id, "role": "assistant", "model": "claude-sonnet-5-5", + "content": [{ "type": "text", "text": "Done." }], + "usage": { "input_tokens": 2, "cache_creation_input_tokens": written, + "cache_read_input_tokens": read, "output_tokens": 983 } }, + }) +} + +fn result(written: u64, read: u64) -> Value { + json!({ + "type": "result", "subtype": "success", "is_error": false, "duration_ms": 10_772, + "usage": { "input_tokens": 2, "cache_creation_input_tokens": written, + "cache_read_input_tokens": read, "output_tokens": 983 }, + }) +} + +fn last_turn(s: &Setup) -> TokenUsage { + let items = s + .daemon + .store() + .chat_history(&s.bot, None, 50) + .expect("history"); + items + .into_iter() + .find_map(|item| match item.body { + ChatBody::Turn(turn) => turn.tokens, + _ => None, + }) + .expect("a turn with tokens") +} + +#[tokio::test(start_paused = true)] +async fn a_turn_after_the_cache_expired_says_how_much_it_wrote_again() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + holds(&process, 72_000, 1_000_000).await; + + s.message("what is still to do?"); + process.emit(stream::began("session-1")).await; + // Nothing came from the cache. One request prints a line per block. + process.emit(request("msg_1", 72_636, 0)).await; + process.emit(request("msg_1", 72_636, 0)).await; + process.emit(result(72_636, 0)).await; + s.until(BotState::Idle).await; + let cold = last_turn(&s); + assert_eq!(cold.cache_write, 72_636); + assert_eq!(cold.reloaded, 72_000, "the conversation from before"); + + s.message("and now?"); + process.emit(stream::began("session-1")).await; + process.emit(request("msg_2", 1_400, 72_638)).await; + process.emit(result(1_400, 72_638)).await; + settle().await; + s.until(BotState::Idle).await; + assert_eq!(last_turn(&s).reloaded, 0, "the cache was warm"); +} diff --git a/crates/botloftd/tests/usage.rs b/crates/botloftd/tests/usage.rs index 2daa374c..1e014157 100644 --- a/crates/botloftd/tests/usage.rs +++ b/crates/botloftd/tests/usage.rs @@ -32,7 +32,7 @@ async fn each_turn_keeps_its_tokens_and_the_bots_add_them_up() { .expect("history"); assert_eq!( history[0]["body"]["tokens"], - json!({ "input": 12, "cacheWrite": 300, "cacheRead": 4000, "output": 45 }), + json!({ "input": 12, "cacheWrite": 300, "reloaded": 0, "cacheRead": 4000, "output": 45 }), "the turn's own tokens, from the result's usage" ); @@ -48,7 +48,7 @@ async fn each_turn_keeps_its_tokens_and_the_bots_add_them_up() { assert_eq!(bots[0]["turns"], 2); assert_eq!( bots[0]["tokens"], - json!({ "input": 24, "cacheWrite": 600, "cacheRead": 8000, "output": 90 }) + json!({ "input": 24, "cacheWrite": 600, "reloaded": 0, "cacheRead": 8000, "output": 90 }) ); assert!( diff --git a/docs/spec.md b/docs/spec.md index 82f2b808..2a989728 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -412,9 +412,10 @@ A compactação custa um pedido ao modelo com a conversa inteira (barato com o c O dono vê quantos tokens cada bot gasta: em cada turno, no chat, e somados por bot, no diálogo de uso. Nunca em dinheiro: o `total_cost_usd` do Claude Code é preço de API e soma o processo inteiro (19), e quem usa um plano do Claude não paga isso. -- **De onde vem.** O `usage` do `result` é a soma dos pedidos ao modelo daquele turno (19). O item `turn` guarda `tokens`: `input` (`input_tokens`), `cacheWrite` (`cache_creation_input_tokens`), `cacheRead` (`cache_read_input_tokens`) e `output` (`output_tokens`, com o pensamento). O `modelUsage` não serve: ele acumula entre turnos. -- **O que conta.** O número em destaque é `input + cacheWrite + output`: o texto novo que o modelo leu e o que escreveu. O `cacheRead` é quase todo a conversa relida a cada pedido, pesa bem menos e numa conversa longa seria a maior parte do total; ele aparece à parte, como "relidos". -- **No chat.** A linha do fim do turno diz a duração e os tokens ("Concluído em 4,2 s · 2,5 mil tokens"); o detalhe (novos, escritos, relidos) fica no título da linha. A unidade é o turno, não a mensagem: mensagens que chegam juntas entram num turno só, e não dá para dividir os tokens entre elas. +- **De onde vem.** O `usage` do `result` é a soma dos pedidos ao modelo daquele turno (19). O item `turn` guarda `tokens`: `input` (`input_tokens`), `cacheWrite` (`cache_creation_input_tokens`), `cacheRead` (`cache_read_input_tokens`) e `output` (`output_tokens`, com o pensamento), e ainda `reloaded`, a conta do daemon descrita abaixo (0 nos itens antigos). O `modelUsage` não serve: ele acumula entre turnos. +- **O que conta.** O número em destaque é `input + cacheWrite - reloaded + output`: o texto novo que o modelo leu e o que escreveu. O Claude Code manda quase tudo que é novo (a mensagem, o resultado de uma ferramenta) para o cache, por isso o `input` sozinho fica perto de zero e o novo está no `cacheWrite`. O `cacheRead` é quase todo a conversa relida a cada pedido, pesa bem menos e numa conversa longa seria a maior parte do total; ele aparece à parte, como "relidos". +- **A conversa recarregada.** Cada pedido manda a conversa inteira. Quando o cache expirou, porque o bot ficou parado ou o processo reiniciou, a parte da conversa que o cache não tinha mais é gravada de novo e vem no `cacheWrite`, misturada com o que é novo; num turno curto depois de uma pausa ela é quase tudo (19). O daemon separa essa parte em `reloaded`, sem guardar nada: para cada pedido da conversa principal (sem subagentes nem respostas sintéticas, contado uma vez por `message.id`), o que a conversa já tinha e não veio do cache, `min(cacheWrite, antes - cacheRead)`, onde `antes` é `input + cacheWrite + cacheRead` do pedido anterior ou, sem pedido visto, o tamanho que o Claude Code respondeu (8.6). Uma conversa nova ou recém-compactada não tem `antes` e não conta nada. O valor é limitado ao `cacheWrite` do `result`. O app mostra à parte só a partir de mil tokens, como "recarregou a conversa (72 mil)" no chat e "recarregados" no diálogo de uso. +- **No chat.** A linha do fim do turno diz a duração e os tokens ("Concluído em 4,2 s · 2,5 mil tokens"), e a conversa recarregada quando houve; o detalhe (novos, escritos, relidos, recarregados) fica no título da linha. A unidade é o turno, não a mensagem: mensagens que chegam juntas entram num turno só, e não dá para dividir os tokens entre elas. - **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo. - Uma compactação (8.6) não vira item `turn` e não entra na conta. @@ -1001,7 +1002,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Esforço em `-p` | 7.4 | Confirmado (`cli-reference`, `model-config`, `headless`): `--effort` aceita `low`, `medium`, `high`, `xhigh`, `max` (e `ultracode`, não usado), vale só para a sessão e não é guardado; os níveis dependem do modelo (Fable, Opus e Sonnet atuais têm os cinco; Opus 4.6 e Sonnet 4.6 não têm `xhigh`); sem escolha, o padrão é `high`, menos em Opus 5.5 e Sonnet 5.5 (`medium`) e Opus 4.7 (`xhigh`); `/effort ` funciona em `-p` e vale só para a sessão; as chaves `effortLevel` e `modelSettings` das settings não aceitam `max`; trocar o esforço no meio da sessão mantém o cache em Opus 5.5, Sonnet 5.5 e Fable 5.1 e o invalida nos outros (`prompt-caching`). **Testado com 2.1.284** (`--setting-sources project,local`, plano Max): `--effort max` e `xhigh` com `--model sonnet` são aplicados; um valor desconhecido só avisa no stderr e usa o padrão; `--resume` sem a flag volta ao padrão do modelo; sem a flag, Sonnet 5.5 e Opus 5.5 aplicam `medium` e Fable 5.1 `high`; Haiku 4.5 não tem níveis (o `initialize` do SDK lista `supportedEffortLevels` para os outros e nada para ele) e ignora a flag sem erro. `/effort low` mandado como mensagem troca na hora, com uma resposta sintética e sem custo, e o pedido de controle `apply_flag_settings {effortLevel}` também; o daemon reinicia o processo em vez disso. O que acontece com um nível que o modelo não tem (`xhigh` num modelo antigo) não foi visto: os apelidos do Botloft apontam para modelos com os cinco | feito | | Pedidos de controle no stdin | 9.2 | `getContextUsage()` é documentado no Agent SDK (`agent-sdk/typescript`), com `totalTokens`, `maxTokens`, `rawMaxTokens`, `percentage`, `autoCompactThreshold` e `isAutoCompactEnabled`; o formato no fio e `get_settings` **não são documentados**. **Testado com 2.1.284**: `{"type":"control_request","request_id","request":{"subtype":"get_context_usage"}}` é respondido com `control_response` (`subtype: "success"`, o mesmo `request_id`, `response`) em até 2,5 s, antes do primeiro turno, entre turnos e durante um turno, sem `system/init` nem `result`; `get_settings` responde `{effective, sources, applied: {model, effort, ...}}`, com `effort: null` em Haiku; um `subtype` desconhecido responde `subtype: "error"`. Numa sessão retomada, a primeira resposta levou uma vez mais de 15 s. **Pelo daemon de dev** (Haiku e Sonnet): o modelo e o esforço do modelo chegaram ao app antes do primeiro turno, e `--model sonnet --effort low` apareceu na linha de comando do bot depois de `bots.setEffort` | feito | | Uso de contexto no `stream-json` | 8.6 | Parcialmente documentado: `usage` e `modelUsage` no `result` (`agent-sdk/typescript`), janela de 1 milhão em Fable, Sonnet 5+ e Opus 4.7+ e compactação automática em cerca de 967 mil (`model-config`). **Visto com 2.1.284**: cada `assistant` traz `message.usage` (`input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`), e a soma dos três primeiros bate com o `totalTokens` do `get_context_usage` a menos de 1%; `result.modelUsage[modelo]` traz `contextWindow` (200000 em Haiku 4.5, 1000000 em Sonnet 5.5) e `maxOutputTokens`; `result.usage.iterations[]` traz cada pedido do turno; `autoCompactThreshold` é a janela menos 33 mil (167000, 967000; 67000 com `--autocompact 100k`, que também encolhe `maxTokens`); `/context` como mensagem devolve um `assistant` sintético com `context_usage` e abre um turno, por isso o daemon usa o pedido de controle. `total_cost_usd` é o total do processo, não do turno. **Pelo daemon de dev**: o app recebeu o tamanho ao subir o bot, a cada pedido ao modelo e no fim do turno | feito | -| Tokens do turno no `result` | 8.7 | Parcialmente documentado: `usage` no `result` (`agent-sdk/typescript`), sem dizer se é do turno ou do processo. **Testado com 2.1.284** (Haiku, um processo com dois turnos): `result.usage` é a soma dos pedidos do turno (um turno com um `Read` deu `input_tokens` 18 = 10 + 8 e `cache_read_input_tokens` 46703 = 21692 + 25011, a soma de `iterations[]`) e recomeça a cada turno; `modelUsage` e `total_cost_usd` acumulam o processo. `output_tokens` inclui `output_tokens_details.thinking_tokens`. Não verificado: se os tokens de subagentes entram nesse `usage` | feito | +| Tokens do turno no `result` | 8.7 | Parcialmente documentado: `usage` no `result` (`agent-sdk/typescript`), sem dizer se é do turno ou do processo. **Testado com 2.1.284** (Haiku, um processo com dois turnos): `result.usage` é a soma dos pedidos do turno (um turno com um `Read` deu `input_tokens` 18 = 10 + 8 e `cache_read_input_tokens` 46703 = 21692 + 25011, a soma de `iterations[]`) e recomeça a cada turno; `modelUsage` e `total_cost_usd` acumulam o processo. `output_tokens` inclui `output_tokens_details.thinking_tokens`. **Visto numa instalação real** (Sonnet, 2026-09-30): o primeiro turno de um bot sete horas depois do anterior, e três minutos depois de o Botloft reiniciar, levou 11 s e deu `input_tokens` 2, `cache_creation_input_tokens` 72636, `cache_read_input_tokens` 0 e `output_tokens` 983: a conversa inteira gravada de novo no cache, que levou à conta de `reloaded` (8.7). Não verificado: se os tokens de subagentes entram nesse `usage`; a conta de `reloaded` com teste real, num turno de cache quente (deve dar 0) e num depois de o cache expirar | feito | | Compactar em `-p` | 8.6 | `/compact [instruções]` é documentado (`commands`) sem dizer se vale em `-p`; o custo com cache quente e frio está em `prompt-caching`. **Testado com 2.1.284** (Haiku): `/compact` como mensagem imprime `system/status` (`status: "compacting"`), outro com `status: null` e `compact_result: "success"`, `system/init`, `system/compact_boundary` (`compact_metadata: {trigger: "manual", pre_tokens, post_tokens, duration_ms, ...}`), um `user` com `isSynthetic` e o resumo, dois `user` com `isReplay` (a saída `Compacted` e o comando, com o `uuid` enviado) e um `result` com `local_command: "compact"`, `num_turns: 0` e custo; levou de 12 a 16 s numa conversa pequena, e o bot lembrou depois o que tinha sido dito antes. Escrito durante um turno, roda quando o turno termina. Numa conversa vazia responde um `assistant` sintético "Error: No messages to compact" com `local_command_outcome: {kind: "failed"}` e um `result` sem erro. **Compactação automática** (Haiku, `--autocompact 100k`, quatro arquivos lidos): no meio do turno, os dois `system/status`, o `compact_boundary` com `trigger: "auto"` e o `user` sintético, sem `system/init` nem `result` próprios; o turno seguiu e terminou certo. **Pelo daemon de dev**: `bots.compact` deixou o bot `busy`, o aviso `compacted` entrou no chat, o tamanho caiu e o bot respondeu depois a palavra que tinha guardado; o log de `debug` só teve ids e números. Uma compactação que falha por erro da API não foi vista | feito | | CLAUDE.md do dono nos bots | 5, 7.5 | Confirmado (`memory`, `settings-reference`): o Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md` e `.claude/CLAUDE.md` do cwd e de toda pasta acima dele; `claudeMdExcludes` pula arquivos por caminho absoluto ou glob, vale em qualquer camada de settings e também para `AGENTS.md`; o `CLAUDE.md` gerenciado não pode ser excluído. **Testado com 2.1.284** (`-p --setting-sources project,local`, `memoryFiles` do `get_context_usage`, numa pasta de teste dentro de `%USERPROFILE%`). Sem a setting: o `%USERPROFILE%\.claude\CLAUDE.md` do dono entra como `Project`, e de cada pasta acima entram `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` com as subpastas; as skills são só as embutidas. Forma do padrão: `C:/Users/...` e `C:\Users\...` casam, também misturadas; `/c/Users/...` e `//c/Users/...` não casam; a comparação diferencia maiúsculas, até na letra do drive, e o Claude Code monta os caminhos com o cwd como o recebeu (um cwd em minúsculas aparece em minúsculas). Numa pasta chamada `odd (x) [y] {z}`, o caminho exato de um arquivo ainda casa, mas `.../.claude/rules/**` não; com `?` no lugar de cada um desses seis caracteres, casa. Espaço, acento e ``!+@#$%^&',;=~`` casam como estão. O `AGENTS.md` de uma pasta acima só entra com `instructionFiles: claude-md-and-agents-md` (os bots sempre têm `CLAUDE.md`), e o padrão o tira. `--add-dir` carrega `CLAUDE.md` e `.claude/rules/` só da própria pasta: os das pastas acima de uma pasta de trabalho escolhida não entram, com ou sem a setting. **Pelo daemon de dev** (workspaces dentro de `%USERPROFILE%`, com `CLAUDE.md` de teste em cada pasta acima; Haiku): com o `settings.json` gerado, `memoryFiles` e o próprio bot listaram só o `CLAUDE.md` da pasta da crew, o do bot, as regras e o da `shared\` | feito | | Memória automática do Claude Code nos bots | 7.5 | Confirmado (`memory`, `settings-reference`): ligada por padrão; o Claude Code grava `MEMORY.md` e um arquivo por nota em `%USERPROFILE%\.claude\projects\\memory\` e carrega o índice em toda sessão; `` vem do repositório git ou, fora de um, do caminho do cwd; `autoMemoryEnabled: false` vale em qualquer settings. **Testado com 2.1.284** (`-p --setting-sources project,local`, Haiku): ligada também assim, com cerca de 3 mil tokens a mais no prompt de sistema (6457 contra 3495). A pasta `memory\` é criada quando a sessão sobe. Pedido para lembrar de uma coisa, o bot gravou a nota e o `MEMORY.md` nessa pasta, fora do workspace, sem pedido de permissão no modo `default`, e não tocou no `CLAUDE.md` dele. O nome de `` é o caminho com `-` no lugar de tudo que não é letra nem número (`C--Users-ana-Botloft-site-revisor`); por essa regra `site-web\dev` e `site\web-dev` dariam a mesma pasta (não testado). Com o cwd dentro de um repositório git, a sessão carregou como `AutoMem` o `MEMORY.md` que o Claude Code guarda para o dono naquele repositório. Com `autoMemoryEnabled: false` no `settings.json` do projeto, o prompt encolhe e nada disso é carregado, nem dentro do repositório. **Pelo daemon de dev** (Haiku, "Aceitar edições"): pedido para lembrar, o bot editou o `CLAUDE.md` do workspace, e nenhuma pasta `memory\` foi criada. Mover a memória automática para dentro do workspace (`autoMemoryDirectory`) não foi testado | feito | From 673b6c2932cf89fb317b3e5ed71637d2f8725610 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:19:19 -0300 Subject: [PATCH 2/2] feat(app): show the reloaded conversation apart from a turn's tokens The turn line and the usage dialog count new input and replies, and tell the conversation sent again after a pause on its own, from 1k tokens on. --- app/src/features/account/TokensByBot.tsx | 9 ++++--- app/src/features/account/tokens.test.tsx | 22 ++++++++++++++++ app/src/features/chat/BotRun.tsx | 8 +++--- app/src/features/chat/context.test.tsx | 32 ++++++++++++++++++++++++ app/src/i18n/en/account.ts | 8 +++--- app/src/i18n/en/chat.ts | 14 ++++++++--- app/src/i18n/es/account.ts | 8 +++--- app/src/i18n/es/chat.ts | 12 ++++++--- app/src/i18n/pt-BR/account.ts | 8 +++--- app/src/i18n/pt-BR/chat.ts | 12 ++++++--- app/src/lib/fakeChat.ts | 14 +++++++++-- app/src/lib/fakeUsage.ts | 5 ++-- app/src/lib/format.ts | 13 ++++++++-- 13 files changed, 135 insertions(+), 30 deletions(-) diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx index fdb34af7..3a9d57c7 100644 --- a/app/src/features/account/TokensByBot.tsx +++ b/app/src/features/account/TokensByBot.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { useT } from "../../i18n"; import { errorText } from "../../lib/api"; -import { tokens, usedTokens } from "../../lib/format"; +import { reloadedTokens, tokens, usedTokens } from "../../lib/format"; import type { BotTokens } from "../../lib/protocol.gen"; import { useApi } from "../../store/context"; import { Callout } from "../../ui/Callout"; @@ -99,7 +99,7 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) { /> - {u.detail(bot.turns, tokens(bot.tokens.cacheRead))} + {u.detail(bot.turns, tokens(bot.tokens.cacheRead), reloadedTokens(bot.tokens.reloaded))} @@ -111,13 +111,16 @@ function Total({ bots }: { bots: BotTokens[] }) { const used = bots.reduce((sum, bot) => sum + usedTokens(bot.tokens), 0); const turns = bots.reduce((sum, bot) => sum + bot.turns, 0); const reread = bots.reduce((sum, bot) => sum + bot.tokens.cacheRead, 0); + const reloaded = bots.reduce((sum, bot) => sum + bot.tokens.reloaded, 0); return (
  • {u.total} {tokens(used)}
    - {u.detail(turns, tokens(reread))} + + {u.detail(turns, tokens(reread), reloadedTokens(reloaded))} +
  • ); } diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx index 10e787ea..5d756b71 100644 --- a/app/src/features/account/tokens.test.tsx +++ b/app/src/features/account/tokens.test.tsx @@ -45,6 +45,28 @@ describe("tokens by bot", () => { expect(dialog.textContent).toContain("All bots9.8k"); }); + test("the conversation sent again after a pause counts apart", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.chat.turn(scout.id, null, { + input: 2, + cacheWrite: 72_636, + reloaded: 72_000, + cacheRead: 0, + output: 983, + }); + fake.chat.turn(scout.id); + + const dialog = await openUsage(fake); + const list = await within(dialog).findByRole("list", { name: "Tokens by bot" }); + expect( + within(list) + .getAllByRole("listitem") + .map((row) => row.textContent), + ).toEqual(["Scout4.1kWorked 2 times · 24k reread · 72k reloaded"]); + }); + test("a period with no work says so", async () => { const dialog = await openUsage(new FakeBotloft()); expect(await within(dialog).findByText("No bot worked in this period.")).toBeDefined(); diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index cdb9d1d6..0529324b 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -5,7 +5,7 @@ import { CircleAlert } from "lucide-react"; import { memo, type ReactNode } from "react"; import { useT } from "../../i18n"; -import { duration, tokens, usedTokens, when } from "../../lib/format"; +import { duration, reloadedTokens, tokens, usedTokens, when } from "../../lib/format"; import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen"; import { useArrival } from "../../ui/motion"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; @@ -33,14 +33,16 @@ function TurnEnd({ turn }: { turn: TurnItem }) { } const time = duration(turn.durationMs); const used = turn.tokens; + const reloaded = used && reloadedTokens(used.reloaded); const detail = used && { - read: tokens(used.input + used.cacheWrite), + read: tokens(used.input + used.cacheWrite - used.reloaded), wrote: tokens(used.output), reread: tokens(used.cacheRead), + reloaded, }; return (

    - {t.chat.run.done(time, used && tokens(usedTokens(used)))} + {t.chat.run.done(time, used && tokens(usedTokens(used)), reloaded)}

    ); } diff --git a/app/src/features/chat/context.test.tsx b/app/src/features/chat/context.test.tsx index ead40c34..c682a61e 100644 --- a/app/src/features/chat/context.test.tsx +++ b/app/src/features/chat/context.test.tsx @@ -39,6 +39,38 @@ function open() { } describe("conversation space", () => { + test("a turn after a pause tells the conversation it sent again apart", async () => { + const { fake, scout } = await openScout(); + act(() => { + // The turn seen in spec 19: almost all of it was the conversation again. + fake.chat.turn(scout.id, null, { + input: 2, + cacheWrite: 72_636, + reloaded: 72_000, + cacheRead: 0, + output: 983, + }); + }); + const done = screen.getByText("Done in 4.2 s · 1.6k tokens · reloaded the conversation (72k)"); + expect(done.title).toBe( + "Took 4.2 s. Read 638 new tokens and wrote 983; reread 0 of the conversation, which weighs much less. It also sent 72k of the conversation again, as the model no longer had it after a pause.", + ); + }); + + test("a little sent again is not worth a mention", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.chat.turn(scout.id, null, { + input: 2, + cacheWrite: 1_400, + reloaded: 300, + cacheRead: 40_000, + output: 100, + }); + }); + expect(screen.getByText("Done in 4.2 s · 1.2k tokens")).toBeDefined(); + }); + test("token counts read short", () => { expect(tokens(850)).toBe("850"); expect(tokens(24_340)).toBe("24.3k"); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 6c1bdbe8..179f4303 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -30,14 +30,16 @@ export const account = { tokens: { title: "Tokens by bot", intro: - "Tokens are the pieces of text a bot reads and writes. The count adds the new text read and the replies written; the conversation reread each time shows apart, as it weighs much less.", + "Tokens are the pieces of text a bot reads and writes. The count adds the new text read and the replies written. The conversation reread each time shows apart, as it weighs much less, and so does the conversation sent again after a pause.", period: "Period", periods: { today: "Today", week: "7 days", month: "30 days", all: "All time" }, empty: "No bot worked in this period.", failed: "Could not load the tokens", archived: "archived", - detail: (times: number, reread: string) => - `Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread`, + detail: (times: number, reread: string, reloaded: string | null) => + `Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread${ + reloaded === null ? "" : ` · ${reloaded} reloaded` + }`, total: "All bots", }, }, diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 78df11f0..caedd348 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -7,6 +7,8 @@ export interface TurnTokens { read: string; wrote: string; reread: string; + /** The conversation sent again after a pause, if it was worth telling. */ + reloaded: string | null; } export const chat = { @@ -54,13 +56,19 @@ export const chat = { }, run: { working: "Working", - done: (time: string, tokens: string | null) => - `Done in ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, + done: (time: string, tokens: string | null, reloaded: string | null) => + `Done in ${time}${tokens === null ? "" : ` · ${tokens} tokens`}${ + reloaded === null ? "" : ` · reloaded the conversation (${reloaded})` + }`, took: (time: string, tokens: TurnTokens | null) => `Took ${time}${ tokens === null ? "" - : `. Read ${tokens.read} new tokens and wrote ${tokens.wrote}; reread ${tokens.reread} of the conversation, which weighs much less.` + : `. Read ${tokens.read} new tokens and wrote ${tokens.wrote}; reread ${tokens.reread} of the conversation, which weighs much less.${ + tokens.reloaded === null + ? "" + : ` It also sent ${tokens.reloaded} of the conversation again, as the model no longer had it after a pause.` + }` }`, stopped: (reason: string) => `The bot stopped working on this: ${reason}`, }, diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index e5b52959..2338f1ec 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -30,14 +30,16 @@ export const account: Messages["account"] = { tokens: { title: "Tokens por bot", intro: - "Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto nuevo leído y las respuestas escritas; la conversación releída cada vez aparece aparte, porque pesa mucho menos.", + "Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto nuevo leído y las respuestas escritas. La conversación releída cada vez aparece aparte, porque pesa mucho menos, y también la conversación enviada de nuevo tras una pausa.", period: "Período", periods: { today: "Hoy", week: "7 días", month: "30 días", all: "Todo" }, empty: "Ningún bot trabajó en este período.", failed: "No se pudieron cargar los tokens", archived: "archivado", - detail: (times: number, reread: string) => - `Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos`, + detail: (times: number, reread: string, reloaded: string | null) => + `Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos${ + reloaded === null ? "" : ` · ${reloaded} recargados` + }`, total: "Todos los bots", }, }, diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index eeb9671d..bc938d6f 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -49,13 +49,19 @@ export const chat: Messages["chat"] = { }, run: { working: "Trabajando", - done: (time: string, tokens: string | null) => - `Terminado en ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, + done: (time: string, tokens: string | null, reloaded: string | null) => + `Terminado en ${time}${tokens === null ? "" : ` · ${tokens} tokens`}${ + reloaded === null ? "" : ` · recargó la conversación (${reloaded})` + }`, took: (time: string, tokens: TurnTokens | null) => `Tardó ${time}${ tokens === null ? "" - : `. Leyó ${tokens.read} tokens nuevos y escribió ${tokens.wrote}; releyó ${tokens.reread} de la conversación, que pesa mucho menos.` + : `. Leyó ${tokens.read} tokens nuevos y escribió ${tokens.wrote}; releyó ${tokens.reread} de la conversación, que pesa mucho menos.${ + tokens.reloaded === null + ? "" + : ` También volvió a enviar ${tokens.reloaded} de la conversación, que el modelo ya no tenía tras una pausa.` + }` }`, stopped: (reason: string) => `El bot dejó de trabajar en esto: ${reason}`, }, diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 129fdb89..45777e6d 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -30,14 +30,16 @@ export const account: Messages["account"] = { tokens: { title: "Tokens por bot", intro: - "Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto novo lido e as respostas escritas; a conversa relida a cada vez aparece à parte, porque pesa bem menos.", + "Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto novo lido e as respostas escritas. A conversa relida a cada vez aparece à parte, porque pesa bem menos, e a conversa mandada de novo depois de uma pausa também.", period: "Período", periods: { today: "Hoje", week: "7 dias", month: "30 dias", all: "Tudo" }, empty: "Nenhum bot trabalhou nesse período.", failed: "Não deu para carregar os tokens", archived: "arquivado", - detail: (times: number, reread: string) => - `Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos`, + detail: (times: number, reread: string, reloaded: string | null) => + `Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos${ + reloaded === null ? "" : ` · ${reloaded} recarregados` + }`, total: "Todos os bots", }, }, diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index cb075b82..2a2f75f0 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -49,13 +49,19 @@ export const chat: Messages["chat"] = { }, run: { working: "Trabalhando", - done: (time: string, tokens: string | null) => - `Concluído em ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, + done: (time: string, tokens: string | null, reloaded: string | null) => + `Concluído em ${time}${tokens === null ? "" : ` · ${tokens} tokens`}${ + reloaded === null ? "" : ` · recarregou a conversa (${reloaded})` + }`, took: (time: string, tokens: TurnTokens | null) => `Levou ${time}${ tokens === null ? "" - : `. Leu ${tokens.read} tokens novos e escreveu ${tokens.wrote}; releu ${tokens.reread} da conversa, que pesa bem menos.` + : `. Leu ${tokens.read} tokens novos e escreveu ${tokens.wrote}; releu ${tokens.reread} da conversa, que pesa bem menos.${ + tokens.reloaded === null + ? "" + : ` Também mandou de novo ${tokens.reloaded} da conversa, que o modelo já não tinha depois de uma pausa.` + }` }`, stopped: (reason: string) => `O bot parou de trabalhar nisto: ${reason}`, }, diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index a3ff2618..dff10a26 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -13,9 +13,19 @@ import type { ChatBody, ChatItem, RoutinesCreateParams, + TokenUsage, ToolItem, } from "./protocol.gen"; +/** A turn's tokens unless a test says otherwise: a warm cache. */ +const TURN_TOKENS: TokenUsage = { + input: 12, + cacheWrite: 1800, + reloaded: 0, + cacheRead: 24_000, + output: 640, +}; + /** The chief's tool to suggest a bot (spec 10.2). */ export const SUGGEST_TOOL = "mcp__botloft__suggest_bot"; /** Any bot's tool to ask for a routine (spec 20.12). */ @@ -121,11 +131,11 @@ export class FakeChat { return this.update(item.id, { ...item.body, status: failed ? "failed" : "done", output }); } - turn(botId: BotId, error: string | null = null): ChatItem { + turn(botId: BotId, error: string | null = null, tokens: TokenUsage = TURN_TOKENS): ChatItem { return this.add(botId, { kind: "turn", durationMs: 4200, - tokens: { input: 12, cacheWrite: 1800, cacheRead: 24_000, output: 640 }, + tokens, error, }); } diff --git a/app/src/lib/fakeUsage.ts b/app/src/lib/fakeUsage.ts index 68ecb1b1..38670d1f 100644 --- a/app/src/lib/fakeUsage.ts +++ b/app/src/lib/fakeUsage.ts @@ -22,15 +22,16 @@ export function usageHandlers(fake: FakeBotloft): Pick color: bot.color, archived: bot.archivedAt !== null, turns: 0, - tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }, + tokens: { input: 0, cacheWrite: 0, reloaded: 0, cacheRead: 0, output: 0 }, }; byBot.set(bot.id, entry); } - const { input, cacheWrite, cacheRead, output } = item.body.tokens; + const { input, cacheWrite, reloaded, cacheRead, output } = item.body.tokens; entry.turns += 1; entry.tokens = { input: entry.tokens.input + input, cacheWrite: entry.tokens.cacheWrite + cacheWrite, + reloaded: entry.tokens.reloaded + reloaded, cacheRead: entry.tokens.cacheRead + cacheRead, output: entry.tokens.output + output, }; diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index 28327ea8..808e4631 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -111,10 +111,19 @@ export function tokens(count: number): string { /** * The tokens a turn counts for (spec 8.7): new input read, with what went * into the prompt cache, and what the model wrote. The conversation reread - * from the cache weighs much less and is shown apart. + * from the cache weighs much less and is shown apart, and so is the + * conversation written to the cache again after it expired. */ export function usedTokens(usage: TokenUsage): number { - return usage.input + usage.cacheWrite + usage.output; + return usage.input + usage.cacheWrite - usage.reloaded + usage.output; +} + +/** Below this, the conversation sent again is not worth a mention. */ +const RELOAD_SHOWN = 1000; + +/** The conversation sent again, written as a number, or `null` if it is too little to tell. */ +export function reloadedTokens(count: number): string | null { + return count >= RELOAD_SHOWN ? tokens(count) : null; } /** "0.8 s", "42 s", "3 min 5 s". */