diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5123f1d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__relaycast__*" + ] + } +} diff --git a/.gitignore b/.gitignore index ed3930e..88d27f1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ npm-debug.log* # Trajectories - don't commit active work .trajectories/active/ +.agent-relay/ diff --git a/.msd/autofix-findings-summary.txt b/.msd/autofix-findings-summary.txt new file mode 100644 index 0000000..f1acdaa --- /dev/null +++ b/.msd/autofix-findings-summary.txt @@ -0,0 +1,18 @@ +1. [HIGH] src/cli/commands/compact.ts — src/cli/commands/compact.ts +2. [HIGH] src/cli/commands/compact.ts — src/cli/commands/compact.ts +3. [MEDIUM] src/cli/commands/compact.ts — src/cli/commands/compact.ts +4. [MEDIUM] src/cli/commands/compact.ts — src/cli/commands/compact.ts +5. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +6. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +7. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +8. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +9. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +10. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +11. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +12. [MEDIUM] src/compact/provider.ts — src/compact/provider.ts +13. [MEDIUM] workflows/llm-compaction.ts — workflows/llm-compaction.ts +14. [MEDIUM] src/compact/parser.ts — src/compact/parser.ts +15. [MEDIUM] src/compact/config.ts — src/compact/config.ts +16. [MEDIUM] package.json — package.json +17. [LOW] src/compact/provider.ts — src/compact/provider.ts +18. [LOW] tests/compact/llm-compact.test.ts — tests/compact/llm-compact.test.ts diff --git a/.msd/autofix-plan.json b/.msd/autofix-plan.json new file mode 100644 index 0000000..e8f5451 --- /dev/null +++ b/.msd/autofix-plan.json @@ -0,0 +1,57 @@ +{ + "groups": [ + { + "id": "group-1", + "label": "compact.ts command fixes — shell injection, env mutation, type dedup, jsonMode", + "domain": "security", + "findings": [ + "src/cli/commands/compact.ts-Shell injection in getBranchCommits (line 403)-security-review, developer-review, historian-review-high", + "src/cli/commands/compact.ts-Global process.env mutation in storage loop (lines 313-314)-developer-review-high", + "src/cli/commands/compact.ts-Duplicate/conflicting CompactedTrajectory types (lines 52-77)-developer-review-medium", + "src/cli/commands/compact.ts-jsonMode inconsistency across providers (line 234)-historian-review-medium" + ], + "files": ["src/cli/commands/compact.ts"], + "rationale": "All 4 findings in the same file; includes both HIGH severity issues (shell injection, env mutation)" + }, + { + "id": "group-2", + "label": "compact provider fixes — SSRF, API keys, timeouts, env passthrough, error leaks, types", + "domain": "security", + "findings": [ + "src/compact/provider.ts-SSRF via configurable base URLs (lines 72, 128)-security-review-medium", + "src/compact/provider.ts-Empty/whitespace API key handling (lines 66, 121)-developer-review-medium", + "src/compact/provider.ts-Anthropic fallback prompt fabrication (lines 152-168)-developer-review-medium", + "src/compact/provider.ts-Missing fetch timeouts (lines 83-96)-historian-review-medium", + "src/compact/provider.ts-Hardcoded Anthropic API version (line 152)-historian-review-medium", + "src/compact/provider.ts-Duplicate Message interface (lines 7-10)-developer-review-medium", + "src/compact/provider.ts-CLI arg length limits (lines 269-273)-historian-review-medium", + "src/compact/provider.ts-Full env passthrough to CLI subprocesses (lines 229-233)-security-review-medium", + "src/compact/provider.ts-Error message data leak in parseJson (line 348)-security-review-low" + ], + "files": ["src/compact/provider.ts"], + "rationale": "All 9 findings are in src/compact/provider.ts — cannot split across workers due to file-conflict rule" + }, + { + "id": "group-3", + "label": "supporting files — parser, config, workflow, package.json, tests", + "domain": "code-quality", + "findings": [ + "workflows/llm-compaction.ts-Hardcoded absolute path (line 26)-historian-review, security-review, developer-review-medium", + "src/compact/parser.ts-Incomplete escape sequence handling in extractBalancedJsonObject (lines 91-134)-developer-review, historian-review-medium", + "src/compact/config.ts-Implicit config merge precedence (lines 61-68)-developer-review, historian-review-medium", + "package.json-@agent-relay/sdk as regular dependency-historian-review-medium", + "tests/compact/llm-compact.test.ts-No mocked LLM provider integration test (lines 152-201)-developer-review-low" + ], + "files": [ + "workflows/llm-compaction.ts", + "src/compact/parser.ts", + "src/compact/config.ts", + "package.json", + "tests/compact/llm-compact.test.ts" + ], + "rationale": "Remaining files grouped together; all in compact domain but distinct files from groups 1-2" + } + ], + "totalGroups": 3, + "conflictCheck": "no file appears in multiple groups" +} diff --git a/package-lock.json b/package-lock.json index 5b18b43..2a1269b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -220,6 +220,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1375,14 +1376,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -1391,6 +1392,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index cf5fe82..ae9fe00 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -8,10 +8,28 @@ * Default behavior: compact only trajectories that haven't been compacted yet. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { Command } from "commander"; +import { getCompactionConfig } from "../../compact/config.js"; +import { generateCompactionMarkdown } from "../../compact/markdown.js"; +import { + type CompactedTrajectoryMetadata, + type LLMCompactedOutput, + mergeCompactionWithMetadata, + parseCompactionResponse, +} from "../../compact/parser.js"; +import { buildCompactionPrompt } from "../../compact/prompts.js"; +import { + AnthropicProvider, + CLIProvider, + type CompactionLLM, + type Message, + OpenAIProvider, + resolveProvider, +} from "../../compact/provider.js"; +import { serializeForLLM } from "../../compact/serializer.js"; import { generateRandomId } from "../../core/id.js"; import type { Decision, Trajectory } from "../../core/types.js"; import { FileStorage, getSearchPaths } from "../../storage/file.js"; @@ -30,28 +48,18 @@ interface DecisionGroup { } /** - * Compacted trajectory summary + * Compacted trajectory summary — extends the shared metadata type from parser.ts + * with mechanical compaction fields and optional LLM output. */ -interface CompactedTrajectory { - id: string; - version: 1; - type: "compacted"; - compactedAt: string; - sourceTrajectories: string[]; - dateRange: { - start: string; - end: string; - }; - summary: { - totalDecisions: number; - totalEvents: number; - uniqueAgents: string[]; - }; +interface CompactedTrajectory extends CompactedTrajectoryMetadata { decisionGroups: DecisionGroup[]; keyLearnings: string[]; keyFindings: string[]; - filesAffected: string[]; - commits: string[]; + narrative?: string; + decisions?: LLMCompactedOutput["decisions"]; + conventions?: LLMCompactedOutput["conventions"]; + lessons?: LLMCompactedOutput["lessons"]; + openQuestions?: string[]; } /** @@ -66,6 +74,29 @@ interface IndexEntry { compactedInto?: string; } +interface CompactCommandOptions { + since?: string; + until?: string; + ids?: string; + pr?: string; + branch?: string; + commits?: string; + all?: boolean; + llm?: boolean; + mechanical?: boolean; + focus?: string; + markdown?: boolean; + dryRun?: boolean; + output?: string; +} + +interface LLMCompactionPlan { + messages: Message[]; + estimatedInputTokens: number; + estimatedOutputTokens: number; + focusAreas: string[]; +} + export function registerCompactCommand(program: Command): void { program .command("compact") @@ -91,9 +122,18 @@ export function registerCompactCommand(program: Command): void { "Comma-separated commit SHAs to match trajectories against", ) .option("--all", "Include all trajectories, even previously compacted ones") + .option("--llm", "Use LLM-based compaction when a provider is available") + .option("--no-llm", "Disable LLM-based compaction") + .option("--mechanical", "Force the original mechanical compaction flow") + .option( + "--focus ", + "Comma-separated focus areas to emphasize in LLM compaction", + ) + .option("--markdown", "Also write a Markdown companion file") + .option("--no-markdown", "Skip writing a Markdown companion file") .option("--dry-run", "Preview what would be compacted without saving") .option("--output ", "Output path for compacted trajectory") - .action(async (options) => { + .action(async (options: CompactCommandOptions) => { const trajectories = await loadTrajectories(options); if (trajectories.length === 0) { @@ -116,22 +156,98 @@ export function registerCompactCommand(program: Command): void { console.log(`Compacting ${trajectories.length} trajectories...\n`); - const compacted = compactTrajectories(trajectories); + const config = getCompactionConfig(); + const provider = await resolveProvider(config); + const useLLM = shouldUseLLM(options, provider !== null); + const markdownEnabled = options.markdown !== false; + const mechanicalCompacted = compactTrajectories(trajectories); + + if (!useLLM || provider === null) { + if (options.llm && provider === null && !options.mechanical) { + console.log( + "No LLM provider detected; falling back to mechanical compaction.\n", + ); + } + + if (options.dryRun) { + console.log("=== DRY RUN - Preview ===\n"); + printCompactedSummary(mechanicalCompacted); + return; + } + + const outputPath = + options.output || getDefaultOutputPath(mechanicalCompacted); + saveCompactionArtifacts( + mechanicalCompacted, + outputPath, + markdownEnabled, + ); + await markTrajectoriesAsCompacted(trajectories, mechanicalCompacted.id); + + console.log(`\nCompacted trajectory saved to: ${outputPath}`); + if (markdownEnabled) { + console.log( + `Markdown summary saved to: ${getMarkdownOutputPath(outputPath)}`, + ); + } + printCompactedSummary(mechanicalCompacted); + return; + } + + const llmPlan = buildLLMCompactionPlan( + trajectories, + parseFocusAreas(options.focus), + config.maxInputTokens, + config.maxOutputTokens, + ); + + console.log( + `Using ${getProviderLabel(provider)} compaction${config.model ? ` with model ${config.model}` : ""}.`, + ); + console.log( + `Estimated: ~${llmPlan.estimatedInputTokens} input tokens, ~${llmPlan.estimatedOutputTokens} output tokens`, + ); if (options.dryRun) { - console.log("=== DRY RUN - Preview ===\n"); - printCompactedSummary(compacted); + printLLMDryRun(llmPlan, config.model); return; } - // Save the compacted trajectory - const outputPath = options.output || getDefaultOutputPath(compacted); - saveCompactedTrajectory(compacted, outputPath); + const llmOutput = await provider.complete(llmPlan.messages, { + maxTokens: config.maxOutputTokens, + temperature: config.temperature, + jsonMode: provider instanceof OpenAIProvider, + }); + const llmCompacted = parseCompactionResponse(llmOutput); + const mergedCompaction = mergeCompactionWithMetadata( + { + id: mechanicalCompacted.id, + version: mechanicalCompacted.version, + type: mechanicalCompacted.type, + compactedAt: mechanicalCompacted.compactedAt, + sourceTrajectories: mechanicalCompacted.sourceTrajectories, + dateRange: mechanicalCompacted.dateRange, + summary: mechanicalCompacted.summary, + filesAffected: mechanicalCompacted.filesAffected, + commits: mechanicalCompacted.commits, + }, + llmCompacted, + ); + const compacted: CompactedTrajectory = { + ...mechanicalCompacted, + ...mergedCompaction, + }; - // Mark source trajectories as compacted + const outputPath = options.output || getDefaultOutputPath(compacted); + saveCompactionArtifacts(compacted, outputPath, markdownEnabled); await markTrajectoriesAsCompacted(trajectories, compacted.id); console.log(`\nCompacted trajectory saved to: ${outputPath}`); + if (markdownEnabled) { + console.log( + `Markdown summary saved to: ${getMarkdownOutputPath(outputPath)}`, + ); + } printCompactedSummary(compacted); }); } @@ -181,80 +297,79 @@ async function loadTrajectories(options: { for (const searchPath of searchPaths) { if (!existsSync(searchPath)) continue; + // Set env var only for the synchronous FileStorage constructor, then + // immediately restore to avoid leaking state across async boundaries. const originalDataDir = process.env.TRAJECTORIES_DATA_DIR; process.env.TRAJECTORIES_DATA_DIR = searchPath; + const storage = new FileStorage(); + if (originalDataDir !== undefined) { + process.env.TRAJECTORIES_DATA_DIR = originalDataDir; + } else { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset (assignment stores string "undefined") + delete process.env.TRAJECTORIES_DATA_DIR; + } - try { - const storage = new FileStorage(); - await storage.initialize(); + await storage.initialize(); - const summaries = await storage.list({ - status: "completed", - limit: Number.MAX_SAFE_INTEGER, - }); + const summaries = await storage.list({ + status: "completed", + limit: Number.MAX_SAFE_INTEGER, + }); - for (const summary of summaries) { - if (seenIds.has(summary.id)) continue; - - // Skip already compacted (unless --all) - if (compactedIds.has(summary.id)) continue; - - // Filter by IDs if specified - if (targetIds && !targetIds.includes(summary.id)) continue; - - // Filter by date range - const startDate = new Date(summary.startedAt); - if (sinceDate && startDate < sinceDate) continue; - if (untilDate && startDate > untilDate) continue; - - // Load full trajectory - const trajectory = await storage.get(summary.id); - if (trajectory) { - seenIds.add(summary.id); - - // Filter by PR if specified - if (options.pr) { - const escaped = options.pr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Match "#N" or "PR #N" / "PR N" patterns, requiring word boundaries - // to avoid false matches on words containing "pr" (e.g., "Improve") - const prPattern = new RegExp( - `#${escaped}\\b|\\bPR\\s*#?\\s*${escaped}\\b`, - "i", - ); - const matchesPR = - prPattern.test(trajectory.task.title) || - prPattern.test(trajectory.task.description || "") || - trajectory.commits.some((c) => prPattern.test(c)); - - if (!matchesPR) continue; - } - - // Filter by branch if specified - if (branchCommits) { - const hasMatchingCommit = trajectory.commits.some( - (c) => branchCommits.has(c.slice(0, 7)) || branchCommits.has(c), - ); - if (!hasMatchingCommit && trajectory.commits.length > 0) continue; - // Include trajectories with no commits (they might still be relevant) - } - - // Filter by commits if specified - if (targetCommits) { - const hasMatchingCommit = trajectory.commits.some( - (c) => targetCommits.has(c) || targetCommits.has(c.slice(0, 7)), - ); - if (!hasMatchingCommit) continue; - } - - trajectories.push(trajectory); + for (const summary of summaries) { + if (seenIds.has(summary.id)) continue; + + // Skip already compacted (unless --all) + if (compactedIds.has(summary.id)) continue; + + // Filter by IDs if specified + if (targetIds && !targetIds.includes(summary.id)) continue; + + // Filter by date range + const startDate = new Date(summary.startedAt); + if (sinceDate && startDate < sinceDate) continue; + if (untilDate && startDate > untilDate) continue; + + // Load full trajectory + const trajectory = await storage.get(summary.id); + if (trajectory) { + seenIds.add(summary.id); + + // Filter by PR if specified + if (options.pr) { + const escaped = options.pr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Match "#N" or "PR #N" / "PR N" patterns, requiring word boundaries + // to avoid false matches on words containing "pr" (e.g., "Improve") + const prPattern = new RegExp( + `#${escaped}\\b|\\bPR\\s*#?\\s*${escaped}\\b`, + "i", + ); + const matchesPR = + prPattern.test(trajectory.task.title) || + prPattern.test(trajectory.task.description || "") || + trajectory.commits.some((c) => prPattern.test(c)); + + if (!matchesPR) continue; } - } - } finally { - if (originalDataDir !== undefined) { - process.env.TRAJECTORIES_DATA_DIR = originalDataDir; - } else { - // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset (assignment stores string "undefined") - delete process.env.TRAJECTORIES_DATA_DIR; + + // Filter by branch if specified + if (branchCommits) { + const hasMatchingCommit = trajectory.commits.some( + (c) => branchCommits.has(c.slice(0, 7)) || branchCommits.has(c), + ); + if (!hasMatchingCommit && trajectory.commits.length > 0) continue; + // Include trajectories with no commits (they might still be relevant) + } + + // Filter by commits if specified + if (targetCommits) { + const hasMatchingCommit = trajectory.commits.some( + (c) => targetCommits.has(c) || targetCommits.has(c.slice(0, 7)), + ); + if (!hasMatchingCommit) continue; + } + + trajectories.push(trajectory); } } } @@ -270,8 +385,9 @@ function getBranchCommits(targetBranch: string): Set { try { // Get commits on HEAD that are not in target branch - const output = execSync( - `git log '${targetBranch.replace(/'/g, "'\\''")}'..HEAD --format=%H`, + const output = execFileSync( + "git", + ["log", `${targetBranch}..HEAD`, "--format=%H"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -560,6 +676,101 @@ function groupDecisions( ); } +function shouldUseLLM( + options: Pick, + providerAvailable: boolean, +): boolean { + if (options.mechanical) { + return false; + } + + if (options.llm === false) { + return false; + } + + if (options.llm === true) { + return providerAvailable; + } + + return providerAvailable; +} + +function buildLLMCompactionPlan( + trajectories: Trajectory[], + focusAreas: string[], + maxInputTokens: number, + maxOutputTokens: number, +): LLMCompactionPlan { + const serialized = serializeForLLM(trajectories, maxInputTokens); + const messages = buildCompactionPrompt(serialized, { + focusAreas, + maxOutputTokens, + }); + + return { + messages, + estimatedInputTokens: estimateTokens( + messages.map((message) => message.content).join("\n\n"), + ), + estimatedOutputTokens: maxOutputTokens, + focusAreas, + }; +} + +function parseFocusAreas(focus?: string): string[] { + if (!focus) { + return []; + } + + return focus + .split(",") + .map((area) => area.trim()) + .filter(Boolean); +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil(text.length / 4)); +} + +function printLLMDryRun( + plan: LLMCompactionPlan, + model: string | undefined, +): void { + console.log("=== DRY RUN - LLM Prompt Preview ===\n"); + console.log( + `Estimated: ~${plan.estimatedInputTokens} input tokens, ~${plan.estimatedOutputTokens} output tokens`, + ); + if (model) { + console.log(`Configured model: ${model}`); + } + if (plan.focusAreas.length > 0) { + console.log(`Focus: ${plan.focusAreas.join(", ")}`); + } + console.log(""); + + for (const message of plan.messages) { + console.log(`[${message.role.toUpperCase()}]`); + console.log(message.content); + console.log(""); + } +} + +function getProviderLabel(provider: CompactionLLM): string { + if (provider instanceof OpenAIProvider) { + return "OpenAI"; + } + + if (provider instanceof AnthropicProvider) { + return "Anthropic"; + } + + if (provider instanceof CLIProvider) { + return `CLI (${provider.cliName})`; + } + + return "LLM"; +} + function getDefaultOutputPath(compacted: CompactedTrajectory): string { const trajDir = process.env.TRAJECTORIES_DATA_DIR || ".trajectories"; const compactedDir = join(trajDir, "compacted"); @@ -572,16 +783,84 @@ function getDefaultOutputPath(compacted: CompactedTrajectory): string { return join(compactedDir, `${compacted.id}_${dateStr}.json`); } -function saveCompactedTrajectory( +function saveCompactionArtifacts( compacted: CompactedTrajectory, outputPath: string, + markdownEnabled: boolean, ): void { - const dir = join(outputPath, ".."); + const dir = dirname(outputPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(outputPath, JSON.stringify(compacted, null, 2)); + + if (markdownEnabled) { + writeFileSync( + getMarkdownOutputPath(outputPath), + renderCompactionMarkdown(compacted), + ); + } +} + +function getMarkdownOutputPath(outputPath: string): string { + return outputPath.endsWith(".json") + ? outputPath.slice(0, -".json".length).concat(".md") + : `${outputPath}.md`; +} + +function renderCompactionMarkdown(compacted: CompactedTrajectory): string { + if (compacted.narrative) { + return generateCompactionMarkdown( + compacted as Parameters[0], + ); + } + + const decisionGroups = + compacted.decisionGroups.length > 0 + ? compacted.decisionGroups + .map((group) => { + const decisions = + group.decisions.length > 0 + ? group.decisions + .map( + (decision) => + `- ${decision.question} -> ${decision.chosen} (${decision.fromTrajectory})`, + ) + .join("\n") + : "- None"; + return `## ${capitalize(group.category)}\n${decisions}`; + }) + .join("\n\n") + : "## Decision Groups\n- None"; + const learnings = + compacted.keyLearnings.length > 0 + ? compacted.keyLearnings.map((learning) => `- ${learning}`).join("\n") + : "- None"; + const findings = + compacted.keyFindings.length > 0 + ? compacted.keyFindings.map((finding) => `- ${finding}`).join("\n") + : "- None"; + + return [ + `# Trajectory Compaction: ${formatDate(compacted.dateRange.start)} - ${formatDate(compacted.dateRange.end)}`, + "", + "## Summary", + `- Sessions: ${compacted.sourceTrajectories.length}`, + `- Decisions: ${compacted.summary.totalDecisions}`, + `- Events: ${compacted.summary.totalEvents}`, + `- Agents: ${compacted.summary.uniqueAgents.join(", ") || "None"}`, + `- Files: ${compacted.filesAffected.length}`, + `- Commits: ${compacted.commits.length}`, + "", + decisionGroups, + "", + "## Key Learnings", + learnings, + "", + "## Key Findings", + findings, + ].join("\n"); } function printCompactedSummary(compacted: CompactedTrajectory): void { @@ -596,30 +875,62 @@ function printCompactedSummary(compacted: CompactedTrajectory): void { console.log(`Agents: ${compacted.summary.uniqueAgents.join(", ")}`); console.log(""); - console.log("=== Decision Groups ===\n"); - for (const group of compacted.decisionGroups) { - console.log( - `${capitalize(group.category)} (${group.decisions.length} decisions):`, - ); - for (const decision of group.decisions.slice(0, 3)) { - console.log(` - ${decision.question}`); - console.log(` Chose: ${decision.chosen}`); - } - if (group.decisions.length > 3) { - console.log(` ... and ${group.decisions.length - 3} more`); - } + if (compacted.narrative) { + console.log("=== Narrative ===\n"); + console.log(compacted.narrative); console.log(""); - } - if (compacted.keyLearnings.length > 0) { - console.log("=== Key Learnings ===\n"); - for (const learning of compacted.keyLearnings.slice(0, 5)) { - console.log(` - ${learning}`); + if (compacted.decisions && compacted.decisions.length > 0) { + console.log("=== Key Decisions ===\n"); + for (const decision of compacted.decisions.slice(0, 5)) { + console.log(` - ${decision.question}`); + console.log(` Chosen: ${decision.chosen}`); + if (decision.impact) { + console.log(` Impact: ${decision.impact}`); + } + } + if (compacted.decisions.length > 5) { + console.log(` ... and ${compacted.decisions.length - 5} more`); + } + console.log(""); } - if (compacted.keyLearnings.length > 5) { - console.log(` ... and ${compacted.keyLearnings.length - 5} more`); + + if (compacted.openQuestions && compacted.openQuestions.length > 0) { + console.log("=== Open Questions ===\n"); + for (const question of compacted.openQuestions.slice(0, 5)) { + console.log(` - ${question}`); + } + if (compacted.openQuestions.length > 5) { + console.log(` ... and ${compacted.openQuestions.length - 5} more`); + } + console.log(""); + } + } else { + console.log("=== Decision Groups ===\n"); + for (const group of compacted.decisionGroups) { + console.log( + `${capitalize(group.category)} (${group.decisions.length} decisions):`, + ); + for (const decision of group.decisions.slice(0, 3)) { + console.log(` - ${decision.question}`); + console.log(` Chose: ${decision.chosen}`); + } + if (group.decisions.length > 3) { + console.log(` ... and ${group.decisions.length - 3} more`); + } + console.log(""); + } + + if (compacted.keyLearnings.length > 0) { + console.log("=== Key Learnings ===\n"); + for (const learning of compacted.keyLearnings.slice(0, 5)) { + console.log(` - ${learning}`); + } + if (compacted.keyLearnings.length > 5) { + console.log(` ... and ${compacted.keyLearnings.length - 5} more`); + } + console.log(""); } - console.log(""); } if (compacted.filesAffected.length > 0) { diff --git a/src/compact/config.ts b/src/compact/config.ts new file mode 100644 index 0000000..ec91ca9 --- /dev/null +++ b/src/compact/config.ts @@ -0,0 +1,126 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getSearchPaths } from "../storage/file.js"; + +export interface CompactionConfig { + provider: string; + model: string | undefined; + maxInputTokens: number; + maxOutputTokens: number; + temperature: number; +} + +const DEFAULT_CONFIG: CompactionConfig = { + provider: "auto", + model: undefined, + maxInputTokens: 30000, + maxOutputTokens: 4000, + temperature: 0.3, +}; + +export function getCompactionConfig(): CompactionConfig { + const fileConfig = loadFileConfig(); + + return { + provider: + readStringEnv("TRAJECTORIES_LLM_PROVIDER") ?? + readString(fileConfig.provider) ?? + DEFAULT_CONFIG.provider, + model: + readStringEnv("TRAJECTORIES_LLM_MODEL") ?? + readString(fileConfig.model) ?? + DEFAULT_CONFIG.model, + maxInputTokens: + readNumberEnv("TRAJECTORIES_LLM_MAX_INPUT_TOKENS") ?? + readNumber(fileConfig.maxInputTokens) ?? + DEFAULT_CONFIG.maxInputTokens, + maxOutputTokens: + readNumberEnv("TRAJECTORIES_LLM_MAX_OUTPUT_TOKENS") ?? + readNumber(fileConfig.maxOutputTokens) ?? + DEFAULT_CONFIG.maxOutputTokens, + temperature: + readNumberEnv("TRAJECTORIES_LLM_TEMPERATURE") ?? + readNumber(fileConfig.temperature) ?? + DEFAULT_CONFIG.temperature, + }; +} + +function loadFileConfig(): Partial { + const configPath = join(getPrimaryConfigDir(), "config.json"); + if (!existsSync(configPath)) { + return {}; + } + + try { + const raw = JSON.parse(readFileSync(configPath, "utf-8")) as unknown; + if (!isRecord(raw)) { + return {}; + } + + // Merge precedence (last wins): root < compaction < llm + // e.g. { "model": "x", "compaction": { "model": "y" }, "llm": { "model": "z" } } + // results in model = "z" + const merged: Record = {}; + for (const section of [raw, raw.compaction, raw.llm]) { + if (!isRecord(section)) { + continue; + } + + for (const [key, value] of Object.entries(section)) { + if (key === "compaction" || key === "llm") { + continue; + } + merged[key] = value; + } + } + + return { + provider: readString(merged.provider), + model: readString(merged.model), + maxInputTokens: readNumber(merged.maxInputTokens), + maxOutputTokens: readNumber(merged.maxOutputTokens), + temperature: readNumber(merged.temperature), + }; + } catch { + return {}; + } +} + +function getPrimaryConfigDir(): string { + const searchPaths = getSearchPaths(); + return searchPaths[0] ?? join(process.cwd(), ".trajectories"); +} + +function readStringEnv(name: string): string | undefined { + return readString(process.env[name]); +} + +function readNumberEnv(name: string): number | undefined { + return readNumber(process.env[name]); +} + +function readString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value !== "string") { + return undefined; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/compact/index.ts b/src/compact/index.ts new file mode 100644 index 0000000..a4adaac --- /dev/null +++ b/src/compact/index.ts @@ -0,0 +1,10 @@ +export * from "./config.js"; +export * from "./markdown.js"; +export * from "./parser.js"; +export { + COMPACTED_OUTPUT_SCHEMA, + COMPACTION_SYSTEM_PROMPT, + buildCompactionPrompt, +} from "./prompts.js"; +export * from "./provider.js"; +export * from "./serializer.js"; diff --git a/src/compact/markdown.ts b/src/compact/markdown.ts new file mode 100644 index 0000000..3fadc04 --- /dev/null +++ b/src/compact/markdown.ts @@ -0,0 +1,83 @@ +import type { CompactedTrajectory, LLMCompactedOutput } from "./parser.js"; + +export function generateCompactionMarkdown( + compacted: CompactedTrajectory & LLMCompactedOutput, +): string { + const dateRange = `${formatDate(compacted.dateRange.start)} - ${formatDate(compacted.dateRange.end)}`; + const agents = + compacted.summary.uniqueAgents.length > 0 + ? compacted.summary.uniqueAgents.join(", ") + : "None"; + const decisionRows = + compacted.decisions.length > 0 + ? compacted.decisions + .map( + (decision) => + `| ${escapeTableCell(decision.question)} | ${escapeTableCell(decision.chosen)} | ${escapeTableCell(decision.impact)} |`, + ) + .join("\n") + : "| None identified | | |"; + const conventions = + compacted.conventions.length > 0 + ? compacted.conventions + .map( + (convention) => + `- **${convention.pattern || "Unnamed pattern"}**: ${convention.rationale || "No rationale captured."} (scope: ${convention.scope || "unspecified"})`, + ) + .join("\n") + : "- None established."; + const lessons = + compacted.lessons.length > 0 + ? compacted.lessons + .map((lesson) => { + const context = lesson.context ? ` (${lesson.context})` : ""; + const recommendation = lesson.recommendation + ? ` - ${lesson.recommendation}` + : ""; + return `- ${lesson.lesson}${context}${recommendation}`; + }) + .join("\n") + : "- None captured."; + const openQuestions = + compacted.openQuestions.length > 0 + ? compacted.openQuestions.map((question) => `- ${question}`).join("\n") + : "- None."; + + return [ + `# Trajectory Compaction: ${dateRange}`, + "", + "## Summary", + compacted.narrative || "No narrative available.", + "", + `## Key Decisions (${compacted.decisions.length})`, + "| Question | Decision | Impact |", + "|----------|----------|--------|", + decisionRows, + "", + "## Conventions Established", + conventions, + "", + "## Lessons Learned", + lessons, + "", + "## Open Questions", + openQuestions, + "", + "## Stats", + `- Sessions: ${compacted.sourceTrajectories.length}, Agents: ${agents}, Files: ${compacted.filesAffected.length}, Commits: ${compacted.commits.length}`, + `- Date range: ${compacted.dateRange.start} - ${compacted.dateRange.end}`, + ].join("\n"); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toISOString().slice(0, 10); +} + +function escapeTableCell(value: string): string { + return value.replace(/\|/g, "\\|").replace(/\n/g, " "); +} diff --git a/src/compact/parser.ts b/src/compact/parser.ts new file mode 100644 index 0000000..43d0d32 --- /dev/null +++ b/src/compact/parser.ts @@ -0,0 +1,486 @@ +export interface CompactedDecision { + question: string; + chosen: string; + reasoning: string; + impact: string; +} + +export interface CompactedConvention { + pattern: string; + rationale: string; + scope: string; +} + +export interface CompactedLesson { + lesson: string; + context: string; + recommendation: string; +} + +export interface LLMCompactedOutput { + narrative: string; + decisions: CompactedDecision[]; + conventions: CompactedConvention[]; + lessons: CompactedLesson[]; + openQuestions: string[]; +} + +export interface CompactedTrajectoryMetadata { + id: string; + version: number; + type: "compacted"; + compactedAt: string; + sourceTrajectories: string[]; + dateRange: { + start: string; + end: string; + }; + summary: { + totalDecisions: number; + totalEvents: number; + uniqueAgents: string[]; + }; + filesAffected: string[]; + commits: string[]; +} + +export type CompactedTrajectory = CompactedTrajectoryMetadata & + LLMCompactedOutput; + +export function parseCompactionResponse(llmOutput: string): LLMCompactedOutput { + const trimmed = llmOutput.trim(); + const parsedJson = + parseJsonCandidate(trimmed) ?? + parseJsonCandidate(extractFirstMarkdownJsonBlock(trimmed)) ?? + parseJsonCandidate(extractBalancedJsonObject(trimmed)); + + if (parsedJson) { + return normalizeCompactionOutput(parsedJson, trimmed); + } + + return normalizeCompactionOutput(extractFromProse(trimmed), trimmed); +} + +export function mergeCompactionWithMetadata( + metadata: CompactedTrajectoryMetadata, + llmOutput: LLMCompactedOutput, +): CompactedTrajectory { + return { + ...metadata, + ...llmOutput, + }; +} + +function parseJsonCandidate(candidate: string | null): unknown | null { + if (!candidate) { + return null; + } + + try { + return JSON.parse(candidate); + } catch { + return null; + } +} + +function extractFirstMarkdownJsonBlock(text: string): string | null { + const match = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + return match ? match[1].trim() : null; +} + +function extractBalancedJsonObject(text: string): string | null { + const start = text.indexOf("{"); + if (start === -1) { + return null; + } + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return text.slice(start, index + 1); + } + } + } + + return null; +} + +function extractFromProse(text: string): Partial { + const sections = splitSections(text); + const narrativeSection = + sections.narrative ?? sections.summary ?? leadingNarrative(text); + + return { + narrative: normalizeText(narrativeSection), + decisions: parseDecisionSection( + sections["key decisions"] ?? sections.decisions ?? "", + ), + conventions: parseConventionSection( + sections["conventions established"] ?? sections.conventions ?? "", + ), + lessons: parseLessonSection( + sections["lessons learned"] ?? sections.lessons ?? "", + ), + openQuestions: parseStringList( + sections["open questions"] ?? sections.questions ?? "", + ), + }; +} + +function splitSections(text: string): Record { + const matches = [...text.matchAll(/^##+\s+(.+?)\s*$/gm)]; + const sections: Record = {}; + + for (let index = 0; index < matches.length; index += 1) { + const current = matches[index]; + const next = matches[index + 1]; + const title = normalizeHeading(current[1]); + const start = + current.index === undefined ? 0 : current.index + current[0].length; + const end = next?.index ?? text.length; + sections[title] = text.slice(start, end).trim(); + } + + return sections; +} + +function normalizeHeading(value: string): string { + return value + .toLowerCase() + .replace(/\(\d+\)/g, "") + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function leadingNarrative(text: string): string { + const beforeHeading = text.split(/^##+\s+/m, 1)[0] ?? ""; + const withoutCode = beforeHeading.replace(/```[\s\S]*?```/g, "").trim(); + return withoutCode; +} + +function normalizeCompactionOutput( + raw: unknown, + fallbackNarrativeSource: string, +): LLMCompactedOutput { + const candidate = isRecord(raw) ? raw : {}; + + const narrative = normalizeText( + typeof candidate.narrative === "string" + ? candidate.narrative + : typeof candidate.summary === "string" + ? candidate.summary + : typeof candidate.overview === "string" + ? candidate.overview + : leadingNarrative(fallbackNarrativeSource), + ); + + return { + narrative: + narrative || + normalizeText(fallbackNarrativeSource) || + "No narrative provided.", + decisions: normalizeDecisionArray(candidate.decisions), + conventions: normalizeConventionArray(candidate.conventions), + lessons: normalizeLessonArray(candidate.lessons), + openQuestions: normalizeStringArray( + candidate.openQuestions ?? + candidate.open_questions ?? + candidate.questions, + ), + }; +} + +function normalizeDecisionArray(value: unknown): CompactedDecision[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + if (typeof entry === "string") { + return { + question: normalizeText(entry), + chosen: "", + reasoning: "", + impact: "", + }; + } + + if (!isRecord(entry)) { + return null; + } + + return { + question: readString(entry, ["question", "prompt", "topic"]), + chosen: readString(entry, ["chosen", "decision", "answer"]), + reasoning: readString(entry, ["reasoning", "why", "rationale"]), + impact: readString(entry, ["impact", "result", "outcome"]), + }; + }) + .filter((entry): entry is CompactedDecision => { + return entry !== null && hasContent(Object.values(entry)); + }); +} + +function normalizeConventionArray(value: unknown): CompactedConvention[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + if (typeof entry === "string") { + return { + pattern: normalizeText(entry), + rationale: "", + scope: "", + }; + } + + if (!isRecord(entry)) { + return null; + } + + return { + pattern: readString(entry, ["pattern", "rule", "convention"]), + rationale: readString(entry, ["rationale", "reasoning", "why"]), + scope: readString(entry, ["scope", "appliesTo", "applies_to"]), + }; + }) + .filter((entry): entry is CompactedConvention => { + return entry !== null && hasContent(Object.values(entry)); + }); +} + +function normalizeLessonArray(value: unknown): CompactedLesson[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + if (typeof entry === "string") { + return { + lesson: normalizeText(entry), + context: "", + recommendation: "", + }; + } + + if (!isRecord(entry)) { + return null; + } + + return { + lesson: readString(entry, ["lesson", "learning", "takeaway"]), + context: readString(entry, ["context", "situation", "when"]), + recommendation: readString(entry, [ + "recommendation", + "suggestion", + "nextStep", + "next_step", + ]), + }; + }) + .filter((entry): entry is CompactedLesson => { + return entry !== null && hasContent(Object.values(entry)); + }); +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => (typeof entry === "string" ? normalizeText(entry) : "")) + .filter(Boolean); +} + +function parseDecisionSection(section: string): CompactedDecision[] { + const tableDecisions = parseMarkdownTable(section).map((row) => ({ + question: row[0] ?? "", + chosen: row[1] ?? "", + reasoning: row[2] ?? "", + impact: row[3] ?? row[2] ?? "", + })); + + if (tableDecisions.length > 0) { + return tableDecisions.filter((entry) => hasContent(Object.values(entry))); + } + + return parseListItems(section) + .map((item) => { + const fields = parseFieldMap(item); + return { + question: + fields.question ?? + fields.prompt ?? + fields.topic ?? + fields.title ?? + item, + chosen: fields.chosen ?? fields.decision ?? fields.answer ?? "", + reasoning: fields.reasoning ?? fields.rationale ?? fields.why ?? "", + impact: fields.impact ?? fields.outcome ?? fields.result ?? "", + }; + }) + .filter((entry) => hasContent(Object.values(entry))); +} + +function parseConventionSection(section: string): CompactedConvention[] { + return parseListItems(section) + .map((item) => { + const emphasized = item.match(/^\*\*(.+?)\*\*:\s*(.+)$/); + const scopeMatch = item.match(/\((?:scope|applies to):\s*([^)]+)\)\s*$/i); + const withoutScope = scopeMatch + ? item.slice(0, scopeMatch.index).trim() + : item; + + if (emphasized) { + return { + pattern: normalizeText(emphasized[1]), + rationale: normalizeText( + withoutScope.replace(/^\*\*(.+?)\*\*:\s*/, ""), + ), + scope: normalizeText(scopeMatch?.[1] ?? ""), + }; + } + + const fields = parseFieldMap(item); + return { + pattern: fields.pattern ?? fields.convention ?? fields.rule ?? item, + rationale: fields.rationale ?? fields.reasoning ?? fields.why ?? "", + scope: fields.scope ?? fields.applies ?? "", + }; + }) + .filter((entry) => hasContent(Object.values(entry))); +} + +function parseLessonSection(section: string): CompactedLesson[] { + return parseListItems(section) + .map((item) => { + const fields = parseFieldMap(item); + const dashParts = item.split(/\s[—-]\s/, 2); + + return { + lesson: + fields.lesson ?? + fields.learning ?? + fields.takeaway ?? + dashParts[0] ?? + item, + context: fields.context ?? "", + recommendation: + fields.recommendation ?? + fields.suggestion ?? + fields.nextstep ?? + dashParts[1] ?? + "", + }; + }) + .filter((entry) => hasContent(Object.values(entry))); +} + +function parseStringList(section: string): string[] { + return parseListItems(section).map(normalizeText).filter(Boolean); +} + +function parseMarkdownTable(section: string): string[][] { + const lines = section + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("|")); + + if (lines.length < 2) { + return []; + } + + return lines + .slice(1) + .filter((line) => !/^\|?\s*:?-{3,}/.test(line.replace(/\|/g, ""))) + .map((line) => + line + .split("|") + .slice(1, -1) + .map((cell) => normalizeText(cell)), + ); +} + +function parseListItems(section: string): string[] { + return section + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^[-*] |\d+\.\s/.test(line)) + .map((line) => line.replace(/^[-*]\s+|\d+\.\s+/, "").trim()) + .filter(Boolean); +} + +function parseFieldMap(item: string): Record { + const normalized = item.replace(/\s+\|\s+/g, "; "); + const segments = normalized.split(/;\s+/); + const fields: Record = {}; + + for (const segment of segments) { + const match = segment.match(/^([A-Za-z ]+):\s*(.+)$/); + if (!match) { + continue; + } + + const key = match[1].toLowerCase().replace(/\s+/g, ""); + fields[key] = normalizeText(match[2]); + } + + return fields; +} + +function readString(record: Record, keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return normalizeText(value); + } + } + + return ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeText(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim(); +} + +function hasContent(values: string[]): boolean { + return values.some((value) => value.trim().length > 0); +} diff --git a/src/compact/prompts.ts b/src/compact/prompts.ts new file mode 100644 index 0000000..40d5407 --- /dev/null +++ b/src/compact/prompts.ts @@ -0,0 +1,96 @@ +export interface Message { + role: "system" | "user"; + content: string; +} + +export interface PromptOptions { + focusAreas?: string[]; + maxOutputTokens?: number; +} + +export const COMPACTION_SYSTEM_PROMPT = `You are a technical analyst reviewing agent work sessions (trajectories). +Your job is to produce a concise, insightful summary that captures: +- What was accomplished and how +- Key decisions and their reasoning +- Patterns/conventions established that should be followed in future work +- Lessons learned from challenges and failures +- Open questions or unresolved issues + +Be specific. Reference actual file paths, function names, and technical details. +Don't be generic - this summary replaces the raw data.`; + +export const COMPACTED_OUTPUT_SCHEMA = `{ + "narrative": "string", + "decisions": [ + { + "question": "string", + "chosen": "string", + "reasoning": "string", + "impact": "string" + } + ], + "conventions": [ + { + "pattern": "string", + "rationale": "string", + "scope": "string" + } + ], + "lessons": [ + { + "lesson": "string", + "context": "string", + "recommendation": "string" + } + ], + "openQuestions": ["string"] +}`; + +export function buildCompactionPrompt( + serializedTrajectories: string, + options: PromptOptions = {}, +): Message[] { + const focusAreas = + options.focusAreas && options.focusAreas.length > 0 + ? options.focusAreas.map((area) => `- ${area}`).join("\n") + : [ + "- What work was attempted, completed, or abandoned", + "- Why specific technical decisions were made", + "- Which conventions should carry forward", + "- What broke, what worked, and what should change next time", + ].join("\n"); + + const maxOutputInstruction = options.maxOutputTokens + ? `Keep the full response within approximately ${options.maxOutputTokens} tokens while preserving technical specificity.` + : "Keep the response concise, dense with signal, and avoid filler."; + + const userPrompt = [ + "Review the following serialized agent trajectories and return a single JSON object.", + "The JSON must match this schema exactly:", + COMPACTED_OUTPUT_SCHEMA, + "", + "Requirements:", + "- Output raw JSON only. Do not wrap it in markdown fences.", + "- `narrative` should be 2-3 tight paragraphs.", + "- `decisions`, `conventions`, and `lessons` must always be arrays, even if empty.", + "- Prefer concrete file paths, symbols, commands, and implementation details over generic summaries.", + maxOutputInstruction, + "", + "Focus areas:", + focusAreas, + "", + "Serialized trajectories:", + serializedTrajectories.trim(), + ].join("\n"); + + return [ + { + role: "system", + content: COMPACTION_SYSTEM_PROMPT, + }, + { + role: "user", + content: userPrompt, + }, + ]; +} diff --git a/src/compact/provider.ts b/src/compact/provider.ts new file mode 100644 index 0000000..b70d80c --- /dev/null +++ b/src/compact/provider.ts @@ -0,0 +1,444 @@ +import { execFile, spawn } from "node:child_process"; +import { constants, accessSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { CompactionConfig } from "./config.js"; + +const execFileAsync = promisify(execFile); + +// Note: extends prompts.ts Message with additional "assistant" role for provider responses +export interface Message { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface CompletionOptions { + maxTokens?: number; + temperature?: number; + jsonMode?: boolean; +} + +export interface CompactionLLM { + complete(messages: Message[], options?: CompletionOptions): Promise; +} + +interface ProviderConfig { + apiKey: string; + model: string; + baseUrl: string; +} + +interface OpenAIChatResponse { + choices?: Array<{ + message?: { + content?: string | null; + }; + }>; + error?: { + message?: string; + }; +} + +interface AnthropicResponse { + content?: Array< + | { + type: "text"; + text: string; + } + | { + type: string; + } + >; + error?: { + message?: string; + }; +} + +export const DEFAULT_OPENAI_MODEL = "gpt-4o"; +export const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"; +const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com"; +const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"; +const DEFAULT_MAX_TOKENS = 4096; + +export class OpenAIProvider implements CompactionLLM { + private readonly apiKey: string; + private readonly model: string; + private readonly baseUrl: string; + + constructor(config: Partial = {}) { + this.apiKey = + config.apiKey?.trim() || process.env.OPENAI_API_KEY?.trim() || ""; + this.model = + normalizeModel(config.model) ?? + normalizeModel(process.env.TRAJECTORIES_LLM_MODEL) ?? + DEFAULT_OPENAI_MODEL; + this.baseUrl = + config.baseUrl ?? process.env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL; + + if (this.baseUrl !== DEFAULT_OPENAI_BASE_URL) { + console.warn( + `[trajectories] OpenAI base URL overridden to: ${this.baseUrl}`, + ); + } + + if (!this.apiKey) { + throw new Error("OPENAI_API_KEY is required for OpenAIProvider"); + } + } + + async complete( + messages: Message[], + options: CompletionOptions = {}, + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 300_000); + try { + const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.model, + messages, + max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS, + temperature: options.temperature ?? 0.2, + response_format: options.jsonMode + ? { type: "json_object" } + : undefined, + }), + signal: controller.signal, + }); + + const body = (await parseJson(response)) as OpenAIChatResponse; + if (!response.ok) { + throw new Error( + body.error?.message ?? + `OpenAI request failed with status ${response.status}`, + ); + } + + const content = body.choices?.[0]?.message?.content; + if (!content) { + throw new Error("OpenAI response did not include completion content"); + } + + return content; + } finally { + clearTimeout(timeout); + } + } +} + +export class AnthropicProvider implements CompactionLLM { + private readonly apiKey: string; + private readonly model: string; + private readonly baseUrl: string; + + constructor(config: Partial = {}) { + this.apiKey = + config.apiKey?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || ""; + this.model = + normalizeModel(config.model) ?? + normalizeModel(process.env.TRAJECTORIES_LLM_MODEL) ?? + DEFAULT_ANTHROPIC_MODEL; + this.baseUrl = + config.baseUrl ?? + process.env.ANTHROPIC_BASE_URL ?? + DEFAULT_ANTHROPIC_BASE_URL; + + if (this.baseUrl !== DEFAULT_ANTHROPIC_BASE_URL) { + console.warn( + `[trajectories] Anthropic base URL overridden to: ${this.baseUrl}`, + ); + } + + if (!this.apiKey) { + throw new Error("ANTHROPIC_API_KEY is required for AnthropicProvider"); + } + } + + async complete( + messages: Message[], + options: CompletionOptions = {}, + ): Promise { + const systemMessages = messages + .filter((message) => message.role === "system") + .map((message) => message.content.trim()) + .filter(Boolean); + + const conversation = messages + .filter((message) => message.role !== "system") + .map((message) => ({ + role: message.role, + content: message.content, + })); + + if (conversation.length === 0) { + throw new Error("AnthropicProvider requires at least one user message"); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 300_000); + try { + const response = await fetch(`${this.baseUrl}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "anthropic-version": "2024-10-22", + "x-api-key": this.apiKey, + }, + body: JSON.stringify({ + model: this.model, + system: + systemMessages.length > 0 ? systemMessages.join("\n\n") : undefined, + messages: conversation, + max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS, + temperature: options.temperature ?? 0.2, + }), + signal: controller.signal, + }); + + const body = (await parseJson(response)) as AnthropicResponse; + if (!response.ok) { + throw new Error( + body.error?.message ?? + `Anthropic request failed with status ${response.status}`, + ); + } + + const textBlocks = (body.content ?? []).filter( + ( + block, + ): block is Extract< + AnthropicResponse["content"], + Array + >[number] & { + type: "text"; + text: string; + } => + block.type === "text" && + typeof (block as { text?: unknown }).text === "string", + ); + const content = textBlocks + .map((block) => block.text) + .join("\n") + .trim(); + + if (!content) { + throw new Error("Anthropic response did not include text content"); + } + + return content; + } finally { + clearTimeout(timeout); + } + } +} + +const SUPPORTED_CLIS = ["claude", "codex"] as const; +type SupportedCli = (typeof SUPPORTED_CLIS)[number]; + +export class CLIProvider implements CompactionLLM { + private readonly cli: SupportedCli; + private readonly binaryPath: string; + + constructor(cli: SupportedCli, binaryPath: string) { + this.cli = cli; + this.binaryPath = binaryPath; + } + + get cliName(): string { + return this.cli; + } + + async complete( + messages: Message[], + _options: CompletionOptions = {}, + ): Promise { + const prompt = messagesToPrompt(messages); + const args = buildCliArgs(this.cli); + + // Use stdin to avoid OS argument length limits for large prompts + const output = await spawnWithStdin(this.binaryPath, args, prompt); + if (!output) { + throw new Error(`${this.cli} CLI returned empty output`); + } + + return output; + } +} + +function messagesToPrompt(messages: Message[]): string { + const systemParts: string[] = []; + const conversationParts: string[] = []; + + for (const msg of messages) { + if (msg.role === "system") { + systemParts.push(msg.content.trim()); + } else { + conversationParts.push(msg.content.trim()); + } + } + + const parts: string[] = []; + if (systemParts.length > 0) { + parts.push(systemParts.join("\n\n")); + } + if (conversationParts.length > 0) { + parts.push(conversationParts.join("\n\n")); + } + + return parts.join("\n\n---\n\n"); +} + +function buildCliArgs(cli: SupportedCli): string[] { + switch (cli) { + case "claude": + return ["-p", "--output-format", "text"]; + case "codex": + return ["exec", "-q"]; + } +} + +function spawnWithStdin( + command: string, + args: string[], + input: string, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + timeout: 300_000, + stdio: ["pipe", "pipe", "pipe"], + }); + + const chunks: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)); + + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject( + new Error(`CLI exited with code ${code}: ${stderr.slice(0, 200)}`), + ); + } else { + resolve(Buffer.concat(chunks).toString().trim()); + } + }); + + child.stdin.write(input); + child.stdin.end(); + }); +} + +export async function resolveProvider( + config: Partial = {}, +): Promise { + const explicitProvider = ( + config.provider ?? process.env.TRAJECTORIES_LLM_PROVIDER + )?.toLowerCase(); + const model = normalizeModel(config.model); + + if (explicitProvider === "openai") { + return process.env.OPENAI_API_KEY ? new OpenAIProvider({ model }) : null; + } + + if (explicitProvider === "anthropic") { + return process.env.ANTHROPIC_API_KEY + ? new AnthropicProvider({ model }) + : null; + } + + if (explicitProvider === "cli") { + return resolveCLIProvider(); + } + + if (explicitProvider && explicitProvider !== "auto") { + return null; + } + + if (process.env.OPENAI_API_KEY) { + return new OpenAIProvider({ model }); + } + + if (process.env.ANTHROPIC_API_KEY) { + return new AnthropicProvider({ model }); + } + + return resolveCLIProvider(); +} + +const CLI_SEARCH_PATHS = [ + "~/.local/bin", + "~/.claude/local", + "/usr/local/bin", + "/opt/homebrew/bin", +]; + +async function resolveCLIProvider(): Promise { + for (const cli of SUPPORTED_CLIS) { + const path = await findBinary(cli); + if (path) { + return new CLIProvider(cli, path); + } + } + + return null; +} + +async function findBinary(name: string): Promise { + // Try PATH first via `which` + try { + const { stdout } = await execFileAsync("which", [name]); + const path = stdout.trim(); + if (path) return path; + } catch { + // not in PATH + } + + // Fall back to well-known install directories + const home = homedir(); + for (const dir of CLI_SEARCH_PATHS) { + const expanded = dir.startsWith("~/") ? join(home, dir.slice(2)) : dir; + const candidate = join(expanded, name); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not found here + } + } + + return undefined; +} + +function normalizeModel(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +async function parseJson(response: Response): Promise { + const text = await response.text(); + if (!text) { + return {}; + } + + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error( + `Invalid JSON response (status ${response.status}, length ${text.length})`, + ); + } +} diff --git a/src/compact/serializer.ts b/src/compact/serializer.ts new file mode 100644 index 0000000..b5a1d42 --- /dev/null +++ b/src/compact/serializer.ts @@ -0,0 +1,438 @@ +import type { + Chapter, + Decision, + EventSignificance, + Finding, + Retrospective, + Trajectory, + TrajectoryEvent, +} from "../core/types.js"; + +const DEFAULT_MAX_TOKENS = 30000; +const CHARS_PER_TOKEN = 4; +const INCLUDED_SIGNIFICANCE = new Set([ + "medium", + "high", + "critical", +]); + +interface SessionRender { + header: string; + agents: string; + chapters: string[]; + decisions: string; + findings: string; + retrospective: string; + filesAndCommits: string; +} + +export function serializeForLLM( + trajectories: Trajectory[], + maxTokens = DEFAULT_MAX_TOKENS, +): string { + const maxChars = Math.max(0, maxTokens * CHARS_PER_TOKEN); + const sessions = trajectories.map(renderSession); + + let document = joinSessions(sessions); + if (document.length <= maxChars || sessions.length === 0) { + return document; + } + + const fixedChars = sessions.reduce( + (total, session) => + total + + session.header.length + + session.agents.length + + session.decisions.length + + session.findings.length + + session.retrospective.length + + session.filesAndCommits.length, + 0, + ); + const chapterChars = sessions.reduce( + (total, session) => + total + + session.chapters.reduce((sum, chapter) => sum + chapter.length, 0), + 0, + ); + + const remainingChapterChars = maxChars - fixedChars; + if (remainingChapterChars <= 0 || chapterChars === 0) { + return truncateText(document, maxChars); + } + + const ratio = Math.min(1, remainingChapterChars / chapterChars); + const truncatedSessions = sessions.map((session) => ({ + ...session, + chapters: truncateChapters( + session.chapters, + session.chapters.reduce((sum, chapter) => sum + chapter.length, 0), + ratio, + ), + })); + + document = joinSessions(truncatedSessions); + return document.length <= maxChars + ? document + : truncateText(document, maxChars); +} + +function renderSession(trajectory: Trajectory): SessionRender { + const sessionTitle = trajectory.task.title.trim() || trajectory.id; + const duration = formatDuration(trajectory.startedAt, trajectory.completedAt); + const header = [ + `## Session: ${sessionTitle} (${trajectory.status}, ${duration})`, + trajectory.task.description + ? `Description: ${trajectory.task.description}` + : "", + `Started: ${trajectory.startedAt}`, + trajectory.completedAt ? `Completed: ${trajectory.completedAt}` : "", + ] + .filter(Boolean) + .join("\n") + .concat("\n"); + + const agents = + trajectory.agents.length > 0 + ? `Agents: ${trajectory.agents + .map((agent) => `${agent.name} (${agent.role})`) + .join(", ")}\n` + : "Agents: none recorded\n"; + + const chapters = trajectory.chapters.map(renderChapter); + const decisions = renderDecisions(trajectory); + const findings = renderFindings(trajectory); + const retrospective = renderRetrospective(trajectory.retrospective); + const filesAndCommits = [ + `Files changed: ${formatList(trajectory.filesChanged)}`, + `Commits: ${formatList(trajectory.commits)}`, + ] + .join("\n") + .concat("\n"); + + return { + header, + agents, + chapters, + decisions, + findings, + retrospective, + filesAndCommits, + }; +} + +function renderChapter(chapter: Chapter): string { + const lines = chapter.events + .filter(shouldIncludeEvent) + .map((event) => formatEvent(event)); + + const chapterBody = + lines.length > 0 + ? lines.map((line) => `- ${line}`).join("\n") + : "- No medium/high/critical events captured"; + + return [ + `### Chapter: ${chapter.title}`, + `Agent: ${chapter.agentName}`, + `Window: ${chapter.startedAt} -> ${chapter.endedAt ?? "ongoing"}`, + chapterBody, + ] + .join("\n") + .concat("\n"); +} + +function renderDecisions(trajectory: Trajectory): string { + const seen = new Set(); + const decisions: Decision[] = []; + + for (const chapter of trajectory.chapters) { + for (const event of chapter.events) { + if (event.type !== "decision") { + continue; + } + + const decision = asDecision(event.raw); + if (!decision) { + continue; + } + + const key = `${decision.question}\n${decision.chosen}\n${decision.reasoning}`; + if (!seen.has(key)) { + seen.add(key); + decisions.push(decision); + } + } + } + + for (const decision of trajectory.retrospective?.decisions ?? []) { + const key = `${decision.question}\n${decision.chosen}\n${decision.reasoning}`; + if (!seen.has(key)) { + seen.add(key); + decisions.push(decision); + } + } + + if (decisions.length === 0) { + return "Decisions:\n- None recorded\n"; + } + + return [ + "Decisions:", + ...decisions.map((decision) => + [ + `- Question: ${decision.question}`, + ` Chosen: ${decision.chosen}`, + ` Reasoning: ${decision.reasoning}`, + ].join("\n"), + ), + ] + .join("\n") + .concat("\n"); +} + +function renderFindings(trajectory: Trajectory): string { + const findings = trajectory.chapters.flatMap((chapter) => + chapter.events + .filter((event) => event.type === "finding") + .map((event) => asFinding(event.raw, event.content)), + ); + + if (findings.length === 0) { + return "Findings:\n- None recorded\n"; + } + + return [ + "Findings:", + ...findings.map((finding) => + [ + `- What: ${finding.what}`, + ` Where: ${finding.where}`, + ` Significance: ${finding.significance}`, + ].join("\n"), + ), + ] + .join("\n") + .concat("\n"); +} + +function renderRetrospective(retrospective?: Retrospective): string { + if (!retrospective) { + return "Retrospective:\n- None recorded\n"; + } + + const lines = [ + "Retrospective:", + `- Summary: ${retrospective.summary}`, + ` Approach: ${retrospective.approach}`, + ]; + + if (retrospective.challenges && retrospective.challenges.length > 0) { + lines.push(` Challenges: ${retrospective.challenges.join("; ")}`); + } + + if (retrospective.learnings && retrospective.learnings.length > 0) { + lines.push(` Learnings: ${retrospective.learnings.join("; ")}`); + } + + if (retrospective.suggestions && retrospective.suggestions.length > 0) { + lines.push(` Suggestions: ${retrospective.suggestions.join("; ")}`); + } + + if (retrospective.timeSpent) { + lines.push(` Time spent: ${retrospective.timeSpent}`); + } + + return lines.join("\n").concat("\n"); +} + +function shouldIncludeEvent(event: TrajectoryEvent): boolean { + if (event.type === "tool_call" || event.type === "tool_result") { + return false; + } + + return INCLUDED_SIGNIFICANCE.has(resolveSignificance(event)); +} + +function resolveSignificance(event: TrajectoryEvent): EventSignificance { + if (event.significance) { + return event.significance; + } + + switch (event.type) { + case "decision": + case "finding": + case "error": + return "high"; + case "reflection": + case "note": + case "message_sent": + case "message_received": + return "medium"; + default: + return "low"; + } +} + +function formatEvent(event: TrajectoryEvent): string { + if (event.type === "decision") { + const decision = asDecision(event.raw); + if (decision) { + return `[decision/${resolveSignificance(event)}] ${decision.question} -> ${decision.chosen}`; + } + } + + if (event.type === "finding") { + const finding = asFinding(event.raw, event.content); + return `[finding/${resolveSignificance(event)}] ${finding.what} @ ${finding.where}`; + } + + return `[${event.type}/${resolveSignificance(event)}] ${event.content}`; +} + +function asDecision(raw: unknown): Decision | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const candidate = raw as Partial; + if ( + typeof candidate.question !== "string" || + typeof candidate.chosen !== "string" || + typeof candidate.reasoning !== "string" + ) { + return null; + } + + return { + question: candidate.question, + chosen: candidate.chosen, + reasoning: candidate.reasoning, + alternatives: Array.isArray(candidate.alternatives) + ? candidate.alternatives + : [], + confidence: candidate.confidence, + }; +} + +function asFinding(raw: unknown, fallbackContent: string): Finding { + if (!raw || typeof raw !== "object") { + return { + what: fallbackContent, + where: "unknown", + significance: "Not structured", + category: "other", + }; + } + + const candidate = raw as Partial; + return { + what: + typeof candidate.what === "string" && candidate.what.trim().length > 0 + ? candidate.what + : fallbackContent, + where: + typeof candidate.where === "string" && candidate.where.trim().length > 0 + ? candidate.where + : "unknown", + significance: + typeof candidate.significance === "string" && + candidate.significance.trim().length > 0 + ? candidate.significance + : "Not structured", + category: candidate.category ?? "other", + suggestedAction: + typeof candidate.suggestedAction === "string" + ? candidate.suggestedAction + : undefined, + confidence: candidate.confidence, + }; +} + +function truncateChapters( + chapters: string[], + totalChapterChars: number, + ratio: number, +): string[] { + if (ratio >= 1 || totalChapterChars === 0) { + return chapters; + } + + let remaining = Math.floor(totalChapterChars * ratio); + + return chapters.map((chapter, index) => { + if (remaining <= 0) { + return "### Chapter: Truncated\n- Omitted due to token budget\n"; + } + + const proportionalTarget = + index === chapters.length - 1 + ? remaining + : Math.floor(chapter.length * ratio); + const allowance = Math.max(0, Math.min(chapter.length, proportionalTarget)); + remaining -= allowance; + + return truncateText(chapter, allowance); + }); +} + +function joinSessions(sessions: SessionRender[]): string { + return sessions + .map((session) => + [ + session.header, + session.agents, + ...session.chapters, + session.decisions, + session.findings, + session.retrospective, + session.filesAndCommits, + ] + .filter(Boolean) + .join("\n") + .trim(), + ) + .join("\n\n"); +} + +function truncateText(text: string, maxChars: number): string { + if (maxChars <= 0) { + return ""; + } + + if (text.length <= maxChars) { + return text; + } + + if (maxChars <= 16) { + return text.slice(0, maxChars); + } + + return `${text.slice(0, maxChars - 16).trimEnd()}\n[truncated]\n`; +} + +function formatList(values: string[]): string { + return values.length > 0 ? values.join(", ") : "none"; +} + +function formatDuration(startedAt: string, completedAt?: string): string { + const start = new Date(startedAt).getTime(); + const end = new Date(completedAt ?? startedAt).getTime(); + const elapsedMs = Math.max(0, end - start); + const minutes = Math.floor(elapsedMs / 60000); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (hours > 0 && remainingMinutes > 0) { + return `${hours}h ${remainingMinutes}m`; + } + + if (hours > 0) { + return `${hours}h`; + } + + if (minutes > 0) { + return `${minutes}m`; + } + + return completedAt ? "0m" : "ongoing"; +} diff --git a/tests/compact/llm-compact.test.ts b/tests/compact/llm-compact.test.ts new file mode 100644 index 0000000..89d0e6c --- /dev/null +++ b/tests/compact/llm-compact.test.ts @@ -0,0 +1,449 @@ +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runCommand } from "../../src/cli/runner.js"; +import { generateCompactionMarkdown } from "../../src/compact/markdown.js"; +import { + mergeCompactionWithMetadata, + parseCompactionResponse, +} from "../../src/compact/parser.js"; +import { buildCompactionPrompt } from "../../src/compact/prompts.js"; +import type { Message as PromptMessage } from "../../src/compact/prompts.js"; +import { + CLIProvider, + type CompactionLLM, + type CompletionOptions, + resolveProvider, +} from "../../src/compact/provider.js"; +import { serializeForLLM } from "../../src/compact/serializer.js"; +import type { Decision, Trajectory } from "../../src/core/types.js"; + +describe("LLM compaction", () => { + let tempDir: string; + let originalCwd: string; + let originalEnv: Record; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "trail-llm-compact-")); + originalCwd = process.cwd(); + process.chdir(tempDir); + + originalEnv = { + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + TRAJECTORIES_LLM_PROVIDER: process.env.TRAJECTORIES_LLM_PROVIDER, + TRAJECTORIES_LLM_MODEL: process.env.TRAJECTORIES_LLM_MODEL, + TRAJECTORIES_LLM_MAX_INPUT_TOKENS: + process.env.TRAJECTORIES_LLM_MAX_INPUT_TOKENS, + TRAJECTORIES_LLM_MAX_OUTPUT_TOKENS: + process.env.TRAJECTORIES_LLM_MAX_OUTPUT_TOKENS, + TRAJECTORIES_LLM_TEMPERATURE: process.env.TRAJECTORIES_LLM_TEMPERATURE, + }; + + clearCompactionEnv(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + restoreEnv(originalEnv); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("serializes trajectories for LLM compaction", () => { + const serialized = serializeForLLM([createTrajectory()], 4000); + + expect(serialized).toContain("## Session: Update compact command"); + expect(serialized).toContain("Agents: Worker (lead)"); + expect(serialized).toContain("Question: Should compact use LLM summaries?"); + expect(serialized).toContain( + "Files changed: src/cli/commands/compact.ts, src/compact/provider.ts", + ); + }); + + it("parses structured LLM output", () => { + const parsed = parseCompactionResponse(`\`\`\`json +{ + "narrative": "LLM compaction now synthesizes the completed sessions into a concise technical summary.", + "decisions": [ + { + "question": "How should compact choose its strategy?", + "chosen": "Prefer LLM compaction when a provider is available.", + "reasoning": "It produces a denser summary while mechanical data still preserves files and commits.", + "impact": "CLI output becomes more useful after merges." + } + ], + "conventions": [ + { + "pattern": "Keep mechanical metadata even when LLM output is used.", + "rationale": "Files, commits, and agents are deterministic and should not rely on model output.", + "scope": "compact command" + } + ], + "lessons": [ + { + "lesson": "Dry runs should show the full prompt and token estimate.", + "context": "LLM calls can be expensive and hard to debug without visibility.", + "recommendation": "Print the constructed messages before invoking the provider." + } + ], + "openQuestions": [ + "Should the command persist raw model responses for debugging?" + ] +} +\`\`\``); + + expect(parsed.narrative).toContain("LLM compaction now synthesizes"); + expect(parsed.decisions).toHaveLength(1); + expect(parsed.decisions[0]?.impact).toContain("CLI output becomes"); + expect(parsed.conventions[0]?.pattern).toContain( + "Keep mechanical metadata", + ); + expect(parsed.lessons[0]?.recommendation).toContain("constructed messages"); + expect(parsed.openQuestions).toEqual([ + "Should the command persist raw model responses for debugging?", + ]); + }); + + it("generates markdown from LLM compaction output", () => { + const markdown = generateCompactionMarkdown({ + id: "compact_123", + version: 1, + type: "compacted", + compactedAt: "2026-03-28T12:00:00.000Z", + sourceTrajectories: ["traj_1", "traj_2"], + dateRange: { + start: "2026-03-20T10:00:00.000Z", + end: "2026-03-28T12:00:00.000Z", + }, + summary: { + totalDecisions: 3, + totalEvents: 14, + uniqueAgents: ["Worker", "Reviewer"], + }, + filesAffected: ["src/cli/commands/compact.ts", "src/compact/config.ts"], + commits: ["abc1234", "def5678"], + narrative: + "The command now prefers LLM compaction when a provider is available.", + decisions: [ + { + question: "How should compact choose the summary strategy?", + chosen: + "Auto-detect an LLM provider unless mechanical mode is forced.", + reasoning: + "This preserves the old flow but upgrades the default path.", + impact: "The command can produce higher-signal summaries by default.", + }, + ], + conventions: [ + { + pattern: "Always keep files and commits from the mechanical pass.", + rationale: "That data is deterministic and cheap to compute.", + scope: "LLM compaction output", + }, + ], + lessons: [ + { + lesson: "Token estimates are required for dry runs.", + context: "LLM compaction can be expensive.", + recommendation: "Print the estimate before calling the provider.", + }, + ], + openQuestions: ["Should config support per-project prompt templates?"], + }); + + expect(markdown).toContain("# Trajectory Compaction:"); + expect(markdown).toContain("## Key Decisions (1)"); + expect(markdown).toContain("| Question | Decision | Impact |"); + expect(markdown).toContain("## Conventions Established"); + expect(markdown).toContain("## Lessons Learned"); + }); + + it("runs the full LLM compaction pipeline with a mocked provider", () => { + const stubbedResponse = JSON.stringify({ + narrative: "Sessions focused on adding LLM-backed compaction.", + decisions: [ + { + question: "How to integrate LLM output?", + chosen: "Merge with mechanical metadata.", + reasoning: "Keeps deterministic data intact.", + impact: "Reliable file and commit lists.", + }, + ], + conventions: [ + { + pattern: "Always retain mechanical metadata.", + rationale: "It is deterministic.", + scope: "compact command", + }, + ], + lessons: [ + { + lesson: "Token budgeting prevents context overflow.", + context: "Large trajectories exceed model limits.", + recommendation: "Truncate chapters proportionally.", + }, + ], + openQuestions: ["Should raw model responses be persisted?"], + }); + + const mockProvider: CompactionLLM = { + complete: async ( + _messages: PromptMessage[], + _options?: CompletionOptions, + ): Promise => stubbedResponse, + }; + + const trajectory = createTrajectory(); + const serialized = serializeForLLM([trajectory], 4000); + const messages = buildCompactionPrompt(serialized); + + // Verify the prompt was built with user + system messages + expect(messages.length).toBeGreaterThanOrEqual(2); + expect(messages[0]?.role).toBe("system"); + + // Run the mocked provider + return mockProvider.complete(messages, { jsonMode: true }).then((raw) => { + const parsed = parseCompactionResponse(raw); + + expect(parsed.narrative).toContain("LLM-backed compaction"); + expect(parsed.decisions).toHaveLength(1); + expect(parsed.conventions).toHaveLength(1); + expect(parsed.lessons).toHaveLength(1); + expect(parsed.openQuestions).toHaveLength(1); + + // Merge with metadata + const merged = mergeCompactionWithMetadata( + { + id: "compact_mock", + version: 1, + type: "compacted", + compactedAt: new Date().toISOString(), + sourceTrajectories: [trajectory.id], + dateRange: { + start: trajectory.startedAt, + end: trajectory.completedAt ?? trajectory.startedAt, + }, + summary: { + totalDecisions: 1, + totalEvents: 2, + uniqueAgents: ["Worker"], + }, + filesAffected: trajectory.filesChanged ?? [], + commits: trajectory.commits ?? [], + }, + parsed, + ); + + expect(merged.id).toBe("compact_mock"); + expect(merged.narrative).toContain("LLM-backed compaction"); + expect(merged.filesAffected).toContain("src/cli/commands/compact.ts"); + + // Verify markdown generation works end-to-end + const md = generateCompactionMarkdown(merged); + expect(md).toContain("## Summary"); + expect(md).toContain("LLM-backed compaction"); + }); + }); + + it( + "uses mechanical compaction with --mechanical flag", + { timeout: 15_000 }, + async () => { + const started = await runCommand(["start", "Update compact command"]); + expect(started.success).toBe(true); + + const decided = await runCommand([ + "decision", + "Use LLM compaction when available", + "--reasoning", + "It produces denser summaries while keeping mechanical metadata.", + ]); + expect(decided.success).toBe(true); + + const completed = await runCommand([ + "complete", + "--summary", + "Finished LLM compaction flow", + "--confidence", + "0.91", + ]); + expect(completed.success).toBe(true); + + const result = await runCommand(["compact", "--mechanical"]); + + expect(result.success).toBe(true); + expect(result.output).toContain("Compacted trajectory saved to:"); + + const compactedDir = join(tempDir, ".trajectories", "compacted"); + const compactedFiles = await readdir(compactedDir); + const jsonFile = compactedFiles.find((file) => file.endsWith(".json")); + const markdownFile = compactedFiles.find((file) => file.endsWith(".md")); + + expect(jsonFile).toBeDefined(); + expect(markdownFile).toBeDefined(); + + const compacted = JSON.parse( + await readFile(join(compactedDir, jsonFile ?? ""), "utf-8"), + ) as { + filesAffected?: string[]; + decisionGroups?: unknown[]; + narrative?: string; + }; + + expect(compacted.narrative).toBeUndefined(); + expect(compacted.decisionGroups).toBeDefined(); + expect(compacted.filesAffected).toBeDefined(); + }, + ); +}); + +describe("CLI provider resolution", () => { + let originalEnv: Record; + + beforeEach(() => { + originalEnv = { + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + TRAJECTORIES_LLM_PROVIDER: process.env.TRAJECTORIES_LLM_PROVIDER, + }; + clearCompactionEnv(); + }); + + afterEach(() => { + restoreEnv(originalEnv); + vi.restoreAllMocks(); + }); + + it("resolves API providers before CLI when API keys are present", async () => { + process.env.OPENAI_API_KEY = "sk-test"; + const provider = await resolveProvider({}); + expect(provider).not.toBeNull(); + expect(provider).not.toBeInstanceOf(CLIProvider); + }); + + it("falls back to CLI provider when no API keys are set", async () => { + const provider = await resolveProvider({}); + // Will be CLIProvider if claude/codex is installed, null otherwise + if (provider !== null) { + expect(provider).toBeInstanceOf(CLIProvider); + } + }); + + it("returns CLI provider when explicit provider is 'cli'", async () => { + const provider = await resolveProvider({ provider: "cli" }); + // Will be CLIProvider if a supported CLI is installed, null otherwise + if (provider !== null) { + expect(provider).toBeInstanceOf(CLIProvider); + } + }); + + it("CLIProvider exposes the cli name", () => { + const provider = new CLIProvider("claude", "/usr/local/bin/claude"); + expect(provider.cliName).toBe("claude"); + }); +}); + +function createTrajectory(id = "traj_compact_llm"): Trajectory { + const startedAt = "2026-03-20T10:00:00.000Z"; + const completedAt = "2026-03-20T11:15:00.000Z"; + const decision: Decision = { + question: "Should compact use LLM summaries?", + chosen: "Use LLM output when a provider is configured.", + reasoning: + "It captures denser technical patterns while keeping deterministic metadata from the mechanical pass.", + alternatives: [{ option: "Use only mechanical summaries" }], + confidence: 0.86, + }; + + return { + id, + version: 1, + task: { + title: "Update compact command", + description: + "Add LLM-backed compaction with prompt preview and markdown output.", + }, + status: "completed", + startedAt, + completedAt, + agents: [ + { + name: "Worker", + role: "lead", + joinedAt: startedAt, + }, + ], + chapters: [ + { + id: "chapter_1", + title: "Implementation", + agentName: "Worker", + startedAt, + endedAt: completedAt, + events: [ + { + ts: new Date(startedAt).getTime(), + type: "decision", + content: "Switch compact to an LLM-first flow", + raw: decision, + significance: "high", + }, + { + ts: new Date("2026-03-20T10:30:00.000Z").getTime(), + type: "finding", + content: + "Existing mechanical output still provides accurate files and commits.", + raw: { + what: "Mechanical compaction already computes deterministic metadata.", + where: "src/cli/commands/compact.ts", + significance: "Useful for merge step", + category: "pattern", + }, + significance: "high", + }, + ], + }, + ], + retrospective: { + summary: "The command now supports LLM and mechanical compaction paths.", + approach: + "Build the prompt from serialized trajectories, then merge parsed output with deterministic metadata.", + decisions: [decision], + learnings: ["Keep artifact writing shared across both paths."], + suggestions: [ + "Add a mocked provider test later if CLI coverage expands.", + ], + confidence: 0.88, + timeSpent: "1h 15m", + }, + commits: ["abc1234"], + filesChanged: ["src/cli/commands/compact.ts", "src/compact/provider.ts"], + projectId: "test-project", + tags: ["compact", "llm"], + }; +} + +function clearCompactionEnv(): void { + for (const key of [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "TRAJECTORIES_LLM_PROVIDER", + "TRAJECTORIES_LLM_MODEL", + "TRAJECTORIES_LLM_MAX_INPUT_TOKENS", + "TRAJECTORIES_LLM_MAX_OUTPUT_TOKENS", + "TRAJECTORIES_LLM_TEMPERATURE", + ]) { + delete process.env[key]; + } +} + +function restoreEnv(values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/workflows/llm-compaction.ts b/workflows/llm-compaction.ts new file mode 100644 index 0000000..db600e3 --- /dev/null +++ b/workflows/llm-compaction.ts @@ -0,0 +1,376 @@ +/** + * llm-compaction.ts + * + * Workflow: Replace the mechanical compaction in compact.ts with + * LLM-powered intelligent summarization. + * + * Current state: compactTrajectories() does keyword-based decision grouping + * and string deduplication. No understanding of what actually happened. + * + * Target state: An LLM reads raw trajectory data (chapters, events, decisions, + * findings, retrospectives) and produces: + * 1. A narrative summary of what was accomplished + * 2. Key decisions with real reasoning (not keyword-matched categories) + * 3. Extracted conventions/patterns that should inform future work + * 4. Lessons learned from failures/challenges + * 5. A compact .md file that's actually useful to read + * + * The LLM compaction should work with any provider (OpenAI, Anthropic, local) + * via a simple chat completion interface. + * + * Run: agent-relay run workflows/llm-compaction.ts + */ + +import { workflow } from "@agent-relay/sdk/workflows"; + +const TRAJ_ROOT = process.cwd(); + +async function main() { + const result = await workflow("llm-compaction") + .description( + "Replace mechanical trajectory compaction with LLM-powered intelligent summarization", + ) + .pattern("dag") + .channel("wf-llm-compaction") + .maxConcurrency(4) + .timeout(3_600_000) + + .agent("architect", { + cli: "claude", + role: "Designs the LLM compaction system", + }) + .agent("llm-builder", { + cli: "codex", + preset: "worker", + role: "Builds the LLM compaction engine", + }) + .agent("prompt-builder", { + cli: "codex", + preset: "worker", + role: "Builds prompts and output parsing", + }) + .agent("cli-builder", { + cli: "codex", + preset: "worker", + role: "Updates the CLI compact command", + }) + .agent("reviewer", { cli: "claude", role: "Reviews the implementation" }) + + .step("design-llm-compaction", { + agent: "architect", + task: `Design the LLM-powered trajectory compaction system. + +Read these files: +- ${TRAJ_ROOT}/src/cli/commands/compact.ts (current mechanical compaction — ~400 lines) +- ${TRAJ_ROOT}/src/core/types.ts (Trajectory, Chapter, TrajectoryEvent, Decision, Finding, Retrospective types) +- ${TRAJ_ROOT}/src/core/trajectory.ts (trajectory lifecycle) + +Current problems with compactTrajectories(): +1. Groups decisions by keyword matching ("architecture", "api", "database") — misses nuance +2. Just dedupes learnings as strings — doesn't synthesize +3. Produces a JSON blob — not a readable document +4. No understanding of what was attempted vs what worked +5. No extraction of reusable patterns/conventions + +Design the replacement: + +1. **LLM Provider Interface** (${TRAJ_ROOT}/src/compact/provider.ts): + - CompactionLLM interface: { complete(messages, options): string } + - OpenAIProvider, AnthropicProvider, LocalProvider implementations + - Config from env: TRAJECTORIES_LLM_PROVIDER, TRAJECTORIES_LLM_MODEL, API key + - Fallback: if no LLM configured, use current mechanical compaction + +2. **Trajectory Serializer** (${TRAJ_ROOT}/src/compact/serializer.ts): + - serializeForLLM(trajectories): string — converts raw trajectories to a + structured text format the LLM can read efficiently + - Strips noise (raw tool call data, low-significance events) + - Keeps: decisions, findings, errors, high-significance events, retrospectives + - Budgets tokens: truncate chapters beyond a max (configurable) + - Includes file-level context: "Files changed: src/auth.ts, src/db/schema.ts" + +3. **Compaction Prompts** (${TRAJ_ROOT}/src/compact/prompts.ts): + - COMPACTION_SYSTEM_PROMPT: role definition for the summarizer + - COMPACTION_USER_PROMPT: template with serialized trajectories + - Output format: structured JSON with narrative sections + - Prompt engineering for consistency: "You are reviewing N agent work sessions..." + +4. **Output Parser** (${TRAJ_ROOT}/src/compact/parser.ts): + - Parse LLM JSON response into CompactedTrajectory + - Validate required fields + - Fallback: if LLM returns invalid JSON, extract what we can + +5. **Compacted Output Format** — enhanced from current: + - narrative: string — 2-3 paragraph summary of what happened + - decisions: Array<{ question, chosen, reasoning, impact }> — LLM-analyzed + - conventions: Array<{ pattern, rationale, scope }> — extracted conventions + - lessons: Array<{ lesson, context, recommendation }> — synthesized learnings + - openQuestions: string[] — things left unresolved + - filesAffected: string[] — keep as-is + - commits: string[] — keep as-is + +6. **Markdown Output** (${TRAJ_ROOT}/src/compact/markdown.ts): + - Generate a readable .md file alongside the JSON + - Sections: Summary, Key Decisions, Conventions Established, Lessons Learned, Open Questions + - This is what humans actually read + +Output: interfaces, file structure, prompt outline, token budget strategy. +Keep output under 100 lines. End with DESIGN_COMPACTION_COMPLETE.`, + verification: { + type: "output_contains", + value: "DESIGN_COMPACTION_COMPLETE", + }, + timeout: 300_000, + }) + + .step("create-llm-engine", { + agent: "llm-builder", + dependsOn: ["design-llm-compaction"], + task: `Build the LLM compaction engine. + +Design: {{steps.design-llm-compaction.output}} + +Create in ${TRAJ_ROOT}/src/compact/: + +1. provider.ts — LLM provider interface + implementations: + - CompactionLLM interface: complete(messages: Message[], options?: CompletionOptions): Promise + - Message: { role: 'system' | 'user' | 'assistant', content: string } + - CompletionOptions: { maxTokens?: number, temperature?: number, jsonMode?: boolean } + - OpenAIProvider: uses fetch to POST /v1/chat/completions (no SDK dep) + Env: OPENAI_API_KEY, TRAJECTORIES_LLM_MODEL (default: gpt-4o) + - AnthropicProvider: uses fetch to POST /v1/messages + Env: ANTHROPIC_API_KEY, TRAJECTORIES_LLM_MODEL (default: claude-sonnet-4-20250514) + - resolveProvider(): auto-detect from env vars, fallback to null + - No new npm dependencies — raw fetch only + +2. serializer.ts — Trajectory → LLM-readable text: + - serializeForLLM(trajectories: Trajectory[], maxTokens?: number): string + - For each trajectory: + - Header: "## Session: {title} ({status}, {duration})" + - Agents: who participated and their roles + - Chapters: title + high/medium/critical events only (skip low) + - Decisions: full question + chosen + reasoning + - Findings: what + where + significance + - Retrospective: summary + approach + challenges + learnings + - Files changed + commits + - Token budgeting: estimate ~4 chars per token + If total > maxTokens (default 30000), truncate chapters proportionally + - Skip: raw tool call data, tool results, low-significance events + +3. index.ts — Re-export everything + +End with LLM_ENGINE_COMPLETE.`, + verification: { type: "output_contains", value: "LLM_ENGINE_COMPLETE" }, + timeout: 900_000, + }) + + .step("create-prompts-parser", { + agent: "prompt-builder", + dependsOn: ["design-llm-compaction"], + task: `Build the compaction prompts and output parser. + +Design: {{steps.design-llm-compaction.output}} + +Create in ${TRAJ_ROOT}/src/compact/: + +1. prompts.ts — Compaction prompt templates: + + COMPACTION_SYSTEM_PROMPT: + "You are a technical analyst reviewing agent work sessions (trajectories). + Your job is to produce a concise, insightful summary that captures: + - What was accomplished and how + - Key decisions and their reasoning + - Patterns/conventions established that should be followed in future work + - Lessons learned from challenges and failures + - Open questions or unresolved issues + + Be specific. Reference actual file paths, function names, and technical details. + Don't be generic — this summary replaces the raw data." + + buildCompactionPrompt(serializedTrajectories: string, options?: PromptOptions): Message[] + - Constructs system + user messages + - User message includes the serialized trajectories + - Requests structured JSON output matching CompactedOutput schema + - Includes output schema in the prompt for format guidance + + PromptOptions: { focusAreas?: string[], maxOutputTokens?: number } + +2. parser.ts — Parse LLM response: + - parseCompactionResponse(llmOutput: string): LLMCompactedOutput + - LLMCompactedOutput: { + narrative: string, + decisions: Array<{ question, chosen, reasoning, impact }>, + conventions: Array<{ pattern, rationale, scope }>, + lessons: Array<{ lesson, context, recommendation }>, + openQuestions: string[], + } + - Try JSON.parse first + - If fails: try extracting JSON from markdown code blocks + - If fails: try extracting sections from prose (regex for ## headers) + - Validate: narrative required, decisions/conventions/lessons arrays + - Merge with mechanical data (files, commits, agents) for full CompactedTrajectory + +3. markdown.ts — Generate readable .md: + - generateCompactionMarkdown(compacted: CompactedTrajectory & LLMCompactedOutput): string + - Format: + # Trajectory Compaction: {dateRange} + + ## Summary + {narrative} + + ## Key Decisions ({count}) + | Question | Decision | Impact | + |----------|----------|--------| + + ## Conventions Established + - **{pattern}**: {rationale} (scope: {scope}) + + ## Lessons Learned + - {lesson} — {recommendation} + + ## Open Questions + - {question} + + ## Stats + - Sessions: {count}, Agents: {names}, Files: {count}, Commits: {count} + - Date range: {start} - {end} + +End with PROMPTS_PARSER_COMPLETE.`, + verification: { + type: "output_contains", + value: "PROMPTS_PARSER_COMPLETE", + }, + timeout: 900_000, + }) + + .step("update-cli", { + agent: "cli-builder", + dependsOn: ["create-llm-engine", "create-prompts-parser"], + task: `Update the CLI compact command to use LLM compaction. + +Modify ${TRAJ_ROOT}/src/cli/commands/compact.ts: + +1. Add --llm flag (default: true if LLM provider detected, false otherwise) +2. Add --mechanical flag to force old behavior +3. Add --focus flag: comma-separated focus areas for the LLM +4. Add --markdown flag (default: true): also output .md file + +Updated flow: +a) Load trajectories (existing loadTrajectories — keep as-is) +b) If --mechanical or no LLM provider: use existing compactTrajectories() +c) If LLM available: + 1. serializeForLLM(trajectories) → text + 2. buildCompactionPrompt(text, options) → messages + 3. provider.complete(messages) → llmOutput + 4. parseCompactionResponse(llmOutput) → llmCompacted + 5. Merge with mechanical data (files, commits, agents) + 6. Save JSON to .trajectories/compacted/ + 7. Save .md alongside if --markdown + 8. Print summary + +d) Keep dry-run working with LLM (show prompt + estimated tokens) +e) Show cost estimate: "Estimated: ~{tokens} input tokens, ~{output} output tokens" + +Also create: +- ${TRAJ_ROOT}/src/compact/config.ts — Configuration: + - getCompactionConfig(): reads from env or .trajectories/config.json + - Config: { provider, model, maxInputTokens, maxOutputTokens, temperature } + - Defaults: provider=auto, maxInput=30000, maxOutput=4000, temperature=0.3 + +Add tests: +- ${TRAJ_ROOT}/tests/compact/llm-compact.test.ts + - Test serializer with sample trajectories + - Test parser with sample LLM output + - Test markdown generation + - Test fallback to mechanical when no LLM + +End with CLI_UPDATE_COMPLETE.`, + verification: { type: "output_contains", value: "CLI_UPDATE_COMPLETE" }, + timeout: 900_000, + }) + + .step("review-compaction", { + agent: "reviewer", + dependsOn: ["update-cli"], + task: `Review the LLM compaction system. + +Files: +- ${TRAJ_ROOT}/src/compact/provider.ts +- ${TRAJ_ROOT}/src/compact/serializer.ts +- ${TRAJ_ROOT}/src/compact/prompts.ts +- ${TRAJ_ROOT}/src/compact/parser.ts +- ${TRAJ_ROOT}/src/compact/markdown.ts +- ${TRAJ_ROOT}/src/compact/config.ts +- ${TRAJ_ROOT}/src/compact/index.ts +- ${TRAJ_ROOT}/src/cli/commands/compact.ts (modified) +- ${TRAJ_ROOT}/tests/compact/llm-compact.test.ts + +Verify: +1. No new npm dependencies (raw fetch only for LLM calls) +2. Graceful fallback: no API key → mechanical compaction +3. Token budgeting prevents exceeding model context window +4. Parser handles malformed LLM output without crashing +5. Prompt is specific enough to get useful output, not generic summaries +6. Markdown output is clean and readable +7. Dry-run shows prompt + cost estimate without calling LLM +8. Config can be set via env vars OR .trajectories/config.json +9. Existing mechanical compaction still works with --mechanical flag +10. Tests cover serializer, parser, markdown, and fallback + +Fix issues. Keep output under 50 lines. End with COMPACTION_REVIEW_COMPLETE.`, + verification: { + type: "output_contains", + value: "COMPACTION_REVIEW_COMPLETE", + }, + timeout: 300_000, + }) + + .step("commit", { + agent: "llm-builder", + dependsOn: ["review-compaction"], + task: `In ${TRAJ_ROOT}: +1. git checkout -b feat/llm-compaction +2. git add src/compact/ src/cli/commands/compact.ts tests/compact/ +3. git commit -m "feat: LLM-powered trajectory compaction + +Replaces mechanical keyword-based compaction with intelligent LLM summarization. + +New compact/ module: + - provider.ts: OpenAI + Anthropic providers (raw fetch, no deps) + - serializer.ts: trajectory → LLM-readable text with token budgeting + - prompts.ts: system + user prompts for compaction + - parser.ts: parse LLM JSON output with fallbacks + - markdown.ts: generate readable .md summaries + - config.ts: env vars or .trajectories/config.json + +CLI updates: + - trail compact now uses LLM by default (if API key present) + - --mechanical flag for old behavior + - --focus for targeted summaries + - --markdown flag (default: true) for .md output + - Dry-run shows prompt + cost estimate + +Output includes: + - Narrative summary (what happened, how) + - Key decisions with reasoning and impact + - Extracted conventions/patterns for future work + - Synthesized lessons from challenges + - Open questions / unresolved issues + +Backwards compatible: falls back to mechanical if no LLM provider." +4. git push origin feat/llm-compaction + +Report commit hash. End with COMMIT_COMPLETE.`, + verification: { type: "output_contains", value: "COMMIT_COMPLETE" }, + timeout: 120_000, + }) + + .onError("retry", { maxRetries: 1, retryDelayMs: 10_000 }) + .run({ cwd: process.cwd() }); + + console.log("LLM compaction complete:", result.status); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});