/** * SDKAgent: SDK query loop handler * * Responsibility: * - Spawn Claude subprocess via Agent SDK * - Run event-driven query loop (no polling) * - Process SDK responses (observations, summaries) * - Sync to database and Chroma */ import { execSync } from 'child_process'; import { homedir } from 'os'; import path from 'path'; import { existsSync, readFileSync } from 'fs'; import { DatabaseManager } from './DatabaseManager.js'; import { SessionManager } from './SessionManager.js'; import { logger } from '../../utils/logger.js'; import { parseObservations, parseSummary } from '../../sdk/parser.js'; import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt } from '../../sdk/prompts.js'; import type { ActiveSession, SDKUserMessage, PendingMessage } from '../worker-types.js'; // Import Agent SDK (assumes it's installed) // @ts-ignore - Agent SDK types may not be available import { query } from '@anthropic-ai/claude-agent-sdk'; export class SDKAgent { private dbManager: DatabaseManager; private sessionManager: SessionManager; constructor(dbManager: DatabaseManager, sessionManager: SessionManager) { this.dbManager = dbManager; this.sessionManager = sessionManager; } /** * Start SDK agent for a session (event-driven, no polling) */ async startSession(session: ActiveSession): Promise { try { // Find Claude executable const claudePath = this.findClaudeExecutable(); // Get model ID and disallowed tools const modelId = this.getModelId(); const disallowedTools = ['Bash']; // Prevent infinite loops // Create message generator (event-driven) const messageGenerator = this.createMessageGenerator(session); // Run Agent SDK query loop const queryResult = query({ prompt: messageGenerator, options: { model: modelId, disallowedTools, abortController: session.abortController, pathToClaudeCodeExecutable: claudePath } }); // Process SDK messages for await (const message of queryResult) { // Handle assistant messages if (message.type === 'assistant') { const content = message.message.content; const textContent = Array.isArray(content) ? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n') : typeof content === 'string' ? content : ''; const responseSize = textContent.length; logger.dataOut('SDK', `Response received (${responseSize} chars)`, { sessionId: session.sessionDbId, promptNumber: session.lastPromptNumber }); // Parse and process response await this.processSDKResponse(session, textContent); } // Log result messages if (message.type === 'result' && message.subtype === 'success') { // Usage telemetry is captured at SDK level } } // Mark session complete const sessionDuration = Date.now() - session.startTime; logger.success('SDK', 'Agent completed', { sessionId: session.sessionDbId, duration: `${(sessionDuration / 1000).toFixed(1)}s` }); this.dbManager.getSessionStore().markSessionCompleted(session.sessionDbId); } catch (error: any) { if (error.name === 'AbortError') { logger.warn('SDK', 'Agent aborted', { sessionId: session.sessionDbId }); } else { logger.failure('SDK', 'Agent error', { sessionDbId: session.sessionDbId }, error); } throw error; } finally { // Cleanup this.sessionManager.deleteSession(session.sessionDbId).catch(() => {}); } } /** * Create event-driven message generator (yields messages from SessionManager) */ private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator { // Yield initial user prompt with context yield { type: 'user', message: { role: 'user', content: buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt) }, session_id: session.claudeSessionId, parent_tool_use_id: null, isSynthetic: true }; // Consume pending messages from SessionManager (event-driven, no polling) for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) { if (message.type === 'observation') { // Update last prompt number if (message.prompt_number !== undefined) { session.lastPromptNumber = message.prompt_number; } yield { type: 'user', message: { role: 'user', content: buildObservationPrompt({ id: 0, // Not used in prompt tool_name: message.tool_name!, tool_input: JSON.stringify(message.tool_input), tool_output: JSON.stringify(message.tool_output), created_at_epoch: Date.now() }) }, session_id: session.claudeSessionId, parent_tool_use_id: null, isSynthetic: true }; } else if (message.type === 'summarize') { yield { type: 'user', message: { role: 'user', content: buildSummaryPrompt({ id: session.sessionDbId, sdk_session_id: session.sdkSessionId, project: session.project, user_prompt: session.userPrompt }) }, session_id: session.claudeSessionId, parent_tool_use_id: null, isSynthetic: true }; } } } /** * Process SDK response text (parse XML, save to database, sync to Chroma) */ private async processSDKResponse(session: ActiveSession, text: string): Promise { // Parse observations const observations = parseObservations(text, session.claudeSessionId); // Store observations for (const obs of observations) { const { id: obsId, createdAtEpoch } = this.dbManager.getSessionStore().storeObservation( session.claudeSessionId, session.project, obs, session.lastPromptNumber ); // Sync to Chroma (fire-and-forget) this.dbManager.getChromaSync().syncObservation( obsId, session.claudeSessionId, session.project, obs, session.lastPromptNumber, createdAtEpoch ).catch(() => {}); logger.info('SDK', 'Observation saved', { obsId, type: obs.type }); } // Parse summary const summary = parseSummary(text, session.sessionDbId); // Store summary if (summary) { const { id: summaryId, createdAtEpoch } = this.dbManager.getSessionStore().storeSummary( session.claudeSessionId, session.project, summary, session.lastPromptNumber ); // Sync to Chroma (fire-and-forget) this.dbManager.getChromaSync().syncSummary( summaryId, session.claudeSessionId, session.project, summary, session.lastPromptNumber, createdAtEpoch ).catch(() => {}); logger.info('SDK', 'Summary saved', { summaryId }); } } // ============================================================================ // Configuration Helpers // ============================================================================ /** * Find Claude executable (inline, called once per session) */ private findClaudeExecutable(): string { const claudePath = process.env.CLAUDE_CODE_PATH || execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { encoding: 'utf8' }) .trim().split('\n')[0].trim(); if (!claudePath) { throw new Error('Claude executable not found in PATH'); } return claudePath; } /** * Get model ID from settings or environment */ private getModelId(): string { try { const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json'); if (existsSync(settingsPath)) { const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); const modelId = settings.env?.CLAUDE_MEM_MODEL; if (modelId) return modelId; } } catch { // Fall through to env var or default } return process.env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5'; } }