diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index 0c6cac80..b0604c58 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -112,6 +112,16 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { chat.finish(shared, JSON.stringify({ shown: [report, papers] })); at(92); chat.reply(crew.scout, SUMMARY); + const memory = chat.tool(crew.scout, "Edit", { + summary: "CLAUDE.md", + file: `${fake.bot(crew.scout).workspace}\\CLAUDE.md`, + input: JSON.stringify({ + old_string: "## The owner\n- Reads summaries on Monday", + new_string: + "## The owner\n- Reads summaries on Monday\n- Wants the chart checked against the papers\n- Prefers tables to long lists", + }), + }); + chat.finish(memory, "The file has been updated."); const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", { summary: "@writer", input: JSON.stringify({ to: "writer", kind: "task", body: "Turn week-39.md into the report" }), diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 443dfe86..af75be12 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -3,15 +3,18 @@ // also shows the reply being written. import { CircleAlert } from "lucide-react"; -import { memo, type ReactNode } from "react"; +import { memo, type ReactNode, useMemo } from "react"; import { useT } from "../../i18n"; import { duration, tokens, usedTokens, when } from "../../lib/format"; import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; import { useArrival } from "../../ui/motion"; import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { QuestionCard } from "../questions/QuestionCard"; import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; +import { MemoryNote } from "./MemoryNote"; +import { memoryOf, memoryPlaces } from "./memory"; import { runParts, splitDraft } from "./rows"; import { SharedFiles } from "./SharedFiles"; import { isSharedFiles } from "./shared"; @@ -108,12 +111,15 @@ function BotRunView({ live?: Live | undefined; }) { const first = items[0]; - const parts = runParts(items); + const crew = useApp((state) => state.crews[bot.crewId]); + const places = useMemo(() => memoryPlaces(bot, crew), [bot, crew]); + const memory = (item: ChatItem) => memoryOf(item, places, bot.workspace); + const parts = runParts(items, (item) => memory(item) !== null); // The bot is still on the last group of calls until it writes or stops. const busy = Boolean(live?.working && !live.draft); const tail = parts.at(-1)?.[0]; // Its line already says it is working. - const onCalls = tail?.body.kind === "tool" && !isSharedFiles(tail); + const onCalls = tail?.body.kind === "tool" && !isSharedFiles(tail) && !memory(tail); return ( // A turn shows up as the bot starts it, still empty; what it does then // arrives inside, so a run with items never animates as a whole. @@ -132,12 +138,26 @@ function BotRunView({ {parts.map((part, index) => { const head = part[0] as ChatItem; switch (head.body.kind) { - case "tool": + case "tool": { + const owner = memory(head); + if (owner) { + return ( + + ); + } return isSharedFiles(head) ? ( ) : ( ); + } case "reply": // It was already on screen as it was written. return ; diff --git a/app/src/features/chat/MemoryNote.tsx b/app/src/features/chat/MemoryNote.tsx new file mode 100644 index 00000000..761d2567 --- /dev/null +++ b/app/src/features/chat/MemoryNote.tsx @@ -0,0 +1,130 @@ +// A line in the bot's turn saying it wrote down something it learned (spec +// 15.3), with whose memory it was: its own, or the crew's. The arrow opens +// what changed; the files button opens the whole memory. + +import { BookMarked, ChevronRight, Files } from "lucide-react"; +import { useContext, useState } from "react"; +import { useT } from "../../i18n"; +import type { ToolItem } from "../../lib/protocol.gen"; +import { useArrival } from "../../ui/motion"; +import { ShowFile } from "../files/showFile"; +import { type MemoryChange, type MemoryOwner, memoryChange } from "./memory"; + +function Lines({ lines, sign }: { lines: string[]; sign: "-" | "+" }) { + const tone = sign === "-" ? "bg-danger/10 text-danger" : "bg-ok/10 text-ok"; + return lines.map((line, index) => ( + // Lines repeat and never move: their place is their key. + // biome-ignore lint/suspicious/noArrayIndexKey: a fixed list + + + {sign} + + {line || " "} + + )); +} + +function Change({ change }: { change: MemoryChange }) { + const t = useT().chat.memory; + const block = + "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-line bg-sunken py-1.5 font-mono text-xs leading-relaxed"; + if (change.kind === "write") { + return ( + <> +

{t.now}

+
{change.text}
+ + ); + } + return ( +
+ {change.hunks.map((hunk, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: a fixed list + + + + + ))} +
+ ); +} + +export function MemoryNote({ + tool, + createdAt, + owner, + name, + color, +}: { + tool: ToolItem; + createdAt: number; + owner: MemoryOwner; + /** The bot's name, or the crew's. */ + name: string; + /** The bot's color; the crew has none. */ + color?: string | undefined; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const showFile = useContext(ShowFile); + const arrival = useArrival(createdAt); + const change = memoryChange(tool); + const words = owner === "bot" ? t.chat.memory.bot : t.chat.memory.crew; + const label = ( + <> + + {words} + + {color && ( + + )} + {name} + + + ); + const line = + "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-muted text-sm"; + return ( +
+
+ {change ? ( + + ) : ( +

{label}

+ )} + {showFile && tool.file && ( + + )} +
+ {open && change && ( +
+ +
+ )} +
+ ); +} diff --git a/app/src/features/chat/memory.test.tsx b/app/src/features/chat/memory.test.tsx new file mode 100644 index 00000000..f2d78cfa --- /dev/null +++ b/app/src/features/chat/memory.test.tsx @@ -0,0 +1,145 @@ +import { cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import type { ChatItem, ToolItem } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { memoryChange, memoryOf, memoryPlaces } from "./memory"; + +afterEach(cleanup); + +const WORKSPACE = "C:\\Users\\owner\\Botloft\\ops\\scout"; +const places = memoryPlaces({ workspace: WORKSPACE }, { workFolder: "D:\\Projects\\site" }); + +function edit(file: string | null, changes: Partial = {}): ChatItem { + return { + id: "cht_1", + botId: "bot_1", + createdAt: 0, + body: { + kind: "tool", + toolUseId: "toolu_1", + name: "Edit", + summary: "", + explanation: null, + input: "{}", + status: "done", + output: "", + file, + ...changes, + }, + } as ChatItem; +} + +describe("whose memory an edit wrote", () => { + test("the bot's own, in any case and with either slash", () => { + expect(memoryOf(edit(`${WORKSPACE}\\CLAUDE.md`), places, WORKSPACE)).toBe("bot"); + expect(memoryOf(edit("c:/users/owner/botloft/ops/scout/claude.md"), places, WORKSPACE)).toBe( + "bot", + ); + expect(memoryOf(edit(`${WORKSPACE}\\.claude\\CLAUDE.md`), places, WORKSPACE)).toBe("bot"); + // A relative path is from the bot's folder, where it runs. + expect(memoryOf(edit("CLAUDE.md"), places, WORKSPACE)).toBe("bot"); + }); + + test("the crew's, in the crew folder or the work folder", () => { + expect(memoryOf(edit("C:\\Users\\owner\\Botloft\\ops\\CLAUDE.md"), places, WORKSPACE)).toBe( + "crew", + ); + expect(memoryOf(edit("D:\\Projects\\site\\CLAUDE.md"), places, WORKSPACE)).toBe("crew"); + }); + + test("nothing for other files, other bots, or calls that did not finish", () => { + expect(memoryOf(edit(`${WORKSPACE}\\notes.md`), places, WORKSPACE)).toBeNull(); + expect( + memoryOf(edit("C:\\Users\\owner\\Botloft\\ops\\writer\\CLAUDE.md"), places, WORKSPACE), + ).toBeNull(); + expect(memoryOf(edit(null), places, WORKSPACE)).toBeNull(); + for (const status of ["running", "failed"] as const) { + expect(memoryOf(edit("CLAUDE.md", { status }), places, WORKSPACE)).toBeNull(); + } + }); +}); + +describe("what changed", () => { + const tool = () => edit("CLAUDE.md").body as ToolItem; + + test("an edit keeps only the lines that changed", () => { + const call = tool(); + call.input = JSON.stringify({ + old_string: "# Notes\n- likes tea\n- end", + new_string: "# Notes\n- likes coffee\n- signs annual\n- end", + }); + expect(memoryChange(call)).toEqual({ + kind: "edit", + hunks: [{ removed: ["- likes tea"], added: ["- likes coffee", "- signs annual"] }], + }); + }); + + test("several edits, a whole write, and an input that was cut", () => { + const call = tool(); + call.input = JSON.stringify({ + edits: [ + { old_string: "a", new_string: "b" }, + { old_string: "same", new_string: "same" }, + ], + }); + expect(memoryChange(call)).toEqual({ kind: "edit", hunks: [{ removed: ["a"], added: ["b"] }] }); + call.input = JSON.stringify({ file_path: "CLAUDE.md", content: "# All new" }); + expect(memoryChange(call)).toEqual({ kind: "write", text: "# All new" }); + call.input = '{"file_path": "CLAUDE.md", "content": "cut he'; + expect(memoryChange(call)).toBeNull(); + }); +}); + +describe("in the chat", () => { + test("a memory edit is its own line, out of the group, and opens what changed", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + const read = fake.chat.tool(scout.id, "Read", { summary: "brief.md" }); + fake.chat.finish(read, "…"); + const remember = fake.chat.tool(scout.id, "Edit", { + summary: "CLAUDE.md", + file: `${scout.workspace}\\CLAUDE.md`, + input: JSON.stringify({ old_string: "- old", new_string: "- Dana approves" }), + }); + fake.chat.finish(remember, "ok"); + const run = fake.chat.tool(scout.id, "Bash", { summary: "ls" }); + fake.chat.finish(run, "…"); + fake.chat.turn(scout.id); + + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const line = await screen.findByRole("button", { name: /Updated memory for\s*Scout/ }); + // The calls around it are single lines of their own. + expect(screen.getByRole("button", { name: /Read a file/ })).toBeDefined(); + expect(screen.queryByRole("button", { name: /Edit a file/ })).toBeNull(); + + fireEvent.click(line); + const changed = screen.getByLabelText("What changed"); + expect(within(changed).getByText("- old")).toBeDefined(); + expect(within(changed).getByText("- Dana approves")).toBeDefined(); + }); + + test("the crew's memory names the crew", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + const write = fake.chat.tool(scout.id, "Write", { + file: `${ops.workFolder}\\CLAUDE.md`, + input: JSON.stringify({ content: "# Ops\nAcme signs annual." }), + }); + fake.chat.finish(write, "ok"); + fake.chat.turn(scout.id); + + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + fireEvent.click(await screen.findByRole("button", { name: /Updated crew memory for\s*Ops/ })); + expect(screen.getByText("The memory now says:")).toBeDefined(); + expect(screen.getByText(/Acme signs annual/)).toBeDefined(); + }); +}); diff --git a/app/src/features/chat/memory.ts b/app/src/features/chat/memory.ts new file mode 100644 index 00000000..b6cab944 --- /dev/null +++ b/app/src/features/chat/memory.ts @@ -0,0 +1,114 @@ +// A bot writing down what it learned (spec 15.3): an edit to its memory, the +// `CLAUDE.md` of its folder, or to the crew's, which every bot of the crew +// reads. The chat shows it as a line of its own, opening what changed. + +import type { Bot, ChatItem, Crew, ToolItem } from "../../lib/protocol.gen"; + +export type MemoryOwner = "bot" | "crew"; + +/** Where a crew's memories live, as `memoryOf` compares them. */ +export interface MemoryPlaces { + bot: string[]; + crew: string[]; +} + +/** One stretch of the memory that changed: lines that left and came. */ +export interface MemoryHunk { + removed: string[]; + added: string[]; +} + +export type MemoryChange = + | { kind: "edit"; hunks: MemoryHunk[] } + /** The whole file written again: what it says now. */ + | { kind: "write"; text: string }; + +/** Windows paths compare without case and with either slash. */ +function norm(path: string): string { + return path.replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/$/, "").toLowerCase(); +} + +function parent(path: string): string { + const cut = path.lastIndexOf("/"); + return cut > 0 ? path.slice(0, cut) : path; +} + +const FILES = ["CLAUDE.md", ".claude/CLAUDE.md"]; + +function inside(folder: string): string[] { + return FILES.map((file) => norm(`${folder}/${file}`)); +} + +export function memoryPlaces(bot: Pick, crew?: Pick) { + const workspace = norm(bot.workspace); + return { + bot: inside(workspace), + // The crew folder holds every bot's folder (spec 5.1). + crew: [norm(`${parent(workspace)}/CLAUDE.md`), ...(crew ? inside(crew.workFolder) : [])], + } satisfies MemoryPlaces; +} + +function isAbsolute(path: string): boolean { + return /^([a-z]:)?[\\/]/i.test(path); +} + +/** Whose memory a finished edit wrote; `null` for any other item. */ +export function memoryOf( + item: ChatItem, + places: MemoryPlaces, + workspace: string, +): MemoryOwner | null { + const tool = item.body.kind === "tool" ? item.body : null; + if (!tool?.file || tool.status !== "done") { + return null; + } + const file = norm(isAbsolute(tool.file) ? tool.file : `${workspace}/${tool.file}`); + if (places.bot.includes(file)) { + return "bot"; + } + return places.crew.includes(file) ? "crew" : null; +} + +/** The lines both sides share at the start and end left out. */ +function hunk(before: string, after: string): MemoryHunk | null { + const removed = before.split("\n"); + const added = after.split("\n"); + while (removed.length && added.length && removed[0] === added[0]) { + removed.shift(); + added.shift(); + } + while (removed.length && added.length && removed.at(-1) === added.at(-1)) { + removed.pop(); + added.pop(); + } + return removed.length || added.length ? { removed, added } : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +/** + * What the call changed, from its input; `null` when the input was cut + * (spec 8.2) or is not an edit, and only the file is left to open. + */ +export function memoryChange(tool: ToolItem): MemoryChange | null { + let input: Record; + try { + input = JSON.parse(tool.input) as Record; + } catch { + return null; + } + const content = text(input.content); + if (content !== null) { + return { kind: "write", text: content }; + } + const edits = Array.isArray(input.edits) ? (input.edits as Record[]) : [input]; + const hunks = edits.flatMap((edit) => { + const before = text(edit?.old_string); + const after = text(edit?.new_string); + const change = before !== null && after !== null ? hunk(before, after) : null; + return change ? [change] : []; + }); + return hunks.length ? { kind: "edit", hunks } : null; +} diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index ae8384be..cff23738 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -79,13 +79,18 @@ function isLine(item: ChatItem | undefined): boolean { /** * Splits a run into replies, approvals, shared files and turns alone, and - * tool calls together. + * tool calls together. `alone` keeps other calls out of the group, such as + * an edit to the bot's memory. */ -export function runParts(items: ChatItem[]): ChatItem[][] { +export function runParts( + items: ChatItem[], + alone: (item: ChatItem) => boolean = () => false, +): ChatItem[][] { const parts: ChatItem[][] = []; + const line = (item: ChatItem | undefined) => isLine(item) && !(item && alone(item)); for (const item of items) { const last = parts.at(-1); - if (last && isLine(item) && isLine(last[0])) { + if (last && line(item) && line(last[0])) { last.push(item); } else { parts.push([item]); diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 8a7f0f61..8601eda4 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -87,6 +87,14 @@ export const chat = { failed: (n: number) => `${n} failed`, }, }, + /** A bot wrote down what it learned (spec 15.3), before the name. */ + memory: { + bot: "Updated memory for", + crew: "Updated crew memory for", + file: "memory", + changed: "What changed", + now: "The memory now says:", + }, approval: { asks: (bot: string, action: string) => `${bot} asks to ${action}`, wants: (bot: string, action: string) => `${bot} wants to ${action}`, diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index b618370c..b95f63f8 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -81,6 +81,13 @@ export const chat: Messages["chat"] = { failed: (n: number) => (n === 1 ? "1 fallo" : `${n} fallos`), }, }, + memory: { + bot: "Actualizó la memoria de", + crew: "Actualizó la memoria del equipo", + file: "memoria", + changed: "Lo que cambió", + now: "Ahora la memoria dice:", + }, approval: { asks: (bot: string, action: string) => `${bot} pide ${action}`, wants: (bot: string, action: string) => `${bot} quiere ${action}`, diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 269827b3..93a106d8 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -80,6 +80,13 @@ export const chat: Messages["chat"] = { failed: (n: number) => (n === 1 ? "1 falha" : `${n} falhas`), }, }, + memory: { + bot: "Atualizou a memória de", + crew: "Atualizou a memória da equipe", + file: "memória", + changed: "O que mudou", + now: "Agora a memória diz:", + }, approval: { asks: (bot: string, action: string) => `${bot} pede para ${action}`, wants: (bot: string, action: string) => `${bot} quer ${action}`, diff --git a/docs/spec.md b/docs/spec.md index 5963eb0e..f0cf1fde 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -889,6 +889,7 @@ No chat: - Pedido para seguir com um plano (10.1) é um cartão com o plano em markdown, um campo para o que deve mudar e os botões Aprovar plano e Pedir mudanças. Respondido, vira uma linha que abre o plano de novo. - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho. - Arquivos que o bot compartilha (`share_file`) aparecem como cartões na fala dele, com Abrir e Salvar como… (15.1). +- Quando o bot guarda o que aprendeu, a edição não entra nas chamadas dobradas: vira uma linha própria, "Atualizou a memória de" com o nome do bot numa pílula com a cor dele. Conta como memória um `Write`, `Edit` ou `MultiEdit` que terminou bem num `CLAUDE.md` (ou `.claude\CLAUDE.md`) da pasta do bot (5.1); o da pasta da crew e o da pasta de trabalho (5) são a memória da equipe, que todo bot dela lê, e a linha diz "Atualizou a memória da equipe" com o nome da crew. O app reconhece pelo `file` do item (8.2), sem diferenciar maiúsculas nem barras; um caminho relativo é da pasta do bot. A seta abre o que mudou, montado da entrada da chamada: num `Edit`, só as linhas que saíram e as que entraram; num `Write`, o que a memória diz agora. Com a entrada cortada (8.2), não há seta, e fica o botão "Ver em arquivos", que abre a memória inteira. Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/app-icon.svg`, de onde `pnpm tauri icon app-icon.svg` gera os PNG, o ICO e o ICNS; as pastas `android` e `ios` que ele cria são apagadas) é o mascote laranja, na cor que mostra o desenho como foi feito, sobre um quadrado preto, montado com os mesmos contornos e camadas de `mascotArt.ts`. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação (e mudada ao editar), sem fundo e com um contorno fino e discreto (escuro no tema claro, claro no escuro) para as cores claras não sumirem. O avatar é a primeira pose da folha de poses da chama, vetorizada (`mascotArt.ts`): o corpo com as labaredas, uma gotinha de fogo solta acima da ponta, camadas de luz e sombra recortadas pelo contorno e olhos pretos com brilhos e reflexo avermelhado embaixo, tudo retingido para a cor do bot (matiz, saturação e luminosidade), então todo bot tem a mesma luz e sombra. A referência da retintura é a primeira cor da paleta (`#FF7A59`): um bot laranja fica igual ao desenho, e as cores claras da paleta mantêm a profundidade em vez de desbotar. A gotinha passa um pouco acima do quadrado do avatar, como as faíscas. O mascote do próprio Botloft (barra de título, mensagens do daemon) é o do ícone: laranja sobre o quadrado preto. Na janela de criar e editar bot, a cor sai de uma paleta de 16 (`PALETTE`, que o daemon também dá em ordem a quem não escolhe) ou de "Escolher qualquer cor", que abre um seletor livre: um quadrado de saturação e brilho sobre o matiz escolhido (também com as setas do teclado; Shift anda mais), uma barra de matiz e a cor em hex e em R, G e B, tudo editando a mesma cor. O daemon aceita qualquer `#RRGGBB`. Um bot com cor fora da paleta abre a janela com o seletor aberto. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. O mascote se mexe conforme o que o bot faz (ver Movimento), sem substituir isso.