UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

203 lines • 8.27 kB
// Chat-history parity for the mobile client: read the Claude Code session // transcripts the orchestrator's agent writes to disk, so the phone can browse // and resume the SAME conversations the desktop panel drives. // // Claude Code stores one JSONL file per session under // ~/.claude/projects/<cwd-encoded>/<session-id>.jsonl // where <cwd-encoded> is the agent's working directory with every path separator // (and the drive colon) replaced by `-`. Both the panel and the mobile app drive // the same orchestrator process (same cwd), so they share this session pool. // // This is Claude-backend-specific (JSONL shape). Other backends return an empty // list — history parity is a Claude feature for now. import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { logger } from "../utils/logger.js"; /** The Claude projects dir for the current working directory. Claude encodes the * cwd by replacing `:`, `\` and `/` with `-` (e.g. C:\Users\a\b → C--Users-a-b). */ function projectDir(cwd) { const encoded = cwd.replace(/[:\\/]/g, "-"); return join(homedir(), ".claude", "projects", encoded); } /** Pull the plain text out of a Claude message `content` (string or block array), * keeping only human-readable text blocks (skips tool_use / tool_result / thinking). */ function textOf(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts = []; for (const b of content) { if (b && typeof b === "object" && b.type === "text") { const t = b.text; if (typeof t === "string") parts.push(t); } } return parts.join("").trim(); } /** Is this a real user turn (not a tool_result echoed back as a user message)? */ function isRealUserText(content) { if (typeof content === "string") return content.trim().length > 0; if (!Array.isArray(content)) return false; // A tool_result-only user message isn't a human turn. return content.some((b) => b && typeof b === "object" && b.type === "text"); } /** Strip orchestrator-injected prefixes so we show the user's actual message. * Headless (mobile) turns are prepended with the HEADLESS_DIRECTIVE joined by * a blank line; panel turns are clean. Returns "" for a message that is ONLY an * injected directive / transcript-replay seed (so it's dropped). */ function cleanUserText(raw) { let t = raw.trim(); if (t.startsWith("[HEADLESS SESSION")) { const i = t.indexOf("\n\n"); t = i >= 0 ? t.slice(i + 2).trim() : ""; } // The panel replays the whole transcript as a bracketed/angled seed on // reconnect — not a real turn. if (t.startsWith("<") || t.startsWith("[Conversation so far")) return ""; return t; } /** * List the agent's saved sessions for [cwd], newest first. [limit] caps how many * are returned. * * Best-effort about CONTENT: an unparseable file is skipped rather than failing * the list. NOT best-effort about the DIRECTORY (#796): a missing directory is an * empty list (a first run looks exactly like that), but one that exists and * cannot be read THROWS — reporting "no saved sessions" for a permission or IO * error is a wrong answer that reads as data loss. */ export async function listSessions(cwd, limit = 50) { const dir = projectDir(cwd); let files; try { files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")); } catch (err) { // #796 — "you have no saved sessions" and "your sessions could not be read" // are different answers, and this returned the first for both. // // ENOENT/ENOTDIR is genuine: the directory has not been created yet, which is // what a first run looks like, and an empty list is the truth. A permission // error, an IO error or a network path that went away is NOT — and telling // someone their history is empty is the one wrong answer that reads as data // loss. They go looking for conversations they think they lost. // // Thrown rather than returned-with-a-flag because the ONE caller already // wraps this in a `.catch` that pushes `{ sessions: [], error }` to the // panel — plumbing that could never fire for a read failure, because this // swallowed every one of them. const code = err?.code; if (code === "ENOENT" || code === "ENOTDIR") return []; throw new Error(`Could not read the saved-session directory (${dir}): ${err instanceof Error ? err.message : String(err)}. This is NOT the same as having no saved sessions — none could be listed, ` + `so nothing about their existence is known.`); } const summaries = await Promise.all(files.map(async (file) => { const path = join(dir, file); try { const info = await stat(path); const raw = await readFile(path, "utf8"); let title = ""; let messageCount = 0; for (const line of raw.split("\n")) { if (!line.trim()) continue; let d; try { d = JSON.parse(line); } catch { continue; } const msg = d.message; if (!msg || typeof msg !== "object") continue; const role = msg.role; const content = msg.content; if (role === "user" && isRealUserText(content)) { const t = cleanUserText(textOf(content)); if (!t) continue; // pure directive / replay seed — not a turn messageCount++; if (!title) title = t.replace(/\s+/g, " ").slice(0, 100); } else if (role === "assistant" && textOf(content)) { messageCount++; } } if (messageCount === 0) return null; // empty / tool-only session — skip return { sessionId: file.replace(/\.jsonl$/, ""), title: title || "(untitled conversation)", updatedAt: info.mtimeMs, messageCount, }; } catch (err) { logger.debug("history: failed to summarize session", { file, err }); return null; } })); return summaries .filter((s) => s !== null) .sort((a, b) => b.updatedAt - a.updatedAt) .slice(0, limit); } /** * Parse one session's transcript into an ordered user/assistant message list, * dropping tool calls, thinking, and meta rows. Returns [] if the file is * missing or unreadable. */ export async function loadTranscript(cwd, sessionId) { // Guard the id to a bare uuid-ish filename (no path traversal). if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) return []; const path = join(projectDir(cwd), `${sessionId}.jsonl`); let raw; try { raw = await readFile(path, "utf8"); } catch { return []; } const out = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; let d; try { d = JSON.parse(line); } catch { continue; } const msg = d.message; if (!msg || typeof msg !== "object") continue; const role = msg.role; const content = msg.content; if (role === "user") { if (!isRealUserText(content)) continue; // skip tool_result echoes const text = cleanUserText(textOf(content)); if (!text) continue; // pure directive / replay seed out.push({ role: "user", text }); } else if (role === "assistant") { const text = textOf(content); if (text) out.push({ role: "assistant", text }); } } return out; } //# sourceMappingURL=history.js.map