/** * Current-project source discovery. * * Scans the Pi session directory for the working directory * (`~/.pi/agent/sessions/----/`) and classifies each JSONL file as a raw * session (R0) or a reduced artifact (R1). Reduced artifacts are ordinary Pi * v3 JSONL sessions containing a custom entry with * `customType === REDUCTION_METADATA_CUSTOM_TYPE`; the metadata on that entry * is the source of truth for lineage and titles. */ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { REDUCTION_METADATA_CUSTOM_TYPE, type JsonlReduceMetadata } from "./reduce"; import type { CommonsSource, SourceLineage, ZLevel } from "./types"; /** customType of the planner metadata entry inside chi-commons artifacts. */ export const COMMONS_ARTIFACT_CUSTOM_TYPE = "chi-commons.artifact"; /** Planner metadata for artifacts assembled by chi-commons (e.g. pairwise z=1). */ export interface CommonsArtifactMetadata { schemaVersion: 1; z: ZLevel; strategy: string; title: string; sourceSessionIds: string[]; sourceEntryIds: string[]; /** Reduction keys of the per-slice reducer calls, in order. */ reductionKeys: string[]; } /** Session storage directory for a working directory, per Pi session-format. */ export function sessionDirForCwd(cwd: string, homeDir: string): string { const encoded = "--" + cwd.replace(/^\/+/, "").replace(/\//g, "-") + "--"; return join(homeDir, ".pi", "agent", "sessions", encoded); } export interface DiscoverOptions { cwd: string; homeDir: string; /** Override the scanned directory (tests, alternate layouts). */ sessionDir?: string; /** Session file to exclude, typically the active session. */ excludePath?: string; } interface ParsedLine { type?: string; id?: string; timestamp?: string; version?: number; customType?: string; data?: unknown; name?: string; message?: { role?: string; content?: unknown; }; } function textOfContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((block) => block && typeof block === "object" && (block as { type?: string }).type === "text" ? String((block as { text?: unknown }).text ?? "") : "", ) .filter(Boolean) .join("\n"); } /** * Characters of model-visible content in a message: text, thinking, and * serialized tool-call arguments. This is what replaying the session feeds * back to a provider, so approxTokens ≈ the statusline context cost of the * conversation (system prompt and tool schemas excluded — those belong to * the resuming runtime, not the source). */ function contentChars(content: unknown): number { if (typeof content === "string") return content.length; if (!Array.isArray(content)) return 0; let chars = 0; for (const block of content) { if (!block || typeof block !== "object") continue; const b = block as { type?: string; text?: unknown; thinking?: unknown; arguments?: unknown }; if (typeof b.text === "string") chars += b.text.length; if (typeof b.thinking === "string") chars += b.thinking.length; if (b.type === "toolCall" && b.arguments !== undefined) { chars += JSON.stringify(b.arguments)?.length ?? 0; } } return chars; } function toLineage(metadata: Partial): SourceLineage { return { sourceSessionIds: Array.isArray(metadata.sources) ? [...new Set(metadata.sources.map((source) => String(source?.sessionId ?? "")).filter(Boolean))] : Array.isArray(metadata.inputSessionIds) ? metadata.inputSessionIds.map(String) : [], sourceEntryIds: Array.isArray(metadata.sources) ? metadata.sources.flatMap((s) => (Array.isArray(s?.entryIds) ? s.entryIds.map(String) : [])) : [], reducerVersion: metadata.reducerVersion, promptVersion: metadata.promptVersion, model: metadata.modelId ?? undefined, contentHash: metadata.artifactHash ?? metadata.reductionKey, }; } /** * Parse one Pi v3 JSONL document into a CommonsSource. Returns undefined for * files without a session header. Malformed lines are skipped. */ export function parseSessionJsonl(jsonl: string, path: string): CommonsSource | undefined { const lines = jsonl.split("\n").filter((line) => line.trim().length > 0); let header: ParsedLine | undefined; let entryCount = 0; let lastTimestamp = ""; let firstUserText = ""; let sessionName = ""; let reduction: Partial | undefined; let artifact: Partial | undefined; let textChars = 0; for (const line of lines) { let entry: ParsedLine; try { entry = JSON.parse(line) as ParsedLine; } catch { continue; } if (entry.type === "session") { header = entry; continue; } entryCount += 1; if (typeof entry.timestamp === "string" && entry.timestamp > lastTimestamp) { lastTimestamp = entry.timestamp; } if (entry.type === "session_info" && typeof entry.name === "string") { sessionName = entry.name; } if (entry.type === "custom" && entry.data && typeof entry.data === "object") { if (entry.customType === REDUCTION_METADATA_CUSTOM_TYPE) reduction = entry.data as Partial; if (entry.customType === COMMONS_ARTIFACT_CUSTOM_TYPE) artifact = entry.data as Partial; } if (entry.type === "message" && entry.message) { textChars += contentChars(entry.message.content); if (!firstUserText && entry.message.role === "user") { firstUserText = textOfContent(entry.message.content); } } } if (!header || typeof header.id !== "string") return undefined; const title = (sessionName || (artifact?.title ?? "") || (reduction?.title ?? "") || firstUserText.split("\n")[0] || "(untitled session)").slice(0, 120); // A whole-span reducer artifact is the tree root: z=-1. Planner artifacts // carry their own z. const z: ZLevel = artifact?.z !== undefined ? artifact.z as ZLevel : reduction ? -1 : 0; const lineage = artifact ? { sourceSessionIds: (artifact.sourceSessionIds ?? []).map(String), sourceEntryIds: (artifact.sourceEntryIds ?? []).map(String), contentHash: artifact.reductionKeys?.join(","), } : reduction ? toLineage(reduction) : undefined; return { id: header.id, path, z, title, timestamp: lastTimestamp || (typeof header.timestamp === "string" ? header.timestamp : ""), entryCount, approxTokens: Math.ceil(textChars / 4), lineage, }; } /** * Extract plain conversation text from a Pi v3 JSONL document for insertion. * Includes user and assistant text blocks in file order. */ export function extractSourceText(jsonl: string): string { const parts: string[] = []; for (const line of jsonl.split("\n")) { if (!line.trim()) continue; let entry: ParsedLine; try { entry = JSON.parse(line) as ParsedLine; } catch { continue; } if (entry.type !== "message" || !entry.message) continue; const role = entry.message.role; if (role !== "user" && role !== "assistant") continue; const text = textOfContent(entry.message.content).trim(); if (text) parts.push(role + ": " + text); } return parts.join("\n\n"); } /** * Discover current-project sources ordered by recency (newest first), reduced * artifacts before raw sessions at equal timestamps. Returns [] when the * session directory does not exist. */ export async function discoverSources(options: DiscoverOptions): Promise { const dir = options.sessionDir ?? sessionDirForCwd(options.cwd, options.homeDir); let files: string[]; try { files = await readdir(dir); } catch { return []; } const sources: CommonsSource[] = []; for (const file of files) { if (!file.endsWith(".jsonl")) continue; const path = join(dir, file); if (options.excludePath && path === options.excludePath) continue; let jsonl: string; try { jsonl = await readFile(path, "utf8"); } catch { continue; } const source = parseSessionJsonl(jsonl, path); if (source) sources.push(source); } sources.sort((a, b) => { if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp ? 1 : -1; return a.z - b.z; }); return sources; }