276 lines
6.9 KiB
TypeScript
276 lines
6.9 KiB
TypeScript
/**
|
|
* 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'),
|
|
};
|
|
}
|