feat(cli-executor): add streaming option and enhance output handling

- Introduced a `stream` parameter to control output streaming vs. caching.
- Enhanced status determination logic to prioritize valid output over exit codes.
- Updated output structure to include full stdout and stderr when not streaming.

feat(cli-history-store): extend conversation turn schema and migration

- Added `cached`, `stdout_full`, and `stderr_full` fields to the conversation turn schema.
- Implemented database migration to add new columns if they do not exist.
- Updated upsert logic to handle new fields.

feat(codex-lens): implement global symbol index for fast lookups

- Created `GlobalSymbolIndex` class to manage project-wide symbol indexing.
- Added methods for adding, updating, and deleting symbols in the global index.
- Integrated global index updates into directory indexing processes.

feat(codex-lens): optimize search functionality with global index

- Enhanced `ChainSearchEngine` to utilize the global symbol index for faster searches.
- Added configuration option to enable/disable global symbol indexing.
- Updated tests to validate global index functionality and performance.
This commit is contained in:
catlog22
2025-12-25 22:22:31 +08:00
parent 673e1d117a
commit 3b842ed290
13 changed files with 1032 additions and 37 deletions

View File

@@ -174,7 +174,7 @@ export function run(argv: string[]): void {
.option('--cd <path>', 'Working directory')
.option('--includeDirs <dirs>', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex/claude)')
.option('--timeout <ms>', 'Timeout in milliseconds', '300000')
.option('--no-stream', 'Disable streaming output')
.option('--stream', 'Enable streaming output (default: non-streaming with caching)')
.option('--limit <n>', 'History limit')
.option('--status <status>', 'Filter by status')
.option('--category <category>', 'Execution category: user, internal, insight', 'user')
@@ -190,6 +190,11 @@ export function run(argv: string[]): void {
.option('--memory', 'Target memory storage')
.option('--storage-cache', 'Target cache storage')
.option('--config', 'Target config storage')
// Cache subcommand options
.option('--offset <n>', 'Character offset for cache pagination', '0')
.option('--output-type <type>', 'Output type: stdout, stderr, both', 'both')
.option('--turn <n>', 'Turn number for cache (default: latest)')
.option('--raw', 'Raw output only (no formatting)')
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
// Memory command

View File

@@ -24,6 +24,7 @@ import {
projectExists,
getStorageLocationInstructions
} from '../tools/storage-manager.js';
import { getHistoryStore } from '../tools/cli-history-store.js';
// Dashboard notification settings
const DASHBOARD_PORT = process.env.CCW_PORT || 3456;
@@ -74,7 +75,7 @@ interface CliExecOptions {
cd?: string;
includeDirs?: string;
timeout?: string;
noStream?: boolean;
stream?: boolean; // Enable streaming (default: false, caches output)
resume?: string | boolean; // true = last, string = execution ID, comma-separated for merge
id?: string; // Custom execution ID (e.g., IMPL-001-step1)
noNative?: boolean; // Force prompt concatenation instead of native resume
@@ -104,6 +105,14 @@ interface StorageOptions {
force?: boolean;
}
interface OutputViewOptions {
offset?: string;
limit?: string;
outputType?: 'stdout' | 'stderr' | 'both';
turn?: string;
raw?: boolean;
}
/**
* Show storage information and management options
*/
@@ -287,6 +296,71 @@ function showStorageHelp(): void {
console.log();
}
/**
* Show cached output for a conversation with pagination
*/
async function outputAction(conversationId: string | undefined, options: OutputViewOptions): Promise<void> {
if (!conversationId) {
console.error(chalk.red('Error: Conversation ID is required'));
console.error(chalk.gray('Usage: ccw cli output <conversation-id> [--offset N] [--limit N]'));
process.exit(1);
}
const store = getHistoryStore(process.cwd());
const result = store.getCachedOutput(
conversationId,
options.turn ? parseInt(options.turn) : undefined,
{
offset: parseInt(options.offset || '0'),
limit: parseInt(options.limit || '10000'),
outputType: options.outputType || 'both'
}
);
if (!result) {
console.error(chalk.red(`Error: Execution not found: ${conversationId}`));
process.exit(1);
}
if (options.raw) {
// Raw output only (for piping)
if (result.stdout) console.log(result.stdout.content);
return;
}
// Formatted output
console.log(chalk.bold.cyan('Execution Output\n'));
console.log(` ${chalk.gray('ID:')} ${result.conversationId}`);
console.log(` ${chalk.gray('Turn:')} ${result.turnNumber}`);
console.log(` ${chalk.gray('Cached:')} ${result.cached ? chalk.green('Yes') : chalk.yellow('No')}`);
console.log(` ${chalk.gray('Status:')} ${result.status}`);
console.log(` ${chalk.gray('Time:')} ${result.timestamp}`);
console.log();
if (result.stdout) {
console.log(` ${chalk.gray('Stdout:')} (${result.stdout.totalBytes} bytes, offset ${result.stdout.offset})`);
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(result.stdout.content);
console.log(chalk.gray(' ' + '-'.repeat(60)));
if (result.stdout.hasMore) {
console.log(chalk.yellow(` ... ${result.stdout.totalBytes - result.stdout.offset - result.stdout.content.length} more bytes available`));
console.log(chalk.gray(` Use --offset ${result.stdout.offset + result.stdout.content.length} to continue`));
}
console.log();
}
if (result.stderr && result.stderr.content) {
console.log(` ${chalk.gray('Stderr:')} (${result.stderr.totalBytes} bytes, offset ${result.stderr.offset})`);
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(result.stderr.content);
console.log(chalk.gray(' ' + '-'.repeat(60)));
if (result.stderr.hasMore) {
console.log(chalk.yellow(` ... ${result.stderr.totalBytes - result.stderr.offset - result.stderr.content.length} more bytes available`));
}
console.log();
}
}
/**
* Test endpoint for debugging multi-line prompt parsing
* Shows exactly how Commander.js parsed the arguments
@@ -391,7 +465,7 @@ async function statusAction(): Promise<void> {
* @param {Object} options - CLI options
*/
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream, resume, id, noNative, cache, injectMode } = options;
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, stream, resume, id, noNative, cache, injectMode } = options;
// Priority: 1. --file, 2. --prompt/-p option, 3. positional argument
let finalPrompt: string | undefined;
@@ -584,10 +658,10 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
custom_id: id || null
});
// Streaming output handler
const onOutput = noStream ? null : (chunk: any) => {
// Streaming output handler - only active when --stream flag is passed
const onOutput = stream ? (chunk: any) => {
process.stdout.write(chunk.data);
};
} : null;
try {
const result = await cliExecutorTool.execute({
@@ -600,11 +674,12 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
timeout: timeout ? parseInt(timeout, 10) : 300000,
resume,
id, // custom execution ID
noNative
noNative,
stream: !!stream // stream=true → streaming enabled, stream=false/undefined → cache output
}, onOutput);
// If not streaming, print output now
if (noStream && result.stdout) {
// If not streaming (default), print output now
if (!stream && result.stdout) {
console.log(result.stdout);
}
@@ -815,6 +890,10 @@ export async function cliCommand(
await storageAction(argsArray[0], options as unknown as StorageOptions);
break;
case 'output':
await outputAction(argsArray[0], options as unknown as OutputViewOptions);
break;
case 'test-parse':
// Test endpoint to debug multi-line prompt parsing
testParseAction(argsArray, options as CliExecOptions);
@@ -845,6 +924,7 @@ export async function cliCommand(
console.log(chalk.gray(' storage [cmd] Manage CCW storage (info/clean/config)'));
console.log(chalk.gray(' history Show execution history'));
console.log(chalk.gray(' detail <id> Show execution detail'));
console.log(chalk.gray(' output <id> Show execution output with pagination'));
console.log(chalk.gray(' test-parse [args] Debug CLI argument parsing'));
console.log();
console.log(' Options:');

View File

@@ -73,6 +73,7 @@ const ParamsSchema = z.object({
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
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
});
// Execution category types
@@ -863,24 +864,36 @@ async function executeCliTool(
const endTime = Date.now();
const duration = endTime - startTime;
// Determine status
// Determine status - prioritize output content over exit code
let status: 'success' | 'error' | 'timeout' = 'success';
if (timedOut) {
status = 'timeout';
} else if (code !== 0) {
// Check if HTTP 429 but results exist (Gemini quirk)
if (stderr.includes('429') && stdout.trim()) {
// Non-zero exit code doesn't always mean failure
// Check if there's valid output (AI response) - treat as success
const hasValidOutput = stdout.trim().length > 0;
const hasFatalError = stderr.includes('FATAL') ||
stderr.includes('Authentication failed') ||
stderr.includes('API key') ||
stderr.includes('rate limit exceeded');
if (hasValidOutput && !hasFatalError) {
// Has output and no fatal errors - treat as success despite exit code
status = 'success';
} else {
status = 'error';
}
}
// Create new turn
// Create new turn - cache full output when not streaming (default)
const shouldCache = !parsed.data.stream;
const newTurnOutput = {
stdout: stdout.substring(0, 10240), // Truncate to 10KB
stderr: stderr.substring(0, 2048), // Truncate to 2KB
truncated: stdout.length > 10240 || stderr.length > 2048
stdout: stdout.substring(0, 10240), // Truncate preview to 10KB
stderr: stderr.substring(0, 2048), // Truncate preview to 2KB
truncated: stdout.length > 10240 || stderr.length > 2048,
cached: shouldCache,
stdout_full: shouldCache ? stdout : undefined,
stderr_full: shouldCache ? stderr : undefined
};
// Determine base turn number for merge scenarios

View File

@@ -23,6 +23,9 @@ export interface ConversationTurn {
stdout: string;
stderr: string;
truncated: boolean;
cached?: boolean;
stdout_full?: string;
stderr_full?: string;
};
}
@@ -315,6 +318,28 @@ export class CliHistoryStore {
} catch (indexErr) {
console.warn('[CLI History] Turns timestamp index creation warning:', (indexErr as Error).message);
}
// Add cached output columns to turns table for non-streaming mode
const turnsInfo = this.db.prepare('PRAGMA table_info(turns)').all() as Array<{ name: string }>;
const hasCached = turnsInfo.some(col => col.name === 'cached');
const hasStdoutFull = turnsInfo.some(col => col.name === 'stdout_full');
const hasStderrFull = turnsInfo.some(col => col.name === 'stderr_full');
if (!hasCached) {
console.log('[CLI History] Migrating database: adding cached column to turns table...');
this.db.exec('ALTER TABLE turns ADD COLUMN cached INTEGER DEFAULT 0;');
console.log('[CLI History] Migration complete: cached column added');
}
if (!hasStdoutFull) {
console.log('[CLI History] Migrating database: adding stdout_full column to turns table...');
this.db.exec('ALTER TABLE turns ADD COLUMN stdout_full TEXT;');
console.log('[CLI History] Migration complete: stdout_full column added');
}
if (!hasStderrFull) {
console.log('[CLI History] Migrating database: adding stderr_full column to turns table...');
this.db.exec('ALTER TABLE turns ADD COLUMN stderr_full TEXT;');
console.log('[CLI History] Migration complete: stderr_full column added');
}
} catch (err) {
console.error('[CLI History] Migration error:', (err as Error).message);
// Don't throw - allow the store to continue working with existing schema
@@ -421,8 +446,8 @@ export class CliHistoryStore {
`);
const upsertTurn = this.db.prepare(`
INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated)
VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated)
INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated, cached, stdout_full, stderr_full)
VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated, @cached, @stdout_full, @stderr_full)
ON CONFLICT(conversation_id, turn_number) DO UPDATE SET
timestamp = @timestamp,
prompt = @prompt,
@@ -431,7 +456,10 @@ export class CliHistoryStore {
exit_code = @exit_code,
stdout = @stdout,
stderr = @stderr,
truncated = @truncated
truncated = @truncated,
cached = @cached,
stdout_full = @stdout_full,
stderr_full = @stderr_full
`);
const transaction = this.db.transaction(() => {
@@ -463,7 +491,10 @@ export class CliHistoryStore {
exit_code: turn.exit_code,
stdout: turn.output.stdout,
stderr: turn.output.stderr,
truncated: turn.output.truncated ? 1 : 0
truncated: turn.output.truncated ? 1 : 0,
cached: turn.output.cached ? 1 : 0,
stdout_full: turn.output.stdout_full || null,
stderr_full: turn.output.stderr_full || null
});
}
});
@@ -507,7 +538,10 @@ export class CliHistoryStore {
output: {
stdout: t.stdout || '',
stderr: t.stderr || '',
truncated: !!t.truncated
truncated: !!t.truncated,
cached: !!t.cached,
stdout_full: t.stdout_full || undefined,
stderr_full: t.stderr_full || undefined
}
}))
};
@@ -533,6 +567,92 @@ export class CliHistoryStore {
};
}
/**
* Get paginated cached output for a conversation turn
* @param conversationId - Conversation ID
* @param turnNumber - Turn number (default: latest turn)
* @param options - Pagination options
*/
getCachedOutput(
conversationId: string,
turnNumber?: number,
options: {
offset?: number; // Character offset (default: 0)
limit?: number; // Max characters to return (default: 10000)
outputType?: 'stdout' | 'stderr' | 'both'; // Which output to fetch
} = {}
): {
conversationId: string;
turnNumber: number;
stdout?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
stderr?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
cached: boolean;
prompt: string;
status: string;
timestamp: string;
} | null {
const { offset = 0, limit = 10000, outputType = 'both' } = options;
// Get turn (latest if not specified)
let turn;
if (turnNumber !== undefined) {
turn = this.db.prepare(`
SELECT * FROM turns WHERE conversation_id = ? AND turn_number = ?
`).get(conversationId, turnNumber) as any;
} else {
turn = this.db.prepare(`
SELECT * FROM turns WHERE conversation_id = ? ORDER BY turn_number DESC LIMIT 1
`).get(conversationId) as any;
}
if (!turn) return null;
const result: {
conversationId: string;
turnNumber: number;
stdout?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
stderr?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
cached: boolean;
prompt: string;
status: string;
timestamp: string;
} = {
conversationId,
turnNumber: turn.turn_number,
cached: !!turn.cached,
prompt: turn.prompt,
status: turn.status,
timestamp: turn.timestamp
};
// Use full output if cached, otherwise use truncated
if (outputType === 'stdout' || outputType === 'both') {
const fullStdout = turn.cached ? (turn.stdout_full || '') : (turn.stdout || '');
const totalBytes = fullStdout.length;
const content = fullStdout.substring(offset, offset + limit);
result.stdout = {
content,
totalBytes,
offset,
hasMore: offset + limit < totalBytes
};
}
if (outputType === 'stderr' || outputType === 'both') {
const fullStderr = turn.cached ? (turn.stderr_full || '') : (turn.stderr || '');
const totalBytes = fullStderr.length;
const content = fullStderr.substring(offset, offset + limit);
result.stderr = {
content,
totalBytes,
offset,
hasMore: offset + limit < totalBytes
};
}
return result;
}
/**
* Query execution history
*/