Files
ralpi/src/utils.ts

579 lines
16 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import type {
RalpiConfig,
PRDProgress,
ProgressState,
ToolUsage,
} from "./types";
import { DEFAULT_CONFIG } from "./types";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
// ─── Directory Helpers ───────────────────────────────────────────────────────
/**
* Ensure a directory exists, creating it recursively if needed
*/
export function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
/**
* Write file content, creating parent directories if needed
*/
export function writeFileSafe(filePath: string, content: string): void {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf-8");
}
// ─── Async Agent Session ────────────────────────────────────────────────────
// ─── Progress Discovery ─────────────────────────────────────────────────────
/**
* Find the nearest .ralpi/progress.json by walking up from the given directory.
* For a specific sourcePath, finds the matching PRD entry.
*/
export function findProgressFile(
startDir: string,
sourcePath?: string,
): { path: string; state: ProgressState; prdKey?: string } | null {
let current = path.resolve(startDir);
const root = path.parse(current).root;
while (current !== root) {
const candidate = path.join(current, ".ralpi", "progress.json");
if (fs.existsSync(candidate)) {
try {
const raw = fs.readFileSync(candidate, "utf-8");
const state = JSON.parse(raw) as ProgressState;
// If looking for a specific source path, find matching PRD
if (sourcePath && state.prds) {
const resolvedSource = path.resolve(sourcePath);
for (const [key, prd] of Object.entries(state.prds)) {
if (path.resolve(prd.sourcePath) === resolvedSource) {
return { path: candidate, state, prdKey: key };
}
}
// No matching PRD found, continue walking up
current = path.dirname(current);
continue;
}
return { path: candidate, state };
} catch {
return null;
}
}
current = path.dirname(current);
}
return null;
}
// ─── Config ──────────────────────────────────────────────────────────────────
/** Try to use the `yaml` package (real dependency in package.json).
* Falls back to a flat key:value parser when unavailable. */
const parseSimpleYaml: (content: string) => Record<string, any> = (() => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse } = require("yaml");
return (content: string) => parse(content) ?? {};
} catch {
return (content: string) => {
const result: Record<string, any> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const match = trimmed.match(/^([^:]+):\s*(.*)$/);
if (match) {
const value = match[2].trim();
if (value === "true") result[match[1].trim()] = true;
else if (value === "false") result[match[1].trim()] = false;
else if (/^\d+$/.test(value))
result[match[1].trim()] = parseInt(value, 10);
else if (/^\d+\.\d+$/.test(value))
result[match[1].trim()] = parseFloat(value);
else result[match[1].trim()] = value;
}
}
return result;
};
}
})();
/**
* Deep merge configuration objects
*/
function mergeConfig(
defaults: RalpiConfig,
overrides: Record<string, any>,
): RalpiConfig {
const result = { ...defaults };
for (const [key, value] of Object.entries(overrides)) {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
(result as any)[key] = { ...(defaults as any)[key], ...value };
} else {
(result as any)[key] = value;
}
}
return result as RalpiConfig;
}
/** Path to the global ralpi config under the user's Pi home directory. */
const GLOBAL_CONFIG_PATH = path.join(
process.env.HOME || "/tmp",
".pi",
"ralpi",
"config.yaml",
);
/**
* Load and merge config from global and project sources.
*
* Precedence (highest wins):
* 1. Project-level: `<projectDir>/.ralpi/config.yaml`
* 2. Global: `~/.pi/ralpi/config.yaml`
* 3. `DEFAULT_CONFIG` in `src/types.ts`
*/
export function loadConfig(projectDir: string): RalpiConfig {
// Start with defaults
const merged: RalpiConfig = { ...DEFAULT_CONFIG };
// Layer 1: global config (~/.pi/ralpi/config.yaml)
tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged);
// Layer 2: project config (.ralpi/config.yaml) — overrides global
tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged);
return merged;
/** Attempt to load a single config file and merge into `acc` in place. */
function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void {
if (!fs.existsSync(filePath)) return;
try {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseSimpleYaml(content);
Object.assign(acc, mergeConfig(acc, parsed));
} catch {
// Malformed config — skip silently
}
}
}
// ─── Task Resolution ─────────────────────────────────────────────────────────
/**
* Resolve a task argument to a file path.
* Strips leading `@` (from autocomplete) before resolution.
*/
export function resolveTaskArg(arg: string, cwd: string): string {
// Strip leading @ from autocomplete
const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg;
const candidates = [
path.resolve(cwd, cleanArg),
path.resolve(cwd, cleanArg + ".md"),
path.resolve(cwd, cleanArg + ".yaml"),
path.resolve(cwd, cleanArg + ".yml"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
// Try looking for README.md in the arg directory
try {
if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) {
const readme = path.resolve(cwd, cleanArg, "README.md");
if (fs.existsSync(readme)) return readme;
}
} catch {
// Directory doesn't exist, fall through to error
}
throw new Error(
`Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`,
);
}
// ─── Formatting ──────────────────────────────────────────────────────────────
/**
* Format duration in milliseconds to human-readable string
*/
export function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
/**
* Format progress status for display. Accepts a single PRDProgress entry.
*/
export function formatProgressStatus(state: PRDProgress): string {
const lines: string[] = [];
const tasks = state.tasks;
const total = Object.keys(tasks).length;
const completed = Object.values(tasks).filter(
(t) => t.status === "completed",
).length;
const failed = Object.values(tasks).filter(
(t) => t.status === "failed",
).length;
const inProgress = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
lines.push("## Progress");
lines.push("");
lines.push(
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
);
lines.push("");
for (const [id, info] of Object.entries(tasks)) {
const statusIcon =
info.status === "completed"
? "[x]"
: info.status === "in_progress"
? "[~]"
: info.status === "failed"
? "[!]"
: "[ ]";
const duration = info.durationMs
? ` (${formatDuration(info.durationMs)})`
: "";
lines.push(`- ${statusIcon} ${id}${duration}`);
if (info.error) {
lines.push(` Error: ${info.error}`);
}
}
lines.push("");
lines.push(`Started: ${state.startedAt}`);
lines.push(`Updated: ${state.lastUpdatedAt}`);
lines.push(`Paused: ${state.paused ? "yes" : "no"}`);
return lines.join("\n");
}
/**
* Format progress status for all PRDs in a ProgressState.
*/
export function formatAllPRDsStatus(state: ProgressState): string {
const prds = state.prds;
if (!prds || Object.keys(prds).length <= 1) {
// Single PRD — use simple format
const prd = prds
? Object.values(prds)[0]
: (state as unknown as PRDProgress);
return formatProgressStatus(prd);
}
const lines: string[] = [];
lines.push("## Progress (all PRDs)");
lines.push("");
for (const [key, prd] of Object.entries(prds)) {
const tasks = prd.tasks;
const total = Object.keys(tasks).length;
const completed = Object.values(tasks).filter(
(t) => t.status === "completed",
).length;
const failed = Object.values(tasks).filter(
(t) => t.status === "failed",
).length;
const inProgress = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
lines.push(`### ${key}`);
lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`);
lines.push(
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
);
lines.push("");
for (const [id, info] of Object.entries(tasks)) {
const statusIcon =
info.status === "completed"
? "[x]"
: info.status === "in_progress"
? "[~]"
: info.status === "failed"
? "[!]"
: "[ ]";
const duration = info.durationMs
? ` (${formatDuration(info.durationMs)})`
: "";
lines.push(`- ${statusIcon} ${id}${duration}`);
if (info.error) {
lines.push(` Error: ${info.error}`);
}
}
lines.push("");
}
return lines.join("\n");
}
// ─── Async Agent Session ────────────────────────────────────────────────────
/**
* Run a task prompt through an in-process Pi agent session (async, non-blocking).
*
* Unlike the old spawnPi() which used spawnSync and froze the TUI,
* this uses createAgentSession from the Pi SDK, keeping the event loop
* responsive and allowing progress updates during task execution.
*/
export async function runAgentSession(
taskPrompt: string,
cwd: string,
timeoutMs: number,
onEvent?: (event: AgentSessionEvent) => void,
signal?: AbortSignal,
sessionFile?: string,
model?: unknown,
thinkingLevel?: unknown,
): Promise<{
success: boolean;
text: string;
error?: string;
toolUsage: ToolUsage;
stopReason?: string;
events: AgentSessionEvent[];
}> {
const toolUsage: ToolUsage = {
read: 0,
write: 0,
edit: 0,
bash: 0,
other: 0,
};
// Stream events to file instead of accumulating in memory.
// Accumulating caused "Invalid string length" crashes when
// JSON.stringify(output.events, null, 2) produced 300+ MB strings.
const eventStream = sessionFile
? fs.createWriteStream(sessionFile, { flags: "a" })
: null;
// Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults)
let timeoutHandle: NodeJS.Timeout | null = null;
if (timeoutMs > 0) {
timeoutHandle = setTimeout(() => {
if (sessionRef?.session) sessionRef.session.agent.abort();
}, timeoutMs);
}
const sessionRef: {
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
} = {};
try {
const loader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
noExtensions: true,
noSkills: false,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload();
const result = await createAgentSession({
cwd,
sessionManager: SessionManager.inMemory(),
resourceLoader: loader,
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
model: model as any,
thinkingLevel: thinkingLevel as any,
});
sessionRef.session = result.session;
// Wire external abort signal
const abortHandler = () => result.session.agent.abort();
signal?.addEventListener("abort", abortHandler, { once: true });
let finalText = "";
let errorMessage: string | undefined;
let stopReason: string | undefined;
const unsubscribe = result.session.subscribe((event) => {
// Stream event to file (avoids accumulating 300+ MB in memory)
if (eventStream) {
eventStream.write(JSON.stringify(event) + "\n");
}
onEvent?.(event);
if (event.type === "message_end") {
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
};
if (message.role !== "assistant") return;
if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage;
const text = extractAssistantText(message.content);
if (text) finalText = text;
}
if (event.type === "tool_execution_start") {
const name = event.toolName;
if (name in toolUsage) {
(toolUsage as unknown as Record<string, number>)[name]++;
} else {
toolUsage.other++;
}
}
});
if (signal?.aborted) throw new Error("Aborted before prompt");
await result.session.prompt(taskPrompt);
await result.session.agent.waitForIdle();
unsubscribe();
result.session.dispose();
signal?.removeEventListener("abort", abortHandler);
if (timeoutHandle) clearTimeout(timeoutHandle);
// Flush and close the event stream before returning
if (eventStream) {
await new Promise<void>((resolve) => eventStream.end(resolve));
}
if (errorMessage && !finalText) {
return {
success: false,
text: "",
error: errorMessage,
toolUsage,
stopReason,
events: [], // streamed to file
};
}
return {
success: true,
text: finalText.trim(),
toolUsage,
stopReason,
events: [], // streamed to file
};
} catch (error) {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (eventStream && !eventStream.destroyed) {
eventStream.end();
}
return {
success: false,
text: "",
error: error instanceof Error ? error.message : String(error),
toolUsage,
events: [], // streamed to file
};
} finally {
sessionRef.session?.dispose();
}
}
/**
* Extract assistant text from message content (text blocks only).
*/
function extractAssistantText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(c): c is { type: string; text?: string } =>
!!c &&
typeof c === "object" &&
(c as { type?: string }).type === "text",
)
.map((c) => (c as { text?: string }).text ?? "")
.join("");
}
// ─── Git Commit Capture ──────────────────────────────────────────────────────
/**
* Capture recent git commits made during task execution
* Returns commit messages and a summary string
*/
export function captureGitCommits(projectDir: string): {
commitMessages: string[];
commitSummary: string;
} {
const { execSync } = require("node:child_process");
try {
// Check if this is a git repo
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
} catch {
return { commitMessages: [], commitSummary: "" };
}
const commitMessages: string[] = [];
let commitSummary = "";
try {
// Get recent commits (last 5) with short hash and subject
const output = execSync("git log --oneline -5 --no-decorate", {
cwd: projectDir,
encoding: "utf-8",
}).trim();
if (output) {
const lines = output.split("\n").filter((l: string) => l.trim());
for (const line of lines) {
// Format: "abc1234 Commit message"
const parts = line.split(" ", 2);
if (parts.length >= 2) {
commitMessages.push(parts[1]);
}
}
// Build summary from commit subjects
commitSummary = commitMessages.slice(0, 3).join("; ");
if (commitMessages.length > 3) {
commitSummary += ` (+${commitMessages.length - 3} more)`;
}
}
} catch {
// Git command failed, return empty
}
return { commitMessages, commitSummary };
}