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?". */
+
{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[];
}