diff --git a/src/components/console/transcript.tsx b/src/components/console/transcript.tsx index 4d0bcf8..193fd5b 100644 --- a/src/components/console/transcript.tsx +++ b/src/components/console/transcript.tsx @@ -1,7 +1,8 @@ "use client"; import * as React from "react"; -import { AlertTriangle, ChevronRight, FileText } from "lucide-react"; +import { AlertTriangle, Brain, ChevronRight, FileText } from "lucide-react"; +import { summariseRecalledMemories } from "@/lib/recalled-memories"; import type { ChatMessage, ToolCall } from "@/lib/types"; import { toolIcon } from "@/lib/console"; import { formatNumber, formatUsd } from "@/lib/utils"; @@ -158,6 +159,21 @@ function BotTurn({ )} + {m.recalledMemories && m.recalledMemories.length > 0 && !m.streaming && ( + /* Memory is recalled without the user asking for it, so an answer + leaning on a remembered fact is unreadable without this. Same + chip row as Sources — both answer "what informed this?". */ +
+ Recalled: + {summariseRecalledMemories(m.recalledMemories).map((label, i) => ( + + + {label} + + ))} +
+ )} + {m.usage && !m.streaming && (
{formatNumber(m.usage.total)} tokens diff --git a/src/hooks/use-chat.ts b/src/hooks/use-chat.ts index c819f16..a8d29f1 100644 --- a/src/hooks/use-chat.ts +++ b/src/hooks/use-chat.ts @@ -343,6 +343,16 @@ export function useChat(opts: UseChatOptions) { }; }); break; + case "memory_recalled": + // Arrives before the first chunk. Guard the shape: an assistant + // turn claiming memories it did not use is worse than showing none. + patch(assistantId, (m) => ({ + ...m, + recalledMemories: Array.isArray(ev.keys) + ? ev.keys.filter((k): k is string => typeof k === "string") + : [], + })); + break; case "usage": patch(assistantId, (m) => ({ ...m, diff --git a/src/lib/recalled-memories.test.ts b/src/lib/recalled-memories.test.ts new file mode 100644 index 0000000..8121c9a --- /dev/null +++ b/src/lib/recalled-memories.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { isGeneratedMemoryKey, summariseRecalledMemories } from "./recalled-memories"; + +describe("isGeneratedMemoryKey", () => { + it("recognises an auto-save key by its uuid tail", () => { + expect(isGeneratedMemoryKey("user_msg_23f4b294-d91b-4ba4-9fd5-a902a78f3a82")).toBe(true); + }); + + it("leaves chosen names alone, including ones with underscores", () => { + expect(isGeneratedMemoryKey("deploy_window")).toBe(false); + expect(isGeneratedMemoryKey("user_lang")).toBe(false); + expect(isGeneratedMemoryKey("plan_2026_08_06")).toBe(false); + }); +}); + +describe("summariseRecalledMemories", () => { + // The reported shape: five auto-saved turns filled the chip row with hex and + // told the reader nothing about what the answer leaned on. + it("counts auto-saved turns instead of naming them", () => { + expect( + summariseRecalledMemories([ + "user_msg_23f4b294-d91b-4ba4-9fd5-a902a78f3a82", + "user_msg_9bd37f53-4f36-4819-972b-21cf335e6280", + "deploy_window", + ]), + ).toEqual(["deploy_window", "2 from this conversation"]); + }); + + it("reads as a count alone when nothing was named", () => { + expect( + summariseRecalledMemories([ + "user_msg_23f4b294-d91b-4ba4-9fd5-a902a78f3a82", + "user_msg_9bd37f53-4f36-4819-972b-21cf335e6280", + ]), + ).toEqual(["2 from this conversation"]); + }); + + it("names up to three and summarises the tail", () => { + expect(summariseRecalledMemories(["a", "b", "c", "d", "e"])).toEqual([ + "a", + "b", + "c", + "+2 more", + ]); + }); + + it("has no tail at exactly the shown limit", () => { + expect(summariseRecalledMemories(["a", "b", "c"])).toEqual(["a", "b", "c"]); + }); + + it("returns nothing for no keys", () => { + expect(summariseRecalledMemories([])).toEqual([]); + }); +}); diff --git a/src/lib/recalled-memories.ts b/src/lib/recalled-memories.ts new file mode 100644 index 0000000..3db814b --- /dev/null +++ b/src/lib/recalled-memories.ts @@ -0,0 +1,30 @@ +/** Most named memories to list before summarising the rest. */ +const NAMED_SHOWN = 3; + +/** + * True for a key the runtime generated rather than a person naming a fact. + * + * Auto-save writes one entry per turn as `_`. The uuid is an + * address, not a name — listing five of them fills the row while identifying + * nothing. Mirrors `memory::is_autosave_key` on the Rust side. + */ +export function isGeneratedMemoryKey(key: string): boolean { + return /_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key); +} + +/** + * Labels for the chips under an assistant turn: the memories a person named, + * plus a count for the turns the agent saved on its own. + */ +export function summariseRecalledMemories(keys: string[]): string[] { + const named = keys.filter((k) => !isGeneratedMemoryKey(k)); + const generated = keys.length - named.length; + + const labels = named.slice(0, NAMED_SHOWN); + const rest = named.length - labels.length; + if (rest > 0) labels.push(`+${rest} more`); + if (generated > 0) { + labels.push(`${generated} from this conversation`); + } + return labels; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 95e701d..601e3b9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -351,6 +351,7 @@ export type ChatEvent = | { type: "tool_call_start"; id: string; name: string; args: unknown } | { type: "tool_call_end"; id: string; ok: boolean; output_preview: string } | { type: "approval_request"; id: string; tool: string; args: unknown } + | { type: "memory_recalled"; keys: string[] } | { type: "error"; message: string } | { type: "done"; text: string; cancelled: boolean; session_id?: string | null } | { type: "reload_complete" } @@ -371,6 +372,10 @@ export interface ChatMessage { cancelled?: boolean; /** KB document titles retrieved for this assistant turn (citations). */ sources?: string[]; + /** Keys of stored memories injected into this turn's prompt. Like `sources`, + * this is what informed the answer — the difference is that memory is + * recalled without the user asking, which is exactly why it has to be shown. */ + recalledMemories?: string[]; /** Filenames the user had attached when sending this user turn. */ attachments?: string[]; }