import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { resolveOpenClawStateDir } from "./workspace"; export type GatewaySessionEntry = { sessionKey: string; sessionId: string; channel: string; origin: { label?: string; provider: string; surface: string; chatType: string; from?: string; to?: string; accountId?: string; }; updatedAt: number; chatType: string; }; export type ChannelStatus = { id: string; configured: boolean; running: boolean; connected: boolean; error?: string; lastMessage?: number; }; export type TranscriptMessage = { id: string; role: "user" | "assistant"; content: string; parts?: Array>; timestamp: string; }; function deriveChannelFromKey(sessionKey: string, lastChannel?: string): string { if (lastChannel && lastChannel !== "unknown") return lastChannel; const parts = sessionKey.split(":"); if (parts.length >= 3) { const segment = parts[2]; if (segment === "web" || segment === "main") return "webchat"; if (segment === "cron") return "cron"; if (segment === "telegram" || segment === "whatsapp" || segment === "discord" || segment === "slack" || segment === "signal" || segment === "imessage" || segment === "nostr" || segment === "googlechat") return segment; } return lastChannel || "unknown"; } export function readGatewaySessionsForAgent(agentId: string): GatewaySessionEntry[] { const stateDir = resolveOpenClawStateDir(); const sessionsFile = join(stateDir, "agents", agentId, "sessions", "sessions.json"); if (!existsSync(sessionsFile)) return []; let data: Record>; try { data = JSON.parse(readFileSync(sessionsFile, "utf-8")); } catch { return []; } const entries: GatewaySessionEntry[] = []; for (const [key, val] of Object.entries(data)) { if (key.includes(":subagent:")) continue; const channel = deriveChannelFromKey(key, val.lastChannel as string | undefined); if (channel === "webchat" || channel === "unknown") continue; const origin = (val.origin ?? {}) as Record; entries.push({ sessionKey: key, sessionId: val.sessionId as string, channel, origin: { label: origin.label as string | undefined, provider: (origin.provider ?? channel) as string, surface: (origin.surface ?? channel) as string, chatType: (origin.chatType ?? val.chatType ?? "direct") as string, from: origin.from as string | undefined, to: origin.to as string | undefined, accountId: origin.accountId as string | undefined, }, updatedAt: val.updatedAt as number ?? 0, chatType: (val.chatType ?? "direct") as string, }); } entries.sort((a, b) => b.updatedAt - a.updatedAt); return entries; } export function listAllAgentIds(): string[] { const stateDir = resolveOpenClawStateDir(); const agentsDir = join(stateDir, "agents"); if (!existsSync(agentsDir)) return []; try { return readdirSync(agentsDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); } catch { return []; } } export function findSessionTranscriptFile(sessionId: string): string | null { const stateDir = resolveOpenClawStateDir(); const agentsDir = join(stateDir, "agents"); if (!existsSync(agentsDir)) return null; try { for (const d of readdirSync(agentsDir, { withFileTypes: true })) { if (!d.isDirectory()) continue; const p = join(agentsDir, d.name, "sessions", `${sessionId}.jsonl`); if (existsSync(p)) return p; } } catch { /* ignore */ } return null; } export function parseTranscriptToMessages(content: string): TranscriptMessage[] { const lines = content.trim().split("\n").filter((l) => l.trim()); const messages: TranscriptMessage[] = []; const pendingToolCalls = new Map(); let currentAssistant: TranscriptMessage | null = null; const flushAssistant = () => { if (!currentAssistant) return; const textSummary = (currentAssistant.parts ?? []) .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text as string) .join("\n") .slice(0, 200); currentAssistant.content = textSummary; messages.push(currentAssistant); currentAssistant = null; }; for (const line of lines) { let entry: Record; try { entry = JSON.parse(line); } catch { continue; } if (entry.type !== "message" || !entry.message) continue; const msg = entry.message as Record; const role = msg.role as string; if (role === "toolResult") { const toolCallId = (msg.toolCallId as string) ?? ""; const rawContent = msg.content; const outputText = typeof rawContent === "string" ? rawContent : Array.isArray(rawContent) ? (rawContent as Array<{ type: string; text?: string }>) .filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") : JSON.stringify(rawContent ?? ""); let result: unknown; try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; } const assistantParts = currentAssistant?.parts; if (assistantParts) { const tc = assistantParts.find( (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, ); if (tc) { delete tc.state; tc.result = result; continue; } } for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role !== "assistant") continue; const tc = messages[i].parts?.find( (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, ); if (tc) { delete tc.state; tc.result = result; } break; } continue; } if (role === "user") { flushAssistant(); } if (role !== "user" && role !== "assistant") continue; const parts: Array> = []; if (Array.isArray(msg.content)) { for (const part of msg.content as Array>) { if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { parts.push({ type: "text", text: part.text }); } else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) { parts.push({ type: "reasoning", text: part.thinking }); } else if (part.type === "toolCall") { const toolName = (part.name ?? part.toolName ?? "unknown") as string; const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string; const args = part.arguments ?? part.input ?? part.args ?? {}; pendingToolCalls.set(toolCallId, { toolName, args }); parts.push({ type: "tool-invocation", toolCallId, toolName, args }); } else if (part.type === "tool_use" || part.type === "tool-call") { const toolName = (part.name ?? part.toolName ?? "unknown") as string; const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string; const args = part.input ?? part.args ?? {}; pendingToolCalls.set(toolCallId, { toolName, args }); parts.push({ type: "tool-invocation", toolCallId, toolName, args }); } else if (part.type === "tool_result" || part.type === "tool-result") { const toolCallId = (part.tool_use_id ?? part.toolCallId ?? "") as string; const pending = pendingToolCalls.get(toolCallId); const raw = part.content ?? part.output; const outputText = typeof raw === "string" ? raw : Array.isArray(raw) ? (raw as Array<{ type: string; text?: string }>).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") : JSON.stringify(raw ?? ""); let result: unknown; try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; } const existingMsg = messages[messages.length - 1]; if (existingMsg) { const tc = existingMsg.parts?.find( (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, ); if (tc) { delete tc.state; tc.result = result; continue; } } parts.push({ type: "tool-invocation", toolCallId, toolName: pending?.toolName ?? "tool", args: pending?.args ?? {}, result, }); } } } else if (typeof msg.content === "string" && msg.content.trim()) { parts.push({ type: "text", text: msg.content }); } if (parts.length > 0) { const timestamp = (entry.timestamp as string) ?? new Date((entry.ts as number) ?? Date.now()).toISOString(); if (role === "assistant") { if (!currentAssistant) { currentAssistant = { id: (entry.id as string) ?? `msg-${messages.length}`, role: "assistant", content: "", parts: [], timestamp, }; } currentAssistant.parts = [...(currentAssistant.parts ?? []), ...parts]; currentAssistant.timestamp = timestamp; } else { messages.push({ id: (entry.id as string) ?? `msg-${messages.length}`, role: "user", content: parts .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text as string) .join("\n") .slice(0, 200), parts, timestamp, }); } } } flushAssistant(); return messages; } export function sessionDisplayTitle(entry: GatewaySessionEntry): string { if (entry.origin.label) return entry.origin.label; if (entry.channel === "cron") { const cronMatch = entry.sessionKey.match(/cron:([^:]+)/); return cronMatch ? `Cron: ${cronMatch[1].slice(0, 8)}` : "Cron Job"; } const channelName = entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1); return `${channelName} Session`; }