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 b4327c2f..78df11f0 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 a85fe1ae..eeb9671d 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 37667dba..cb075b82 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 f06fd2f6..a3ff2618 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -122,7 +122,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 83a23179..4dcde12d 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. */ @@ -1179,6 +1222,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 364a1e84..8eafdb47 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 6b8d8e28..c4f4be70 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}; @@ -227,7 +229,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, }), ); @@ -236,3 +238,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 15210dfc..084d4bd6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -328,7 +328,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 o turno aberto, com todas as messages que entraram nele (7.2). 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 o turno aberto, com todas as messages que entraram nele (7.2). 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) | @@ -346,7 +346,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). @@ -407,6 +407,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 @@ -611,6 +621,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"). @@ -649,7 +660,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 @@ -986,6 +997,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 |