diff --git a/README.md b/README.md index e380b57..cb280f9 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ T4 Code needs an OMP build with desktop appserver support. For v0.1.24, use the T4 Code v0.1.24 was verified with OMP 17.0.4 built from [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), tagged [`t4code-17.0.4-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). That public integration is based on the official upstream [`v17.0.4`](https://github.com/can1357/oh-my-pi/tree/v17.0.4) tag at [`3fdd85ab`](https://github.com/can1357/oh-my-pi/commit/3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0). It adds redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.4 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.0 from integration commit [`ae4b53b4`](https://github.com/lyc-aon/oh-my-pi/commit/ae4b53b416f32b200865a32ed9baabd5a4666fa4), source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`. +The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.6.1 from integration commit [`e3e15c03`](https://github.com/lyc-aon/oh-my-pi/commit/e3e15c03ae95ebbda5f26495cd21213cc53518b1), source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`. This adds the bounded cross-session transcript search and historical context contract. + | Platform | Arch | Package | | -------- | --------------------- | ----------------------------------------- | | Android | arm64, armv7, x86_64 | `.apk` (**signed**) | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f2a7fa2..3ac7a34 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd ## Oh My Pi -Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.6.0` package is packed from the public `lyc-aon/oh-my-pi` integration commit `ae4b53b416f32b200865a32ed9baabd5a4666fa4`, source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`; tarball SHA-256 `92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c`; golden corpus SHA-256 `7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. +Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.6.1` package is packed from the public `lyc-aon/oh-my-pi` integration commit `e3e15c03ae95ebbda5f26495cd21213cc53518b1`, source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`; tarball SHA-256 `78ec6223e7ad0f4f9e14526a822eaa45b363c856065c08196b2817a2f42740b9`; golden corpus SHA-256 `d5e674095de3d9b3b56a5668bc91cbbf1904b409ea9ea6456c2eabdf272e7870`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. ## Oh My Pi icon diff --git a/apps/desktop/test/target-manager.test.ts b/apps/desktop/test/target-manager.test.ts index 33dcfa4..627f3bf 100644 --- a/apps/desktop/test/target-manager.test.ts +++ b/apps/desktop/test/target-manager.test.ts @@ -464,8 +464,10 @@ describe("desktop target manager boundaries", () => { }; expect(firstHello.requestedFeatures).toContain("prompt.images"); expect(firstHello.requestedFeatures).toContain("transcript.images"); + expect(firstHello.requestedFeatures).toContain("transcript.search"); expect(fallbackHello.requestedFeatures).not.toContain("prompt.images"); expect(fallbackHello.requestedFeatures).not.toContain("transcript.images"); + expect(fallbackHello.requestedFeatures).toContain("transcript.search"); await runtime.close(); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1aea3a4..8c22f53 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -3,10 +3,12 @@ // focus (dialog primitive owns the focus contract). import { cn, Dialog, DialogPopup, StatusPill } from "@t4-code/ui"; import { useNavigate } from "@tanstack/react-router"; -import { CornerDownLeft } from "lucide-react"; +import { CornerDownLeft, Search } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import type { ProjectGroup } from "../lib/session-tree.ts"; +import { handoffTranscriptSearchQuery } from "../features/transcript-search/index.ts"; +import { TRANSCRIPT_SEARCH_ROUTE } from "../features/transcript-search/route.ts"; import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; import { resolveTheme } from "../theme/theme.ts"; interface PaletteItem { @@ -21,6 +23,7 @@ function buildItems( groups: readonly ProjectGroup[], navigate: (sessionId: string) => void, openInbox: () => void, + openTranscriptSearch: (query: string) => void, openAgentView: () => void, openSettings: () => void, ): PaletteItem[] { @@ -66,6 +69,13 @@ function buildItems( status: null, run: openInbox, }, + { + id: "action:transcript-search", + label: "Open transcript search", + hint: "Prior decisions and code discussions", + status: , + run: () => openTranscriptSearch(""), + }, { id: "action:agents", label: "Open Agent View", @@ -101,6 +111,10 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] }) () => { void navigate({ to: "/inbox" }); }, + (searchQuery) => { + handoffTranscriptSearchQuery(searchQuery); + void navigate({ to: TRANSCRIPT_SEARCH_ROUTE }); + }, () => { void navigate({ to: "/agents" }); }, @@ -112,10 +126,26 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] }) ); const needle = query.trim().toLowerCase(); - const filtered = + const baseFiltered = needle === "" ? items : items.filter((item) => `${item.label} ${item.hint}`.toLowerCase().includes(needle)); + const filtered = + needle.length < 2 + ? baseFiltered + : [ + ...baseFiltered, + { + id: "action:transcript-search-query", + label: "View all transcript results", + hint: `Search for “${query.trim()}”`, + status: , + run: () => { + handoffTranscriptSearchQuery(query.trim()); + void navigate({ to: TRANSCRIPT_SEARCH_ROUTE }); + }, + }, + ]; useEffect(() => { setHighlighted(0); @@ -143,7 +173,7 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] }) return ( workspaceStore.getState().setPaletteOpen(next)} open={open}> @@ -166,7 +196,7 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] }) runItem(filtered[highlighted]); } }} - placeholder="Search sessions and commands" + placeholder="Search sessions, transcripts, and commands" role="combobox" type="text" value={query} @@ -180,7 +210,7 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] }) > {filtered.length === 0 && ( - Nothing matches "{query}". Try a session title or project name. + Nothing matches "{query}". Try a session title, project, or transcript phrase. )} {filtered.map((item, index) => ( diff --git a/apps/web/src/components/Titlebar.tsx b/apps/web/src/components/Titlebar.tsx index 930289e..4a278fc 100644 --- a/apps/web/src/components/Titlebar.tsx +++ b/apps/web/src/components/Titlebar.tsx @@ -3,7 +3,7 @@ // Linux window controls are injected by the desktop shell later. import { Badge, BrandLockup, IconButton, Tooltip, TooltipPopup, TooltipTrigger } from "@t4-code/ui"; import { useNavigate } from "@tanstack/react-router"; -import { Command, Moon, PanelLeft, Settings, Sun, UsersRound } from "lucide-react"; +import { Moon, PanelLeft, Search, Settings, Sun, UsersRound } from "lucide-react"; import { useEffect } from "react"; import { updateIsAvailable } from "../features/updates/update-model.ts"; @@ -149,16 +149,16 @@ export function Titlebar({ workspaceStore.getState().setPaletteOpen(true)} size="icon-sm" > - + } /> - Search sessions and commands (Ctrl+K) + Search sessions and transcripts (Ctrl+K) diff --git a/apps/web/src/features/transcript-search/LiveTranscriptSearch.tsx b/apps/web/src/features/transcript-search/LiveTranscriptSearch.tsx new file mode 100644 index 0000000..cdbc7a3 --- /dev/null +++ b/apps/web/src/features/transcript-search/LiveTranscriptSearch.tsx @@ -0,0 +1,224 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { desktopRuntime } from "../../platform/desktop-runtime.ts"; +import { useShellData } from "../../state/shell-data.ts"; +import { fixtureTranscriptSearchSource } from "./fixtures.ts"; +import { LatestTranscriptSearchExecutor } from "./execution.ts"; +import type { HistoricContextState, TranscriptSearchPhase } from "./TranscriptSearchScreen.tsx"; +import { TranscriptSearchScreen } from "./TranscriptSearchScreen.tsx"; +import { + DEFAULT_TRANSCRIPT_SEARCH_FILTERS, + type TranscriptSearchFilters, + type TranscriptSearchResponse, + type TranscriptSearchResult, + transcriptSearchCanRun, +} from "./model.ts"; +import { + setTranscriptSearchQuery, + TranscriptSearchHandoffTracker, + useTranscriptSearchMemory, +} from "./search-memory.ts"; +import { clientTranscriptSearchSource } from "./source.ts"; +import type { DesktopRuntimeController } from "@t4-code/client"; +import type { WorkspaceData } from "../../lib/workspace-data.ts"; + +/** Browser-direct has a real controller; only the disconnected showcase uses fixtures. */ +export function selectTranscriptSearchSource( + controller: DesktopRuntimeController | null, + data: WorkspaceData, +) { + return controller === null + ? fixtureTranscriptSearchSource + : clientTranscriptSearchSource(controller, data); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim() !== "") return error.message; + return "The host did not return a usable response."; +} + +/** Keep a untouched search page idle, but supersede any active or completed search. */ +export function shouldRefreshTranscriptSearchForFilters( + phase: TranscriptSearchPhase, + response: TranscriptSearchResponse | null, +): boolean { + return phase === "searching" || response !== null; +} + +/** Live route adapter: ephemeral UI state in, client-owned host fan-out out. */ +export function LiveTranscriptSearch() { + const navigate = useNavigate(); + const shellData = useShellData(); + const controller = desktopRuntime(); + const searchMemory = useTranscriptSearchMemory(); + const query = searchMemory.query; + const [filters, setFilters] = useState( + DEFAULT_TRANSCRIPT_SEARCH_FILTERS, + ); + const [phase, setPhase] = useState("idle"); + const [response, setResponse] = useState(null); + const [error, setError] = useState(); + const [historicContext, setHistoricContext] = useState(null); + const [loadingMoreHostId, setLoadingMoreHostId] = useState(null); + const [loadMoreError, setLoadMoreError] = useState(); + const searchExecutor = useRef(new LatestTranscriptSearchExecutor()).current; + const contextAbort = useRef(null); + const loadMoreAbort = useRef(null); + const disposeTimer = useRef(null); + const handoffTracker = useRef(new TranscriptSearchHandoffTracker()).current; + + const source = useMemo( + () => selectTranscriptSearchSource(controller, shellData), + [controller, shellData], + ); + const projects = useMemo( + () => + [...shellData.projects] + .map((project) => ({ id: project.id, label: project.name })) + .sort((left, right) => left.label.localeCompare(right.label)), + [shellData.projects], + ); + + const runSearch = useCallback( + async ( + nextFilters: TranscriptSearchFilters = filters, + nextQuery: string = query, + ) => { + if (!transcriptSearchCanRun(nextQuery)) return; + loadMoreAbort.current?.abort(); + await searchExecutor.run( + source, + { query: nextQuery.trim(), filters: nextFilters }, + { + onStart: () => { + setPhase("searching"); + setError(undefined); + setLoadingMoreHostId(null); + setLoadMoreError(undefined); + }, + onSuccess: (next) => { + setResponse(next); + setPhase("complete"); + }, + onError: (caught) => { + setResponse(null); + setError(errorMessage(caught)); + setPhase("error"); + }, + }, + ); + }, + [filters, query, searchExecutor, source], + ); + + useLayoutEffect(() => { + const handoff = handoffTracker.take(searchMemory); + if (handoff === null) return; + searchExecutor.cancel(); + contextAbort.current?.abort(); + loadMoreAbort.current?.abort(); + setHistoricContext(null); + setResponse(null); + setError(undefined); + setLoadMoreError(undefined); + setLoadingMoreHostId(null); + setPhase("idle"); + if (transcriptSearchCanRun(handoff.query)) { + void runSearch(filters, handoff.query); + } + }, [filters, handoffTracker, runSearch, searchExecutor, searchMemory]); + + useEffect(() => { + // StrictMode immediately runs setup → cleanup → setup in development. + // Defer disposal one tick so that probe cannot cancel the palette handoff. + if (disposeTimer.current !== null) window.clearTimeout(disposeTimer.current); + return () => { + disposeTimer.current = window.setTimeout(() => { + searchExecutor.cancel(); + contextAbort.current?.abort(); + loadMoreAbort.current?.abort(); + }, 0); + }; + }, [searchExecutor]); + + const changeFilters = (next: TranscriptSearchFilters) => { + setFilters(next); + if ( + shouldRefreshTranscriptSearchForFilters(phase, response) && + transcriptSearchCanRun(query) + ) { + void runSearch(next); + } + }; + + const openResult = async (result: TranscriptSearchResult) => { + contextAbort.current?.abort(); + const abort = new AbortController(); + contextAbort.current = abort; + setHistoricContext({ result, phase: "loading" }); + try { + const context = await source.context(result, abort.signal); + if (!abort.signal.aborted) setHistoricContext({ result, phase: "ready", context }); + } catch (caught) { + if (!abort.signal.aborted) { + setHistoricContext({ result, phase: "error", error: errorMessage(caught) }); + } + } + }; + + return ( + { + contextAbort.current?.abort(); + setHistoricContext(null); + }} + onFiltersChange={changeFilters} + onOpenLiveTail={(result) => { + contextAbort.current?.abort(); + void navigate({ params: { sessionId: result.sessionViewId }, to: "/sessions/$sessionId" }); + }} + loadingMoreHostId={loadingMoreHostId} + loadMoreError={loadMoreError} + onLoadMoreHost={ + source.loadMore === undefined + ? undefined + : (hostId) => { + loadMoreAbort.current?.abort(); + const abort = new AbortController(); + loadMoreAbort.current = abort; + setLoadingMoreHostId(hostId); + setLoadMoreError(undefined); + void source + .loadMore?.(hostId, abort.signal) + .then((next) => { + if (!abort.signal.aborted) setResponse(next); + }) + .catch((caught) => { + if (!abort.signal.aborted) setLoadMoreError(errorMessage(caught)); + }) + .finally(() => { + if (!abort.signal.aborted) setLoadingMoreHostId(null); + }); + } + } + onOpenResult={(result) => void openResult(result)} + onQueryChange={(next) => { + setTranscriptSearchQuery(next); + if (phase !== "idle") { + searchExecutor.cancel(); + setResponse(null); + setPhase("idle"); + } + }} + onSubmit={() => void runSearch()} + phase={phase} + projects={projects} + query={query} + response={response} + /> + ); +} diff --git a/apps/web/src/features/transcript-search/TranscriptSearchScreen.tsx b/apps/web/src/features/transcript-search/TranscriptSearchScreen.tsx new file mode 100644 index 0000000..23b511a --- /dev/null +++ b/apps/web/src/features/transcript-search/TranscriptSearchScreen.tsx @@ -0,0 +1,469 @@ +import { + Badge, + Button, + cn, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Spinner, +} from "@t4-code/ui"; +import { + Archive, + ArrowLeft, + Bot, + CheckCircle2, + CircleAlert, + CloudOff, + Search, + Server, + UserRound, +} from "lucide-react"; +import type { FormEvent, ReactNode } from "react"; + +import type { + HistoricTranscriptContext, + TranscriptHostSearchState, + TranscriptRole, + TranscriptSearchFilters, + TranscriptSearchResponse, + TranscriptSearchResult, +} from "./model.ts"; +import { + plainTextHighlightSegments, + transcriptSearchCanRun, + transcriptSearchIsPartial, +} from "./model.ts"; + +export type TranscriptSearchPhase = "idle" | "searching" | "complete" | "error"; + +export type HistoricContextState = + | null + | { + readonly result: TranscriptSearchResult; + readonly phase: "loading" | "ready" | "error"; + readonly context?: HistoricTranscriptContext; + readonly error?: string; + }; + +export interface TranscriptSearchScreenProps { + readonly query: string; + readonly filters: TranscriptSearchFilters; + readonly projects: readonly { readonly id: string; readonly label: string }[]; + readonly phase: TranscriptSearchPhase; + readonly response: TranscriptSearchResponse | null; + readonly error: string | undefined; + readonly historicContext: HistoricContextState; + readonly onQueryChange: (query: string) => void; + readonly onFiltersChange: (filters: TranscriptSearchFilters) => void; + readonly onSubmit: () => void; + readonly onOpenResult: (result: TranscriptSearchResult) => void; + readonly onCloseHistoricContext: () => void; + readonly onOpenLiveTail: (result: TranscriptSearchResult) => void; + readonly onLoadMoreHost: ((hostId: string) => void) | undefined; + readonly loadingMoreHostId: string | null; + readonly loadMoreError: string | undefined; +} + +function roleLabel(role: TranscriptRole): string { + if (role === "user") return "You"; + if (role === "assistant") return "Assistant"; + return "Summary"; +} + +function RoleIcon({ role }: { readonly role: TranscriptRole }) { + if (role === "user") return ; + return ; +} + +function resultTime(value: string): string { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +function HighlightedPlainText({ text, query }: { readonly text: string; readonly query: string }) { + return ( + <> + {plainTextHighlightSegments(text, query).map((segment, index) => + segment.highlighted ? ( + + {segment.text} + + ) : ( + segment.text + ), + )} + > + ); +} + +const HOST_PRESENTATION: Readonly< + Record +> = { + searched: { + label: "Searched", + icon: , + tone: "text-status-done", + }, + offline: { + label: "Offline", + icon: , + tone: "text-muted-foreground", + }, + unsupported: { + label: "Needs update", + icon: , + tone: "text-status-plan", + }, + indexing: { + label: "Indexing", + icon: , + tone: "text-status-plan", + }, + error: { + label: "Failed", + icon: , + tone: "text-status-error", + }, +}; + +function HostStatusList({ + response, + onLoadMoreHost, + loadingMoreHostId, +}: { + readonly response: TranscriptSearchResponse; + readonly onLoadMoreHost: ((hostId: string) => void) | undefined; + readonly loadingMoreHostId: string | null; +}) { + return ( + + + Host search status + + + {response.hosts.map((host) => { + const presentation = HOST_PRESENTATION[host.state]; + return ( + + + {host.hostLabel} + + {presentation.icon} + {presentation.label} + + {host.resultCount !== undefined && ( + + {host.resultCount} {host.resultCount === 1 ? "result" : "results"} + + )} + {host.message !== undefined && ( + {host.message} + )} + {host.hasMore && onLoadMoreHost !== undefined && ( + onLoadMoreHost(host.hostId)} + size="xs" + variant="outline" + > + {loadingMoreHostId === host.hostId && } + Load more from {host.hostLabel} + + )} + + ); + })} + + + ); +} + +function SearchResults({ + response, + query, + onOpenResult, +}: { + readonly response: TranscriptSearchResponse; + readonly query: string; + readonly onOpenResult: (result: TranscriptSearchResult) => void; +}) { + if (response.results.length === 0) { + const incomplete = transcriptSearchIsPartial(response); + return ( + + + + + + {incomplete ? "No results from the hosts that answered" : "No transcript matches"} + + {incomplete + ? "Some hosts were not searched. Reconnect or update them, then try again." + : "Try fewer words or broaden the project, role, and archive filters."} + + + + ); + } + return ( + + + + {response.results.length} {response.results.length === 1 ? "match" : "matches"} + + {response.truncated && More may exist} + + + {response.results.map((result) => ( + + onOpenResult(result)} + type="button" + > + + + + {roleLabel(result.role)} + + {result.projectLabel} + · + {result.sessionTitle} + {result.archived && ( + + + Archived + + )} + {resultTime(result.occurredAt)} + + + + + {result.hostLabel} · Open older context + + + ))} + + + ); +} + +function HistoricContext({ + state, + onClose, + onOpenLiveTail, +}: { + readonly state: Exclude; + readonly onClose: () => void; + readonly onOpenLiveTail: (result: TranscriptSearchResult) => void; +}) { + const { result } = state; + return ( + + + + + + Viewing older transcript context + + This is a read-only window around the matched message. Your live session stays separate and keeps receiving new output. + + + + + + Back to results + + onOpenLiveTail(result)} size="xs"> + Open live tail + + + + + {result.sessionTitle} + + {result.projectLabel} · {result.hostLabel} + + + {state.phase === "loading" && ( + + + Loading the surrounding messages… + + )} + {state.phase === "error" && ( + + + + Older context could not load + {state.error ?? "The host did not return this transcript window."} + + + )} + {state.phase === "ready" && state.context !== undefined && ( + + {state.context.hasBefore && ( + Earlier messages are available on the host + )} + {state.context.rows.map((row, index) => ( + + + {roleLabel(row.role)} + {resultTime(row.occurredAt)} + {index === state.context?.anchorIndex && Search match} + + {row.text} + + ))} + {state.context.hasAfter && ( + Later messages are available on the host + )} + + )} + + + ); +} + +export function TranscriptSearchScreen(props: TranscriptSearchScreenProps) { + const submit = (event: FormEvent) => { + event.preventDefault(); + if (transcriptSearchCanRun(props.query)) props.onSubmit(); + }; + return ( + + + + Transcript search + Queries and snippets stay in memory + + {props.historicContext !== null ? ( + + ) : ( + + + + + + Search prior transcript discussions + props.onQueryChange(event.target.value)} + placeholder="Find a decision, error, file name, or code discussion" + spellCheck={false} + type="search" + value={props.query} + /> + + + {props.phase === "searching" ? : } + Search + + + + + Project + props.onFiltersChange({ ...props.filters, projectId: event.target.value === "" ? null : event.target.value })} + value={props.filters.projectId ?? ""} + > + All projects + {props.projects.map((project) => {project.label})} + + + + Speaker + props.onFiltersChange({ ...props.filters, role: event.target.value as TranscriptSearchFilters["role"] })} + value={props.filters.role} + > + Anyone + You + Assistant + Summary + + + + Sessions + props.onFiltersChange({ ...props.filters, archived: event.target.value as TranscriptSearchFilters["archived"] })} + value={props.filters.archived} + > + Current and archived + Current only + Archived only + + + + + {props.phase === "idle" && ( + + + + Find a past decision without finding the session first + Search message text across connected hosts. Results include archived sessions by default. + + + )} + {props.phase === "searching" && props.response === null && ( + + + Asking each connected host… + + )} + {props.phase === "error" && ( + + + + Search could not start + {props.error ?? "Try again after the host connection recovers."} + + + )} + {props.response !== null && ( + <> + + {props.loadMoreError !== undefined && ( + + {props.loadMoreError} + + )} + + > + )} + + + )} + + ); +} diff --git a/apps/web/src/features/transcript-search/execution.ts b/apps/web/src/features/transcript-search/execution.ts new file mode 100644 index 0000000..33f0d38 --- /dev/null +++ b/apps/web/src/features/transcript-search/execution.ts @@ -0,0 +1,47 @@ +import type { + TranscriptSearchRequest, + TranscriptSearchResponse, + TranscriptSearchSource, +} from "./model.ts"; + +export interface TranscriptSearchRunCallbacks { + readonly onStart: () => void; + readonly onSuccess: (response: TranscriptSearchResponse) => void; + readonly onError: (error: unknown) => void; +} +/** + * Owns the latest search request. A source that ignores abort still cannot + * publish an older result after a newer palette handoff has started. + */ +export class LatestTranscriptSearchExecutor { + private generation = 0; + private controller: AbortController | null = null; + + cancel(): void { + this.generation += 1; + this.controller?.abort(); + this.controller = null; + } + + async run( + source: TranscriptSearchSource, + request: TranscriptSearchRequest, + callbacks: TranscriptSearchRunCallbacks, + ): Promise { + this.controller?.abort(); + const generation = ++this.generation; + const controller = new AbortController(); + this.controller = controller; + callbacks.onStart(); + try { + const response = await source.search(request, controller.signal); + if (controller.signal.aborted || generation !== this.generation) return; + this.controller = null; + callbacks.onSuccess(response); + } catch (error) { + if (controller.signal.aborted || generation !== this.generation) return; + this.controller = null; + callbacks.onError(error); + } + } +} diff --git a/apps/web/src/features/transcript-search/fixtures.ts b/apps/web/src/features/transcript-search/fixtures.ts new file mode 100644 index 0000000..0013a5a --- /dev/null +++ b/apps/web/src/features/transcript-search/fixtures.ts @@ -0,0 +1,159 @@ +import type { + HistoricTranscriptContext, + TranscriptSearchRequest, + TranscriptSearchResponse, + TranscriptSearchResult, + TranscriptSearchSource, +} from "./model.ts"; + +const NOW = Date.UTC(2026, 6, 18, 18, 0, 0); + +const FIXTURE_RESULTS: readonly TranscriptSearchResult[] = [ + { + key: "host-local:sess-stream:entry-reconnect-decision", + hostId: "host-local", + hostLabel: "This machine", + sessionId: "sess-stream", + sessionViewId: "sess-stream", + entryId: "entry-reconnect-decision", + sessionTitle: "Trace duplicate stream frames after reconnect", + projectId: "proj-omp", + projectLabel: "oh-my-pi", + role: "assistant", + snippet: + "The reconnect decision was to keep the durable cursor and ignore duplicate stream frames already applied to the projection.", + occurredAt: new Date(NOW - 34 * 60_000).toISOString(), + archived: false, + }, + { + key: "host-local:sess-fixtures:entry-protocol-decision", + hostId: "host-local", + hostLabel: "This machine", + sessionId: "sess-fixtures", + sessionViewId: "sess-fixtures", + entryId: "entry-protocol-decision", + sessionTitle: "Pin protocol fixtures for desktop CI", + projectId: "proj-t4", + projectLabel: "t4-code", + role: "user", + snippet: + "Keep both strict and legacy protocol fixtures. The compatibility decision needs an explicit release test.", + occurredAt: new Date(NOW - 3 * 60 * 60_000).toISOString(), + archived: false, + }, + { + key: "host-local:sess-settings:entry-storage-boundary", + hostId: "host-local", + hostLabel: "This machine", + sessionId: "sess-settings", + sessionViewId: "sess-settings", + entryId: "entry-storage-boundary", + sessionTitle: "Migrate settings store to schema v3", + projectId: "proj-omp", + projectLabel: "oh-my-pi", + role: "assistant", + snippet: + "Search terms and transcript snippets stay in memory only; the settings migration must not persist either value.", + occurredAt: new Date(NOW - 20 * 60 * 60_000).toISOString(), + archived: false, + }, + { + key: "host-local:sess-notes:entry-release-decision", + hostId: "host-local", + hostLabel: "This machine", + sessionId: "sess-notes", + sessionViewId: "sess-notes", + entryId: "entry-release-decision", + sessionTitle: "Draft release notes for v0.1", + projectId: "proj-t4", + projectLabel: "t4-code", + role: "system", + snippet: + "Compaction summary: release notes should separate what shipped from what is still planned and environment-blocked.", + occurredAt: new Date(NOW - 4 * 24 * 60 * 60_000).toISOString(), + archived: true, + }, +]; + +function matchesQuery(result: TranscriptSearchResult, query: string): boolean { + const words = query + .trim() + .toLocaleLowerCase() + .split(/\s+/u) + .filter(Boolean); + const haystack = `${result.snippet} ${result.sessionTitle} ${result.projectLabel}`.toLocaleLowerCase(); + return words.every((word) => haystack.includes(word)); +} + +export const fixtureTranscriptSearchSource: TranscriptSearchSource = { + async search(request: TranscriptSearchRequest, signal: AbortSignal): Promise { + await new Promise((resolve, reject) => { + const timer = window.setTimeout(resolve, 180); + signal.addEventListener( + "abort", + () => { + window.clearTimeout(timer); + reject(new DOMException("Search cancelled", "AbortError")); + }, + { once: true }, + ); + }); + const results = FIXTURE_RESULTS.filter((result) => { + if (!matchesQuery(result, request.query)) return false; + if (request.filters.role !== "all" && result.role !== request.filters.role) return false; + if ( + request.filters.projectId !== null && + result.projectId !== request.filters.projectId + ) + return false; + if (request.filters.archived === "current" && result.archived) return false; + if (request.filters.archived === "archived" && !result.archived) return false; + return true; + }); + return { + results, + hosts: [ + { + hostId: "host-local", + hostLabel: "This machine", + state: "searched", + resultCount: results.length, + }, + { + hostId: "host-remote", + hostLabel: "dev-server", + state: "offline", + message: "This sample host is offline, so its older sessions were not searched.", + }, + ], + }; + }, + async context(result: TranscriptSearchResult, signal: AbortSignal): Promise { + if (signal.aborted) throw new DOMException("Search cancelled", "AbortError"); + return { + rows: [ + { + entryId: `${result.entryId}-before`, + role: "user", + occurredAt: new Date(Date.parse(result.occurredAt) - 60_000).toISOString(), + text: "What decision did we make here, and what constraint should the next change preserve?", + }, + { + entryId: result.entryId, + role: result.role, + occurredAt: result.occurredAt, + text: result.snippet, + }, + { + entryId: `${result.entryId}-after`, + role: "assistant", + occurredAt: new Date(Date.parse(result.occurredAt) + 60_000).toISOString(), + text: "I recorded that boundary in the implementation plan and the focused test list.", + }, + ], + anchorIndex: 1, + hasBefore: true, + hasAfter: true, + }; + }, +}; diff --git a/apps/web/src/features/transcript-search/historic-context.ts b/apps/web/src/features/transcript-search/historic-context.ts new file mode 100644 index 0000000..2d6f81b --- /dev/null +++ b/apps/web/src/features/transcript-search/historic-context.ts @@ -0,0 +1,27 @@ +import type { TranscriptSearchResult } from "./model.ts"; + +/** + * The navigation payload for a host-owned, read-only transcript window. + * A client adapter can consume this without replacing the session's live + * projection. Returning to live is therefore a separate, explicit action. + */ +export interface HistoricTranscriptContextIntent { + readonly hostId: string; + readonly sessionId: string; + readonly sessionViewId: string; + readonly entryId: string; +} +export interface HistoricTranscriptNavigator { + open(intent: HistoricTranscriptContextIntent): Promise<"opened" | "unavailable">; +} + +export function historicContextIntent( + result: TranscriptSearchResult, +): HistoricTranscriptContextIntent { + return { + hostId: result.hostId, + sessionId: result.sessionId, + sessionViewId: result.sessionViewId, + entryId: result.entryId, + }; +} diff --git a/apps/web/src/features/transcript-search/index.ts b/apps/web/src/features/transcript-search/index.ts new file mode 100644 index 0000000..dce6e9a --- /dev/null +++ b/apps/web/src/features/transcript-search/index.ts @@ -0,0 +1,45 @@ +export { + LiveTranscriptSearch, + selectTranscriptSearchSource, + shouldRefreshTranscriptSearchForFilters, +} from "./LiveTranscriptSearch.tsx"; +export { + TranscriptSearchScreen, + type HistoricContextState, + type TranscriptSearchPhase, + type TranscriptSearchScreenProps, +} from "./TranscriptSearchScreen.tsx"; +export { fixtureTranscriptSearchSource } from "./fixtures.ts"; +export { + DEFAULT_TRANSCRIPT_SEARCH_FILTERS, + plainTextHighlightSegments, + transcriptSearchCanRun, + transcriptSearchIsPartial, + type HistoricTranscriptContext, + type HistoricTranscriptRow, + type PlainTextSegment, + type TranscriptHostSearchState, + type TranscriptHostSearchStatus, + type TranscriptRole, + type TranscriptSearchFilters, + type TranscriptSearchRequest, + type TranscriptSearchResponse, + type TranscriptSearchResult, + type TranscriptSearchSource, +} from "./model.ts"; +export { getTranscriptSearchQuery, setTranscriptSearchQuery } from "./search-memory.ts"; +export { + getTranscriptSearchMemorySnapshot, + handoffTranscriptSearchQuery, + TranscriptSearchHandoffTracker, + useTranscriptSearchMemory, + type TranscriptSearchMemorySnapshot, +} from "./search-memory.ts"; +export { LatestTranscriptSearchExecutor } from "./execution.ts"; +export { clientTranscriptSearchSource } from "./source.ts"; +export { TRANSCRIPT_SEARCH_ROUTE } from "./route.ts"; +export { + historicContextIntent, + type HistoricTranscriptContextIntent, + type HistoricTranscriptNavigator, +} from "./historic-context.ts"; diff --git a/apps/web/src/features/transcript-search/model.ts b/apps/web/src/features/transcript-search/model.ts new file mode 100644 index 0000000..b9917e6 --- /dev/null +++ b/apps/web/src/features/transcript-search/model.ts @@ -0,0 +1,136 @@ +export type TranscriptRole = "user" | "assistant" | "system"; + +export interface TranscriptSearchFilters { + readonly archived: "all" | "current" | "archived"; + readonly role: "all" | TranscriptRole; + readonly projectId: string | null; +} + +export interface TranscriptSearchRequest { + readonly query: string; + readonly filters: TranscriptSearchFilters; +} + +export type TranscriptHostSearchState = + | "searched" + | "offline" + | "unsupported" + | "indexing" + | "error"; + +export interface TranscriptHostSearchStatus { + readonly hostId: string; + readonly hostLabel: string; + readonly state: TranscriptHostSearchState; + readonly resultCount?: number; + readonly hasMore?: boolean; + readonly message?: string; +} + +/** + * Search results carry only opaque ids and display-safe text. The renderer + * never needs a remote path, full transcript, or search query in a URL. + */ +export interface TranscriptSearchResult { + readonly key: string; + readonly hostId: string; + readonly hostLabel: string; + readonly sessionId: string; + readonly sessionViewId: string; + readonly entryId: string; + readonly sessionTitle: string; + readonly projectId: string; + readonly projectLabel: string; + readonly role: TranscriptRole; + readonly snippet: string; + readonly occurredAt: string; + readonly archived: boolean; +} + +export interface TranscriptSearchResponse { + readonly results: readonly TranscriptSearchResult[]; + readonly hosts: readonly TranscriptHostSearchStatus[]; + readonly truncated?: boolean; +} + +export interface HistoricTranscriptRow { + readonly entryId: string; + readonly role: TranscriptRole; + readonly occurredAt: string; + readonly text: string; +} + +export interface HistoricTranscriptContext { + readonly rows: readonly HistoricTranscriptRow[]; + readonly anchorIndex: number; + readonly hasBefore: boolean; + readonly hasAfter: boolean; +} + +/** Narrow seam between the web UI and the client-owned search coordinator. */ +export interface TranscriptSearchSource { + search(request: TranscriptSearchRequest, signal: AbortSignal): Promise; + context( + result: TranscriptSearchResult, + signal: AbortSignal, + ): Promise; + loadMore?( + hostId: string, + signal: AbortSignal, + ): Promise; +} + +export const DEFAULT_TRANSCRIPT_SEARCH_FILTERS: TranscriptSearchFilters = Object.freeze({ + archived: "all", + role: "all", + projectId: null, +}); + +export function transcriptSearchCanRun(query: string): boolean { + return query.trim().length >= 2; +} + +export function transcriptSearchIsPartial(response: TranscriptSearchResponse): boolean { + return response.truncated === true || response.hosts.some((host) => host.state !== "searched"); +} + +export interface PlainTextSegment { + readonly text: string; + readonly highlighted: boolean; +} + +/** Split untrusted plain text into safe React-ready highlight segments. */ +export function plainTextHighlightSegments(text: string, query: string): readonly PlainTextSegment[] { + const terms = [...new Set(query.trim().split(/\s+/u).map((term) => term.toLocaleLowerCase()))] + .filter((term) => term.length >= 2) + .sort((left, right) => right.length - left.length); + if (terms.length === 0) return [{ text, highlighted: false }]; + const lower = text.toLocaleLowerCase(); + const ranges: Array<{ start: number; end: number }> = []; + for (const term of terms) { + let from = 0; + while (from < lower.length) { + const start = lower.indexOf(term, from); + if (start < 0) break; + ranges.push({ start, end: start + term.length }); + from = start + term.length; + } + } + ranges.sort((left, right) => left.start - right.start || right.end - left.end); + const merged: Array<{ start: number; end: number }> = []; + for (const range of ranges) { + const last = merged.at(-1); + if (last !== undefined && range.start <= last.end) last.end = Math.max(last.end, range.end); + else merged.push({ ...range }); + } + if (merged.length === 0) return [{ text, highlighted: false }]; + const segments: PlainTextSegment[] = []; + let cursor = 0; + for (const range of merged) { + if (range.start > cursor) segments.push({ text: text.slice(cursor, range.start), highlighted: false }); + segments.push({ text: text.slice(range.start, range.end), highlighted: true }); + cursor = range.end; + } + if (cursor < text.length) segments.push({ text: text.slice(cursor), highlighted: false }); + return segments; +} diff --git a/apps/web/src/features/transcript-search/route.ts b/apps/web/src/features/transcript-search/route.ts new file mode 100644 index 0000000..3845d97 --- /dev/null +++ b/apps/web/src/features/transcript-search/route.ts @@ -0,0 +1,2 @@ +/** Stable route without query parameters; the query itself stays in memory. */ +export const TRANSCRIPT_SEARCH_ROUTE = "/search" as const; diff --git a/apps/web/src/features/transcript-search/search-memory.ts b/apps/web/src/features/transcript-search/search-memory.ts new file mode 100644 index 0000000..12b981a --- /dev/null +++ b/apps/web/src/features/transcript-search/search-memory.ts @@ -0,0 +1,72 @@ +import { useSyncExternalStore } from "react"; + +/** + * Deliberately ephemeral handoff between the command palette and /search. + * It is neither persisted nor encoded in the URL. + */ +let currentQuery = ""; +let handoffVersion = 0; +const listeners = new Set<() => void>(); + +export interface TranscriptSearchMemorySnapshot { + readonly query: string; + /** Changes only when another surface explicitly hands a query to /search. */ + readonly handoffVersion: number; +} + +/** Consume each palette handoff once, including across StrictMode effect probes. */ +export class TranscriptSearchHandoffTracker { + private handledVersion: number | null = null; + + take(snapshot: TranscriptSearchMemorySnapshot): TranscriptSearchMemorySnapshot | null { + if (this.handledVersion === snapshot.handoffVersion) return null; + this.handledVersion = snapshot.handoffVersion; + return snapshot; + } +} + +let currentSnapshot: TranscriptSearchMemorySnapshot = Object.freeze({ + query: currentQuery, + handoffVersion, +}); + +function publish(): void { + currentSnapshot = Object.freeze({ query: currentQuery, handoffVersion }); + for (const listener of listeners) listener(); +} + +/** Update the search field itself without starting a new external handoff. */ +export function setTranscriptSearchQuery(query: string): void { + if (query === currentQuery) return; + currentQuery = query; + publish(); +} + +/** Hand a query from the palette to /search, even when /search is already mounted. */ +export function handoffTranscriptSearchQuery(query: string): void { + currentQuery = query; + handoffVersion += 1; + publish(); +} +export function getTranscriptSearchQuery(): string { + return currentQuery; +} + +export function getTranscriptSearchMemorySnapshot(): TranscriptSearchMemorySnapshot { + return currentSnapshot; +} + +export function useTranscriptSearchMemory(): TranscriptSearchMemorySnapshot { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getTranscriptSearchMemorySnapshot, + getTranscriptSearchMemorySnapshot, + ); +} + +export function useTranscriptSearchQuery(): string { + return useTranscriptSearchMemory().query; +} diff --git a/apps/web/src/features/transcript-search/source.ts b/apps/web/src/features/transcript-search/source.ts new file mode 100644 index 0000000..0bdf2cf --- /dev/null +++ b/apps/web/src/features/transcript-search/source.ts @@ -0,0 +1,142 @@ +import { + createTranscriptSearchCoordinator, + type TranscriptSearchCoordinator as ClientTranscriptSearchCoordinator, + type TranscriptSearchRole as ClientTranscriptSearchRole, + type TranscriptSearchSnapshot as ClientTranscriptSearchSnapshot, +} from "@t4-code/client"; +import type { DesktopRuntimeController } from "@t4-code/client"; +import { entryId, projectId } from "@t4-code/protocol"; + +import type { WorkspaceData } from "../../lib/workspace-data.ts"; +import { sessionViewId } from "../../platform/live-workspace.ts"; +import type { + HistoricTranscriptContext, + TranscriptHostSearchStatus, + TranscriptRole, + TranscriptSearchRequest, + TranscriptSearchResponse, + TranscriptSearchResult, + TranscriptSearchSource, +} from "./model.ts"; + +const coordinators = new WeakMap(); + +function coordinatorFor(controller: DesktopRuntimeController): ClientTranscriptSearchCoordinator { + let coordinator = coordinators.get(controller); + if (coordinator === undefined) { + coordinator = createTranscriptSearchCoordinator(controller); + coordinators.set(controller, coordinator); + } + return coordinator; +} + +function uiRole(role: ClientTranscriptSearchRole): TranscriptRole { + return role === "summary" ? "system" : role; +} + +function hostMessage(state: string, errorCode?: string): string | undefined { + if (state === "offline") return "This host is offline and could not be searched."; + if (state === "unsupported") return "Update this host to a version that supports transcript search."; + if (state === "building") return "This host is still building its transcript index. Results may be incomplete."; + if (state === "stale") return "This host returned results from an older index."; + if (state === "error") return errorCode === undefined ? "This host could not be searched." : `Search failed (${errorCode}).`; + return undefined; +} + +/** Renderer adapter around the client-owned fan-out, validation, and merge coordinator. */ +export function clientTranscriptSearchSource( + controller: DesktopRuntimeController, + data: WorkspaceData, +): TranscriptSearchSource { + const coordinator = coordinatorFor(controller); + const hostLabels = new Map(data.hosts.map((host) => [host.id, host.name])); + const projectLabels = new Map(data.projects.map((project) => [project.id, project.name])); + const uiResponse = (snapshot: ClientTranscriptSearchSnapshot): TranscriptSearchResponse => { + const results: TranscriptSearchResult[] = snapshot.items.map((item) => ({ + key: `${item.hostId}\u0000${item.sessionId}\u0000${item.anchorId}`, + hostId: String(item.hostId), + hostLabel: hostLabels.get(String(item.hostId)) ?? String(item.hostId), + sessionId: String(item.sessionId), + sessionViewId: sessionViewId(String(item.hostId), String(item.sessionId)), + entryId: String(item.anchorId), + sessionTitle: item.sessionTitle || "Untitled session", + projectId: String(item.projectId), + projectLabel: projectLabels.get(String(item.projectId)) ?? String(item.projectId), + role: uiRole(item.role), + snippet: item.snippet, + occurredAt: item.timestamp, + archived: item.archivedAt !== undefined, + })); + const hosts: TranscriptHostSearchStatus[] = [...snapshot.hosts.values()].map((host) => { + const message = hostMessage(host.state, host.errorCode); + return { + hostId: String(host.hostId), + hostLabel: hostLabels.get(String(host.hostId)) ?? String(host.hostId), + state: + host.state === "ready" || host.state === "stale" + ? "searched" + : host.state === "building" + ? "indexing" + : host.state, + resultCount: results.filter((item) => item.hostId === host.hostId).length, + ...(host.nextCursor === undefined ? {} : { hasMore: true }), + ...(message === undefined ? {} : { message }), + }; + }); + return { results, hosts, truncated: snapshot.incomplete }; + }; + return { + async search(request: TranscriptSearchRequest, signal: AbortSignal): Promise { + const roles: readonly ClientTranscriptSearchRole[] | undefined = + request.filters.role === "all" + ? undefined + : [request.filters.role === "system" ? "summary" : request.filters.role]; + const snapshot = await coordinator.search( + { + query: request.query, + limit: 50, + archived: + request.filters.archived === "all" + ? "include" + : request.filters.archived === "archived" + ? "only" + : "exclude", + ...(roles === undefined ? {} : { roles }), + ...(request.filters.projectId === null + ? {} + : { projectId: projectId(request.filters.projectId) }), + }, + { signal }, + ); + return uiResponse(snapshot); + }, + async loadMore(hostId: string, signal: AbortSignal): Promise { + return uiResponse(await coordinator.loadMore(hostId, { signal })); + }, + async context(result: TranscriptSearchResult, signal: AbortSignal): Promise { + if (signal.aborted) throw new DOMException("Context cancelled", "AbortError"); + const context = await coordinator.context( + result.hostId, + result.sessionId, + { + anchorId: entryId(result.entryId), + before: 12, + after: 12, + }, + { signal }, + ); + if (signal.aborted) throw new DOMException("Context cancelled", "AbortError"); + return { + rows: context.rows.map((row) => ({ + entryId: String(row.anchorId), + role: uiRole(row.role), + occurredAt: row.timestamp, + text: row.text, + })), + anchorIndex: context.anchorIndex, + hasBefore: context.hasBefore, + hasAfter: context.hasAfter, + }; + }, + }; +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index fd4eb19..2eb5bee 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -27,6 +27,8 @@ import { SessionScreen } from "./components/SessionScreen.tsx"; import { AgentViewScreen } from "./features/agent-view/AgentViewScreen.tsx"; import { PreviewWorkspace } from "./features/preview/PreviewWorkspace.tsx"; import { LiveAttentionInbox } from "./features/attention/index.ts"; +import { LiveTranscriptSearch } from "./features/transcript-search/index.ts"; +import { TRANSCRIPT_SEARCH_ROUTE } from "./features/transcript-search/route.ts"; import { SettingsWorkspace } from "./features/settings/index.ts"; import { LiveSettingsScreen } from "./features/settings/LiveSettingsScreen.tsx"; import { TargetsScreen } from "./features/targets/TargetsScreen.tsx"; @@ -91,6 +93,12 @@ const inboxRoute = createRoute({ component: LiveAttentionInbox, }); +const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: TRANSCRIPT_SEARCH_ROUTE, + component: LiveTranscriptSearch, +}); + interface SessionRouteGateProps { readonly sessionId: string; readonly previewRoute: boolean; @@ -439,6 +447,7 @@ const usageRoute = createRoute({ const routeTree = rootRoute.addChildren([ indexRoute, inboxRoute, + searchRoute, sessionRoute, previewRoute, agentViewRoute, diff --git a/apps/web/test/browser-platform.test.ts b/apps/web/test/browser-platform.test.ts index dd07be5..c2d6b1c 100644 --- a/apps/web/test/browser-platform.test.ts +++ b/apps/web/test/browser-platform.test.ts @@ -313,8 +313,10 @@ describe("browser platform boundary", () => { expect(connectCalls).toBe(1); expect(capturedOptions?.requestedFeatures).toContain("prompt.images"); expect(capturedOptions?.requestedFeatures).toContain("transcript.images"); + expect(capturedOptions?.requestedFeatures).toContain("transcript.search"); expect(capturedOptions?.compatibilityRequestedFeatures).not.toContain("prompt.images"); expect(capturedOptions?.compatibilityRequestedFeatures).not.toContain("transcript.images"); + expect(capturedOptions?.compatibilityRequestedFeatures).toContain("transcript.search"); const pair = await shell.pair({ targetId: "remote", code: "123456" }); expect(pair.paired).toBe(true); expect(capturedOptions?.authentication?.()).toEqual({ diff --git a/apps/web/test/transcript-search.test.tsx b/apps/web/test/transcript-search.test.tsx new file mode 100644 index 0000000..069477e --- /dev/null +++ b/apps/web/test/transcript-search.test.tsx @@ -0,0 +1,319 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; +import type { DesktopRuntimeController } from "@t4-code/client"; + +import { + DEFAULT_TRANSCRIPT_SEARCH_FILTERS, + clientTranscriptSearchSource, + fixtureTranscriptSearchSource, + getTranscriptSearchMemorySnapshot, + getTranscriptSearchQuery, + handoffTranscriptSearchQuery, + historicContextIntent, + LatestTranscriptSearchExecutor, + plainTextHighlightSegments, + selectTranscriptSearchSource, + setTranscriptSearchQuery, + shouldRefreshTranscriptSearchForFilters, + TRANSCRIPT_SEARCH_ROUTE, + TranscriptSearchHandoffTracker, + TranscriptSearchScreen, + transcriptSearchCanRun, + transcriptSearchIsPartial, + type HistoricTranscriptContext, + type TranscriptSearchResponse, + type TranscriptSearchResult, + type TranscriptSearchSource, +} from "../src/features/transcript-search/index.ts"; +import { SHELL_FIXTURE } from "../src/fixture/data.ts"; + +const result: TranscriptSearchResult = { + key: "host-a\u0000session-a\u0000entry-a", + hostId: "host-a", + hostLabel: "Studio Mac", + sessionId: "session-a", + sessionViewId: "host-a/session-a", + entryId: "entry-a", + sessionTitle: "Fix reconnect replay", + projectId: "project-a", + projectLabel: "t4-code", + role: "assistant", + snippet: "The reconnect decision keeps the durable cursor in memory.", + occurredAt: "2026-07-18T18:00:00.000Z", + archived: true, +}; + +const response: TranscriptSearchResponse = { + results: [result], + hosts: [ + { hostId: "host-a", hostLabel: "Studio Mac", state: "searched", resultCount: 1 }, + { + hostId: "host-b", + hostLabel: "Build host", + state: "offline", + message: "This host is offline and could not be searched.", + }, + ], +}; + +const callbacks = { + onQueryChange: () => {}, + onFiltersChange: () => {}, + onSubmit: () => {}, + onOpenResult: () => {}, + onCloseHistoricContext: () => {}, + onOpenLiveTail: () => {}, + onLoadMoreHost: undefined, + loadingMoreHostId: null, + loadMoreError: undefined, +}; + +describe("transcript search model", () => { + it("requires a useful query and reports partial multi-host results honestly", () => { + expect(transcriptSearchCanRun("a")).toBe(false); + expect(transcriptSearchCanRun("cursor")).toBe(true); + expect(transcriptSearchIsPartial(response)).toBe(true); + }); + + it("hands palette queries to the route in memory without putting them in route state", () => { + const startingVersion = getTranscriptSearchMemorySnapshot().handoffVersion; + setTranscriptSearchQuery("durable cursor"); + expect(getTranscriptSearchQuery()).toBe("durable cursor"); + expect(getTranscriptSearchMemorySnapshot().handoffVersion).toBe(startingVersion); + handoffTranscriptSearchQuery("new palette query"); + expect(getTranscriptSearchMemorySnapshot()).toEqual({ + query: "new palette query", + handoffVersion: startingVersion + 1, + }); + expect(TRANSCRIPT_SEARCH_ROUTE).toBe("/search"); + expect(TRANSCRIPT_SEARCH_ROUTE).not.toContain("?"); + setTranscriptSearchQuery(""); + }); + + it("lets same-route query B replace query A and rejects a stale A completion", async () => { + handoffTranscriptSearchQuery("query A"); + const queryAVersion = getTranscriptSearchMemorySnapshot().handoffVersion; + handoffTranscriptSearchQuery("query B"); + expect(getTranscriptSearchMemorySnapshot()).toEqual({ + query: "query B", + handoffVersion: queryAVersion + 1, + }); + const tracker = new TranscriptSearchHandoffTracker(); + expect(tracker.take(getTranscriptSearchMemorySnapshot())?.query).toBe("query B"); + expect(tracker.take(getTranscriptSearchMemorySnapshot())).toBeNull(); + const resolvers = new Map void>(); + const source: TranscriptSearchSource = { + search: (request) => + new Promise((resolve) => { + resolvers.set(request.query, resolve); + }), + context: () => Promise.reject(new Error("not used")), + }; + const executor = new LatestTranscriptSearchExecutor(); + const completed: string[] = []; + const callbacks = { + onStart: () => {}, + onSuccess: (next: TranscriptSearchResponse) => { + completed.push(next.results[0]?.snippet ?? "empty"); + }, + onError: () => {}, + }; + const responseFor = (snippet: string): TranscriptSearchResponse => ({ + results: [{ ...result, key: snippet, snippet }], + hosts: [], + }); + + const queryA = executor.run( + source, + { query: "query A", filters: DEFAULT_TRANSCRIPT_SEARCH_FILTERS }, + callbacks, + ); + const queryB = executor.run( + source, + { query: "query B", filters: DEFAULT_TRANSCRIPT_SEARCH_FILTERS }, + callbacks, + ); + resolvers.get("query B")?.(responseFor("query B")); + await queryB; + resolvers.get("query A")?.(responseFor("stale query A")); + await queryA; + + expect(completed).toEqual(["query B"]); + setTranscriptSearchQuery(""); + }); + + it("supersedes an initial search when filters change and keeps an untouched page idle", async () => { + expect(shouldRefreshTranscriptSearchForFilters("idle", null)).toBe(false); + expect(shouldRefreshTranscriptSearchForFilters("searching", null)).toBe(true); + + const resolvers = new Map void>(); + const source: TranscriptSearchSource = { + search: (request) => + new Promise((resolve) => { + resolvers.set(request.filters.role, resolve); + }), + context: () => Promise.reject(new Error("not used")), + }; + const executor = new LatestTranscriptSearchExecutor(); + const completed: string[] = []; + const callbacks = { + onStart: () => {}, + onSuccess: (next: TranscriptSearchResponse) => { + completed.push(next.results[0]?.snippet ?? "empty"); + }, + onError: () => {}, + }; + const responseFor = (snippet: string): TranscriptSearchResponse => ({ + results: [{ ...result, key: snippet, snippet }], + hosts: [], + }); + + const initial = executor.run( + source, + { query: "decision", filters: DEFAULT_TRANSCRIPT_SEARCH_FILTERS }, + callbacks, + ); + const filtered = executor.run( + source, + { + query: "decision", + filters: { ...DEFAULT_TRANSCRIPT_SEARCH_FILTERS, role: "user" }, + }, + callbacks, + ); + resolvers.get("user")?.(responseFor("filtered user result")); + await filtered; + resolvers.get("all")?.(responseFor("stale unfiltered result")); + await initial; + + expect(completed).toEqual(["filtered user result"]); + }); + + it("uses the client coordinator whenever a controller exists, including browser-direct", () => { + const browserDirectController = {} as DesktopRuntimeController; + expect(selectTranscriptSearchSource(null, SHELL_FIXTURE)).toBe(fixtureTranscriptSearchSource); + expect(selectTranscriptSearchSource(browserDirectController, SHELL_FIXTURE)).not.toBe( + fixtureTranscriptSearchSource, + ); + }); + + it("maps an offline client-coordinator host without inventing a successful search", async () => { + const controller = { + getSnapshot: () => ({ + targetHosts: new Map(), + hosts: new Map([["host-a", {}]]), + connections: new Map(), + }), + command: () => Promise.reject(new Error("offline hosts must not receive a command")), + } as unknown as DesktopRuntimeController; + const source = clientTranscriptSearchSource(controller, { + hosts: [{ id: "host-a", name: "Studio Mac", kind: "local" }], + projects: [], + sessions: [], + }); + const search = await source.search( + { query: "cursor", filters: DEFAULT_TRANSCRIPT_SEARCH_FILTERS }, + new AbortController().signal, + ); + + expect(search.results).toEqual([]); + expect(search.hosts).toEqual([ + { + hostId: "host-a", + hostLabel: "Studio Mac", + state: "offline", + resultCount: 0, + message: "This host is offline and could not be searched.", + }, + ]); + expect(transcriptSearchIsPartial(search)).toBe(true); + }); + + it("creates an opaque historic-context intent without transcript text or live projection state", () => { + expect(historicContextIntent(result)).toEqual({ + hostId: "host-a", + sessionId: "session-a", + sessionViewId: "host-a/session-a", + entryId: "entry-a", + }); + expect(historicContextIntent(result)).not.toHaveProperty("snippet"); + expect(historicContextIntent(result)).not.toHaveProperty("query"); + expect(historicContextIntent(result)).not.toHaveProperty("projection"); + }); + + it("splits plain text for highlighting without producing markup strings", () => { + expect(plainTextHighlightSegments("Keep the durable cursor and cursor id.", "durable cursor")).toEqual([ + { text: "Keep the ", highlighted: false }, + { text: "durable", highlighted: true }, + { text: " ", highlighted: false }, + { text: "cursor", highlighted: true }, + { text: " and ", highlighted: false }, + { text: "cursor", highlighted: true }, + { text: " id.", highlighted: false }, + ]); + expect(plainTextHighlightSegments("", "markup")).toContainEqual({ + text: "markup", + highlighted: true, + }); + }); +}); + +describe("transcript search screen", () => { + it("renders memory-only search, filters, partial host status, and plain result snippets", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Transcript search"); + expect(markup).toContain("Queries and snippets stay in memory"); + expect(markup).toContain("Current and archived"); + expect(markup).toContain("Build host"); + expect(markup).toContain("Offline"); + expect(markup).toContain("Open older context"); + expect(markup).toContain(" { + const context: HistoricTranscriptContext = { + rows: [ + { + entryId: "entry-a", + role: "assistant", + occurredAt: result.occurredAt, + text: result.snippet, + }, + ], + anchorIndex: 0, + hasBefore: true, + hasAfter: true, + }; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Viewing older transcript context"); + expect(markup).toContain("read-only window"); + expect(markup).toContain("Back to results"); + expect(markup).toContain("Open live tail"); + expect(markup).toContain("Search match"); + }); +}); diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index aa26d03..3175efe 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -2,13 +2,13 @@ "appProtocol": "omp-app/1", "appWire": { "package": "@oh-my-pi/app-wire", - "version": "0.6.0", + "version": "0.6.1", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", - "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499", - "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz", - "tarballSha256": "92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c", - "goldenCorpusSha256": "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1" + "sourceCommit": "e3e15c03ae95ebbda5f26495cd21213cc53518b1", + "sourceTreeHash": "e0f32b279eb4b8cbc403e47d765a226bee99c99f", + "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz", + "tarballSha256": "78ec6223e7ad0f4f9e14526a822eaa45b363c856065c08196b2817a2f42740b9", + "goldenCorpusSha256": "d5e674095de3d9b3b56a5668bc91cbbf1904b409ea9ea6456c2eabdf272e7870" }, "publishedAppWire": { "package": "@oh-my-pi/app-wire", diff --git a/docs/adr/011-cross-session-transcript-search.md b/docs/adr/011-cross-session-transcript-search.md new file mode 100644 index 0000000..b8a3b02 --- /dev/null +++ b/docs/adr/011-cross-session-transcript-search.md @@ -0,0 +1,80 @@ +# ADR 011: Cross-session transcript search + +## Status + +Accepted and implemented as one coordinated OMP host contract plus one T4 client and UI slice. + +## Problem + +People remember a decision or code discussion, but often do not remember which session or machine +contains it. T4 intentionally keeps only bounded warm transcript projections, so searching the +renderer cache would miss older sessions and would give misleading results when a host is offline. + +## Decision + +Each OMP profile owns a private, rebuildable SQLite full-text index. T4 asks every connected host +independently and merges the bounded answers in memory. + +```text +T4 /search route + | + +-- local profile A ----> profile A SQLite index + +-- local profile B ----> profile B SQLite index + +-- paired host --------> that host's SQLite index + +-- Tailnet host -------> that host's SQLite index +``` + +There is no T4 cloud index and no host-to-host search. The existing authenticated target binding, +`sessions.read` capability, and negotiated `transcript.search` feature remain the authority boundary. + +## Protocol shape + +| Command | Scope | Purpose | +| --- | --- | --- | +| `transcript.search` | Host | Return bounded snippets, entry IDs, filters, per-host coverage, and an opaque cursor | +| `transcript.context` | Session | Return a bounded read-only window around one durable entry ID | + +Search cursors belong to one host and one exact query/filter set. T4 paginates one host at a time and +never sends one host's cursor to another host. + +The context command is separate from `session.attach`. Opening an old result therefore cannot replace, +truncate, or corrupt the live session projection. The UI labels the window as older read-only context +and offers an explicit action to return to the live tail. + +## Search corpus and privacy + +The index includes visible durable user text, assistant text, visible custom messages, and compaction +summaries. It excludes hidden messages, reasoning, tool arguments/results, images, and local paths. +OMP applies its display-safe text sanitizer before indexing. + +Queries, result lists, snippets, and context windows remain in memory. They are not added to URLs, +browser history, workspace persistence, projection caches, or the appserver completed-command cache. +Snippets are rendered as plain text, never as HTML or Markdown. + +## Product shape + +- `Cmd/Ctrl+K` can hand the current phrase to the full `/search` route. +- A visible titlebar search action makes the feature discoverable on narrow and touch layouts. +- The full route provides project, role, and archive filters; per-host ready/indexing/offline/ + unsupported/error states; bounded pagination; and read-only historic context. +- Archived sessions are included by default because finding old work is the primary use case. +- Partial results remain visible when one host is offline, outdated, indexing, or failed. + +## Failure behavior + +An older host is marked unsupported instead of queried. An offline host is not searched through a +stale renderer cache. One bad OMP session marks that host's index incomplete but does not block healthy +sessions or other hosts. Deleted or moved anchors return a stable not-found error and leave the current +search results available. + +## Verification boundary + +The implementation is covered at four boundaries: + +1. strict app-wire request/result fixtures and bounds; +2. SQLite extraction, rewrite, pagination, Unicode, deletion, and crash-consistency tests; +3. appserver feature, capability, lifecycle, error, and idempotency tests; and +4. T4 multi-host coordination, cancellation, pagination, UI state, and historic-context tests. + +A fixture/browser screenshot proves layout only. A real release claim still requires a supporting OMP +runtime on local, named-profile, paired-host, and Tailnet paths. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fc12856..de83de6 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -13,9 +13,7 @@ export type { PreviewPolicyCheckIntent, PreviewHandoffIntent, } from "./omp-client-runtime.ts"; -export { - ompAppV1ProtocolProvider, -} from "./omp-app-v1-protocol-provider.ts"; +export { ompAppV1ProtocolProvider } from "./omp-app-v1-protocol-provider.ts"; export { defaultOmpProtocolProviderRegistry, OmpProtocolProviderRegistry, @@ -214,3 +212,30 @@ export type { DesktopControllerLeaseOperationResult, DesktopControllerLeaseOptions, } from "./desktop-runtime.ts"; +export { + createTranscriptSearchCoordinator, + decodeTranscriptContextResult, + decodeTranscriptSearchResult, + TranscriptSearchCoordinator, + TranscriptSearchError, +} from "./transcript-search.ts"; +export type { + HostedTranscriptSearchItem, + TranscriptContextArguments, + TranscriptContextResult, + TranscriptContextRow, + TranscriptSearchArchivedFilter, + TranscriptSearchArguments, + TranscriptSearchHighlight, + TranscriptSearchHostState, + TranscriptSearchHostStatus, + TranscriptSearchIndexState, + TranscriptSearchIndexStatus, + TranscriptSearchItem, + TranscriptSearchListener, + TranscriptSearchOptions, + TranscriptSearchResult, + TranscriptSearchRole, + TranscriptSearchRuntime, + TranscriptSearchSnapshot, +} from "./transcript-search.ts"; diff --git a/packages/client/src/transcript-search.ts b/packages/client/src/transcript-search.ts new file mode 100644 index 0000000..270f912 --- /dev/null +++ b/packages/client/src/transcript-search.ts @@ -0,0 +1,769 @@ +import { + decodeTranscriptContextArguments, + decodeTranscriptContextResult, + decodeTranscriptSearchArguments, + decodeTranscriptSearchResult, + hostId, + sessionId, + TRANSCRIPT_SEARCH_MAX_RESULTS, + type HostId, + type SessionId, + type TranscriptContextArguments, + type TranscriptContextResult, + type TranscriptSearchArguments, + type TranscriptSearchIndexState, + type TranscriptSearchItem, + type TranscriptSearchResult, +} from "@t4-code/protocol"; +import type { CommandRequest, CommandResult } from "@t4-code/protocol/desktop-ipc"; +import type { DesktopRuntimeController } from "./desktop-runtime.ts"; +import { freezeClone, mapValue, type DesktopRuntimeSnapshot } from "./desktop-runtime-contracts.ts"; +import type { Unsubscribe } from "./omp-client-contracts.ts"; + +export { decodeTranscriptContextResult, decodeTranscriptSearchResult } from "@t4-code/protocol"; +export type { + TranscriptContextArguments, + TranscriptContextResult, + TranscriptContextRow, + TranscriptSearchArchivedFilter, + TranscriptSearchArguments, + TranscriptSearchHighlight, + TranscriptSearchIndexState, + TranscriptSearchIndexStatus, + TranscriptSearchItem, + TranscriptSearchResult, + TranscriptSearchRole, +} from "@t4-code/protocol"; + +const FEATURE = "transcript.search"; +const CAPABILITY = "sessions.read"; +/** Client-wide in-memory/display bound across all hosts and pages. */ +export const MAX_RETAINED_TRANSCRIPT_SEARCH_ITEMS = 200; +export type TranscriptSearchHostState = + | TranscriptSearchIndexState + | "unsupported" + | "offline" + | "error"; + +export interface HostedTranscriptSearchItem extends TranscriptSearchItem { + readonly hostId: HostId; +} + +export interface TranscriptSearchHostStatus { + readonly hostId: HostId; + readonly state: TranscriptSearchHostState; + readonly targetId?: string; + readonly indexedSessions?: number; + readonly knownSessions?: number; + readonly generation?: string; + readonly incomplete?: boolean; + readonly nextCursor?: string; + readonly errorCode?: string; +} + +export interface TranscriptSearchSnapshot { + readonly generation: number; + readonly searching: boolean; + readonly items: readonly HostedTranscriptSearchItem[]; + readonly hosts: ReadonlyMap; + readonly incomplete: boolean; +} + +export interface TranscriptSearchOptions { + readonly signal?: AbortSignal; +} + +export type TranscriptSearchListener = (snapshot: TranscriptSearchSnapshot) => void; +export type TranscriptSearchRuntime = Pick; + +export class TranscriptSearchError extends Error { + readonly code: "invalid" | "offline" | "unsupported" | "command" | "superseded" | "no_cursor"; + readonly hostId: string | undefined; + + constructor(code: TranscriptSearchError["code"], message: string, hostIdValue?: string) { + super(message); + this.name = "TranscriptSearchError"; + this.code = code; + this.hostId = hostIdValue; + Object.defineProperty(this, "stack", { + configurable: true, + enumerable: false, + value: undefined, + writable: false, + }); + } +} + +interface EligibleHost { + readonly hostId: HostId; + readonly targetId: string; +} + +interface HostPlan { + readonly eligible: readonly EligibleHost[]; + readonly statuses: Map; +} + +interface RankedItem { + readonly item: HostedTranscriptSearchItem; + readonly rank: number; +} + +interface ActiveRun { + readonly generation: number; + readonly controller: AbortController; +} + +interface SearchMemory { + readonly generation: number; + readonly args: TranscriptSearchArguments; + readonly statuses: Map; + readonly results: Map; + readonly cursors: Map>; + visibleLimit: number; +} + +function normalizeSearchArguments(args: TranscriptSearchArguments): TranscriptSearchArguments { + if (args.cursor !== undefined) { + throw new TranscriptSearchError( + "invalid", + "cross-host search cursors must be used through loadMore(hostId)", + ); + } + try { + const query = typeof args.query === "string" ? args.query.trim() : args.query; + return freezeClone(decodeTranscriptSearchArguments({ ...args, query })); + } catch { + throw new TranscriptSearchError("invalid", "transcript search arguments are invalid"); + } +} + +function normalizeContextArguments(args: TranscriptContextArguments): TranscriptContextArguments { + try { + return freezeClone(decodeTranscriptContextArguments(args)); + } catch { + throw new TranscriptSearchError("invalid", "transcript context arguments are invalid"); + } +} + +function commandFailureCode(result: CommandResult): string { + const code = result.error?.code; + return typeof code === "string" && code.length > 0 ? code.slice(0, 128) : "command_failed"; +} + +function isUnsupportedCode(code: string): boolean { + return code === "unsupported" || code === "feature_required" || code === "capability_denied"; +} + +function planHosts(snapshot: DesktopRuntimeSnapshot): HostPlan { + const hostIds = new Set([...snapshot.targetHosts.values(), ...snapshot.hosts.keys()]); + const eligible: EligibleHost[] = []; + const statuses = new Map(); + for (const hostIdValue of [...hostIds].sort()) { + const metadata = snapshot.hosts.get(hostIdValue); + const connectedTargets = [...snapshot.targetHosts.entries()] + .filter( + ([targetId, boundHostId]) => + boundHostId === hostIdValue && snapshot.connections.get(targetId) === "connected", + ) + .map(([targetId]) => targetId) + .sort(); + if (connectedTargets.length === 0) { + statuses.set(hostIdValue, Object.freeze({ hostId: hostId(hostIdValue), state: "offline" })); + continue; + } + if (metadata === undefined) { + statuses.set(hostIdValue, Object.freeze({ hostId: hostId(hostIdValue), state: "error" })); + continue; + } + const firstConnectedTarget = connectedTargets[0]; + if (firstConnectedTarget === undefined) { + statuses.set(hostIdValue, Object.freeze({ hostId: hostId(hostIdValue), state: "error" })); + continue; + } + const targetId = connectedTargets.includes(metadata.targetId) + ? metadata.targetId + : firstConnectedTarget; + if ( + !metadata.grantedFeatures.includes(FEATURE) || + !metadata.grantedCapabilities.includes(CAPABILITY) + ) { + statuses.set( + hostIdValue, + Object.freeze({ hostId: hostId(hostIdValue), state: "unsupported", targetId }), + ); + continue; + } + const item = Object.freeze({ hostId: hostId(hostIdValue), targetId }); + eligible.push(item); + statuses.set(hostIdValue, Object.freeze({ hostId: item.hostId, state: "building", targetId })); + } + return { eligible: Object.freeze(eligible), statuses }; +} + +function mergeItems( + results: ReadonlyMap, + limit: number, +): readonly HostedTranscriptSearchItem[] { + const unique = new Map(); + for (const [hostIdValue, items] of [...results.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { + for (const [rank, item] of items.entries()) { + const key = `${hostIdValue}\u0000${item.sessionId}\u0000${item.anchorId}`; + if (unique.has(key)) continue; + unique.set(key, { + rank, + item: Object.freeze({ ...item, hostId: hostId(hostIdValue) }), + }); + } + } + return Object.freeze( + [...unique.values()] + .sort( + (left, right) => + left.rank - right.rank || + right.item.timestamp.localeCompare(left.item.timestamp) || + String(left.item.hostId).localeCompare(String(right.item.hostId)) || + String(left.item.sessionId).localeCompare(String(right.item.sessionId)) || + String(left.item.anchorId).localeCompare(String(right.item.anchorId)), + ) + .slice(0, limit) + .map(({ item }) => item), + ); +} + +function appendHostItems( + existing: readonly TranscriptSearchItem[], + added: readonly TranscriptSearchItem[], +): readonly TranscriptSearchItem[] { + const items = [...existing]; + const seen = new Set(existing.map((item) => `${item.sessionId}\u0000${item.anchorId}`)); + for (const item of added) { + const key = `${item.sessionId}\u0000${item.anchorId}`; + if (seen.has(key)) continue; + seen.add(key); + items.push(item); + } + return Object.freeze(items); +} + +function hostedItemKey(item: HostedTranscriptSearchItem): string { + return `${item.hostId}\u0000${item.sessionId}\u0000${item.anchorId}`; +} + +function trimRetainedResults(results: Map): void { + const retainedKeys = new Set( + mergeItems(results, MAX_RETAINED_TRANSCRIPT_SEARCH_ITEMS).map(hostedItemKey), + ); + for (const [hostIdValue, items] of results) { + const retained = items.filter((item) => + retainedKeys.has(`${hostIdValue}\u0000${item.sessionId}\u0000${item.anchorId}`), + ); + results.set(hostIdValue, Object.freeze(retained)); + } +} + +function rememberNextCursor( + memory: SearchMemory, + hostIdValue: string, + candidate: string | undefined, +): string | undefined { + if (candidate === undefined) return undefined; + const seen = memory.cursors.get(hostIdValue) ?? new Set(); + memory.cursors.set(hostIdValue, seen); + if (seen.has(candidate)) return undefined; + seen.add(candidate); + return candidate; +} + +function clearPaginationAtDisplayCap(memory: SearchMemory): void { + if (memory.visibleLimit < MAX_RETAINED_TRANSCRIPT_SEARCH_ITEMS) return; + for (const [hostIdValue, status] of memory.statuses) { + if (status.nextCursor === undefined) continue; + const { nextCursor: _nextCursor, ...withoutCursor } = status; + memory.statuses.set(hostIdValue, Object.freeze({ ...withoutCursor, incomplete: true })); + } +} + +function freezeSnapshot( + generation: number, + searching: boolean, + statuses: ReadonlyMap, + results: ReadonlyMap, + limit: number, +): TranscriptSearchSnapshot { + const hosts = mapValue( + [...statuses].map(([key, value]) => [key, Object.freeze({ ...value })] as const), + ); + const incomplete = [...hosts.values()].some( + (status) => status.state !== "ready" || status.incomplete === true, + ); + return Object.freeze({ + generation, + searching, + items: mergeItems(results, limit), + hosts, + incomplete, + }); +} + +function abortError(): TranscriptSearchError { + return new TranscriptSearchError("superseded", "transcript search was superseded"); +} + +export class TranscriptSearchCoordinator { + private readonly runtime: TranscriptSearchRuntime; + private readonly listeners = new Set(); + private generation = 0; + private active: ActiveRun | undefined; + private readonly pagination = new Map(); + private memory: SearchMemory | undefined; + private current: TranscriptSearchSnapshot = freezeSnapshot( + 0, + false, + new Map(), + new Map(), + TRANSCRIPT_SEARCH_MAX_RESULTS, + ); + + constructor(runtime: TranscriptSearchRuntime) { + this.runtime = runtime; + } + + getSnapshot(): TranscriptSearchSnapshot { + return this.current; + } + + subscribe(listener: TranscriptSearchListener): Unsubscribe { + this.listeners.add(listener); + let active = true; + return () => { + if (!active) return; + active = false; + this.listeners.delete(listener); + }; + } + + clear(): TranscriptSearchSnapshot { + this.active?.controller.abort(); + this.active = undefined; + this.abortPagination(); + this.memory = undefined; + this.generation += 1; + this.current = freezeSnapshot( + this.generation, + false, + planHosts(this.runtime.getSnapshot()).statuses, + new Map(), + TRANSCRIPT_SEARCH_MAX_RESULTS, + ); + this.publish(); + return this.current; + } + + cancel(): void { + this.active?.controller.abort(); + this.active = undefined; + this.abortPagination(); + if (this.current.searching) { + this.current = Object.freeze({ ...this.current, searching: false, incomplete: true }); + this.publish(); + } + } + + async search( + args: TranscriptSearchArguments, + options: TranscriptSearchOptions = {}, + ): Promise { + const normalized = normalizeSearchArguments(args); + this.active?.controller.abort(); + this.abortPagination(); + const generation = ++this.generation; + const controller = new AbortController(); + const active: ActiveRun = { generation, controller }; + this.active = active; + const removeExternalAbort = this.forwardAbort(options.signal, controller); + const plan = planHosts(this.runtime.getSnapshot()); + const statuses = new Map(plan.statuses); + const results = new Map(); + const limit = normalized.limit ?? 20; + const memory: SearchMemory = { + generation, + args: normalized, + statuses, + results, + cursors: new Map(), + visibleLimit: limit, + }; + this.memory = memory; + this.current = freezeSnapshot(generation, true, statuses, results, limit); + this.publish(); + + const work = Promise.all( + plan.eligible.map(async (host) => { + if (controller.signal.aborted || this.active !== active) return; + let commandResult: CommandResult; + try { + commandResult = await this.issueSearch(host, normalized); + } catch { + if (controller.signal.aborted || this.active !== active) return; + statuses.set( + host.hostId, + Object.freeze({ + hostId: host.hostId, + targetId: host.targetId, + state: "error", + errorCode: "command_failed", + }), + ); + this.current = freezeSnapshot(generation, true, statuses, results, limit); + this.publish(); + return; + } + if (controller.signal.aborted || this.active !== active) return; + if (commandResult.accepted !== true) { + const code = commandFailureCode(commandResult); + statuses.set( + host.hostId, + Object.freeze({ + hostId: host.hostId, + targetId: host.targetId, + state: isUnsupportedCode(code) ? "unsupported" : "error", + errorCode: code, + }), + ); + } else { + try { + const decoded = decodeTranscriptSearchResult(commandResult.result); + results.set(host.hostId, decoded.items); + trimRetainedResults(results); + const nextCursor = rememberNextCursor(memory, host.hostId, decoded.nextCursor); + statuses.set( + host.hostId, + Object.freeze({ + hostId: host.hostId, + targetId: host.targetId, + state: decoded.index.state, + indexedSessions: decoded.index.indexedSessions, + knownSessions: decoded.index.knownSessions, + generation: decoded.index.generation, + incomplete: + decoded.incomplete || + (decoded.nextCursor !== undefined && nextCursor === undefined), + ...(nextCursor === undefined ? {} : { nextCursor }), + }), + ); + clearPaginationAtDisplayCap(memory); + } catch { + statuses.set( + host.hostId, + Object.freeze({ + hostId: host.hostId, + targetId: host.targetId, + state: "error", + errorCode: "invalid_result", + }), + ); + } + } + if (!controller.signal.aborted && this.active === active) { + this.current = freezeSnapshot(generation, true, statuses, results, limit); + this.publish(); + } + }), + ); + + try { + await Promise.race([work, this.abortPromise(controller.signal)]); + if (controller.signal.aborted || this.active !== active) throw abortError(); + this.current = freezeSnapshot(generation, false, statuses, results, limit); + this.active = undefined; + this.publish(); + return this.current; + } catch (error) { + if (this.active === active) { + this.active = undefined; + this.current = Object.freeze({ ...this.current, searching: false, incomplete: true }); + this.publish(); + } + throw error; + } finally { + removeExternalAbort(); + } + } + + async loadMore( + hostIdValue: string, + options: TranscriptSearchOptions = {}, + ): Promise { + const memory = this.memory; + if (memory === undefined || memory.generation !== this.generation) { + throw new TranscriptSearchError("superseded", "transcript search was cleared or superseded"); + } + if (this.active !== undefined) { + throw new TranscriptSearchError( + "invalid", + "wait for the current transcript search to finish", + ); + } + const status = memory.statuses.get(hostIdValue); + const cursor = status?.nextCursor; + if (cursor === undefined) { + throw new TranscriptSearchError( + "no_cursor", + "this host has no more transcript results", + hostIdValue, + ); + } + const plan = planHosts(this.runtime.getSnapshot()); + const eligible = plan.eligible.find((candidate) => candidate.hostId === hostIdValue); + if (eligible === undefined) { + const currentState = plan.statuses.get(hostIdValue)?.state; + if (currentState === "offline" || currentState === undefined) { + throw new TranscriptSearchError("offline", "transcript host is offline", hostIdValue); + } + throw new TranscriptSearchError( + "unsupported", + "transcript search is unsupported by this host", + hostIdValue, + ); + } + + this.pagination.get(hostIdValue)?.controller.abort(); + const controller = new AbortController(); + const run: ActiveRun = { generation: memory.generation, controller }; + this.pagination.set(hostIdValue, run); + const removeExternalAbort = this.forwardAbort(options.signal, controller); + this.current = freezeSnapshot( + memory.generation, + true, + memory.statuses, + memory.results, + memory.visibleLimit, + ); + this.publish(); + + try { + if (controller.signal.aborted) throw abortError(); + const command = this.issueSearch(eligible, { ...memory.args, cursor }); + const commandResult = await Promise.race([command, this.abortPromise(controller.signal)]); + if ( + controller.signal.aborted || + this.memory !== memory || + this.pagination.get(hostIdValue) !== run || + this.generation !== memory.generation + ) { + throw abortError(); + } + if (commandResult.accepted !== true) { + const code = commandFailureCode(commandResult); + memory.statuses.set( + hostIdValue, + Object.freeze({ + hostId: eligible.hostId, + targetId: eligible.targetId, + state: isUnsupportedCode(code) ? "unsupported" : "error", + errorCode: code, + }), + ); + throw new TranscriptSearchError( + "command", + "transcript pagination command failed", + hostIdValue, + ); + } + let decoded: TranscriptSearchResult; + try { + decoded = decodeTranscriptSearchResult(commandResult.result); + } catch { + memory.statuses.set( + hostIdValue, + Object.freeze({ + hostId: eligible.hostId, + targetId: eligible.targetId, + state: "error", + errorCode: "invalid_result", + }), + ); + throw new TranscriptSearchError( + "command", + "transcript pagination result was invalid", + hostIdValue, + ); + } + memory.results.set( + hostIdValue, + appendHostItems(memory.results.get(hostIdValue) ?? [], decoded.items), + ); + trimRetainedResults(memory.results); + const nextCursor = rememberNextCursor(memory, hostIdValue, decoded.nextCursor); + memory.statuses.set( + hostIdValue, + Object.freeze({ + hostId: eligible.hostId, + targetId: eligible.targetId, + state: decoded.index.state, + indexedSessions: decoded.index.indexedSessions, + knownSessions: decoded.index.knownSessions, + generation: decoded.index.generation, + incomplete: + decoded.incomplete || (decoded.nextCursor !== undefined && nextCursor === undefined), + ...(nextCursor === undefined ? {} : { nextCursor }), + }), + ); + memory.visibleLimit = Math.min( + MAX_RETAINED_TRANSCRIPT_SEARCH_ITEMS, + memory.visibleLimit + (memory.args.limit ?? 20), + ); + clearPaginationAtDisplayCap(memory); + this.pagination.delete(hostIdValue); + this.current = freezeSnapshot( + memory.generation, + this.pagination.size > 0, + memory.statuses, + memory.results, + memory.visibleLimit, + ); + this.publish(); + return this.current; + } catch (error) { + if (this.pagination.get(hostIdValue) === run) { + this.pagination.delete(hostIdValue); + if (this.memory === memory) { + const currentStatus = memory.statuses.get(hostIdValue); + if ( + !(error instanceof TranscriptSearchError && error.code === "superseded") && + currentStatus?.state !== "error" && + currentStatus?.state !== "unsupported" + ) { + memory.statuses.set( + hostIdValue, + Object.freeze({ + hostId: eligible.hostId, + targetId: eligible.targetId, + state: "error", + errorCode: "command_failed", + }), + ); + } + this.current = freezeSnapshot( + memory.generation, + this.pagination.size > 0, + memory.statuses, + memory.results, + memory.visibleLimit, + ); + this.publish(); + } + } + throw error; + } finally { + removeExternalAbort(); + } + } + + async context( + hostIdValue: string, + sessionIdValue: string, + args: TranscriptContextArguments, + options: TranscriptSearchOptions = {}, + ): Promise { + const normalized = normalizeContextArguments(args); + const plan = planHosts(this.runtime.getSnapshot()); + const eligible = plan.eligible.find((candidate) => candidate.hostId === hostIdValue); + if (eligible === undefined) { + const status = plan.statuses.get(hostIdValue)?.state; + if (status === "offline" || status === undefined) { + throw new TranscriptSearchError("offline", "transcript host is offline", hostIdValue); + } + throw new TranscriptSearchError( + "unsupported", + "transcript search is unsupported by this host", + hostIdValue, + ); + } + const controller = new AbortController(); + const removeExternalAbort = this.forwardAbort(options.signal, controller); + try { + if (controller.signal.aborted) throw abortError(); + const command = this.issueContext(eligible, sessionId(sessionIdValue), normalized); + const result = await Promise.race([command, this.abortPromise(controller.signal)]); + if (controller.signal.aborted) throw abortError(); + if (result.accepted !== true) { + throw new TranscriptSearchError( + "command", + "transcript context command failed", + hostIdValue, + ); + } + try { + return decodeTranscriptContextResult(result.result); + } catch { + throw new TranscriptSearchError( + "command", + "transcript context result was invalid", + hostIdValue, + ); + } + } finally { + removeExternalAbort(); + } + } + + private publish(): void { + for (const listener of this.listeners) listener(this.current); + } + + private abortPagination(): void { + for (const run of this.pagination.values()) run.controller.abort(); + this.pagination.clear(); + } + + private issueSearch(host: EligibleHost, args: TranscriptSearchArguments): Promise { + const intent: CommandRequest["intent"] = { + hostId: host.hostId, + command: "transcript.search", + args: { ...args }, + }; + return this.runtime.command(host.targetId, intent); + } + + private issueContext( + host: EligibleHost, + contextSessionId: SessionId, + args: TranscriptContextArguments, + ): Promise { + const intent: CommandRequest["intent"] = { + hostId: host.hostId, + sessionId: contextSessionId, + command: "transcript.context", + args: { ...args }, + }; + return this.runtime.command(host.targetId, intent); + } + + private forwardAbort(signal: AbortSignal | undefined, controller: AbortController): Unsubscribe { + if (signal === undefined) return () => undefined; + if (signal.aborted) { + controller.abort(); + return () => undefined; + } + const abort = (): void => controller.abort(); + signal.addEventListener("abort", abort, { once: true }); + return () => signal.removeEventListener("abort", abort); + } + + private abortPromise(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError()); + return new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(abortError()), { once: true }); + }); + } +} + +export function createTranscriptSearchCoordinator( + runtime: TranscriptSearchRuntime, +): TranscriptSearchCoordinator { + return new TranscriptSearchCoordinator(runtime); +} diff --git a/packages/client/test/transcript-search.test.ts b/packages/client/test/transcript-search.test.ts new file mode 100644 index 0000000..4859c29 --- /dev/null +++ b/packages/client/test/transcript-search.test.ts @@ -0,0 +1,509 @@ +import { describe, expect, it } from "vite-plus/test"; +import { entryId, hostId, projectId, sessionId } from "@t4-code/protocol"; +import type { CommandRequest, CommandResult, DesktopTarget } from "@t4-code/protocol/desktop-ipc"; +import type { + DesktopHostMetadata, + DesktopRuntimeSnapshot, +} from "../src/desktop-runtime-contracts.ts"; +import { + createTranscriptSearchCoordinator, + MAX_RETAINED_TRANSCRIPT_SEARCH_ITEMS, + TranscriptSearchError, + type TranscriptSearchResult, + type TranscriptSearchRuntime, +} from "../src/transcript-search.ts"; + +const stamp = "2026-01-01T00:00:00.000Z"; + +interface HostSpec { + readonly hostId: string; + readonly connected?: boolean; + readonly supported?: boolean; +} + +function runtimeSnapshot(hosts: readonly HostSpec[]): DesktopRuntimeSnapshot { + const targets = new Map(); + const connections = new Map(); + const targetHosts = new Map(); + const metadata = new Map(); + for (const spec of hosts) { + const targetId = `target-${spec.hostId}`; + const state = spec.connected === false ? "disconnected" : "connected"; + targets.set(targetId, { + targetId, + label: spec.hostId, + kind: "remote", + state, + paired: true, + }); + connections.set(targetId, state); + targetHosts.set(targetId, spec.hostId); + metadata.set(spec.hostId, { + targetId, + hostId: spec.hostId, + ompVersion: "test", + ompBuild: "test", + appserverVersion: "test", + appserverBuild: "test", + epoch: "epoch-1", + grantedCapabilities: ["sessions.read"], + grantedFeatures: spec.supported === false ? [] : ["transcript.search"], + negotiatedLimits: {}, + authentication: "local", + resumed: false, + }); + } + return { + version: 1, + platform: "linux", + desktopVersion: "test", + startState: "started", + targets, + connections, + targetHosts, + hosts: metadata, + catalogs: new Map(), + settings: new Map(), + projection: { + version: 1, + hosts: new Map(), + sessions: new Map(), + activeSession: undefined, + }, + runtimeErrors: [], + } as unknown as DesktopRuntimeSnapshot; +} + +function item( + session: string, + anchor: string, + timestamp: string, + snippet = anchor, +): TranscriptSearchResult["items"][number] { + return { + sessionId: sessionId(session), + projectId: projectId("project-1"), + sessionTitle: session, + anchorId: entryId(anchor), + role: "assistant", + timestamp, + snippet, + highlights: [], + }; +} + +function result( + state: "building" | "ready" | "stale", + items: TranscriptSearchResult["items"] = [], + options: { readonly incomplete?: boolean; readonly nextCursor?: string } = {}, +): TranscriptSearchResult { + return { + items, + incomplete: options.incomplete ?? state !== "ready", + ...(options.nextCursor === undefined ? {} : { nextCursor: options.nextCursor }), + index: { + state, + indexedSessions: items.length, + knownSessions: Math.max(3, items.length), + generation: `${state}-generation`, + }, + }; +} + +class FakeRuntime implements TranscriptSearchRuntime { + readonly calls: Array<{ readonly targetId: string; readonly intent: CommandRequest["intent"] }> = + []; + constructor( + readonly current: DesktopRuntimeSnapshot, + readonly handler: ( + targetId: string, + intent: CommandRequest["intent"], + ) => Promise, + ) {} + getSnapshot(): DesktopRuntimeSnapshot { + return this.current; + } + async command(targetId: string, intent: CommandRequest["intent"]): Promise { + this.calls.push({ targetId, intent }); + return this.handler(targetId, intent); + } +} + +function accepted(targetId: string, value: unknown): CommandResult { + return { + targetId, + requestId: `request-${targetId}`, + commandId: `command-${targetId}`, + accepted: true, + result: value, + }; +} + +describe("cross-host transcript search coordinator", () => { + it("fans out only to eligible hosts and deterministically merges bounded results", async () => { + const snapshot = runtimeSnapshot([ + { hostId: "alpha" }, + { hostId: "beta" }, + { hostId: "gamma" }, + { hostId: "delta", supported: false }, + { hostId: "echo", connected: false }, + { hostId: "foxtrot" }, + ]); + const runtime = new FakeRuntime(snapshot, async (targetId) => { + if (targetId === "target-alpha") { + return accepted( + targetId, + result("ready", [ + item("alpha-session", "alpha-rank-0", "2026-01-02T00:00:00.000Z"), + item("alpha-session", "alpha-rank-1", "2026-01-05T00:00:00.000Z"), + ]), + ); + } + if (targetId === "target-beta") { + return accepted( + targetId, + result( + "stale", + [ + item("beta-session", "beta-rank-0", "2026-01-03T00:00:00.000Z"), + item("beta-session", "beta-rank-0", "2026-01-03T00:00:00.000Z"), + ], + { nextCursor: "beta-next" }, + ), + ); + } + if (targetId === "target-gamma") return accepted(targetId, result("building")); + throw new Error("host unavailable"); + }); + const coordinator = createTranscriptSearchCoordinator(runtime); + + const searched = await coordinator.search({ query: " prior decision ", limit: 10 }); + + expect(runtime.calls.map((call) => call.targetId).sort()).toEqual([ + "target-alpha", + "target-beta", + "target-foxtrot", + "target-gamma", + ]); + expect(runtime.calls.every((call) => call.intent.command === "transcript.search")).toBe(true); + expect(runtime.calls.every((call) => call.intent.args?.query === "prior decision")).toBe(true); + expect(searched.items.map((entry) => `${entry.hostId}:${entry.anchorId}`)).toEqual([ + "beta:beta-rank-0", + "alpha:alpha-rank-0", + "alpha:alpha-rank-1", + ]); + expect(searched.hosts.get("alpha")?.state).toBe("ready"); + expect(searched.hosts.get("beta")).toMatchObject({ state: "stale", nextCursor: "beta-next" }); + expect(searched.hosts.get("gamma")?.state).toBe("building"); + expect(searched.hosts.get("delta")?.state).toBe("unsupported"); + expect(searched.hosts.get("echo")?.state).toBe("offline"); + expect(searched.hosts.get("foxtrot")).toMatchObject({ + state: "error", + errorCode: "command_failed", + }); + expect(searched.incomplete).toBe(true); + expect("query" in searched).toBe(false); + + const cleared = coordinator.clear(); + expect(cleared.items).toEqual([]); + expect("query" in cleared).toBe(false); + }); + + it("rejects superseded searches and ignores their late results", async () => { + const first = Promise.withResolvers(); + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }]), + async (targetId, intent) => { + if (intent.args?.query === "first") return first.promise; + return accepted( + targetId, + result("ready", [ + item("new-session", "new-anchor", "2026-01-04T00:00:00.000Z", "new result"), + ]), + ); + }, + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + + const staleSearch = coordinator.search({ query: "first" }); + const freshSearch = coordinator.search({ query: "second" }); + + await expect(staleSearch).rejects.toMatchObject({ code: "superseded" }); + expect((await freshSearch).items.map((entry) => entry.snippet)).toEqual(["new result"]); + first.resolve( + accepted( + "target-alpha", + result("ready", [ + item("old-session", "old-anchor", "2026-01-05T00:00:00.000Z", "old result"), + ]), + ), + ); + await Promise.resolve(); + expect(coordinator.getSnapshot().items.map((entry) => entry.snippet)).toEqual(["new result"]); + }); + + it("cancels a pending search without leaving the public state stuck as searching", async () => { + const pending = Promise.withResolvers(); + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }]), + async () => pending.promise, + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + + const search = coordinator.search({ query: "cancel me" }); + coordinator.cancel(); + + await expect(search).rejects.toMatchObject({ code: "superseded" }); + expect(coordinator.getSnapshot()).toMatchObject({ searching: false, incomplete: true }); + pending.resolve(accepted("target-alpha", result("ready"))); + }); + + it("routes pagination only to the host that issued its cursor and appends unique results", async () => { + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }, { hostId: "beta" }]), + async (targetId, intent) => { + if (intent.args?.cursor === "alpha-next") { + return accepted( + targetId, + result("ready", [ + item("alpha-session", "alpha-0", "2026-01-02T00:00:00.000Z"), + item("alpha-session", "alpha-1", "2026-01-04T00:00:00.000Z"), + ]), + ); + } + if (targetId === "target-alpha") { + return accepted( + targetId, + result("ready", [item("alpha-session", "alpha-0", "2026-01-02T00:00:00.000Z")], { + nextCursor: "alpha-next", + }), + ); + } + return accepted( + targetId, + result("ready", [item("beta-session", "beta-0", "2026-01-03T00:00:00.000Z")], { + nextCursor: "beta-next", + }), + ); + }, + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + await coordinator.search({ query: "page safely", limit: 2 }); + + const paged = await coordinator.loadMore("alpha"); + + expect(runtime.calls).toHaveLength(3); + expect(runtime.calls[2]).toMatchObject({ + targetId: "target-alpha", + intent: { + command: "transcript.search", + args: { query: "page safely", limit: 2, cursor: "alpha-next" }, + }, + }); + expect(paged.items.map((entry) => `${entry.hostId}:${entry.anchorId}`)).toEqual([ + "beta:beta-0", + "alpha:alpha-0", + "alpha:alpha-1", + ]); + expect(paged.hosts.get("alpha")?.nextCursor).toBeUndefined(); + expect(paged.hosts.get("beta")?.nextCursor).toBe("beta-next"); + await expect(coordinator.loadMore("alpha")).rejects.toMatchObject({ + code: "no_cursor", + hostId: "alpha", + }); + expect(runtime.calls).toHaveLength(3); + }); + + it("shows production-sized pages up to the client-wide retention cap", async () => { + const pageSize = 50; + const pageItems = (page: number) => + Array.from({ length: pageSize }, (_, offset) => { + const index = page * pageSize + offset; + return item("long-session", `anchor-${index.toString().padStart(3, "0")}`, stamp); + }); + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }]), + async (targetId, intent) => { + const cursor = intent.args?.cursor; + const page = cursor === undefined ? 0 : Number(String(cursor).replace("cursor-", "")); + return accepted( + targetId, + result("ready", pageItems(page), { nextCursor: `cursor-${page + 1}` }), + ); + }, + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + + expect( + (await coordinator.search({ query: "many results", limit: pageSize })).items, + ).toHaveLength(pageSize); + await coordinator.loadMore("alpha"); + await coordinator.loadMore("alpha"); + const bounded = await coordinator.loadMore("alpha"); + + expect(bounded.items).toHaveLength(MAX_RETAINED_TRANSCRIPT_SEARCH_ITEMS); + expect(bounded.items.at(-1)?.anchorId).toBe("anchor-199"); + expect(bounded.hosts.get("alpha")?.nextCursor).toBeUndefined(); + await expect(coordinator.loadMore("alpha")).rejects.toMatchObject({ code: "no_cursor" }); + expect(runtime.calls).toHaveLength(4); + }); + + it("stops pagination when a host repeats an opaque cursor", async () => { + let page = 0; + const runtime = new FakeRuntime(runtimeSnapshot([{ hostId: "alpha" }]), async (targetId) => { + const current = page++; + return accepted( + targetId, + result("ready", [item("loop-session", `loop-${current}`, stamp)], { + nextCursor: "repeated-cursor", + }), + ); + }); + const coordinator = createTranscriptSearchCoordinator(runtime); + await coordinator.search({ query: "cursor loop" }); + + const secondPage = await coordinator.loadMore("alpha"); + + expect(secondPage.items.map((entry) => entry.anchorId)).toEqual(["loop-0", "loop-1"]); + expect(secondPage.hosts.get("alpha")?.incomplete).toBe(true); + expect(secondPage.hosts.get("alpha")?.nextCursor).toBeUndefined(); + await expect(coordinator.loadMore("alpha")).rejects.toMatchObject({ code: "no_cursor" }); + expect(runtime.calls).toHaveLength(2); + }); + + it("rejects a shared cursor before fan-out and ignores pagination completed after clear", async () => { + const page = Promise.withResolvers(); + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }, { hostId: "beta" }]), + async (targetId, intent) => { + if (intent.args?.cursor === "alpha-next") return page.promise; + return accepted( + targetId, + result( + "ready", + [item(`${targetId}-session`, `${targetId}-anchor`, stamp)], + targetId === "target-alpha" ? { nextCursor: "alpha-next" } : {}, + ), + ); + }, + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + + await expect( + coordinator.search({ query: "unsafe", cursor: "shared-cursor" }), + ).rejects.toMatchObject({ + code: "invalid", + }); + expect(runtime.calls).toHaveLength(0); + + await coordinator.search({ query: "safe" }); + const stalePage = coordinator.loadMore("alpha"); + coordinator.clear(); + await expect(stalePage).rejects.toMatchObject({ code: "superseded" }); + page.resolve( + accepted( + "target-alpha", + result("ready", [item("late-session", "late-anchor", stamp, "late result")]), + ), + ); + await Promise.resolve(); + expect(coordinator.getSnapshot().items).toEqual([]); + }); + + it("reads bounded context from the owning host and fails closed for offline hosts", async () => { + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }, { hostId: "offline", connected: false }]), + async (targetId) => + accepted(targetId, { + anchorId: "anchor-1", + rows: [ + { + anchorId: "anchor-1", + role: "user", + timestamp: stamp, + text: "Earlier question", + }, + ], + anchorIndex: 0, + hasBefore: false, + hasAfter: false, + generation: "context-generation", + }), + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + + const context = await coordinator.context("alpha", "session-1", { + anchorId: entryId("anchor-1"), + before: 5, + after: 5, + }); + expect(context.rows[0]?.text).toBe("Earlier question"); + expect(runtime.calls[0]).toMatchObject({ + targetId: "target-alpha", + intent: { + hostId: hostId("alpha"), + sessionId: sessionId("session-1"), + command: "transcript.context", + args: { anchorId: entryId("anchor-1"), before: 5, after: 5 }, + }, + }); + + await expect( + coordinator.context("offline", "session-1", { anchorId: entryId("anchor-1") }), + ).rejects.toEqual( + expect.objectContaining>({ + code: "offline", + hostId: "offline", + }), + ); + expect(runtime.calls).toHaveLength(1); + }); + + it("aborts a context read and ignores its late completion", async () => { + const pending = Promise.withResolvers(); + const runtime = new FakeRuntime( + runtimeSnapshot([{ hostId: "alpha" }]), + async () => pending.promise, + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + const controller = new AbortController(); + + const context = coordinator.context( + "alpha", + "session-1", + { anchorId: entryId("anchor-1") }, + { signal: controller.signal }, + ); + controller.abort(); + + await expect(context).rejects.toMatchObject({ code: "superseded" }); + pending.resolve( + accepted("target-alpha", { + anchorId: "anchor-1", + rows: [{ anchorId: "anchor-1", role: "assistant", timestamp: stamp, text: "late" }], + anchorIndex: 0, + hasBefore: false, + hasAfter: false, + generation: "late-generation", + }), + ); + }); + + it("rejects malformed context results instead of exposing untrusted data", async () => { + const runtime = new FakeRuntime(runtimeSnapshot([{ hostId: "alpha" }]), async (targetId) => + accepted(targetId, { + anchorId: "anchor-1", + rows: [{ anchorId: "different", role: "assistant", timestamp: stamp, text: "bad" }], + anchorIndex: 0, + hasBefore: false, + hasAfter: false, + generation: "context-generation", + }), + ); + const coordinator = createTranscriptSearchCoordinator(runtime); + + await expect( + coordinator.context("alpha", "session-1", { anchorId: entryId("anchor-1") }), + ).rejects.toMatchObject({ code: "command" }); + }); +}); diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 0f707f4..aa1a432 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -15,7 +15,7 @@ "test": "vp test run --passWithNoTests" }, "dependencies": { - "@oh-my-pi/app-wire": "file:../../vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz" + "@oh-my-pi/app-wire": "file:../../vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/protocol/test/distribution.test.ts b/packages/protocol/test/distribution.test.ts index 9334b26..d6e4c59 100644 --- a/packages/protocol/test/distribution.test.ts +++ b/packages/protocol/test/distribution.test.ts @@ -68,6 +68,12 @@ const expectedTarEntries = [ "snapshot", "terminal-output", "terminal", + "transcript-context-anchor.invalid", + "transcript-context-request", + "transcript-context-response", + "transcript-search-limit.invalid", + "transcript-search-request", + "transcript-search-response", "welcome", ].map((name) => `package/fixtures/v1/${name}.json`), "package/fixtures/v1/scenarios/agent-view-lifecycle.json", @@ -97,6 +103,7 @@ const expectedTarEntries = [ "session-state", "snapshot", "terminal", + "transcript-search", "usage", "user-terminals", ].map((name) => `package/src/${name}.ts`), @@ -132,15 +139,15 @@ describe("vendored app-wire distribution", () => { it("pins the frozen source, protocol, corpus, and tarball checksums", () => { expect(manifest).toMatchObject({ package: "@oh-my-pi/app-wire", - version: "0.6.0", + version: "0.6.1", sourceRepository: "https://github.com/lyc-aon/oh-my-pi", - sourceCommit: "ae4b53b416f32b200865a32ed9baabd5a4666fa4", - sourceTreeHash: "2b8a5f697273f5044789b8ae638b6c264f9f8499", - tarball: "oh-my-pi-app-wire-0.6.0.tgz", + sourceCommit: "e3e15c03ae95ebbda5f26495cd21213cc53518b1", + sourceTreeHash: "e0f32b279eb4b8cbc403e47d765a226bee99c99f", + tarball: "oh-my-pi-app-wire-0.6.1.tgz", appProtocol: "omp-app/1", - goldenCorpusSha256: "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1", + goldenCorpusSha256: "d5e674095de3d9b3b56a5668bc91cbbf1904b409ea9ea6456c2eabdf272e7870", }); - expect(manifest.createdAt).toBe("2026-07-19T01:06:38Z"); + expect(manifest.createdAt).toBe("2026-07-19T05:29:53Z"); expect(sha256(tarballPath)).toBe(manifest.tarballSha256); expect(goldenCorpusSha256(join(installedRoot, "fixtures", "v1"))).toBe( manifest.goldenCorpusSha256, @@ -159,7 +166,7 @@ describe("vendored app-wire distribution", () => { .split("\n") .sort(); expect(entries).toEqual(expectedTarEntries); - expect(entries).toHaveLength(71); + expect(entries).toHaveLength(78); const protocolPackage = readFileSync( join(repoRoot, "packages", "protocol", "package.json"), @@ -168,9 +175,9 @@ describe("vendored app-wire distribution", () => { const lockfile = readFileSync(join(repoRoot, "pnpm-lock.yaml"), "utf8"); expect(`${protocolPackage}\n${lockfile}`).not.toContain("/home/"); expect(protocolPackage).toMatch( - /"@oh-my-pi\/app-wire": "file:\.\.\/\.\.\/vendor\/app-wire\/oh-my-pi-app-wire-0\.6\.0\.tgz"/u, + /"@oh-my-pi\/app-wire": "file:\.\.\/\.\.\/vendor\/app-wire\/oh-my-pi-app-wire-0\.6\.1\.tgz"/u, ); - expect(lockfile).toMatch(/version: file:vendor\/app-wire\/oh-my-pi-app-wire-0\.6\.0\.tgz/u); + expect(lockfile).toMatch(/version: file:vendor\/app-wire\/oh-my-pi-app-wire-0\.6\.1\.tgz/u); expect(`${protocolPackage}\n${lockfile}`).not.toMatch(/file:\/\//u); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eca2ea0..e76bf12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,8 +297,8 @@ importers: packages/protocol: dependencies: '@oh-my-pi/app-wire': - specifier: file:../../vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz - version: file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz + specifier: file:../../vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz + version: file:vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz devDependencies: '@types/node': specifier: 'catalog:' @@ -647,9 +647,9 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz': - resolution: {integrity: sha512-n//WxHc1cMOfKeA47sZgFR5l6mGMCEb6JX2kubw+W3AasriPHio6S85eW8x1mZdJYRjFILu3STLIYasqOMifaw==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz} - version: 0.6.0 + '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz': + resolution: {integrity: sha512-+63mOp2tLcZjLIJSJBbWhRTZwCMdWRvOlz4XMJkSEcAQbdxrjylThJPuxol9d1Ib5QeWM6S3aeYIImzOsa+78w==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz} + version: 0.6.1 engines: {bun: '>=1.3.14'} '@oxc-project/runtime@0.138.0': @@ -3746,7 +3746,7 @@ snapshots: '@noble/hashes@2.2.0': {} - '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz': {} + '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz': {} '@oxc-project/runtime@0.138.0': {} diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 05b8501..3682b3e 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -27,6 +27,17 @@ function changedRuntime(name, mutate) { }); } +function replaceRequired(text, search, replacement) { + assert.ok(text.includes(search), `fixture is missing expected value ${search}`); + return text.replace(search, replacement); +} + +function nextPatchVersion(version) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/u); + assert.ok(match, `expected a stable semantic version, received ${version}`); + return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`; +} + function requiredWorkflowJob(workflow, name) { const parsed = parseYaml(workflow); assert.ok(parsed && typeof parsed === "object" && !Array.isArray(parsed)); @@ -335,12 +346,11 @@ test("rejects published app-wire provenance drift until release surfaces agree", }); test("rejects drift between the compatibility matrix and vendored app-wire manifest", () => { - const drifted = changed("vendor/app-wire/manifest.json", (text) => - text.replace( - '"sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499"', - '"sourceTreeHash": "0000000000000000000000000000000000000000"', - ), - ); + const drifted = changed("vendor/app-wire/manifest.json", (text) => { + const manifest = JSON.parse(text); + manifest.sourceTreeHash = "0".repeat(40); + return JSON.stringify(manifest); + }); assert.ok( collectReleaseConsistencyErrors(drifted).some((error) => error.includes("vendor/app-wire/manifest.json sourceTreeHash must match"), @@ -349,8 +359,11 @@ test("rejects drift between the compatibility matrix and vendored app-wire manif }); test("rejects a stale app-wire third-party notice", () => { + const { package: packageName, version } = JSON.parse( + files.get("vendor/app-wire/manifest.json"), + ); const drifted = changed("THIRD_PARTY_NOTICES.md", (text) => - text.replace("@oh-my-pi/app-wire@0.6.0", "@oh-my-pi/app-wire@0.5.8"), + replaceRequired(text, `${packageName}@${version}`, `${packageName}@0.0.0`), ); assert.ok( collectReleaseConsistencyErrors(drifted).some((error) => @@ -402,52 +415,37 @@ test("rejects drift in published OMP runtime provenance", () => { test("accepts a current app-wire update without rewriting published release surfaces", () => { const coordinated = new Map(files); - for (const path of ["compat/omp-app-matrix.json", "vendor/app-wire/manifest.json"]) { - coordinated.set( - path, - coordinated - .get(path) - .replace('"version": "0.5.10"', '"version": "0.6.0"') - .replace( - '"sourceCommit": "93f48ab62e2002b48a0dc2734de33d5328ea76d6"', - '"sourceCommit": "1111111111111111111111111111111111111111"', - ) - .replace( - '"sourceTreeHash": "ea8608496731f29addc95d43ea68e44c5c42cb22"', - '"sourceTreeHash": "2222222222222222222222222222222222222222"', - ) - .replace("oh-my-pi-app-wire-0.5.10.tgz", "oh-my-pi-app-wire-0.6.0.tgz") - .replace( - '"tarballSha256": "d30da820ff2bb8a7efa024fc829b654a2dfaf2600688fa369abc64b053ae8ede"', - '"tarballSha256": "3333333333333333333333333333333333333333333333333333333333333333"', - ) - .replace( - '"goldenCorpusSha256": "63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e"', - '"goldenCorpusSha256": "4444444444444444444444444444444444444444444444444444444444444444"', - ), - ); - } + const matrix = JSON.parse(coordinated.get("compat/omp-app-matrix.json")); + const manifest = JSON.parse(coordinated.get("vendor/app-wire/manifest.json")); + const current = { ...matrix.appWire }; + const proposed = { + version: nextPatchVersion(current.version), + sourceCommit: "1".repeat(40), + sourceTreeHash: "2".repeat(40), + tarballSha256: "3".repeat(64), + goldenCorpusSha256: "4".repeat(64), + }; + + Object.assign(matrix.appWire, proposed, { + tarball: `vendor/app-wire/oh-my-pi-app-wire-${proposed.version}.tgz`, + }); + Object.assign(manifest, proposed, { + tarball: `oh-my-pi-app-wire-${proposed.version}.tgz`, + }); + coordinated.set("compat/omp-app-matrix.json", JSON.stringify(matrix)); + coordinated.set("vendor/app-wire/manifest.json", JSON.stringify(manifest)); coordinated.set( "THIRD_PARTY_NOTICES.md", - coordinated - .get("THIRD_PARTY_NOTICES.md") - .replace("@oh-my-pi/app-wire@0.5.10", "@oh-my-pi/app-wire@0.6.0") - .replace( - "93f48ab62e2002b48a0dc2734de33d5328ea76d6", - "1111111111111111111111111111111111111111", - ) - .replace( - "ea8608496731f29addc95d43ea68e44c5c42cb22", - "2222222222222222222222222222222222222222", - ) - .replace( - "d30da820ff2bb8a7efa024fc829b654a2dfaf2600688fa369abc64b053ae8ede", - "3333333333333333333333333333333333333333333333333333333333333333", - ) - .replace( - "63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e", - "4444444444444444444444444444444444444444444444444444444444444444", - ), + [ + [`${current.package}@${current.version}`, `${current.package}@${proposed.version}`], + [current.sourceCommit, proposed.sourceCommit], + [current.sourceTreeHash, proposed.sourceTreeHash], + [current.tarballSha256, proposed.tarballSha256], + [current.goldenCorpusSha256, proposed.goldenCorpusSha256], + ].reduce( + (notice, [from, to]) => replaceRequired(notice, from, to), + coordinated.get("THIRD_PARTY_NOTICES.md"), + ), ); assert.deepEqual(collectReleaseConsistencyErrors(coordinated), []); diff --git a/vendor/app-wire/manifest.json b/vendor/app-wire/manifest.json index 73d34f8..9c7adca 100644 --- a/vendor/app-wire/manifest.json +++ b/vendor/app-wire/manifest.json @@ -1,12 +1,12 @@ { "package": "@oh-my-pi/app-wire", - "version": "0.6.0", + "version": "0.6.1", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", - "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499", - "tarball": "oh-my-pi-app-wire-0.6.0.tgz", - "tarballSha256": "92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c", + "sourceCommit": "e3e15c03ae95ebbda5f26495cd21213cc53518b1", + "sourceTreeHash": "e0f32b279eb4b8cbc403e47d765a226bee99c99f", + "tarball": "oh-my-pi-app-wire-0.6.1.tgz", + "tarballSha256": "78ec6223e7ad0f4f9e14526a822eaa45b363c856065c08196b2817a2f42740b9", "appProtocol": "omp-app/1", - "goldenCorpusSha256": "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1", - "createdAt": "2026-07-19T01:06:38Z" + "goldenCorpusSha256": "d5e674095de3d9b3b56a5668bc91cbbf1904b409ea9ea6456c2eabdf272e7870", + "createdAt": "2026-07-19T05:29:53Z" } diff --git a/vendor/app-wire/oh-my-pi-app-wire-0.5.10.tgz b/vendor/app-wire/oh-my-pi-app-wire-0.5.10.tgz deleted file mode 100644 index 596d46d..0000000 Binary files a/vendor/app-wire/oh-my-pi-app-wire-0.5.10.tgz and /dev/null differ diff --git a/vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz b/vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz new file mode 100644 index 0000000..6de7e90 Binary files /dev/null and b/vendor/app-wire/oh-my-pi-app-wire-0.6.1.tgz differ
+ This is a read-only window around the matched message. Your live session stays separate and keeps receiving new output. +
{result.sessionTitle}
+ {result.projectLabel} · {result.hostLabel} +
{row.text}
+ {props.loadMoreError} +