import { readdir, readFile } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { join } from "node:path"; import type { ChatMessage, ChatSession } from "./chat-types"; /** * Claude Desktop (Anthropic) — this parser handles **Local Agent Mode / * Skills sessions only**: one JSON file per invocation, written under: * * macOS: ~/Library/Application Support/Claude/local-agent-mode-sessions/ * Linux: ~/.config/Claude/local-agent-mode-sessions/ * Windows: %APPDATA%/Claude/local-agent-mode-sessions/ * * Directory tree: ///local_.json * Plus a parallel /skills-plugin// subtree with the same * shape. * * Each file captures one agent session: title, initial message, model, slash * commands, and start/last-activity timestamps. The file does NOT persist a * message-by-message log, so we synthesize a single user message from * `initialMessage` so the session survives the ingest pipeline's * messages.length > 0 gate. * * Out of scope for this parser: regular chat history, which Claude Desktop * caches locally in the claude.ai origin's IndexedDB store at * `~/Library/Application Support/Claude/IndexedDB/https_claude.ai_0.indexeddb.leveldb/` * (plus the sibling `.blob/` dir). Parsing that LevelDB-backed store is a * separate ingestion path; it's not handled here. */ export interface LocalAgentSession { sessionId: string; processName?: string; cliSessionId?: string; cwd?: string; userSelectedFolders?: string[]; createdAt: number; lastActivityAt: number; model?: string; isArchived?: boolean; title?: string; initialMessage?: string; vmProcessName?: string; slashCommands?: string[]; } export function getClaudeDesktopDefaultRoot(): string { const plat = platform(); if (plat === "darwin") { return join( homedir(), "Library", "Application Support", "Claude", "local-agent-mode-sessions", ); } if (plat === "linux") { return join(homedir(), ".config", "Claude", "local-agent-mode-sessions"); } if (plat === "win32") { const appData = process.env.APPDATA; if (!appData) return ""; return join(appData, "Claude", "local-agent-mode-sessions"); } return ""; } function coerceNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; return undefined; } function normalizeSession(raw: unknown): LocalAgentSession | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; const sessionId = record.sessionId; const createdAt = coerceNumber(record.createdAt); const lastActivityAt = coerceNumber(record.lastActivityAt); if (typeof sessionId !== "string" || !sessionId) return null; if (createdAt === undefined || lastActivityAt === undefined) return null; return { sessionId, createdAt, lastActivityAt, processName: typeof record.processName === "string" ? record.processName : undefined, cliSessionId: typeof record.cliSessionId === "string" ? record.cliSessionId : undefined, cwd: typeof record.cwd === "string" ? record.cwd : undefined, userSelectedFolders: Array.isArray(record.userSelectedFolders) ? record.userSelectedFolders.filter( (value): value is string => typeof value === "string", ) : undefined, model: typeof record.model === "string" ? record.model : undefined, isArchived: typeof record.isArchived === "boolean" ? record.isArchived : undefined, title: typeof record.title === "string" ? record.title : undefined, initialMessage: typeof record.initialMessage === "string" ? record.initialMessage : undefined, vmProcessName: typeof record.vmProcessName === "string" ? record.vmProcessName : undefined, slashCommands: Array.isArray(record.slashCommands) ? record.slashCommands.filter( (value): value is string => typeof value === "string", ) : undefined, }; } async function listDir(path: string): Promise { try { return await readdir(path); } catch { return []; } } export async function* getClaudeDesktopSessionsStreaming( scanRoots: string[], since?: Date, options?: { includeDefaultRoot?: boolean }, ): AsyncGenerator { // Claude Desktop Local Agent Mode sessions ALWAYS live at a fixed OS path // (the default root). The generic scan-root mechanism seeds every provider's // root to the user's code dir (e.g. ~/code), which can never contain these // sessions — so honoring only the configured root meant Claude Desktop // ingestion silently found nothing. Always scan the canonical default root, // plus any explicitly-configured roots (deduped), so real sessions surface. // includeDefaultRoot=false exists for tests, which need hermetic temp roots // that don't pick up the machine's real sessions. const includeDefaultRoot = options?.includeDefaultRoot ?? true; const defaultRoot = includeDefaultRoot ? getClaudeDesktopDefaultRoot() : ""; const roots = Array.from( new Set([...scanRoots, ...(defaultRoot ? [defaultRoot] : [])]), ); const sinceMs = since?.getTime(); for (const root of roots) { const userDirs = await listDir(root); for (const userDir of userDirs) { const userPath = join(root, userDir); const accountDirs = await listDir(userPath); for (const accountDir of accountDirs) { const accountPath = join(userPath, accountDir); const files = await listDir(accountPath); for (const filename of files) { if (!filename.startsWith("local_") || !filename.endsWith(".json")) { continue; } const filePath = join(accountPath, filename); let raw: string; try { raw = await readFile(filePath, "utf-8"); } catch { continue; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { continue; } const session = normalizeSession(parsed); if (!session) continue; if (sinceMs !== undefined && session.lastActivityAt < sinceMs) { continue; } const chatSession = mapToChatSession(session, filePath); if (!chatSession) continue; yield chatSession; } } } } } function mapToChatSession( session: LocalAgentSession, conversationFile: string, ): ChatSession | null { const text = session.initialMessage?.trim() || session.title?.trim() || ""; if (!text) return null; // no usable content → skip so ingest doesn't skip it anyway const startedAt = new Date(session.createdAt); const endedAt = new Date(session.lastActivityAt); const title = session.title?.trim() || (session.initialMessage ? truncate(session.initialMessage, 80) : "") || session.sessionId; const synthesizedMessage: ChatMessage = { externalMessageId: `${session.sessionId}:initial`, role: "user", timestamp: startedAt, text, }; return { providerId: "claude_desktop", externalThreadId: session.sessionId, title, conversationFile, startedAt, endedAt, metadata: { model: session.model, slashCommands: session.slashCommands, processName: session.processName, cwd: session.cwd, isArchived: session.isArchived ?? false, userSelectedFolders: session.userSelectedFolders, }, messages: [synthesizedMessage], }; } function truncate(text: string, max: number): string { const normalized = text.replace(/\s+/g, " ").trim(); if (normalized.length <= max) return normalized; return `${normalized.slice(0, max)}...`; }