Initial commit
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user