Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

pi/agent/skills/pi-sessions/scripts/inspect.mjs

Raw
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 <id-prefix|exact-name|discovered-file> [--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 <selector>; 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;
    }
}