From 23daa409d67bab00d873f25951e946f4ca3be4cf Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:44:42 -0300 Subject: [PATCH] feat(chat): show when a bot updates its memory An edit to the bot's CLAUDE.md, or the crew's, leaves the folded tool calls and becomes its own line naming whose memory it was. The arrow opens what changed: the lines that left and came, or what a full write says now. --- app/src/dev/seedChats.ts | 10 ++ app/src/features/chat/BotRun.tsx | 28 ++++- app/src/features/chat/MemoryNote.tsx | 130 +++++++++++++++++++++++ app/src/features/chat/memory.test.tsx | 145 ++++++++++++++++++++++++++ app/src/features/chat/memory.ts | 114 ++++++++++++++++++++ app/src/features/chat/rows.ts | 11 +- app/src/i18n/en/chat.ts | 8 ++ app/src/i18n/es/chat.ts | 7 ++ app/src/i18n/pt-BR/chat.ts | 7 ++ docs/spec.md | 1 + 10 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 app/src/features/chat/MemoryNote.tsx create mode 100644 app/src/features/chat/memory.test.tsx create mode 100644 app/src/features/chat/memory.ts 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.