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:
catlog22
2025-12-13 20:29:19 +08:00
parent 32217f87fd
commit 52935d4b8e
26 changed files with 9387 additions and 86 deletions

View File

@@ -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 };

View File

@@ -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';

View 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;
}

View 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';
}
}

View 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;
}