From 03ef3d355b75baae5428c9358e1a7fae3108ae20 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Mon, 5 Oct 2026 13:56:40 -0300
Subject: [PATCH] feat(usage): each bot's share of the weekly plan, learned
from the plan's rise
Anthropic publishes no plan sizes and the plan's usage comes for the
whole account, in whole points. So the daemon keeps what each turn cost
at API list prices (the growth of Claude Code's process total) and each
new reading of the plan's windows, and learns the weekly share per
dollar from how the seven-day window rose while the bots worked; a rise
with no bot cost in between is left out as someone else's use. Each
bot's share is its cost times that. The usage dialog shows '~0.4% of the
week' per bot with the tokens beside it and a last-hour period; the
roster gives the chief week_plan_percent. Spec 8.7.
---
app/src/features/account/TokensByBot.tsx | 64 ++++++---
app/src/features/account/tokens.test.tsx | 42 +++++-
app/src/i18n/en/account.ts | 18 ++-
app/src/i18n/es/account.ts | 18 ++-
app/src/i18n/pt-BR/account.ts | 18 ++-
app/src/lib/fake.ts | 2 +
app/src/lib/fakeUsage.ts | 8 +-
app/src/lib/format.ts | 12 ++
app/src/lib/protocol.gen.ts | 7 +-
crates/botloft-core/src/protocol/usage.rs | 5 +-
.../migrations/0023_plan_share.sql | 19 +++
crates/botloft-store/src/delete.rs | 1 +
crates/botloft-store/src/delete/tests.rs | 1 +
crates/botloft-store/src/lib.rs | 2 +
crates/botloft-store/src/migrate.rs | 1 +
crates/botloft-store/src/plan.rs | 133 ++++++++++++++++++
crates/botloft-store/src/usage.rs | 1 +
crates/botloftd/src/chat/account.rs | 4 +-
crates/botloftd/src/chat/events.rs | 1 +
crates/botloftd/src/service/mod.rs | 1 +
crates/botloftd/src/service/plan.rs | 129 +++++++++++++++++
crates/botloftd/src/service/usage.rs | 9 +-
crates/botloftd/src/state.rs | 3 +
crates/botloftd/src/tools/calls.rs | 6 +
crates/botloftd/src/tools/catalog.rs | 6 +-
crates/botloftd/src/workspace/files.rs | 3 +-
crates/botloftd/tests/plan_share.rs | 93 ++++++++++++
crates/botloftd/tests/tasks.rs | 2 +-
docs/spec.md | 5 +-
29 files changed, 571 insertions(+), 43 deletions(-)
create mode 100644 crates/botloft-store/migrations/0023_plan_share.sql
create mode 100644 crates/botloft-store/src/plan.rs
create mode 100644 crates/botloftd/src/service/plan.rs
create mode 100644 crates/botloftd/tests/plan_share.rs
diff --git a/app/src/features/account/TokensByBot.tsx b/app/src/features/account/TokensByBot.tsx
index 3ed43b50..283bfdc2 100644
--- a/app/src/features/account/TokensByBot.tsx
+++ b/app/src/features/account/TokensByBot.tsx
@@ -1,24 +1,28 @@
-// Tokens each bot used over a period (spec 8.7), in the usage dialog: the
-// text read plus the replies written. Each bot shows its crew, as two crews can have
-// bots with the same name.
+// What each bot used over a period (spec 8.7), in the usage dialog: about
+// how much of the weekly plan, once Botloft has learned it from how the
+// plan rises while the bots work, and the tokens read and written. Each
+// bot shows its crew, as two crews can have bots with the same name.
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
-import { tokens, usedTokens } from "../../lib/format";
+import { planPercent, 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";
+type Period = "hour" | "today" | "week" | "month" | "all";
-const DAY_MS = 86_400_000;
+const HOUR_MS = 3_600_000;
+const DAY_MS = 24 * HOUR_MS;
/** When `period` starts, in Unix ms. */
export function periodStart(period: Period, now = Date.now()): number {
switch (period) {
+ case "hour":
+ return now - HOUR_MS;
case "today":
return new Date(now).setHours(0, 0, 0, 0);
case "week":
@@ -30,6 +34,11 @@ export function periodStart(period: Period, now = Date.now()): number {
}
}
+/** How much a bot weighs in the list: its plan share, else its tokens. */
+function weight(bot: BotTokens): number {
+ return bot.planShare ?? usedTokens(bot.tokens);
+}
+
export function TokensByBot() {
const u = useT().account.usage.tokens;
const api = useApi();
@@ -41,7 +50,7 @@ export function TokensByBot() {
let alive = true;
setFailed(null);
api.call("usage.tokens", { since: periodStart(period) }).then(
- (list) => alive && setBots(list),
+ (list) => alive && setBots([...list].sort((a, b) => weight(b) - weight(a))),
(error) => alive && setFailed(errorText(error)),
);
return () => {
@@ -49,11 +58,12 @@ export function TokensByBot() {
};
}, [api, period]);
- const periods = (["today", "week", "month", "all"] as const).map((value) => ({
+ const periods = (["hour", "today", "week", "month", "all"] as const).map((value) => ({
value,
label: u.periods[value],
}));
- const most = Math.max(1, ...(bots ?? []).map((bot) => usedTokens(bot.tokens)));
+ const most = Math.max(Number.MIN_VALUE, ...(bots ?? []).map(weight));
+ const learned = (bots ?? []).some((bot) => bot.planShare !== null);
return (
@@ -67,12 +77,15 @@ export function TokensByBot() {
) : bots === null ? null : bots.length === 0 ? (
{u.empty}
) : (
-
- {bots.map((bot) => (
-
- ))}
- {bots.length > 1 && }
-
+ <>
+
+ {bots.map((bot) => (
+
+ ))}
+ {bots.length > 1 && }
+
+ {learned ? u.estimate : u.learning}
+ >
)}
);
@@ -81,6 +94,7 @@ export function TokensByBot() {
function Row({ bot, most }: { bot: BotTokens; most: number }) {
const u = useT().account.usage.tokens;
const used = usedTokens(bot.tokens);
+ const share = bot.planShare;
return (
@@ -90,15 +104,20 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) {
{bot.name}
{bot.archived && {u.archived}}
- {tokens(used)}
+
+ {share === null ? tokens(used) : u.share(planPercent(share))}
+
- {u.detail(bot.crew, bot.turns)}
+
+ {u.detail(bot.crew, bot.turns)}
+ {share !== null && ` · ${u.tokensUsed(tokens(used))}`}
+
);
@@ -108,13 +127,18 @@ 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 learned = bots.every((bot) => bot.planShare !== null);
+ const share = bots.reduce((sum, bot) => sum + (bot.planShare ?? 0), 0);
return (
{u.total}
- {tokens(used)}
+ {learned ? u.share(planPercent(share)) : tokens(used)}
- {u.detail(null, turns)}
+
+ {u.detail(null, turns)}
+ {learned && ` · ${u.tokensUsed(tokens(used))}`}
+
);
}
diff --git a/app/src/features/account/tokens.test.tsx b/app/src/features/account/tokens.test.tsx
index 0f4a30bc..766734a8 100644
--- a/app/src/features/account/tokens.test.tsx
+++ b/app/src/features/account/tokens.test.tsx
@@ -4,6 +4,7 @@ import { App } from "../../App";
import type { Client } from "../../lib/client";
import { FakeBotloft } from "../../lib/fake";
import { FakeHost } from "../../lib/fakeHost";
+import { planPercent } from "../../lib/format";
import { periodStart } from "./TokensByBot";
afterEach(cleanup);
@@ -30,7 +31,7 @@ describe("tokens by bot", () => {
fake.chat.turn(writer.id);
const dialog = await openUsage(fake);
- const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
+ const list = await within(dialog).findByRole("list", { name: "Use 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([
@@ -59,7 +60,7 @@ describe("tokens by bot", () => {
fake.chat.turn(scout.id);
const dialog = await openUsage(fake);
- const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
+ const list = await within(dialog).findByRole("list", { name: "Use by bot" });
expect(
within(list)
.getAllByRole("listitem")
@@ -75,7 +76,7 @@ describe("tokens by bot", () => {
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" });
+ const list = await within(dialog).findByRole("list", { name: "Use by bot" });
expect(
within(list)
.getAllByRole("listitem")
@@ -99,3 +100,38 @@ describe("tokens by bot", () => {
expect(periodStart("all", now)).toBe(0);
});
});
+
+describe("each bot's share of the weekly plan", () => {
+ test("shows once learned, with the tokens beside it, and says it is an estimate", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.chat.turn(scout.id);
+ fake.chat.turn(scout.id);
+ // 4,904 tokens at 0.000001 each: 0.49% of the week.
+ fake.planSharePerToken = 0.000_001;
+
+ const dialog = await openUsage(fake);
+ const list = await within(dialog).findByRole("list", { name: "Use by bot" });
+ expect(within(list).getByRole("listitem").textContent).toBe(
+ "Scout≈ 0.5% of the weekOps · Worked 2 times · 4.9k tokens",
+ );
+ expect(dialog.textContent).toContain("≈ Estimated:");
+ fireEvent.click(within(dialog).getByRole("radio", { name: "Last hour" }));
+ expect(await within(dialog).findByText("≈ 0.5% of the week")).toBeDefined();
+ });
+
+ test("until learned, the tokens and a note that the share comes later", async () => {
+ const fake = new FakeBotloft();
+ fake.chat.turn(fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources").id);
+ const dialog = await openUsage(fake);
+ expect(await within(dialog).findByText(/shows up once the plan has gone up/)).toBeDefined();
+ });
+
+ test("a share reads as a short percentage", () => {
+ expect(planPercent(0.045)).toBe("4.5%");
+ expect(planPercent(0.123)).toBe("12%");
+ expect(planPercent(0.0000001)).toBe("<0.1%");
+ expect(planPercent(0)).toBe("0%");
+ });
+});
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index ff9d603e..2f9ab6a9 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -28,11 +28,17 @@ export const account = {
limited: "Limit reached. Your bots wait until it resets.",
updated: (relative: string) => `Updated ${relative}`,
tokens: {
- title: "Tokens by bot",
+ title: "Use by bot",
intro:
- "Tokens are the pieces of text a bot reads and writes. The count adds the text read and the replies written.",
+ "How much of your weekly plan each bot used, and the tokens behind it: the pieces of text a bot reads and writes.",
period: "Period",
- periods: { today: "Today", week: "7 days", month: "30 days", all: "All time" },
+ periods: {
+ hour: "Last hour",
+ 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",
@@ -40,6 +46,12 @@ export const account = {
detail: (crew: string | null, times: number) =>
`${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`}`,
total: "All bots",
+ share: (percent: string) => `≈ ${percent} of the week`,
+ tokensUsed: (count: string) => `${count} tokens`,
+ estimate:
+ "≈ Estimated: Botloft learns how much of your plan the bots' work takes from how the weekly plan rises while they work. What you use outside Botloft also raises it, so the bots may show a little more than they used.",
+ learning:
+ "Each bot's share of your weekly plan shows up once the plan has gone up a few points while the bots work. Until then, the tokens.",
},
},
settings: {
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 3ceaf455..0f33bafc 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -28,11 +28,17 @@ export const account: Messages["account"] = {
limited: "Límite alcanzado. Tus bots esperan hasta que se renueve.",
updated: (relative: string) => `Actualizado ${relative}`,
tokens: {
- title: "Tokens por bot",
+ title: "Uso por bot",
intro:
- "Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto leído y las respuestas escritas.",
+ "Cuánto de tu plan semanal usó cada bot, y los tokens detrás: los trozos de texto que un bot lee y escribe.",
period: "Período",
- periods: { today: "Hoy", week: "7 días", month: "30 días", all: "Todo" },
+ periods: {
+ hour: "Última hora",
+ 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",
@@ -40,6 +46,12 @@ export const account: Messages["account"] = {
detail: (crew: string | null, times: number) =>
`${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`}`,
total: "Todos los bots",
+ share: (percent) => `≈ ${percent} de la semana`,
+ tokensUsed: (count) => `${count} tokens`,
+ estimate:
+ "≈ Estimado: Botloft aprende cuánto de tu plan consume el trabajo de los bots según cuánto sube el plan semanal mientras trabajan. Lo que usas fuera de Botloft también lo sube, así que los bots pueden aparecer con un poco más de lo que usaron.",
+ learning:
+ "La parte de cada bot en tu plan semanal aparece cuando el plan haya subido unos puntos con los bots trabajando. Hasta entonces, los tokens.",
},
},
settings: {
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index 8496d764..0740017b 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -28,11 +28,17 @@ export const account: Messages["account"] = {
limited: "Limite atingido. Seus bots esperam até ele renovar.",
updated: (relative: string) => `Atualizado ${relative}`,
tokens: {
- title: "Tokens por bot",
+ title: "Uso por bot",
intro:
- "Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto lido e as respostas escritas.",
+ "Quanto do seu plano semanal cada bot usou, e os tokens por trás disso: os pedaços de texto que um bot lê e escreve.",
period: "Período",
- periods: { today: "Hoje", week: "7 dias", month: "30 dias", all: "Tudo" },
+ periods: {
+ hour: "Última hora",
+ 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",
@@ -40,6 +46,12 @@ export const account: Messages["account"] = {
detail: (crew: string | null, times: number) =>
`${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`}`,
total: "Todos os bots",
+ share: (percent) => `≈ ${percent} da semana`,
+ tokensUsed: (count) => `${count} tokens`,
+ estimate:
+ "≈ Estimativa: o Botloft aprende quanto do seu plano o trabalho dos bots consome pelo quanto o plano semanal sobe enquanto eles trabalham. O que você usa fora do Botloft também faz o plano subir, então os bots podem aparecer com um pouco mais do que usaram.",
+ learning:
+ "A parte de cada bot no seu plano semanal aparece depois que o plano subir alguns pontos com os bots trabalhando. Até lá, os tokens.",
},
},
settings: {
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index 86cb8a49..30aad84b 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -89,6 +89,8 @@ export class FakeBotloft implements BotloftApi {
readonly lesson = new FakeLesson(this);
/** Clock for created and updated times. */
now = Date.now();
+ /** Plan share per token for `usage.tokens`; `null`, not learned yet. */
+ planSharePerToken: number | null = null;
closed = false;
private state: ConnectionState = { kind: "open", daemonVersion: "0.1.0" };
private readonly listeners = new Set<(event: ServerEvent) => void>();
diff --git a/app/src/lib/fakeUsage.ts b/app/src/lib/fakeUsage.ts
index d1c42cd3..f344b638 100644
--- a/app/src/lib/fakeUsage.ts
+++ b/app/src/lib/fakeUsage.ts
@@ -1,5 +1,6 @@
// 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.
+// of the fake chats, as the daemon sums its stored ones. The share of the
+// plan is the tokens times `planSharePerToken`, once a test sets it.
import type { FakeBotloft, Handlers } from "./fake";
import { usedTokens } from "./format";
@@ -24,6 +25,7 @@ export function usageHandlers(fake: FakeBotloft): Pick
archived: bot.archivedAt !== null,
turns: 0,
tokens: { input: 0, cacheWrite: 0, reloaded: 0, cacheRead: 0, output: 0 },
+ planShare: null,
};
byBot.set(bot.id, entry);
}
@@ -37,6 +39,10 @@ export function usageHandlers(fake: FakeBotloft): Pick
output: entry.tokens.output + output,
};
}
+ const rate = fake.planSharePerToken;
+ for (const entry of byBot.values()) {
+ entry.planShare = rate === null ? null : usedTokens(entry.tokens) * rate;
+ }
return [...byBot.values()].sort(
(a, b) =>
usedTokens(b.tokens) - usedTokens(a.tokens) ||
diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts
index 4622cc84..693a037f 100644
--- a/app/src/lib/format.ts
+++ b/app/src/lib/format.ts
@@ -93,6 +93,18 @@ export function fileSize(bytes: number): string {
}
/** Tokens as the owner reads them: "850", "24.3k", "556k", "1M", "1.2M". */
+/** A share of the plan, 0 to 1, as a percentage: "4.5%", "<0.1%". */
+export function planPercent(share: number): string {
+ const percent = share * 100;
+ if (percent <= 0) {
+ return `${decimal(0, 0)}%`;
+ }
+ if (percent < 0.05) {
+ return `<${decimal(0.1, 1)}%`;
+ }
+ return `${decimal(percent, percent < 10 ? 1 : 0)}%`;
+}
+
export function tokens(count: number): string {
if (count < 1000) {
return String(count);
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 353dc505..554e472d 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -1107,7 +1107,12 @@ archived: boolean,
/**
* Turns that reported tokens.
*/
-turns: number, tokens: TokenUsage, };
+turns: number, tokens: TokenUsage,
+/**
+ * About how much of the weekly plan the bot used in the period, 0 to
+ * 1; `null` until Botloft has learned it (spec 8.7).
+ */
+planShare: number | null, };
export type QuestionStatus = "open" | "answered" | "dismissed";
diff --git a/crates/botloft-core/src/protocol/usage.rs b/crates/botloft-core/src/protocol/usage.rs
index a3ef13a4..596fe287 100644
--- a/crates/botloft-core/src/protocol/usage.rs
+++ b/crates/botloft-core/src/protocol/usage.rs
@@ -45,7 +45,7 @@ pub struct UsageTokensParams {
}
/// One bot's tokens since the time asked for.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct BotTokens {
@@ -59,4 +59,7 @@ pub struct BotTokens {
/// Turns that reported tokens.
pub turns: u32,
pub tokens: TokenUsage,
+ /// About how much of the weekly plan the bot used in the period, 0 to
+ /// 1; `null` until Botloft has learned it (spec 8.7).
+ pub plan_share: Option,
}
diff --git a/crates/botloft-store/migrations/0023_plan_share.sql b/crates/botloft-store/migrations/0023_plan_share.sql
new file mode 100644
index 00000000..0448d7c3
--- /dev/null
+++ b/crates/botloft-store/migrations/0023_plan_share.sql
@@ -0,0 +1,19 @@
+-- How much of the owner's Claude plan each bot uses (spec 8.7): what each
+-- turn cost at API list prices, as Claude Code reports it, and the plan's
+-- usage as it changes. The share is learned from the two.
+CREATE TABLE turn_costs (
+ bot_id TEXT NOT NULL REFERENCES bots (id),
+ at INTEGER NOT NULL,
+ cost REAL NOT NULL
+);
+CREATE INDEX turn_costs_at ON turn_costs (at);
+CREATE INDEX turn_costs_bot ON turn_costs (bot_id);
+
+-- One row each time a usage window of the plan reads a new value.
+CREATE TABLE plan_readings (
+ name TEXT NOT NULL,
+ at INTEGER NOT NULL,
+ utilization REAL NOT NULL,
+ resets_at INTEGER
+);
+CREATE INDEX plan_readings_window_at ON plan_readings (name, at);
diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs
index 7dc5d5aa..b867739d 100644
--- a/crates/botloft-store/src/delete.rs
+++ b/crates/botloft-store/src/delete.rs
@@ -20,6 +20,7 @@ const DELETE_BOT: &[&str] = &[
"DELETE FROM chat_items WHERE bot_id = ?1",
"DELETE FROM browser_sites WHERE bot_id = ?1",
"DELETE FROM desktop_grants WHERE bot_id = ?1",
+ "DELETE FROM turn_costs WHERE bot_id = ?1",
"DELETE FROM crew_access WHERE bot_id = ?1 OR target_bot_id = ?1",
"DELETE FROM allow_rules WHERE bot_id = ?1",
"DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)",
diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs
index 7825aab2..b5aff6ce 100644
--- a/crates/botloft-store/src/delete/tests.rs
+++ b/crates/botloft-store/src/delete/tests.rs
@@ -329,6 +329,7 @@ fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() {
"allow_rules",
"desktop_grants",
"crew_access",
+ "turn_costs",
] {
assert_eq!(count(&fx.store, table), 0, "{table}");
}
diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs
index b70bbae3..c8c546de 100644
--- a/crates/botloft-store/src/lib.rs
+++ b/crates/botloft-store/src/lib.rs
@@ -16,6 +16,7 @@ mod deliveries;
mod desktop_grants;
mod messages;
mod migrate;
+mod plan;
#[cfg(test)]
mod plans;
mod questions;
@@ -40,6 +41,7 @@ pub use crew_access::{AccessKinds, CrewAccessRecord};
pub use deliveries::DeliveryOutcome;
pub use messages::MessageFilter;
pub use migrate::LATEST_VERSION;
+pub use plan::PlanReading;
pub use questions::{Answered, QuestionRecord};
pub use search::{Found, SearchFilter, fts_query};
pub use tasks::TaskFilter;
diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs
index 5211684c..390dc9f2 100644
--- a/crates/botloft-store/src/migrate.rs
+++ b/crates/botloft-store/src/migrate.rs
@@ -29,6 +29,7 @@ const MIGRATIONS: &[(u32, &str)] = &[
(20, include_str!("../migrations/0020_desktop_grants.sql")),
(21, include_str!("../migrations/0021_crew_access.sql")),
(22, include_str!("../migrations/0022_crew_access_files.sql")),
+ (23, include_str!("../migrations/0023_plan_share.sql")),
];
/// Schema version after every migration has run.
diff --git a/crates/botloft-store/src/plan.rs b/crates/botloft-store/src/plan.rs
new file mode 100644
index 00000000..0cf223cd
--- /dev/null
+++ b/crates/botloft-store/src/plan.rs
@@ -0,0 +1,133 @@
+//! `turn_costs` and `plan_readings` (spec 8.7): what each bot's turns cost
+//! at API list prices, and how the owner's plan usage moved. The share of
+//! the plan each bot uses is learned from the two.
+
+use std::collections::HashMap;
+
+use botloft_core::ids::BotId;
+use rusqlite::{OptionalExtension, params};
+
+use crate::{Result, Store, parse_column};
+
+/// How long plan readings are kept.
+const READINGS_KEPT_MS: i64 = 60 * 24 * 60 * 60 * 1000;
+
+/// A usage window of the plan at one moment.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PlanReading {
+ pub at: i64,
+ /// Share used, 0 to 1.
+ pub utilization: f64,
+ pub resets_at: Option,
+}
+
+impl Store {
+ /// What one turn of `bot` cost at `at`.
+ pub fn add_turn_cost(&self, bot: &BotId, at: i64, cost: f64) -> Result<()> {
+ self.conn.execute(
+ "INSERT INTO turn_costs (bot_id, at, cost) VALUES (?1, ?2, ?3)",
+ params![bot.as_str(), at, cost],
+ )?;
+ Ok(())
+ }
+
+ /// Each bot's cost from `since` on, archived bots included.
+ pub fn bot_costs(&self, since: i64) -> Result> {
+ let mut stmt = self.conn.prepare_cached(
+ "SELECT bot_id, SUM(cost) FROM turn_costs WHERE at >= ?1 GROUP BY bot_id",
+ )?;
+ let rows = stmt.query_map([since], |row| Ok((parse_column(row, 0)?, row.get(1)?)))?;
+ Ok(rows.collect::>()?)
+ }
+
+ /// Every bot's cost after `from` and up to `to`.
+ pub fn costs_between(&self, from: i64, to: i64) -> Result {
+ Ok(self.conn.query_row(
+ "SELECT COALESCE(SUM(cost), 0) FROM turn_costs WHERE at > ?1 AND at <= ?2",
+ [from, to],
+ |row| row.get(0),
+ )?)
+ }
+
+ /// Records the window `name` at `at`, unless it reads as it did last
+ /// time. Readings older than two months go.
+ pub fn add_plan_reading(&self, name: &str, reading: &PlanReading) -> Result<()> {
+ let last: Option<(f64, Option)> = self
+ .conn
+ .query_row(
+ "SELECT utilization, resets_at FROM plan_readings WHERE name = ?1 \
+ ORDER BY at DESC, rowid DESC LIMIT 1",
+ [name],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .optional()?;
+ if last == Some((reading.utilization, reading.resets_at)) {
+ return Ok(());
+ }
+ self.conn.execute(
+ "INSERT INTO plan_readings (name, at, utilization, resets_at) VALUES (?1, ?2, ?3, ?4)",
+ params![name, reading.at, reading.utilization, reading.resets_at],
+ )?;
+ self.conn.execute(
+ "DELETE FROM plan_readings WHERE at < ?1",
+ [reading.at - READINGS_KEPT_MS],
+ )?;
+ Ok(())
+ }
+
+ /// The readings of window `name` from `since` on, oldest first.
+ pub fn plan_readings(&self, name: &str, since: i64) -> Result> {
+ let mut stmt = self.conn.prepare_cached(
+ "SELECT at, utilization, resets_at FROM plan_readings \
+ WHERE name = ?1 AND at >= ?2 ORDER BY at, rowid",
+ )?;
+ let rows = stmt.query_map(params![name, since], |row| {
+ Ok(PlanReading {
+ at: row.get(0)?,
+ utilization: row.get(1)?,
+ resets_at: row.get(2)?,
+ })
+ })?;
+ Ok(rows.collect::>()?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::PlanReading;
+ use crate::tests::Fixture;
+
+ #[test]
+ fn costs_add_up_per_bot_and_between_times() {
+ let fx = Fixture::new();
+ let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id);
+ fx.store.add_turn_cost(scout, 10, 0.5).expect("cost");
+ fx.store.add_turn_cost(scout, 20, 0.25).expect("cost");
+ fx.store.add_turn_cost(writer, 30, 1.0).expect("cost");
+ let costs = fx.store.bot_costs(15).expect("costs");
+ assert_eq!(costs.get(scout), Some(&0.25));
+ assert_eq!(costs.get(writer), Some(&1.0));
+ assert!((fx.store.costs_between(10, 30).expect("between") - 1.25).abs() < 1e-9);
+ assert_eq!(fx.store.costs_between(30, 40).expect("none"), 0.0);
+ }
+
+ #[test]
+ fn a_reading_is_kept_only_when_it_changes() {
+ let fx = Fixture::new();
+ let read = |at, utilization| PlanReading {
+ at,
+ utilization,
+ resets_at: Some(1_000),
+ };
+ for (at, utilization) in [(1, 0.30), (2, 0.30), (3, 0.31), (4, 0.31)] {
+ fx.store
+ .add_plan_reading("seven_day", &read(at, utilization))
+ .expect("reading");
+ }
+ fx.store
+ .add_plan_reading("five_hour", &read(5, 0.5))
+ .expect("other window");
+ let kept = fx.store.plan_readings("seven_day", 0).expect("readings");
+ assert_eq!(kept, [read(1, 0.30), read(3, 0.31)]);
+ }
+}
diff --git a/crates/botloft-store/src/usage.rs b/crates/botloft-store/src/usage.rs
index 092e9198..08ffa570 100644
--- a/crates/botloft-store/src/usage.rs
+++ b/crates/botloft-store/src/usage.rs
@@ -45,6 +45,7 @@ impl Store {
cache_read: count(row, 7)?,
output: count(row, 8)?,
},
+ plan_share: None,
})
})?;
Ok(rows.collect::>()?)
diff --git a/crates/botloftd/src/chat/account.rs b/crates/botloftd/src/chat/account.rs
index da71c429..0dea8e43 100644
--- a/crates/botloftd/src/chat/account.rs
+++ b/crates/botloftd/src/chat/account.rs
@@ -101,11 +101,13 @@ pub(super) fn rate_limit(daemon: &Daemon, bot: &BotId, generation: u64, event: &
let status = info["status"].as_str().unwrap_or("unknown").to_owned();
let resets_at = seconds(&info["resetsAt"]);
let limited = !status.starts_with("allowed");
+ let now = daemon.clock.now_ms();
+ crate::service::plan::observed(daemon, &windows, now);
daemon.set_usage(AccountUsage {
status,
resets_at,
windows,
- observed_at: daemon.clock.now_ms(),
+ observed_at: now,
});
if limited {
let until = resets_at.unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS);
diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs
index 43c7d1e3..80db5186 100644
--- a/crates/botloftd/src/chat/events.rs
+++ b/crates/botloftd/src/chat/events.rs
@@ -209,6 +209,7 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
daemon.supervisor.session_missing(bot, generation);
return;
}
+ crate::service::plan::turn_ended(daemon, bot, generation, event);
let reloaded = context::reloaded(daemon, bot);
let failed = event["is_error"].as_bool() == Some(true);
let error = failed.then(|| {
diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs
index 1ec74072..ef08e1b7 100644
--- a/crates/botloftd/src/service/mod.rs
+++ b/crates/botloftd/src/service/mod.rs
@@ -15,6 +15,7 @@ pub mod lead;
pub mod messages;
pub mod models;
pub mod modes;
+pub mod plan;
pub mod questions;
pub mod reactions;
pub mod routines;
diff --git a/crates/botloftd/src/service/plan.rs b/crates/botloftd/src/service/plan.rs
new file mode 100644
index 00000000..3392bfb0
--- /dev/null
+++ b/crates/botloftd/src/service/plan.rs
@@ -0,0 +1,129 @@
+//! How much of the owner's weekly Claude plan each bot uses (spec 8.7).
+//! Claude Code says what each turn cost at API list prices; the plan says
+//! how full its seven-day window is, rounded to a point. The rise of the
+//! window over what the bots spent meanwhile gives the plan's share per
+//! dollar, and each bot's share is its cost times that. The cost is never
+//! shown as money: the owner pays for the plan, not the API.
+
+use std::collections::HashMap;
+use std::sync::{Mutex, PoisonError};
+
+use botloft_core::ids::BotId;
+use botloft_core::protocol::UsageWindow;
+use botloft_store::{PlanReading, Store};
+use serde_json::Value;
+use tracing::warn;
+
+use crate::state::Daemon;
+
+/// The window the share is of.
+pub const WEEK: &str = "seven_day";
+/// How far back the plan's rises are learned from.
+const LEARN_MS: i64 = 35 * 24 * 60 * 60 * 1000;
+/// Rises smaller than this, in all, say too little: the plan reads in
+/// whole points.
+const LEARN_MIN_RISE: f64 = 0.02;
+/// Readings of one window share its reset time; a little drift is the same.
+const SAME_RESET_MS: i64 = 60 * 60 * 1000;
+
+/// What each bot's running process had cost by its last turn: Claude
+/// Code's `total_cost_usd` adds up the whole process.
+#[derive(Default)]
+pub struct CostMeter {
+ last: Mutex>,
+}
+
+impl CostMeter {
+ /// The cost of the turn that just ended, from the process total.
+ fn turn(&self, bot: &BotId, generation: u64, total: f64) -> f64 {
+ let mut last = self.last.lock().unwrap_or_else(PoisonError::into_inner);
+ let before = match last.get(bot) {
+ Some((seen, before)) if *seen == generation && *before <= total => *before,
+ _ => 0.0,
+ };
+ last.insert(bot.clone(), (generation, total));
+ total - before
+ }
+}
+
+/// A turn ended: what it cost goes to `turn_costs`.
+pub fn turn_ended(daemon: &Daemon, bot: &BotId, generation: u64, result: &Value) {
+ let Some(total) = result["total_cost_usd"].as_f64() else {
+ return;
+ };
+ let cost = daemon.costs.turn(bot, generation, total);
+ if cost <= 0.0 {
+ return;
+ }
+ let now = daemon.clock.now_ms();
+ if let Err(err) = daemon.store().add_turn_cost(bot, now, cost) {
+ warn!(bot = %bot, "could not save what a turn cost: {err}");
+ }
+}
+
+/// The plan's windows as Claude Code reported them.
+pub fn observed(daemon: &Daemon, windows: &[UsageWindow], at: i64) {
+ let store = daemon.store();
+ for window in windows {
+ let reading = PlanReading {
+ at,
+ utilization: window.utilization,
+ resets_at: window.resets_at,
+ };
+ if let Err(err) = store.add_plan_reading(&window.name, &reading) {
+ warn!("could not save the plan's usage: {err}");
+ }
+ }
+}
+
+/// The weekly plan's share per dollar of the bots' cost, once the plan
+/// has risen enough while they worked. A rise with nothing spent between
+/// two readings was someone else's use and is left out.
+pub fn rate(store: &Store, now: i64) -> Option {
+ let readings = store.plan_readings(WEEK, now - LEARN_MS).ok()?;
+ let (mut rise, mut spent) = (0.0, 0.0);
+ for pair in readings.windows(2) {
+ let (a, b) = (&pair[0], &pair[1]);
+ let same = match (a.resets_at, b.resets_at) {
+ (Some(x), Some(y)) => (x - y).abs() <= SAME_RESET_MS,
+ _ => false,
+ };
+ if !same || b.utilization < a.utilization {
+ continue;
+ }
+ let cost = store.costs_between(a.at, b.at).ok()?;
+ if cost > 0.0 {
+ rise += b.utilization - a.utilization;
+ spent += cost;
+ }
+ }
+ (rise >= LEARN_MIN_RISE && spent > 0.0).then(|| rise / spent)
+}
+
+/// Each bot's share of the weekly plan from `since` on, 0 to 1, or `None`
+/// before the rate is known.
+pub fn shares(store: &Store, since: i64, now: i64) -> Option> {
+ let rate = rate(store, now)?;
+ let costs = store.bot_costs(since).ok()?;
+ Some(
+ costs
+ .into_iter()
+ .map(|(bot, cost)| (bot, cost * rate))
+ .collect(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_turn_costs_what_the_process_total_grew_by() {
+ let meter = CostMeter::default();
+ let bot = BotId::generate();
+ assert!((meter.turn(&bot, 1, 0.5) - 0.5).abs() < 1e-9);
+ assert!((meter.turn(&bot, 1, 0.75) - 0.25).abs() < 1e-9);
+ // A new process starts over.
+ assert!((meter.turn(&bot, 2, 0.1) - 0.1).abs() < 1e-9);
+ }
+}
diff --git a/crates/botloftd/src/service/usage.rs b/crates/botloftd/src/service/usage.rs
index 7d59ee5a..b786a58d 100644
--- a/crates/botloftd/src/service/usage.rs
+++ b/crates/botloftd/src/service/usage.rs
@@ -10,5 +10,12 @@ pub fn tokens(daemon: &Daemon, params: UsageTokensParams) -> ApiResult,
pub contexts: Contexts,
/// Time for everything stored or compared with stored times.
@@ -157,6 +159,7 @@ impl Daemon {
screens: Screens::default(),
desktop: Desktop::new(options.owner_idle),
crew_access: Default::default(),
+ costs: Default::default(),
trash: options.trash,
contexts: Contexts::default(),
clock: options.clock,
diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs
index 3791653a..145ae85f 100644
--- a/crates/botloftd/src/tools/calls.rs
+++ b/crates/botloftd/src/tools/calls.rs
@@ -107,6 +107,8 @@ fn roster(daemon: &Daemon, bot: &BotId) -> Outcome {
crew_id: Some(crew.id.clone()),
};
let crew_bots = bots::list(daemon, list).map_err(explain)?;
+ let now = daemon.clock.now_ms();
+ let week = crate::service::plan::shares(&daemon.store(), now - 7 * 86_400_000, now);
let others: Vec<_> = crew_bots
.iter()
.filter(|other| other.id != me.id)
@@ -118,6 +120,10 @@ fn roster(daemon: &Daemon, bot: &BotId) -> Outcome {
"state": other.state,
"model": other.model,
"effort": other.effort,
+ "week_plan_percent": week.as_ref().map(|shares| {
+ let share = shares.get(&other.id).copied().unwrap_or_default();
+ (share * 10_000.0).round() / 100.0
+ }),
"chief": lead::is_lead(&crew, &other.id),
})
})
diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs
index 893ee37a..91a8b325 100644
--- a/crates/botloftd/src/tools/catalog.rs
+++ b/crates/botloftd/src/tools/catalog.rs
@@ -57,8 +57,10 @@ fn crew_tools() -> Value {
"title": "Crew roster",
"description": "Lists the other bots of your crew with their handle, name, role and \
current state (idle, busy, offline...). Use it to find who can help and the \
- handle to send a message to. With `crew`, the bots of another crew you were \
- let in to (ask_crew_access).",
+ handle to send a message to. Each bot also shows its model, effort and \
+ `week_plan_percent`: about how much of the owner's weekly plan it used in the \
+ last 7 days (null until Botloft has learned it). With `crew`, the bots of another \
+ crew you were let in to (ask_crew_access).",
"inputSchema": {
"type": "object",
"properties": {
diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs
index 3f21962e..da07c4cf 100644
--- a/crates/botloftd/src/workspace/files.rs
+++ b/crates/botloftd/src/workspace/files.rs
@@ -83,7 +83,8 @@ The owner made you the chief of this crew: its goal is in your instructions.
exists (`crew_roster`).
- When the owner asks you to rename or change another bot of the crew, call
`change_bot` with its handle in `bot`; the owner approves that too.
-- Keep an eye on the cost: `crew_roster` shows each bot's model and effort.
+- Keep an eye on the cost: `crew_roster` shows each bot's model, effort and
+ `week_plan_percent`, about how much of the owner's weekly plan it used.
When a bot's work turns out simpler than its model or effort (or harder),
propose a change with `change_bot` (`model`, `effort`) and say why; the
owner approves it. Simple, repetitive work: `haiku` and `low`.
diff --git a/crates/botloftd/tests/plan_share.rs b/crates/botloftd/tests/plan_share.rs
new file mode 100644
index 00000000..8e0be634
--- /dev/null
+++ b/crates/botloftd/tests/plan_share.rs
@@ -0,0 +1,93 @@
+//! Each bot's share of the weekly plan (spec 8.7), end to end: the turns'
+//! cost from Claude Code's process total, the plan's rise while they ran,
+//! and the share that comes out.
+
+mod common;
+
+use std::time::Duration;
+
+use botloft_core::protocol::BotState;
+use common::bots::{Crew, two_bots};
+use common::stream;
+use serde_json::{Value, json};
+
+const RESETS: i64 = 1_800_000_000;
+
+fn week(utilization: f64) -> Value {
+ json!({
+ "type": "rate_limit_event",
+ "rate_limit_info": { "status": "allowed", "resetsAt": RESETS,
+ "unifiedWindows": { "seven_day": { "utilization": utilization, "resetsAt": RESETS } } },
+ })
+}
+
+/// One turn of the lead: the plan reads `utilization` as it starts, and
+/// the process has cost `total` by its end.
+async fn turn(c: &mut Crew, lead: &Value, utilization: f64, total: f64) {
+ c.t.clock.advance(Duration::from_secs(60));
+ c.app
+ .call(
+ "messages.send",
+ json!({ "botId": lead["id"], "body": "Go" }),
+ )
+ .await
+ .expect("send");
+ let line = c
+ .lead_process
+ .wait_lines(1)
+ .await
+ .last()
+ .cloned()
+ .expect("line");
+ c.lead_process.emit(week(utilization)).await;
+ // The reading is in before the turn goes on.
+ for _ in 0..500 {
+ let read = c.t.daemon.usage().map(|usage| usage.windows[0].utilization);
+ if read == Some(utilization) {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+ c.lead_process.emit(stream::init("session-1")).await;
+ c.lead_process.emit(stream::replay(&line)).await;
+ c.lead_process.emit(stream::text("Done.")).await;
+ c.t.clock.advance(Duration::from_secs(60));
+ let mut result = stream::result(false);
+ result["total_cost_usd"] = json!(total);
+ c.lead_process.emit(result).await;
+ c.t.until_state(lead, BotState::Idle).await;
+}
+
+#[tokio::test]
+async fn the_share_is_learned_from_the_plans_rise_and_each_turns_cost() {
+ let mut c = two_bots().await;
+ let lead = c.app.call("bots.list", json!({})).await.expect("bots")[0].clone();
+
+ turn(&mut c, &lead, 0.30, 1.0).await;
+ let early = c
+ .app
+ .call("usage.tokens", json!({ "since": 0 }))
+ .await
+ .expect("usage");
+ assert_eq!(early[0]["planShare"], Value::Null, "nothing learned yet");
+
+ // The week rose 3 points over the first turn, which cost $1.
+ turn(&mut c, &lead, 0.33, 1.5).await;
+ let usage = c
+ .app
+ .call("usage.tokens", json!({ "since": 0 }))
+ .await
+ .expect("usage");
+ let share = usage[0]["planShare"].as_f64().expect("share");
+ assert!(
+ (share - 0.045).abs() < 1e-9,
+ "{share}: $1.50 at 3 points a dollar"
+ );
+
+ let roster = c
+ .writer
+ .tool("crew_roster", json!({}))
+ .await
+ .expect("roster");
+ assert_eq!(roster["bots"][0]["week_plan_percent"], 4.5);
+}
diff --git a/crates/botloftd/tests/tasks.rs b/crates/botloftd/tests/tasks.rs
index b5ce20da..288cc12d 100644
--- a/crates/botloftd/tests/tasks.rs
+++ b/crates/botloftd/tests/tasks.rs
@@ -186,7 +186,7 @@ async fn mistakes_come_back_as_tool_errors() {
assert_eq!(
roster["bots"],
json!([{ "handle": "writer", "name": "Writer", "role": "Writer role", "state": "idle",
- "model": "default", "effort": "default", "chief": false }])
+ "model": "default", "effort": "default", "week_plan_percent": null, "chief": false }])
);
let err = |out: Result| out.expect_err("refused");
diff --git a/docs/spec.md b/docs/spec.md
index 81b635c1..984687c6 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -420,13 +420,14 @@ A compactação custa um pedido ao modelo com a conversa inteira (barato com o c
### 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.
+O dono vê quantos tokens cada bot gasta: em cada turno, no chat, e somados por bot, no diálogo de uso, junto com quanto do plano semanal cada bot usou (abaixo). 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; o daemon só o usa como peso.
- **De onde vem.** O `usage` do `result` é a soma dos pedidos ao modelo daquele turno (19). O item `turn` guarda `tokens`: `input` (`input_tokens`), `cacheWrite` (`cache_creation_input_tokens`), `cacheRead` (`cache_read_input_tokens`) e `output` (`output_tokens`, com o pensamento), e ainda `reloaded`, a conta do daemon descrita abaixo (0 nos itens antigos). O `modelUsage` não serve: ele acumula entre turnos.
- **O que conta.** O número em destaque é `input + cacheWrite + output`: o que o modelo leu sem vir do cache (o texto novo e a conversa recarregada, abaixo) e o que escreveu. O Claude Code manda quase tudo que é novo (a mensagem, o resultado de uma ferramenta) para o cache, por isso o `input` sozinho fica perto de zero e o novo está no `cacheWrite`. O `cacheRead` é quase todo a conversa relida a cada pedido, pesa bem menos e numa conversa longa seria a maior parte do total. O app não o mostra: um número "relidos" ao lado do gasto confundia quem lê. Ele continua gravado no item `turn` e somado no `usage.tokens`.
- **A conversa recarregada.** Cada pedido manda a conversa inteira. Quando o cache expirou, porque o bot ficou parado ou o processo reiniciou, a parte da conversa que o cache não tinha mais é gravada de novo e vem no `cacheWrite`, misturada com o que é novo; num turno curto depois de uma pausa ela é quase tudo (19). O daemon separa essa parte em `reloaded`, sem guardar nada: para cada pedido da conversa principal (sem subagentes nem respostas sintéticas, contado uma vez por `message.id`), o que a conversa já tinha e não veio do cache, `min(cacheWrite, antes - cacheRead)`, onde `antes` é `input + cacheWrite + cacheRead` do pedido anterior ou, sem pedido visto, o tamanho que o Claude Code respondeu (8.6). Uma conversa nova ou recém-compactada não tem `antes` e não conta nada. O valor é limitado ao `cacheWrite` do `result`. O app não mostra essa parte: ela entra no número do turno, porque é gasto de verdade do plano, e um número a mais ao lado confundia quem lê. Um turno curto depois de uma pausa mostra um número grande, e isso é verdade. O `reloaded` continua gravado no item `turn`.
- **No chat.** A linha do fim do turno diz a duração e os tokens ("Concluído em 4,2 s · 2,5 mil tokens"); o detalhe (lidos e escritos) fica no título da linha. A unidade é o turno, não a mensagem: mensagens que chegam juntas entram num turno só, e não dá para dividir os tokens entre elas.
-- **Por bot.** `usage.tokens {since}` soma os itens `turn` com `tokens` terminados a partir de `since` (ms Unix), por bot, com nome, cor, o nome da equipe (dois bots podem ter o mesmo nome em equipes diferentes) e se está arquivado, do que mais gastou para o que menos. Bots sem turno no período ficam de fora; bots excluídos levam os itens junto (7.6). O app oferece hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo.
+- **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 a última hora, hoje (desde a meia-noite local), 7 dias, 30 dias e tudo, com o total dos bots embaixo.
+- **Parte do plano semanal.** A Anthropic não publica quanto cabe em cada plano, e o `rate_limit_event` traz o uso da conta inteira, em pontos inteiros (`utilization` 0.32), sem dizer de quem. Então o daemon aprende. A cada fim de turno, guarda em `turn_costs` o custo do turno a preço de API: a diferença do `total_cost_usd` do processo (que acumula, 19) para o do turno anterior da mesma generation. A cada leitura de uma janela que muda, guarda em `plan_readings` (`name`, `at`, `utilization`, `resets_at`; dois meses). A taxa é a soma das subidas da janela `seven_day` entre leituras seguidas da mesma semana (mesmo `resets_at`, com uma hora de folga) sobre o custo dos bots nesse meio, nos últimos 35 dias. Um par em que a janela subiu sem custo de bot no meio fica de fora: foi uso de fora. Com menos de 2 pontos de subida no total, não há taxa ainda. A parte de cada bot num período é o custo dele vezes a taxa (`BotTokens.planShare`, 0 a 1, ou `null` sem taxa). O peso entre os bots é exato, pelos preços de cada modelo; o valor absoluto é estimativa, e o uso do dono fora do Botloft o puxa um pouco para cima. O app mostra "≈ 0,4% da semana" por bot, com os tokens na linha de baixo e a nota de que é estimativa; sem taxa, os tokens e a nota de que a parte aparece depois. `crew_roster` dá `week_plan_percent` de cada bot (últimos 7 dias, em pontos com duas casas, ou `null`), para o chefe cuidar do custo (10.3).
- Uma compactação (8.6) não vira item `turn` e não entra na conta.
### 8.8 Busca