mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-30 09:21:33 +00:00
refactor: move projects provider specific logic into respective session providers
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import { getSessionMessages } from '@/projects.js';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
|
||||
@@ -15,17 +21,186 @@ type ClaudeToolResult = {
|
||||
type ClaudeHistoryResult =
|
||||
| AnyRecord[]
|
||||
| {
|
||||
messages?: AnyRecord[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
messages?: AnyRecord[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
const loadClaudeSessionMessages = getSessionMessages as unknown as (
|
||||
type ClaudeHistoryMessagesResult =
|
||||
| AnyRecord[]
|
||||
| {
|
||||
messages: AnyRecord[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
offset?: number;
|
||||
limit?: number | null;
|
||||
};
|
||||
|
||||
async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
||||
const tools: AnyRecord[] = [];
|
||||
|
||||
try {
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
|
||||
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content as AnyRecord[]) {
|
||||
if (part.type === 'tool_use') {
|
||||
tools.push({
|
||||
toolId: part.id,
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
timestamp: entry.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content as AnyRecord[]) {
|
||||
if (part.type !== 'tool_result') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
|
||||
if (!tool) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tool.toolResult = {
|
||||
content: typeof part.content === 'string'
|
||||
? part.content
|
||||
: Array.isArray(part.content)
|
||||
? part.content
|
||||
.map((contentPart: AnyRecord) => contentPart?.text || '')
|
||||
.join('\n')
|
||||
: JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines that can happen during concurrent writes.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Error parsing agent file ${filePath}:`, message);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
async function getSessionMessages(
|
||||
projectName: string,
|
||||
sessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
) => Promise<ClaudeHistoryResult>;
|
||||
): Promise<ClaudeHistoryMessagesResult> {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
|
||||
if (!jsonLPath) {
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const files = await fsp.readdir(projectDir);
|
||||
const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
const agentToolsCache = new Map<string, AnyRecord[]>();
|
||||
|
||||
const fileStream = fs.createReadStream(jsonLPath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
if (entry.sessionId === sessionId) {
|
||||
messages.push(entry);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSONL lines that can happen during concurrent writes.
|
||||
}
|
||||
}
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const agentId = message.toolUseResult?.agentId;
|
||||
if (agentId) {
|
||||
agentIds.add(String(agentId));
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
const agentFileName = `agent-${agentId}.jsonl`;
|
||||
if (!agentFiles.includes(agentFileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentFilePath = path.join(projectDir, agentFileName);
|
||||
const tools = await parseAgentTools(agentFilePath);
|
||||
agentToolsCache.set(agentId, tools);
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
const agentId = message.toolUseResult?.agentId;
|
||||
if (!agentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentTools = agentToolsCache.get(String(agentId));
|
||||
if (agentTools && agentTools.length > 0) {
|
||||
message.subagentTools = agentTools;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMessages = messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
const total = sortedMessages.length;
|
||||
|
||||
if (limit === null) {
|
||||
return sortedMessages;
|
||||
}
|
||||
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error reading messages for session ${sessionId}:`, error);
|
||||
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes internal command and system reminder entries into history.
|
||||
@@ -245,7 +420,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
|
||||
let result: ClaudeHistoryResult;
|
||||
try {
|
||||
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
|
||||
result = await getSessionMessages(projectName, sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getCodexSessionMessages } from '@/projects.js';
|
||||
import fsSync from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
@@ -11,14 +14,250 @@ type CodexHistoryResult =
|
||||
messages?: AnyRecord[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
offset?: number;
|
||||
limit?: number | null;
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
|
||||
function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean {
|
||||
if (!payload || payload.type !== 'user_message') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.kind && payload.kind !== 'plain') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof payload.message === 'string' && payload.message.trim().length > 0;
|
||||
}
|
||||
|
||||
function extractCodexTextContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return typeof content === 'string' ? content : '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = item as AnyRecord;
|
||||
if (
|
||||
(record.type === 'input_text' || record.type === 'output_text' || record.type === 'text')
|
||||
&& typeof record.text === 'string'
|
||||
) {
|
||||
return record.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function getCodexSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
) => Promise<CodexHistoryResult>;
|
||||
limit: number | null = null,
|
||||
offset = 0,
|
||||
): Promise<CodexHistoryResult> {
|
||||
try {
|
||||
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
|
||||
if (!sessionFilePath) {
|
||||
console.warn(`Codex session file not found for session ${sessionId}`);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
let tokenUsage: AnyRecord | null = null;
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const info = entry.payload.info as AnyRecord;
|
||||
if (info.total_token_usage) {
|
||||
const usage = info.total_token_usage as AnyRecord;
|
||||
tokenUsage = {
|
||||
used: usage.total_tokens || 0,
|
||||
total: info.model_context_window || 200000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) {
|
||||
messages.push({
|
||||
type: 'user',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: entry.payload.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
entry.type === 'response_item' &&
|
||||
entry.payload?.type === 'message' &&
|
||||
entry.payload.role === 'assistant'
|
||||
) {
|
||||
const textContent = extractCodexTextContent(entry.payload.content);
|
||||
if (textContent.trim()) {
|
||||
messages.push({
|
||||
type: 'assistant',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
|
||||
const summaryText = Array.isArray(entry.payload.summary)
|
||||
? entry.payload.summary
|
||||
.map((item: AnyRecord) => item?.text)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
if (summaryText.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: summaryText,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
let toolName = entry.payload.name;
|
||||
let toolInput = entry.payload.arguments;
|
||||
|
||||
if (toolName === 'shell_command') {
|
||||
toolName = 'Bash';
|
||||
try {
|
||||
const args = JSON.parse(entry.payload.arguments) as AnyRecord;
|
||||
toolInput = JSON.stringify({ command: args.command });
|
||||
} catch {
|
||||
// Keep original arguments when parsing fails.
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName,
|
||||
toolInput,
|
||||
toolCallId: entry.payload.call_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output,
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
||||
const toolName = entry.payload.name || 'custom_tool';
|
||||
const input = entry.payload.input || '';
|
||||
|
||||
if (toolName === 'apply_patch') {
|
||||
const fileMatch = String(input).match(/\*\*\* Update File: (.+)/);
|
||||
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
||||
const lines = String(input).split('\n');
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
|
||||
for (const lineContent of lines) {
|
||||
if (lineContent.startsWith('-') && !lineContent.startsWith('---')) {
|
||||
oldLines.push(lineContent.slice(1));
|
||||
} else if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) {
|
||||
newLines.push(lineContent.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: 'Edit',
|
||||
toolInput: JSON.stringify({
|
||||
file_path: filePath,
|
||||
old_string: oldLines.join('\n'),
|
||||
new_string: newLines.join('\n'),
|
||||
}),
|
||||
toolCallId: entry.payload.call_id,
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName,
|
||||
toolInput: input,
|
||||
toolCallId: entry.payload.call_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output || '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines.
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
const total = messages.length;
|
||||
|
||||
if (limit !== null) {
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = messages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return { messages, tokenUsage };
|
||||
} catch (error) {
|
||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
@@ -275,7 +514,7 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
|
||||
let result: CodexHistoryResult;
|
||||
try {
|
||||
result = await loadCodexSessionMessages(sessionId, limit, offset);
|
||||
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import sessionManager from '@/sessionManager.js';
|
||||
import { getGeminiCliSessionMessages } from '@/projects.js';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId: string): Promise<AnyRecord[]> {
|
||||
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
if (!sessionFilePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(sessionFilePath, 'utf8');
|
||||
const session = JSON.parse(data) as AnyRecord;
|
||||
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
|
||||
|
||||
return sourceMessages.map((msg: AnyRecord) => {
|
||||
const role = msg.type === 'user'
|
||||
? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant')
|
||||
? 'assistant'
|
||||
: msg.type;
|
||||
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((part: AnyRecord) => part?.text)
|
||||
.map((part: AnyRecord) => part.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
message: { role, content },
|
||||
timestamp: msg.timestamp || null,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class GeminiSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes live Gemini stream-json events into the shared message shape.
|
||||
@@ -108,8 +148,7 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Gemini history from the in-memory session manager first, then falls
|
||||
* back to Gemini CLI session files on disk.
|
||||
* Loads Gemini history from Gemini CLI session files on disk.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
@@ -119,11 +158,7 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
|
||||
let rawMessages: AnyRecord[];
|
||||
try {
|
||||
rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[];
|
||||
|
||||
if (rawMessages.length === 0) {
|
||||
rawMessages = await getGeminiCliSessionMessages(sessionId) as AnyRecord[];
|
||||
}
|
||||
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
||||
|
||||
@@ -32,7 +32,7 @@ import os from 'os';
|
||||
import { generateDisplayName } from '@/modules/projects';
|
||||
|
||||
import sessionManager from './sessionManager.js';
|
||||
import { projectsDb, sessionsDb } from './modules/database/index.js';
|
||||
import { projectsDb } from './modules/database/index.js';
|
||||
|
||||
/**
|
||||
* Resolve the absolute project path for a database `projectId`.
|
||||
@@ -71,11 +71,6 @@ function claudeFolderNameFromPath(projectPath) {
|
||||
// Cache for extracted project directories
|
||||
const projectDirectoryCache = new Map();
|
||||
|
||||
// Clear cache when needed (called when project files change)
|
||||
function clearProjectDirectoryCache() {
|
||||
projectDirectoryCache.clear();
|
||||
}
|
||||
|
||||
// Load project configuration file
|
||||
async function loadProjectConfig() {
|
||||
const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
|
||||
@@ -507,164 +502,6 @@ async function parseJsonlSessions(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an agent JSONL file and extract tool uses
|
||||
async function parseAgentTools(filePath) {
|
||||
const tools = [];
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Look for assistant messages with tool_use
|
||||
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content) {
|
||||
if (part.type === 'tool_use') {
|
||||
tools.push({
|
||||
toolId: part.id,
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
timestamp: entry.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for tool results
|
||||
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
// Find the matching tool and add result
|
||||
const tool = tools.find(t => t.toolId === part.tool_use_id);
|
||||
if (tool) {
|
||||
tool.toolResult = {
|
||||
content: typeof part.content === 'string' ? part.content :
|
||||
Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') :
|
||||
JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error parsing agent file ${filePath}:`, error.message);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// Get messages for a specific session with pagination support
|
||||
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
// agent-*.jsonl files contain subagent tool history - we'll process them separately
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
||||
const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
||||
|
||||
if (jsonlFiles.length === 0) {
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
// Map of agentId -> tools for subagent tool grouping
|
||||
const agentToolsCache = new Map();
|
||||
|
||||
// Process all JSONL files to find messages for this session
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const fileStream = fsSync.createReadStream(jsonlFile);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.sessionId === sessionId) {
|
||||
messages.push(entry);
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Silently skip malformed JSONL lines (common with concurrent writes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect agentIds from Task tool results
|
||||
const agentIds = new Set();
|
||||
for (const message of messages) {
|
||||
if (message.toolUseResult?.agentId) {
|
||||
agentIds.add(message.toolUseResult.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Load agent tools for each agentId found
|
||||
for (const agentId of agentIds) {
|
||||
const agentFileName = `agent-${agentId}.jsonl`;
|
||||
if (agentFiles.includes(agentFileName)) {
|
||||
const agentFilePath = path.join(projectDir, agentFileName);
|
||||
const tools = await parseAgentTools(agentFilePath);
|
||||
agentToolsCache.set(agentId, tools);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach agent tools to their parent Task messages
|
||||
for (const message of messages) {
|
||||
if (message.toolUseResult?.agentId) {
|
||||
const agentId = message.toolUseResult.agentId;
|
||||
const agentTools = agentToolsCache.get(agentId);
|
||||
if (agentTools && agentTools.length > 0) {
|
||||
message.subagentTools = agentTools;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort messages by timestamp
|
||||
const sortedMessages = messages.sort((a, b) =>
|
||||
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
||||
);
|
||||
|
||||
const total = sortedMessages.length;
|
||||
|
||||
// If no limit is specified, return all messages (backward compatibility)
|
||||
if (limit === null) {
|
||||
return sortedMessages;
|
||||
}
|
||||
|
||||
// Apply pagination - for recent messages, we need to slice from the end
|
||||
// offset 0 should give us the most recent messages
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error reading messages for session ${sessionId}:`, error);
|
||||
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ID-based wrapper around `deleteSession`.
|
||||
*
|
||||
@@ -734,54 +571,6 @@ async function deleteSession(projectName, sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a project manually to the config (without creating folders)
|
||||
async function addProjectManually(projectPath, displayName = null) {
|
||||
const absolutePath = path.resolve(projectPath);
|
||||
|
||||
try {
|
||||
// Check if the path exists
|
||||
await fs.access(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
|
||||
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
if (config[projectName]) {
|
||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||
}
|
||||
|
||||
// Allow adding projects even if the directory exists - this enables tracking
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
manuallyAdded: true,
|
||||
originalPath: absolutePath
|
||||
};
|
||||
|
||||
if (displayName) {
|
||||
config[projectName].displayName = displayName;
|
||||
}
|
||||
|
||||
await saveProjectConfig(config);
|
||||
|
||||
|
||||
return {
|
||||
name: projectName,
|
||||
path: absolutePath,
|
||||
fullPath: absolutePath,
|
||||
displayName: displayName || await generateDisplayName(projectName, absolutePath),
|
||||
sessions: [],
|
||||
cursorSessions: []
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeComparablePath(inputPath) {
|
||||
if (!inputPath || typeof inputPath !== 'string') {
|
||||
return '';
|
||||
@@ -985,252 +774,6 @@ async function parseCodexSessionFile(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get messages for a specific Codex session
|
||||
async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
console.warn(`Codex session file not found for session ${sessionId}`);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
let tokenUsage = null;
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
// Helper to extract text from Codex content array
|
||||
const extractText = (content) => {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content
|
||||
.map(item => {
|
||||
if (item.type === 'input_text' || item.type === 'output_text') {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === 'text') {
|
||||
return item.text;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Extract token usage from token_count events (keep latest)
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const info = entry.payload.info;
|
||||
if (info.total_token_usage) {
|
||||
tokenUsage = {
|
||||
used: info.total_token_usage.total_tokens || 0,
|
||||
total: info.model_context_window || 200000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use event_msg.user_message for user-visible inputs.
|
||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
|
||||
messages.push({
|
||||
type: 'user',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: entry.payload.message
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// response_item.message may include internal prompts for non-assistant roles.
|
||||
// Keep only assistant output from response_item.
|
||||
if (
|
||||
entry.type === 'response_item' &&
|
||||
entry.payload?.type === 'message' &&
|
||||
entry.payload.role === 'assistant'
|
||||
) {
|
||||
const content = entry.payload.content;
|
||||
const textContent = extractText(content);
|
||||
|
||||
// Only add if there's actual content
|
||||
if (textContent?.trim()) {
|
||||
messages.push({
|
||||
type: 'assistant',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: textContent
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
|
||||
const summaryText = entry.payload.summary
|
||||
?.map(s => s.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (summaryText?.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: summaryText
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
let toolName = entry.payload.name;
|
||||
let toolInput = entry.payload.arguments;
|
||||
|
||||
// Map Codex tool names to Claude equivalents
|
||||
if (toolName === 'shell_command') {
|
||||
toolName = 'Bash';
|
||||
try {
|
||||
const args = JSON.parse(entry.payload.arguments);
|
||||
toolInput = JSON.stringify({ command: args.command });
|
||||
} catch (e) {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: toolName,
|
||||
toolInput: toolInput,
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
||||
const toolName = entry.payload.name || 'custom_tool';
|
||||
const input = entry.payload.input || '';
|
||||
|
||||
if (toolName === 'apply_patch') {
|
||||
// Parse Codex patch format and convert to Claude Edit format
|
||||
const fileMatch = input.match(/\*\*\* Update File: (.+)/);
|
||||
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
||||
|
||||
// Extract old and new content from patch
|
||||
const lines = input.split('\n');
|
||||
const oldLines = [];
|
||||
const newLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
oldLines.push(line.substring(1));
|
||||
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
newLines.push(line.substring(1));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: 'Edit',
|
||||
toolInput: JSON.stringify({
|
||||
file_path: filePath,
|
||||
old_string: oldLines.join('\n'),
|
||||
new_string: newLines.join('\n')
|
||||
}),
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: toolName,
|
||||
toolInput: input,
|
||||
toolCallId: entry.payload.call_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output || ''
|
||||
});
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||
|
||||
const total = messages.length;
|
||||
|
||||
// Apply pagination if limit is specified
|
||||
if (limit !== null) {
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = messages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage
|
||||
};
|
||||
}
|
||||
|
||||
return { messages, tokenUsage };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCodexSession(sessionId) {
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
@@ -1823,72 +1366,13 @@ async function searchGeminiSessionsForProject(
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId) {
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
const fileSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
if (fileSessionId !== sessionId) continue;
|
||||
|
||||
return (session.messages || []).map(msg => {
|
||||
const role = msg.type === 'user' ? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||
: msg.type;
|
||||
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content.filter(p => p.text).map(p => p.text).join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
message: { role, content },
|
||||
timestamp: msg.timestamp || null
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Only functions with consumers outside this module are exported. Folder-name
|
||||
// based helpers (`getSessions`, `deleteSession`, etc.) are kept as internal
|
||||
// implementation details of the id-based wrappers below.
|
||||
export {
|
||||
getSessionMessages,
|
||||
deleteSessionById,
|
||||
addProjectManually,
|
||||
getProjectPathById,
|
||||
claudeFolderNameFromPath,
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession,
|
||||
getGeminiCliSessionMessages,
|
||||
searchConversations
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user