UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,306 lines (1,305 loc) 51.1 kB
import { uuidv7 } from "@earendil-works/pi-ai"; import { randomUUID } from "crypto"; import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs"; import { readdir, stat } from "fs/promises"; import { join, resolve } from "path"; import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; import { normalizePath, resolvePath } from "../utils/paths.js"; import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js"; export const CURRENT_SESSION_VERSION = 3; function createSessionId() { return uuidv7(); } export function assertValidSessionId(id) { if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) { throw new Error("Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character"); } } /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId) { for (let i = 0; i < 100; i++) { const id = randomUUID().slice(0, 8); if (!byId.has(id)) return id; } // Fallback to full UUID if somehow we have collisions return randomUUID(); } /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ function migrateV1ToV2(entries) { const ids = new Set(); let prevId = null; for (const entry of entries) { if (entry.type === "session") { entry.version = 2; continue; } entry.id = generateId(ids); entry.parentId = prevId; prevId = entry.id; // Convert firstKeptEntryIndex to firstKeptEntryId for compaction if (entry.type === "compaction") { const comp = entry; if (typeof comp.firstKeptEntryIndex === "number") { const targetEntry = entries[comp.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") { comp.firstKeptEntryId = targetEntry.id; } delete comp.firstKeptEntryIndex; } } } } /** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ function migrateV2ToV3(entries) { for (const entry of entries) { if (entry.type === "session") { entry.version = 3; continue; } // Update message entries with hookMessage role if (entry.type === "message") { const msgEntry = entry; if (msgEntry.message && msgEntry.message.role === "hookMessage") { msgEntry.message.role = "custom"; } } } } /** * Run all necessary migrations to bring entries to current version. * Mutates entries in place. Returns true if any migration was applied. */ function migrateToCurrentVersion(entries) { const header = entries.find((e) => e.type === "session"); const version = header?.version ?? 1; if (version >= CURRENT_SESSION_VERSION) return false; if (version < 2) migrateV1ToV2(entries); if (version < 3) migrateV2ToV3(entries); return true; } /** Exported for testing */ export function migrateSessionEntries(entries) { migrateToCurrentVersion(entries); } /** Exported for compaction.test.ts */ export function parseSessionEntries(content) { const entries = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); entries.push(entry); } catch { // Skip malformed lines } } return entries; } export function getLatestCompactionEntry(entries) { for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { return entries[i]; } } return null; } function buildEntryIndex(entries, byId) { if (byId) return byId; const index = new Map(); for (const entry of entries) { index.set(entry.id, entry); } return index; } function buildSessionPath(entries, leafId, byId) { const index = buildEntryIndex(entries, byId); let leaf; if (leafId === null) { return []; } if (leafId) { leaf = index.get(leafId); } leaf ??= entries[entries.length - 1]; if (!leaf) { return []; } const path = []; let current = leaf; while (current) { path.push(current); current = current.parentId ? index.get(current.parentId) : undefined; } path.reverse(); return path; } function getSessionContextSettings(path) { let thinkingLevel = "off"; let model = null; for (const entry of path) { if (entry.type === "thinking_level_change") { thinkingLevel = entry.thinkingLevel; } else if (entry.type === "model_change") { model = { provider: entry.provider, modelId: entry.modelId }; } else if (entry.type === "message" && entry.message.role === "assistant") { model = { provider: entry.message.provider, modelId: entry.message.model }; } } return { thinkingLevel, model }; } /** * Project one selected session entry into LLM/runtime messages. * Plain custom entries are display/state entries and do not participate in context. */ export function sessionEntryToContextMessages(entry) { if (entry.type === "message") { const message = entry.message; // Session files are parsed without validation; old versions, forks, or // hand-edited files can contain messages with null/missing content. if ((message.role === "user" || message.role === "assistant" || message.role === "toolResult") && message.content == null) { return [{ ...message, content: [] }]; } return [message]; } if (entry.type === "custom_message") { return [ createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp), ]; } if (entry.type === "branch_summary" && entry.summary) { return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; } if (entry.type === "compaction") { return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)]; } return []; } /** * Build the active, compaction-aware session entry list. * * This follows the current leaf path. If the path contains compaction entries, * the latest compaction is represented by the compaction entry itself, followed * by the kept entries starting at firstKeptEntryId and all entries after the * compaction entry. Older summarized entries are omitted. */ export function buildContextEntries(entries, leafId, byId) { const path = buildSessionPath(entries, leafId, byId); let compaction = null; for (const entry of path) { if (entry.type === "compaction") { compaction = entry; } } if (!compaction) { return path; } const compactionIdx = path.findIndex((entry) => entry.id === compaction.id); if (compactionIdx < 0) { return path; } const contextEntries = [compaction]; let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = path[i]; if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } if (foundFirstKept) { contextEntries.push(entry); } } contextEntries.push(...path.slice(compactionIdx + 1)); return contextEntries; } /** * Build the session context from entries using tree traversal. * If leafId is provided, walks from that entry to root. * Handles compaction and branch summaries along the path. */ export function buildSessionContext(entries, leafId, byId) { const path = buildSessionPath(entries, leafId, byId); const { thinkingLevel, model } = getSessionContextSettings(path); const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages); return { messages, thinkingLevel, model }; } /** * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. */ function getDefaultSessionDirPath(cwd, agentDir = getDefaultAgentDir()) { const resolvedCwd = resolvePath(cwd); const resolvedAgentDir = resolvePath(agentDir); const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; return join(resolvedAgentDir, "sessions", safePath); } export function getDefaultSessionDir(cwd, agentDir = getDefaultAgentDir()) { const sessionDir = getDefaultSessionDirPath(cwd, agentDir); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir; } const SESSION_READ_BUFFER_SIZE = 1024 * 1024; const SESSION_HEADER_READ_BUFFER_SIZE = 4096; /** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */ const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024; class SessionHeaderScanLimitError extends Error { constructor(filePath) { super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`); this.name = "SessionHeaderScanLimitError"; } } function parseSessionEntryLine(line) { if (!line.trim()) return null; try { return JSON.parse(line); } catch { // Skip malformed lines return null; } } /** Exported for testing */ export function loadEntriesFromFile(filePath) { const resolvedFilePath = normalizePath(filePath); if (!existsSync(resolvedFilePath)) return []; const entries = []; const fd = openSync(resolvedFilePath, "r"); try { const decoder = new StringDecoder("utf8"); const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE); let pending = ""; while (true) { const bytesRead = readSync(fd, buffer, 0, buffer.length, null); if (bytesRead === 0) break; pending += decoder.write(buffer.subarray(0, bytesRead)); let lineStart = 0; let newlineIndex = pending.indexOf("\n", lineStart); while (newlineIndex !== -1) { const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex)); if (entry) entries.push(entry); lineStart = newlineIndex + 1; newlineIndex = pending.indexOf("\n", lineStart); } pending = pending.slice(lineStart); } pending += decoder.end(); const finalEntry = parseSessionEntryLine(pending); if (finalEntry) entries.push(finalEntry); } finally { closeSync(fd); } // Validate session header if (entries.length === 0) return entries; const header = entries[0]; if (header.type !== "session" || typeof header.id !== "string") { return []; } return entries; } /** * Inspect a physical line while searching for the first parsed session entry. * Blank and malformed lines are skipped to match loadEntriesFromFile(). * Returns undefined to keep scanning, null for a parsed non-header entry, or the header. */ function parseSessionHeaderCandidate(line) { if (!line.trim()) return undefined; const entry = parseSessionEntryLine(line); if (!entry) return undefined; if (entry.type !== "session" || typeof entry.id !== "string") return null; return entry; } function readSessionHeader(filePath) { const fd = openSync(filePath, "r"); try { const decoder = new StringDecoder("utf8"); const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE); const lineChunks = []; let scannedBytes = 0; while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) { const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes); const bytesRead = readSync(fd, buffer, 0, readLength, null); if (bytesRead === 0) { lineChunks.push(decoder.end()); return parseSessionHeaderCandidate(lineChunks.join("")) ?? null; } scannedBytes += bytesRead; const chunk = decoder.write(buffer.subarray(0, bytesRead)); let lineStart = 0; let newlineIndex = chunk.indexOf("\n", lineStart); while (newlineIndex !== -1) { lineChunks.push(chunk.slice(lineStart, newlineIndex)); const header = parseSessionHeaderCandidate(lineChunks.join("")); if (header !== undefined) return header; lineChunks.length = 0; lineStart = newlineIndex + 1; newlineIndex = chunk.indexOf("\n", lineStart); } lineChunks.push(chunk.slice(lineStart)); } // Probe for EOF so a final header without a newline is allowed when it ends // exactly at the scan limit. Any additional byte exceeds the bounded scan. const probe = Buffer.allocUnsafe(1); if (readSync(fd, probe, 0, probe.length, null) === 0) { lineChunks.push(decoder.end()); return parseSessionHeaderCandidate(lineChunks.join("")) ?? null; } throw new SessionHeaderScanLimitError(filePath); } finally { closeSync(fd); } } function readSessionHeaderForDiscovery(filePath) { try { return readSessionHeader(filePath); } catch { // Discovery is best-effort: unreadable or oversized files are not sessions, // and one corrupt file must not prevent other sessions from being found. return null; } } function getSessionHeaderCwd(header) { const cwd = header.cwd; return typeof cwd === "string" ? cwd : undefined; } function sessionCwdMatches(cwd, resolvedCwd) { return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd; } /** Exported for testing */ export function findMostRecentSession(sessionDir, cwd) { const resolvedSessionDir = normalizePath(sessionDir); const resolvedCwd = cwd ? resolvePath(cwd) : undefined; try { const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(resolvedSessionDir, f)) .map((path) => ({ path, header: readSessionHeaderForDiscovery(path) })) .filter((file) => file.header !== null && (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd))) .map(({ path }) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; } catch { // Directory access and stat races make recent-session discovery unavailable. return null; } } function isMessageWithContent(message) { return typeof message.role === "string" && "content" in message; } function extractTextContent(message) { const content = message.content; if (typeof content === "string") { return content; } return content .filter((block) => block.type === "text") .map((block) => block.text) .join(" "); } function getMessageActivityTime(entry) { const message = entry.message; if (!isMessageWithContent(message)) return undefined; if (message.role !== "user" && message.role !== "assistant") return undefined; const msgTimestamp = message.timestamp; if (typeof msgTimestamp === "number") { return msgTimestamp; } const t = new Date(entry.timestamp).getTime(); return Number.isNaN(t) ? undefined : t; } async function buildSessionInfo(filePath) { try { const stats = await stat(filePath); let header = null; let messageCount = 0; let firstMessage = ""; const allMessages = []; let name; let lastActivityTime; const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }), crlfDelay: Infinity, }); for await (const line of rl) { const entry = parseSessionEntryLine(line); if (!entry) continue; if (!header) { if (entry.type !== "session") return null; header = entry; continue; } // Extract session name (use latest, including explicit clears) if (entry.type === "session_info") { name = entry.name?.trim() || undefined; } if (entry.type !== "message") continue; messageCount++; const activityTime = getMessageActivityTime(entry); if (typeof activityTime === "number") { lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime); } const message = entry.message; if (!isMessageWithContent(message)) continue; if (message.role !== "user" && message.role !== "assistant") continue; const textContent = extractTextContent(message); if (!textContent) continue; allMessages.push(textContent); if (!firstMessage && message.role === "user") { firstMessage = textContent; } } if (!header) return null; const cwd = typeof header.cwd === "string" ? header.cwd : ""; const parentSessionPath = header.parentSession; const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; const modified = typeof lastActivityTime === "number" && lastActivityTime > 0 ? new Date(lastActivityTime) : !Number.isNaN(headerTime) ? new Date(headerTime) : stats.mtime; return { path: filePath, id: header.id, cwd, name, parentSessionPath, created: new Date(header.timestamp), modified, messageCount, firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" "), }; } catch { return null; } } const MAX_CONCURRENT_SESSION_INFO_LOADS = 10; async function buildSessionInfosWithConcurrency(files, onLoaded) { const results = new Array(files.length).fill(null); const inFlight = new Set(); let nextIndex = 0; const startNext = () => { const index = nextIndex++; const file = files[index]; if (!file) return; let task; task = buildSessionInfo(file) .then((info) => { results[index] = info; }) .catch(() => { results[index] = null; }) .finally(() => { inFlight.delete(task); onLoaded(); }); inFlight.add(task); }; while (nextIndex < files.length || inFlight.size > 0) { while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) { startNext(); } if (inFlight.size > 0) { await Promise.race(inFlight); } } return results; } async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progressTotal) { const sessions = []; if (!existsSync(dir)) { return sessions; } try { const dirEntries = await readdir(dir); const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f)); const total = progressTotal ?? files.length; let loaded = 0; const results = await buildSessionInfosWithConcurrency(files, () => { loaded++; onProgress?.(progressOffset + loaded, total); }); for (const info of results) { if (info) { sessions.push(info); } } } catch { // Return empty list on error } return sessions; } /** * Manages conversation sessions as append-only trees stored in JSONL files. * * Each session entry has an id and parentId forming a tree structure. The "leaf" * pointer tracks the current position. Appending creates a child of the current leaf. * Branching moves the leaf to an earlier entry, allowing new branches without * modifying history. * * Use buildSessionContext() to get the resolved message list for the LLM, which * handles compaction summaries and follows the path from root to current leaf. */ export class SessionManager { sessionId = ""; sessionFile; sessionDir; cwd; persist; flushed = false; fileEntries = []; byId = new Map(); labelsById = new Map(); labelTimestampsById = new Map(); leafId = null; constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) { this.cwd = resolvePath(cwd); this.sessionDir = normalizePath(sessionDir); this.persist = persist; if (persist && this.sessionDir && !existsSync(this.sessionDir)) { mkdirSync(this.sessionDir, { recursive: true }); } if (sessionFile) { this._setSessionFile(sessionFile, preloadedFileEntries); } else { this.newSession(newSessionOptions); } } /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile) { this._setSessionFile(sessionFile); } _setSessionFile(sessionFile, preloadedFileEntries) { this.sessionFile = resolvePath(sessionFile); if (existsSync(this.sessionFile)) { this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile); // If file was empty, initialize it with a valid session header. If it was // non-empty but did not parse as a pi session, fail without modifying it. if (this.fileEntries.length === 0) { const explicitPath = this.sessionFile; if (statSync(explicitPath).size > 0) { throw new Error(`Session file is not a valid pi session: ${explicitPath}`); } this.newSession(); this.sessionFile = explicitPath; this._rewriteFile(); this.flushed = true; return; } const header = this.fileEntries.find((e) => e.type === "session"); this.sessionId = header?.id ?? createSessionId(); if (migrateToCurrentVersion(this.fileEntries)) { this._rewriteFile(); } this._buildIndex(); this.flushed = true; } else { const explicitPath = this.sessionFile; this.newSession(); this.sessionFile = explicitPath; // preserve explicit path from --session flag } } newSession(options) { if (options?.id !== undefined) { assertValidSessionId(options.id); } this.sessionId = options?.id ?? createSessionId(); const timestamp = new Date().toISOString(); const header = { type: "session", version: CURRENT_SESSION_VERSION, id: this.sessionId, timestamp, cwd: this.cwd, parentSession: options?.parentSession, }; this.fileEntries = [header]; this.byId.clear(); this.labelsById.clear(); this.labelTimestampsById.clear(); this.leafId = null; this.flushed = false; if (this.persist) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } return this.sessionFile; } _buildIndex() { this.byId.clear(); this.labelsById.clear(); this.labelTimestampsById.clear(); this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id; if (entry.type === "label") { if (entry.label) { this.labelsById.set(entry.targetId, entry.label); this.labelTimestampsById.set(entry.targetId, entry.timestamp); } else { this.labelsById.delete(entry.targetId); this.labelTimestampsById.delete(entry.targetId); } } } } _rewriteFile() { if (!this.persist || !this.sessionFile) return; const fd = openSync(this.sessionFile, "w"); try { for (const entry of this.fileEntries) { writeFileSync(fd, `${JSON.stringify(entry)}\n`); } } finally { closeSync(fd); } } isPersisted() { return this.persist; } getCwd() { return this.cwd; } getSessionDir() { return this.sessionDir; } usesDefaultSessionDir() { return this.sessionDir === getDefaultSessionDirPath(this.cwd); } getSessionId() { return this.sessionId; } getSessionFile() { return this.sessionFile; } _persist(entry) { if (!this.persist || !this.sessionFile) return; const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) { if (this.flushed) { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); } else { // Mark as not flushed so when assistant arrives, all entries get written this.flushed = false; } return; } if (!this.flushed) { const fd = openSync(this.sessionFile, "wx"); try { for (const e of this.fileEntries) { writeFileSync(fd, `${JSON.stringify(e)}\n`); } } finally { closeSync(fd); } this.flushed = true; } else { appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); } } _appendEntry(entry) { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; this._persist(entry); } /** Append a message as child of current leaf, then advance leaf. Returns entry id. * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. * Reason: we want these to be top-level entries in the session, not message session entries, * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */ appendMessage(message) { const entry = { type: "message", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), message, }; this._appendEntry(entry); return entry.id; } /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ appendThinkingLevelChange(thinkingLevel) { const entry = { type: "thinking_level_change", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), thinkingLevel, }; this._appendEntry(entry); return entry.id; } /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */ appendModelChange(provider, modelId) { const entry = { type: "model_change", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), provider, modelId, }; this._appendEntry(entry); return entry.id; } /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) { const entry = { type: "compaction", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), summary, firstKeptEntryId, tokensBefore, details, usage, fromHook, }; this._appendEntry(entry); return entry.id; } /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ appendCustomEntry(customType, data) { const entry = { type: "custom", customType, data, id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); return entry.id; } /** Append a session info entry (e.g., display name). Returns entry id. */ appendSessionInfo(name) { const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); const entry = { type: "session_info", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), name: sanitizedName, }; this._appendEntry(entry); return entry.id; } /** Get the current session name from the latest session_info entry, if any. */ getSessionName() { // Walk entries in reverse to find the latest session_info entry. // Empty names explicitly clear the session title. const entries = this.getEntries(); for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "session_info") { return entry.name?.trim() || undefined; } } return undefined; } /** * Append a custom message entry (for extensions) that participates in LLM context. * @param customType Extension identifier for filtering on reload * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) * @param details Optional extension-specific metadata (not sent to LLM) * @returns Entry id */ appendCustomMessageEntry(customType, content, display, details) { const entry = { type: "custom_message", customType, content, display, details, id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); return entry.id; } // ========================================================================= // Tree Traversal // ========================================================================= getLeafId() { return this.leafId; } getLeafEntry() { return this.leafId ? this.byId.get(this.leafId) : undefined; } getEntry(id) { return this.byId.get(id); } /** * Get all direct children of an entry. */ getChildren(parentId) { const children = []; for (const entry of this.byId.values()) { if (entry.parentId === parentId) { children.push(entry); } } return children; } /** * Get the label for an entry, if any. */ getLabel(id) { return this.labelsById.get(id); } /** * Set or clear a label on an entry. * Labels are user-defined markers for bookmarking/navigation. * Pass undefined or empty string to clear the label. */ appendLabelChange(targetId, label) { if (!this.byId.has(targetId)) { throw new Error(`Entry ${targetId} not found`); } const entry = { type: "label", id: generateId(this.byId), parentId: this.leafId, timestamp: new Date().toISOString(), targetId, label, }; this._appendEntry(entry); if (label) { this.labelsById.set(targetId, label); this.labelTimestampsById.set(targetId, entry.timestamp); } else { this.labelsById.delete(targetId); this.labelTimestampsById.delete(targetId); } return entry.id; } /** * Walk from entry to root, returning all entries in path order. * Includes all entry types (messages, compaction, model changes, etc.). * Use buildSessionContext() to get the resolved messages for the LLM. */ getBranch(fromId) { const path = []; const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : undefined; while (current) { path.push(current); current = current.parentId ? this.byId.get(current.parentId) : undefined; } path.reverse(); return path; } /** * Build the active, compaction-aware entry list for context/rendering. * Uses tree traversal from current leaf. */ buildContextEntries() { return buildContextEntries(this.getEntries(), this.leafId, this.byId); } /** * Build the session context (what gets sent to the LLM). * Uses tree traversal from current leaf. */ buildSessionContext() { return buildSessionContext(this.getEntries(), this.leafId, this.byId); } /** * Get session header. */ getHeader() { const h = this.fileEntries.find((e) => e.type === "session"); return h ? h : null; } /** * Get all session entries (excludes header). Returns a shallow copy. * The session is append-only: use appendXXX() to add entries, branch() to * change the leaf pointer. Entries cannot be modified or deleted. */ getEntries() { return this.fileEntries.filter((e) => e.type !== "session"); } /** * Get the session as a tree structure. Returns a shallow defensive copy of all entries. * A well-formed session has exactly one root (first entry with parentId === null). * Orphaned entries (broken parent chain) are also returned as roots. */ getTree() { const entries = this.getEntries(); const nodeMap = new Map(); const roots = []; // Create nodes with resolved labels for (const entry of entries) { const label = this.labelsById.get(entry.id); const labelTimestamp = this.labelTimestampsById.get(entry.id); nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp }); } // Build tree for (const entry of entries) { const node = nodeMap.get(entry.id); if (entry.parentId === null || entry.parentId === entry.id) { roots.push(node); } else { const parent = nodeMap.get(entry.parentId); if (parent) { parent.children.push(node); } else { // Orphan - treat as root roots.push(node); } } } // Sort children by timestamp (oldest first, newest at bottom) // Use iterative approach to avoid stack overflow on deep trees const stack = [...roots]; while (stack.length > 0) { const node = stack.pop(); node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); stack.push(...node.children); } return roots; } // ========================================================================= // Branching // ========================================================================= /** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */ branch(branchFromId) { if (!this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId; } /** * Reset the leaf pointer to null (before any entries). * The next appendXXX() call will create a new root entry (parentId = null). * Use this when navigating to re-edit the first user message. */ resetLeaf() { this.leafId = null; } /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ branchWithSummary(branchFromId, summary, details, fromHook, usage) { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId; const entry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId: branchFromId ?? "root", summary, details, usage, fromHook, }; this._appendEntry(entry); return entry.id; } /** * Create a new session file containing only the path from root to the specified leaf. * Useful for extracting a single conversation path from a branched session. * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId) { const previousSessionFile = this.sessionFile; const path = this.getBranch(leafId); if (path.length === 0) { throw new Error(`Entry ${leafId} not found`); } // Filter out LabelEntry from path - we'll recreate them from the resolved map. // Because labels are real tree entries, later entries can be children of labels; // removing labels requires re-chaining the retained path to avoid orphaned subtrees. const pathWithoutLabels = []; let pathParentId = null; for (const entry of path) { if (entry.type === "label") continue; pathWithoutLabels.push({ ...entry, parentId: pathParentId }); pathParentId = entry.id; } const newSessionId = createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); const header = { type: "session", version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, cwd: this.cwd, parentSession: this.persist ? previousSessionFile : undefined, }; // Collect labels for entries in the path const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); const labelsToWrite = []; for (const [targetId, label] of this.labelsById) { if (pathEntryIds.has(targetId)) { labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) }); } } if (this.persist) { // Build label entries const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; let parentId = lastEntryId; const labelEntries = []; for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { const labelEntry = { type: "label", id: generateId(new Set(pathEntryIds)), parentId, timestamp: labelTimestamp, targetId, label, }; pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; } this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this.sessionFile = newSessionFile; this._buildIndex(); // Only write the file now if it contains an assistant message. // Otherwise defer to _persist(), which creates the file on the // first assistant response, matching the newSession() contract // and avoiding the duplicate-header bug when _persist()'s // no-assistant guard later resets flushed to false. const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (hasAssistant) { this._rewriteFile(); this.flushed = true; } else { this.flushed = false; } return newSessionFile; } // In-memory mode: replace current session with the path + labels const labelEntries = []; let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { const labelEntry = { type: "label", id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), parentId, timestamp: labelTimestamp, targetId, label, }; labelEntries.push(labelEntry); parentId = labelEntry.id; } this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); return undefined; } /** * Create a new session. * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/). */ static create(cwd, sessionDir, options) { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); return new SessionManager(cwd, dir, undefined, true, options); } /** * Open a specific session file. * @param path Path to session file * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. * @param cwdOverride Optional cwd override instead of the session header cwd. */ static open(path, sessionDir, cwdOverride) { const resolvedPath = resolvePath(path); let header = null; let preloadedFileEntries; if (cwdOverride === undefined && existsSync(resolvedPath)) { try { header = readSessionHeader(resolvedPath); } catch (error) { if (!(error instanceof SessionHeaderScanLimitError)) throw error; // The bounded scan is only a discovery optimization. A full load remains // authoritative for legacy files with very large headers or prefixes. preloadedFileEntries = loadEntriesFromFile(resolvedPath); const firstEntry = preloadedFileEntries[0]; header = firstEntry?.type === "session" ? firstEntry : null; } } const cwd = cwdOverride ?? (header ? getSessionHeaderCwd(header) : undefined) ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, ".."); return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries); } /** * Continue the most recent session, or create new if none. * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/). */ static continueRecent(cwd, sessionDir) { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } return new SessionManager(cwd, dir, undefined, true); } /** Create an in-memory session (no file persistence) */ static inMemory(cwd = process.cwd(), options) { return new SessionManager(cwd, "", undefined, false, options); } /** * Fork a session from another project directory into the current project. * Creates a new session in the target cwd with the full history from the source session. * @param sourcePath Path to the source session file * @param targetCwd Target working directory (where the new session will be stored) * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. */ static forkFrom(sourcePath, targetCwd, sessionDir, options) { const resolvedSourcePath = resolvePath(sourcePath); const resolvedTargetCwd = resolvePath(targetCwd); const sourceEntries = loadEntriesFromFile(resolvedSourcePath); if (sourceEntries.length === 0) { throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`); } const sourceHeader = sourceEntries.find((e) => e.type === "session"); if (!sourceHeader) { throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`); } const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Create new session file with new ID but forked content if (options?.id !== undefined) { assertValidSessionId(options.id); } const newSessionId = options?.id ?? createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`); // Write new header pointing to source as parent, with updated cwd const newHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, cwd: resolvedTargetCwd, parentSession: resolvedSourcePath, }; writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" }); // Copy all non-header entries from source for (const entry of sourceEntries) { if (entry.type !== "session") { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); } } return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true); } /** * List all sessions for a directory. * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/). * @param onProgress Optional callback for progress updates (loaded, total) */ static async list(cwd, sessionDir, onProgress) { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); const resolvedCwd = resolvePath(cwd); const sessions = (await listSessionsFromDir(dir, onProgress)).filter((session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd)); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } static async listAll(sessionDirOrOnProgress, onProgress) { const customSessionDir = typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined; const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress; if (customSessionDir) { const sessions = await listSessionsFromDir(customSessionDir, progress); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } const sessionsDir = getSessionsDir(); try { if (!existsSync(sessionsDir)) { return []; } const entries = await readdir(sessionsDir, { withFileTypes: true }); const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name)); // Count total files first for accurate progress let totalFiles = 0;