Files
stack/packages/conversation/src/pi.mjs
T
jason.woltjeandClaude Opus 5.5 a5beb6d97d feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session
parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control
board adds GET /api/conversations and /api/conversation behind the Host
and Origin guard. Both are read-only, their queries are validated, and
each refusal code maps to a status.

Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code:
R1 revise (branch ids moving on append, the assumed-link bridge merging
branches, one unreadable seat directory turning the catalogue into a 500),
then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve
(07b10ad1), R2 approve (b9d92003). The package lands with the routes,
because serve.mjs imports the reader at load.

On an index export: the eight suites 24/90/43/17/14/15/63/18,
conversation and control-board 153/153, webui 9/9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:36:20 -05:00

288 lines
13 KiB
JavaScript

// 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 };