| 1 | import { app } from "./bridge"; |
| 2 | import { historyMessageIdentity } from "./historyItemIds"; |
| 3 | import type { TranscriptSnapshot } from "./transcriptProtocol"; |
| 4 | import type { HistoryEntry, HistoryWindowPageView, HistoryWindowRequestView } from "./types"; |
| 5 | import type { HistoryOutlinePage, HistoryOutlineRequest } from "../generated/desktopContract.generated"; |
| 6 | |
| 7 | type Binding = { snapshotId: string; total: number; sequence: number; remote: boolean }; |
| 8 | const bindings = new Map<string, Binding>(); |
| 9 | type Cursor = { snapshotId: string; boundary: number; direction: "older" | "newer" }; |
| 10 | const cursor = (snapshotId: string, boundary: number, direction: Cursor["direction"]) => JSON.stringify({ snapshotId, boundary, direction }); |
| 11 | const staleWindow = (): HistoryWindowPageView => ({ entries: [], status: "stale_cursor", olderCursor: "", newerCursor: "", hasOlder: false, hasNewer: false, |
| 12 | totalTurns: 0, startTurn: 0, endTurn: 0, revision: 0, revisionKnown: false, digest: "" }); |
| 13 | |
| 14 | export function bindNativeTranscriptHistory(tabId: string, snapshot: TranscriptSnapshot, remote: boolean): () => void { |
| 15 | const binding = { snapshotId: snapshot.snapshotId, total: snapshot.totalRecords, sequence: snapshot.coveredThroughSeq, remote }; |
| 16 | bindings.set(tabId, binding); |
| 17 | return () => { if (bindings.get(tabId) === binding) bindings.delete(tabId); }; |
| 18 | } |
| 19 | |
| 20 | export function nativeSnapshotWindow(snapshot: TranscriptSnapshot): HistoryWindowPageView { |
| 21 | const entries: HistoryEntry[] = snapshot.records.map(record => { |
| 22 | const messageId = historyMessageIdentity(record.message, record.id); |
| 23 | const entryId = messageId ? `m:${messageId}` : record.message.recordId ?? record.id; |
| 24 | return { entryId, order: record.order, turn: record.message.historyTurn ?? 0, message: { ...record.message, messageId, recordId: entryId }, |
| 25 | refs: record.refs.map(ref => ({ entryId, field: ref.path[0], size: ref.bytes, chunks: 1, |
| 26 | revision: snapshot.coveredThroughSeq, revKnown: true, digest: snapshot.snapshotId, transcriptRef: ref })) }; |
| 27 | }); |
| 28 | const end = entries.length ? entries[entries.length - 1].order + 1 : snapshot.before; |
| 29 | const turns = entries.map(entry => entry.turn).filter(turn => turn > 0); |
| 30 | return { entries, status: snapshot.stale ? "stale_cursor" : snapshot.notFound ? "not_found" : "ready", |
| 31 | olderCursor: snapshot.hasOlder ? cursor(snapshot.snapshotId, snapshot.before, "older") : "", |
| 32 | newerCursor: end < snapshot.totalRecords ? cursor(snapshot.snapshotId, end, "newer") : "", |
| 33 | hasOlder: snapshot.hasOlder, hasNewer: end < snapshot.totalRecords, totalTurns: snapshot.totalTurns, |
| 34 | startTurn: turns.length ? Math.min(...turns) : 0, endTurn: turns.length ? Math.max(...turns) : 0, |
| 35 | revision: snapshot.coveredThroughSeq, revisionKnown: true, digest: snapshot.snapshotId }; |
| 36 | } |
| 37 | |
| 38 | // Select the reader from the negotiated binding, never from a failed request. |
| 39 | export async function readNativeTranscriptWindow(tabId: string, req: HistoryWindowRequestView): Promise<HistoryWindowPageView | undefined> { |
| 40 | const binding = bindings.get(tabId); |
| 41 | if (!binding) return undefined; |
| 42 | const read = binding.remote ? app.RemoteTranscriptPageForTab : app.TranscriptPageForTab; |
| 43 | if (!read) throw new Error("Native transcript paging is unavailable"); |
| 44 | const limit = Math.max(1, Math.min(req.limit ?? 32, 100)); |
| 45 | if (!req.cursor && (!req.anchor || req.anchor === "newest")) { |
| 46 | const snapshot = await read(tabId, { records: limit, ...(req.generation ? { snapshotId: req.generation } : {}) }); |
| 47 | if (bindings.get(tabId) !== binding) return staleWindow(); |
| 48 | if (!req.generation && !snapshot.stale) { |
| 49 | binding.snapshotId = snapshot.snapshotId; binding.total = snapshot.totalRecords; binding.sequence = snapshot.coveredThroughSeq; |
| 50 | } |
| 51 | return nativeSnapshotWindow(snapshot); |
| 52 | } |
| 53 | let snapshotId = req.generation || binding.snapshotId, boundary = binding.total; |
| 54 | let direction: Cursor["direction"] = req.direction === "newer" ? "newer" : "older"; |
| 55 | if (req.cursor) { |
| 56 | let parsed: Cursor; |
| 57 | try { parsed = JSON.parse(req.cursor); } catch { return staleWindow(); } |
| 58 | if (!parsed || typeof parsed.snapshotId !== "string" || !Number.isSafeInteger(parsed.boundary) || parsed.boundary < 0 || !["older", "newer"].includes(parsed.direction)) return staleWindow(); |
| 59 | snapshotId = parsed.snapshotId; boundary = parsed.boundary; direction = parsed.direction; |
| 60 | if (req.generation && req.generation !== snapshotId) return staleWindow(); |
| 61 | } else if (req.anchor === "turn") { |
| 62 | const outline = binding.remote ? app.RemoteTranscriptOutlineForTab : app.TranscriptOutlineForTab; |
| 63 | if (!outline) throw new Error("Native transcript outline is unavailable"); |
| 64 | const page = await outline(tabId, { snapshotId, offset: Math.max(0, (req.turn ?? 1) - 1), entries: 1 }); |
| 65 | if (bindings.get(tabId) !== binding) return staleWindow(); |
| 66 | if (page.stale || !page.entries.length) { |
| 67 | const view = nativeSnapshotWindow(await read(tabId, { snapshotId, before: 0, records: 1 })); |
| 68 | if (bindings.get(tabId) !== binding) return staleWindow(); |
| 69 | return { ...view, status: page.stale ? "stale_cursor" : "not_found" }; |
| 70 | } |
| 71 | boundary = page.entries[0].order; direction = "newer"; |
| 72 | } else if (req.anchor === "message") { |
| 73 | const snapshot = await read(tabId, { snapshotId, messageId: req.messageId, records: limit }); |
| 74 | return { ...nativeSnapshotWindow(snapshot), ...(bindings.get(tabId) !== binding ? { status: "stale_cursor" as const } : {}) }; |
| 75 | } |
| 76 | let snapshot = await read(tabId, { snapshotId, before: direction === "newer" ? boundary + limit : boundary, records: limit }); |
| 77 | if (direction === "newer" && !snapshot.stale && snapshot.records.length && snapshot.records[0].order > boundary) { |
| 78 | // Byte limits may shorten a backwards page. Never skip the forward prefix. |
| 79 | snapshot = await read(tabId, { snapshotId, before: boundary + 1, records: 1 }); |
| 80 | } |
| 81 | if (direction === "newer" && !snapshot.stale) { |
| 82 | // The server clamps before to the cut's end. Its backwards window can |
| 83 | // then overlap the preceding page; keep only the requested suffix. |
| 84 | const records = snapshot.records.filter(record => record.order >= boundary); |
| 85 | snapshot = { ...snapshot, records, before: records[0]?.order ?? Math.min(boundary, snapshot.totalRecords), |
| 86 | hasOlder: (records[0]?.order ?? boundary) > 0 }; |
| 87 | } |
| 88 | if (bindings.get(tabId) !== binding) return { ...nativeSnapshotWindow(snapshot), status: "stale_cursor" }; |
| 89 | return nativeSnapshotWindow(snapshot); |
| 90 | } |
| 91 | |
| 92 | export async function readNativeTranscriptOutline(tabId: string, req: HistoryOutlineRequest): Promise<HistoryOutlinePage | undefined> { |
| 93 | const binding = bindings.get(tabId); |
| 94 | if (!binding) return undefined; |
| 95 | const read = binding.remote ? app.RemoteTranscriptOutlineForTab : app.TranscriptOutlineForTab; |
| 96 | if (!read) throw new Error("Native transcript outline is unavailable"); |
| 97 | if (!req.generation) await readNativeTranscriptWindow(tabId, { anchor: "newest", limit: 1 }); |
| 98 | const snapshotId = req.generation || binding.snapshotId; |
| 99 | const page = await read(tabId, { snapshotId, offset: Math.max(0, (req.startTurn ?? 1) - 1), entries: Math.min(req.limit ?? 128, 1000) }); |
| 100 | return { status: page.stale || bindings.get(tabId) !== binding ? "stale_cursor" : "ready", generation: snapshotId, |
| 101 | snapshotSequence: req.snapshotSequence ?? binding.sequence, coverageSequence: binding.sequence, totalTurns: page.total, |
| 102 | entries: page.entries.map(entry => ({ messageId: entry.messageId ?? entry.id, turn: entry.turn, position: entry.order, prompt: entry.prompt, answer: entry.answer })), |
| 103 | nextTurn: page.nextOffset + 1, done: page.done }; |
| 104 | } |
| 105 |