diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx index 3a9d57c7..7b4d33fa 100644 --- a/app/src/features/account/TokensByBot.tsx +++ b/app/src/features/account/TokensByBot.tsx @@ -1,6 +1,7 @@ // Tokens each bot used over a period (spec 8.7), in the usage dialog: the // new text read plus the replies written, with the conversation reread -// each time shown apart. +// each time shown apart. Each bot shows its crew, as two crews can have +// bots with the same name. import { useEffect, useState } from "react"; import { useT } from "../../i18n"; @@ -99,7 +100,12 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) { /> - {u.detail(bot.turns, tokens(bot.tokens.cacheRead), reloadedTokens(bot.tokens.reloaded))} + {u.detail( + bot.crew, + bot.turns, + tokens(bot.tokens.cacheRead), + reloadedTokens(bot.tokens.reloaded), + )} @@ -119,7 +125,7 @@ function Total({ bots }: { bots: BotTokens[] }) { {tokens(used)} - {u.detail(turns, tokens(reread), reloadedTokens(reloaded))} + {u.detail(null, turns, tokens(reread), reloadedTokens(reloaded))} ); diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx index 5d756b71..33f88a7e 100644 --- a/app/src/features/account/tokens.test.tsx +++ b/app/src/features/account/tokens.test.tsx @@ -34,14 +34,14 @@ describe("tokens by bot", () => { const rows = within(list).getAllByRole("listitem"); // Each turn: 12 new, 1,800 into the cache and 640 written count; 24,000 reread do not. expect(rows.map((row) => row.textContent)).toEqual([ - "Scout4.9kWorked 2 times · 48k reread", - "Writer2.5kWorked once · 24k reread", + "Scout4.9kOps · Worked 2 times · 48k reread", + "Writer2.5kOps · Worked 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("Writer4.9kOps · Worked 2 times"); expect(dialog.textContent).toContain("All bots9.8k"); }); @@ -64,7 +64,27 @@ describe("tokens by bot", () => { within(list) .getAllByRole("listitem") .map((row) => row.textContent), - ).toEqual(["Scout4.1kWorked 2 times · 24k reread · 72k reloaded"]); + ).toEqual(["Scout4.1kOps · Worked 2 times · 24k reread · 72k reloaded"]); + }); + + test("bots with the same name in two crews show their crew", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const news = fake.addCrew("Newsroom"); + fake.chat.turn(fake.addBot(ops.id, "Scout", "Finds sources").id); + fake.chat.turn(fake.addBot(news.id, "Scout", "Finds stories").id); + + const dialog = await openUsage(fake); + const list = await within(dialog).findByRole("list", { name: "Tokens by bot" }); + expect( + within(list) + .getAllByRole("listitem") + .map((row) => row.textContent), + ).toEqual([ + "Scout2.5kNewsroom · Worked once · 24k reread", + "Scout2.5kOps · Worked once · 24k reread", + "All bots4.9kWorked 2 times · 48k reread", + ]); }); test("a period with no work says so", async () => { diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 179f4303..91ef15c0 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -36,8 +36,9 @@ export const account = { empty: "No bot worked in this period.", failed: "Could not load the tokens", archived: "archived", - detail: (times: number, reread: string, reloaded: string | null) => - `Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread${ + /** `crew` is null on the total of all bots. */ + detail: (crew: string | null, times: number, reread: string, reloaded: string | null) => + `${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`} · ${reread} reread${ reloaded === null ? "" : ` · ${reloaded} reloaded` }`, total: "All bots", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 2338f1ec..291d8182 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -36,8 +36,9 @@ export const account: Messages["account"] = { empty: "Ningún bot trabajó en este período.", failed: "No se pudieron cargar los tokens", archived: "archivado", - detail: (times: number, reread: string, reloaded: string | null) => - `Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos${ + /** `crew` is null on the total of all bots. */ + detail: (crew: string | null, times: number, reread: string, reloaded: string | null) => + `${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`} · ${reread} releídos${ reloaded === null ? "" : ` · ${reloaded} recargados` }`, total: "Todos los bots", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 45777e6d..93085ee6 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -36,8 +36,9 @@ export const account: Messages["account"] = { empty: "Nenhum bot trabalhou nesse período.", failed: "Não deu para carregar os tokens", archived: "arquivado", - detail: (times: number, reread: string, reloaded: string | null) => - `Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos${ + /** `crew` is null on the total of all bots. */ + detail: (crew: string | null, times: number, reread: string, reloaded: string | null) => + `${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`} · ${reread} relidos${ reloaded === null ? "" : ` · ${reloaded} recarregados` }`, total: "Todos os bots", diff --git a/app/src/lib/fakeUsage.ts b/app/src/lib/fakeUsage.ts index 38670d1f..d1c42cd3 100644 --- a/app/src/lib/fakeUsage.ts +++ b/app/src/lib/fakeUsage.ts @@ -20,6 +20,7 @@ export function usageHandlers(fake: FakeBotloft): Pick botId: bot.id, name: bot.name, color: bot.color, + crew: fake.crew(bot.crewId, false).name, archived: bot.archivedAt !== null, turns: 0, tokens: { input: 0, cacheWrite: 0, reloaded: 0, cacheRead: 0, output: 0 }, @@ -37,7 +38,10 @@ export function usageHandlers(fake: FakeBotloft): Pick }; } return [...byBot.values()].sort( - (a, b) => usedTokens(b.tokens) - usedTokens(a.tokens) || a.name.localeCompare(b.name), + (a, b) => + usedTokens(b.tokens) - usedTokens(a.tokens) || + a.name.localeCompare(b.name) || + a.crew.localeCompare(b.crew), ); }, }; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 756a998c..f47fce18 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -785,6 +785,10 @@ since: number, }; * One bot's tokens since the time asked for. */ export type BotTokens = { botId: BotId, name: string, color: string, +/** + * The name of the bot's crew, so two bots with one name stay apart. + */ +crew: string, /** * The bot is archived. */ diff --git a/crates/botloft-core/src/protocol/usage.rs b/crates/botloft-core/src/protocol/usage.rs index d0be44e2..a3ef13a4 100644 --- a/crates/botloft-core/src/protocol/usage.rs +++ b/crates/botloft-core/src/protocol/usage.rs @@ -52,6 +52,8 @@ pub struct BotTokens { pub bot_id: BotId, pub name: String, pub color: String, + /// The name of the bot's crew, so two bots with one name stay apart. + pub crew: String, /// The bot is archived. pub archived: bool, /// Turns that reported tokens. diff --git a/crates/botloft-store/src/usage.rs b/crates/botloft-store/src/usage.rs index 41e9380d..190c6fb4 100644 --- a/crates/botloft-store/src/usage.rs +++ b/crates/botloft-store/src/usage.rs @@ -20,20 +20,22 @@ impl Store { SUM(json_extract(c.data, '$.tokens.cacheWrite')), \ SUM(json_extract(c.data, '$.tokens.cacheRead')), \ SUM(json_extract(c.data, '$.tokens.output')) AS output, \ - COALESCE(SUM(json_extract(c.data, '$.tokens.reloaded')), 0) AS reloaded \ - FROM chat_items c JOIN bots b ON b.id = c.bot_id \ + COALESCE(SUM(json_extract(c.data, '$.tokens.reloaded')), 0) AS reloaded, \ + w.name \ + FROM chat_items c JOIN bots b ON b.id = c.bot_id JOIN crews w ON w.id = b.crew_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')) - reloaded + output DESC, \ - b.name", + b.name, w.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)?, + crew: row.get(10)?, archived: row.get(3)?, turns: row.get(4)?, tokens: TokenUsage { @@ -104,6 +106,7 @@ mod tests { assert_eq!(order, [writer, scout], "the bot that used the most first"); let scout_tokens = &all[1]; assert_eq!(scout_tokens.name, "scout"); + assert_eq!(scout_tokens.crew, "Ops"); assert!(!scout_tokens.archived); assert_eq!(scout_tokens.turns, 2); assert_eq!( diff --git a/docs/spec.md b/docs/spec.md index 2a989728..f87686f7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -416,7 +416,7 @@ O dono vê quantos tokens cada bot gasta: em cada turno, no chat, e somados por - **O que conta.** O número em destaque é `input + cacheWrite - reloaded + output`: o texto novo que o modelo leu e o que escreveu. O Claude Code manda quase tudo que é novo (a mensagem, o resultado de uma ferramenta) para o cache, por isso o `input` sozinho fica perto de zero e o novo está no `cacheWrite`. O `cacheRead` é quase todo a conversa relida a cada pedido, pesa bem menos e numa conversa longa seria a maior parte do total; ele aparece à parte, como "relidos". - **A conversa recarregada.** Cada pedido manda a conversa inteira. Quando o cache expirou, porque o bot ficou parado ou o processo reiniciou, a parte da conversa que o cache não tinha mais é gravada de novo e vem no `cacheWrite`, misturada com o que é novo; num turno curto depois de uma pausa ela é quase tudo (19). O daemon separa essa parte em `reloaded`, sem guardar nada: para cada pedido da conversa principal (sem subagentes nem respostas sintéticas, contado uma vez por `message.id`), o que a conversa já tinha e não veio do cache, `min(cacheWrite, antes - cacheRead)`, onde `antes` é `input + cacheWrite + cacheRead` do pedido anterior ou, sem pedido visto, o tamanho que o Claude Code respondeu (8.6). Uma conversa nova ou recém-compactada não tem `antes` e não conta nada. O valor é limitado ao `cacheWrite` do `result`. O app mostra à parte só a partir de mil tokens, como "recarregou a conversa (72 mil)" no chat e "recarregados" no diálogo de uso. - **No chat.** A linha do fim do turno diz a duração e os tokens ("Concluído em 4,2 s · 2,5 mil tokens"), e a conversa recarregada quando houve; o detalhe (novos, escritos, relidos, recarregados) fica no título da linha. A unidade é o turno, não a mensagem: mensagens que chegam juntas entram num turno só, e não dá para dividir os tokens entre elas. -- **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo. +- **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor, o nome da equipe (dois bots podem ter o mesmo nome em equipes diferentes) e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo. - Uma compactação (8.6) não vira item `turn` e não entra na conta. ## 9. Mensagens e entrega @@ -624,7 +624,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) | +| `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `crew` (nome da equipe), `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").