From 143e14a920f2169f30e5c44e8a384ab1df470440 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:05:22 -0300 Subject: [PATCH 1/2] feat(app): show only the tokens a bot spent, without the reread conversation The reread count next to the spent one confused people. The chat's turn line tooltip and the usage dialog drop it; the conversation sent again after a pause still shows apart. cacheRead is still stored and summed. --- app/src/features/account/TokensByBot.tsx | 16 ++++------------ app/src/features/account/tokens.test.tsx | 14 +++++++------- app/src/features/chat/BotRun.tsx | 1 - app/src/features/chat/chat.test.tsx | 4 +--- app/src/features/chat/context.test.tsx | 2 +- app/src/i18n/en/account.ts | 6 +++--- app/src/i18n/en/chat.ts | 3 +-- app/src/i18n/es/account.ts | 6 +++--- app/src/i18n/es/chat.ts | 2 +- app/src/i18n/pt-BR/account.ts | 6 +++--- app/src/i18n/pt-BR/chat.ts | 2 +- app/src/lib/format.ts | 4 ++-- docs/spec.md | 4 ++-- 13 files changed, 29 insertions(+), 41 deletions(-) diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx index 7b4d33fa..a0cc7e82 100644 --- a/app/src/features/account/TokensByBot.tsx +++ b/app/src/features/account/TokensByBot.tsx @@ -1,6 +1,6 @@ // Tokens each bot used over a period (spec 8.7), in the usage dialog: the -// new text read plus the replies written, with the conversation reread -// each time shown apart. Each bot shows its crew, as two crews can have +// new text read plus the replies written, with the conversation sent +// again after a pause shown apart. Each bot shows its crew, as two crews can have // bots with the same name. import { useEffect, useState } from "react"; @@ -100,12 +100,7 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) { /> - {u.detail( - bot.crew, - bot.turns, - tokens(bot.tokens.cacheRead), - reloadedTokens(bot.tokens.reloaded), - )} + {u.detail(bot.crew, bot.turns, reloadedTokens(bot.tokens.reloaded))} @@ -116,7 +111,6 @@ function Total({ bots }: { bots: BotTokens[] }) { const u = useT().account.usage.tokens; 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 (
  • @@ -124,9 +118,7 @@ function Total({ bots }: { bots: BotTokens[] }) { {u.total} {tokens(used)} - - {u.detail(null, turns, tokens(reread), reloadedTokens(reloaded))} - + {u.detail(null, turns, reloadedTokens(reloaded))}
  • ); } diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx index 33f88a7e..168a3b6d 100644 --- a/app/src/features/account/tokens.test.tsx +++ b/app/src/features/account/tokens.test.tsx @@ -34,9 +34,9 @@ describe("tokens by bot", () => { const rows = within(list).getAllByRole("listitem"); // Each turn: 12 new, 1,800 into the cache and 640 written count; 24,000 reread do not. expect(rows.map((row) => row.textContent)).toEqual([ - "Scout4.9kOps · Worked 2 times · 48k reread", - "Writer2.5kOps · Worked once · 24k reread", - "All bots7.4kWorked 3 times · 72k reread", + "Scout4.9kOps · Worked 2 times", + "Writer2.5kOps · Worked once", + "All bots7.4kWorked 3 times", ]); fireEvent.click(within(dialog).getByRole("radio", { name: "7 days" })); @@ -64,7 +64,7 @@ describe("tokens by bot", () => { within(list) .getAllByRole("listitem") .map((row) => row.textContent), - ).toEqual(["Scout4.1kOps · Worked 2 times · 24k reread · 72k reloaded"]); + ).toEqual(["Scout4.1kOps · Worked 2 times · 72k reloaded"]); }); test("bots with the same name in two crews show their crew", async () => { @@ -81,9 +81,9 @@ describe("tokens by bot", () => { .getAllByRole("listitem") .map((row) => row.textContent), ).toEqual([ - "Scout2.5kNewsroom · Worked once · 24k reread", - "Scout2.5kOps · Worked once · 24k reread", - "All bots4.9kWorked 2 times · 48k reread", + "Scout2.5kNewsroom · Worked once", + "Scout2.5kOps · Worked once", + "All bots4.9kWorked 2 times", ]); }); diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 0529324b..4c2b1394 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -37,7 +37,6 @@ function TurnEnd({ turn }: { turn: TurnItem }) { const detail = used && { read: tokens(used.input + used.cacheWrite - used.reloaded), wrote: tokens(used.output), - reread: tokens(used.cacheRead), reloaded, }; return ( diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx index 3a45a272..1587597a 100644 --- a/app/src/features/chat/chat.test.tsx +++ b/app/src/features/chat/chat.test.tsx @@ -91,9 +91,7 @@ describe("chat", () => { expect(within(chat()).getByText("Command")).toBeDefined(); expect(within(chat()).getByText("npm test")).toBeDefined(); const done = within(chat()).getByText("Done in 4.2 s · 2.5k tokens"); - expect(done.title).toBe( - "Took 4.2 s. Read 1.8k new tokens and wrote 640; reread 24k of the conversation, which weighs much less.", - ); + expect(done.title).toBe("Took 4.2 s. Read 1.8k new tokens and wrote 640."); }); test("an approval waits for the owner, and a denial carries a note", async () => { diff --git a/app/src/features/chat/context.test.tsx b/app/src/features/chat/context.test.tsx index c682a61e..894dc64c 100644 --- a/app/src/features/chat/context.test.tsx +++ b/app/src/features/chat/context.test.tsx @@ -53,7 +53,7 @@ describe("conversation space", () => { }); 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.", + "Took 4.2 s. Read 638 new tokens and wrote 983. It also sent 72k of the conversation again, as the model no longer had it after a pause.", ); }); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 91ef15c0..2abd985f 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -30,15 +30,15 @@ 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, and so does the conversation sent again after a pause.", + "Tokens are the pieces of text a bot reads and writes. The count adds the new text read and the replies written. The conversation sent again after a pause shows apart.", 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", /** `crew` is null on the total of all bots. */ - detail: (crew: string | null, times: number, reread: string, reloaded: string | null) => - `${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread${ + detail: (crew: string | null, times: number, reloaded: string | null) => + `${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`}${ reloaded === null ? "" : ` · ${reloaded} reloaded` }`, total: "All bots", diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index caedd348..13f646dc 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -6,7 +6,6 @@ export interface TurnTokens { read: string; wrote: string; - reread: string; /** The conversation sent again after a pause, if it was worth telling. */ reloaded: string | null; } @@ -64,7 +63,7 @@ export const chat = { `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}.${ tokens.reloaded === null ? "" : ` It also sent ${tokens.reloaded} of the conversation again, as the model no longer had it after a pause.` diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 291d8182..f80635ce 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -30,15 +30,15 @@ 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, y también la conversación enviada de nuevo tras una pausa.", + "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 enviada de nuevo tras una pausa aparece aparte.", 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", /** `crew` is null on the total of all bots. */ - detail: (crew: string | null, times: number, reread: string, reloaded: string | null) => - `${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos${ + detail: (crew: string | null, times: number, reloaded: string | null) => + `${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`}${ 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 bc938d6f..6b96e918 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -57,7 +57,7 @@ export const chat: Messages["chat"] = { `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}.${ tokens.reloaded === null ? "" : ` También volvió a enviar ${tokens.reloaded} de la conversación, que el modelo ya no tenía tras una pausa.` diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 93085ee6..eae2c4e4 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -30,15 +30,15 @@ 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, e a conversa mandada de novo depois de uma pausa também.", + "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 mandada de novo depois de uma pausa aparece à parte.", 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", /** `crew` is null on the total of all bots. */ - detail: (crew: string | null, times: number, reread: string, reloaded: string | null) => - `${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos${ + detail: (crew: string | null, times: number, reloaded: string | null) => + `${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`}${ 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 2a2f75f0..b5971f91 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -57,7 +57,7 @@ export const chat: Messages["chat"] = { `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}.${ tokens.reloaded === null ? "" : ` Também mandou de novo ${tokens.reloaded} da conversa, que o modelo já não tinha depois de uma pausa.` diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index 808e4631..68ec96cb 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -111,8 +111,8 @@ 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, and so is the - * conversation written to the cache again after it expired. + * from the cache is left out, and the conversation written to the cache + * again after it expired is shown apart. */ export function usedTokens(usage: TokenUsage): number { return usage.input + usage.cacheWrite - usage.reloaded + usage.output; diff --git a/docs/spec.md b/docs/spec.md index f87686f7..10f0a8ab 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -413,9 +413,9 @@ 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), 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". +- **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. O app não o mostra: um número "relidos" ao lado do gasto confundia quem lê. Ele continua gravado no item `turn` e somado no `usage.tokens`. - **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. +- **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, 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, o nome da equipe (dois bots podem ter o mesmo nome em equipes diferentes) 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. From a4bf186b496ab3373ea9c221411a4081bdb0c141 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:13:51 -0300 Subject: [PATCH 2/2] feat(app): count the reloaded conversation in a turn's tokens The conversation sent again after a pause is real spending of the plan, and the separate "reloaded" count next to the total confused people. The turn line, its tooltip and the usage dialog now show one number, input + cacheWrite + output. The daemon still records reloaded. --- app/src/features/account/TokensByBot.tsx | 12 ++++-------- app/src/features/account/tokens.test.tsx | 4 ++-- app/src/features/chat/BotRun.tsx | 8 +++----- app/src/features/chat/chat.test.tsx | 2 +- app/src/features/chat/context.test.tsx | 22 +++------------------- app/src/i18n/en/account.ts | 8 +++----- app/src/i18n/en/chat.ts | 16 +++------------- app/src/i18n/es/account.ts | 8 +++----- app/src/i18n/es/chat.ts | 14 +++----------- app/src/i18n/pt-BR/account.ts | 8 +++----- app/src/i18n/pt-BR/chat.ts | 14 +++----------- app/src/lib/format.ts | 17 ++++------------- crates/botloft-store/src/usage.rs | 2 +- docs/spec.md | 6 +++--- 14 files changed, 39 insertions(+), 102 deletions(-) diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx index a0cc7e82..3ed43b50 100644 --- a/app/src/features/account/TokensByBot.tsx +++ b/app/src/features/account/TokensByBot.tsx @@ -1,12 +1,11 @@ // Tokens each bot used over a period (spec 8.7), in the usage dialog: the -// new text read plus the replies written, with the conversation sent -// again after a pause shown apart. Each bot shows its crew, as two crews can have +// text read plus the replies written. Each bot shows its crew, as two crews can have // bots with the same name. import { useEffect, useState } from "react"; import { useT } from "../../i18n"; import { errorText } from "../../lib/api"; -import { reloadedTokens, tokens, usedTokens } from "../../lib/format"; +import { tokens, usedTokens } from "../../lib/format"; import type { BotTokens } from "../../lib/protocol.gen"; import { useApi } from "../../store/context"; import { Callout } from "../../ui/Callout"; @@ -99,9 +98,7 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) { style={{ width: `${(used / most) * 100}%` }} /> - - {u.detail(bot.crew, bot.turns, reloadedTokens(bot.tokens.reloaded))} - + {u.detail(bot.crew, bot.turns)} ); @@ -111,14 +108,13 @@ function Total({ bots }: { bots: BotTokens[] }) { const u = useT().account.usage.tokens; const used = bots.reduce((sum, bot) => sum + usedTokens(bot.tokens), 0); const turns = bots.reduce((sum, bot) => sum + bot.turns, 0); - const reloaded = bots.reduce((sum, bot) => sum + bot.tokens.reloaded, 0); return (
  • {u.total} {tokens(used)}
    - {u.detail(null, turns, reloadedTokens(reloaded))} + {u.detail(null, turns)}
  • ); } diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx index 168a3b6d..0f4a30bc 100644 --- a/app/src/features/account/tokens.test.tsx +++ b/app/src/features/account/tokens.test.tsx @@ -45,7 +45,7 @@ describe("tokens by bot", () => { expect(dialog.textContent).toContain("All bots9.8k"); }); - test("the conversation sent again after a pause counts apart", async () => { + test("the conversation sent again after a pause counts in the total", async () => { const fake = new FakeBotloft(); const ops = fake.addCrew("Ops"); const scout = fake.addBot(ops.id, "Scout", "Finds sources"); @@ -64,7 +64,7 @@ describe("tokens by bot", () => { within(list) .getAllByRole("listitem") .map((row) => row.textContent), - ).toEqual(["Scout4.1kOps · Worked 2 times · 72k reloaded"]); + ).toEqual(["Scout76.1kOps · Worked 2 times"]); }); test("bots with the same name in two crews show their crew", async () => { diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 4c2b1394..2916d9c2 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, reloadedTokens, tokens, usedTokens, when } from "../../lib/format"; +import { duration, 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,15 +33,13 @@ 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 - used.reloaded), + read: tokens(used.input + used.cacheWrite), wrote: tokens(used.output), - reloaded, }; return (

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

    ); } diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx index 1587597a..b443df19 100644 --- a/app/src/features/chat/chat.test.tsx +++ b/app/src/features/chat/chat.test.tsx @@ -91,7 +91,7 @@ describe("chat", () => { expect(within(chat()).getByText("Command")).toBeDefined(); expect(within(chat()).getByText("npm test")).toBeDefined(); const done = within(chat()).getByText("Done in 4.2 s · 2.5k tokens"); - expect(done.title).toBe("Took 4.2 s. Read 1.8k new tokens and wrote 640."); + expect(done.title).toBe("Took 4.2 s. Read 1.8k tokens and wrote 640."); }); test("an approval waits for the owner, and a denial carries a note", async () => { diff --git a/app/src/features/chat/context.test.tsx b/app/src/features/chat/context.test.tsx index 894dc64c..a3407b50 100644 --- a/app/src/features/chat/context.test.tsx +++ b/app/src/features/chat/context.test.tsx @@ -39,7 +39,7 @@ function open() { } describe("conversation space", () => { - test("a turn after a pause tells the conversation it sent again apart", async () => { + test("a turn after a pause counts the conversation it sent again", async () => { const { fake, scout } = await openScout(); act(() => { // The turn seen in spec 19: almost all of it was the conversation again. @@ -51,24 +51,8 @@ describe("conversation space", () => { 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. 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(); + const done = screen.getByText("Done in 4.2 s · 73.6k tokens"); + expect(done.title).toBe("Took 4.2 s. Read 72.6k tokens and wrote 983."); }); test("token counts read short", () => { diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 2abd985f..c9f48c36 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -30,17 +30,15 @@ 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 sent again after a pause shows apart.", + "Tokens are the pieces of text a bot reads and writes. The count adds the text read and the replies written.", 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", /** `crew` is null on the total of all bots. */ - detail: (crew: string | null, times: number, reloaded: string | null) => - `${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`}${ - reloaded === null ? "" : ` · ${reloaded} reloaded` - }`, + detail: (crew: string | null, times: number) => + `${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`}`, total: "All bots", }, }, diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 13f646dc..f1be1055 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -6,8 +6,6 @@ export interface TurnTokens { read: string; wrote: string; - /** The conversation sent again after a pause, if it was worth telling. */ - reloaded: string | null; } export const chat = { @@ -55,19 +53,11 @@ export const chat = { }, run: { working: "Working", - done: (time: string, tokens: string | null, reloaded: string | null) => - `Done in ${time}${tokens === null ? "" : ` · ${tokens} tokens`}${ - reloaded === null ? "" : ` · reloaded the conversation (${reloaded})` - }`, + done: (time: string, tokens: string | null) => + `Done in ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, took: (time: string, tokens: TurnTokens | null) => `Took ${time}${ - tokens === null - ? "" - : `. Read ${tokens.read} new tokens and wrote ${tokens.wrote}.${ - tokens.reloaded === null - ? "" - : ` It also sent ${tokens.reloaded} of the conversation again, as the model no longer had it after a pause.` - }` + tokens === null ? "" : `. Read ${tokens.read} tokens and wrote ${tokens.wrote}.` }`, 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 f80635ce..8d29564f 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -30,17 +30,15 @@ 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 enviada de nuevo tras una pausa aparece aparte.", + "Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto leído y las respuestas escritas.", 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", /** `crew` is null on the total of all bots. */ - detail: (crew: string | null, times: number, reloaded: string | null) => - `${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`}${ - reloaded === null ? "" : ` · ${reloaded} recargados` - }`, + detail: (crew: string | null, times: number) => + `${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`}`, total: "Todos los bots", }, }, diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index 6b96e918..bcd7ee66 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -49,19 +49,11 @@ export const chat: Messages["chat"] = { }, run: { working: "Trabajando", - done: (time: string, tokens: string | null, reloaded: string | null) => - `Terminado en ${time}${tokens === null ? "" : ` · ${tokens} tokens`}${ - reloaded === null ? "" : ` · recargó la conversación (${reloaded})` - }`, + done: (time: string, tokens: string | null) => + `Terminado en ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, took: (time: string, tokens: TurnTokens | null) => `Tardó ${time}${ - tokens === null - ? "" - : `. Leyó ${tokens.read} tokens nuevos y escribió ${tokens.wrote}.${ - tokens.reloaded === null - ? "" - : ` También volvió a enviar ${tokens.reloaded} de la conversación, que el modelo ya no tenía tras una pausa.` - }` + tokens === null ? "" : `. Leyó ${tokens.read} tokens y escribió ${tokens.wrote}.` }`, 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 eae2c4e4..8779cfe6 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -30,17 +30,15 @@ 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 mandada de novo depois de uma pausa aparece à parte.", + "Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto lido e as respostas escritas.", 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", /** `crew` is null on the total of all bots. */ - detail: (crew: string | null, times: number, reloaded: string | null) => - `${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`}${ - reloaded === null ? "" : ` · ${reloaded} recarregados` - }`, + detail: (crew: string | null, times: number) => + `${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`}`, total: "Todos os bots", }, }, diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index b5971f91..58bdd341 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -49,19 +49,11 @@ export const chat: Messages["chat"] = { }, run: { working: "Trabalhando", - done: (time: string, tokens: string | null, reloaded: string | null) => - `Concluído em ${time}${tokens === null ? "" : ` · ${tokens} tokens`}${ - reloaded === null ? "" : ` · recarregou a conversa (${reloaded})` - }`, + done: (time: string, tokens: string | null) => + `Concluído em ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, took: (time: string, tokens: TurnTokens | null) => `Levou ${time}${ - tokens === null - ? "" - : `. Leu ${tokens.read} tokens novos e escreveu ${tokens.wrote}.${ - tokens.reloaded === null - ? "" - : ` Também mandou de novo ${tokens.reloaded} da conversa, que o modelo já não tinha depois de uma pausa.` - }` + tokens === null ? "" : `. Leu ${tokens.read} tokens e escreveu ${tokens.wrote}.` }`, stopped: (reason: string) => `O bot parou de trabalhar nisto: ${reason}`, }, diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index 68ec96cb..4622cc84 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -109,21 +109,12 @@ 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 is left out, and the conversation written to the cache - * again after it expired is shown apart. + * The tokens a turn counts for (spec 8.7): input read, with what went into + * the prompt cache (the conversation sent again after a pause too), and + * what the model wrote. The conversation reread from the cache is left out. */ export function usedTokens(usage: TokenUsage): number { - 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; + return usage.input + usage.cacheWrite + usage.output; } /** "0.8 s", "42 s", "3 min 5 s". */ diff --git a/crates/botloft-store/src/usage.rs b/crates/botloft-store/src/usage.rs index 190c6fb4..092e9198 100644 --- a/crates/botloft-store/src/usage.rs +++ b/crates/botloft-store/src/usage.rs @@ -27,7 +27,7 @@ impl Store { 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')) - reloaded + output DESC, \ + + SUM(json_extract(c.data, '$.tokens.cacheWrite')) + output DESC, \ b.name, w.name", )?; let rows = stmt.query_map(params![since], |row| { diff --git a/docs/spec.md b/docs/spec.md index 10f0a8ab..3c6ef1d4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -413,9 +413,9 @@ 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), 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. O app não o mostra: um número "relidos" ao lado do gasto confundia quem lê. Ele continua gravado no item `turn` e somado no `usage.tokens`. -- **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, 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. +- **O que conta.** O número em destaque é `input + cacheWrite + output`: o que o modelo leu sem vir do cache (o texto novo e a conversa recarregada, abaixo) 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. O app não o mostra: um número "relidos" ao lado do gasto confundia quem lê. Ele continua gravado no item `turn` e somado no `usage.tokens`. +- **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 não mostra essa parte: ela entra no número do turno, porque é gasto de verdade do plano, e um número a mais ao lado confundia quem lê. Um turno curto depois de uma pausa mostra um número grande, e isso é verdade. O `reloaded` continua gravado no item `turn`. +- **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 (lidos e escritos) 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, o nome da equipe (dois bots podem ter o mesmo nome em equipes diferentes) 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.