import { createReadStream } from "node:fs"; import { lstat, readFile, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { parseArgs } from "node:util"; const help = `Read-only Pi session inspection (JSON output). inspect.mjs list [literal-query] [--cwd PATH] [--since YYYY-MM-DD] [--until YYYY-MM-DD] inspect.mjs show [--leaf ID] [--view branch|context] --session-dir PATH Override session storage; never scan HOME --offset N --limit N Page results (default 0/20 for list, 0/50 for show; max 100) --text Include bounded user/assistant/summary excerpts --content Include exact persisted entry payloads, including tool calls and results --help Show usage `; const textLimit = 500; const clipped = (value) => (typeof value === "string" ? value.slice(0, textLimit) : undefined); const fail = (file, line, reason) => { throw new Error(`${file}${line ? `: line ${line}` : ""}: ${reason}`); }; async function info(path) { try { return await lstat(path); } catch (error) { if (error.code === "ENOENT") return null; throw new Error(`${path}: ${error.code ?? "stat failed"}`); } } async function settings(path) { const stat = await info(path); if (!stat) return {}; if (!stat.isFile()) fail(path, null, "settings must be a regular file"); let data; try { data = JSON.parse(await readFile(path, "utf8")); } catch { fail(path, null, "cannot parse settings JSON"); } if (!data || typeof data !== "object" || Array.isArray(data)) fail(path, null, "settings must be an object"); if (data.sessionDir !== undefined && (typeof data.sessionDir !== "string" || !data.sessionDir.trim())) fail(path, null, "sessionDir must be a nonempty string"); return { sessionDir: data.sessionDir }; } async function filesUnder(root) { const stat = await info(root); if (!stat) return []; if (!stat.isDirectory()) fail(root, null, "session root must be a directory, not a symlink"); const files = []; for (const item of await readdir(root, { withFileTypes: true })) { const path = join(root, item.name); if (item.isDirectory()) files.push(...(await filesUnder(path))); else if (item.isFile() && item.name.endsWith(".jsonl")) files.push(path); } return files.sort(); } async function discover(options, { cwd, home, env }) { const expand = (path) => resolve(cwd, path === "~" ? home : path.replace(/^~[/\\]/, `${home}/`)); const agent = expand(env.PI_CODING_AGENT_DIR || join(home, ".pi/agent")); const global = await settings(join(agent, "settings.json")); const project = await settings(join(cwd, ".pi/settings.json")); const configured = [ ["--session-dir", options["session-dir"]], ["PI_CODING_AGENT_SESSION_DIR", env.PI_CODING_AGENT_SESSION_DIR], ["project settings", project.sessionDir], ["global settings", global.sessionDir], ].find(([, path]) => path); const roots = configured ? [configured] : [ ["state fallback", join(home, ".local/state/pi/sessions")], ["agent fallback", join(agent, "sessions")], ["project fallback", join(cwd, ".pi/sessions")], ]; for (const [source, path] of roots) { const root = expand(path); const files = await filesUnder(root); if (configured || files.length) return { source, root, files }; } return { source: "no sessions found", root: null, files: [] }; } function projectMessage(message, withText) { const row = { role: clipped(message?.role) }; if (!row.role) throw new Error("message role is missing"); const blocks = Array.isArray(message.content) ? message.content : []; const calls = blocks.filter((b) => b?.type === "toolCall"); row.callCount = calls.length; if (calls.length) row.calls = calls.slice(0, 20).map((call) => ({ id: clipped(call.id), name: clipped(call.name) })); if (message.role === "assistant") { row.provider = clipped(message.provider); row.model = clipped(message.model); row.stopReason = clipped(message.stopReason); } if (message.role === "toolResult") { row.tool = clipped(message.toolName); row.toolCallId = clipped(message.toolCallId); row.isError = message.isError === true; } if (message.role === "bashExecution") { row.exitCode = Number.isInteger(message.exitCode) ? message.exitCode : null; row.cancelled = message.cancelled === true; } if (withText && ["user", "assistant"].includes(message.role)) { const text = typeof message.content === "string" ? message.content : blocks .filter((b) => b?.type === "text" && typeof b.text === "string") .map((b) => b.text) .join("\n"); row.text = clipped(text); row.textTruncated = text.length > textLimit; } return row; } async function readSession(file, withText, scope, withContent) { const stat = await info(file); if (!stat?.isFile()) fail(file, null, "session must be a regular file"); if (!stat.size) fail(file, 1, "empty session"); // Freeze the read boundary: live appends must not extend this inspection. const stream = createReadStream(file, { encoding: "utf8", end: stat.size - 1 }); const lines = createInterface({ input: stream, crlfDelay: Infinity }); const entries = []; const byId = new Map(); let header; let name = null; let line = 0; try { for await (const text of lines) { line++; if (!text.trim()) continue; let entry; try { entry = JSON.parse(text); } catch { fail(file, line, "malformed JSONL (possibly an unfinished live write)"); } if (!header) { if ( entry?.type !== "session" || typeof entry.id !== "string" || !entry.id || typeof entry.cwd !== "string" || typeof entry.timestamp !== "string" || !Number.isFinite(Date.parse(entry.timestamp)) ) fail(file, line, "invalid session header"); if (entry.version !== 3) fail(file, line, "unsupported session version; expected 3"); header = { file, id: entry.id, cwd: entry.cwd, started: new Date(entry.timestamp).toISOString() }; if (scope && !scope(header)) return null; continue; } if ( !entry || typeof entry.id !== "string" || !entry.id || typeof entry.type !== "string" || !(entry.parentId === null || typeof entry.parentId === "string") ) fail(file, line, "invalid entry shape"); if (byId.has(entry.id)) fail(file, line, "duplicate entry ID"); if (entry.parentId !== null && !byId.has(entry.parentId)) fail(file, line, "missing or forward parent"); const row = { id: entry.id, parentId: entry.parentId, type: entry.type, line, time: clipped(entry.timestamp), }; if (withContent) { const { id, parentId, type, timestamp, ...payload } = entry; row.payload = payload; } if (entry.type === "message") { try { Object.assign(row, projectMessage(entry.message, withText)); } catch { fail(file, line, "invalid message"); } } if (entry.type === "session_info") { if (typeof entry.name === "string") name = entry.name; row.name = clipped(entry.name); } if (entry.type === "model_change") { row.provider = clipped(entry.provider); row.model = clipped(entry.modelId); } if (entry.type === "thinking_level_change") row.thinkingLevel = clipped(entry.thinkingLevel); if (entry.type === "custom" || entry.type === "custom_message") row.customType = clipped(entry.customType); if (entry.type === "compaction") { row.tokensBefore = Number.isFinite(entry.tokensBefore) ? entry.tokensBefore : null; if (entry.firstKeptEntryId !== undefined && typeof entry.firstKeptEntryId !== "string") fail(file, line, "firstKeptEntryId must be a string"); row.firstKeptEntryId = entry.firstKeptEntryId; if (entry.retainedTail !== undefined) { if (!Array.isArray(entry.retainedTail)) fail(file, line, "retainedTail must be an array"); try { row.retainedTail = entry.retainedTail.map((message, i) => ({ id: `${entry.id}:tail:${i}`, type: "message", line, retainedIndex: i, ...projectMessage(message, withText), ...(withContent ? { payload: { message } } : {}), })); } catch { fail(file, line, "invalid retained-tail message"); } } } if (withText && ["compaction", "branch_summary"].includes(entry.type)) { row.text = clipped(entry.summary); row.textTruncated = typeof entry.summary === "string" && entry.summary.length > textLimit; } entries.push(row); byId.set(row.id, row); } } finally { lines.close(); stream.destroy(); } if (!header) fail(file, 1, "missing session header"); return { ...header, name, updated: entries.at(-1)?.time ?? header.started, entries, byId }; } function branch(session, leafId = session.entries.at(-1)?.id) { const result = []; if (leafId && !session.byId.has(leafId)) throw new Error("Unknown leaf ID"); let current = session.byId.get(leafId); while (current) { result.push(current); current = session.byId.get(current.parentId); } return result.reverse(); } function context(path, file) { const index = path.findLastIndex((e) => e.type === "compaction"); if (index < 0) return path; const compaction = path[index]; let kept = compaction.retainedTail; if (kept === undefined) { const start = path.findIndex((e) => e.id === compaction.firstKeptEntryId); if (start < 0 || start >= index) fail(file, compaction.line, "compaction has no valid firstKeptEntryId or retainedTail"); kept = path.slice(start, index); } return [compaction, ...kept, ...path.slice(index + 1)]; } function counts(rows) { const count = (predicate) => rows.filter(predicate).length; return { users: count((r) => r.role === "user"), assistants: count((r) => r.role === "assistant"), toolCalls: rows.reduce((n, r) => n + (r.callCount ?? 0), 0), toolErrors: count((r) => r.role === "toolResult" && r.isError), assistantErrors: count((r) => r.stopReason === "error"), aborts: count((r) => r.stopReason === "aborted" || r.cancelled), bashErrors: count((r) => r.role === "bashExecution" && r.exitCode !== null && r.exitCode !== 0), compactions: count((r) => r.type === "compaction"), }; } function summary(session) { const path = branch(session); return { file: session.file, id: session.id, name: clipped(session.name), cwd: session.cwd, started: session.started, updated: session.updated, leafId: path.at(-1)?.id ?? null, counts: counts(path), }; } function pageNumber(value, fallback, name, max = Number.MAX_SAFE_INTEGER) { if (value === undefined) return fallback; if ( !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) > max || (name === "limit" && Number(value) === 0) ) throw new Error(`Invalid ${name}`); return Number(value); } function date(value) { if (value === undefined) return undefined; if ( !/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value ) throw new Error("Invalid date; use YYYY-MM-DD"); return value; } export async function inspect(args, { cwd = process.cwd(), home = homedir(), env = process.env } = {}) { const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { "session-dir": { type: "string" }, cwd: { type: "string" }, since: { type: "string" }, until: { type: "string" }, offset: { type: "string" }, limit: { type: "string" }, leaf: { type: "string" }, view: { type: "string" }, text: { type: "boolean" }, content: { type: "boolean" }, help: { type: "boolean" }, }, }); if (values.help) return { help }; const [command = "list", query] = positionals; if (!["list", "show"].includes(command) || positionals.length > 2 || (command === "show" && !query)) throw new Error("Use list [query] or show ; see --help"); const offset = pageNumber(values.offset, 0, "offset"); const limit = pageNumber(values.limit, command === "list" ? 20 : 50, "limit", 100); const since = date(values.since); const until = date(values.until); if (since && until && since > until) throw new Error("Date range is reversed"); const view = values.view ?? "branch"; if (!["branch", "context"].includes(view)) throw new Error("View must be branch or context"); if (values["session-dir"] === "" || values.leaf === "") throw new Error("Session directory and leaf must not be empty"); const store = await discover(values, { cwd, home, env }); const direct = command === "show" ? store.files.filter((file) => file === resolve(cwd, query) || basename(file) === query) : []; const cwdFilter = values.cwd ? resolve(cwd, values.cwd) : undefined; // Only a validated header outside explicit scope may bypass body validation. const scope = command === "list" && (cwdFilter || since || until) ? (header) => (!cwdFilter || header.cwd === cwdFilter) && (!since || header.started.slice(0, 10) >= since) && (!until || header.started.slice(0, 10) <= until) : undefined; const sessions = []; const errors = []; let skipped = 0; for (const file of direct.length ? direct : store.files) { try { const session = await readSession(file, values.text, scope, values.content); if (session) sessions.push(session); else skipped++; } catch (error) { if (direct.length === 1) throw error; errors.push({ file, error: error.message }); } } const status = { root: store.root, source: store.source, scanned: direct.length || store.files.length, parsed: sessions.length, skipped, complete: errors.length === 0, errorCount: errors.length, errors: errors.slice(0, limit), }; if (command === "list") { const queryLower = query?.toLowerCase(); const selected = sessions .filter( (s) => (!queryLower || [s.name, s.id, s.cwd].some((v) => v?.toLowerCase().includes(queryLower))) && (!cwdFilter || s.cwd === cwdFilter) && (!since || s.started.slice(0, 10) >= since) && (!until || s.started.slice(0, 10) <= until), ) .sort((a, b) => b.updated.localeCompare(a.updated) || a.file.localeCompare(b.file)); return { ...status, total: selected.length, offset, limit, sessions: selected.slice(offset, offset + limit).map((s) => ({ ...summary(s), ...(values.text ? { firstPrompt: branch(s).find((e) => e.role === "user")?.text } : {}), })), }; } const exact = sessions.filter((s) => s.id === query); const selected = direct.length ? sessions : exact.length ? exact : sessions.filter((s) => s.name === query || s.id.startsWith(query)); if (selected.length > 1) throw new Error( `Ambiguous session selector (${selected.length} matches); list then choose an exact ID or file`, ); if (!selected.length) throw new Error(`Session not found${errors.length ? `; ${errors.length} files could not be read` : ""}`); const session = selected[0]; const path = branch(session, values.leaf); const retained = context(path, session.file); const rows = view === "branch" ? path : retained; const parents = new Set(session.entries.map((e) => e.parentId)); const heads = session.entries.filter((e) => !parents.has(e.id)).map((e) => e.id); return { ...status, ...summary(session), counts: counts(path), view, leafId: path.at(-1)?.id ?? null, leafSource: values.leaf ? "explicit" : "last-persisted-entry", branch: { entries: path.length, counts: counts(path) }, context: { entries: retained.length, counts: counts(retained) }, heads: { total: heads.length, ids: heads.slice(0, limit) }, total: rows.length, offset, limit, ...(values.text || values.content ? { historicalContentIsUntrusted: true } : {}), timeline: rows.slice(offset, offset + limit).map(({ retainedTail, firstKeptEntryId, ...row }) => ({ ...row, ...(retainedTail ? { retainedMessages: retainedTail.length } : {}), ...(firstKeptEntryId ? { firstKeptEntryId } : {}), })), }; } if (import.meta.main) { try { const result = await inspect(process.argv.slice(2)); process.stdout.write(result.help ?? `${JSON.stringify(result, null, 2)}\n`); if (result.complete === false) process.exitCode = 1; } catch (error) { process.stderr.write(`${JSON.stringify({ error: error.message })}\n`); process.exitCode = 1; } }