/**
* Hydration: direct JSONL parsing of Pi session files (never SessionManager.open —
* it rewrites legacy files), branch-aware windows, READ head/tail.
* Pure functions over file paths; no SQLite access.
*/
///
import { readTranscriptEntries, type TranscriptEntry } from "./transcript.ts";
import type { WindowMessage } from "./types.ts";
export interface WindowResult {
messages: WindowMessage[];
messagesBefore: number;
messagesAfter: number;
/** Tip of the branch the window was resolved on — pass back as branchTip to
* keep scrolling on this branch across forks. May be a non-message entry id. */
branchTip: string;
}
export interface ReadResult {
messages: WindowMessage[];
totalMessages: number;
truncated: boolean;
/** Resolved leaf branch, or null when no selected message can be hydrated. */
branchTip: string | null;
}
export interface ReadOptions {
/** Preparation view: retain only non-empty user/assistant text. */
userAssistantTextOnly?: boolean;
}
interface Entry {
id: string;
parentId: string | null;
type: string;
timestamp?: string;
message?: {
role?: string;
content?: string | Array<{ type?: string; text?: string }>;
};
}
/** Project a boundary entry onto the shape hydration consumes. Entries without
* a validated id pair are dropped (they carry no hydratable position). */
function toEntry(t: TranscriptEntry): Entry | null {
if (t.id === undefined) return null;
const rec = t.data as Record;
const message = rec.message !== null && typeof rec.message === "object"
? (rec.message as Entry["message"])
: undefined;
return {
id: t.id,
parentId: t.parentId ?? null,
type: typeof rec.type === "string" ? rec.type : "",
timestamp: typeof rec.timestamp === "string" ? rec.timestamp : undefined,
message,
};
}
/** Parse JSONL lines; malformed lines are skipped and duplicate/invalid ids are
* handled by the shared transcript boundary. Entries stream out of the
* boundary's generator and only successfully projected ones are pushed. */
function parseSessionEntries(sessionPath: string): Entry[] {
// The bounded snapshot read (O_NONBLOCK + descriptor validation + fixed-size
// read) is shared with the index engine; the fd pins the inode so a
// concurrent append after fstat waits until the next hydration call.
const entries: Entry[] = [];
for (const t of readTranscriptEntries(sessionPath)) {
const projected = toEntry(t);
if (projected !== null) entries.push(projected);
}
return entries;
}
/** Leaf = last entry in file order whose ancestry contains a message; detached
* trailing metadata must not hide the transcript.
* Linear time: each entry's ancestry verdict is memoized, so every parent
* chain segment is traversed once regardless of how many trailing candidates
* share ancestry. Cycle handling matches branch(): truncation at the first
* revisit means every path node's truncated chain is a suffix of the path,
* so one walk's verdict covers all its memoized nodes. */
function leafId(entriesById: Map, entries: Entry[]): string | null {
const hasMessageAncestor = new Map();
let leaf: string | null = null;
for (const start of entries) {
const path: Entry[] = [];
const onPath = new Set();
let cur: Entry = start;
for (;;) {
const known = hasMessageAncestor.get(cur.id);
if (known !== undefined) {
for (const e of path) hasMessageAncestor.set(e.id, known);
break;
}
if (onPath.has(cur.id)) {
// No message was found before revisiting (we exit on message first).
for (const e of path) hasMessageAncestor.set(e.id, false);
break;
}
onPath.add(cur.id);
path.push(cur);
if (cur.type === "message") {
// Every node above the first message has one in its ancestry too.
for (const e of path) hasMessageAncestor.set(e.id, true);
break;
}
const parent = cur.parentId ? entriesById.get(cur.parentId) : undefined;
if (!parent) {
for (const e of path) hasMessageAncestor.set(e.id, false);
break;
}
cur = parent;
}
if (hasMessageAncestor.get(start.id)) leaf = start.id;
}
return leaf;
}
/** Walk parentId chain from entry to root, reversed (root→entry). */
function branch(entriesById: Map, entryId: string): Entry[] {
const chain: Entry[] = [];
let cur: Entry | undefined = entriesById.get(entryId);
// ponytail: cycle guard for corrupt files — revisit only if real sessions ever contain cycles
const seen = new Set();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
chain.push(cur);
cur = cur.parentId ? entriesById.get(cur.parentId) : undefined;
}
return chain.reverse();
}
/** Deepest entry in file order whose ancestry contains tipId (tipId itself if none). */
function deepestDescendant(entries: Entry[], anchorId: string): string {
const onBranch = new Set([anchorId]);
let tip = anchorId;
for (const e of entries) {
if (e.parentId && onBranch.has(e.parentId)) {
onBranch.add(e.id);
tip = e.id;
}
}
return tip;
}
function toWindowMessage(e: Entry): WindowMessage {
const m = e.message ?? {};
const content =
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? m.content
.filter((p) => p?.type === "text" && typeof p.text === "string")
.map((p) => p.text)
.join("\n")
: "";
return {
entryId: e.id,
role: typeof m.role === "string" ? m.role.slice(0, 32) : "",
content,
timestamp: e.timestamp?.slice(0, 128) ?? "",
};
}
/** Active-branch messages for a given tip, root→tip chronological. */
function branchMessages(
entriesById: Map,
tipId: string,
): WindowMessage[] {
return branch(entriesById, tipId)
.filter((e) => e.type === "message")
.map(toWindowMessage);
}
/** Resolve a scroll cursor to a message: the cursor itself when it is a
* message, otherwise its nearest message ancestor. Throws when the cursor is
* unknown or has no message ancestor. */
function resolveMessageCursor(
entriesById: Map,
anchorEntryId: string,
): Entry {
const anchor = entriesById.get(anchorEntryId);
if (!anchor) throw new Error(`anchor entry ${anchorEntryId} not found in session`);
let cur: Entry | undefined = anchor;
const seen = new Set();
while (cur && cur.type !== "message" && !seen.has(cur.id)) {
seen.add(cur.id);
cur = cur.parentId ? entriesById.get(cur.parentId) : undefined;
}
if (!cur || cur.type !== "message") throw new Error(`anchor entry ${anchorEntryId} has no message ancestor`);
return cur;
}
export function getWindow(
sessionPath: string,
anchorEntryId: string,
windowN: number,
opts?: { branchTip?: string },
): WindowResult {
const entries = parseSessionEntries(sessionPath);
const entriesById = new Map(entries.map((e) => [e.id, e]));
// A non-message branch-tip cursor centers on its nearest message ancestor;
// the non-message tip itself stays branchTip so scrolling remains on branch.
const messageAnchor = resolveMessageCursor(entriesById, anchorEntryId);
const anchorEntryIdMsg = messageAnchor.id;
let tip: string;
if (opts?.branchTip) {
// Explicit branch selection: resolve the branch from its tip (deepest
// descendant), independent of where the window centers.
resolveMessageCursor(entriesById, opts.branchTip); // unknown tip → throw
tip = deepestDescendant(entries, opts.branchTip);
if (!branch(entriesById, tip).some((e) => e.id === messageAnchor.id)) {
throw new Error(`anchor entry ${anchorEntryId} is not on branch ${opts.branchTip}`);
}
} else {
tip = deepestDescendant(entries, anchorEntryId);
}
const msgs = branchMessages(entriesById, tip);
const idx = msgs.findIndex((m) => m.entryId === anchorEntryIdMsg);
if (idx < 0) throw new Error(`anchor entry ${anchorEntryId} is not a message`);
const start = Math.max(0, idx - windowN);
const end = Math.min(msgs.length - 1, idx + windowN);
return {
messages: msgs.slice(start, end + 1).map((m) =>
m.entryId === anchorEntryIdMsg ? { ...m, anchor: true } : m,
),
messagesBefore: idx,
messagesAfter: msgs.length - 1 - idx,
branchTip: tip,
};
}
export function readSession(
sessionPath: string,
head = 20,
tail = 10,
opts?: ReadOptions,
): ReadResult {
const entries = parseSessionEntries(sessionPath);
const entriesById = new Map(entries.map((e) => [e.id, e]));
const leaf = leafId(entriesById, entries);
if (!leaf) return { messages: [], totalMessages: 0, truncated: false, branchTip: null };
const rawMessages = branchMessages(entriesById, leaf);
const msgs = opts?.userAssistantTextOnly
? rawMessages.filter((m) => (m.role === "user" || m.role === "assistant") && m.content.trim().length > 0)
: rawMessages;
const branchTip = msgs.length > 0 ? leaf : null;
if (msgs.length > head + tail) {
return {
messages: [...msgs.slice(0, head), ...msgs.slice(-tail)],
totalMessages: msgs.length,
truncated: true,
branchTip,
};
}
return { messages: msgs, totalMessages: msgs.length, truncated: false, branchTip };
}