// Pi session parser for read-only histories (#1507, CHAT-02). Pinned against // @earendil-works/pi-coding-agent 0.85.1 docs/session-format.md and // dist/core/session-manager.js: // // - line 1 is the session header; every other line is an entry with id, // parentId and timestamp, forming a tree; // - on load Pi takes the leaf to be the last entry in file order and skips // malformed lines (_buildIndex, parseSessionEntries). This parser uses the // same default leaf and shows a malformed line as an unavailable notice; // - resetLeaf starts a new root entry, so a file can hold several roots; // - the full branch path is shown, including entries before a compaction; // the compaction itself is a marker in place. retainedTail copies entries // that are already on the path, so it is not rendered again. // // get_entries order is not a branch transcript and is not used, and // parentSession is never followed or opened. import { Refusal } from "./safe-fs.mjs"; import { safeId } from "./parts.mjs"; const toTime = (v) => { const ms = typeof v === "number" ? v : typeof v === "string" ? Date.parse(v) : NaN; if (!Number.isFinite(ms)) return null; const iso = new Date(ms).toISOString(); return /^\d{4}-/.test(iso) ? iso : null; }; // Branch names. The first root's line of history is "main"; every later root // (Pi's resetLeaf) and every later child at a fork starts a branch named after // its first entry. Appending never changes which child came first, so a name // holds for as long as the file only grows. export const MAIN = "main"; const branchName = (r) => safeId(`b.${r.entry.id}`); // `text` holds complete lines only (it ends with "\n" or is empty). export function parseSnapshot(text) { const lines = text.split("\n"); lines.pop(); let header; try { header = JSON.parse(lines[0] ?? ""); } catch { header = null; } if (!header || typeof header !== "object" || header.type !== "session") throw new Refusal("not-a-pi-session", "the first line is not a Pi session header"); const entries = [], malformed = [], byId = new Map(), children = new Map(); for (let i = 1; i < lines.length; i++) { const line = lines[i]; if (!line.trim()) continue; let e; try { e = JSON.parse(line); } catch { e = null; } if (!e || typeof e !== "object" || Array.isArray(e) || typeof e.id !== "string" || typeof e.type !== "string" || e.type === "session") { malformed.push({ line: i + 1, after: entries.length - 1 }); continue; } const record = { entry: e, line: i + 1, index: entries.length }; entries.push(record); byId.set(e.id, record); // later wins, as in Pi's index } for (const r of byId.values()) { const p = typeof r.entry.parentId === "string" ? r.entry.parentId : null; if (!children.has(p)) children.set(p, []); children.get(p).push(r); } for (const list of children.values()) list.sort((a, b) => a.index - b.index); const parentOf = (r) => (typeof r.entry.parentId === "string" ? byId.get(r.entry.parentId) : undefined) ?? null; const firstRoot = [...byId.values()].filter((r) => !parentOf(r)).sort((a, b) => a.index - b.index)[0] ?? null; // Walk up while each entry is its parent's earliest child. The walk ends at // a root or a later child, whose name the branch takes. A walk from a leaf // always ends there; the loop guard only keeps a hostile file finite. const branchOf = (leaf) => { const seen = new Set(); for (let r = leaf; !seen.has(r); ) { seen.add(r); const p = parentOf(r); if (!p) return r === firstRoot ? MAIN : branchName(r); if (children.get(p.entry.id)[0] !== r) return branchName(r); r = p; } return branchName(leaf); }; const leaves = [...byId.values()].filter((r) => !children.has(r.entry.id)).sort((a, b) => a.index - b.index); const defaultLeaf = entries.length ? byId.get(entries[entries.length - 1].entry.id) : null; const defaultBranch = defaultLeaf ? branchOf(defaultLeaf) : MAIN; // Branch name to leaf. The default branch ends at Pi's default leaf, and a // file with no entries yet has an empty "main". const branches = new Map(); for (const leaf of leaves) { const name = branchOf(leaf); if (!branches.has(name)) branches.set(name, leaf); } if (defaultLeaf) branches.set(defaultBranch, defaultLeaf); if (!branches.size) branches.set(MAIN, null); return { header, entries, malformed, byId, branches, defaultLeaf, defaultBranch }; } // Root-to-leaf items for one branch: { record } for native entries and // { notice, lines? } for markers placed where they apply. A null leaf is a // branch with no entries yet. export function branchPath(parsed, leaf) { const path = []; const seen = new Set(); const named = new Set(); let r = leaf; while (r) { if (seen.has(r.entry.id)) { path.push({ notice: "loop" }); break; } seen.add(r.entry.id); path.push({ record: r }); const parentId = r.entry.parentId; if (parentId === null || parentId === undefined) break; const parent = typeof parentId === "string" ? parsed.byId.get(parentId) : null; if (parent) { r = parent; continue; } // The parent is missing. Unreadable lines just before this entry may have // held it; the notice names them. The history does not continue past the // gap: the entries before it may belong to another branch. const lost = parsed.malformed.filter((m) => m.after === r.index - 1).map((m) => m.line); lost.forEach((line) => named.add(line)); path.push({ notice: "missing-parent", lines: lost }); break; } path.reverse(); // Every other unreadable line is shown at its file position on every // branch, after the leaf too: it may belong to any branch. Placing it the // same way on every branch keeps a branch's earlier parts unchanged while // the file grows. const out = []; const pending = parsed.malformed.filter((m) => !named.has(m.line)); let mi = 0; const flushBefore = (line) => { while (mi < pending.length && pending[mi].line < line) out.push({ notice: "malformed", lines: [pending[mi++].line] }); }; for (const item of path) { const line = item.record?.line ?? item.lines?.[0]; if (line) flushBefore(line); out.push(item); } flushBefore(Infinity); return out; } const NOTICE_TEXT = { loop: "History before this point is unavailable: the entry chain loops.", "missing-parent": "History before this point is unavailable: an earlier entry is missing from the file.", "parent-session": "This session was forked from an earlier session. The earlier session is not opened here.", }; function text(value) { return { fields: { type: "text" }, key: "text", value: String(value) }; } function contentText(content) { if (typeof content === "string") return [text(content)]; if (!Array.isArray(content)) return []; return content.flatMap((b, i) => blockFor(b, i)); } function blockFor(b, i, owner = "") { if (!b || typeof b !== "object") return []; if (b.type === "text") return [text(b.text ?? "")]; if (b.type === "image") return [{ fields: { type: "attachment", attachment: safeId(`${owner}image.${i}`) }, key: null, value: null }]; return [text(`[unsupported content block: ${safeId(String(b.type))}]`)]; } // Unit ids are namespaced so no native id can collide with a notice: "n." // for native entries, "x." for notices. Entry ids derive from them (parts.mjs). // // Converts one native entry to zero or more units. Entries that Pi keeps out // of the transcript (model and thinking changes, labels, names, extension // state) produce none. function unitsFor(record, ctx) { const e = record.entry; const nativeEntry = safeId(e.id); const createdAt = toTime(e.timestamp) ?? ctx.lastTime; ctx.lastTime = createdAt; const base = { id: `n.${e.id}`, nativeEntry, parent: typeof e.parentId === "string" ? safeId(e.parentId) : null, createdAt, message: nativeEntry }; const notice = (value, suffix) => ({ ...base, id: `x.${suffix}.${e.id}`, message: safeId(`x.${suffix}.${e.id}`), role: "notice", blocks: [text(value)] }); switch (e.type) { case "message": return messageUnits(e.message, base, notice); case "compaction": return [{ ...base, role: "compaction", blocks: [{ fields: { type: "compaction", nativeEntry }, key: "summary", value: String(e.summary ?? "") }] }]; case "branch_summary": return [{ ...base, role: "notice", blocks: [text(`Branch summary\n\n${e.summary ?? ""}`)] }]; case "custom_message": return e.display ? [{ ...base, role: "notice", blocks: contentText(e.content) }] : []; case "model_change": case "thinking_level_change": case "label": case "session_info": case "custom": return []; default: return [{ ...base, role: "notice", blocks: [text(`An entry of type ${safeId(e.type)} is not shown.`)] }]; } } function messageUnits(m, base, notice) { if (!m || typeof m !== "object") return [{ ...base, role: "notice", blocks: [text("This entry has no readable message.")] }]; const owner = `${base.nativeEntry}.`; switch (m.role) { case "user": return [{ ...base, role: "user", blocks: typeof m.content === "string" ? [text(m.content)] : (Array.isArray(m.content) ? m.content : []).flatMap((b, i) => blockFor(b, i, owner)) }]; case "assistant": { const blocks = (Array.isArray(m.content) ? m.content : []).flatMap((b, i) => { if (b?.type === "thinking") { // Redacted reasoning is stored as a placeholder; it is unavailable, and // thinkingSignature is never read. const t = b.redacted === true ? "" : typeof b.thinking === "string" ? b.thinking : ""; return [{ fields: { type: "thinking", visibility: t ? "permitted-visible" : "unavailable" }, key: "text", value: t }]; } if (b?.type === "toolCall") { return [{ fields: { type: "tool-call", call: safeId(b.id), name: safeId(b.name) }, key: "argumentsText", value: JSON.stringify(b.arguments ?? {}) }]; } return blockFor(b, i, owner); }); const units = [{ ...base, role: "assistant", blocks }]; if (m.stopReason === "error" || m.stopReason === "aborted") { units.push(notice(m.errorMessage ? `The turn ended (${m.stopReason}): ${m.errorMessage}` : `The turn ended (${m.stopReason}).`, "end")); } return units; } case "toolResult": { const content = Array.isArray(m.content) ? m.content : []; const joined = content.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join("\n"); const blocks = [{ fields: { type: "tool-result", call: safeId(m.toolCallId), isError: m.isError === true }, key: "text", value: joined }]; content.forEach((b, i) => { if (b?.type === "image") blocks.push(...blockFor(b, i, owner)); }); return [{ ...base, role: "tool", blocks }]; } case "bashExecution": { const call = safeId(`bash:${base.nativeEntry}`); return [{ ...base, role: "tool", blocks: [ { fields: { type: "tool-call", call, name: "bash" }, key: "argumentsText", value: JSON.stringify({ command: String(m.command ?? "") }) }, { fields: { type: "tool-result", call, isError: m.cancelled === true || (m.exitCode !== 0 && m.exitCode !== undefined) }, key: "text", value: String(m.output ?? "") }, ], }]; } case "custom": return m.display ? [{ ...base, role: "notice", blocks: contentText(m.content) }] : []; case "branchSummary": return [{ ...base, role: "notice", blocks: [text(`Branch summary\n\n${m.summary ?? ""}`)] }]; case "compactionSummary": return [{ ...base, role: "compaction", blocks: [{ fields: { type: "compaction", nativeEntry: base.nativeEntry }, key: "summary", value: String(m.summary ?? "") }] }]; default: return [{ ...base, role: "notice", blocks: [text(`A message with role ${safeId(String(m.role))} is not shown.`)] }]; } } // All units for one branch, in order. export function branchUnits(parsed, path) { const ctx = { lastTime: toTime(parsed.header.timestamp) ?? new Date(0).toISOString() }; const units = []; if (parsed.header.parentSession !== undefined && parsed.header.parentSession !== null) { units.push({ id: "x.parent-session", nativeEntry: "x.parent-session", parent: null, createdAt: ctx.lastTime, message: "x.parent-session", role: "notice", blocks: [text(NOTICE_TEXT["parent-session"])] }); } for (const item of path) { if (item.record) { units.push(...unitsFor(item.record, ctx)); continue; } const lines = item.lines ?? []; const id = item.notice === "malformed" ? `x.line-${lines[0]}` : `x.${item.notice}`; const value = item.notice === "malformed" ? `Line ${lines[0]} could not be read. It may belong to this branch or another one.` : item.notice === "missing-parent" && lines.length ? `${NOTICE_TEXT["missing-parent"]} ${lines.length === 1 ? `Line ${lines[0]} could not be read and may have held it.` : `Lines ${lines.join(", ")} could not be read and may have held it.`}` : NOTICE_TEXT[item.notice]; units.push({ id, nativeEntry: id, parent: null, createdAt: ctx.lastTime, message: id, role: "notice", blocks: [text(value)] }); } return units; } export { toTime };