import fs from "node:fs"; import path from "node:path"; /** * Parsed session data from a JSONL file. */ export interface ParsedSession { id: string; project: string; cwd: string; startedAt: string; endedAt: string | null; messages: ParsedMessage[]; } /** * A single parsed message from a session. */ export interface ParsedMessage { id: string; role: "user" | "assistant" | "system"; content: string; timestamp: string; toolCalls?: string[]; } /** * Raw JSONL entry types. */ interface JsonlEntry { type: string; id?: string; parentId?: string | null; timestamp?: string; cwd?: string; message?: { role?: string; content?: unknown; timestamp?: number; }; customType?: string; [key: string]: unknown; } /** * Extract text content from a message's content array. */ function extractTextContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const b = block as Record; switch (b.type) { case "text": if (typeof b.text === "string") parts.push(b.text); break; case "thinking": // Skip thinking blocks — they're internal reasoning break; case "tool_use": // Skip tool_use blocks — we track tool calls separately break; case "tool_result": // Tool results commonly contain full file or command output. They are // ephemeral and can be very large; tool names are indexed separately. break; } } return parts.join("\n").trim(); } /** * Extract tool call names from a message's content array. */ function extractToolCalls(content: unknown): string[] | undefined { if (!Array.isArray(content)) return undefined; const toolNames: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const b = block as Record; if ((b.type === "tool_use" || b.type === "toolCall") && typeof b.name === "string") { toolNames.push(b.name); } } return toolNames.length > 0 ? toolNames : undefined; } /** * Parse a Pi session JSONL file. * * @param filePath — Path to the .jsonl file * @returns Parsed session data, or null if the file is invalid */ export function parseSessionFile(filePath: string): ParsedSession | null { const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n").filter((line) => line.trim()); if (lines.length === 0) return null; let sessionId: string | null = null; let sessionCwd: string | null = null; let sessionTimestamp: string | null = null; const messages: ParsedMessage[] = []; for (const line of lines) { let entry: JsonlEntry; try { entry = JSON.parse(line); } catch { continue; // Skip malformed lines } switch (entry.type) { case "session": sessionId = entry.id ?? null; sessionCwd = entry.cwd ?? null; sessionTimestamp = entry.timestamp ?? null; break; case "message": { if (!entry.message || !entry.id || !entry.timestamp) break; const role = entry.message.role; if (role !== "user" && role !== "assistant" && role !== "system") break; const textContent = extractTextContent(entry.message.content); if (!textContent) break; // Skip empty messages const toolCalls = role === "assistant" ? extractToolCalls(entry.message.content) : undefined; messages.push({ id: entry.id, role, content: textContent, timestamp: entry.timestamp, toolCalls, }); break; } // Skip other entry types (model_change, thinking_level_change, custom, etc.) } } if (!sessionId || !sessionCwd || !sessionTimestamp) return null; // Decode project name from cwd-encoded directory name // The directory is named like "--Users-chandrateja-Documents-pi-hermes-memory--" // We extract the last segment as the project name const project = sessionCwd.split("/").pop() ?? sessionCwd; return { id: sessionId, project, cwd: sessionCwd, startedAt: sessionTimestamp, endedAt: null, // We don't know when it ended from the JSONL messages, }; } /** * Get all session JSONL files for a project (or all projects). * * @param sessionsDir — Path to ~/.pi/agent/sessions/ * @param projectDir — Optional: specific project directory name (e.g., "--Users-...--") * @returns Array of file paths */ export function getSessionFiles(sessionsDir: string, projectDir?: string): string[] { if (projectDir) { const dir = path.join(sessionsDir, projectDir); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((f) => f.endsWith(".jsonl")) .map((f) => path.join(dir, f)); } // All projects if (!fs.existsSync(sessionsDir)) return []; const files: string[] = []; for (const entry of fs.readdirSync(sessionsDir)) { const entryPath = path.join(sessionsDir, entry); const stat = fs.statSync(entryPath); if (stat.isDirectory()) { // Scan .jsonl files inside project subdirectories for (const f of fs.readdirSync(entryPath)) { if (f.endsWith(".jsonl")) { files.push(path.join(entryPath, f)); } } } else if (stat.isFile() && entry.endsWith(".jsonl")) { // Also pick up root-level .jsonl files files.push(entryPath); } } return files; } /** * Decode a project directory name to a human-readable project name. * "--Users-chandrateja-Documents-pi-hermes-memory--" → "pi-hermes-memory" */ export function decodeProjectDir(dirName: string): string { // Remove leading/trailing dashes const cleaned = dirName.replace(/^-+|-+$/g, ""); // Split by dash and take the last segment (project name) const segments = cleaned.split("-"); return segments[segments.length - 1] ?? cleaned; }