);
default:
@@ -62,6 +75,39 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
}
}
+const BOX =
+ "max-h-56 overflow-auto break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5";
+
+/**
+ * A command the bot wants to run: first what the bot says it is for, in
+ * plain text, then the command itself, one click away. The bot wrote the
+ * explanation, so the card says so and never shows it in the command's
+ * place; with none, the command is open from the start (spec 10.1).
+ */
+function CommandAsked({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
+ const t = useT();
+ const said = approval.explanation;
+ const command = commandOf(approval.input);
+ return (
+ <>
+ {said ? (
+ <>
+
-
diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx
index fc524195..a7b6b107 100644
--- a/app/src/features/chat/ChatComposer.tsx
+++ b/app/src/features/chat/ChatComposer.tsx
@@ -20,6 +20,8 @@ import { fileSize } from "../../lib/format";
import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
import { prefs, usePref } from "../../shell/prefs";
import { useApi } from "../../store/context";
+import { ContextMeter } from "./ContextMeter";
+import { EffortPicker } from "./EffortPicker";
import { rememberImage } from "./images";
import { ModelPicker } from "./ModelPicker";
import { ModePicker } from "./ModePicker";
@@ -70,7 +72,7 @@ export function ChatComposer({
const t = useT();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
- /** A new mode or model that waits for the bot to finish what it is doing. */
+ /** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */
const [later, setLater] = useState(null);
const field = useRef(null);
const picker = useRef(null);
@@ -159,7 +161,7 @@ export function ChatComposer({
{later}
)}
-
+
+ {said}
+
+
+ );
+}
diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx
index f26c0988..a8be2fb9 100644
--- a/app/src/features/crews/botMenu.test.tsx
+++ b/app/src/features/crews/botMenu.test.tsx
@@ -40,6 +40,7 @@ describe("a bot's right-click menu", () => {
"Restart with a new conversation",
"Open folder",
"Archive bot",
+ "Delete bot",
]);
// The crew's page is still the one open.
expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined();
diff --git a/app/src/features/messages/MessageRow.tsx b/app/src/features/messages/MessageRow.tsx
index 08075b3d..0db72590 100644
--- a/app/src/features/messages/MessageRow.tsx
+++ b/app/src/features/messages/MessageRow.tsx
@@ -9,7 +9,7 @@ import { DeliveryStatus } from "./DeliveryStatus";
function BotName({ bot }: { bot: Bot | undefined }) {
const t = useT();
if (!bot) {
- return {t.messages.row.archivedBot};
+ return {t.messages.row.goneBot};
}
return (
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index c4ba71d8..2bc6745c 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -87,6 +87,17 @@ export const account = {
size: "Size",
sizeHint: "Ctrl+= and Ctrl+- change the size too, and Ctrl+0 goes back to the default.",
language: "Language",
+ archived: "Archived",
+ archivedIntro:
+ "Bots and crews you archived are stopped and out of sight, and Botloft still keeps their conversations. Delete one to remove it for good.",
+ archivedEmpty: "Nothing is archived.",
+ archivedLoadFailed: "Could not load what is archived",
+ /** `when` is relative, like "2 days ago". */
+ archivedCrew: (bots: number, when: string) =>
+ `Crew · ${bots === 1 ? "1 bot" : `${bots} bots`} · archived ${when}`,
+ archivedBot: (crew: string, when: string) => `Bot in ${crew} · archived ${when}`,
+ deleteArchived: "Delete",
+ deleteArchivedOne: (name: string) => `Delete ${name}`,
about: "About",
claudeCode: (version: string) => `Claude Code ${version}`,
botloft: (version: string) => `Botloft ${version}`,
diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts
index 47896821..a8234f5e 100644
--- a/app/src/i18n/en/bots.ts
+++ b/app/src/i18n/en/bots.ts
@@ -48,6 +48,7 @@ export const bots = {
makeChief: "Make crew chief",
stopChief: "Stop being chief",
archive: "Archive bot",
+ delete: "Delete bot",
failed: {
pause: "Could not pause the bot",
resume: "Could not resume the bot",
@@ -55,6 +56,7 @@ export const bots = {
openFolder: "Could not open the folder",
chief: "Could not change the chief",
archive: "Could not archive the bot",
+ delete: "Could not delete the bot",
},
fresh: {
title: "Start a new conversation?",
@@ -67,6 +69,18 @@ export const bots = {
confirm: "Archive bot",
body: "The bot stops and leaves the crew. Messages still waiting for it are not delivered.",
},
+ deleteConfirm: {
+ title: (name: string) => `Delete ${name}?`,
+ confirm: "Delete bot",
+ /** `running` is false for an archived bot, which stopped long ago. */
+ removed: (name: string, running: boolean) =>
+ `${name} ${running ? "stops now and leaves" : "leaves"} Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.`,
+ chief: (crew: string) => `${crew} will be left without a chief.`,
+ kept: (name: string) => `${name}'s folder stays on your computer, with everything in it:`,
+ recycle: "Move this folder to the Recycle Bin",
+ recycled: (name: string) =>
+ `${name}'s folder goes to the Recycle Bin, where you can still get it back:`,
+ },
},
dialog: {
newTitle: "New bot",
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index 30ef29c9..479f4d05 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -43,7 +43,7 @@ export const chat = {
inbound: {
task: "Task",
result: "Result",
- archivedBot: "An archived bot",
+ goneBot: "A bot no longer in the crew",
},
run: {
working: "Working",
@@ -60,11 +60,16 @@ export const chat = {
input: "Input",
output: "Output",
error: "Error",
+ command: "Command",
+ commandCut: "This command is too long to show in full.",
},
approval: {
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
wants: (bot: string, action: string) => `${bot} wants to ${action}`,
- fullInput: "Full input",
+ explainedBy: (bot: string) => `${bot} wrote this. What actually runs is the command below.`,
+ unexplained: (bot: string) =>
+ `${bot} did not say what this command is for. If you are not sure, deny it and ask.`,
+ command: "See the command",
noteLabel: (bot: string) => `Note for ${bot} if you deny`,
notePlaceholder: "Why not? Sent to the bot if you deny (optional)",
allow: "Allow",
@@ -135,6 +140,58 @@ export const chat = {
later: (bot: string, model: string) =>
`${bot} switches to ${model} when it finishes what it's doing.`,
},
+ effort: {
+ title: "Effort",
+ button: (level: string) => `Effort: ${level}`,
+ short: "Effort",
+ faster: "Faster",
+ smarter: "Smarter",
+ names: {
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ xhigh: "Extra high",
+ max: "Max",
+ },
+ hints: {
+ low: (bot: string) => `${bot} answers fastest and thinks the least, for simple tasks`,
+ medium: (bot: string) => `${bot} balances speed and thinking, good for most work`,
+ high: (bot: string) => `${bot} thinks more before answering, for harder tasks`,
+ xhigh: (bot: string) => `${bot} thinks a lot more, for long and complex work`,
+ max: (bot: string) =>
+ `${bot} thinks as much as it can: slowest, and uses the most of your plan`,
+ },
+ recommended: "Recommended",
+ recommendedFor: (model: string) => `Recommended for ${model}`,
+ useRecommended: "Use recommended",
+ unknown: (bot: string) => `${bot} uses the level recommended for its model`,
+ unavailable: "Not available",
+ none: (bot: string, model: string) =>
+ `${model} has no effort levels: ${bot} always answers at the same pace.`,
+ thisModel: "This model",
+ cost: "More effort uses up your plan's limit faster.",
+ failed: "Could not change the effort",
+ later: (bot: string, level: string) =>
+ `${bot} switches to ${level} effort when it finishes what it's doing.`,
+ },
+ context: {
+ title: "Conversation space",
+ button: (used: string, total: string, percent: number) =>
+ `Conversation space: ${used} of ${total} used (${percent}%)`,
+ used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`,
+ about: (bot: string) =>
+ `Everything ${bot} read and wrote in this conversation takes up space. The fuller it is, the more of your plan each message uses.`,
+ autoLeft: (left: string, at: string) => `${left} left before it compacts by itself, at ${at}.`,
+ autoNow: "It is full enough to compact by itself on the next message.",
+ noAuto: "It does not compact by itself.",
+ compact: "Compact now",
+ compactHint: (bot: string) =>
+ `Replaces what came before with a summary, so ${bot} has room again and each message uses less.`,
+ compacting: "Compacting…",
+ later: (bot: string) => `${bot} compacts the conversation when it finishes what it's doing.`,
+ notRunning: (bot: string) => `${bot} is not running, so it cannot compact now.`,
+ failed: "Could not compact the conversation",
+ },
suggestion: {
title: (bot: string) => `${bot} suggests a new bot`,
why: "Why",
@@ -176,5 +233,10 @@ export const chat = {
modelUnavailable:
"This bot's model isn't available: it may not be on your Claude plan. Pick another model below the chat.",
turnFailed: (detail: string) => `The bot couldn't finish this: ${detail}`,
+ compacted:
+ "The conversation was compacted: what came before is now a summary, and there is room again.",
+ autoCompacted:
+ "The conversation was full, so it was compacted: what came before is now a summary.",
+ compactFailed: (detail: string) => `The conversation could not be compacted: ${detail}`,
},
};
diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts
index 0ea025b5..b84f232e 100644
--- a/app/src/i18n/en/crews.ts
+++ b/app/src/i18n/en/crews.ts
@@ -49,6 +49,7 @@ export const crews = {
`The bots will work in ${path}. Each one restarts when it finishes what it's doing. Files already made stay where they are.`,
move: "Move",
archive: "Archive crew",
+ delete: "Delete crew",
tabs: {
label: "Crew views",
bots: "Bots",
@@ -62,12 +63,26 @@ export const crews = {
bots === 0
? "The crew leaves the app."
: `The crew leaves the app. ${bots === 1 ? "Its bot stops" : `Its ${bots} bots stop`}, and messages still waiting for them are not delivered.`,
+ deleteTitle: (crew: string) => `Delete ${crew}?`,
+ /** `running` is false for an archived crew, which stopped long ago. */
+ deleteBody: (crew: string, bots: number, running: boolean) =>
+ bots === 0
+ ? `${crew} leaves Botloft for good. This can't be undone.`
+ : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} ${running ? "stop now and leave" : "leave"} Botloft for good, with their conversations, routines and tasks. This can't be undone.`,
+ deleteKept:
+ "The folders stay on your computer, with everything in them: each bot's own folder and the crew's work folder:",
+ deleteRecycle: "Move the crew's folders to the Recycle Bin",
+ deleteRecycled:
+ "The crew's folder goes to the Recycle Bin, with each bot's folder and the work folder in it. You can still get it back from there:",
+ deleteRecycledChosen:
+ "Each bot's own folder goes to the Recycle Bin, where you can still get it back. The work folder you chose stays where it is:",
failed: {
pause: "Could not pause the crew",
resume: "Could not resume the crew",
changeFolder: "Could not change the folder",
openFolder: "Could not open the folder",
archive: "Could not archive the crew",
+ delete: "Could not delete the crew",
},
},
bots: {
diff --git a/app/src/i18n/en/messages.ts b/app/src/i18n/en/messages.ts
index 19bdafd4..01c2b7a7 100644
--- a/app/src/i18n/en/messages.ts
+++ b/app/src/i18n/en/messages.ts
@@ -33,7 +33,7 @@ export const messages = {
row: {
you: "You",
system: "Botloft",
- archivedBot: "an archived bot",
+ goneBot: "a bot no longer in the crew",
to: "to",
task: "Task",
result: "Result",
diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts
index 745c3686..7be45d3c 100644
--- a/app/src/i18n/en/shell.ts
+++ b/app/src/i18n/en/shell.ts
@@ -22,6 +22,9 @@ export const shell = {
reconnecting: "Reconnecting…",
},
loadFailed: "Could not load your crews",
+ /** A deleted bot's or crew's folder that could not go to the Recycle Bin. */
+ recycleFailed: (path: string) =>
+ `The folder ${path} did not go to the Recycle Bin and is still there`,
pickCrew: "Pick a crew on the left, or create one with the + button.",
botsCantStart: "Bots can't start",
signIn: {
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 9b79d765..828327b5 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -86,6 +86,16 @@ export const account: Messages["account"] = {
size: "Tamaño",
sizeHint: "Ctrl+= y Ctrl+- también cambian el tamaño, y Ctrl+0 vuelve al predeterminado.",
language: "Idioma",
+ archived: "Archivados",
+ archivedIntro:
+ "Los bots y los equipos que archivaste están detenidos y fuera de la vista, y Botloft todavía guarda sus conversaciones. Elimina uno para quitarlo para siempre.",
+ archivedEmpty: "No hay nada archivado.",
+ archivedLoadFailed: "No se pudo cargar lo archivado",
+ archivedCrew: (bots: number, when: string) =>
+ `Equipo · ${bots === 1 ? "1 bot" : `${bots} bots`} · archivado ${when}`,
+ archivedBot: (crew: string, when: string) => `Bot de ${crew} · archivado ${when}`,
+ deleteArchived: "Eliminar",
+ deleteArchivedOne: (name: string) => `Eliminar ${name}`,
about: "Acerca de",
claudeCode: (version: string) => `Claude Code ${version}`,
botloft: (version: string) => `Botloft ${version}`,
diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts
index ef9fa0e4..0cb7480a 100644
--- a/app/src/i18n/es/bots.ts
+++ b/app/src/i18n/es/bots.ts
@@ -44,6 +44,7 @@ export const bots: Messages["bots"] = {
makeChief: "Hacer jefe del equipo",
stopChief: "Dejar de ser jefe",
archive: "Archivar bot",
+ delete: "Eliminar bot",
failed: {
pause: "No se pudo pausar el bot",
resume: "No se pudo reanudar el bot",
@@ -51,6 +52,7 @@ export const bots: Messages["bots"] = {
openFolder: "No se pudo abrir la carpeta",
chief: "No se pudo cambiar el jefe",
archive: "No se pudo archivar el bot",
+ delete: "No se pudo eliminar el bot",
},
fresh: {
title: "¿Empezar una conversación nueva?",
@@ -63,6 +65,18 @@ export const bots: Messages["bots"] = {
confirm: "Archivar bot",
body: "El bot se detiene y sale del equipo. Los mensajes que aún lo esperan no se entregan.",
},
+ deleteConfirm: {
+ title: (name: string) => `¿Eliminar ${name}?`,
+ confirm: "Eliminar bot",
+ removed: (name: string, running: boolean) =>
+ `${name} ${running ? "se detiene ahora y sale" : "sale"} de Botloft para siempre, con su conversación, sus rutinas y las tareas en las que participaba. No se puede deshacer.`,
+ chief: (crew: string) => `${crew} se queda sin jefe.`,
+ kept: (name: string) =>
+ `La carpeta de ${name} sigue en tu ordenador, con todo lo que contiene:`,
+ recycle: "Enviar esta carpeta a la Papelera",
+ recycled: (name: string) =>
+ `La carpeta de ${name} va a la Papelera, de donde todavía puedes recuperarla:`,
+ },
},
dialog: {
newTitle: "Nuevo bot",
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index a69002a1..5c49b32f 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -44,7 +44,7 @@ export const chat: Messages["chat"] = {
inbound: {
task: "Tarea",
result: "Resultado",
- archivedBot: "Un bot archivado",
+ goneBot: "Un bot que ya no está en el equipo",
},
run: {
working: "Trabajando",
@@ -65,11 +65,17 @@ export const chat: Messages["chat"] = {
input: "Entrada",
output: "Salida",
error: "Error",
+ command: "Comando",
+ commandCut: "Este comando es demasiado largo para mostrarse entero.",
},
approval: {
asks: (bot: string, action: string) => `${bot} pide ${action}`,
wants: (bot: string, action: string) => `${bot} quiere ${action}`,
- fullInput: "Entrada completa",
+ explainedBy: (bot: string) =>
+ `${bot} escribió esto. Lo que de verdad se ejecuta es el comando de abajo.`,
+ unexplained: (bot: string) =>
+ `${bot} no dijo para qué sirve este comando. Si tienes dudas, deniégalo y pregunta.`,
+ command: "Ver el comando",
noteLabel: (bot: string) => `Nota para ${bot} si deniegas`,
notePlaceholder: "¿Por qué no? Se envía al bot si deniegas (opcional)",
allow: "Permitir",
@@ -141,6 +147,58 @@ export const chat: Messages["chat"] = {
later: (bot: string, model: string) =>
`${bot} cambia a ${model} cuando termine lo que está haciendo.`,
},
+ effort: {
+ title: "Esfuerzo",
+ button: (level: string) => `Esfuerzo: ${level}`,
+ short: "Esfuerzo",
+ faster: "Más rápido",
+ smarter: "Más inteligente",
+ names: {
+ low: "Bajo",
+ medium: "Medio",
+ high: "Alto",
+ xhigh: "Extra alto",
+ max: "Máximo",
+ },
+ hints: {
+ low: (bot: string) => `${bot} responde más rápido y piensa menos, para tareas simples`,
+ medium: (bot: string) => `${bot} equilibra rapidez y razonamiento, bueno para casi todo`,
+ high: (bot: string) => `${bot} piensa más antes de responder, para tareas más difíciles`,
+ xhigh: (bot: string) => `${bot} piensa mucho más, para trabajo largo y complejo`,
+ max: (bot: string) =>
+ `${bot} piensa todo lo que puede: es el más lento y el que más gasta de tu plan`,
+ },
+ recommended: "Recomendado",
+ recommendedFor: (model: string) => `Recomendado para ${model}`,
+ useRecommended: "Usar el recomendado",
+ unknown: (bot: string) => `${bot} usa el nivel recomendado para su modelo`,
+ unavailable: "No disponible",
+ none: (bot: string, model: string) =>
+ `${model} no tiene niveles de esfuerzo: ${bot} responde siempre al mismo ritmo.`,
+ thisModel: "Este modelo",
+ cost: "Más esfuerzo gasta el límite de tu plan más rápido.",
+ failed: "No se pudo cambiar el esfuerzo",
+ later: (bot: string, level: string) =>
+ `${bot} cambia al esfuerzo ${level} cuando termine lo que está haciendo.`,
+ },
+ context: {
+ title: "Espacio de la conversación",
+ button: (used: string, total: string, percent: number) =>
+ `Espacio de la conversación: ${used} de ${total} en uso (${percent}%)`,
+ used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`,
+ about: (bot: string) =>
+ `Todo lo que ${bot} leyó y escribió en esta conversación ocupa espacio. Cuanto más llena, más de tu plan gasta cada mensaje.`,
+ autoLeft: (left: string, at: string) => `Faltan ${left} para que se compacte sola, en ${at}.`,
+ autoNow: "Ya está lo bastante llena para compactarse sola en el próximo mensaje.",
+ noAuto: "No se compacta sola.",
+ compact: "Compactar ahora",
+ compactHint: (bot: string) =>
+ `Cambia lo anterior por un resumen: ${bot} vuelve a tener espacio y cada mensaje gasta menos.`,
+ compacting: "Compactando…",
+ later: (bot: string) => `${bot} compacta la conversación cuando termine lo que está haciendo.`,
+ notRunning: (bot: string) => `${bot} no está en marcha, así que no se puede compactar ahora.`,
+ failed: "No se pudo compactar la conversación",
+ },
suggestion: {
title: (bot: string) => `${bot} sugiere un bot nuevo`,
why: "Por qué",
@@ -183,5 +241,9 @@ export const chat: Messages["chat"] = {
modelUnavailable:
"El modelo de este bot no está disponible: quizá no esté en tu plan de Claude. Elige otro modelo debajo del chat.",
turnFailed: (detail: string) => `El bot no pudo terminar esto: ${detail}`,
+ compacted:
+ "La conversación se compactó: lo anterior ahora es un resumen, y vuelve a haber espacio.",
+ autoCompacted: "La conversación estaba llena y se compactó: lo anterior ahora es un resumen.",
+ compactFailed: (detail: string) => `No se pudo compactar la conversación: ${detail}`,
},
};
diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts
index b5b164f4..06d239c4 100644
--- a/app/src/i18n/es/crews.ts
+++ b/app/src/i18n/es/crews.ts
@@ -46,6 +46,7 @@ export const crews: Messages["crews"] = {
`Los bots trabajarán en ${path}. Cada uno se reinicia cuando termine lo que está haciendo. Lo que ya hicieron se queda donde está.`,
move: "Mover",
archive: "Archivar equipo",
+ delete: "Eliminar equipo",
tabs: {
label: "Vistas del equipo",
bots: "Bots",
@@ -59,12 +60,25 @@ export const crews: Messages["crews"] = {
bots === 0
? "El equipo sale de la app."
: `El equipo sale de la app. ${bots === 1 ? "Su bot se detiene" : `Sus ${bots} bots se detienen`}, y los mensajes que aún ${bots === 1 ? "lo" : "los"} esperan no se entregan.`,
+ deleteTitle: (crew: string) => `¿Eliminar ${crew}?`,
+ deleteBody: (crew: string, bots: number, running: boolean) =>
+ bots === 0
+ ? `${crew} sale de Botloft para siempre. No se puede deshacer.`
+ : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} ${running ? "se detienen ahora y salen" : "salen"} de Botloft para siempre, con sus conversaciones, rutinas y tareas. No se puede deshacer.`,
+ deleteKept:
+ "Las carpetas siguen en tu ordenador, con todo lo que contienen: la carpeta de cada bot y la carpeta de trabajo del equipo:",
+ deleteRecycle: "Enviar las carpetas del equipo a la Papelera",
+ deleteRecycled:
+ "La carpeta del equipo va a la Papelera, con la carpeta de cada bot y la carpeta de trabajo dentro. Todavía puedes recuperarla de allí:",
+ deleteRecycledChosen:
+ "La carpeta de cada bot va a la Papelera, de donde todavía puedes recuperarla. La carpeta de trabajo que elegiste se queda donde está:",
failed: {
pause: "No se pudo pausar el equipo",
resume: "No se pudo reanudar el equipo",
changeFolder: "No se pudo cambiar la carpeta",
openFolder: "No se pudo abrir la carpeta",
archive: "No se pudo archivar el equipo",
+ delete: "No se pudo eliminar el equipo",
},
},
bots: {
diff --git a/app/src/i18n/es/messages.ts b/app/src/i18n/es/messages.ts
index b210d238..cbfdf4f1 100644
--- a/app/src/i18n/es/messages.ts
+++ b/app/src/i18n/es/messages.ts
@@ -31,7 +31,7 @@ export const messages: Messages["messages"] = {
row: {
you: "Tú",
system: "Botloft",
- archivedBot: "un bot archivado",
+ goneBot: "un bot que ya no está en el equipo",
to: "para",
task: "Tarea",
result: "Resultado",
diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts
index 8020d799..e4f239d3 100644
--- a/app/src/i18n/es/shell.ts
+++ b/app/src/i18n/es/shell.ts
@@ -20,6 +20,7 @@ export const shell: Messages["shell"] = {
reconnecting: "Reconectando…",
},
loadFailed: "No se pudieron cargar tus equipos",
+ recycleFailed: (path: string) => `La carpeta ${path} no fue a la Papelera y sigue allí`,
pickCrew: "Elige un equipo a la izquierda, o crea uno con el botón +.",
botsCantStart: "Los bots no pueden iniciar",
signIn: {
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index c798b426..7a82b859 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -87,6 +87,16 @@ export const account: Messages["account"] = {
size: "Tamanho",
sizeHint: "Ctrl+= e Ctrl+- também mudam o tamanho, e Ctrl+0 volta ao padrão.",
language: "Idioma",
+ archived: "Arquivados",
+ archivedIntro:
+ "Os bots e as equipes que você arquivou estão parados e fora da vista, e o Botloft ainda guarda as conversas deles. Exclua um para tirá-lo de vez.",
+ archivedEmpty: "Nada está arquivado.",
+ archivedLoadFailed: "Não foi possível carregar o que está arquivado",
+ archivedCrew: (bots: number, when: string) =>
+ `Equipe · ${bots === 1 ? "1 bot" : `${bots} bots`} · arquivada ${when}`,
+ archivedBot: (crew: string, when: string) => `Bot de ${crew} · arquivado ${when}`,
+ deleteArchived: "Excluir",
+ deleteArchivedOne: (name: string) => `Excluir ${name}`,
about: "Sobre",
claudeCode: (version: string) => `Claude Code ${version}`,
botloft: (version: string) => `Botloft ${version}`,
diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts
index add69c70..8469dc91 100644
--- a/app/src/i18n/pt-BR/bots.ts
+++ b/app/src/i18n/pt-BR/bots.ts
@@ -44,6 +44,7 @@ export const bots: Messages["bots"] = {
makeChief: "Tornar chefe da equipe",
stopChief: "Deixar de ser chefe",
archive: "Arquivar bot",
+ delete: "Excluir bot",
failed: {
pause: "Não foi possível pausar o bot",
resume: "Não foi possível retomar o bot",
@@ -51,6 +52,7 @@ export const bots: Messages["bots"] = {
openFolder: "Não foi possível abrir a pasta",
chief: "Não foi possível trocar o chefe",
archive: "Não foi possível arquivar o bot",
+ delete: "Não foi possível excluir o bot",
},
fresh: {
title: "Começar uma nova conversa?",
@@ -63,6 +65,18 @@ export const bots: Messages["bots"] = {
confirm: "Arquivar bot",
body: "O bot para e sai da equipe. As mensagens que ainda esperam por ele não são entregues.",
},
+ deleteConfirm: {
+ title: (name: string) => `Excluir ${name}?`,
+ confirm: "Excluir bot",
+ removed: (name: string, running: boolean) =>
+ `${name} ${running ? "para agora e sai" : "sai"} do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte. Não dá para desfazer.`,
+ chief: (crew: string) => `${crew} fica sem chefe.`,
+ kept: (name: string) =>
+ `A pasta de ${name} continua no seu computador, com tudo o que está nela:`,
+ recycle: "Mandar esta pasta para a Lixeira",
+ recycled: (name: string) =>
+ `A pasta de ${name} vai para a Lixeira, de onde ainda dá para recuperar:`,
+ },
},
dialog: {
newTitle: "Novo bot",
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index 62d4a9d1..47bc0c53 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -44,7 +44,7 @@ export const chat: Messages["chat"] = {
inbound: {
task: "Tarefa",
result: "Resultado",
- archivedBot: "Um bot arquivado",
+ goneBot: "Um bot que não está mais na equipe",
},
run: {
working: "Trabalhando",
@@ -65,11 +65,16 @@ export const chat: Messages["chat"] = {
input: "Entrada",
output: "Saída",
error: "Erro",
+ command: "Comando",
+ commandCut: "Este comando é longo demais para aparecer inteiro.",
},
approval: {
asks: (bot: string, action: string) => `${bot} pede para ${action}`,
wants: (bot: string, action: string) => `${bot} quer ${action}`,
- fullInput: "Entrada completa",
+ explainedBy: (bot: string) => `${bot} escreveu isto. O que roda de verdade é o comando abaixo.`,
+ unexplained: (bot: string) =>
+ `${bot} não disse para que serve este comando. Na dúvida, negue e pergunte.`,
+ command: "Ver o comando",
noteLabel: (bot: string) => `Observação para ${bot} se você negar`,
notePlaceholder: "Por que não? Vai para o bot se você negar (opcional)",
allow: "Permitir",
@@ -139,6 +144,59 @@ export const chat: Messages["chat"] = {
later: (bot: string, model: string) =>
`${bot} muda para ${model} quando terminar o que está fazendo.`,
},
+ effort: {
+ title: "Esforço",
+ button: (level: string) => `Esforço: ${level}`,
+ short: "Esforço",
+ faster: "Mais rápido",
+ smarter: "Mais inteligente",
+ names: {
+ low: "Baixo",
+ medium: "Médio",
+ high: "Alto",
+ xhigh: "Extra alto",
+ max: "Máximo",
+ },
+ hints: {
+ low: (bot: string) => `${bot} responde mais rápido e pensa menos, para tarefas simples`,
+ medium: (bot: string) => `${bot} equilibra rapidez e raciocínio, bom para quase tudo`,
+ high: (bot: string) => `${bot} pensa mais antes de responder, para tarefas mais difíceis`,
+ xhigh: (bot: string) => `${bot} pensa bem mais, para trabalho longo e complexo`,
+ max: (bot: string) =>
+ `${bot} pensa o máximo que pode: é o mais lento e o que mais gasta do seu plano`,
+ },
+ recommended: "Recomendado",
+ recommendedFor: (model: string) => `Recomendado para o ${model}`,
+ useRecommended: "Usar o recomendado",
+ unknown: (bot: string) => `${bot} usa o nível recomendado para o modelo dele`,
+ unavailable: "Indisponível",
+ none: (bot: string, model: string) =>
+ `${model} não tem níveis de esforço: ${bot} responde sempre no mesmo ritmo.`,
+ thisModel: "Este modelo",
+ cost: "Mais esforço gasta o limite do seu plano mais rápido.",
+ failed: "Não foi possível mudar o esforço",
+ later: (bot: string, level: string) =>
+ `${bot} muda para o esforço ${level} quando terminar o que está fazendo.`,
+ },
+ context: {
+ title: "Espaço da conversa",
+ button: (used: string, total: string, percent: number) =>
+ `Espaço da conversa: ${used} de ${total} em uso (${percent}%)`,
+ used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`,
+ about: (bot: string) =>
+ `Tudo o que ${bot} leu e escreveu nesta conversa ocupa espaço. Quanto mais cheia, mais do seu plano cada mensagem gasta.`,
+ autoLeft: (left: string, at: string) =>
+ `Faltam ${left} para ela se compactar sozinha, em ${at}.`,
+ autoNow: "Já está cheia o bastante para se compactar sozinha na próxima mensagem.",
+ noAuto: "Ela não se compacta sozinha.",
+ compact: "Compactar agora",
+ compactHint: (bot: string) =>
+ `Troca o que veio antes por um resumo: ${bot} volta a ter espaço e cada mensagem gasta menos.`,
+ compacting: "Compactando…",
+ later: (bot: string) => `${bot} compacta a conversa quando terminar o que está fazendo.`,
+ notRunning: (bot: string) => `${bot} não está rodando, então não dá para compactar agora.`,
+ failed: "Não foi possível compactar a conversa",
+ },
suggestion: {
title: (bot: string) => `${bot} sugere um bot novo`,
why: "Por quê",
@@ -181,5 +239,9 @@ export const chat: Messages["chat"] = {
modelUnavailable:
"O modelo deste bot não está disponível: talvez não faça parte do seu plano do Claude. Escolha outro modelo abaixo do chat.",
turnFailed: (detail: string) => `O bot não conseguiu terminar isto: ${detail}`,
+ compacted:
+ "A conversa foi compactada: o que veio antes agora é um resumo, e há espaço de novo.",
+ autoCompacted: "A conversa ficou cheia e foi compactada: o que veio antes agora é um resumo.",
+ compactFailed: (detail: string) => `Não foi possível compactar a conversa: ${detail}`,
},
};
diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts
index 526e27ed..36ea056a 100644
--- a/app/src/i18n/pt-BR/crews.ts
+++ b/app/src/i18n/pt-BR/crews.ts
@@ -47,6 +47,7 @@ export const crews: Messages["crews"] = {
`Os bots vão trabalhar em ${path}. Cada um reinicia quando terminar o que está fazendo. O que já foi feito fica onde está.`,
move: "Mudar",
archive: "Arquivar equipe",
+ delete: "Excluir equipe",
tabs: {
label: "Visões da equipe",
bots: "Bots",
@@ -60,12 +61,25 @@ export const crews: Messages["crews"] = {
bots === 0
? "A equipe sai do app."
: `A equipe sai do app. ${bots === 1 ? "O bot dela para" : `Os ${bots} bots dela param`}, e as mensagens que ainda esperam por ${bots === 1 ? "ele" : "eles"} não são entregues.`,
+ deleteTitle: (crew: string) => `Excluir ${crew}?`,
+ deleteBody: (crew: string, bots: number, running: boolean) =>
+ bots === 0
+ ? `${crew} sai do Botloft de vez. Não dá para desfazer.`
+ : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} ${running ? "param agora e saem" : "saem"} do Botloft de vez, com as conversas, as rotinas e as tarefas. Não dá para desfazer.`,
+ deleteKept:
+ "As pastas continuam no seu computador, com tudo o que está nelas: a pasta de cada bot e a pasta de trabalho da equipe:",
+ deleteRecycle: "Mandar as pastas da equipe para a Lixeira",
+ deleteRecycled:
+ "A pasta da equipe vai para a Lixeira, com a pasta de cada bot e a pasta de trabalho dentro. Ainda dá para recuperar de lá:",
+ deleteRecycledChosen:
+ "A pasta de cada bot vai para a Lixeira, de onde ainda dá para recuperar. A pasta de trabalho que você escolheu fica onde está:",
failed: {
pause: "Não foi possível pausar a equipe",
resume: "Não foi possível retomar a equipe",
changeFolder: "Não foi possível mudar a pasta",
openFolder: "Não foi possível abrir a pasta",
archive: "Não foi possível arquivar a equipe",
+ delete: "Não foi possível excluir a equipe",
},
},
bots: {
diff --git a/app/src/i18n/pt-BR/messages.ts b/app/src/i18n/pt-BR/messages.ts
index b48332d5..7b73e49d 100644
--- a/app/src/i18n/pt-BR/messages.ts
+++ b/app/src/i18n/pt-BR/messages.ts
@@ -32,7 +32,7 @@ export const messages: Messages["messages"] = {
row: {
you: "Você",
system: "Botloft",
- archivedBot: "um bot arquivado",
+ goneBot: "um bot que não está mais na equipe",
to: "para",
task: "Tarefa",
result: "Resultado",
diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts
index bb82a25a..e95ed305 100644
--- a/app/src/i18n/pt-BR/shell.ts
+++ b/app/src/i18n/pt-BR/shell.ts
@@ -20,6 +20,7 @@ export const shell: Messages["shell"] = {
reconnecting: "Reconectando…",
},
loadFailed: "Não foi possível carregar suas equipes",
+ recycleFailed: (path: string) => `A pasta ${path} não foi para a Lixeira e continua lá`,
pickCrew: "Escolha uma equipe à esquerda, ou crie uma com o botão +.",
botsCantStart: "Os bots não conseguem iniciar",
signIn: {
diff --git a/app/src/index.css b/app/src/index.css
index 0cce2368..05d1659d 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -8,6 +8,7 @@
@import "./mascot.css";
@import "./mascot-flame.css";
@import "./panels.css";
+@import "./effort.css";
@import "./motion-less.css";
/* Colors are tokens so the light and dark themes swap in one place
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index 82e5f6ce..0f0b04d3 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -16,6 +16,7 @@ import {
type Bot,
type BotId,
type BotState,
+ type ContextUsage,
type Crew,
type CrewId,
PROTOCOL_VERSION,
@@ -58,6 +59,10 @@ export class FakeBotloft implements BotloftApi {
/** How many times the app asked for a new Claude Code check. */
refreshes = 0;
settings: Settings = { startWithWindows: true, keepAwake: true, approvalWaitMinutes: 60 };
+ /** Folders the owner sent to the Recycle Bin with a delete, in order. */
+ readonly recycled: string[] = [];
+ /** Why the next folder cannot go to the bin, if it cannot. */
+ recycleError: string | null = null;
/** Every call, in order. */
readonly calls: { method: Method; params: unknown }[] = [];
readonly chat = new FakeChat(this);
@@ -147,6 +152,12 @@ export class FakeBotloft implements BotloftApi {
this.emit({ name: "bot.state", params: { botId, state, generation: bot.generation } });
}
+ /** What Claude Code tells about the bot's conversation (spec 8.6). */
+ setContext(botId: BotId, context: ContextUsage | null): void {
+ this.bot(botId).context = context;
+ this.emit({ name: "bot.context", params: { botId, context } });
+ }
+
/** A new id with `prefix`, like the daemon's. */
id(prefix: string): string {
counter += 1;
@@ -183,6 +194,15 @@ export class FakeBotloft implements BotloftApi {
);
}
+ /** The Recycle Bin takes `path`, or the app hears why it did not. */
+ recycle(path: string): void {
+ const error = this.recycleError;
+ if (error === null) {
+ this.recycled.push(path);
+ }
+ this.emit({ name: "folder.recycled", params: { path, error } });
+ }
+
changedCrew(crew: Crew): Crew {
this.emit({ name: "crew.changed", params: crew });
return crew;
@@ -221,6 +241,10 @@ export class FakeBotloft implements BotloftApi {
};
return this.settings;
},
+ "archive.list": () => ({
+ crews: [...this.crews.values()].filter((crew) => crew.archivedAt !== null),
+ bots: [...this.bots.values()].filter((bot) => bot.archivedAt !== null),
+ }),
...crewHandlers(this),
...botHandlers(this),
...this.chat.handlers(),
diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts
index 6057818a..fc235685 100644
--- a/app/src/lib/fakeBots.ts
+++ b/app/src/lib/fakeBots.ts
@@ -4,7 +4,7 @@
import type { FakeBotloft, Handlers } from "./fake";
import { purgeBot } from "./fakeDelete";
-import { checkName } from "./fakeRules";
+import { checkName, conflict } from "./fakeRules";
import { AVATAR_PALETTE, type Bot } from "./protocol.gen";
type BotMethods = Extract;
@@ -35,6 +35,9 @@ export function botHandlers(fake: FakeBotloft): Pick {
permissionMode: "default",
model: model ?? "default",
modelInUse: null,
+ effort: "default",
+ effortDefault: null,
+ context: null,
state: crew.paused ? "offline" : "launching",
generation: crew.paused ? null : 1,
workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`,
@@ -68,9 +71,29 @@ export function botHandlers(fake: FakeBotloft): Pick {
},
"bots.setModel": ({ botId, model }) => {
const bot = fake.bot(botId);
- bot.model = model;
+ if (bot.model !== model) {
+ bot.model = model;
+ // What a model does by itself is asked again of the new one.
+ bot.effortDefault = null;
+ }
return fake.changedBot(bot);
},
+ "bots.setEffort": ({ botId, effort }) => {
+ const bot = fake.bot(botId);
+ bot.effort = effort;
+ return fake.changedBot(bot);
+ },
+ "bots.compact": ({ botId }) => {
+ const bot = fake.bot(botId);
+ if (bot.state !== "idle" && bot.state !== "busy" && bot.state !== "needs_approval") {
+ throw conflict(`bot ${botId} is not running`);
+ }
+ // It waits behind the turn in progress; a test ends it with `setContext`.
+ if (bot.context && !bot.context.compacting) {
+ fake.setContext(botId, { ...bot.context, compacting: true });
+ }
+ return bot;
+ },
"bots.restart": ({ botId }) => {
const bot = fake.bot(botId);
fake.setBotState(botId, "launching", (bot.generation ?? 0) + 1);
@@ -91,7 +114,7 @@ export function botHandlers(fake: FakeBotloft): Pick {
}
return bot;
},
- "bots.delete": ({ botId }) => {
+ "bots.delete": ({ botId, recycleFolder }) => {
const bot = fake.bot(botId, false);
purgeBot(fake, bot);
// A deleted chief leaves its crew without one.
@@ -102,6 +125,9 @@ export function botHandlers(fake: FakeBotloft): Pick {
}
const deleted = { botId, crewId: bot.crewId };
fake.emit({ name: "bot.deleted", params: deleted });
+ if (recycleFolder) {
+ fake.recycle(bot.workspace);
+ }
return deleted;
},
};
diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts
index 0db5c99b..ed35db71 100644
--- a/app/src/lib/fakeChat.ts
+++ b/app/src/lib/fakeChat.ts
@@ -38,15 +38,23 @@ export function activityLine(
break;
case "tool":
kind = "tool";
- text = body.summary;
+ text = body.explanation ?? body.summary;
tool = body.name;
break;
case "approval":
kind = "approval";
- text = body.summary;
+ text = body.explanation ?? body.summary;
tool = body.toolName;
break;
case "notice":
+ // A compaction is housekeeping, not news about the conversation.
+ if (
+ body.code === "compacted" ||
+ body.code === "auto_compacted" ||
+ body.code === "compact_failed"
+ ) {
+ return null;
+ }
kind = "notice";
text = body.text;
break;
@@ -93,6 +101,7 @@ export class FakeChat {
toolUseId: this.fake.id("toolu"),
name,
summary: "",
+ explanation: null,
input: "{}",
status: "running",
output: null,
@@ -113,8 +122,14 @@ export class FakeChat {
return this.add(botId, { kind: "turn", durationMs: 4200, costUsd: 0.0123, error });
}
- /** A permission request waiting for the owner. */
- ask(botId: BotId, toolName: string, summary: string, input = "{}"): ChatItem {
+ /** A permission request waiting for the owner; `explanation` is what the bot says a command is for. */
+ ask(
+ botId: BotId,
+ toolName: string,
+ summary: string,
+ input = "{}",
+ explanation: string | null = null,
+ ): ChatItem {
const approval: Approval = {
id: this.fake.id("apr"),
botId,
@@ -132,6 +147,7 @@ export class FakeChat {
approvalId: approval.id,
toolName,
summary,
+ explanation,
input,
status: "pending",
note: null,
diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts
index 549817cb..d71539f4 100644
--- a/app/src/lib/fakeCrews.ts
+++ b/app/src/lib/fakeCrews.ts
@@ -82,8 +82,8 @@ export function crewHandlers(fake: FakeBotloft): Pick {
}
return crew;
},
- "crews.delete": ({ crewId }) => {
- fake.crew(crewId, false);
+ "crews.delete": ({ crewId, recycleFolder }) => {
+ const crew = fake.crew(crewId, false);
for (const bot of [...fake.bots.values()]) {
if (bot.crewId === crewId) {
purgeBot(fake, bot);
@@ -91,6 +91,9 @@ export function crewHandlers(fake: FakeBotloft): Pick {
}
fake.crews.delete(crewId);
fake.emit({ name: "crew.deleted", params: { crewId } });
+ if (recycleFolder) {
+ fake.recycle(`C:\\Users\\owner\\Botloft\\${crew.slug}`);
+ }
return { crewId };
},
};
diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts
index b308d054..215f449b 100644
--- a/app/src/lib/format.ts
+++ b/app/src/lib/format.ts
@@ -76,6 +76,22 @@ export function fileSize(bytes: number): string {
return `${mb < 10 ? decimal(mb, 1) : Math.round(mb)} MB`;
}
+/** Tokens as the owner reads them: "850", "24.3k", "556k", "1M", "1.2M". */
+export function tokens(count: number): string {
+ if (count < 1000) {
+ return String(count);
+ }
+ const thousands = count / 1000;
+ if (thousands < 100) {
+ return `${decimal(thousands, thousands % 1 < 0.05 ? 0 : 1)}k`;
+ }
+ if (thousands < 999.5) {
+ return `${Math.round(thousands)}k`;
+ }
+ const millions = count / 1_000_000;
+ return `${decimal(millions, Math.abs(millions - Math.round(millions)) < 0.05 ? 0 : 1)}M`;
+}
+
/** "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 3b0b4bc3..05e75478 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -136,6 +136,57 @@ tool: string | null,
*/
at: number, };
+/**
+ * How much a bot thinks before it answers (spec 7.4): Claude Code's
+ * `--effort` levels, or the level its model uses by itself.
+ */
+export type BotEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
+
+/**
+ * The effort a model runs at when the owner picks none, as Claude Code
+ * reports it (spec 7.4).
+ */
+export type ModelEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max";
+
+/**
+ * How full a bot's conversation is, in tokens, as Claude Code counts it
+ * (spec 8.6).
+ */
+export type ContextUsage = {
+/**
+ * What the conversation holds now.
+ */
+usedTokens: number,
+/**
+ * The most it can hold.
+ */
+windowTokens: number,
+/**
+ * Where Claude Code compacts the conversation by itself; `null` when it
+ * does not.
+ */
+autoCompactTokens: number | null,
+/**
+ * The conversation is being compacted right now.
+ */
+compacting: boolean,
+/**
+ * Unix time in milliseconds.
+ */
+updatedAt: number, };
+
+/**
+ * Params of the `bot.context` notification.
+ */
+export type BotContextChanged = { botId: BotId,
+/**
+ * `null` when the bot began a new conversation and its size is not
+ * known yet.
+ */
+context: ContextUsage | null, };
+
+export type BotsSetEffortParams = { botId: BotId, effort: BotEffort, };
+
/**
* A persistent Claude Code session with a name, a role and instructions.
*/
@@ -157,7 +208,16 @@ color: string, paused: boolean, permissionMode: PermissionMode, model: BotModel,
* The model id Claude Code reported when the bot last started a turn
* (`claude-opus-5-5`); `null` before its first turn.
*/
-modelInUse: string | null, state: BotState,
+modelInUse: string | null, effort: BotEffort,
+/**
+ * The effort the bot's model uses by itself, as Claude Code last
+ * reported it; `null` until it is known.
+ */
+effortDefault: ModelEffort | null,
+/**
+ * How full the conversation is; `null` until Claude Code said so.
+ */
+context: ContextUsage | null, state: BotState,
/**
* Current process generation; `null` if the bot has not started since
* the daemon did. Changes on every (re)start.
@@ -372,6 +432,47 @@ export type BotDeleted = { botId: BotId, crewId: CrewId, };
*/
export type CrewDeleted = { crewId: CrewId, };
+/**
+ * What the owner archived (`archive.list`): out of the app, kept in the
+ * database, and still there to delete (spec 7.6). `bots` has every
+ * archived bot, those of archived crews too.
+ */
+export type Archive = { crews: Array, bots: Array, };
+
+/**
+ * Params of `bots.delete` (spec 7.6).
+ */
+export type BotsDeleteParams = { botId: BotId,
+/**
+ * Also move the bot's folder to the Recycle Bin; it stays when absent.
+ */
+recycleFolder?: boolean, };
+
+/**
+ * Params of `crews.delete` (spec 7.6).
+ */
+export type CrewsDeleteParams = { crewId: CrewId,
+/**
+ * Also move the crew's own folder, with each bot's folder and
+ * `shared`, to the Recycle Bin; it stays when absent. A work folder
+ * the owner chose is never moved.
+ */
+recycleFolder?: boolean, };
+
+/**
+ * Params of the `folder.recycled` notification: how the move of a deleted
+ * bot's or crew's folder to the Recycle Bin ended (spec 7.6).
+ */
+export type FolderRecycled = {
+/**
+ * Absolute path the folder had.
+ */
+path: string,
+/**
+ * Why the folder is still there; `null` when it is in the bin.
+ */
+error: string | null, };
+
/**
* Who wrote a message.
*/
@@ -604,7 +705,7 @@ export type NoticeLevel = "info" | "warning" | "error";
/**
* What a notice is about, so the app can say it in the owner's language.
*/
-export type NoticeCode = "signed_out" | "usage_limit" | "turn_failed" | "model_unavailable";
+export type NoticeCode = "signed_out" | "usage_limit" | "turn_failed" | "model_unavailable" | "compacted" | "auto_compacted" | "compact_failed";
export type InboundItem = { message: Message, };
@@ -615,6 +716,11 @@ export type ToolItem = { toolUseId: string, name: string,
* One line about what the call does, e.g. the command or the file.
*/
summary: string,
+/**
+ * What the bot says a command is for, in its own words (spec 10.1);
+ * absent when it said nothing, for other tools and for older items.
+ */
+explanation: string | null,
/**
* The call's input as JSON, cut at 4 KB.
*/
@@ -629,7 +735,13 @@ output: string | null,
*/
file: string | null, };
-export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, input: string, status: ApprovalStatus,
+export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
+/**
+ * What the bot says the command is for, as in `ToolItem`. The bot
+ * wrote it: it goes before the command, never in its place. Kept only
+ * here, not in the `Approval`.
+ */
+explanation: string | null, input: string, status: ApprovalStatus,
/**
* What the owner wrote with a denial.
*/
@@ -1038,7 +1150,8 @@ export interface RpcMethods {
"crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew };
"crews.setLead": { params: CrewsSetLeadParams; result: Crew };
"crews.archive": { params: CrewIdParams; result: Crew };
- "crews.delete": { params: CrewIdParams; result: CrewDeleted };
+ "crews.delete": { params: CrewsDeleteParams; result: CrewDeleted };
+ "archive.list": { params: undefined; result: Archive };
"bots.list": { params: BotsListParams; result: Array };
"bots.create": { params: BotsCreateParams; result: Bot };
"bots.update": { params: BotsUpdateParams; result: Bot };
@@ -1046,7 +1159,7 @@ export interface RpcMethods {
"bots.setPermissionMode": { params: BotsSetPermissionModeParams; result: Bot };
"bots.setModel": { params: BotsSetModelParams; result: Bot };
"bots.archive": { params: BotIdParams; result: Bot };
- "bots.delete": { params: BotIdParams; result: BotDeleted };
+ "bots.delete": { params: BotsDeleteParams; result: BotDeleted };
"bots.restart": { params: BotsRestartParams; result: Bot };
"chat.history": { params: ChatHistoryParams; result: Array };
"approvals.answer": { params: ApprovalsAnswerParams; result: Approval };
@@ -1058,6 +1171,8 @@ export interface RpcMethods {
"deliveries.list": { params: DeliveriesListParams; result: Array };
"deliveries.retry": { params: DeliveryIdParams; result: Delivery };
"tasks.list": { params: TasksListParams; result: Array };
+ "bots.setEffort": { params: BotsSetEffortParams; result: Bot };
+ "bots.compact": { params: BotIdParams; result: Bot };
"routines.list": { params: RoutinesListParams; result: Array };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
@@ -1085,12 +1200,14 @@ export interface RpcNotifications {
"bot.changed": Bot;
"crew.deleted": CrewDeleted;
"bot.deleted": BotDeleted;
+ "folder.recycled": FolderRecycled;
"bot.state": BotStateChanged;
"chat.item": ChatItemChanged;
"chat.delta": ChatDelta;
"message.created": Message;
"delivery.changed": Delivery;
"task.changed": Task;
+ "bot.context": BotContextChanged;
"routine.changed": Routine;
"routine.run": RoutineRun;
"browser.changed": BrowserState;
diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx
index 80447e4c..1dcf878d 100644
--- a/app/src/shell/Workspace.tsx
+++ b/app/src/shell/Workspace.tsx
@@ -17,6 +17,7 @@ import { useApp } from "../store/context";
import { Callout } from "../ui/Callout";
import { useBotAlerts } from "./alerts";
import { useAttentionMark } from "./attention";
+import { useFolderNotices } from "./folders";
import { useOpenAtSignIn } from "./signIn";
import { TitleBar } from "./TitleBar";
import { useTray } from "./tray";
@@ -31,6 +32,7 @@ export function Workspace() {
useAttentionMark();
useTray();
useBotAlerts();
+ useFolderNotices();
useOpenAtSignIn();
let main: ReactNode;
diff --git a/app/src/shell/folders.ts b/app/src/shell/folders.ts
new file mode 100644
index 00000000..28bc214c
--- /dev/null
+++ b/app/src/shell/folders.ts
@@ -0,0 +1,21 @@
+// Telling the owner when the folder of a deleted bot or crew could not go
+// to the Recycle Bin (spec 7.6): the daemon moves it after answering, so
+// the news comes as a notification.
+
+import { useEffect } from "react";
+import { t } from "../i18n";
+import { useApi } from "../store/context";
+import { notifyError } from "../ui/toast";
+
+export function useFolderNotices(): void {
+ const api = useApi();
+ useEffect(
+ () =>
+ api.subscribe((event) => {
+ if (event.name === "folder.recycled" && event.params.error !== null) {
+ notifyError(t().shell.recycleFailed(event.params.path), event.params.error);
+ }
+ }),
+ [api],
+ );
+}
diff --git a/app/src/store/app.ts b/app/src/store/app.ts
index d2b634df..8c9ac349 100644
--- a/app/src/store/app.ts
+++ b/app/src/store/app.ts
@@ -222,6 +222,13 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial
+
- {t.common.details}
+ {label ?? t.common.details}
{children}
diff --git a/app/src/ui/KeptFolder.tsx b/app/src/ui/KeptFolder.tsx
new file mode 100644
index 00000000..e6ed6ecc
--- /dev/null
+++ b/app/src/ui/KeptFolder.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+
+/**
+ * In a confirmation: what happens to a folder on disk, and where it is.
+ * `recycle` adds the choice to send it to the Recycle Bin.
+ */
+export function KeptFolder({
+ path,
+ children,
+ recycle,
+}: {
+ path: string;
+ children: ReactNode;
+ recycle?: { label: string; checked: boolean; onChange(checked: boolean): void };
+}) {
+ return (
+
+
{children}
+
+ {path}
+
+ {recycle && (
+
+ )}
+
+ );
+}
diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs
index cbfd3e32..23e335f4 100644
--- a/crates/botloft-core/src/chat.rs
+++ b/crates/botloft-core/src/chat.rs
@@ -3,7 +3,8 @@
use serde_json::Value;
-use crate::protocol::{Activity, ActivityKind, ChatBody, SenderKind};
+use crate::command::{COMMAND_INPUT_MAX, is_command};
+use crate::protocol::{Activity, ActivityKind, ChatBody, NoticeCode, SenderKind};
/// Longest tool input kept, in bytes.
pub const TOOL_INPUT_MAX: usize = 4 * 1024;
@@ -57,6 +58,7 @@ pub fn tool_input_max(tool: &str) -> usize {
match tool {
PLAN_TOOL => PLAN_INPUT_MAX,
SUGGEST_TOOL => SUGGESTION_INPUT_MAX,
+ tool if is_command(tool) => COMMAND_INPUT_MAX,
_ => TOOL_INPUT_MAX,
}
}
@@ -74,9 +76,8 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
.map(file_name)
};
let summary = match name {
- "Bash" | "PowerShell" => field(input, "description")
- .map(str::to_owned)
- .or_else(|| field(input, "command").map(str::to_owned)),
+ // The command itself; what the bot says about it is the explanation.
+ "Bash" | "PowerShell" => field(input, "command").map(str::to_owned),
"Read" | "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => path(),
"Grep" | "Glob" => field(input, "pattern").map(str::to_owned),
"WebFetch" => field(input, "url").map(str::to_owned),
@@ -147,8 +148,24 @@ pub fn activity(body: &ChatBody, at: i64) -> Option {
_ => (ActivityKind::Message, item.message.body.clone()),
},
ChatBody::Reply(item) => (ActivityKind::Reply, item.text.clone()),
- ChatBody::Tool(item) => (ActivityKind::Tool, item.summary.clone()),
- ChatBody::Approval(item) => (ActivityKind::Approval, item.summary.clone()),
+ // A command reads better in the bot's words, when it gave them.
+ ChatBody::Tool(item) => (
+ ActivityKind::Tool,
+ item.explanation.as_ref().unwrap_or(&item.summary).clone(),
+ ),
+ ChatBody::Approval(item) => (
+ ActivityKind::Approval,
+ item.explanation.as_ref().unwrap_or(&item.summary).clone(),
+ ),
+ // A compaction is housekeeping, not news about the conversation.
+ ChatBody::Notice(item)
+ if matches!(
+ item.code,
+ Some(NoticeCode::Compacted | NoticeCode::AutoCompacted | NoticeCode::CompactFailed)
+ ) =>
+ {
+ return None;
+ }
ChatBody::Notice(item) => (ActivityKind::Notice, item.text.clone()),
ChatBody::Turn(_) => return None,
};
@@ -188,10 +205,10 @@ mod tests {
"Bash",
&json!({ "command": "npm test", "description": "Run the tests" })
),
- "Run the tests"
+ "npm test"
);
assert_eq!(
- tool_summary("Bash", &json!({ "command": "ls\n-la" })),
+ tool_summary("PowerShell", &json!({ "command": "ls\n-la" })),
"ls -la"
);
assert_eq!(
@@ -254,10 +271,29 @@ mod tests {
),
"Move the cache"
);
+ assert_eq!(tool_input_max("Bash"), COMMAND_INPUT_MAX);
+ assert_eq!(tool_input_max("Read"), TOOL_INPUT_MAX);
assert_eq!(tool_label("mcp__botloft__send_message"), "send_message");
assert_eq!(tool_label("Bash"), "Bash");
}
+ #[test]
+ fn a_compaction_does_not_change_the_conversation_list_line() {
+ use crate::protocol::{NoticeItem, NoticeLevel};
+ let notice = |code| {
+ ChatBody::Notice(NoticeItem {
+ level: NoticeLevel::Info,
+ code: Some(code),
+ text: "The conversation was compacted.".into(),
+ })
+ };
+ assert_eq!(activity(¬ice(NoticeCode::Compacted), 1), None);
+ assert_eq!(activity(¬ice(NoticeCode::AutoCompacted), 1), None);
+ assert_eq!(activity(¬ice(NoticeCode::CompactFailed), 1), None);
+ let limit = activity(¬ice(NoticeCode::UsageLimit), 1).expect("a line");
+ assert_eq!(limit.kind, ActivityKind::Notice);
+ }
+
#[test]
fn long_lines_are_flattened_and_cut() {
let text = format!("first line\nsecond {}", "x".repeat(300));
diff --git a/crates/botloft-core/src/command.rs b/crates/botloft-core/src/command.rs
new file mode 100644
index 00000000..c6d96dfc
--- /dev/null
+++ b/crates/botloft-core/src/command.rs
@@ -0,0 +1,88 @@
+//! Shell commands a bot runs (`Bash`, `PowerShell`): the one kind of tool
+//! call whose input carries the bot's own words about it, which the owner
+//! reads before allowing the command (spec 10.1).
+
+use serde_json::Value;
+
+use crate::chat::one_line;
+
+/// Longest command input kept, in bytes: the owner can read all of what
+/// they allow, as with a plan.
+pub const COMMAND_INPUT_MAX: usize = 32 * 1024;
+/// Longest explanation, in characters.
+const EXPLANATION_MAX_CHARS: usize = 300;
+
+/// Whether `tool` runs a shell command.
+pub fn is_command(tool: &str) -> bool {
+ matches!(tool, "Bash" | "PowerShell")
+}
+
+/// What the bot says a command is for: the `description` Claude Code's
+/// shell tools take next to `command`, on one line. The model writes it and
+/// may leave it out, and one that only repeats the command explains nothing.
+pub fn tool_explanation(tool: &str, input: &Value) -> Option {
+ if !is_command(tool) {
+ return None;
+ }
+ let flat = |name: &str| {
+ let text = input.get(name).and_then(Value::as_str)?;
+ Some(text.split_whitespace().collect::>().join(" "))
+ };
+ let said = flat("description").filter(|said| !said.is_empty())?;
+ if flat("command").as_deref() == Some(said.as_str()) {
+ return None;
+ }
+ Some(one_line(&said, EXPLANATION_MAX_CHARS))
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+
+ #[test]
+ fn the_explanation_is_what_the_bot_wrote_about_a_command() {
+ let input = json!({ "command": "pip install requests", "description": "Instala o\npacote requests" });
+ assert_eq!(
+ tool_explanation("Bash", &input).as_deref(),
+ Some("Instala o pacote requests")
+ );
+ assert_eq!(
+ tool_explanation("PowerShell", &input).as_deref(),
+ Some("Instala o pacote requests")
+ );
+ }
+
+ #[test]
+ fn a_command_without_one_has_none() {
+ assert_eq!(
+ tool_explanation("Bash", &json!({ "command": "make" })),
+ None
+ );
+ let blank = json!({ "command": "make", "description": " \n" });
+ assert_eq!(tool_explanation("Bash", &blank), None);
+ let not_text = json!({ "command": "make", "description": 3 });
+ assert_eq!(tool_explanation("Bash", ¬_text), None);
+ // Repeating the command is not explaining it.
+ let echo = json!({ "command": "npm run build", "description": "npm run build" });
+ assert_eq!(tool_explanation("Bash", &echo), None);
+ }
+
+ #[test]
+ fn other_tools_have_none() {
+ // A helper's `description` is its title, already the summary.
+ let helper = json!({ "description": "Find the endpoints", "prompt": "..." });
+ assert_eq!(tool_explanation("Agent", &helper), None);
+ let file = json!({ "file_path": "a.md", "description": "x" });
+ assert_eq!(tool_explanation("Write", &file), None);
+ }
+
+ #[test]
+ fn a_long_one_is_cut() {
+ let input = json!({ "command": "make", "description": "a".repeat(500) });
+ let said = tool_explanation("Bash", &input).expect("explanation");
+ assert_eq!(said.chars().count(), EXPLANATION_MAX_CHARS);
+ assert!(said.ends_with('…'));
+ }
+}
diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs
index d29b5529..70d16923 100644
--- a/crates/botloft-core/src/lib.rs
+++ b/crates/botloft-core/src/lib.rs
@@ -3,6 +3,7 @@
pub mod avatar;
pub mod chat;
+pub mod command;
pub mod envelope;
pub mod ids;
pub mod protocol;
diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs
index 97d55450..48abe9c0 100644
--- a/crates/botloft-core/src/protocol/chat.rs
+++ b/crates/botloft-core/src/protocol/chat.rs
@@ -44,6 +44,12 @@ text_enum!(
TurnFailed => "turn_failed",
/// The bot's model does not exist or the account cannot use it.
ModelUnavailable => "model_unavailable",
+ /// The conversation was compacted because the owner asked.
+ Compacted => "compacted",
+ /// Claude Code compacted the conversation by itself: it was full.
+ AutoCompacted => "auto_compacted",
+ /// The conversation could not be compacted; `text` holds the detail.
+ CompactFailed => "compact_failed",
}
);
@@ -116,6 +122,10 @@ pub struct ToolItem {
pub name: String,
/// One line about what the call does, e.g. the command or the file.
pub summary: String,
+ /// What the bot says a command is for, in its own words (spec 10.1);
+ /// absent when it said nothing, for other tools and for older items.
+ #[serde(default)]
+ pub explanation: Option,
/// The call's input as JSON, cut at 4 KB.
pub input: String,
pub status: ToolStatus,
@@ -134,6 +144,11 @@ pub struct ApprovalItem {
pub approval_id: ApprovalId,
pub tool_name: String,
pub summary: String,
+ /// What the bot says the command is for, as in `ToolItem`. The bot
+ /// wrote it: it goes before the command, never in its place. Kept only
+ /// here, not in the `Approval`.
+ #[serde(default)]
+ pub explanation: Option,
pub input: String,
pub status: ApprovalStatus,
/// What the owner wrote with a denial.
@@ -264,14 +279,17 @@ mod tests {
tool_use_id: "toolu_1".into(),
name: "Bash".into(),
summary: "npm test".into(),
+ explanation: None,
input: "{}".into(),
status: ToolStatus::Running,
output: None,
file: None,
});
- let json = serde_json::to_value(&tool).expect("serialize");
+ let mut json = serde_json::to_value(&tool).expect("serialize");
assert_eq!(json["kind"], "tool");
assert_eq!(json["toolUseId"], "toolu_1");
+ // Items stored before a field existed still read.
+ json.as_object_mut().expect("object").remove("explanation");
assert_eq!(serde_json::from_value::(json).ok(), Some(tool));
}
}
diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs
index c7a302a7..ab9d8176 100644
--- a/crates/botloft-core/src/protocol/export.rs
+++ b/crates/botloft-core/src/protocol/export.rs
@@ -18,6 +18,8 @@ use crate::ids::{
mod browser;
#[path = "export_routines.rs"]
mod routines;
+#[path = "export_session.rs"]
+mod session;
use crate::validate;
const HEADER: &str = "\
@@ -99,6 +101,7 @@ fn export_bindings() {
out.decl::();
out.decl::();
out.decl::();
+ session::decls(&mut out);
out.decl::();
out.decl::();
out.decl::();
@@ -127,6 +130,10 @@ fn export_bindings() {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -229,9 +236,10 @@ fn export_bindings() {
let bot_deleted = out.name::();
out.method(
method::CREWS_DELETE,
- &out.name::(),
+ &out.name::(),
&crew_deleted,
);
+ out.method(method::ARCHIVE_LIST, "undefined", &out.name::());
out.method(method::BOTS_LIST, &out.name::(), &bots);
out.method(method::BOTS_CREATE, &out.name::(), &bot);
out.method(method::BOTS_UPDATE, &out.name::(), &bot);
@@ -253,7 +261,7 @@ fn export_bindings() {
out.method(method::BOTS_ARCHIVE, &out.name::(), &bot);
out.method(
method::BOTS_DELETE,
- &out.name::(),
+ &out.name::(),
&bot_deleted,
);
out.method(method::BOTS_RESTART, &out.name::(), &bot);
@@ -309,6 +317,7 @@ fn export_bindings() {
&out.name::(),
&out.name::>(),
);
+ session::methods(&mut out);
routines::methods(&mut out);
browser::methods(&mut out);
out.text.push_str("}\n\n");
@@ -328,6 +337,12 @@ fn export_bindings() {
" \"{}\": {bot_deleted};",
notification::BOT_DELETED
);
+ let _ = writeln!(
+ out.text,
+ " \"{}\": {};",
+ notification::FOLDER_RECYCLED,
+ out.name::()
+ );
let state = out.name::();
let item = out.name::();
let delta = out.name::();
@@ -346,6 +361,7 @@ fn export_bindings() {
notification::DELIVERY_CHANGED
);
let _ = writeln!(out.text, " \"{}\": {task};", notification::TASK_CHANGED);
+ session::notifications(&mut out);
routines::notifications(&mut out);
browser::notifications(&mut out);
out.text.push_str("}\n\n");
diff --git a/crates/botloft-core/src/protocol/export_session.rs b/crates/botloft-core/src/protocol/export_session.rs
new file mode 100644
index 00000000..186ddf5b
--- /dev/null
+++ b/crates/botloft-core/src/protocol/export_session.rs
@@ -0,0 +1,30 @@
+//! The effort and context part of the TypeScript bindings (spec 7.4, 8.6).
+
+use std::fmt::Write as _;
+
+use super::super::*;
+use super::Out;
+
+/// Before `Bot`, which holds them.
+pub(super) fn decls(out: &mut Out) {
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+}
+
+pub(super) fn methods(out: &mut Out) {
+ let bot = out.name::();
+ out.method(
+ method::BOTS_SET_EFFORT,
+ &out.name::(),
+ &bot,
+ );
+ out.method(method::BOTS_COMPACT, &out.name::(), &bot);
+}
+
+pub(super) fn notifications(out: &mut Out) {
+ let context = out.name::();
+ let _ = writeln!(out.text, " \"{}\": {context};", notification::BOT_CONTEXT);
+}
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index c487e107..8505c334 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -60,6 +60,7 @@ mod methods;
mod model;
mod routines;
mod screens;
+mod session;
mod settings;
pub use browser::*;
@@ -71,6 +72,7 @@ pub use methods::*;
pub use model::*;
pub use routines::*;
pub use screens::*;
+pub use session::*;
pub use settings::*;
/// Version negotiated in `session.hello`. 2 replaced the terminal with the
@@ -90,12 +92,15 @@ pub mod method {
pub const CREWS_SET_LEAD: &str = "crews.setLead";
pub const CREWS_ARCHIVE: &str = "crews.archive";
pub const CREWS_DELETE: &str = "crews.delete";
+ pub const ARCHIVE_LIST: &str = "archive.list";
pub const BOTS_LIST: &str = "bots.list";
pub const BOTS_CREATE: &str = "bots.create";
pub const BOTS_UPDATE: &str = "bots.update";
pub const BOTS_SET_PAUSED: &str = "bots.setPaused";
pub const BOTS_SET_PERMISSION_MODE: &str = "bots.setPermissionMode";
pub const BOTS_SET_MODEL: &str = "bots.setModel";
+ pub const BOTS_SET_EFFORT: &str = "bots.setEffort";
+ pub const BOTS_COMPACT: &str = "bots.compact";
pub const BOTS_ARCHIVE: &str = "bots.archive";
pub const BOTS_DELETE: &str = "bots.delete";
pub const BOTS_RESTART: &str = "bots.restart";
@@ -138,7 +143,9 @@ pub mod notification {
pub const CREW_DELETED: &str = "crew.deleted";
pub const BOT_CHANGED: &str = "bot.changed";
pub const BOT_DELETED: &str = "bot.deleted";
+ pub const FOLDER_RECYCLED: &str = "folder.recycled";
pub const BOT_STATE: &str = "bot.state";
+ pub const BOT_CONTEXT: &str = "bot.context";
pub const CHAT_ITEM: &str = "chat.item";
pub const CHAT_DELTA: &str = "chat.delta";
pub const MESSAGE_CREATED: &str = "message.created";
diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs
index 169ca45f..873ad028 100644
--- a/crates/botloft-core/src/protocol/model.rs
+++ b/crates/botloft-core/src/protocol/model.rs
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
-use super::Activity;
+use super::{Activity, BotEffort, ContextUsage, ModelEffort};
use crate::ids::{BotId, CrewId};
/// A group of bots that can message each other and share a folder.
@@ -137,6 +137,12 @@ pub struct Bot {
/// The model id Claude Code reported when the bot last started a turn
/// (`claude-opus-5-5`); `null` before its first turn.
pub model_in_use: Option,
+ pub effort: BotEffort,
+ /// The effort the bot's model uses by itself, as Claude Code last
+ /// reported it; `null` until it is known.
+ pub effort_default: Option,
+ /// How full the conversation is; `null` until Claude Code said so.
+ pub context: Option,
pub state: BotState,
/// Current process generation; `null` if the bot has not started since
/// the daemon did. Changes on every (re)start.
@@ -179,3 +185,52 @@ pub struct BotDeleted {
pub struct CrewDeleted {
pub crew_id: CrewId,
}
+
+/// What the owner archived (`archive.list`): out of the app, kept in the
+/// database, and still there to delete (spec 7.6). `bots` has every
+/// archived bot, those of archived crews too.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct Archive {
+ pub crews: Vec,
+ pub bots: Vec,
+}
+
+/// Params of `bots.delete` (spec 7.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BotsDeleteParams {
+ pub bot_id: BotId,
+ /// Also move the bot's folder to the Recycle Bin; it stays when absent.
+ #[serde(default)]
+ #[cfg_attr(test, ts(optional))]
+ pub recycle_folder: Option,
+}
+
+/// Params of `crews.delete` (spec 7.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct CrewsDeleteParams {
+ pub crew_id: CrewId,
+ /// Also move the crew's own folder, with each bot's folder and
+ /// `shared`, to the Recycle Bin; it stays when absent. A work folder
+ /// the owner chose is never moved.
+ #[serde(default)]
+ #[cfg_attr(test, ts(optional))]
+ pub recycle_folder: Option,
+}
+
+/// Params of the `folder.recycled` notification: how the move of a deleted
+/// bot's or crew's folder to the Recycle Bin ended (spec 7.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct FolderRecycled {
+ /// Absolute path the folder had.
+ pub path: String,
+ /// Why the folder is still there; `null` when it is in the bin.
+ pub error: Option,
+}
diff --git a/crates/botloft-core/src/protocol/session.rs b/crates/botloft-core/src/protocol/session.rs
new file mode 100644
index 00000000..4c703190
--- /dev/null
+++ b/crates/botloft-core/src/protocol/session.rs
@@ -0,0 +1,102 @@
+//! What a bot's Claude Code session runs with and how full it is: the
+//! effort level (spec 7.4) and the context in use (spec 8.6).
+
+use serde::{Deserialize, Serialize};
+
+use crate::ids::BotId;
+
+text_enum!(
+ /// How much a bot thinks before it answers (spec 7.4): Claude Code's
+ /// `--effort` levels, or the level its model uses by itself.
+ BotEffort, "effort" {
+ /// No `--effort`: the level Claude Code uses for the bot's model.
+ Default => "default",
+ Low => "low",
+ Medium => "medium",
+ High => "high",
+ Xhigh => "xhigh",
+ Max => "max",
+ }
+);
+
+impl BotEffort {
+ /// The value of Claude Code's `--effort`; `None` leaves the flag out.
+ pub fn cli_value(self) -> Option<&'static str> {
+ match self {
+ Self::Default => None,
+ level => Some(level.as_str()),
+ }
+ }
+}
+
+text_enum!(
+ /// The effort a model runs at when the owner picks none, as Claude Code
+ /// reports it (spec 7.4).
+ ModelEffort, "model effort" {
+ /// The model takes no effort level.
+ None => "none",
+ Low => "low",
+ Medium => "medium",
+ High => "high",
+ Xhigh => "xhigh",
+ Max => "max",
+ }
+);
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BotsSetEffortParams {
+ pub bot_id: BotId,
+ pub effort: BotEffort,
+}
+
+/// How full a bot's conversation is, in tokens, as Claude Code counts it
+/// (spec 8.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct ContextUsage {
+ /// What the conversation holds now.
+ pub used_tokens: u64,
+ /// The most it can hold.
+ pub window_tokens: u64,
+ /// Where Claude Code compacts the conversation by itself; `null` when it
+ /// does not.
+ pub auto_compact_tokens: Option,
+ /// The conversation is being compacted right now.
+ pub compacting: bool,
+ /// Unix time in milliseconds.
+ pub updated_at: i64,
+}
+
+/// Params of the `bot.context` notification.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BotContextChanged {
+ pub bot_id: BotId,
+ /// `null` when the bot began a new conversation and its size is not
+ /// known yet.
+ pub context: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn efforts_read_as_claude_code_names_them() {
+ assert_eq!(BotEffort::Default.cli_value(), None);
+ assert_eq!(BotEffort::Xhigh.cli_value(), Some("xhigh"));
+ assert_eq!(
+ "xhigh".parse::().ok(),
+ Some(ModelEffort::Xhigh)
+ );
+ assert_eq!(
+ serde_json::to_value(ModelEffort::None).expect("serialize"),
+ serde_json::json!("none")
+ );
+ assert!("ultra".parse::().is_err());
+ }
+}
diff --git a/crates/botloft-store/migrations/0010_bot_effort.sql b/crates/botloft-store/migrations/0010_bot_effort.sql
new file mode 100644
index 00000000..a1577e51
--- /dev/null
+++ b/crates/botloft-store/migrations/0010_bot_effort.sql
@@ -0,0 +1,5 @@
+-- How much each bot thinks before it answers (spec 7.4): the effort level the
+-- owner picked, and the level its model uses by itself, as Claude Code last
+-- reported it. Existing bots keep their model's own level, as they did.
+ALTER TABLE bots ADD COLUMN effort TEXT NOT NULL DEFAULT 'default';
+ALTER TABLE bots ADD COLUMN effort_default TEXT;
diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs
index 8a5c1568..b0c150c8 100644
--- a/crates/botloft-store/src/approvals.rs
+++ b/crates/botloft-store/src/approvals.rs
@@ -144,6 +144,7 @@ mod tests {
approval_id: approval.id.clone(),
tool_name: approval.tool_name.clone(),
summary: approval.summary.clone(),
+ explanation: None,
input: approval.input.clone(),
status: ApprovalStatus::Pending,
note: None,
diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs
index 59e5e5ac..53ffb9f3 100644
--- a/crates/botloft-store/src/bots.rs
+++ b/crates/botloft-store/src/bots.rs
@@ -1,7 +1,7 @@
//! `bots` table.
use botloft_core::ids::{BotId, CrewId};
-use botloft_core::protocol::{BotModel, PermissionMode};
+use botloft_core::protocol::{BotEffort, BotModel, ModelEffort, PermissionMode};
use rusqlite::{OptionalExtension, Row, params};
use crate::{Result, Store, parse_column, unique_as_duplicate};
@@ -24,12 +24,16 @@ pub struct BotRecord {
/// What Claude Code last reported; written by [`Store::set_model_in_use`]
/// and left alone by [`Store::update_bot`].
pub model_in_use: Option,
+ pub effort: BotEffort,
+ /// What Claude Code last reported; written by
+ /// [`Store::set_effort_default`] and left alone by [`Store::update_bot`].
+ pub effort_default: Option,
pub created_at: i64,
pub archived_at: Option,
}
const COLUMNS: &str = "id, crew_id, name, handle, slug, role, instructions, color, paused, created_at, archived_at, \
- permission_mode, model, model_in_use";
+ permission_mode, model, model_in_use, effort, effort_default";
fn from_row(row: &Row<'_>) -> rusqlite::Result {
Ok(BotRecord {
@@ -47,6 +51,11 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result {
permission_mode: parse_column(row, 11)?,
model: parse_column(row, 12)?,
model_in_use: row.get(13)?,
+ effort: parse_column(row, 14)?,
+ // A level this build does not know reads as not known.
+ effort_default: row
+ .get::<_, Option>(15)?
+ .and_then(|level| level.parse().ok()),
})
}
@@ -56,7 +65,7 @@ impl Store {
.execute(
&format!(
"INSERT INTO bots ({COLUMNS}) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)"
),
params![
bot.id.as_str(),
@@ -72,7 +81,9 @@ impl Store {
bot.archived_at,
bot.permission_mode.as_str(),
bot.model.as_str(),
- bot.model_in_use
+ bot.model_in_use,
+ bot.effort.as_str(),
+ bot.effort_default.map(ModelEffort::as_str)
],
)
.map_err(|err| unique_as_duplicate(err, "bot handle or slug"))?;
@@ -140,7 +151,8 @@ impl Store {
self.conn
.execute(
"UPDATE bots SET name = ?2, handle = ?3, role = ?4, instructions = ?5, \
- color = ?6, paused = ?7, archived_at = ?8, permission_mode = ?9, model = ?10 WHERE id = ?1",
+ color = ?6, paused = ?7, archived_at = ?8, permission_mode = ?9, model = ?10, \
+ effort = ?11 WHERE id = ?1",
params![
bot.id.as_str(),
bot.name,
@@ -151,14 +163,15 @@ impl Store {
bot.paused,
bot.archived_at,
bot.permission_mode.as_str(),
- bot.model.as_str()
+ bot.model.as_str(),
+ bot.effort.as_str()
],
)
.map_err(|err| unique_as_duplicate(err, "bot handle"))?;
Ok(())
}
- /// The model Claude Code reported at the start of a turn (spec 7.4).
+ /// The model Claude Code reported (spec 7.4).
pub fn set_model_in_use(&self, id: &BotId, model: &str) -> Result<()> {
self.conn.execute(
"UPDATE bots SET model_in_use = ?2 WHERE id = ?1",
@@ -167,6 +180,16 @@ impl Store {
Ok(())
}
+ /// The effort the bot's model uses by itself, as Claude Code reported it
+ /// (spec 7.4); `None` when it is not known.
+ pub fn set_effort_default(&self, id: &BotId, effort: Option) -> Result<()> {
+ self.conn.execute(
+ "UPDATE bots SET effort_default = ?2 WHERE id = ?1",
+ params![id.as_str(), effort.map(ModelEffort::as_str)],
+ )?;
+ Ok(())
+ }
+
/// The Claude Code conversation the bot resumes (spec 7.3).
pub fn session_id(&self, id: &BotId) -> Result