From fbb946e052c797fdc1a038255bd23d8a6126e453 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:44:21 -0300 Subject: [PATCH] feat(chat): tokens per turn and per bot, never in money Each turn item keeps the tokens of Claude Code's result usage (new input, cache written, cache read, output), which is the sum of the turn's requests. The chat shows them on the turn's line and usage.tokens sums them per bot for a new part of the usage dialog, over today, 7 days, 30 days or all time. The dollar cost is gone from the app: total_cost_usd is API pricing and adds up the whole process, not the turn, so the tooltip was wrong after the first turn. Spec 8.7 is new; 19 records the real test. --- app/src/features/account/TokensByBot.tsx | 123 ++++++++++++++++++ app/src/features/account/UsageDialog.tsx | 4 +- app/src/features/account/tokens.test.tsx | 59 +++++++++ app/src/features/chat/BotRun.tsx | 12 +- app/src/features/chat/chat.test.tsx | 5 +- app/src/features/chat/rows.test.ts | 2 +- app/src/i18n/en/account.ts | 13 ++ app/src/i18n/en/chat.ts | 18 ++- app/src/i18n/es/account.ts | 13 ++ app/src/i18n/es/chat.ts | 10 +- app/src/i18n/pt-BR/account.ts | 13 ++ app/src/i18n/pt-BR/chat.ts | 10 +- app/src/lib/fake.ts | 2 + app/src/lib/fakeChat.ts | 7 +- app/src/lib/fakeUsage.ts | 43 ++++++ app/src/lib/format.ts | 10 ++ app/src/lib/protocol.gen.ts | 48 ++++++- crates/botloft-core/src/protocol/chat.rs | 8 +- crates/botloft-core/src/protocol/export.rs | 4 + .../botloft-core/src/protocol/export_usage.rs | 19 +++ crates/botloft-core/src/protocol/mod.rs | 3 + crates/botloft-core/src/protocol/usage.rs | 55 ++++++++ .../migrations/0011_turn_tokens.sql | 3 + crates/botloft-store/src/chat.rs | 2 +- crates/botloft-store/src/lib.rs | 1 + crates/botloft-store/src/migrate.rs | 1 + crates/botloft-store/src/usage.rs | 116 +++++++++++++++++ crates/botloftd/src/chat/events.rs | 21 ++- crates/botloftd/src/rpc/dispatch.rs | 3 +- crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/src/service/usage.rs | 14 ++ crates/botloftd/tests/common/stream.rs | 6 + crates/botloftd/tests/usage.rs | 60 +++++++++ docs/spec.md | 18 ++- 34 files changed, 697 insertions(+), 30 deletions(-) create mode 100644 app/src/features/account/TokensByBot.tsx create mode 100644 app/src/features/account/tokens.test.tsx create mode 100644 app/src/lib/fakeUsage.ts create mode 100644 crates/botloft-core/src/protocol/export_usage.rs create mode 100644 crates/botloft-core/src/protocol/usage.rs create mode 100644 crates/botloft-store/migrations/0011_turn_tokens.sql create mode 100644 crates/botloft-store/src/usage.rs create mode 100644 crates/botloftd/src/service/usage.rs create mode 100644 crates/botloftd/tests/usage.rs diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx new file mode 100644 index 00000000..fdb34af7 --- /dev/null +++ b/app/src/features/account/TokensByBot.tsx @@ -0,0 +1,123 @@ +// 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. + +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { tokens, usedTokens } from "../../lib/format"; +import type { BotTokens } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Callout } from "../../ui/Callout"; +import { Choices } from "../../ui/Choices"; +import { BotAvatar } from "../bots/BotAvatar"; + +type Period = "today" | "week" | "month" | "all"; + +const DAY_MS = 86_400_000; + +/** When `period` starts, in Unix ms. */ +export function periodStart(period: Period, now = Date.now()): number { + switch (period) { + case "today": + return new Date(now).setHours(0, 0, 0, 0); + case "week": + return now - 7 * DAY_MS; + case "month": + return now - 30 * DAY_MS; + case "all": + return 0; + } +} + +export function TokensByBot() { + const u = useT().account.usage.tokens; + const api = useApi(); + const [period, setPeriod] = useState("today"); + const [bots, setBots] = useState(null); + const [failed, setFailed] = useState(null); + + useEffect(() => { + let alive = true; + setFailed(null); + api.call("usage.tokens", { since: periodStart(period) }).then( + (list) => alive && setBots(list), + (error) => alive && setFailed(errorText(error)), + ); + return () => { + alive = false; + }; + }, [api, period]); + + const periods = (["today", "week", "month", "all"] as const).map((value) => ({ + value, + label: u.periods[value], + })); + const most = Math.max(1, ...(bots ?? []).map((bot) => usedTokens(bot.tokens))); + + return ( +
+

{u.title}

+

{u.intro}

+ label={u.period} value={period} options={periods} onChange={setPeriod} /> + {failed ? ( + + {failed} + + ) : bots === null ? null : bots.length === 0 ? ( +

{u.empty}

+ ) : ( +
    + {bots.map((bot) => ( + + ))} + {bots.length > 1 && } +
+ )} +
+ ); +} + +function Row({ bot, most }: { bot: BotTokens; most: number }) { + const u = useT().account.usage.tokens; + const used = usedTokens(bot.tokens); + return ( +
  • + +
    +
    + + {bot.name} + {bot.archived && {u.archived}} + + {tokens(used)} +
    +
    +
    +
    + + {u.detail(bot.turns, tokens(bot.tokens.cacheRead))} + +
    +
  • + ); +} + +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); + return ( +
  • +
    + {u.total} + {tokens(used)} +
    + {u.detail(turns, tokens(reread))} +
  • + ); +} diff --git a/app/src/features/account/UsageDialog.tsx b/app/src/features/account/UsageDialog.tsx index 8f7cb67e..cbaa96a1 100644 --- a/app/src/features/account/UsageDialog.tsx +++ b/app/src/features/account/UsageDialog.tsx @@ -1,5 +1,5 @@ // How much of the Claude plan the bots have used (spec 8.1), from the -// usage Claude Code reports while bots work. +// usage Claude Code reports while bots work, and the tokens each bot used. import { useT } from "../../i18n"; import { fromNow } from "../../lib/format"; @@ -7,6 +7,7 @@ import type { UsageWindow } from "../../lib/protocol.gen"; import { useApp } from "../../store/context"; import { Callout } from "../../ui/Callout"; import { Dialog } from "../../ui/Dialog"; +import { TokensByBot } from "./TokensByBot"; export function UsageDialog({ onClose }: { onClose(): void }) { const u = useT().account.usage; @@ -31,6 +32,7 @@ export function UsageDialog({ onClose }: { onClose(): void }) {

    {u.updated(fromNow(usage.observedAt))}

    )} + ); diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx new file mode 100644 index 00000000..10e787ea --- /dev/null +++ b/app/src/features/account/tokens.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "../../App"; +import type { Client } from "../../lib/client"; +import { FakeBotloft } from "../../lib/fake"; +import { FakeHost } from "../../lib/fakeHost"; +import { periodStart } from "./TokensByBot"; + +afterEach(cleanup); + +async function openUsage(fake: FakeBotloft) { + render( fake as Client} />); + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Usage" })); + return screen.getByRole("dialog", { name: "Usage" }); +} + +describe("tokens by bot", () => { + test("each bot's tokens add up over the period chosen, with the total", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const writer = fake.addBot(ops.id, "Writer", "Writes"); + const now = fake.now; + fake.now = now - 3 * 86_400_000; + fake.chat.turn(writer.id); + fake.now = now; + fake.chat.turn(scout.id); + fake.chat.turn(scout.id); + fake.chat.turn(writer.id); + + const dialog = await openUsage(fake); + const list = await within(dialog).findByRole("list", { name: "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.9kWorked 2 times · 48k reread", + "Writer2.5kWorked once · 24k reread", + "All bots7.4kWorked 3 times · 72k reread", + ]); + + fireEvent.click(within(dialog).getByRole("radio", { name: "7 days" })); + expect(await within(dialog).findByText("4.9k")).toBeDefined(); + expect(dialog.textContent).toContain("Writer4.9kWorked 2 times"); + expect(dialog.textContent).toContain("All bots9.8k"); + }); + + 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(); + }); + + test("today starts at midnight; the others count back from now", () => { + const now = new Date(2026, 8, 30, 15, 20).getTime(); + expect(periodStart("today", now)).toBe(new Date(2026, 8, 30).getTime()); + expect(periodStart("week", now)).toBe(now - 7 * 86_400_000); + expect(periodStart("all", now)).toBe(0); + }); +}); diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 4f36c81b..44718794 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 type { ReactNode } from "react"; import { useT } from "../../i18n"; -import { duration, 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"; @@ -32,9 +32,15 @@ function TurnEnd({ turn }: { turn: TurnItem }) { ); } const time = duration(turn.durationMs); + const used = turn.tokens; + const detail = used && { + read: tokens(used.input + used.cacheWrite), + wrote: tokens(used.output), + reread: tokens(used.cacheRead), + }; return ( -

    - {t.chat.run.done(time)} +

    + {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 a28f54d2..3a45a272 100644 --- a/app/src/features/chat/chat.test.tsx +++ b/app/src/features/chat/chat.test.tsx @@ -90,7 +90,10 @@ describe("chat", () => { expect(within(chat()).getByText("12 passed")).toBeDefined(); expect(within(chat()).getByText("Command")).toBeDefined(); expect(within(chat()).getByText("npm test")).toBeDefined(); - expect(within(chat()).getByText("Done in 4.2 s")).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.", + ); }); test("an approval waits for the owner, and a denial carries a note", async () => { diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index f8dfa7a0..33adf308 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -24,7 +24,7 @@ const tool = (name: string) => output: null, file: null, }); -const turn = () => item({ kind: "turn", durationMs: 1000, costUsd: null, error: null }); +const turn = () => item({ kind: "turn", durationMs: 1000, tokens: null, error: null }); const inbound = (body: string, at?: number) => item( { diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 2bc6745c..6c1bdbe8 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -27,6 +27,19 @@ export const account = { resets: (relative: string) => `Resets ${relative}`, limited: "Limit reached. Your bots wait until it resets.", updated: (relative: string) => `Updated ${relative}`, + 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.", + 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`, + total: "All bots", + }, }, settings: { title: "Settings", diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 479f4d05..1a788b61 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -2,6 +2,13 @@ // files and the composer. Bot names, tool names and what the bots write // are data and never pass through here. +/** A turn's tokens, already written as numbers. */ +export interface TurnTokens { + read: string; + wrote: string; + reread: string; +} + export const chat = { view: { label: (bot: string) => `Chat with ${bot}`, @@ -47,9 +54,14 @@ export const chat = { }, run: { working: "Working", - done: (time: string) => `Done in ${time}`, - took: (time: string, usd: number | null) => - `Took ${time}${usd === null ? "" : ` · about $${usd.toFixed(2)} of usage`}`, + 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}; reread ${tokens.reread} of the conversation, which weighs much less.` + }`, stopped: (reason: string) => `The bot stopped working on this: ${reason}`, }, tools: { diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 828327b5..e5b52959 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -27,6 +27,19 @@ export const account: Messages["account"] = { resets: (relative: string) => `Se renueva ${relative}`, limited: "Límite alcanzado. Tus bots esperan hasta que se renueve.", updated: (relative: string) => `Actualizado ${relative}`, + 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.", + 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`, + total: "Todos los bots", + }, }, settings: { title: "Configuración", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index 5c49b32f..2941a8c7 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -1,4 +1,5 @@ import type { Messages } from "../en"; +import type { TurnTokens } from "../en/chat"; export const chat: Messages["chat"] = { view: { @@ -48,12 +49,13 @@ export const chat: Messages["chat"] = { }, run: { working: "Trabajando", - done: (time: string) => `Terminado en ${time}`, - took: (time: string, usd: number | null) => + done: (time: string, tokens: string | null) => + `Terminado en ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, + took: (time: string, tokens: TurnTokens | null) => `Tardó ${time}${ - usd === null + tokens === null ? "" - : ` · unos ${usd.toLocaleString("es", { style: "currency", currency: "USD" })} de uso` + : `. Leyó ${tokens.read} tokens nuevos y escribió ${tokens.wrote}; releyó ${tokens.reread} de la conversación, que pesa mucho menos.` }`, 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 7a82b859..129fdb89 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -27,6 +27,19 @@ export const account: Messages["account"] = { resets: (relative: string) => `Renova ${relative}`, limited: "Limite atingido. Seus bots esperam até ele renovar.", updated: (relative: string) => `Atualizado ${relative}`, + 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.", + 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`, + total: "Todos os bots", + }, }, settings: { title: "Configurações", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 47bc0c53..83482b9a 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -1,4 +1,5 @@ import type { Messages } from "../en"; +import type { TurnTokens } from "../en/chat"; export const chat: Messages["chat"] = { view: { @@ -48,12 +49,13 @@ export const chat: Messages["chat"] = { }, run: { working: "Trabalhando", - done: (time: string) => `Concluído em ${time}`, - took: (time: string, usd: number | null) => + done: (time: string, tokens: string | null) => + `Concluído em ${time}${tokens === null ? "" : ` · ${tokens} tokens`}`, + took: (time: string, tokens: TurnTokens | null) => `Levou ${time}${ - usd === null + tokens === null ? "" - : ` · cerca de ${usd.toLocaleString("pt-BR", { style: "currency", currency: "USD" })} de uso` + : `. Leu ${tokens.read} tokens novos e escreveu ${tokens.wrote}; releu ${tokens.reread} da conversa, que pesa bem menos.` }`, stopped: (reason: string) => `O bot parou de trabalhar nisto: ${reason}`, }, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 0f0b04d3..254c5d41 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -12,6 +12,7 @@ import { FakeFiles } from "./fakeFiles"; import { FakeRoutines } from "./fakeRoutines"; import { conflict, invalid, notFound, slugify } from "./fakeRules"; import { FakeScreens } from "./fakeScreens"; +import { usageHandlers } from "./fakeUsage"; import { type Bot, type BotId, @@ -253,5 +254,6 @@ export class FakeBotloft implements BotloftApi { ...this.browser.handlers(), ...this.screens.handlers(), ...this.routines.handlers(), + ...usageHandlers(this), }; } diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index ed35db71..23665fa8 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -119,7 +119,12 @@ export class FakeChat { } turn(botId: BotId, error: string | null = null): ChatItem { - return this.add(botId, { kind: "turn", durationMs: 4200, costUsd: 0.0123, error }); + return this.add(botId, { + kind: "turn", + durationMs: 4200, + tokens: { input: 12, cacheWrite: 1800, cacheRead: 24_000, output: 640 }, + error, + }); } /** A permission request waiting for the owner; `explanation` is what the bot says a command is for. */ diff --git a/app/src/lib/fakeUsage.ts b/app/src/lib/fakeUsage.ts new file mode 100644 index 00000000..68ecb1b1 --- /dev/null +++ b/app/src/lib/fakeUsage.ts @@ -0,0 +1,43 @@ +// The fake daemon's tokens per bot (spec 8.7), summed from the `turn` items +// of the fake chats, as the daemon sums its stored ones. + +import type { FakeBotloft, Handlers } from "./fake"; +import { usedTokens } from "./format"; +import type { BotId, BotTokens } from "./protocol.gen"; + +export function usageHandlers(fake: FakeBotloft): Pick { + return { + "usage.tokens": ({ since }) => { + const byBot = new Map(); + for (const item of fake.chat.items) { + if (item.body.kind !== "turn" || !item.body.tokens || item.createdAt < since) { + continue; + } + let entry = byBot.get(item.botId); + if (!entry) { + const bot = fake.bot(item.botId, false); + entry = { + botId: bot.id, + name: bot.name, + color: bot.color, + archived: bot.archivedAt !== null, + turns: 0, + tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }, + }; + byBot.set(bot.id, entry); + } + const { input, cacheWrite, cacheRead, output } = item.body.tokens; + entry.turns += 1; + entry.tokens = { + input: entry.tokens.input + input, + cacheWrite: entry.tokens.cacheWrite + cacheWrite, + cacheRead: entry.tokens.cacheRead + cacheRead, + output: entry.tokens.output + output, + }; + } + return [...byBot.values()].sort( + (a, b) => usedTokens(b.tokens) - usedTokens(a.tokens) || a.name.localeCompare(b.name), + ); + }, + }; +} diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index 215f449b..1896265a 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -2,6 +2,7 @@ // 15.6): clock time, dates and short relative phrases ("in 5 min"). import { currentLocale, type Locale } from "../i18n"; +import type { TokenUsage } from "./protocol.gen"; interface Formats { clock: Intl.DateTimeFormat; @@ -92,6 +93,15 @@ export function tokens(count: number): string { return `${decimal(millions, Math.abs(millions - Math.round(millions)) < 0.05 ? 0 : 1)}M`; } +/** + * 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. + */ +export function usedTokens(usage: TokenUsage): number { + return usage.input + usage.cacheWrite + usage.output; +} + /** "0.8 s", "42 s", "3 min 5 s". */ export function duration(ms: number): string { const seconds = ms / 1000; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 67775665..c3017b05 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -747,11 +747,54 @@ explanation: string | null, input: string, status: ApprovalStatus, */ note: string | null, }; +/** + * Tokens of one turn, as the `usage` of Claude Code's `result` event: the + * sum of every request to the model in the turn. + */ +export type TokenUsage = { +/** + * New input the model read (`input_tokens`). + */ +input: number, +/** + * New input also written to the prompt cache (`cache_creation_input_tokens`). + */ +cacheWrite: number, +/** + * Input read back from the prompt cache (`cache_read_input_tokens`): + * mostly the conversation so far, and much cheaper than new input. + */ +cacheRead: number, +/** + * What the model wrote, thinking included (`output_tokens`). + */ +output: number, }; + +export type UsageTokensParams = { +/** + * Counts turns that ended at or after this Unix time in ms. + */ +since: number, }; + +/** + * One bot's tokens since the time asked for. + */ +export type BotTokens = { botId: BotId, name: string, color: string, +/** + * The bot is archived. + */ +archived: boolean, +/** + * Turns that reported tokens. + */ +turns: number, tokens: TokenUsage, }; + export type TurnItem = { durationMs: number, /** - * As Claude Code reports it; `null` when it did not. + * Tokens the turn used (spec 8.7); `null` when Claude Code did not say + * and on turns stored before it was kept. */ -costUsd: number | null, +tokens: TokenUsage | null, /** * Why the turn failed, e.g. `rate_limit`; `null` when it worked. */ @@ -1178,6 +1221,7 @@ export interface RpcMethods { "tasks.list": { params: TasksListParams; result: Array }; "bots.setEffort": { params: BotsSetEffortParams; result: Bot }; "bots.compact": { params: BotIdParams; result: Bot }; + "usage.tokens": { params: UsageTokensParams; result: Array }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 48abe9c0..cd8299d7 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use super::Message; +use super::{Message, TokenUsage}; use crate::ids::{ApprovalId, BotId, ChatItemId}; text_enum!( @@ -160,8 +160,10 @@ pub struct ApprovalItem { #[cfg_attr(test, derive(ts_rs::TS))] pub struct TurnItem { pub duration_ms: u64, - /// As Claude Code reports it; `null` when it did not. - pub cost_usd: Option, + /// Tokens the turn used (spec 8.7); `null` when Claude Code did not say + /// and on turns stored before it was kept. + #[serde(default)] + pub tokens: Option, /// Why the turn failed, e.g. `rate_limit`; `null` when it worked. pub error: Option, } diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index ab9d8176..53ef2426 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -20,6 +20,8 @@ mod browser; mod routines; #[path = "export_session.rs"] mod session; +#[path = "export_usage.rs"] +mod usage; use crate::validate; const HEADER: &str = "\ @@ -162,6 +164,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + usage::decls(&mut out); out.decl::(); out.decl::(); out.decl::(); @@ -318,6 +321,7 @@ fn export_bindings() { &out.name::>(), ); session::methods(&mut out); + usage::methods(&mut out); routines::methods(&mut out); browser::methods(&mut out); out.text.push_str("}\n\n"); diff --git a/crates/botloft-core/src/protocol/export_usage.rs b/crates/botloft-core/src/protocol/export_usage.rs new file mode 100644 index 00000000..4327e1ce --- /dev/null +++ b/crates/botloft-core/src/protocol/export_usage.rs @@ -0,0 +1,19 @@ +//! The token part of the TypeScript bindings (spec 8.7). + +use super::super::*; +use super::Out; + +/// Before `TurnItem`, which holds `TokenUsage`. +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::USAGE_TOKENS, + &out.name::(), + &out.name::>(), + ); +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 8505c334..ae0f55ba 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -62,6 +62,7 @@ mod routines; mod screens; mod session; mod settings; +mod usage; pub use browser::*; pub use browser_input::*; @@ -74,6 +75,7 @@ pub use routines::*; pub use screens::*; pub use session::*; pub use settings::*; +pub use usage::*; /// Version negotiated in `session.hello`. 2 replaced the terminal with the /// chat (ADR 0001). @@ -135,6 +137,7 @@ pub mod method { pub const SCREENS_LIST: &str = "screens.list"; pub const SETTINGS_GET: &str = "settings.get"; pub const SETTINGS_UPDATE: &str = "settings.update"; + pub const USAGE_TOKENS: &str = "usage.tokens"; } /// Server notification method names. diff --git a/crates/botloft-core/src/protocol/usage.rs b/crates/botloft-core/src/protocol/usage.rs new file mode 100644 index 00000000..094be101 --- /dev/null +++ b/crates/botloft-core/src/protocol/usage.rs @@ -0,0 +1,55 @@ +//! Tokens the bots use (spec 8.7): per turn in the chat, and summed per bot +//! for the usage dialog. + +use serde::{Deserialize, Serialize}; + +use crate::ids::BotId; + +/// Tokens of one turn, as the `usage` of Claude Code's `result` event: the +/// sum of every request to the model in the turn. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TokenUsage { + /// New input the model read (`input_tokens`). + pub input: u64, + /// New input also written to the prompt cache (`cache_creation_input_tokens`). + pub cache_write: 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, + /// What the model wrote, thinking included (`output_tokens`). + pub output: u64, +} + +impl TokenUsage { + pub fn add(&mut self, other: &Self) { + self.input += other.input; + self.cache_write += other.cache_write; + self.cache_read += other.cache_read; + self.output += other.output; + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct UsageTokensParams { + /// Counts turns that ended at or after this Unix time in ms. + pub since: i64, +} + +/// One bot's tokens since the time asked for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotTokens { + pub bot_id: BotId, + pub name: String, + pub color: String, + /// The bot is archived. + pub archived: bool, + /// Turns that reported tokens. + pub turns: u32, + pub tokens: TokenUsage, +} diff --git a/crates/botloft-store/migrations/0011_turn_tokens.sql b/crates/botloft-store/migrations/0011_turn_tokens.sql new file mode 100644 index 00000000..3bea31ff --- /dev/null +++ b/crates/botloft-store/migrations/0011_turn_tokens.sql @@ -0,0 +1,3 @@ +-- Tokens per bot over a period (spec 8.7) read only the `turn` items, by +-- the time they ended. +CREATE INDEX chat_items_turns ON chat_items (created_at) WHERE kind = 'turn'; diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index 43cb4ee9..15143bd8 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -294,7 +294,7 @@ mod tests { .expect("insert"); let turn = ChatBody::Turn(TurnItem { duration_ms: 1000, - cost_usd: Some(0.01), + tokens: None, error: None, }); fx.store diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 767ad9ad..d5bca92e 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -16,6 +16,7 @@ mod migrate; mod routine_runs; mod routines; mod tasks; +mod usage; use std::path::Path; use std::str::FromStr; diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 561e41d0..73cb0cda 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -17,6 +17,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (8, include_str!("../migrations/0008_routines.sql")), (9, include_str!("../migrations/0009_browser_sites.sql")), (10, include_str!("../migrations/0010_bot_effort.sql")), + (11, include_str!("../migrations/0011_turn_tokens.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloft-store/src/usage.rs b/crates/botloft-store/src/usage.rs new file mode 100644 index 00000000..1c65a93b --- /dev/null +++ b/crates/botloft-store/src/usage.rs @@ -0,0 +1,116 @@ +//! Tokens per bot (spec 8.7), summed from the `turn` items of the chat. + +use botloft_core::protocol::{BotTokens, TokenUsage}; +use rusqlite::{Row, params}; + +use crate::{Result, Store, parse_column}; + +/// A sum of token counts; SQLite keeps integers as `i64`. +fn count(row: &Row<'_>, index: usize) -> rusqlite::Result { + Ok(u64::try_from(row.get::<_, i64>(index)?).unwrap_or_default()) +} + +impl Store { + /// Each bot's tokens in turns that ended at or after `since`, the bot + /// that used the most first. Bots with no such turn are left out. + pub fn turn_tokens(&self, since: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT b.id, b.name, b.color, b.archived_at IS NOT NULL, COUNT(*), \ + 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 \ + 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", + )?; + let rows = stmt.query_map(params![since], |row| { + Ok(BotTokens { + bot_id: parse_column(row, 0)?, + name: row.get(1)?, + color: row.get(2)?, + archived: row.get(3)?, + turns: row.get(4)?, + tokens: TokenUsage { + input: count(row, 5)?, + cache_write: count(row, 6)?, + cache_read: count(row, 7)?, + output: count(row, 8)?, + }, + }) + })?; + Ok(rows.collect::>()?) + } +} + +#[cfg(test)] +mod tests { + use botloft_core::ids::{BotId, ChatItemId}; + use botloft_core::protocol::{ChatBody, ChatItem, ReplyItem, TurnItem}; + + use super::*; + use crate::tests::Fixture; + + fn add(fx: &Fixture, bot: &BotId, body: ChatBody, at: i64) { + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body, + created_at: at, + updated_at: at, + }; + fx.store.insert_chat_item(&item).expect("insert"); + } + + fn turn(tokens: Option) -> ChatBody { + ChatBody::Turn(TurnItem { + duration_ms: 1000, + tokens, + error: None, + }) + } + + fn tokens(input: u64, output: u64) -> TokenUsage { + TokenUsage { + input, + cache_write: 100, + cache_read: 5000, + output, + } + } + + #[test] + fn turns_are_summed_per_bot_from_the_time_asked() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + add(&fx, scout, turn(Some(tokens(10, 20))), 5); + add(&fx, scout, turn(Some(tokens(1, 2))), 20); + add(&fx, scout, turn(Some(tokens(3, 4))), 30); + // Older turns and other items do not count. + add(&fx, scout, turn(None), 25); + let reply = ChatBody::Reply(ReplyItem { text: "hi".into() }); + add(&fx, scout, reply, 26); + add(&fx, writer, turn(Some(tokens(500, 0))), 40); + + let all = fx.store.turn_tokens(10).expect("tokens"); + let order: Vec<&BotId> = all.iter().map(|b| &b.bot_id).collect(); + assert_eq!(order, [writer, scout], "the bot that used the most first"); + let scout_tokens = &all[1]; + assert_eq!(scout_tokens.name, "scout"); + assert!(!scout_tokens.archived); + assert_eq!(scout_tokens.turns, 2); + assert_eq!( + scout_tokens.tokens, + TokenUsage { + input: 4, + cache_write: 200, + cache_read: 10_000, + output: 6, + } + ); + assert!(fx.store.turn_tokens(41).expect("tokens").is_empty()); + } +} diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index adbf7ffc..d09d720c 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -5,7 +5,9 @@ use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary}; use botloft_core::command::tool_explanation; use botloft_core::ids::BotId; -use botloft_core::protocol::{ChatBody, ChatDelta, ReplyItem, ToolItem, ToolStatus, TurnItem}; +use botloft_core::protocol::{ + ChatBody, ChatDelta, ReplyItem, TokenUsage, ToolItem, ToolStatus, TurnItem, +}; use serde_json::Value; use tracing::{debug, warn}; @@ -223,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(), - cost_usd: event["total_cost_usd"].as_f64(), + tokens: tokens(&event["usage"]), error, }), ); @@ -232,3 +234,18 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { crate::screens::turn_ended(daemon, bot); daemon.supervisor.turn_ended(bot, generation); } + +/// 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 { + if !usage.is_object() { + return None; + } + let count = |key: &str| usage[key].as_u64().unwrap_or_default(); + Some(TokenUsage { + input: count("input_tokens"), + cache_write: count("cache_creation_input_tokens"), + cache_read: count("cache_read_input_tokens"), + output: count("output_tokens"), + }) +} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index b5bbc6b7..cc614c41 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,7 +9,7 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead, - messages, models, modes, routines, screens, settings, tasks, + messages, models, modes, routines, screens, settings, tasks, usage, }; use crate::state::Daemon; @@ -64,6 +64,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(screens::list(daemon, parse(params)?)), method::SETTINGS_GET => reply(settings::get(daemon)), method::SETTINGS_UPDATE => reply(settings::update(daemon, parse(params)?)), + method::USAGE_TOKENS => reply(usage::tokens(daemon, parse(params)?)), method::SESSION_HELLO => Err(RpcError::new( error_code::CONFLICT, "this connection is already authenticated", diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 7fde3679..bfbd3113 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -17,6 +17,7 @@ pub mod routines; pub mod screens; pub mod settings; pub mod tasks; +pub mod usage; use botloft_core::protocol::{OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code}; use botloft_core::slug; diff --git a/crates/botloftd/src/service/usage.rs b/crates/botloftd/src/service/usage.rs new file mode 100644 index 00000000..7d59ee5a --- /dev/null +++ b/crates/botloftd/src/service/usage.rs @@ -0,0 +1,14 @@ +//! `usage.tokens` (spec 8.7, 11.2). + +use botloft_core::protocol::{BotTokens, UsageTokensParams}; + +use super::{ApiError, ApiResult}; +use crate::state::Daemon; + +/// Each bot's tokens since `since`, archived bots included. +pub fn tokens(daemon: &Daemon, params: UsageTokensParams) -> ApiResult> { + if params.since < 0 { + return Err(ApiError::validation("since must not be negative")); + } + Ok(daemon.store().turn_tokens(params.since)?) +} diff --git a/crates/botloftd/tests/common/stream.rs b/crates/botloftd/tests/common/stream.rs index b23128ee..327fafba 100644 --- a/crates/botloftd/tests/common/stream.rs +++ b/crates/botloftd/tests/common/stream.rs @@ -99,6 +99,12 @@ pub fn result(is_error: bool) -> Value { "is_error": is_error, "duration_ms": 1234, "total_cost_usd": 0.0125, + "usage": { + "input_tokens": 12, + "cache_creation_input_tokens": 300, + "cache_read_input_tokens": 4000, + "output_tokens": 45, + }, "terminal_reason": if is_error { "api_error" } else { "completed" }, }) } diff --git a/crates/botloftd/tests/usage.rs b/crates/botloftd/tests/usage.rs new file mode 100644 index 00000000..2daa374c --- /dev/null +++ b/crates/botloftd/tests/usage.rs @@ -0,0 +1,60 @@ +//! Tokens per turn and per bot (spec 8.7), end to end. + +mod common; + +use botloft_core::protocol::BotState; +use common::bots::two_bots; +use common::stream; +use serde_json::json; + +#[tokio::test] +async fn each_turn_keeps_its_tokens_and_the_bots_add_them_up() { + let mut c = two_bots().await; + let lead = c.app.call("bots.list", json!({})).await.expect("bots")[0].clone(); + for body in ["First", "Second"] { + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": body }), + ) + .await + .expect("send"); + let lines = c.lead_process.wait_lines(1).await; + let line = lines.last().cloned().expect("line"); + stream::answer(&c.lead_process, &line, "Done.").await; + c.t.until_state(&lead, BotState::Idle).await; + } + + let history = c + .app + .call("chat.history", json!({ "botId": lead["id"], "limit": 1 })) + .await + .expect("history"); + assert_eq!( + history[0]["body"]["tokens"], + json!({ "input": 12, "cacheWrite": 300, "cacheRead": 4000, "output": 45 }), + "the turn's own tokens, from the result's usage" + ); + + let usage = c + .app + .call("usage.tokens", json!({ "since": 0 })) + .await + .expect("usage"); + let bots = usage.as_array().expect("bots"); + assert_eq!(bots.len(), 1, "only bots that worked: {usage}"); + assert_eq!(bots[0]["botId"], lead["id"]); + assert_eq!(bots[0]["name"], lead["name"]); + assert_eq!(bots[0]["turns"], 2); + assert_eq!( + bots[0]["tokens"], + json!({ "input": 24, "cacheWrite": 600, "cacheRead": 8000, "output": 90 }) + ); + + assert!( + c.app + .call("usage.tokens", json!({ "since": -1 })) + .await + .is_err() + ); +} diff --git a/docs/spec.md b/docs/spec.md index 3b97d90f..57d6c150 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -325,7 +325,7 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, | `user`, bloco `tool_result` | atualiza o item `tool` do mesmo `tool_use_id`: `done` ou `failed` e um trecho da saída | | `assistant` com `error` | item `notice` e, conforme o erro, estado `rate_limited` ou `auth_error` (7.2); `model_not_found` vira o aviso `model_unavailable` | | `rate_limit_event` | uso da conta (janelas de 5 h e 7 dias) em `system.status.usage`; status diferente de `allowed` leva a `rate_limited` | -| `result` | item `turn` com duração, custo e erro (se houve); fecha um turno pendente. O daemon pergunta de novo o tamanho da conversa (8.6). O `result` de uma compactação (`local_command: "compact"`) fecha o turno sem item `turn` | +| `result` | item `turn` com duração, tokens (8.7) e erro (se houve); fecha um turno pendente. O daemon pergunta de novo o tamanho da conversa (8.6). O `result` de uma compactação (`local_command: "compact"`) fecha o turno sem item `turn` | | `assistant` com `usage` | quanto a conversa ocupa naquele pedido ao modelo (8.6) | | `system/status` | `status: "compacting"`: o Claude Code começou a compactar a conversa; com `compact_result`, terminou (8.6) | | `system/compact_boundary` | a conversa foi compactada: aviso `compacted` ou `auto_compacted` no chat (8.6) | @@ -343,7 +343,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. | `reply` | `text` | texto do bot | | `tool` | `toolUseId`, `name`, `summary`, `explanation`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `explanation` é o que o bot diz que um comando faz (10.1; ausente nas outras ferramentas, quando o bot não disse nada e nos itens antigos); `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) | | `approval` | `approvalId`, `toolName`, `summary`, `explanation`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1); `explanation` como no item `tool`, guardada só no item do chat | -| `turn` | `durationMs`, `costUsd`, `error` | fim de um turno | +| `turn` | `durationMs`, `tokens` (8.7; `null` quando o Claude Code não disse e nos itens antigos), `error` | fim de um turno | | `notice` | `level` (`info`, `warning`, `error`), `code` (`signed_out`, `usage_limit`, `turn_failed`, `model_unavailable`, `compacted`, `auto_compacted`, `compact_failed`; ausente em avisos antigos), `text` | avisos do daemon: limite de uso, login, turno com erro, conversa compactada (8.6). O app escreve os avisos com `code` no idioma do dono; `text` fica em inglês para quem não conhece o código e, em `turn_failed` e `compact_failed`, traz o detalhe do erro | - `summary` é um trecho curto tirado da entrada, sem palavras do daemon: o comando do `Bash` e do `PowerShell` (o comando mesmo, nunca o que o bot diz dele: isso é a `explanation`), o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message` (`@writer`), a tecla do `browser_press`. Fica vazio onde só palavras diriam algo (`TodoWrite`, `complete_task`, `ToolSearch` que carrega ferramentas, a direção do `browser_scroll`) e onde o trecho não diz nada ao dono (a `ref` de um elemento do navegador). O app escreve o nome da ferramenta como uma ação no idioma do dono ("Mandar uma mensagem", "Abrir uma página"; uma ferramenta que ele não conhece aparece pelo nome) e, nas que ficam vazias, o que der para dizer a partir da entrada (as ferramentas carregadas, a direção da rolagem). @@ -404,6 +404,16 @@ Os três avisos não mudam a linha da conversa na barra lateral (`lastActivity`) A compactação custa um pedido ao modelo com a conversa inteira (barato com o cache quente, 19) e depois cada turno manda só o resumo. O dono também pode escrever `/compact` no chat: a linha segue como qualquer mensagem dele (9.3), e o efeito é o mesmo. +### 8.7 Tokens + +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. +- **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. + ## 9. Mensagens e entrega ### 9.1 Fluxo @@ -607,6 +617,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle | | `screens.list` | seção 22.4 | as telas HTML do bot | | `settings.get` | | `Settings`: `startWithWindows`, `keepAwake` e `approvalWaitMinutes`, como estão no `config.toml` (6) | +| `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `archived`, `turns` e `tokens` somados (8.7) | | `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | `Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). @@ -645,7 +656,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | `routines`, `routine_runs` | seção 20.7; `messages` ganha `routine_id` | | `browser_sites` | `bot_id, host, allowed_at`: sites que o dono deixou o bot usar no navegador (21.5) | -Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `attachments(message_id)`. +Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `chat_items(created_at) WHERE kind = 'turn'` (8.7), `attachments(message_id)`. ## 13. Segurança e privacidade @@ -981,6 +992,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 | | 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 |