mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: Enhance CLI output handling with structured Intermediate Representation (IR)
- Introduced `CliOutputUnit` and `IOutputParser` interfaces for unified output processing. - Implemented `PlainTextParser` and `JsonLinesParser` for parsing raw CLI output into structured units. - Updated `executeCliTool` to utilize output parsers and handle structured output. - Added `flattenOutputUnits` utility for extracting clean output from structured data. - Enhanced `ConversationTurn` and `ExecutionRecord` interfaces to include structured output. - Created comprehensive documentation for CLI Output Converter usage and integration. - Improved error handling and type mapping for various output formats.
This commit is contained in:
@@ -13,6 +13,7 @@ export interface CliToolConfig {
|
||||
enabled: boolean;
|
||||
primaryModel: string; // For CLI endpoint calls (ccw cli -p)
|
||||
secondaryModel: string; // For internal calls (llm_enhancer, generate_module_docs)
|
||||
tags?: string[]; // User-defined tags/labels for the tool
|
||||
}
|
||||
|
||||
export interface CliConfig {
|
||||
@@ -204,7 +205,8 @@ export function updateToolConfig(
|
||||
const updatedToolConfig: CliToolConfig = {
|
||||
enabled: updates.enabled !== undefined ? updates.enabled : currentToolConfig.enabled,
|
||||
primaryModel: updates.primaryModel || currentToolConfig.primaryModel,
|
||||
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel
|
||||
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel,
|
||||
tags: updates.tags !== undefined ? updates.tags : currentToolConfig.tags
|
||||
};
|
||||
|
||||
// Save updated config
|
||||
|
||||
@@ -10,6 +10,12 @@ import { validatePath } from '../utils/path-resolver.js';
|
||||
import { escapeWindowsArg } from '../utils/shell-escape.js';
|
||||
import { buildCommand, checkToolAvailability, clearToolCache, debugLog, errorLog, type NativeResumeConfig, type ToolAvailability } from './cli-executor-utils.js';
|
||||
import type { ConversationRecord, ConversationTurn, ExecutionOutput, ExecutionRecord } from './cli-executor-state.js';
|
||||
import {
|
||||
createOutputParser,
|
||||
type CliOutputUnit,
|
||||
type IOutputParser,
|
||||
flattenOutputUnits
|
||||
} from './cli-output-converter.js';
|
||||
import {
|
||||
buildMergedPrompt,
|
||||
buildMultiTurnPrompt,
|
||||
@@ -110,6 +116,7 @@ const ParamsSchema = z.object({
|
||||
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
|
||||
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
|
||||
stream: z.boolean().default(false), // false = cache full output (default), true = stream output via callback
|
||||
outputFormat: z.enum(['text', 'json-lines']).optional().default('json-lines'), // Output parsing format (default: json-lines for type badges)
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
@@ -127,14 +134,14 @@ function assertNonEmptyArray<T>(items: T[], message: string): asserts items is N
|
||||
*/
|
||||
async function executeCliTool(
|
||||
params: Record<string, unknown>,
|
||||
onOutput?: ((data: { type: string; data: string }) => void) | null
|
||||
onOutput?: ((unit: CliOutputUnit) => void) | null
|
||||
): Promise<ExecutionOutput> {
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category, parentExecutionId } = parsed.data;
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
|
||||
|
||||
// Validate and determine working directory early (needed for conversation lookup)
|
||||
let workingDir: string;
|
||||
@@ -155,7 +162,11 @@ async function executeCliTool(
|
||||
if (endpoint) {
|
||||
// Route to LiteLLM executor
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[Routing to LiteLLM endpoint: ${model}]\n` });
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
content: `[Routing to LiteLLM endpoint: ${model}]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeLiteLLMEndpoint({
|
||||
@@ -363,7 +374,11 @@ async function executeCliTool(
|
||||
if (resumeDecision) {
|
||||
const modeDesc = getResumeModeDescription(resumeDecision);
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[Resume mode: ${modeDesc}]\n` });
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
content: `[Resume mode: ${modeDesc}]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +396,10 @@ async function executeCliTool(
|
||||
nativeResume: nativeResumeConfig
|
||||
});
|
||||
|
||||
// Create output parser and IR storage
|
||||
const parser = createOutputParser(outputFormat);
|
||||
const allOutputUnits: CliOutputUnit[] = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
debugLog('EXEC', `Starting CLI execution`, {
|
||||
@@ -390,7 +409,8 @@ async function executeCliTool(
|
||||
conversationId,
|
||||
promptLength: finalPrompt.length,
|
||||
hasResume: !!resume,
|
||||
hasCustomId: !!customId
|
||||
hasCustomId: !!customId,
|
||||
outputFormat
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -436,20 +456,36 @@ async function executeCliTool(
|
||||
let timedOut = false;
|
||||
|
||||
// Handle stdout
|
||||
child.stdout!.on('data', (data) => {
|
||||
child.stdout!.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
|
||||
// Parse into IR units
|
||||
const units = parser.parse(data, 'stdout');
|
||||
allOutputUnits.push(...units);
|
||||
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stdout', data: text });
|
||||
// Send each IR unit to callback
|
||||
for (const unit of units) {
|
||||
onOutput(unit);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
child.stderr!.on('data', (data) => {
|
||||
child.stderr!.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
|
||||
// Parse into IR units
|
||||
const units = parser.parse(data, 'stderr');
|
||||
allOutputUnits.push(...units);
|
||||
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: text });
|
||||
// Send each IR unit to callback
|
||||
for (const unit of units) {
|
||||
onOutput(unit);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -464,6 +500,15 @@ async function executeCliTool(
|
||||
// Clear current child process reference
|
||||
currentChildProcess = null;
|
||||
|
||||
// Flush remaining buffer from parser
|
||||
const remainingUnits = parser.flush();
|
||||
allOutputUnits.push(...remainingUnits);
|
||||
if (onOutput) {
|
||||
for (const unit of remainingUnits) {
|
||||
onOutput(unit);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
@@ -472,7 +517,8 @@ async function executeCliTool(
|
||||
duration: `${duration}ms`,
|
||||
timedOut,
|
||||
stdoutLength: stdout.length,
|
||||
stderrLength: stderr.length
|
||||
stderrLength: stderr.length,
|
||||
outputUnitsCount: allOutputUnits.length
|
||||
});
|
||||
|
||||
// Determine status - prioritize output content over exit code
|
||||
@@ -524,7 +570,8 @@ async function executeCliTool(
|
||||
truncated: stdout.length > 10240 || stderr.length > 2048,
|
||||
cached: shouldCache,
|
||||
stdout_full: shouldCache ? stdout : undefined,
|
||||
stderr_full: shouldCache ? stderr : undefined
|
||||
stderr_full: shouldCache ? stderr : undefined,
|
||||
structured: allOutputUnits // Save structured IR units
|
||||
};
|
||||
|
||||
// Determine base turn number for merge scenarios
|
||||
@@ -677,13 +724,16 @@ async function executeCliTool(
|
||||
id: conversationId,
|
||||
timestamp: new Date(startTime).toISOString(),
|
||||
tool,
|
||||
model: model || 'default',
|
||||
model: effectiveModel || 'default',
|
||||
mode,
|
||||
prompt,
|
||||
status,
|
||||
exit_code: code,
|
||||
duration_ms: duration,
|
||||
output: newTurnOutput
|
||||
output: newTurnOutput,
|
||||
parsedOutput: flattenOutputUnits(allOutputUnits, {
|
||||
excludeTypes: ['stderr', 'progress', 'metadata', 'system']
|
||||
})
|
||||
};
|
||||
|
||||
resolve({
|
||||
@@ -691,7 +741,8 @@ async function executeCliTool(
|
||||
execution,
|
||||
conversation,
|
||||
stdout,
|
||||
stderr
|
||||
stderr,
|
||||
parsedOutput: execution.parsedOutput
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { HistoryIndexEntry } from './cli-history-store.js';
|
||||
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
|
||||
import type { CliOutputUnit } from './cli-output-converter.js';
|
||||
|
||||
// Lazy-loaded SQLite store module
|
||||
let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;
|
||||
@@ -44,6 +45,10 @@ export interface ConversationTurn {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
truncated: boolean;
|
||||
cached?: boolean;
|
||||
stdout_full?: string;
|
||||
stderr_full?: string;
|
||||
structured?: CliOutputUnit[]; // Structured IR sequence for advanced parsing
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +84,7 @@ export interface ExecutionRecord {
|
||||
stderr: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
parsedOutput?: string; // Extracted clean text from structured output units
|
||||
}
|
||||
|
||||
interface HistoryIndex {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync,
|
||||
import { join } from 'path';
|
||||
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
|
||||
import { StoragePaths, ensureStorageDir, getProjectId } from '../config/storage-paths.js';
|
||||
import type { CliOutputUnit } from './cli-output-converter.js';
|
||||
|
||||
// Types
|
||||
export interface ConversationTurn {
|
||||
@@ -26,6 +27,7 @@ export interface ConversationTurn {
|
||||
cached?: boolean;
|
||||
stdout_full?: string;
|
||||
stderr_full?: string;
|
||||
structured?: CliOutputUnit[]; // Structured IR sequence for advanced parsing
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
474
ccw/src/tools/cli-output-converter.ts
Normal file
474
ccw/src/tools/cli-output-converter.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* CLI Output Converter
|
||||
* Converts raw CLI tool output into structured Intermediate Representation (IR)
|
||||
*
|
||||
* Purpose: Decouple output parsing from consumption scenarios (View, Storage, Resume)
|
||||
* Supports: Plain text, JSON Lines, and other structured formats
|
||||
*/
|
||||
|
||||
// ========== Type Definitions ==========
|
||||
|
||||
/**
|
||||
* Unified output unit types for the intermediate representation layer
|
||||
*/
|
||||
export type CliOutputUnitType =
|
||||
| 'stdout' // Standard output text
|
||||
| 'stderr' // Standard error text
|
||||
| 'thought' // AI reasoning/thinking
|
||||
| 'code' // Code block content
|
||||
| 'file_diff' // File modification diff
|
||||
| 'progress' // Progress updates
|
||||
| 'metadata' // Session/execution metadata
|
||||
| 'system'; // System events/messages
|
||||
|
||||
/**
|
||||
* Intermediate Representation unit
|
||||
* Common structure for all CLI output chunks
|
||||
*/
|
||||
export interface CliOutputUnit<T = any> {
|
||||
type: CliOutputUnitType;
|
||||
content: T;
|
||||
timestamp: string; // ISO 8601 format
|
||||
}
|
||||
|
||||
// ========== Parser Interface ==========
|
||||
|
||||
/**
|
||||
* Parser interface for converting raw output into IR
|
||||
*/
|
||||
export interface IOutputParser {
|
||||
/**
|
||||
* Parse a chunk of data from stdout/stderr stream
|
||||
* @param chunk - Raw buffer from stream
|
||||
* @param streamType - Source stream (stdout or stderr)
|
||||
* @returns Array of parsed output units
|
||||
*/
|
||||
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[];
|
||||
|
||||
/**
|
||||
* Flush any remaining buffered data
|
||||
* Called when stream ends to ensure no data is lost
|
||||
* @returns Array of remaining output units
|
||||
*/
|
||||
flush(): CliOutputUnit[];
|
||||
}
|
||||
|
||||
// ========== Plain Text Parser ==========
|
||||
|
||||
/**
|
||||
* PlainTextParser - Converts plain text output to IR
|
||||
* Simply wraps text in appropriate type envelope
|
||||
*/
|
||||
export class PlainTextParser implements IOutputParser {
|
||||
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[] {
|
||||
const text = chunk.toString('utf8');
|
||||
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
type: streamType,
|
||||
content: text,
|
||||
timestamp: new Date().toISOString()
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any remaining buffered data
|
||||
* Called when stream ends to ensure no data is lost
|
||||
*
|
||||
* Note: PlainTextParser does not buffer data internally, so this method
|
||||
* always returns an empty array. Other parsers (e.g., JsonLinesParser)
|
||||
* may have buffered incomplete lines that need to be flushed.
|
||||
*
|
||||
* @returns Array of remaining output units (always empty for PlainTextParser)
|
||||
*/
|
||||
flush(): CliOutputUnit[] {
|
||||
// Plain text parser has no internal buffer
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== JSON Lines Parser ==========
|
||||
|
||||
/**
|
||||
* JsonLinesParser - Parses newline-delimited JSON output
|
||||
*
|
||||
* Features:
|
||||
* - Handles incomplete lines across chunks
|
||||
* - Maps JSON events to appropriate IR types
|
||||
* - Falls back to stdout for unparseable lines
|
||||
* - Robust error handling for malformed JSON
|
||||
*/
|
||||
export class JsonLinesParser implements IOutputParser {
|
||||
private buffer: string = '';
|
||||
|
||||
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[] {
|
||||
const text = chunk.toString('utf8');
|
||||
this.buffer += text;
|
||||
|
||||
const units: CliOutputUnit[] = [];
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// Keep the last incomplete line in buffer
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Not valid JSON, treat as plain text
|
||||
units.push({
|
||||
type: streamType,
|
||||
content: line,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map JSON structure to IR type
|
||||
const unit = this.mapJsonToIR(parsed, streamType);
|
||||
if (unit) {
|
||||
units.push(unit);
|
||||
}
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
flush(): CliOutputUnit[] {
|
||||
const units: CliOutputUnit[] = [];
|
||||
|
||||
if (this.buffer.trim()) {
|
||||
// Try to parse remaining buffer
|
||||
try {
|
||||
const parsed = JSON.parse(this.buffer.trim());
|
||||
const unit = this.mapJsonToIR(parsed, 'stdout');
|
||||
if (unit) {
|
||||
units.push(unit);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, return as plain text
|
||||
units.push({
|
||||
type: 'stdout',
|
||||
content: this.buffer,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.buffer = '';
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map parsed JSON object to appropriate IR type
|
||||
* Handles various JSON event formats from different CLI tools
|
||||
*/
|
||||
private mapJsonToIR(json: any, fallbackStreamType: 'stdout' | 'stderr'): CliOutputUnit | null {
|
||||
const timestamp = json.timestamp || new Date().toISOString();
|
||||
|
||||
// Detect type from JSON structure
|
||||
if (json.type) {
|
||||
switch (json.type) {
|
||||
case 'thought':
|
||||
case 'thinking':
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'thought',
|
||||
content: json.content || json.text || json.message,
|
||||
timestamp
|
||||
};
|
||||
|
||||
case 'code':
|
||||
case 'code_block':
|
||||
return {
|
||||
type: 'code',
|
||||
content: json.content || json.code,
|
||||
timestamp
|
||||
};
|
||||
|
||||
case 'diff':
|
||||
case 'file_diff':
|
||||
case 'file_change':
|
||||
return {
|
||||
type: 'file_diff',
|
||||
content: {
|
||||
path: json.path || json.file,
|
||||
diff: json.diff || json.content,
|
||||
action: json.action || 'modify'
|
||||
},
|
||||
timestamp
|
||||
};
|
||||
|
||||
case 'progress':
|
||||
case 'status':
|
||||
return {
|
||||
type: 'progress',
|
||||
content: {
|
||||
message: json.message || json.content,
|
||||
progress: json.progress,
|
||||
total: json.total
|
||||
},
|
||||
timestamp
|
||||
};
|
||||
|
||||
case 'metadata':
|
||||
case 'session_meta':
|
||||
return {
|
||||
type: 'metadata',
|
||||
content: json.payload || json.data || json,
|
||||
timestamp
|
||||
};
|
||||
|
||||
case 'system':
|
||||
case 'event':
|
||||
return {
|
||||
type: 'system',
|
||||
content: json.message || json.content || json,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Codex JSONL format
|
||||
if (json.type === 'response_item' && json.payload) {
|
||||
const payloadType = json.payload.type;
|
||||
|
||||
if (payloadType === 'message') {
|
||||
// User or assistant message
|
||||
const content = json.payload.content
|
||||
?.map((c: any) => c.text || '')
|
||||
.filter((t: string) => t)
|
||||
.join('\n') || '';
|
||||
|
||||
return {
|
||||
type: 'stdout',
|
||||
content,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
if (payloadType === 'reasoning') {
|
||||
return {
|
||||
type: 'thought',
|
||||
content: json.payload.summary || json.payload.content,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
if (payloadType === 'function_call' || payloadType === 'function_call_output') {
|
||||
return {
|
||||
type: 'system',
|
||||
content: json.payload,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Gemini/Qwen message format
|
||||
if (json.role === 'user' || json.role === 'assistant') {
|
||||
return {
|
||||
type: 'stdout',
|
||||
content: json.content || json.text || '',
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
if (json.thoughts && Array.isArray(json.thoughts)) {
|
||||
return {
|
||||
type: 'thought',
|
||||
content: json.thoughts.map((t: any) =>
|
||||
typeof t === 'string' ? t : `${t.subject}: ${t.description}`
|
||||
).join('\n'),
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
// Default: treat as stdout/stderr based on fallback
|
||||
if (json.content || json.message || json.text) {
|
||||
return {
|
||||
type: fallbackStreamType,
|
||||
content: json.content || json.message || json.text,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
// Unrecognized structure, return as metadata
|
||||
return {
|
||||
type: 'metadata',
|
||||
content: json,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Factory Function ==========
|
||||
|
||||
/**
|
||||
* Create an output parser instance based on format
|
||||
* @param format - Output format type
|
||||
* @returns Parser instance
|
||||
*/
|
||||
export function createOutputParser(format: 'text' | 'json-lines'): IOutputParser {
|
||||
switch (format) {
|
||||
case 'json-lines':
|
||||
return new JsonLinesParser();
|
||||
case 'text':
|
||||
default:
|
||||
return new PlainTextParser();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility Functions ==========
|
||||
|
||||
/**
|
||||
* Flatten output units into plain text string
|
||||
* Useful for Resume scenario where we need concatenated context
|
||||
*
|
||||
* @param units - Array of output units to flatten
|
||||
* @param options - Filtering and formatting options
|
||||
* @returns Concatenated text content
|
||||
*/
|
||||
export function flattenOutputUnits(
|
||||
units: CliOutputUnit[],
|
||||
options?: {
|
||||
includeTypes?: CliOutputUnitType[];
|
||||
excludeTypes?: CliOutputUnitType[];
|
||||
includeTimestamps?: boolean;
|
||||
separator?: string;
|
||||
}
|
||||
): string {
|
||||
const {
|
||||
includeTypes,
|
||||
excludeTypes,
|
||||
includeTimestamps = false,
|
||||
separator = '\n'
|
||||
} = options || {};
|
||||
|
||||
// Filter units by type
|
||||
let filtered = units;
|
||||
if (includeTypes && includeTypes.length > 0) {
|
||||
filtered = filtered.filter(u => includeTypes.includes(u.type));
|
||||
}
|
||||
if (excludeTypes && excludeTypes.length > 0) {
|
||||
filtered = filtered.filter(u => !excludeTypes.includes(u.type));
|
||||
}
|
||||
|
||||
// Convert to text
|
||||
const lines = filtered.map(unit => {
|
||||
let text = '';
|
||||
|
||||
if (includeTimestamps) {
|
||||
text += `[${unit.timestamp}] `;
|
||||
}
|
||||
|
||||
// Extract text content based on type
|
||||
if (typeof unit.content === 'string') {
|
||||
text += unit.content;
|
||||
} else if (typeof unit.content === 'object' && unit.content !== null) {
|
||||
// Handle structured content with type-specific formatting
|
||||
switch (unit.type) {
|
||||
case 'file_diff':
|
||||
// Format file diff with path and diff content
|
||||
text += `File: ${unit.content.path}\n\`\`\`diff\n${unit.content.diff}\n\`\`\``;
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
// Format code block with language
|
||||
const lang = unit.content.language || '';
|
||||
const code = unit.content.code || unit.content;
|
||||
text += `\`\`\`${lang}\n${typeof code === 'string' ? code : JSON.stringify(code)}\n\`\`\``;
|
||||
break;
|
||||
|
||||
case 'thought':
|
||||
// Format thought/reasoning content
|
||||
text += `[Thought] ${typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content)}`;
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
// Format progress updates
|
||||
if (unit.content.message) {
|
||||
text += unit.content.message;
|
||||
if (unit.content.progress !== undefined && unit.content.total !== undefined) {
|
||||
text += ` (${unit.content.progress}/${unit.content.total})`;
|
||||
}
|
||||
} else {
|
||||
text += JSON.stringify(unit.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'metadata':
|
||||
case 'system':
|
||||
// Metadata and system events are typically excluded from prompt context
|
||||
// Include minimal representation if they passed filtering
|
||||
text += JSON.stringify(unit.content);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Fallback for unknown structured types
|
||||
text += JSON.stringify(unit.content);
|
||||
}
|
||||
} else {
|
||||
text += String(unit.content);
|
||||
}
|
||||
|
||||
return text;
|
||||
});
|
||||
|
||||
return lines.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract specific content type from units
|
||||
* Convenience helper for common extraction patterns
|
||||
*/
|
||||
export function extractContent(
|
||||
units: CliOutputUnit[],
|
||||
type: CliOutputUnitType
|
||||
): string[] {
|
||||
return units
|
||||
.filter(u => u.type === type)
|
||||
.map(u => typeof u.content === 'string' ? u.content : JSON.stringify(u.content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about output units
|
||||
* Useful for debugging and analytics
|
||||
*/
|
||||
export function getOutputStats(units: CliOutputUnit[]): {
|
||||
total: number;
|
||||
byType: Record<CliOutputUnitType, number>;
|
||||
firstTimestamp?: string;
|
||||
lastTimestamp?: string;
|
||||
} {
|
||||
const byType: Record<string, number> = {};
|
||||
let firstTimestamp: string | undefined;
|
||||
let lastTimestamp: string | undefined;
|
||||
|
||||
for (const unit of units) {
|
||||
byType[unit.type] = (byType[unit.type] || 0) + 1;
|
||||
|
||||
if (!firstTimestamp || unit.timestamp < firstTimestamp) {
|
||||
firstTimestamp = unit.timestamp;
|
||||
}
|
||||
if (!lastTimestamp || unit.timestamp > lastTimestamp) {
|
||||
lastTimestamp = unit.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: units.length,
|
||||
byType: byType as Record<CliOutputUnitType, number>,
|
||||
firstTimestamp,
|
||||
lastTimestamp
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,59 @@
|
||||
*/
|
||||
|
||||
import type { ConversationRecord, ConversationTurn } from './cli-executor-state.js';
|
||||
import { flattenOutputUnits, type CliOutputUnit, type CliOutputUnitType } from './cli-output-converter.js';
|
||||
|
||||
// Prompt concatenation format types
|
||||
export type PromptFormat = 'plain' | 'yaml' | 'json';
|
||||
|
||||
/**
|
||||
* Extract clean AI output content from ConversationTurn
|
||||
* Prioritizes structured IR data, falls back to raw stdout
|
||||
*
|
||||
* This function performs noise filtering to extract only meaningful content
|
||||
* for use in prompt context, excluding progress updates, metadata, and system events.
|
||||
*
|
||||
* @param turn - Conversation turn containing output
|
||||
* @param options - Extraction options
|
||||
* @param options.includeThoughts - Whether to include AI reasoning/thinking in output (default: false)
|
||||
* @returns Clean output string suitable for prompt context
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const cleanOutput = extractCleanOutput(turn, { includeThoughts: true });
|
||||
* // Returns: stdout + code + file_diff + thought (excludes: progress, metadata, system)
|
||||
* ```
|
||||
*/
|
||||
function extractCleanOutput(
|
||||
turn: ConversationTurn,
|
||||
options?: {
|
||||
includeThoughts?: boolean;
|
||||
}
|
||||
): string {
|
||||
// Priority 1: Use structured IR if available (clean, noise-filtered)
|
||||
if (turn.output?.structured && turn.output.structured.length > 0) {
|
||||
const includeTypes: CliOutputUnitType[] = ['stdout', 'code', 'file_diff'];
|
||||
|
||||
// Optionally include thought processes
|
||||
if (options?.includeThoughts) {
|
||||
includeTypes.push('thought');
|
||||
}
|
||||
|
||||
return flattenOutputUnits(turn.output.structured, {
|
||||
includeTypes,
|
||||
excludeTypes: ['progress', 'metadata', 'system']
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 2: Use full output if available
|
||||
if (turn.output?.stdout_full) {
|
||||
return turn.output.stdout_full;
|
||||
}
|
||||
|
||||
// Priority 3: Fall back to truncated stdout
|
||||
return turn.output?.stdout || '[No output]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple conversations into a unified context
|
||||
* Returns merged turns sorted by timestamp with source tracking
|
||||
@@ -166,7 +215,11 @@ export class PromptConcatenator {
|
||||
timestamp: turn.timestamp,
|
||||
source_id: sourceId
|
||||
});
|
||||
this.addAssistantTurn(turn.output.stdout || '[No output]', {
|
||||
|
||||
// Use extractCleanOutput to get noise-filtered content
|
||||
const cleanOutput = extractCleanOutput(turn);
|
||||
|
||||
this.addAssistantTurn(cleanOutput, {
|
||||
turn: turn.turn * 2,
|
||||
timestamp: turn.timestamp,
|
||||
status: turn.status,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getProviderWithResolvedEnvVars,
|
||||
} from '../config/litellm-api-config-manager.js';
|
||||
import type { CustomEndpoint, ProviderCredential } from '../types/litellm-api-config.js';
|
||||
import type { CliOutputUnit } from './cli-output-converter.js';
|
||||
|
||||
export interface LiteLLMExecutionOptions {
|
||||
prompt: string;
|
||||
@@ -18,7 +19,7 @@ export interface LiteLLMExecutionOptions {
|
||||
cwd?: string; // Working directory for file resolution
|
||||
includeDirs?: string[]; // Additional directories for @patterns
|
||||
enableCache?: boolean; // Override endpoint cache setting
|
||||
onOutput?: (data: { type: string; data: string }) => void;
|
||||
onOutput?: (unit: CliOutputUnit) => void;
|
||||
/** Number of retries after the initial attempt (default: 0) */
|
||||
maxRetries?: number;
|
||||
/** Base delay for exponential backoff in milliseconds (default: 1000) */
|
||||
@@ -105,7 +106,11 @@ export async function executeLiteLLMEndpoint(
|
||||
const patterns = extractPatterns(prompt);
|
||||
if (patterns.length > 0) {
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[Context cache: Found ${patterns.length} @patterns]\n` });
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
content: `[Context cache: Found ${patterns.length} @patterns]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Pack files into cache
|
||||
@@ -124,7 +129,8 @@ export async function executeLiteLLMEndpoint(
|
||||
if (onOutput) {
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
data: `[Context cache: Packed ${pack.files_packed} files, ${pack.total_bytes} bytes]\n`,
|
||||
content: `[Context cache: Packed ${pack.files_packed} files, ${pack.total_bytes} bytes]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,12 +149,20 @@ export async function executeLiteLLMEndpoint(
|
||||
cachedFiles = pack.files_packed ? Array(pack.files_packed).fill('...') : [];
|
||||
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[Context cache: Applied to prompt]\n` });
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
content: `[Context cache: Applied to prompt]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (packResult.error) {
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[Context cache warning: ${packResult.error}]\n` });
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
content: `[Context cache warning: ${packResult.error}]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +173,8 @@ export async function executeLiteLLMEndpoint(
|
||||
if (onOutput) {
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
data: `[LiteLLM: Calling ${provider.type}/${endpoint.model}]\n`,
|
||||
content: `[LiteLLM: Calling ${provider.type}/${endpoint.model}]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,7 +210,11 @@ export async function executeLiteLLMEndpoint(
|
||||
);
|
||||
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stdout', data: response });
|
||||
onOutput({
|
||||
type: 'stdout',
|
||||
content: response,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -209,7 +228,11 @@ export async function executeLiteLLMEndpoint(
|
||||
} catch (error) {
|
||||
const errorMsg = (error as Error).message;
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[LiteLLM error: ${errorMsg}]\n` });
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
content: `[LiteLLM error: ${errorMsg}]\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -279,7 +302,7 @@ async function callWithRetries(
|
||||
options: {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
onOutput?: (data: { type: string; data: string }) => void;
|
||||
onOutput?: (unit: CliOutputUnit) => void;
|
||||
rateLimitKey: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
@@ -301,7 +324,8 @@ async function callWithRetries(
|
||||
if (onOutput) {
|
||||
onOutput({
|
||||
type: 'stderr',
|
||||
data: `[LiteLLM retry ${attempt + 1}/${maxRetries}: waiting ${delayMs}ms] ${errorMessage}\n`,
|
||||
content: `[LiteLLM retry ${attempt + 1}/${maxRetries}: waiting ${delayMs}ms] ${errorMessage}\n`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user