Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions app/src/features/account/TokensByBot.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -99,7 +100,12 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) {
/>
</div>
<span className="text-muted text-xs">
{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),
)}
</span>
</div>
</li>
Expand All @@ -119,7 +125,7 @@ function Total({ bots }: { bots: BotTokens[] }) {
<span className="tabular-nums">{tokens(used)}</span>
</div>
<span className="text-muted text-xs">
{u.detail(turns, tokens(reread), reloadedTokens(reloaded))}
{u.detail(null, turns, tokens(reread), reloadedTokens(reloaded))}
</span>
</li>
);
Expand Down
28 changes: 24 additions & 4 deletions app/src/features/account/tokens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand All @@ -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 () => {
Expand Down
5 changes: 3 additions & 2 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion app/src/lib/fakeUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function usageHandlers(fake: FakeBotloft): Pick<Handlers, "usage.tokens">
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 },
Expand All @@ -37,7 +38,10 @@ export function usageHandlers(fake: FakeBotloft): Pick<Handlers, "usage.tokens">
};
}
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),
);
},
};
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 2 additions & 0 deletions crates/botloft-core/src/protocol/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions crates/botloft-store/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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").
Expand Down
Loading