248 lines
8.4 KiB
JavaScript
248 lines
8.4 KiB
JavaScript
import { readdirSync, existsSync, createReadStream, statSync } from 'fs';
|
|
import { join, basename } from 'path';
|
|
import { homedir } from 'os';
|
|
import { createInterface } from 'readline';
|
|
import { BaseEngineAdapter } from './base.js';
|
|
|
|
const CLAUDE_HOME = process.env.CLAUDE_HOME || join(homedir(), '.claude');
|
|
const PROJECTS_DIR = join(CLAUDE_HOME, 'projects');
|
|
|
|
// Directories known to contain subagent spawn files, excluded from top-level sessions
|
|
const SUBAGENT_SUFFIXES = [
|
|
'--claude',
|
|
'-cliproxyapi',
|
|
];
|
|
|
|
export class ClaudeAdapter extends BaseEngineAdapter {
|
|
constructor() {
|
|
super('claude', 'Claude');
|
|
this._sessionMessages = new Map();
|
|
}
|
|
|
|
async checkHealth() {
|
|
const hasDir = existsSync(PROJECTS_DIR);
|
|
this.available = hasDir;
|
|
return this.getHealthInfo();
|
|
}
|
|
|
|
async refresh() {
|
|
this.error = null;
|
|
try {
|
|
await this._scanSessions();
|
|
this.available = true;
|
|
this.lastRefresh = Date.now();
|
|
} catch (err) {
|
|
this.error = err.message;
|
|
console.error(`[ClaudeAdapter] Refresh error:`, err.message);
|
|
}
|
|
return this.getHealthInfo();
|
|
}
|
|
|
|
_isSubagentDir(dirName) {
|
|
return SUBAGENT_SUFFIXES.some(suffix => dirName.endsWith(suffix));
|
|
}
|
|
|
|
async _scanSessions() {
|
|
this._sessions = [];
|
|
this._sessionsMap.clear();
|
|
this._sessionMessages.clear();
|
|
|
|
if (!existsSync(PROJECTS_DIR)) return;
|
|
|
|
const projectDirs = readdirSync(PROJECTS_DIR);
|
|
|
|
for (const dir of projectDirs) {
|
|
const dirPath = join(PROJECTS_DIR, dir);
|
|
if (!statSync(dirPath).isDirectory()) continue;
|
|
|
|
// Skip subagent directories
|
|
if (this._isSubagentDir(dir)) continue;
|
|
|
|
const files = readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
|
for (const file of files) {
|
|
const filePath = join(dirPath, file);
|
|
const session = await this._parseSessionFile(filePath, dir);
|
|
if (session) {
|
|
this._sessions.push(session.session);
|
|
this._sessionsMap.set(session.session.id, session.session);
|
|
if (session.messages?.length) {
|
|
this._sessionMessages.set(session.session.id, session.messages);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async _parseSessionFile(filePath, projectDir) {
|
|
try {
|
|
const sessionId = basename(filePath, '.jsonl');
|
|
let firstUserMessage = '';
|
|
let hasAssistant = false;
|
|
let hasError = false;
|
|
let hasInterrupt = false;
|
|
const toolCalls = {};
|
|
let toolCallCount = 0;
|
|
let messageCount = 0;
|
|
let userMessageCount = 0;
|
|
let assistantMessageCount = 0;
|
|
let earliestTime = null;
|
|
let latestTime = null;
|
|
let cwd = '';
|
|
let model = '';
|
|
let lastType = '';
|
|
const assistantTypes = new Set();
|
|
const messages = [];
|
|
|
|
const rl = createInterface({
|
|
input: createReadStream(filePath, { highWaterMark: 65536 }),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
messageCount++;
|
|
const type = entry.type || '';
|
|
|
|
// Get timestamp from any available field
|
|
const entryTime = entry.timestamp || entry.ts || '';
|
|
|
|
if (type === 'user') {
|
|
const msg = entry.message || {};
|
|
const rawContent = msg.content || entry.content || '';
|
|
const text = typeof rawContent === 'string' ? rawContent :
|
|
Array.isArray(rawContent) ? rawContent.map(c => c.text || c.content || '').join(' ') :
|
|
typeof rawContent === 'object' ? JSON.stringify(rawContent) : '';
|
|
// Skip system-generated command wrapper messages
|
|
const isSystemMsg = text.startsWith('<local-command') || text.startsWith('<command-');
|
|
if (!firstUserMessage && text.trim() && !isSystemMsg) {
|
|
firstUserMessage = text.slice(0, 500);
|
|
}
|
|
userMessageCount++;
|
|
if (!isSystemMsg) {
|
|
messages.push({ time: entryTime, type: 'user_message', message: text.slice(0, 2000) });
|
|
}
|
|
} else if (type === 'assistant') {
|
|
hasAssistant = true;
|
|
assistantMessageCount++;
|
|
const msgContent = entry.message || {};
|
|
const content = msgContent.content || [];
|
|
const textParts = [];
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text') {
|
|
textParts.push(block.text || '');
|
|
} else if (block.type === 'tool_use') {
|
|
const name = block.name || 'unknown';
|
|
toolCalls[name] = (toolCalls[name] || 0) + 1;
|
|
toolCallCount++;
|
|
messages.push({
|
|
time: entryTime,
|
|
type: 'function_call',
|
|
name,
|
|
arguments: block.input ? JSON.stringify(block.input).slice(0, 500) : '',
|
|
});
|
|
} else if (block.type === 'tool_result') {
|
|
const output = typeof block.content === 'string' ? block.content :
|
|
Array.isArray(block.content) ? block.content.map(c => c.text || '').join(' ') : '';
|
|
messages.push({
|
|
time: entryTime,
|
|
type: 'function_call_output',
|
|
output_preview: (output || '').slice(0, 300),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const text = textParts.join(' ').trim();
|
|
if (text) {
|
|
messages.push({ time: entryTime, type: 'assistant_message', message: text.slice(0, 2000) });
|
|
}
|
|
} else if (type === 'ai-title') {
|
|
// title info
|
|
} else if (type === 'error' || type === 'fatal_error') {
|
|
hasError = true;
|
|
} else if (type === 'mode' || type === 'permission-mode') {
|
|
// config entries
|
|
}
|
|
|
|
// Track time from message content
|
|
const rawTs = entry.timestamp || entry.ts || '';
|
|
if (rawTs) {
|
|
const t = new Date(rawTs).getTime();
|
|
if (!isNaN(t)) {
|
|
if (earliestTime === null || t < earliestTime) earliestTime = t;
|
|
if (latestTime === null || t > latestTime) latestTime = t;
|
|
}
|
|
}
|
|
|
|
// Track last entry type for interrupt detection
|
|
if (type === 'user' || type === 'assistant') {
|
|
lastType = type;
|
|
}
|
|
} catch (e) {
|
|
// skip malformed lines
|
|
}
|
|
}
|
|
|
|
if (!hasAssistant && !firstUserMessage) return null;
|
|
|
|
// Convert project dir name to a filesystem path
|
|
// e.g. "-home-liumangmang-IdeaProjects-PR7050" → "/home/liumangmang/IdeaProjects/PR7050"
|
|
if (!cwd) {
|
|
let raw = projectDir;
|
|
// Strip leading dash(es)
|
|
raw = raw.replace(/^-+/, '');
|
|
// Replace inter-segment dashes with /
|
|
raw = raw.replace(/-/g, '/');
|
|
// Ensure leading /
|
|
cwd = '/' + raw;
|
|
// Collapse any double slashes
|
|
cwd = cwd.replace(/\/+/g, '/');
|
|
}
|
|
|
|
// Claude JSONL doesn't include interrupt/error signals reliably.
|
|
// Only set status=error when an explicit error type is found.
|
|
// Otherwise use "unknown" rather than guessing "success".
|
|
|
|
return {
|
|
session: {
|
|
key: `claude:${sessionId}`,
|
|
engine: 'claude',
|
|
engine_label: 'Claude',
|
|
id: sessionId,
|
|
summary: firstUserMessage || '(无消息)',
|
|
cwd: cwd,
|
|
model: model || 'claude',
|
|
cli_version: '',
|
|
source: 'cli',
|
|
created_at: earliestTime || 0,
|
|
updated_at: latestTime || 0,
|
|
duration_ms: earliestTime && latestTime ? latestTime - earliestTime : null,
|
|
status: hasError ? 'error' : 'unknown',
|
|
tool_call_count: toolCallCount,
|
|
tool_calls: toolCalls,
|
|
message_count: userMessageCount + assistantMessageCount,
|
|
resume_command: this.buildResumeCommand(sessionId),
|
|
can_resume: true,
|
|
},
|
|
messages: messages.slice(0, 300),
|
|
};
|
|
} catch (err) {
|
|
console.warn(`[ClaudeAdapter] Parse error ${filePath}:`, err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getMessages(id) {
|
|
if (this._sessionMessages.has(id)) {
|
|
return this._sessionMessages.get(id);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
buildResumeCommand(id) {
|
|
return `claude --resume ${id}`;
|
|
}
|
|
}
|