/** * Run session reader for interactive mode * * Scans .takt/runs/ for recent runs, loads NDJSON logs and reports, * and formats them for injection into the interactive system prompt. */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { PROVIDER_EVENTS_LOG_FILE_SUFFIX, USAGE_EVENTS_LOG_FILE_SUFFIX, } from '../../core/logging/contracts.js'; import { loadNdjsonLog } from '../../infra/fs/index.js'; import type { SessionLog } from '../../shared/utils/index.js'; /** Maximum number of runs to return from listing */ const MAX_RUNS = 10; /** Maximum character length for movement log content */ const MAX_CONTENT_LENGTH = 500; /** Summary of a run for selection UI */ export interface RunSummary { readonly slug: string; readonly task: string; readonly piece: string; readonly status: string; readonly startTime: string; } /** A single movement log entry for display */ interface MovementLogEntry { readonly step: string; readonly persona: string; readonly status: string; readonly content: string; } /** A report file entry */ interface ReportEntry { readonly filename: string; readonly content: string; } /** Full context loaded from a run for prompt injection */ export interface RunSessionContext { readonly task: string; readonly piece: string; readonly status: string; readonly movementLogs: readonly MovementLogEntry[]; readonly reports: readonly ReportEntry[]; } /** Absolute paths to a run's logs and reports directories */ export interface RunPaths { readonly logsDir: string; readonly reportsDir: string; } interface MetaJson { readonly task: string; readonly piece: string; readonly status: string; readonly startTime: string; readonly logsDirectory: string; readonly reportDirectory: string; readonly runSlug: string; } function truncateContent(content: string, maxLength: number): string { if (content.length <= maxLength) { return content; } return content.slice(0, maxLength) + '…'; } function parseMetaJson(metaPath: string): MetaJson | null { if (!existsSync(metaPath)) { return null; } const raw = readFileSync(metaPath, 'utf-8').trim(); if (!raw) { return null; } try { return JSON.parse(raw) as MetaJson; } catch { return null; } } function buildMovementLogs(sessionLog: SessionLog): MovementLogEntry[] { return sessionLog.history.map((entry) => ({ step: entry.step, persona: entry.persona, status: entry.status, content: truncateContent(entry.content, MAX_CONTENT_LENGTH), })); } function loadReports(reportsDir: string): ReportEntry[] { if (!existsSync(reportsDir)) { return []; } const files = readdirSync(reportsDir).filter((f) => f.endsWith('.md')).sort(); return files.map((filename) => ({ filename, content: readFileSync(join(reportsDir, filename), 'utf-8'), })); } function findSessionLogFile(logsDir: string): string | null { if (!existsSync(logsDir)) { return null; } const files = readdirSync(logsDir).filter( (f) => ( f.endsWith('.jsonl') && !f.endsWith(PROVIDER_EVENTS_LOG_FILE_SUFFIX) && !f.endsWith(USAGE_EVENTS_LOG_FILE_SUFFIX) ), ); const first = files[0]; if (!first) { return null; } return join(logsDir, first); } /** * List recent runs sorted by startTime descending. */ export function listRecentRuns(cwd: string): RunSummary[] { const runsDir = join(cwd, '.takt', 'runs'); if (!existsSync(runsDir)) { return []; } const entries = readdirSync(runsDir, { withFileTypes: true }); const summaries: RunSummary[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const metaPath = join(runsDir, entry.name, 'meta.json'); const meta = parseMetaJson(metaPath); if (!meta) continue; summaries.push({ slug: entry.name, task: meta.task, piece: meta.piece, status: meta.status, startTime: meta.startTime, }); } summaries.sort((a, b) => b.startTime.localeCompare(a.startTime)); return summaries.slice(0, MAX_RUNS); } /** * Find the most recent run matching the given task content. * * @returns The run slug if found, null otherwise. */ export function findRunForTask(cwd: string, taskContent: string): string | null { const runs = listRecentRuns(cwd); const match = runs.find((r) => r.task === taskContent); return match?.slug ?? null; } /** * Get absolute paths to a run's logs and reports directories. */ export function getRunPaths(cwd: string, slug: string): RunPaths { const metaPath = join(cwd, '.takt', 'runs', slug, 'meta.json'); const meta = parseMetaJson(metaPath); if (!meta) { throw new Error(`Run not found: ${slug}`); } return { logsDir: join(cwd, meta.logsDirectory), reportsDir: join(cwd, meta.reportDirectory), }; } /** * Load full run session context for prompt injection. */ export function loadRunSessionContext(cwd: string, slug: string): RunSessionContext { const metaPath = join(cwd, '.takt', 'runs', slug, 'meta.json'); const meta = parseMetaJson(metaPath); if (!meta) { throw new Error(`Run not found: ${slug}`); } const logsDir = join(cwd, meta.logsDirectory); const logFile = findSessionLogFile(logsDir); let movementLogs: MovementLogEntry[] = []; if (logFile) { const sessionLog = loadNdjsonLog(logFile); if (sessionLog) { movementLogs = buildMovementLogs(sessionLog); } } const reportsDir = join(cwd, meta.reportDirectory); const reports = loadReports(reportsDir); return { task: meta.task, piece: meta.piece, status: meta.status, movementLogs, reports, }; } /** * Load the previous order.md content from the run directory. * * Uses findRunForTask to locate the matching run by task content, * then reads order.md from its context/task directory. * * @returns The order.md content if found, null otherwise. */ export function loadPreviousOrderContent(cwd: string, taskContent: string): string | null { const slug = findRunForTask(cwd, taskContent); if (!slug) { return null; } const orderPath = join(cwd, '.takt', 'runs', slug, 'context', 'task', 'order.md'); if (!existsSync(orderPath)) { return null; } return readFileSync(orderPath, 'utf-8'); } /** * Format run session context into a text block for the system prompt. */ export function formatRunSessionForPrompt(ctx: RunSessionContext): { runTask: string; runPiece: string; runStatus: string; runMovementLogs: string; runReports: string; } { const logLines = ctx.movementLogs.map((log) => { const header = `### ${log.step} (${log.persona}) — ${log.status}`; return `${header}\n${log.content}`; }); const reportLines = ctx.reports.map((report) => { return `### ${report.filename}\n${report.content}`; }); return { runTask: ctx.task, runPiece: ctx.piece, runStatus: ctx.status, runMovementLogs: logLines.join('\n\n'), runReports: reportLines.join('\n\n'), }; }