+ );
+}
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.