diff --git a/packages/junior-dashboard/e2e/conversation-details.spec.ts b/packages/junior-dashboard/e2e/conversation-details.spec.ts index 8259cb8122..2b470c6be7 100644 --- a/packages/junior-dashboard/e2e/conversation-details.spec.ts +++ b/packages/junior-dashboard/e2e/conversation-details.spec.ts @@ -105,11 +105,18 @@ test("opens conversation details beside the chat", async ({ conversationUrl, ); - // Memories stay collapsed until the reader asks for them. + // Details show memory counts. The full list replaces details in place. const memories = details.getByText("Use pnpm for repository commands."); await expect(memories).toBeHidden(); - await details.getByRole("button", { name: "Show memories" }).click(); + await details + .getByRole("button", { name: "1 recalled · 2 captured" }) + .click(); await expect(memories).toBeVisible(); + await expect( + details.getByRole("heading", { name: "Memories" }), + ).toBeVisible(); + await details.getByRole("button", { name: "Back to details" }).click(); + await expect(memories).toBeHidden(); await page.setViewportSize({ width: 1024, height: 900 }); const sheet = page.getByRole("dialog", { name: "Conversation details" }); await expect(sheet).toBeVisible(); @@ -175,3 +182,75 @@ test("opens conversation details beside the chat", async ({ await expect(page.getByRole("note")).toContainText("Private conversation"); await expect(page.getByRole("note")).toContainText("Private"); }); + +test("shows recalled and captured memories in popovers", async ({ + page, + dashboard, +}) => { + await page.setViewportSize({ height: 900, width: 1600 }); + await page.goto( + `${dashboard.baseURL}/conversations/${encodeURIComponent("internal:dashboard-qa")}`, + ); + + // Captured memories stay out of the collapsed activity rows. + const captured = page.getByRole("button", { name: "Captured 2 memories" }); + await expect(captured).toBeVisible(); + await captured.click(); + const capturedPopover = page.getByRole("dialog", { + name: "Captured 2 memories", + }); + await expect( + capturedPopover.getByText("Use pnpm for repository commands."), + ).toBeVisible(); + await expect(capturedPopover.getByText("Private")).toBeVisible(); + await screenshot(page, "conversation-captured-memories-popover"); + await page.keyboard.press("Escape"); + await expect(capturedPopover).toBeHidden(); + + // A memory in details opens its Turn's popover in the transcript, even + // when a search hides that row. + await page.getByRole("button", { name: "Search transcript" }).click(); + await page.getByPlaceholder("Search transcript…").fill("no such text"); + await expect(captured).toBeHidden(); + const details = page.getByRole("complementary", { + name: "Conversation details", + exact: true, + }); + await details + .getByRole("button", { name: "1 recalled · 2 captured" }) + .click(); + await details + .getByRole("button", { name: /Dashboard transcript events should remain/ }) + .click(); + await expect(capturedPopover).toBeVisible(); + await expect( + details.getByRole("heading", { name: "Memories" }), + ).toBeVisible(); + await screenshot(page, "conversation-memory-list-jump"); +}); + +test("shows recalled memories under their prompt", async ({ + page, + dashboard, +}) => { + await page.setViewportSize({ height: 900, width: 1600 }); + await page.goto( + `${dashboard.baseURL}/conversations/${encodeURIComponent("slack:CQA123:1770003600.000200")}`, + ); + + const recalled = page.getByRole("button", { name: "Recalled 1 memory" }); + await recalled.click(); + const popover = page.getByRole("dialog", { name: "Recalled 1 memory" }); + await expect( + popover.getByText( + "Checkout latency investigations start with the deployment comparison dashboard.", + ), + ).toBeVisible(); + await expect(popover.getByRole("link", { name: "Open" })).toHaveAttribute( + "href", + "/memories/memory-checkout-runbook", + ); + await screenshot(page, "conversation-recalled-memories-popover"); + await recalled.click(); + await expect(popover).toBeHidden(); +}); diff --git a/packages/junior-dashboard/e2e/harness.ts b/packages/junior-dashboard/e2e/harness.ts index 1b335d1cf9..00772eb33d 100644 --- a/packages/junior-dashboard/e2e/harness.ts +++ b/packages/junior-dashboard/e2e/harness.ts @@ -152,19 +152,33 @@ export async function mockDashboardApis( async (route) => { await route.fulfill({ json: { + events: [ + { + memoryIds: ["memory-checkout-runbook"], + source: "recalled", + turnId: "active-turn", + }, + { + memoryIds: ["captured-memory-1", "captured-memory-2"], + source: "captured", + turnId: "qa-turn", + }, + ], memories: [ { - capturedAt: "2026-08-07T07:01:00.000Z", + content: + "Checkout latency investigations start with the deployment comparison dashboard.", + id: "memory-checkout-runbook", + visibility: "public", + }, + { content: "Use pnpm for repository commands.", id: "captured-memory-1", - kind: "preference", visibility: "private", }, { - capturedAt: "2026-08-07T07:01:00.000Z", content: "Dashboard transcript events should remain expandable.", id: "captured-memory-2", - kind: "knowledge", visibility: "public", }, ], diff --git a/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx b/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx index f333072372..ba4110f08f 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx @@ -1,11 +1,11 @@ -import { ChevronRight } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { useState, type ReactNode } from "react"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; import { DetailsPanel, useDetailsPanel } from "../components/DetailsPanel"; import { formatRelativeTime, formatTime } from "../format"; import { ConversationReviewItems } from "../review-desk/ConversationReviewItems"; -import { dashboardDisclosureChevronClass } from "../styles"; +import { Button } from "../components/ui/button"; import { ConversationBrief } from "./ConversationBrief"; import { ConversationChildAgents, @@ -16,9 +16,14 @@ import { type ConversationLink, } from "./ConversationLinkedWork"; import { - ConversationMemories, - useConversationMemories, + ConversationMemoryCard, + ConversationMemoryEntries, } from "./ConversationMemories"; +import { + conversationMemoryEntries, + useConversationMemoryState, + type ConversationMemoryEntry, +} from "./conversationMemory"; import { ConversationWatches, useConversationWatches, @@ -43,15 +48,63 @@ export function ConversationDetails(props: { onClose(): void; /** Open one child agent transcript. A details sheet closes first. */ onOpenSubagent(target: SubagentTranscriptTarget): void; + /** Show one memory where its Turn recalled or captured it. */ + onSelectMemory(entry: ConversationMemoryEntry & { turnId: string }): void; + /** The prompt that started each loaded Turn. */ + memoryPrompts: Map; overview: ReactNode; usage: ReactNode; }) { const panel = useDetailsPanel(); - const memories = useConversationMemories(props.conversationId).data?.memories; + const memoryList = useConversationMemoryState()?.list; + const [memoriesOpen, setMemoriesOpen] = useState(false); + const [memoryConversationId, setMemoryConversationId] = useState( + props.conversationId, + ); + if (memoryConversationId !== props.conversationId) { + setMemoryConversationId(props.conversationId); + setMemoriesOpen(false); + } const agents = useConversationChildAgents(props.conversationId).data?.agents; const watches = useConversationWatches(props.conversationId).data?.watches ?? []; + if (memoriesOpen && memoryList) { + // The list replaces details in place, so nothing stacks over the panel. + return ( + + +

+ Memories +

+ + } + > +
+ { + // The transcript sits under a details sheet. Close it first. + if (!panel) props.onClose(); + props.onSelectMemory(entry); + }} + prompts={props.memoryPrompts} + /> +
+
+ ); + } + return ( {props.identity} ) : null} - {memories?.length ? ( - - - - - + {memoryList?.events.length ? ( + setMemoriesOpen(true)} + /> ) : null} ); } - -/** Hide long, rarely read content behind one show/hide row. */ -function Disclosure(props: { children: ReactNode; label: string }) { - const [open, setOpen] = useState(false); - return ( -
- - {open ? props.children : null} -
- ); -} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx b/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx index 22603e6286..19f94217bf 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx @@ -1,90 +1,130 @@ -import { useQuery } from "@tanstack/react-query"; -import { Brain } from "lucide-react"; -import { z } from "zod"; +import { ChevronRight } from "lucide-react"; +import type { ConversationDetailReport } from "@sentry/junior/api/schema"; -import { formatMessageTimestamp } from "../format"; -import { fetchDashboardJson } from "../http"; +import { dashboardDisclosureChevronClass } from "../styles"; +import type { + ConversationMemoryEntry, + ConversationMemoryList, +} from "./conversationMemory"; +import { DetailsCard } from "./DetailsList"; -const conversationMemoryListSchema = z - .object({ - memories: z.array( - z - .object({ - capturedAt: z.iso.datetime(), - content: z.string().min(1), - id: z.string().min(1), - kind: z.enum(["preference", "procedure", "knowledge"]), - visibility: z.enum(["private", "public"]), - }) - .strict(), - ), - }) - .strict(); - -/** Load the memories captured from one Conversation. */ -export function useConversationMemories(conversationId: string) { - return useQuery({ - queryFn: ({ signal }) => - fetchDashboardJson( - conversationMemoryListSchema, - `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`, - signal, - ), - queryKey: ["conversation", conversationId, "memories"], - retry: false, - }); +/** Count the distinct memories that a Conversation recalled and captured. */ +function conversationMemoryCounts(list: ConversationMemoryList) { + const ids = (source: "captured" | "recalled") => + new Set( + list.events + .filter((event) => event.source === source) + .flatMap((event) => event.memoryIds), + ).size; + return { captured: ids("captured"), recalled: ids("recalled") }; } -/** Show the memories captured from one Conversation. */ -export function ConversationMemories(props: { conversationId: string }) { - const query = useConversationMemories(props.conversationId); +/** Show memory counts in one row that opens the full memory list. */ +export function ConversationMemoryCard(props: { + list: ConversationMemoryList; + onOpen(): void; +}) { + const counts = conversationMemoryCounts(props.list); + const summary = [ + counts.recalled ? `${counts.recalled} recalled` : undefined, + counts.captured ? `${counts.captured} captured` : undefined, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + ); +} - if (query.isPending) { - return ( -

- Loading memories… -

- ); - } - if (query.error) { - return ( -

- Could not load memories from this Conversation. -

- ); - } - if (query.data.memories.length === 0) { - return ( -
-
- ); +/** + * Map each loaded Turn to the prompt that started it, so a memory can say + * where it came from. A Turn starts from its last user input message. + */ +export function turnPrompts( + events: ConversationDetailReport["events"] | undefined, +): Map { + const texts = new Map(); + const prompts = new Map(); + for (const { data } of events ?? []) { + if (data.type === "message" && data.role === "user" && data.text) { + texts.set(data.messageId, data.text); + } + if (data.type === "turn_lifecycle" && data.state === "started") { + const prompt = data.inputMessageIds + ?.map((id) => texts.get(id)) + .filter((text) => text !== undefined) + .at(-1); + if (prompt) prompts.set(data.turnId, prompt); + } } + return prompts; +} +/** + * List every memory that a Conversation recalled or captured, with its full + * text. Selecting one shows it in the transcript. + */ +export function ConversationMemoryEntries(props: { + entries: ConversationMemoryEntry[]; + onSelect(entry: ConversationMemoryEntry & { turnId: string }): void; + prompts: Map; +}) { return ( -
    - {query.data.memories.map((memory) => ( -
  1. -

    - {memory.content} -

    -
    - - {memory.kind} - - - {memory.visibility} +
      + {props.entries.map((entry) => { + const { turnId } = entry; + const prompt = turnId ? props.prompts.get(turnId) : undefined; + const source = + entry.source === "recalled" + ? prompt + ? `Recalled for “${prompt}”` + : "Recalled" + : prompt + ? `Captured after “${prompt}”` + : "Captured"; + const body = ( + <> +

      + {entry.memory.content} +

      + + {source} + {entry.memory.visibility === "private" ? ( + · Private + ) : null} - -
    -
  2. - ))} + + ); + const className = + "grid w-full gap-1.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 text-left font-sans"; + return ( +
  3. + {turnId ? ( + + ) : ( +
    {body}
    + )} +
  4. + ); + })}
); } diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 7b20e38bad..2afa376f9c 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -31,6 +31,14 @@ import { import { ConversationHeader } from "./ConversationHeader"; import { ConversationReplyFooter } from "./ConversationReplyFooter"; import { ConversationDetails } from "./ConversationDetails"; +import { turnPrompts } from "./ConversationMemories"; +import { + ConversationMemoryContext, + memoryAnchorKey, + useConversationMemories, + type ConversationMemoryState, + type OpenMemoryPopover, +} from "./conversationMemory"; import { ConversationEventDetails } from "./ConversationEventDetails"; import { ConversationEventLogLoading } from "./ConversationEventLog"; import { @@ -155,6 +163,18 @@ export function ConversationPage(props: { ); }, [setSearchParams]); const [turnContext, setTurnContext] = useState(); + const [memoryPopover, setMemoryPopover] = useState(); + // A memory chosen in details waits here until its transcript row renders. + const [memoryJump, setMemoryJump] = useState(); + // The page stays mounted across Conversations. Memory state belongs to one. + const [memoryConversationId, setMemoryConversationId] = useState( + props.conversationId, + ); + if (memoryConversationId !== props.conversationId) { + setMemoryConversationId(props.conversationId); + setMemoryPopover(undefined); + setMemoryJump(undefined); + } const showDetails = detailsOpen && !eventOpen && !turnContext; const [viewState, setView] = useState(() => eventOpen ? "raw" : "rich", @@ -167,6 +187,15 @@ export function ConversationPage(props: { const summaries = props.data?.conversations.conversations ?? []; const conversations = buildConversations(summaries); const detail = useConversationData(conversationId); + const memoryList = useConversationMemories(conversationId).data; + const memoryState = useMemo( + () => ({ + list: memoryList, + open: memoryPopover, + setOpen: setMemoryPopover, + }), + [memoryList, memoryPopover], + ); const archive = useArchiveConversation(conversationId); const markRead = useMarkConversationRead(conversationId); const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail); @@ -288,6 +317,38 @@ export function ConversationPage(props: { if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq); else if (linkedTurnNotFound) setSelectedEventSeq(undefined); }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]); + // Show a chosen memory at its transcript row. Wait for the first page, then + // load earlier pages until the row renders, or stop when history ends. + const memoryPrompts = useMemo( + () => turnPrompts(transcript?.events), + [transcript?.events], + ); + useEffect(() => { + if (!memoryJump || !transcript) return; + const anchor = pageRef.current?.querySelector( + `[data-memory-anchor="${CSS.escape(memoryAnchorKey(memoryJump))}"]`, + ); + if (anchor) { + anchor.scrollIntoView({ block: "center" }); + setMemoryPopover(memoryJump); + setMemoryJump(undefined); + } else if ( + detail.hasPreviousPage && + !detail.isLoadingPreviousPage && + !detail.historyError + ) { + loadPreviousPageRef.current(); + } else if (!detail.isLoadingPreviousPage) { + setMemoryJump(undefined); + } + }, [ + detail.hasPreviousPage, + detail.historyError, + detail.isLoadingPreviousPage, + memoryJump, + transcript, + view, + ]); // Keep the panel while the transcript or a linked event loads, and for the // render before a found Turn link becomes an event link. const eventLoading = @@ -470,31 +531,35 @@ export function ConversationPage(props: { data. ) : null} - + + + )} @@ -532,37 +597,51 @@ export function ConversationPage(props: { } /> {showDetails ? ( - - ) : null - } - usage={ - hasConversationUsageBreakdown({ - conversation, - detail: detail.data, - }) ? ( - - ) : null - } - /> + + { + // Memory rows show only in the Conversation view, and search + // can hide them. + if (view !== "rich") onViewChange("rich"); + setSearch(""); + setMemoryJump({ + memoryId: entry.memory.id, + source: entry.source, + turnId: entry.turnId, + }); + }} + links={conversationLinks(detail.data)} + overview={ + hasConversationStats({ + conversation, + detail: detail.data, + }) ? ( + + ) : null + } + usage={ + hasConversationUsageBreakdown({ + conversation, + detail: detail.data, + }) ? ( + + ) : null + } + /> + ) : null} {showEvent ? ( props.conversation.sourceTask && isAutomationTriggerMessage(props.conversation, entry.message) ? ( - +
+ + +
) : entry.message.eventType ? ( + ) : entry.message.context ? ( @@ -265,6 +277,10 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { message={entry.message} conversation={props.conversation} /> + ) : ( - + {isMemoryCaptureEvent(entry.part) ? ( + + } + timestamp={entry.timestamp} + turnId={entry.part.turnId} + /> + ) : ( + + )} )} renderSubagent={(entry) => ( diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx index d0ca6fb97e..6ff92af5a1 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx @@ -10,6 +10,7 @@ import { } from "../components/ui/tooltip"; import { formatMs } from "../format"; import { reviewItemIdFromToolCall } from "../review-desk/reviewItemFormat"; +import { isMemoryCaptureEvent } from "./conversationMemory"; import type { RenderedTranscriptEntry } from "./transcriptRenderModel"; import { cn } from "../styles"; import { useTranscriptSearch } from "./transcriptSearch"; @@ -30,6 +31,10 @@ export function isCollapsibleActivityEntry( // Child agent rows stay in the main view, like their result events. if (entry.kind === "subagent") return false; if (entry.kind === "message") return false; + // A Turn's captured memories stay visible, so the reader sees what it saved. + if (entry.kind === "structured_event" && isMemoryCaptureEvent(entry.part)) { + return false; + } // A review card stays visible where the agent added the item. if (entry.kind === "tool" && reviewItemIdFromToolCall(entry.part)) { return false; diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx new file mode 100644 index 0000000000..5ffd2611e7 --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx @@ -0,0 +1,179 @@ +import { Brain } from "lucide-react"; +import type { ReactElement, ReactNode } from "react"; +import { Link } from "react-router"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../components/ui/popover"; +import { formatMessageTimestamp, memoryPath } from "../format"; +import { cn } from "../styles"; +import type { TranscriptViewMessage } from "../types"; +import { + anchorMemories, + memoryAnchorKey, + useConversationMemoryState, + type ConversationMemory, + type ConversationMemoryState, + type MemoryAnchor, +} from "./conversationMemory"; +import { memoryRecallContent } from "./turnContext"; + +function memoryCount(count: number): string { + return `${count} ${count === 1 ? "memory" : "memories"}`; +} + +/** + * Open one transcript spot's memories in a popover, so the transcript keeps + * its length. The page owns which popover is open, so the details panel can + * open one too. + */ +function MemoryPopover(props: { + anchor: MemoryAnchor; + label: string; + memories: ConversationMemory[]; + state: ConversationMemoryState; + trigger: ReactElement; +}) { + const { anchor, state } = props; + const open = + state.open?.source === anchor.source && state.open.turnId === anchor.turnId; + const highlightId = open ? state.open?.memoryId : undefined; + return ( + state.setOpen(next ? anchor : undefined)} + open={open} + > + + +
    + {props.memories.map((memory) => ( +
  • node?.scrollIntoView({ block: "nearest" }) + : undefined + } + > +

    + {memory.content} +

    +
    + {memory.visibility === "private" ? Private : null} + + Open + +
    +
  • + ))} +
+
+
+ ); +} + +/** + * Show the memories that a prompt recalled, under that prompt. One prompt can + * start more than one Turn, and each Turn recalls, so each gets a chip. + */ +export function TranscriptRecalledMemories(props: { + className?: string; + message: TranscriptViewMessage; +}) { + const state = useConversationMemoryState(); + const anchors = [ + ...new Set( + props.message.contexts + ?.filter((context) => memoryRecallContent(context)) + .map((context) => context.turnId), + ), + ].flatMap((turnId) => { + const anchor = { source: "recalled", turnId } as const; + const memories = anchorMemories(state?.list, anchor); + return memories.length > 0 ? [{ anchor, memories }] : []; + }); + if (!state || anchors.length === 0) return null; + return ( +
+ {anchors.map(({ anchor, memories }) => { + const label = `Recalled ${memoryCount(memories.length)}`; + return ( + +
+ ); +} + +/** + * Show the memories that one Turn captured. Without memory state or a list + * for the Turn, such as in a nested transcript, show `fallback`. + */ +export function TranscriptCapturedMemories(props: { + fallback: ReactNode; + timestamp?: number; + turnId?: string; +}) { + const state = useConversationMemoryState(); + const anchor = props.turnId + ? ({ source: "captured", turnId: props.turnId } as const) + : undefined; + const memories = anchor ? anchorMemories(state?.list, anchor) : []; + if (!state || !anchor || memories.length === 0) return props.fallback; + const label = `Captured ${memoryCount(memories.length)}`; + const timestamp = formatMessageTimestamp(props.timestamp); + return ( + + + {label} + + {timestamp ? ( + + {timestamp} + + ) : null} + + } + /> + ); +} diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx index be0e6f232b..a7b5142ab7 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx @@ -24,6 +24,7 @@ import { RedactedMarker } from "./TranscriptRedacted"; import { SlackMark } from "./SlackMark"; import { DisplayText } from "../display-text/DisplayText"; import { TranscriptTurnContextView } from "./TranscriptTurnContextView"; +import { TranscriptRecalledMemories } from "./TranscriptMemoryPopover"; import { TranscriptTimestamp } from "./TranscriptTimestamp"; import { showsSlackSourceIcon } from "./transcriptSource"; @@ -83,6 +84,7 @@ export const TranscriptMessageView = memo( /> )) : text} + {props.message.attachments?.length ? ( ; +export type ConversationMemory = ConversationMemoryList["memories"][number]; +type MemorySource = ConversationMemoryList["events"][number]["source"]; + +/** One transcript spot that recalled or captured memories. */ +export type MemoryAnchor = { source: MemorySource; turnId: string }; + +/** Load what one Conversation recalled and captured. */ +export function useConversationMemories(conversationId: string) { + return useQuery({ + queryFn: ({ signal }) => + fetchDashboardJson( + conversationMemoryListSchema, + `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`, + signal, + ), + queryKey: ["conversation", conversationId, "memories"], + retry: false, + }); +} + +/** Name the transcript element that opens one anchor's popover. */ +export function memoryAnchorKey(anchor: MemoryAnchor): string { + return `${anchor.source}:${anchor.turnId}`; +} + +/** List the memories that one transcript spot recalled or captured. */ +export function anchorMemories( + list: ConversationMemoryList | undefined, + anchor: MemoryAnchor, +): ConversationMemory[] { + const event = list?.events.find( + (candidate) => + candidate.source === anchor.source && candidate.turnId === anchor.turnId, + ); + if (!event || !list) return []; + const byId = new Map(list.memories.map((memory) => [memory.id, memory])); + return event.memoryIds.flatMap((id) => { + const memory = byId.get(id); + return memory ? [memory] : []; + }); +} + +/** One row of the Conversation memory list. */ +export type ConversationMemoryEntry = { + memory: ConversationMemory; + source: MemorySource; + turnId?: string; +}; + +/** + * Flatten recalls and captures into one list in Conversation order. A memory + * shows once per source, at the first Turn that recalled or captured it. + */ +export function conversationMemoryEntries( + list: ConversationMemoryList | undefined, +): ConversationMemoryEntry[] { + if (!list) return []; + const byId = new Map(list.memories.map((memory) => [memory.id, memory])); + const seen = new Set(); + const entries: ConversationMemoryEntry[] = []; + for (const event of list.events) { + for (const id of event.memoryIds) { + const key = `${event.source}:${id}`; + const memory = byId.get(id); + if (seen.has(key) || !memory) continue; + seen.add(key); + entries.push({ + memory, + source: event.source, + ...(event.turnId ? { turnId: event.turnId } : undefined), + }); + } + } + return entries; +} + +/** The open memory popover, and the memory it highlights. */ +export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string }; + +/** Shared memory popover state for one Conversation page. */ +export type ConversationMemoryState = { + list?: ConversationMemoryList; + open?: OpenMemoryPopover; + setOpen(open: OpenMemoryPopover | undefined): void; +}; + +/** + * Let the transcript and the details panel share memory popovers. Only the + * Conversation page sets it, so nested transcripts show plain events. + */ +export const ConversationMemoryContext = createContext< + ConversationMemoryState | undefined +>(undefined); + +/** Read the memory popover state of the current Conversation page. */ +export function useConversationMemoryState() { + return useContext(ConversationMemoryContext); +} + +/** True for the memory plugin event that a Turn's capture emits. */ +export function isMemoryCaptureEvent(part: { + name: string; + namespace: string; +}): boolean { + return part.namespace === "memory" && part.name === "memories_captured"; +} diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 036ed78ab5..20676711fd 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -333,6 +333,7 @@ export function transcriptMessagesFromEvents( kind: data.kind, loadedAt: event.createdAt, pluginName: data.pluginName, + turnId: data.turnId, version: data.version, }); } @@ -417,6 +418,7 @@ export function transcriptMessagesFromEvents( name: data.name, version: data.version, presentation: data.presentation, + ...(data.turnId ? { turnId: data.turnId } : undefined), }, ]), ); diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 826b25f90f..2b206ebab2 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -91,6 +91,8 @@ export type TranscriptViewStructuredEventPart = { name: string; namespace: string; presentation: ConversationEventPresentation; + /** Turn that emitted the event. */ + turnId?: string; type: "structured_event"; version: number; }; @@ -114,6 +116,7 @@ export type TranscriptViewTurnContext = { kind: string; loadedAt: string; pluginName: string; + turnId: string; version: number; }; diff --git a/packages/junior-memory/src/api.ts b/packages/junior-memory/src/api.ts index 93b7e28962..642654f1c4 100644 --- a/packages/junior-memory/src/api.ts +++ b/packages/junior-memory/src/api.ts @@ -15,6 +15,7 @@ import type { MemoryDb } from "./store"; import { archiveMemory, countMemories, + getMemories, getMemory, getMemoryStats, getMemoryTimeline, @@ -24,7 +25,7 @@ import { MemoryNotFoundError, type MemoryView, } from "./viewer"; -import { parseCapturedMemories } from "./events"; +import { parseCapturedMemories, parseRecalledMemoryIds } from "./events"; import { MEMORY_SOURCE_PLATFORMS } from "./types"; export const memoryApiSchema = z @@ -50,16 +51,30 @@ export const memoryListResponseSchema = z export const conversationMemorySchema = z .object({ - capturedAt: z.iso.datetime(), content: z.string().min(1), id: z.string().min(1), - kind: z.enum(["preference", "procedure", "knowledge"]), visibility: z.enum(["private", "public"]), }) .strict(); +/** One recall or capture in a Conversation, and the memories it touched. */ +const conversationMemoryEventSchema = z + .object({ + memoryIds: z.array(z.string().min(1)).min(1), + source: z.enum(["captured", "recalled"]), + turnId: z.string().min(1).optional(), + }) + .strict(); + +/** + * Memories that a Conversation recalled or captured. `events` are in + * Conversation order, and each id in them is a key of `memories`. + */ export const conversationMemoryListResponseSchema = z - .object({ memories: z.array(conversationMemorySchema) }) + .object({ + events: z.array(conversationMemoryEventSchema), + memories: z.array(conversationMemorySchema), + }) .strict(); const memoryBucketSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/); @@ -180,6 +195,91 @@ function viewerEmail(context: unknown): string | undefined { return parsed.data.auth.user.email?.trim().toLowerCase() || undefined; } +/** + * List what one Conversation recalled and captured, in Conversation order. + * + * Captured memories come from the event snapshot. Recall events store only + * ids, so recalled memories are read from the store, and the viewer sees only + * the active memories they can access. + */ +async function conversationMemories(args: { + conversationEvents: PluginConversationEventReader; + conversationId: string; + db: MemoryDb; + viewer: User; +}): Promise | undefined> { + const [captured, recalled] = await Promise.all( + (["memories_captured", "memories_recalled"] as const).map((eventName) => + args.conversationEvents.list({ + conversationId: args.conversationId, + eventName, + viewer: args.viewer, + }), + ), + ); + if (!captured || !recalled) return undefined; + + const memories = new Map>(); + const capturedEvents = captured.map((event) => { + const eventMemories = parseCapturedMemories(event.version, event.content); + for (const memory of eventMemories) { + memories.set(memory.id, { + content: memory.content, + id: memory.id, + visibility: currentMemoryVisibility(memory.scope), + }); + } + return { + createdAt: event.createdAt, + memoryIds: eventMemories.map((memory) => memory.id), + source: "captured" as const, + turnId: event.turnId, + }; + }); + const recalledEvents = recalled.map((event) => ({ + createdAt: event.createdAt, + memoryIds: parseRecalledMemoryIds(event.version, event.content), + source: "recalled" as const, + turnId: event.turnId, + })); + const capturedIds = new Set(memories.keys()); + // A recall shows only memories the viewer can read in the store now, even + // when the same memory is in a capture snapshot. + const recalledIds = new Set(); + for (const memory of await getMemories(args.db, args.viewer.id, [ + ...new Set(recalledEvents.flatMap((event) => event.memoryIds)), + ])) { + recalledIds.add(memory.id); + if (memories.has(memory.id)) continue; + memories.set(memory.id, { + content: memory.content, + id: memory.id, + visibility: memory.visibility, + }); + } + + // ISO timestamps sort as text. Recall runs before its Turn replies and + // capture runs after, so a stable sort keeps one Turn in that order. + const events = [...recalledEvents, ...capturedEvents] + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .flatMap(({ memoryIds, source, turnId }) => { + const shown = source === "captured" ? capturedIds : recalledIds; + const visibleIds = memoryIds.filter((id) => shown.has(id)); + if (visibleIds.length === 0) return []; + return [ + { + memoryIds: visibleIds, + source, + ...(turnId ? { turnId } : undefined), + }, + ]; + }); + return conversationMemoryListResponseSchema.parse({ + events, + memories: [...memories.values()], + }); +} + /** Create the authenticated memory REST app. */ export function createMemoryApi(options: MemoryApiOptions): PluginRouteApp { return { @@ -294,35 +394,13 @@ export function createMemoryApi(options: MemoryApiOptions): PluginRouteApp { } if (conversationPath && isRead) { - const events = await options.conversationEvents.list({ + const body = await conversationMemories({ + conversationEvents: options.conversationEvents, conversationId: decodeURIComponent(conversationPath[1]!), - eventName: "memories_captured", + db: options.db, viewer, }); - if (!events) return json({ error: "Conversation not found." }, 404); - const memories = new Map< - string, - z.input - >(); - for (const event of events) { - for (const memory of parseCapturedMemories( - event.version, - event.content, - )) { - if (!memories.has(memory.id)) { - memories.set(memory.id, { - capturedAt: event.createdAt, - content: memory.content, - id: memory.id, - kind: memory.kind, - visibility: currentMemoryVisibility(memory.scope), - }); - } - } - } - const body = conversationMemoryListResponseSchema.parse({ - memories: [...memories.values()], - }); + if (!body) return json({ error: "Conversation not found." }, 404); return request.method === "HEAD" ? new Response(null, { headers: { "cache-control": "no-store" }, diff --git a/packages/junior-memory/src/events.ts b/packages/junior-memory/src/events.ts index 67f932aab9..615151ea31 100644 --- a/packages/junior-memory/src/events.ts +++ b/packages/junior-memory/src/events.ts @@ -63,7 +63,7 @@ function renderCapturedMemories(event: { if (count === 0) return undefined; return { icon: "brain" as const, - title: `${count} ${count === 1 ? "memory" : "memories"} captured`, + title: `Captured ${count} ${count === 1 ? "memory" : "memories"}`, details: event.memories.map((memory) => ({ title: memory.content, metadata: [memory.kind, currentScope(memory.scope)], @@ -115,6 +115,17 @@ export function parseCapturedMemories( return []; } +/** Parse the memory ids from one supported stored recall event. */ +export function parseRecalledMemoryIds( + version: number, + content: unknown, +): string[] { + if (version === memoriesRecalledEvent.version) { + return recalledMemoriesSchema.parse(content).memories; + } + return []; +} + /** Select the stable, safe memory fields retained in conversation history. */ export function capturedMemory(memory: MemoryRecord) { return { diff --git a/packages/junior-memory/src/viewer.ts b/packages/junior-memory/src/viewer.ts index 74365a1c44..9365727531 100644 --- a/packages/junior-memory/src/viewer.ts +++ b/packages/junior-memory/src/viewer.ts @@ -10,6 +10,7 @@ import { eq, gt, ilike, + inArray, isNull, like, lt, @@ -254,6 +255,25 @@ export async function getMemory( return toMemoryView(rows[0]); } +/** Read the active memories in `ids` that the authenticated User can see. */ +export async function getMemories( + db: MemoryDb, + userId: string, + ids: readonly string[], +): Promise { + if (ids.length === 0) return []; + const rows = await db + .select() + .from(juniorMemoryMemories) + .where( + and( + activeMemoryPredicate(userId, Date.now()), + inArray(juniorMemoryMemories.id, [...ids]), + ), + ); + return rows.map(toMemoryView); +} + /** List one stable page of active memory visible to the authenticated User. */ export async function listMemories( db: MemoryDb, diff --git a/packages/junior-memory/tests/events.test.ts b/packages/junior-memory/tests/events.test.ts index eb91722c67..cc72dca937 100644 --- a/packages/junior-memory/tests/events.test.ts +++ b/packages/junior-memory/tests/events.test.ts @@ -71,7 +71,7 @@ describe("memory conversation events", () => { ], }); - expect(presentation?.title).toBe("1 memory captured"); + expect(presentation?.title).toBe("Captured 1 memory"); expect(presentation?.preview).toBeUndefined(); expect(presentation?.details?.[0]?.title).toBe(content); }); @@ -96,6 +96,6 @@ describe("memory conversation events", () => { ], }); - expect(presentation?.title).toBe("2 memories captured"); + expect(presentation?.title).toBe("Captured 2 memories"); }); }); diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index d18d45814e..51b943e0a8 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -2527,6 +2527,29 @@ describe("memory plugin storage", () => { conversationEvents: { async list({ conversationId, eventName }) { if (conversationId === "missing") return undefined; + if (eventName === "memories_recalled") { + return [ + { + content: { memories: [privateMemory.memory.id] }, + createdAt: "2026-07-28T12:02:00.000Z", + version: 1, + }, + { + content: { memories: [hidden.memory.id] }, + createdAt: "2026-07-28T12:00:30.000Z", + turnId: "turn-2", + version: 1, + }, + { + content: { + memories: [publicMemory.memory.id, hidden.memory.id], + }, + createdAt: "2026-07-28T11:59:00.000Z", + turnId: "turn-1", + version: 1, + }, + ]; + } expect(eventName).toBe("memories_captured"); return [ { @@ -2539,9 +2562,17 @@ describe("memory plugin storage", () => { observedAtMs: TEST_NOW_MS, scope: "public", }, + { + content: "Hidden viewer memory.", + id: hidden.memory.id, + kind: "knowledge", + observedAtMs: TEST_NOW_MS, + scope: "private", + }, ], }, createdAt: "2026-07-28T12:01:00.000Z", + turnId: "turn-2", version: 2, }, { @@ -2557,6 +2588,7 @@ describe("memory plugin storage", () => { ], }, createdAt: "2026-07-28T12:00:00.000Z", + turnId: "turn-1", version: 2, }, ]; @@ -2639,15 +2671,45 @@ describe("memory plugin storage", () => { await conversationResponse.json(), ), ).toEqual({ - memories: [ + // The viewer cannot see `hidden` in the store, so its recall drops + // it even though a capture snapshot has it, and a recall with only + // hidden memories drops out. + events: [ { - capturedAt: "2026-07-28T12:01:00.000Z", - content: "Use pnpm.", - id: "captured-memory", - kind: "procedure", - visibility: "public", + memoryIds: [publicMemory.memory.id], + source: "recalled", + turnId: "turn-1", }, + { + memoryIds: ["captured-memory"], + source: "captured", + turnId: "turn-1", + }, + { + memoryIds: ["captured-memory", hidden.memory.id], + source: "captured", + turnId: "turn-2", + }, + { memoryIds: [privateMemory.memory.id], source: "recalled" }, ], + memories: expect.arrayContaining([ + { content: "Use pnpm.", id: "captured-memory", visibility: "public" }, + { + content: "Hidden viewer memory.", + id: hidden.memory.id, + visibility: "private", + }, + { + content: "Public workspace memory.", + id: publicMemory.memory.id, + visibility: "public", + }, + { + content: "Private conversation memory.", + id: privateMemory.memory.id, + visibility: "private", + }, + ]), }); const missingConversationResponse = await api.fetch( new Request("http://localhost/conversations/missing/memories"), diff --git a/packages/junior-plugin-api/src/conversation-events.ts b/packages/junior-plugin-api/src/conversation-events.ts index 652e760766..dd5d5b32e0 100644 --- a/packages/junior-plugin-api/src/conversation-events.ts +++ b/packages/junior-plugin-api/src/conversation-events.ts @@ -127,6 +127,8 @@ export interface PluginConversationEventCostDay { export interface PluginConversationEventRecord { content: Record; createdAt: string; + /** Turn that emitted the event. Events from outside a Turn have none. */ + turnId?: string; version: number; } diff --git a/packages/junior/src/chat/plugins/conversation-event-reader.ts b/packages/junior/src/chat/plugins/conversation-event-reader.ts index f974d6876c..054d8504dc 100644 --- a/packages/junior/src/chat/plugins/conversation-event-reader.ts +++ b/packages/junior/src/chat/plugins/conversation-event-reader.ts @@ -46,6 +46,9 @@ export function createPluginConversationEventReader( Record >`${juniorConversationEvents.payload}->'content'`, createdAt: juniorConversationEvents.createdAt, + turnId: sql< + string | null + >`${juniorConversationEvents.payload}->>'turnId'`, version: sql`(${juniorConversationEvents.payload}->>'version')::integer`, }) .from(juniorConversationEvents) @@ -61,6 +64,7 @@ export function createPluginConversationEventReader( return rows.map((row) => ({ content: row.content, createdAt: row.createdAt.toISOString(), + ...(row.turnId ? { turnId: row.turnId } : undefined), version: row.version, })); },