mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: Implement resume strategy engine and session content parser
- Added `resume-strategy.ts` to determine optimal resume approaches including native, prompt concatenation, and hybrid modes. - Introduced `determineResumeStrategy` function to evaluate various resume scenarios. - Created utility functions for building context prefixes and formatting outputs in plain, YAML, and JSON formats. - Added `session-content-parser.ts` to parse native CLI tool session files supporting Gemini/Qwen JSON and Codex JSONL formats. - Implemented parsing logic for different session formats, including error handling for invalid lines. - Provided functions to format conversations and extract user-assistant pairs from parsed sessions.
This commit is contained in:
@@ -9,6 +9,20 @@ import { spawn, ChildProcess } from 'child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
// Native resume support
|
||||
import {
|
||||
trackNewSession,
|
||||
getNativeResumeArgs,
|
||||
supportsNativeResume,
|
||||
calculateProjectHash
|
||||
} from './native-session-discovery.js';
|
||||
import {
|
||||
determineResumeStrategy,
|
||||
buildContextPrefix,
|
||||
getResumeModeDescription,
|
||||
type ResumeDecision
|
||||
} from './resume-strategy.js';
|
||||
|
||||
// CLI History storage path
|
||||
const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history');
|
||||
|
||||
@@ -47,8 +61,13 @@ const ParamsSchema = z.object({
|
||||
timeout: z.number().default(300000),
|
||||
resume: z.union([z.boolean(), z.string()]).optional(), // true = last, string = single ID or comma-separated IDs
|
||||
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
|
||||
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
|
||||
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
|
||||
});
|
||||
|
||||
// Execution category types
|
||||
export type ExecutionCategory = 'user' | 'internal' | 'insight';
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
// Prompt concatenation format types
|
||||
@@ -82,6 +101,7 @@ interface ConversationRecord {
|
||||
tool: string;
|
||||
model: string;
|
||||
mode: string;
|
||||
category: ExecutionCategory; // user | internal | insight
|
||||
total_duration_ms: number;
|
||||
turn_count: number;
|
||||
latest_status: 'success' | 'error' | 'timeout';
|
||||
@@ -165,6 +185,13 @@ async function checkToolAvailability(tool: string): Promise<ToolAvailability> {
|
||||
});
|
||||
}
|
||||
|
||||
// Native resume configuration
|
||||
interface NativeResumeConfig {
|
||||
enabled: boolean;
|
||||
sessionId?: string; // Native UUID
|
||||
isLatest?: boolean; // Use latest/--last flag
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command arguments based on tool and options
|
||||
*/
|
||||
@@ -175,8 +202,9 @@ function buildCommand(params: {
|
||||
model?: string;
|
||||
dir?: string;
|
||||
include?: string;
|
||||
nativeResume?: NativeResumeConfig;
|
||||
}): { command: string; args: string[]; useStdin: boolean } {
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include } = params;
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume } = params;
|
||||
|
||||
let command = tool;
|
||||
let args: string[] = [];
|
||||
@@ -185,7 +213,14 @@ function buildCommand(params: {
|
||||
|
||||
switch (tool) {
|
||||
case 'gemini':
|
||||
// gemini reads from stdin when no positional prompt is provided
|
||||
// Native resume: gemini -r <uuid> or -r latest
|
||||
if (nativeResume?.enabled) {
|
||||
if (nativeResume.isLatest) {
|
||||
args.push('-r', 'latest');
|
||||
} else if (nativeResume.sessionId) {
|
||||
args.push('-r', nativeResume.sessionId);
|
||||
}
|
||||
}
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
}
|
||||
@@ -198,7 +233,14 @@ function buildCommand(params: {
|
||||
break;
|
||||
|
||||
case 'qwen':
|
||||
// qwen reads from stdin when no positional prompt is provided
|
||||
// Native resume: qwen --continue (latest) or --resume <uuid>
|
||||
if (nativeResume?.enabled) {
|
||||
if (nativeResume.isLatest) {
|
||||
args.push('--continue');
|
||||
} else if (nativeResume.sessionId) {
|
||||
args.push('--resume', nativeResume.sessionId);
|
||||
}
|
||||
}
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
}
|
||||
@@ -211,26 +253,50 @@ function buildCommand(params: {
|
||||
break;
|
||||
|
||||
case 'codex':
|
||||
// codex reads from stdin for prompt
|
||||
args.push('exec');
|
||||
if (dir) {
|
||||
args.push('-C', dir);
|
||||
}
|
||||
args.push('--full-auto');
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
|
||||
}
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
}
|
||||
if (include) {
|
||||
// Codex uses --add-dir for additional directories
|
||||
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
|
||||
for (const addDir of dirs) {
|
||||
args.push('--add-dir', addDir);
|
||||
// Native resume: codex resume <uuid> [prompt] or --last
|
||||
if (nativeResume?.enabled) {
|
||||
args.push('resume');
|
||||
if (nativeResume.isLatest) {
|
||||
args.push('--last');
|
||||
} else if (nativeResume.sessionId) {
|
||||
args.push(nativeResume.sessionId);
|
||||
}
|
||||
// Codex resume still supports additional flags
|
||||
if (dir) {
|
||||
args.push('-C', dir);
|
||||
}
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
|
||||
}
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
}
|
||||
if (include) {
|
||||
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
|
||||
for (const addDir of dirs) {
|
||||
args.push('--add-dir', addDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard exec mode
|
||||
args.push('exec');
|
||||
if (dir) {
|
||||
args.push('-C', dir);
|
||||
}
|
||||
args.push('--full-auto');
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
|
||||
}
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
}
|
||||
if (include) {
|
||||
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
|
||||
for (const addDir of dirs) {
|
||||
args.push('--add-dir', addDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prompt passed via stdin (default)
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -310,6 +376,7 @@ function convertToConversation(record: ExecutionRecord): ConversationRecord {
|
||||
tool: record.tool,
|
||||
model: record.model,
|
||||
mode: record.mode,
|
||||
category: 'user', // Legacy records default to user category
|
||||
total_duration_ms: record.duration_ms,
|
||||
turn_count: 1,
|
||||
latest_status: record.status,
|
||||
@@ -406,12 +473,15 @@ async function executeCliTool(
|
||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId } = parsed.data;
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category } = parsed.data;
|
||||
|
||||
// Determine working directory early (needed for conversation lookup)
|
||||
const workingDir = cd || process.cwd();
|
||||
const historyDir = ensureHistoryDir(workingDir);
|
||||
|
||||
// Get SQLite store for native session lookup
|
||||
const store = await getSqliteStore(workingDir);
|
||||
|
||||
// Determine conversation ID and load existing conversation
|
||||
// Logic:
|
||||
// - If --resume <id1,id2,...> (multiple IDs): merge conversations
|
||||
@@ -484,14 +554,61 @@ async function executeCliTool(
|
||||
conversationId = `${Date.now()}-${tool}`;
|
||||
}
|
||||
|
||||
// Determine resume strategy (native vs prompt-concat vs hybrid)
|
||||
let resumeDecision: ResumeDecision | null = null;
|
||||
let nativeResumeConfig: NativeResumeConfig | undefined;
|
||||
|
||||
// resume=true (latest) - use native latest if supported
|
||||
if (resume === true && !noNative && supportsNativeResume(tool)) {
|
||||
resumeDecision = {
|
||||
strategy: 'native',
|
||||
isLatest: true,
|
||||
primaryConversationId: conversationId
|
||||
};
|
||||
}
|
||||
// Use strategy engine for complex scenarios
|
||||
else if (resumeIds.length > 0 && !noNative) {
|
||||
resumeDecision = determineResumeStrategy({
|
||||
tool,
|
||||
resumeIds,
|
||||
customId,
|
||||
forcePromptConcat: noNative,
|
||||
getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId),
|
||||
getConversation: (ccwId) => loadConversation(historyDir, ccwId),
|
||||
getConversationTool: (ccwId) => {
|
||||
const conv = loadConversation(historyDir, ccwId);
|
||||
return conv?.tool || null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Configure native resume if strategy decided to use it
|
||||
if (resumeDecision && (resumeDecision.strategy === 'native' || resumeDecision.strategy === 'hybrid')) {
|
||||
nativeResumeConfig = {
|
||||
enabled: true,
|
||||
sessionId: resumeDecision.nativeSessionId,
|
||||
isLatest: resumeDecision.isLatest
|
||||
};
|
||||
}
|
||||
|
||||
// Build final prompt with conversation context
|
||||
// For merge: use merged context from all source conversations
|
||||
// For fork: use contextConversation (from resume ID) for prompt context
|
||||
// For append: use existingConversation (from target ID)
|
||||
// For native: minimal prompt (native tool handles context)
|
||||
// For hybrid: context prefix from other conversations + new prompt
|
||||
// For prompt-concat: full multi-turn prompt
|
||||
let finalPrompt = prompt;
|
||||
if (mergeResult && mergeResult.mergedTurns.length > 0) {
|
||||
|
||||
if (resumeDecision?.strategy === 'native') {
|
||||
// Native mode: just use the new prompt, tool handles context
|
||||
finalPrompt = prompt;
|
||||
} else if (resumeDecision?.strategy === 'hybrid' && resumeDecision.contextTurns?.length) {
|
||||
// Hybrid mode: add context prefix from other conversations
|
||||
const contextPrefix = buildContextPrefix(resumeDecision.contextTurns, format);
|
||||
finalPrompt = contextPrefix + prompt;
|
||||
} else if (mergeResult && mergeResult.mergedTurns.length > 0) {
|
||||
// Full merge: use merged prompt
|
||||
finalPrompt = buildMergedPrompt(mergeResult, prompt, format);
|
||||
} else {
|
||||
// Standard prompt-concat
|
||||
const conversationForContext = contextConversation || existingConversation;
|
||||
if (conversationForContext && conversationForContext.turns.length > 0) {
|
||||
finalPrompt = buildMultiTurnPrompt(conversationForContext, prompt, format);
|
||||
@@ -504,6 +621,14 @@ async function executeCliTool(
|
||||
throw new Error(`CLI tool not available: ${tool}. Please ensure it is installed and in PATH.`);
|
||||
}
|
||||
|
||||
// Log resume mode for debugging
|
||||
if (resumeDecision) {
|
||||
const modeDesc = getResumeModeDescription(resumeDecision);
|
||||
if (onOutput) {
|
||||
onOutput({ type: 'stderr', data: `[Resume mode: ${modeDesc}]\n` });
|
||||
}
|
||||
}
|
||||
|
||||
// Build command
|
||||
const { command, args, useStdin } = buildCommand({
|
||||
tool,
|
||||
@@ -511,7 +636,8 @@ async function executeCliTool(
|
||||
mode,
|
||||
model,
|
||||
dir: cd,
|
||||
include: includeDirs
|
||||
include: includeDirs,
|
||||
nativeResume: nativeResumeConfig
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -668,6 +794,7 @@ async function executeCliTool(
|
||||
tool,
|
||||
model: model || 'default',
|
||||
mode,
|
||||
category,
|
||||
total_duration_ms: mergeResult.totalDuration + duration,
|
||||
turn_count: mergedTurns.length + 1,
|
||||
latest_status: status,
|
||||
@@ -697,6 +824,7 @@ async function executeCliTool(
|
||||
tool,
|
||||
model: model || 'default',
|
||||
mode,
|
||||
category,
|
||||
total_duration_ms: duration,
|
||||
turn_count: 1,
|
||||
latest_status: status,
|
||||
@@ -711,6 +839,29 @@ async function executeCliTool(
|
||||
}
|
||||
}
|
||||
|
||||
// Track native session after execution (async, non-blocking)
|
||||
trackNewSession(tool, new Date(startTime), workingDir)
|
||||
.then((nativeSession) => {
|
||||
if (nativeSession) {
|
||||
// Save native session mapping
|
||||
try {
|
||||
store.saveNativeSessionMapping({
|
||||
ccw_id: conversationId,
|
||||
tool,
|
||||
native_session_id: nativeSession.sessionId,
|
||||
native_session_path: nativeSession.filePath,
|
||||
project_hash: nativeSession.projectHash,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLI Executor] Failed to save native session mapping:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[CLI Executor] Failed to track native session:', (err as Error).message);
|
||||
});
|
||||
|
||||
// Create legacy execution record for backward compatibility
|
||||
const execution: ExecutionRecord = {
|
||||
id: conversationId,
|
||||
@@ -860,6 +1011,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
|
||||
limit?: number;
|
||||
tool?: string | null;
|
||||
status?: string | null;
|
||||
category?: ExecutionCategory | null;
|
||||
search?: string | null;
|
||||
recursive?: boolean;
|
||||
} = {}): Promise<{
|
||||
@@ -867,7 +1019,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
|
||||
count: number;
|
||||
executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[];
|
||||
}> {
|
||||
const { limit = 50, tool = null, status = null, search = null, recursive = false } = options;
|
||||
const { limit = 50, tool = null, status = null, category = null, search = null, recursive = false } = options;
|
||||
|
||||
if (recursive) {
|
||||
// For recursive, we need to check multiple directories
|
||||
@@ -878,7 +1030,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
|
||||
for (const historyDir of historyDirs) {
|
||||
const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
|
||||
const store = await getSqliteStore(dirBase);
|
||||
const result = store.getHistory({ limit: 100, tool, status, search });
|
||||
const result = store.getHistory({ limit: 100, tool, status, category, search });
|
||||
totalCount += result.total;
|
||||
|
||||
const relativeSource = relative(baseDir, dirBase) || '.';
|
||||
@@ -898,7 +1050,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
|
||||
}
|
||||
|
||||
const store = await getSqliteStore(baseDir);
|
||||
return store.getHistory({ limit, tool, status, search });
|
||||
return store.getHistory({ limit, tool, status, category, search });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1447,6 +1599,61 @@ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRec
|
||||
return getExecutionDetail(baseDir, history.executions[0].id);
|
||||
}
|
||||
|
||||
// ========== Native Session Content Functions ==========
|
||||
|
||||
/**
|
||||
* Get native session content by CCW ID
|
||||
* Parses the native session file and returns full conversation data
|
||||
*/
|
||||
export async function getNativeSessionContent(baseDir: string, ccwId: string) {
|
||||
const store = await getSqliteStore(baseDir);
|
||||
return store.getNativeSessionContent(ccwId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted native conversation text
|
||||
*/
|
||||
export async function getFormattedNativeConversation(baseDir: string, ccwId: string, options?: {
|
||||
includeThoughts?: boolean;
|
||||
includeToolCalls?: boolean;
|
||||
includeTokens?: boolean;
|
||||
maxContentLength?: number;
|
||||
}) {
|
||||
const store = await getSqliteStore(baseDir);
|
||||
return store.getFormattedNativeConversation(ccwId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation pairs from native session
|
||||
*/
|
||||
export async function getNativeConversationPairs(baseDir: string, ccwId: string) {
|
||||
const store = await getSqliteStore(baseDir);
|
||||
return store.getNativeConversationPairs(ccwId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enriched conversation (CCW + native session merged)
|
||||
*/
|
||||
export async function getEnrichedConversation(baseDir: string, ccwId: string) {
|
||||
const store = await getSqliteStore(baseDir);
|
||||
return store.getEnrichedConversation(ccwId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history with native session info
|
||||
*/
|
||||
export async function getHistoryWithNativeInfo(baseDir: string, options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
tool?: string | null;
|
||||
status?: string | null;
|
||||
category?: ExecutionCategory | null;
|
||||
search?: string | null;
|
||||
}) {
|
||||
const store = await getSqliteStore(baseDir);
|
||||
return store.getHistoryWithNativeInfo(options || {});
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type { ConversationRecord, ConversationTurn, ExecutionRecord, PromptFormat, ConcatOptions };
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
|
||||
|
||||
// Types
|
||||
export interface ConversationTurn {
|
||||
@@ -22,6 +23,9 @@ export interface ConversationTurn {
|
||||
};
|
||||
}
|
||||
|
||||
// Execution category types
|
||||
export type ExecutionCategory = 'user' | 'internal' | 'insight';
|
||||
|
||||
export interface ConversationRecord {
|
||||
id: string;
|
||||
created_at: string;
|
||||
@@ -29,6 +33,7 @@ export interface ConversationRecord {
|
||||
tool: string;
|
||||
model: string;
|
||||
mode: string;
|
||||
category: ExecutionCategory; // user | internal | insight
|
||||
total_duration_ms: number;
|
||||
turn_count: number;
|
||||
latest_status: 'success' | 'error' | 'timeout';
|
||||
@@ -40,6 +45,7 @@ export interface HistoryQueryOptions {
|
||||
offset?: number;
|
||||
tool?: string | null;
|
||||
status?: string | null;
|
||||
category?: ExecutionCategory | null;
|
||||
search?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
@@ -51,12 +57,23 @@ export interface HistoryIndexEntry {
|
||||
updated_at?: string;
|
||||
tool: string;
|
||||
status: string;
|
||||
category?: ExecutionCategory;
|
||||
duration_ms: number;
|
||||
turn_count?: number;
|
||||
prompt_preview: string;
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
// Native session mapping interface
|
||||
export interface NativeSessionMapping {
|
||||
ccw_id: string; // CCW execution ID (e.g., 1702123456789-gemini)
|
||||
tool: string; // gemini | qwen | codex
|
||||
native_session_id: string; // Native UUID
|
||||
native_session_path?: string; // Native file path
|
||||
project_hash?: string; // Project hash (Gemini/Qwen)
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI History Store using SQLite
|
||||
*/
|
||||
@@ -92,6 +109,7 @@ export class CliHistoryStore {
|
||||
tool TEXT NOT NULL,
|
||||
model TEXT DEFAULT 'default',
|
||||
mode TEXT DEFAULT 'analysis',
|
||||
category TEXT DEFAULT 'user',
|
||||
total_duration_ms INTEGER DEFAULT 0,
|
||||
turn_count INTEGER DEFAULT 0,
|
||||
latest_status TEXT DEFAULT 'success',
|
||||
@@ -118,6 +136,7 @@ export class CliHistoryStore {
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_tool ON conversations(tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(latest_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_category ON conversations(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_created ON conversations(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_conversation ON turns(conversation_id);
|
||||
@@ -143,7 +162,41 @@ export class CliHistoryStore {
|
||||
INSERT INTO turns_fts(turns_fts, rowid, prompt, stdout) VALUES('delete', old.id, old.prompt, old.stdout);
|
||||
INSERT INTO turns_fts(rowid, prompt, stdout) VALUES (new.id, new.prompt, new.stdout);
|
||||
END;
|
||||
|
||||
-- Native session mapping table (CCW ID <-> Native Session ID)
|
||||
CREATE TABLE IF NOT EXISTS native_session_mapping (
|
||||
ccw_id TEXT PRIMARY KEY,
|
||||
tool TEXT NOT NULL,
|
||||
native_session_id TEXT NOT NULL,
|
||||
native_session_path TEXT,
|
||||
project_hash TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(tool, native_session_id)
|
||||
);
|
||||
|
||||
-- Indexes for native session lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_native_tool_session ON native_session_mapping(tool, native_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_native_session_id ON native_session_mapping(native_session_id);
|
||||
`);
|
||||
|
||||
// Migration: Add category column if not exists (for existing databases)
|
||||
this.migrateSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate schema for existing databases
|
||||
*/
|
||||
private migrateSchema(): void {
|
||||
// Check if category column exists
|
||||
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
|
||||
const hasCategory = tableInfo.some(col => col.name === 'category');
|
||||
|
||||
if (!hasCategory) {
|
||||
this.db.exec(`
|
||||
ALTER TABLE conversations ADD COLUMN category TEXT DEFAULT 'user';
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_category ON conversations(category);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +261,7 @@ export class CliHistoryStore {
|
||||
tool: data.tool,
|
||||
model: data.model || 'default',
|
||||
mode: data.mode || 'analysis',
|
||||
category: data.category || 'user',
|
||||
total_duration_ms: data.duration_ms || 0,
|
||||
turn_count: 1,
|
||||
latest_status: data.status || 'success',
|
||||
@@ -232,8 +286,8 @@ export class CliHistoryStore {
|
||||
: '';
|
||||
|
||||
const upsertConversation = this.db.prepare(`
|
||||
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, total_duration_ms, turn_count, latest_status, prompt_preview)
|
||||
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @total_duration_ms, @turn_count, @latest_status, @prompt_preview)
|
||||
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview)
|
||||
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
updated_at = @updated_at,
|
||||
total_duration_ms = @total_duration_ms,
|
||||
@@ -264,6 +318,7 @@ export class CliHistoryStore {
|
||||
tool: conversation.tool,
|
||||
model: conversation.model,
|
||||
mode: conversation.mode,
|
||||
category: conversation.category || 'user',
|
||||
total_duration_ms: conversation.total_duration_ms,
|
||||
turn_count: conversation.turn_count,
|
||||
latest_status: conversation.latest_status,
|
||||
@@ -310,6 +365,7 @@ export class CliHistoryStore {
|
||||
tool: conv.tool,
|
||||
model: conv.model,
|
||||
mode: conv.mode,
|
||||
category: conv.category || 'user',
|
||||
total_duration_ms: conv.total_duration_ms,
|
||||
turn_count: conv.turn_count,
|
||||
latest_status: conv.latest_status,
|
||||
@@ -337,7 +393,7 @@ export class CliHistoryStore {
|
||||
count: number;
|
||||
executions: HistoryIndexEntry[];
|
||||
} {
|
||||
const { limit = 50, offset = 0, tool, status, search, startDate, endDate } = options;
|
||||
const { limit = 50, offset = 0, tool, status, category, search, startDate, endDate } = options;
|
||||
|
||||
let whereClause = '1=1';
|
||||
const params: any = {};
|
||||
@@ -352,6 +408,11 @@ export class CliHistoryStore {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClause += ' AND category = @category';
|
||||
params.category = category;
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
whereClause += ' AND created_at >= @startDate';
|
||||
params.startDate = startDate;
|
||||
@@ -398,6 +459,7 @@ export class CliHistoryStore {
|
||||
updated_at: r.updated_at,
|
||||
tool: r.tool,
|
||||
status: r.latest_status,
|
||||
category: r.category || 'user',
|
||||
duration_ms: r.total_duration_ms,
|
||||
turn_count: r.turn_count,
|
||||
prompt_preview: r.prompt_preview || ''
|
||||
@@ -496,6 +558,252 @@ export class CliHistoryStore {
|
||||
return { total, byTool, byStatus, totalDuration };
|
||||
}
|
||||
|
||||
// ========== Native Session Mapping Methods ==========
|
||||
|
||||
/**
|
||||
* Save or update native session mapping
|
||||
*/
|
||||
saveNativeSessionMapping(mapping: NativeSessionMapping): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO native_session_mapping (ccw_id, tool, native_session_id, native_session_path, project_hash, created_at)
|
||||
VALUES (@ccw_id, @tool, @native_session_id, @native_session_path, @project_hash, @created_at)
|
||||
ON CONFLICT(ccw_id) DO UPDATE SET
|
||||
native_session_id = @native_session_id,
|
||||
native_session_path = @native_session_path,
|
||||
project_hash = @project_hash
|
||||
`);
|
||||
|
||||
stmt.run({
|
||||
ccw_id: mapping.ccw_id,
|
||||
tool: mapping.tool,
|
||||
native_session_id: mapping.native_session_id,
|
||||
native_session_path: mapping.native_session_path || null,
|
||||
project_hash: mapping.project_hash || null,
|
||||
created_at: mapping.created_at || new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get native session ID by CCW ID
|
||||
*/
|
||||
getNativeSessionId(ccwId: string): string | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT native_session_id FROM native_session_mapping WHERE ccw_id = ?
|
||||
`).get(ccwId) as any;
|
||||
return row?.native_session_id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CCW ID by native session ID
|
||||
*/
|
||||
getCcwIdByNativeSession(tool: string, nativeSessionId: string): string | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT ccw_id FROM native_session_mapping WHERE tool = ? AND native_session_id = ?
|
||||
`).get(tool, nativeSessionId) as any;
|
||||
return row?.ccw_id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full mapping by CCW ID
|
||||
*/
|
||||
getNativeSessionMapping(ccwId: string): NativeSessionMapping | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM native_session_mapping WHERE ccw_id = ?
|
||||
`).get(ccwId) as any;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
ccw_id: row.ccw_id,
|
||||
tool: row.tool,
|
||||
native_session_id: row.native_session_id,
|
||||
native_session_path: row.native_session_path,
|
||||
project_hash: row.project_hash,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest native session mapping for a tool
|
||||
*/
|
||||
getLatestNativeMapping(tool: string): NativeSessionMapping | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM native_session_mapping
|
||||
WHERE tool = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`).get(tool) as any;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
ccw_id: row.ccw_id,
|
||||
tool: row.tool,
|
||||
native_session_id: row.native_session_id,
|
||||
native_session_path: row.native_session_path,
|
||||
project_hash: row.project_hash,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete native session mapping
|
||||
*/
|
||||
deleteNativeSessionMapping(ccwId: string): boolean {
|
||||
const result = this.db.prepare('DELETE FROM native_session_mapping WHERE ccw_id = ?').run(ccwId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CCW ID has native session mapping
|
||||
*/
|
||||
hasNativeSession(ccwId: string): boolean {
|
||||
const row = this.db.prepare(`
|
||||
SELECT 1 FROM native_session_mapping WHERE ccw_id = ? LIMIT 1
|
||||
`).get(ccwId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// ========== Native Session Content Methods ==========
|
||||
|
||||
/**
|
||||
* Get parsed native session content by CCW ID
|
||||
* Returns full conversation with all turns from native session file
|
||||
*/
|
||||
getNativeSessionContent(ccwId: string): ParsedSession | null {
|
||||
const mapping = this.getNativeSessionMapping(ccwId);
|
||||
if (!mapping || !mapping.native_session_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseSessionFile(mapping.native_session_path, mapping.tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted conversation text from native session
|
||||
*/
|
||||
getFormattedNativeConversation(ccwId: string, options?: {
|
||||
includeThoughts?: boolean;
|
||||
includeToolCalls?: boolean;
|
||||
includeTokens?: boolean;
|
||||
maxContentLength?: number;
|
||||
}): string | null {
|
||||
const session = this.getNativeSessionContent(ccwId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return formatConversation(session, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation pairs (user prompt + assistant response) from native session
|
||||
*/
|
||||
getNativeConversationPairs(ccwId: string): Array<{
|
||||
turn: number;
|
||||
userPrompt: string;
|
||||
assistantResponse: string;
|
||||
timestamp: string;
|
||||
}> | null {
|
||||
const session = this.getNativeSessionContent(ccwId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return extractConversationPairs(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation with enriched native session data
|
||||
* Merges CCW history with native session content
|
||||
*/
|
||||
getEnrichedConversation(ccwId: string): {
|
||||
ccw: ConversationRecord | null;
|
||||
native: ParsedSession | null;
|
||||
merged: Array<{
|
||||
turn: number;
|
||||
timestamp: string;
|
||||
ccwPrompt?: string;
|
||||
ccwOutput?: string;
|
||||
nativeUserContent?: string;
|
||||
nativeAssistantContent?: string;
|
||||
nativeThoughts?: string[];
|
||||
nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>;
|
||||
}>;
|
||||
} | null {
|
||||
const ccwConv = this.getConversation(ccwId);
|
||||
const nativeSession = this.getNativeSessionContent(ccwId);
|
||||
|
||||
if (!ccwConv && !nativeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merged: Array<{
|
||||
turn: number;
|
||||
timestamp: string;
|
||||
ccwPrompt?: string;
|
||||
ccwOutput?: string;
|
||||
nativeUserContent?: string;
|
||||
nativeAssistantContent?: string;
|
||||
nativeThoughts?: string[];
|
||||
nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>;
|
||||
}> = [];
|
||||
|
||||
// Determine max turn count
|
||||
const maxTurns = Math.max(
|
||||
ccwConv?.turn_count || 0,
|
||||
nativeSession?.turns.filter(t => t.role === 'user').length || 0
|
||||
);
|
||||
|
||||
for (let i = 1; i <= maxTurns; i++) {
|
||||
const ccwTurn = ccwConv?.turns.find(t => t.turn === i);
|
||||
const nativeUserTurn = nativeSession?.turns.find(t => t.turnNumber === i && t.role === 'user');
|
||||
const nativeAssistantTurn = nativeSession?.turns.find(t => t.turnNumber === i && t.role === 'assistant');
|
||||
|
||||
merged.push({
|
||||
turn: i,
|
||||
timestamp: ccwTurn?.timestamp || nativeUserTurn?.timestamp || '',
|
||||
ccwPrompt: ccwTurn?.prompt,
|
||||
ccwOutput: ccwTurn?.output.stdout,
|
||||
nativeUserContent: nativeUserTurn?.content,
|
||||
nativeAssistantContent: nativeAssistantTurn?.content,
|
||||
nativeThoughts: nativeAssistantTurn?.thoughts,
|
||||
nativeToolCalls: nativeAssistantTurn?.toolCalls
|
||||
});
|
||||
}
|
||||
|
||||
return { ccw: ccwConv, native: nativeSession, merged };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all conversations with native session info
|
||||
*/
|
||||
getHistoryWithNativeInfo(options: HistoryQueryOptions = {}): {
|
||||
total: number;
|
||||
count: number;
|
||||
executions: Array<HistoryIndexEntry & {
|
||||
hasNativeSession: boolean;
|
||||
nativeSessionId?: string;
|
||||
nativeSessionPath?: string;
|
||||
}>;
|
||||
} {
|
||||
const history = this.getHistory(options);
|
||||
|
||||
const enrichedExecutions = history.executions.map(exec => {
|
||||
const mapping = this.getNativeSessionMapping(exec.id);
|
||||
return {
|
||||
...exec,
|
||||
hasNativeSession: !!mapping,
|
||||
nativeSessionId: mapping?.native_session_id,
|
||||
nativeSessionPath: mapping?.native_session_path
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
total: history.total,
|
||||
count: history.count,
|
||||
executions: enrichedExecutions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
@@ -526,3 +834,6 @@ export function closeAllStores(): void {
|
||||
}
|
||||
storeCache.clear();
|
||||
}
|
||||
|
||||
// Re-export types from session-content-parser
|
||||
export type { ParsedSession, ParsedTurn } from './session-content-parser.js';
|
||||
|
||||
542
ccw/src/tools/native-session-discovery.ts
Normal file
542
ccw/src/tools/native-session-discovery.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Native Session Discovery - Discovers and tracks native CLI tool sessions
|
||||
* Supports Gemini, Qwen, and Codex session formats
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, basename, resolve } from 'path';
|
||||
// basename is used for extracting session ID from filename
|
||||
import { createHash } from 'crypto';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// Types
|
||||
export interface NativeSession {
|
||||
sessionId: string; // Native UUID
|
||||
tool: string; // gemini | qwen | codex
|
||||
filePath: string; // Full path to session file
|
||||
projectHash?: string; // Project directory hash (Gemini/Qwen)
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SessionDiscoveryOptions {
|
||||
workingDir?: string; // Project working directory
|
||||
limit?: number; // Max sessions to return
|
||||
afterTimestamp?: Date; // Only sessions after this time
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project hash (same algorithm as Gemini/Qwen)
|
||||
* Note: Gemini/Qwen use the absolute path AS-IS without normalization
|
||||
* On Windows, this means using backslashes and original case
|
||||
*/
|
||||
export function calculateProjectHash(projectDir: string): string {
|
||||
// resolve() returns absolute path with native separators (backslash on Windows)
|
||||
const absolutePath = resolve(projectDir);
|
||||
return createHash('sha256').update(absolutePath).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get home directory path
|
||||
*/
|
||||
function getHomePath(): string {
|
||||
return homedir().replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base session discoverer interface
|
||||
*/
|
||||
abstract class SessionDiscoverer {
|
||||
abstract tool: string;
|
||||
abstract basePath: string;
|
||||
|
||||
/**
|
||||
* Get all sessions for a project
|
||||
*/
|
||||
abstract getSessions(options?: SessionDiscoveryOptions): NativeSession[];
|
||||
|
||||
/**
|
||||
* Get the latest session
|
||||
*/
|
||||
getLatestSession(options?: SessionDiscoveryOptions): NativeSession | null {
|
||||
const sessions = this.getSessions({ ...options, limit: 1 });
|
||||
return sessions.length > 0 ? sessions[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find session by ID
|
||||
*/
|
||||
abstract findSessionById(sessionId: string): NativeSession | null;
|
||||
|
||||
/**
|
||||
* Track new session created during execution
|
||||
*/
|
||||
async trackNewSession(
|
||||
beforeTimestamp: Date,
|
||||
workingDir: string
|
||||
): Promise<NativeSession | null> {
|
||||
const sessions = this.getSessions({
|
||||
workingDir,
|
||||
afterTimestamp: beforeTimestamp,
|
||||
limit: 1
|
||||
});
|
||||
return sessions.length > 0 ? sessions[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini Session Discoverer
|
||||
* Path: ~/.gemini/tmp/<projectHash>/chats/session-*.json
|
||||
*/
|
||||
class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
tool = 'gemini';
|
||||
basePath = join(getHomePath(), '.gemini', 'tmp');
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { workingDir, limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
try {
|
||||
if (!existsSync(this.basePath)) return [];
|
||||
|
||||
// If workingDir provided, only look in that project's folder
|
||||
let projectDirs: string[];
|
||||
if (workingDir) {
|
||||
const projectHash = calculateProjectHash(workingDir);
|
||||
const projectPath = join(this.basePath, projectHash);
|
||||
projectDirs = existsSync(projectPath) ? [projectHash] : [];
|
||||
} else {
|
||||
projectDirs = readdirSync(this.basePath).filter(d => {
|
||||
const fullPath = join(this.basePath, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectHash of projectDirs) {
|
||||
const chatsDir = join(this.basePath, projectHash, 'chats');
|
||||
if (!existsSync(chatsDir)) continue;
|
||||
|
||||
const sessionFiles = readdirSync(chatsDir)
|
||||
.filter(f => f.startsWith('session-') && f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(chatsDir, f),
|
||||
stat: statSync(join(chatsDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(file.path, 'utf8'));
|
||||
sessions.push({
|
||||
sessionId: content.sessionId,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
projectHash,
|
||||
createdAt: new Date(content.startTime || file.stat.birthtime),
|
||||
updatedAt: new Date(content.lastUpdated || file.stat.mtime)
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending
|
||||
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
|
||||
return limit ? sessions.slice(0, limit) : sessions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
findSessionById(sessionId: string): NativeSession | null {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a path to Qwen's project folder name format
|
||||
* D:\Claude_dms3 -> D--Claude-dms3
|
||||
* Rules: : -> -, \ -> -, _ -> -
|
||||
*/
|
||||
function encodeQwenProjectPath(projectDir: string): string {
|
||||
const absolutePath = resolve(projectDir);
|
||||
// Replace : -> -, \ -> -, _ -> -
|
||||
return absolutePath
|
||||
.replace(/:/g, '-')
|
||||
.replace(/\\/g, '-')
|
||||
.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Session Discoverer
|
||||
* New path: ~/.qwen/projects/<path-encoded>/chats/<uuid>.jsonl
|
||||
* Old path: ~/.qwen/tmp/<projectHash>/chats/session-*.json (deprecated, fallback)
|
||||
*/
|
||||
class QwenSessionDiscoverer extends SessionDiscoverer {
|
||||
tool = 'qwen';
|
||||
basePath = join(getHomePath(), '.qwen', 'projects');
|
||||
legacyBasePath = join(getHomePath(), '.qwen', 'tmp');
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { workingDir, limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
// Try new format first (projects folder)
|
||||
try {
|
||||
if (existsSync(this.basePath)) {
|
||||
let projectDirs: string[];
|
||||
if (workingDir) {
|
||||
const encodedPath = encodeQwenProjectPath(workingDir);
|
||||
const projectPath = join(this.basePath, encodedPath);
|
||||
projectDirs = existsSync(projectPath) ? [encodedPath] : [];
|
||||
} else {
|
||||
projectDirs = readdirSync(this.basePath).filter(d => {
|
||||
const fullPath = join(this.basePath, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectFolder of projectDirs) {
|
||||
const chatsDir = join(this.basePath, projectFolder, 'chats');
|
||||
if (!existsSync(chatsDir)) continue;
|
||||
|
||||
// New format: <uuid>.jsonl files
|
||||
const sessionFiles = readdirSync(chatsDir)
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(chatsDir, f),
|
||||
stat: statSync(join(chatsDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
|
||||
try {
|
||||
// Parse JSONL - read first line for session info
|
||||
const content = readFileSync(file.path, 'utf8');
|
||||
const firstLine = content.split('\n')[0];
|
||||
const firstEntry = JSON.parse(firstLine);
|
||||
|
||||
// Session ID is in the filename or first entry
|
||||
const sessionId = firstEntry.sessionId || basename(file.name, '.jsonl');
|
||||
|
||||
// Find timestamp from entries
|
||||
let createdAt = file.stat.birthtime;
|
||||
let updatedAt = file.stat.mtime;
|
||||
|
||||
if (firstEntry.timestamp) {
|
||||
createdAt = new Date(firstEntry.timestamp);
|
||||
}
|
||||
|
||||
// Get last entry for updatedAt
|
||||
const lines = content.trim().split('\n').filter(l => l.trim());
|
||||
if (lines.length > 0) {
|
||||
try {
|
||||
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
||||
if (lastEntry.timestamp) {
|
||||
updatedAt = new Date(lastEntry.timestamp);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
sessionId,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
projectHash: projectFolder, // Using encoded path as project identifier
|
||||
createdAt,
|
||||
updatedAt
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore errors */ }
|
||||
|
||||
// Fallback to legacy format (tmp folder with hash)
|
||||
try {
|
||||
if (existsSync(this.legacyBasePath)) {
|
||||
let projectDirs: string[];
|
||||
if (workingDir) {
|
||||
const projectHash = calculateProjectHash(workingDir);
|
||||
const projectPath = join(this.legacyBasePath, projectHash);
|
||||
projectDirs = existsSync(projectPath) ? [projectHash] : [];
|
||||
} else {
|
||||
projectDirs = readdirSync(this.legacyBasePath).filter(d => {
|
||||
const fullPath = join(this.legacyBasePath, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectHash of projectDirs) {
|
||||
const chatsDir = join(this.legacyBasePath, projectHash, 'chats');
|
||||
if (!existsSync(chatsDir)) continue;
|
||||
|
||||
const sessionFiles = readdirSync(chatsDir)
|
||||
.filter(f => f.startsWith('session-') && f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(chatsDir, f),
|
||||
stat: statSync(join(chatsDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(file.path, 'utf8'));
|
||||
sessions.push({
|
||||
sessionId: content.sessionId,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
projectHash,
|
||||
createdAt: new Date(content.startTime || file.stat.birthtime),
|
||||
updatedAt: new Date(content.lastUpdated || file.stat.mtime)
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore errors */ }
|
||||
|
||||
// Sort by updatedAt descending and dedupe by sessionId
|
||||
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
|
||||
// Dedupe (new format takes precedence as it's checked first)
|
||||
const seen = new Set<string>();
|
||||
const uniqueSessions = sessions.filter(s => {
|
||||
if (seen.has(s.sessionId)) return false;
|
||||
seen.add(s.sessionId);
|
||||
return true;
|
||||
});
|
||||
|
||||
return limit ? uniqueSessions.slice(0, limit) : uniqueSessions;
|
||||
}
|
||||
|
||||
findSessionById(sessionId: string): NativeSession | null {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Session Discoverer
|
||||
* Path: ~/.codex/sessions/YYYY/MM/DD/rollout-*-<uuid>.jsonl
|
||||
*/
|
||||
class CodexSessionDiscoverer extends SessionDiscoverer {
|
||||
tool = 'codex';
|
||||
basePath = join(getHomePath(), '.codex', 'sessions');
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
try {
|
||||
if (!existsSync(this.basePath)) return [];
|
||||
|
||||
// Get year directories (e.g., 2025)
|
||||
const yearDirs = readdirSync(this.basePath)
|
||||
.filter(d => /^\d{4}$/.test(d))
|
||||
.sort((a, b) => b.localeCompare(a)); // Descending
|
||||
|
||||
for (const year of yearDirs) {
|
||||
const yearPath = join(this.basePath, year);
|
||||
if (!statSync(yearPath).isDirectory()) continue;
|
||||
|
||||
// Get month directories
|
||||
const monthDirs = readdirSync(yearPath)
|
||||
.filter(d => /^\d{2}$/.test(d))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
for (const month of monthDirs) {
|
||||
const monthPath = join(yearPath, month);
|
||||
if (!statSync(monthPath).isDirectory()) continue;
|
||||
|
||||
// Get day directories
|
||||
const dayDirs = readdirSync(monthPath)
|
||||
.filter(d => /^\d{2}$/.test(d))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
for (const day of dayDirs) {
|
||||
const dayPath = join(monthPath, day);
|
||||
if (!statSync(dayPath).isDirectory()) continue;
|
||||
|
||||
// Get session files
|
||||
const sessionFiles = readdirSync(dayPath)
|
||||
.filter(f => f.startsWith('rollout-') && f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(dayPath, f),
|
||||
stat: statSync(join(dayPath, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
|
||||
try {
|
||||
// Parse first line for session_meta
|
||||
const firstLine = readFileSync(file.path, 'utf8').split('\n')[0];
|
||||
const meta = JSON.parse(firstLine);
|
||||
|
||||
if (meta.type === 'session_meta' && meta.payload?.id) {
|
||||
sessions.push({
|
||||
sessionId: meta.payload.id,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
createdAt: new Date(meta.payload.timestamp || file.stat.birthtime),
|
||||
updatedAt: file.stat.mtime
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Try extracting UUID from filename
|
||||
const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
||||
if (uuidMatch) {
|
||||
sessions.push({
|
||||
sessionId: uuidMatch[1],
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
createdAt: file.stat.birthtime,
|
||||
updatedAt: file.stat.mtime
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
return limit ? sessions.slice(0, limit) : sessions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
findSessionById(sessionId: string): NativeSession | null {
|
||||
const sessions = this.getSessions();
|
||||
return sessions.find(s => s.sessionId === sessionId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton discoverers
|
||||
const discoverers: Record<string, SessionDiscoverer> = {
|
||||
gemini: new GeminiSessionDiscoverer(),
|
||||
qwen: new QwenSessionDiscoverer(),
|
||||
codex: new CodexSessionDiscoverer()
|
||||
};
|
||||
|
||||
/**
|
||||
* Get session discoverer for a tool
|
||||
*/
|
||||
export function getDiscoverer(tool: string): SessionDiscoverer | null {
|
||||
return discoverers[tool] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest native session for a tool
|
||||
*/
|
||||
export function getLatestNativeSession(
|
||||
tool: string,
|
||||
workingDir?: string
|
||||
): NativeSession | null {
|
||||
const discoverer = discoverers[tool];
|
||||
if (!discoverer) return null;
|
||||
return discoverer.getLatestSession({ workingDir });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find native session by ID
|
||||
*/
|
||||
export function findNativeSessionById(
|
||||
tool: string,
|
||||
sessionId: string
|
||||
): NativeSession | null {
|
||||
const discoverer = discoverers[tool];
|
||||
if (!discoverer) return null;
|
||||
return discoverer.findSessionById(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track new session created during execution
|
||||
*/
|
||||
export async function trackNewSession(
|
||||
tool: string,
|
||||
beforeTimestamp: Date,
|
||||
workingDir: string
|
||||
): Promise<NativeSession | null> {
|
||||
const discoverer = discoverers[tool];
|
||||
if (!discoverer) return null;
|
||||
return discoverer.trackNewSession(beforeTimestamp, workingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions for a tool
|
||||
*/
|
||||
export function getNativeSessions(
|
||||
tool: string,
|
||||
options?: SessionDiscoveryOptions
|
||||
): NativeSession[] {
|
||||
const discoverer = discoverers[tool];
|
||||
if (!discoverer) return [];
|
||||
return discoverer.getSessions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool supports native resume
|
||||
*/
|
||||
export function supportsNativeResume(tool: string): boolean {
|
||||
return tool in discoverers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get native resume command arguments for a tool
|
||||
*/
|
||||
export function getNativeResumeArgs(
|
||||
tool: string,
|
||||
sessionId: string | 'latest'
|
||||
): string[] {
|
||||
switch (tool) {
|
||||
case 'gemini':
|
||||
// gemini -r <uuid> or -r latest
|
||||
return ['-r', sessionId];
|
||||
|
||||
case 'qwen':
|
||||
// qwen --continue (latest) or --resume <uuid>
|
||||
if (sessionId === 'latest') {
|
||||
return ['--continue'];
|
||||
}
|
||||
return ['--resume', sessionId];
|
||||
|
||||
case 'codex':
|
||||
// codex resume <uuid> or codex resume --last
|
||||
if (sessionId === 'latest') {
|
||||
return ['resume', '--last'];
|
||||
}
|
||||
return ['resume', sessionId];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base path for a tool's sessions
|
||||
*/
|
||||
export function getToolSessionPath(tool: string): string | null {
|
||||
const discoverer = discoverers[tool];
|
||||
return discoverer?.basePath || null;
|
||||
}
|
||||
345
ccw/src/tools/resume-strategy.ts
Normal file
345
ccw/src/tools/resume-strategy.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Resume Strategy Engine - Determines optimal resume approach
|
||||
* Supports native resume, prompt concatenation, and hybrid modes
|
||||
*/
|
||||
|
||||
import type { ConversationTurn, ConversationRecord, NativeSessionMapping } from './cli-history-store.js';
|
||||
|
||||
// Strategy types
|
||||
export type ResumeStrategy = 'native' | 'prompt-concat' | 'hybrid';
|
||||
|
||||
// Resume decision result
|
||||
export interface ResumeDecision {
|
||||
strategy: ResumeStrategy;
|
||||
nativeSessionId?: string; // Native UUID for native/hybrid modes
|
||||
isLatest?: boolean; // Use latest/--last flag
|
||||
contextTurns?: ConversationTurn[]; // Turns to include as context prefix
|
||||
primaryConversationId?: string; // Primary conversation for append
|
||||
}
|
||||
|
||||
// Resume strategy options
|
||||
export interface ResumeStrategyOptions {
|
||||
tool: string;
|
||||
resumeIds: string[]; // CCW IDs to resume from
|
||||
customId?: string; // New custom ID (fork scenario)
|
||||
forceNative?: boolean; // Force native resume
|
||||
forcePromptConcat?: boolean; // Force prompt concatenation
|
||||
|
||||
// Lookup functions (dependency injection)
|
||||
getNativeSessionId: (ccwId: string) => string | null;
|
||||
getConversation: (ccwId: string) => ConversationRecord | null;
|
||||
getConversationTool: (ccwId: string) => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the optimal resume strategy based on scenario
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. Single append (no customId) → native if mapping exists
|
||||
* 2. Fork (customId provided) → prompt-concat (new conversation)
|
||||
* 3. Merge multiple → hybrid (primary native + others as context)
|
||||
* 4. Cross-tool → prompt-concat (tools differ)
|
||||
* 5. resume=true (latest) → native with isLatest flag
|
||||
*/
|
||||
export function determineResumeStrategy(options: ResumeStrategyOptions): ResumeDecision {
|
||||
const {
|
||||
tool,
|
||||
resumeIds,
|
||||
customId,
|
||||
forceNative,
|
||||
forcePromptConcat,
|
||||
getNativeSessionId,
|
||||
getConversation,
|
||||
getConversationTool
|
||||
} = options;
|
||||
|
||||
// Force prompt concatenation
|
||||
if (forcePromptConcat) {
|
||||
return buildPromptConcatDecision(resumeIds, getConversation);
|
||||
}
|
||||
|
||||
// No resume IDs - new conversation
|
||||
if (resumeIds.length === 0) {
|
||||
return { strategy: 'prompt-concat' };
|
||||
}
|
||||
|
||||
// Scenario 5: resume=true (latest) - use native latest
|
||||
// This is handled before this function is called, but included for completeness
|
||||
|
||||
// Scenario 2: Fork (customId provided) → always prompt-concat
|
||||
if (customId) {
|
||||
return buildPromptConcatDecision(resumeIds, getConversation);
|
||||
}
|
||||
|
||||
// Scenario 4: Check for cross-tool resume
|
||||
const crossTool = resumeIds.some(id => {
|
||||
const convTool = getConversationTool(id);
|
||||
return convTool && convTool !== tool;
|
||||
});
|
||||
|
||||
if (crossTool) {
|
||||
return buildPromptConcatDecision(resumeIds, getConversation);
|
||||
}
|
||||
|
||||
// Scenario 1: Single append
|
||||
if (resumeIds.length === 1) {
|
||||
const nativeId = getNativeSessionId(resumeIds[0]);
|
||||
|
||||
if (nativeId || forceNative) {
|
||||
return {
|
||||
strategy: 'native',
|
||||
nativeSessionId: nativeId || undefined,
|
||||
primaryConversationId: resumeIds[0]
|
||||
};
|
||||
}
|
||||
|
||||
// No native mapping, fall back to prompt-concat
|
||||
return buildPromptConcatDecision(resumeIds, getConversation);
|
||||
}
|
||||
|
||||
// Scenario 3: Merge multiple conversations → hybrid mode
|
||||
return buildHybridDecision(resumeIds, tool, getNativeSessionId, getConversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompt-concat decision with all turns loaded
|
||||
*/
|
||||
function buildPromptConcatDecision(
|
||||
resumeIds: string[],
|
||||
getConversation: (ccwId: string) => ConversationRecord | null
|
||||
): ResumeDecision {
|
||||
const allTurns: ConversationTurn[] = [];
|
||||
|
||||
for (const id of resumeIds) {
|
||||
const conversation = getConversation(id);
|
||||
if (conversation) {
|
||||
// Add source ID to each turn for tracking
|
||||
const turnsWithSource = conversation.turns.map(turn => ({
|
||||
...turn,
|
||||
_sourceId: id
|
||||
}));
|
||||
allTurns.push(...turnsWithSource as ConversationTurn[]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
allTurns.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
strategy: 'prompt-concat',
|
||||
contextTurns: allTurns,
|
||||
primaryConversationId: resumeIds[0]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build hybrid decision: primary uses native, others as context prefix
|
||||
*/
|
||||
function buildHybridDecision(
|
||||
resumeIds: string[],
|
||||
tool: string,
|
||||
getNativeSessionId: (ccwId: string) => string | null,
|
||||
getConversation: (ccwId: string) => ConversationRecord | null
|
||||
): ResumeDecision {
|
||||
// Find the first ID with native session mapping
|
||||
let primaryId: string | null = null;
|
||||
let nativeId: string | null = null;
|
||||
|
||||
for (const id of resumeIds) {
|
||||
const native = getNativeSessionId(id);
|
||||
if (native) {
|
||||
primaryId = id;
|
||||
nativeId = native;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no native mapping found, use first as primary
|
||||
if (!primaryId) {
|
||||
primaryId = resumeIds[0];
|
||||
}
|
||||
|
||||
// Collect context turns from non-primary conversations
|
||||
const contextTurns: ConversationTurn[] = [];
|
||||
|
||||
for (const id of resumeIds) {
|
||||
if (id === primaryId && nativeId) {
|
||||
// Skip primary if using native - its context is handled natively
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversation = getConversation(id);
|
||||
if (conversation) {
|
||||
const turnsWithSource = conversation.turns.map(turn => ({
|
||||
...turn,
|
||||
_sourceId: id
|
||||
}));
|
||||
contextTurns.push(...turnsWithSource as ConversationTurn[]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort context turns by timestamp
|
||||
contextTurns.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
// If we have native ID, use hybrid; otherwise fall back to prompt-concat
|
||||
if (nativeId) {
|
||||
return {
|
||||
strategy: 'hybrid',
|
||||
nativeSessionId: nativeId,
|
||||
contextTurns: contextTurns.length > 0 ? contextTurns : undefined,
|
||||
primaryConversationId: primaryId
|
||||
};
|
||||
}
|
||||
|
||||
// No native mapping, use full prompt-concat
|
||||
return buildPromptConcatDecision(resumeIds, getConversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context prefix for hybrid mode
|
||||
* Formats non-primary conversation turns as context
|
||||
*/
|
||||
export function buildContextPrefix(
|
||||
contextTurns: ConversationTurn[],
|
||||
format: 'plain' | 'yaml' | 'json' = 'plain'
|
||||
): string {
|
||||
if (!contextTurns || contextTurns.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const maxOutputLength = 4096; // Truncate long outputs
|
||||
|
||||
switch (format) {
|
||||
case 'yaml':
|
||||
return buildYamlContext(contextTurns, maxOutputLength);
|
||||
case 'json':
|
||||
return buildJsonContext(contextTurns, maxOutputLength);
|
||||
default:
|
||||
return buildPlainContext(contextTurns, maxOutputLength);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlainContext(turns: ConversationTurn[], maxLength: number): string {
|
||||
const lines: string[] = [
|
||||
'=== MERGED CONTEXT FROM OTHER CONVERSATIONS ===',
|
||||
''
|
||||
];
|
||||
|
||||
for (const turn of turns) {
|
||||
const sourceId = (turn as any)._sourceId || 'unknown';
|
||||
lines.push(`--- Turn ${turn.turn} [${sourceId}] ---`);
|
||||
lines.push(`USER:`);
|
||||
lines.push(turn.prompt);
|
||||
lines.push('');
|
||||
lines.push(`ASSISTANT:`);
|
||||
const output = turn.output.stdout || '';
|
||||
lines.push(output.length > maxLength ? output.substring(0, maxLength) + '\n[truncated]' : output);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('=== END MERGED CONTEXT ===');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildYamlContext(turns: ConversationTurn[], maxLength: number): string {
|
||||
const lines: string[] = [
|
||||
'merged_context:',
|
||||
' source: "other_conversations"',
|
||||
' turns:'
|
||||
];
|
||||
|
||||
for (const turn of turns) {
|
||||
const sourceId = (turn as any)._sourceId || 'unknown';
|
||||
const output = turn.output.stdout || '';
|
||||
const truncatedOutput = output.length > maxLength
|
||||
? output.substring(0, maxLength) + '\n[truncated]'
|
||||
: output;
|
||||
|
||||
lines.push(` - turn: ${turn.turn}`);
|
||||
lines.push(` source: "${sourceId}"`);
|
||||
lines.push(` user: |`);
|
||||
lines.push(turn.prompt.split('\n').map(l => ` ${l}`).join('\n'));
|
||||
lines.push(` assistant: |`);
|
||||
lines.push(truncatedOutput.split('\n').map(l => ` ${l}`).join('\n'));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildJsonContext(turns: ConversationTurn[], maxLength: number): string {
|
||||
const context = {
|
||||
merged_context: {
|
||||
source: 'other_conversations',
|
||||
turns: turns.map(turn => {
|
||||
const output = turn.output.stdout || '';
|
||||
return {
|
||||
turn: turn.turn,
|
||||
source: (turn as any)._sourceId || 'unknown',
|
||||
user: turn.prompt,
|
||||
assistant: output.length > maxLength
|
||||
? output.substring(0, maxLength) + '\n[truncated]'
|
||||
: output
|
||||
};
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
return JSON.stringify(context, null, 2) + '\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a resume scenario requires native resume
|
||||
*/
|
||||
export function shouldUseNativeResume(
|
||||
tool: string,
|
||||
resumeIds: string[],
|
||||
customId: string | undefined,
|
||||
getNativeSessionId: (ccwId: string) => string | null,
|
||||
getConversationTool: (ccwId: string) => string | null
|
||||
): boolean {
|
||||
// Fork always uses prompt-concat
|
||||
if (customId) return false;
|
||||
|
||||
// No resume IDs
|
||||
if (resumeIds.length === 0) return false;
|
||||
|
||||
// Cross-tool not supported natively
|
||||
const crossTool = resumeIds.some(id => {
|
||||
const convTool = getConversationTool(id);
|
||||
return convTool && convTool !== tool;
|
||||
});
|
||||
if (crossTool) return false;
|
||||
|
||||
// Single resume with native mapping
|
||||
if (resumeIds.length === 1) {
|
||||
return !!getNativeSessionId(resumeIds[0]);
|
||||
}
|
||||
|
||||
// Merge: at least one needs native mapping for hybrid
|
||||
return resumeIds.some(id => !!getNativeSessionId(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume mode description for logging
|
||||
*/
|
||||
export function getResumeModeDescription(decision: ResumeDecision): string {
|
||||
switch (decision.strategy) {
|
||||
case 'native':
|
||||
return `Native resume (session: ${decision.nativeSessionId || 'latest'})`;
|
||||
case 'hybrid':
|
||||
const contextCount = decision.contextTurns?.length || 0;
|
||||
return `Hybrid (native + ${contextCount} context turns)`;
|
||||
case 'prompt-concat':
|
||||
const turnCount = decision.contextTurns?.length || 0;
|
||||
return `Prompt concat (${turnCount} turns)`;
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
619
ccw/src/tools/session-content-parser.ts
Normal file
619
ccw/src/tools/session-content-parser.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Session Content Parser - Parses native CLI tool session files
|
||||
* Supports Gemini/Qwen JSON and Codex JSONL formats
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
|
||||
// Standardized conversation turn
|
||||
export interface ParsedTurn {
|
||||
turnNumber: number;
|
||||
timestamp: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
thoughts?: string[]; // Assistant reasoning/thoughts
|
||||
toolCalls?: ToolCallInfo[]; // Tool calls made
|
||||
tokens?: TokenInfo; // Token usage
|
||||
}
|
||||
|
||||
export interface ToolCallInfo {
|
||||
name: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cached?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
// Full parsed session
|
||||
export interface ParsedSession {
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
projectHash?: string;
|
||||
workingDir?: string;
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
turns: ParsedTurn[];
|
||||
totalTokens?: TokenInfo;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// Gemini/Qwen session file structure
|
||||
interface GeminiQwenSession {
|
||||
sessionId: string;
|
||||
projectHash: string;
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
messages: GeminiQwenMessage[];
|
||||
}
|
||||
|
||||
interface GeminiQwenMessage {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'gemini' | 'qwen';
|
||||
content: string;
|
||||
thoughts?: Array<{ subject: string; description: string; timestamp: string }>;
|
||||
tokens?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cached?: number;
|
||||
thoughts?: number;
|
||||
tool?: number;
|
||||
total: number;
|
||||
};
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// Codex JSONL line types
|
||||
interface CodexSessionMeta {
|
||||
timestamp: string;
|
||||
type: 'session_meta';
|
||||
payload: {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
cli_version?: string;
|
||||
model_provider?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexResponseItem {
|
||||
timestamp: string;
|
||||
type: 'response_item';
|
||||
payload: {
|
||||
type: string;
|
||||
role?: string;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
call_id?: string;
|
||||
output?: string;
|
||||
summary?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexEventMsg {
|
||||
timestamp: string;
|
||||
type: 'event_msg';
|
||||
payload: {
|
||||
type: string;
|
||||
info?: {
|
||||
total_token_usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type CodexLine = CodexSessionMeta | CodexResponseItem | CodexEventMsg;
|
||||
|
||||
// Qwen new JSONL format
|
||||
interface QwenJSONLEntry {
|
||||
uuid: string;
|
||||
parentUuid: string | null;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'assistant' | 'system';
|
||||
cwd?: string;
|
||||
version?: string;
|
||||
gitBranch?: string;
|
||||
model?: string;
|
||||
subtype?: string; // e.g., 'ui_telemetry'
|
||||
message?: {
|
||||
role: string;
|
||||
parts: Array<{ text?: string }>;
|
||||
};
|
||||
usageMetadata?: {
|
||||
promptTokenCount: number;
|
||||
candidatesTokenCount: number;
|
||||
thoughtsTokenCount?: number;
|
||||
totalTokenCount: number;
|
||||
cachedContentTokenCount?: number;
|
||||
};
|
||||
systemPayload?: {
|
||||
uiEvent?: {
|
||||
model?: string;
|
||||
input_token_count?: number;
|
||||
output_token_count?: number;
|
||||
cached_content_token_count?: number;
|
||||
thoughts_token_count?: number;
|
||||
tool_token_count?: number;
|
||||
total_token_count?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if content is JSONL or JSON format
|
||||
*/
|
||||
function isJSONL(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
// JSON starts with { or [, but JSONL has multiple lines each starting with {
|
||||
if (trimmed.startsWith('[')) return false; // JSON array
|
||||
if (!trimmed.startsWith('{')) return false;
|
||||
|
||||
// Check if first line is complete JSON
|
||||
const firstLine = trimmed.split('\n')[0];
|
||||
try {
|
||||
JSON.parse(firstLine);
|
||||
// If multiple lines each parse as JSON, it's JSONL
|
||||
const lines = trimmed.split('\n').filter(l => l.trim());
|
||||
if (lines.length > 1) {
|
||||
// Try to parse second line
|
||||
JSON.parse(lines[1]);
|
||||
return true; // Multiple lines of JSON = JSONL
|
||||
}
|
||||
return false; // Single JSON object
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a native session file and return standardized conversation data
|
||||
*/
|
||||
export function parseSessionFile(filePath: string, tool: string): ParsedSession | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
|
||||
switch (tool) {
|
||||
case 'gemini':
|
||||
return parseGeminiQwenSession(content, tool);
|
||||
case 'qwen':
|
||||
// Qwen can be either JSON (legacy) or JSONL (new format)
|
||||
if (isJSONL(content)) {
|
||||
return parseQwenJSONLSession(content);
|
||||
}
|
||||
return parseGeminiQwenSession(content, tool);
|
||||
case 'codex':
|
||||
return parseCodexSession(content);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse session file ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Gemini or Qwen JSON session file
|
||||
*/
|
||||
function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
|
||||
const session: GeminiQwenSession = JSON.parse(content);
|
||||
const turns: ParsedTurn[] = [];
|
||||
let turnNumber = 0;
|
||||
let totalTokens: TokenInfo = { input: 0, output: 0, cached: 0, total: 0 };
|
||||
let model: string | undefined;
|
||||
|
||||
for (const msg of session.messages) {
|
||||
if (msg.type === 'user') {
|
||||
turnNumber++;
|
||||
turns.push({
|
||||
turnNumber,
|
||||
timestamp: msg.timestamp,
|
||||
role: 'user',
|
||||
content: msg.content
|
||||
});
|
||||
} else if (msg.type === 'gemini' || msg.type === 'qwen') {
|
||||
// Find the corresponding user turn
|
||||
const userTurn = turns.find(t => t.turnNumber === turnNumber && t.role === 'user');
|
||||
|
||||
// Extract thoughts
|
||||
const thoughts = msg.thoughts?.map(t => `${t.subject}: ${t.description}`) || [];
|
||||
|
||||
turns.push({
|
||||
turnNumber,
|
||||
timestamp: msg.timestamp,
|
||||
role: 'assistant',
|
||||
content: msg.content,
|
||||
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
||||
tokens: msg.tokens ? {
|
||||
input: msg.tokens.input,
|
||||
output: msg.tokens.output,
|
||||
cached: msg.tokens.cached,
|
||||
total: msg.tokens.total
|
||||
} : undefined
|
||||
});
|
||||
|
||||
// Accumulate tokens
|
||||
if (msg.tokens) {
|
||||
totalTokens.input = (totalTokens.input || 0) + msg.tokens.input;
|
||||
totalTokens.output = (totalTokens.output || 0) + msg.tokens.output;
|
||||
totalTokens.cached = (totalTokens.cached || 0) + (msg.tokens.cached || 0);
|
||||
totalTokens.total = (totalTokens.total || 0) + msg.tokens.total;
|
||||
}
|
||||
|
||||
if (msg.model) {
|
||||
model = msg.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
tool,
|
||||
projectHash: session.projectHash,
|
||||
startTime: session.startTime,
|
||||
lastUpdated: session.lastUpdated,
|
||||
turns,
|
||||
totalTokens,
|
||||
model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Qwen JSONL session file (new format)
|
||||
*/
|
||||
function parseQwenJSONLSession(content: string): ParsedSession {
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
const turns: ParsedTurn[] = [];
|
||||
|
||||
let sessionId = '';
|
||||
let workingDir = '';
|
||||
let startTime = '';
|
||||
let lastUpdated = '';
|
||||
let model: string | undefined;
|
||||
let totalTokens: TokenInfo = { input: 0, output: 0, cached: 0, total: 0 };
|
||||
let currentTurn = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry: QwenJSONLEntry = JSON.parse(line);
|
||||
lastUpdated = entry.timestamp;
|
||||
|
||||
// Get session info from first entry
|
||||
if (!sessionId && entry.sessionId) {
|
||||
sessionId = entry.sessionId;
|
||||
}
|
||||
if (!workingDir && entry.cwd) {
|
||||
workingDir = entry.cwd;
|
||||
}
|
||||
if (!startTime) {
|
||||
startTime = entry.timestamp;
|
||||
}
|
||||
|
||||
if (entry.type === 'user' && entry.message) {
|
||||
// User message
|
||||
currentTurn++;
|
||||
const textContent = entry.message.parts
|
||||
.map(p => p.text || '')
|
||||
.filter(t => t)
|
||||
.join('\n');
|
||||
|
||||
turns.push({
|
||||
turnNumber: currentTurn,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
content: textContent
|
||||
});
|
||||
} else if (entry.type === 'assistant' && entry.message) {
|
||||
// Assistant response
|
||||
const textContent = entry.message.parts
|
||||
.map(p => p.text || '')
|
||||
.filter(t => t)
|
||||
.join('\n');
|
||||
|
||||
const tokens = entry.usageMetadata ? {
|
||||
input: entry.usageMetadata.promptTokenCount,
|
||||
output: entry.usageMetadata.candidatesTokenCount,
|
||||
cached: entry.usageMetadata.cachedContentTokenCount || 0,
|
||||
total: entry.usageMetadata.totalTokenCount
|
||||
} : undefined;
|
||||
|
||||
turns.push({
|
||||
turnNumber: currentTurn,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
tokens
|
||||
});
|
||||
|
||||
// Accumulate tokens
|
||||
if (tokens) {
|
||||
totalTokens.input = (totalTokens.input || 0) + tokens.input;
|
||||
totalTokens.output = (totalTokens.output || 0) + tokens.output;
|
||||
totalTokens.cached = (totalTokens.cached || 0) + (tokens.cached || 0);
|
||||
totalTokens.total = (totalTokens.total || 0) + tokens.total;
|
||||
}
|
||||
|
||||
if (entry.model) {
|
||||
model = entry.model;
|
||||
}
|
||||
} else if (entry.type === 'system' && entry.subtype === 'ui_telemetry') {
|
||||
// Telemetry event - extract model info if available
|
||||
if (entry.systemPayload?.uiEvent?.model && !model) {
|
||||
model = entry.systemPayload.uiEvent.model;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
tool: 'qwen',
|
||||
workingDir,
|
||||
startTime,
|
||||
lastUpdated,
|
||||
turns,
|
||||
totalTokens,
|
||||
model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Codex JSONL session file
|
||||
*/
|
||||
function parseCodexSession(content: string): ParsedSession {
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
const turns: ParsedTurn[] = [];
|
||||
|
||||
let sessionId = '';
|
||||
let workingDir = '';
|
||||
let startTime = '';
|
||||
let lastUpdated = '';
|
||||
let model: string | undefined;
|
||||
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
||||
|
||||
let currentTurn = 0;
|
||||
let currentToolCalls: ToolCallInfo[] = [];
|
||||
let pendingToolCalls: Map<string, ToolCallInfo> = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed: CodexLine = JSON.parse(line);
|
||||
lastUpdated = parsed.timestamp;
|
||||
|
||||
if (parsed.type === 'session_meta') {
|
||||
const meta = parsed as CodexSessionMeta;
|
||||
sessionId = meta.payload.id;
|
||||
workingDir = meta.payload.cwd;
|
||||
startTime = meta.payload.timestamp;
|
||||
} else if (parsed.type === 'response_item') {
|
||||
const item = parsed as CodexResponseItem;
|
||||
|
||||
if (item.payload.type === 'message' && item.payload.role === 'user') {
|
||||
// User message
|
||||
currentTurn++;
|
||||
const textContent = item.payload.content
|
||||
?.filter(c => c.type === 'input_text')
|
||||
.map(c => c.text)
|
||||
.join('\n') || '';
|
||||
|
||||
turns.push({
|
||||
turnNumber: currentTurn,
|
||||
timestamp: parsed.timestamp,
|
||||
role: 'user',
|
||||
content: textContent
|
||||
});
|
||||
|
||||
// Reset tool calls for new turn
|
||||
currentToolCalls = [];
|
||||
pendingToolCalls.clear();
|
||||
} else if (item.payload.type === 'function_call') {
|
||||
// Tool call
|
||||
const toolCall: ToolCallInfo = {
|
||||
name: item.payload.name || 'unknown',
|
||||
arguments: item.payload.arguments
|
||||
};
|
||||
if (item.payload.call_id) {
|
||||
pendingToolCalls.set(item.payload.call_id, toolCall);
|
||||
}
|
||||
currentToolCalls.push(toolCall);
|
||||
} else if (item.payload.type === 'function_call_output') {
|
||||
// Tool result
|
||||
if (item.payload.call_id && pendingToolCalls.has(item.payload.call_id)) {
|
||||
const toolCall = pendingToolCalls.get(item.payload.call_id)!;
|
||||
toolCall.output = item.payload.output;
|
||||
}
|
||||
} else if (item.payload.type === 'message' && item.payload.role === 'assistant') {
|
||||
// Assistant message (final response)
|
||||
const textContent = item.payload.content
|
||||
?.filter(c => c.type === 'output_text' || c.type === 'text')
|
||||
.map(c => c.text)
|
||||
.join('\n') || '';
|
||||
|
||||
if (textContent) {
|
||||
turns.push({
|
||||
turnNumber: currentTurn,
|
||||
timestamp: parsed.timestamp,
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
toolCalls: currentToolCalls.length > 0 ? [...currentToolCalls] : undefined
|
||||
});
|
||||
}
|
||||
} else if (item.payload.type === 'reasoning') {
|
||||
// Reasoning (may be encrypted, extract summary if available)
|
||||
const summary = item.payload.summary;
|
||||
if (summary && summary.length > 0) {
|
||||
// Add reasoning summary to the last assistant turn
|
||||
const lastAssistantTurn = turns.findLast(t => t.role === 'assistant');
|
||||
if (lastAssistantTurn) {
|
||||
lastAssistantTurn.thoughts = summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (parsed.type === 'event_msg') {
|
||||
const event = parsed as CodexEventMsg;
|
||||
if (event.payload.type === 'token_count' && event.payload.info?.total_token_usage) {
|
||||
const usage = event.payload.info.total_token_usage;
|
||||
totalTokens = {
|
||||
input: usage.input_tokens,
|
||||
output: usage.output_tokens,
|
||||
total: usage.total_tokens
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// If we have tool calls but no final assistant message, create one
|
||||
if (currentToolCalls.length > 0) {
|
||||
const lastTurn = turns[turns.length - 1];
|
||||
if (lastTurn && lastTurn.role === 'user') {
|
||||
// Find if there's already an assistant response for this turn
|
||||
const hasAssistant = turns.some(t => t.turnNumber === currentTurn && t.role === 'assistant');
|
||||
if (!hasAssistant) {
|
||||
turns.push({
|
||||
turnNumber: currentTurn,
|
||||
timestamp: lastUpdated,
|
||||
role: 'assistant',
|
||||
content: '[Tool execution completed]',
|
||||
toolCalls: currentToolCalls
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
tool: 'codex',
|
||||
workingDir,
|
||||
startTime,
|
||||
lastUpdated,
|
||||
turns,
|
||||
totalTokens,
|
||||
model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation as formatted text
|
||||
*/
|
||||
export function formatConversation(session: ParsedSession, options?: {
|
||||
includeThoughts?: boolean;
|
||||
includeToolCalls?: boolean;
|
||||
includeTokens?: boolean;
|
||||
maxContentLength?: number;
|
||||
}): string {
|
||||
const {
|
||||
includeThoughts = false,
|
||||
includeToolCalls = false,
|
||||
includeTokens = false,
|
||||
maxContentLength = 4096
|
||||
} = options || {};
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`=== Session: ${session.sessionId} ===`);
|
||||
lines.push(`Tool: ${session.tool}`);
|
||||
lines.push(`Started: ${session.startTime}`);
|
||||
lines.push(`Updated: ${session.lastUpdated}`);
|
||||
if (session.model) {
|
||||
lines.push(`Model: ${session.model}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
for (const turn of session.turns) {
|
||||
const roleLabel = turn.role === 'user' ? 'USER' : 'ASSISTANT';
|
||||
lines.push(`--- Turn ${turn.turnNumber} [${roleLabel}] ---`);
|
||||
|
||||
const content = turn.content.length > maxContentLength
|
||||
? turn.content.substring(0, maxContentLength) + '\n[truncated]'
|
||||
: turn.content;
|
||||
lines.push(content);
|
||||
|
||||
if (includeThoughts && turn.thoughts && turn.thoughts.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Thoughts:');
|
||||
for (const thought of turn.thoughts) {
|
||||
lines.push(` - ${thought}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeToolCalls && turn.toolCalls && turn.toolCalls.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Tool Calls:');
|
||||
for (const tc of turn.toolCalls) {
|
||||
lines.push(` - ${tc.name}`);
|
||||
if (tc.output) {
|
||||
const output = tc.output.length > 200
|
||||
? tc.output.substring(0, 200) + '...'
|
||||
: tc.output;
|
||||
lines.push(` Output: ${output}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeTokens && turn.tokens) {
|
||||
lines.push(`Tokens: ${turn.tokens.total} (in: ${turn.tokens.input}, out: ${turn.tokens.output})`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (session.totalTokens) {
|
||||
lines.push(`=== Total Tokens: ${session.totalTokens.total} ===`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract just user prompts and assistant responses as simple pairs
|
||||
*/
|
||||
export function extractConversationPairs(session: ParsedSession): Array<{
|
||||
turn: number;
|
||||
userPrompt: string;
|
||||
assistantResponse: string;
|
||||
timestamp: string;
|
||||
}> {
|
||||
const pairs: Array<{
|
||||
turn: number;
|
||||
userPrompt: string;
|
||||
assistantResponse: string;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
const turnNumbers = [...new Set(session.turns.map(t => t.turnNumber))];
|
||||
|
||||
for (const turnNum of turnNumbers) {
|
||||
const userTurn = session.turns.find(t => t.turnNumber === turnNum && t.role === 'user');
|
||||
const assistantTurn = session.turns.find(t => t.turnNumber === turnNum && t.role === 'assistant');
|
||||
|
||||
if (userTurn) {
|
||||
pairs.push({
|
||||
turn: turnNum,
|
||||
userPrompt: userTurn.content,
|
||||
assistantResponse: assistantTurn?.content || '',
|
||||
timestamp: userTurn.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
Reference in New Issue
Block a user