Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/components/console/transcript.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -158,6 +159,21 @@ function BotTurn({
</div>
)}

{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?". */
<div className="src-chips">
<span className="src-label">Recalled:</span>
{summariseRecalledMemories(m.recalledMemories).map((label, i) => (
<span className="src-chip" key={`${label}-${i}`} title={`Memory: ${label}`}>
<Brain />
{label}
</span>
))}
</div>
)}

{m.usage && !m.streaming && (
<div className="msg-meta" style={{ marginTop: 8 }}>
{formatNumber(m.usage.total)} tokens
Expand Down
10 changes: 10 additions & 0 deletions src/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions src/lib/recalled-memories.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
30 changes: 30 additions & 0 deletions src/lib/recalled-memories.ts
Original file line number Diff line number Diff line change
@@ -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 `<prefix>_<uuid>`. 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;
}
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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[];
}
Expand Down
Loading