import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { type ChatMessage, type ChatRole, type ChatSession, clampText, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; /** * Grok CLI (xAI) adapter — https://x.ai/cli * * Grok auto-persists every conversation under ~/.grok/sessions/, grouped by * URL-encoded working directory: * * ~/.grok/sessions/// * summary.json # title, timestamps, model id, message counts * updates.jsonl # ACP session-update stream (chunked) * chat_history.jsonl # raw chat messages sent to the model <-- parsed here * ... * * We parse `chat_history.jsonl` (one xAI/OpenAI-style chat message per line: * `{"role":"user|assistant|system|tool","content": string | ContentPart[]}`) * because it is the flattened, role-tagged message list — cleaner than the * chunked ACP `updates.jsonl` for evidence extraction. Per-message timestamps * are not stored alongside each message, so we synthesize them by interpolating * between the session's created/updated timestamps from `summary.json` * (falling back to the chat_history file mtime). * * When the encoded cwd exceeds 255 bytes Grok stores the original path in a * `.cwd` file inside the group dir; otherwise the group name is the * URL-encoded path. */ interface GrokContentPart { type?: string; text?: string; } interface GrokChatMessage { // Grok CLI tags the role in `type` ("system" | "user" | "assistant" | "tool"), // NOT `role`. Reading `role` left every message defaulting to "system", so no // user messages were recognized and grok produced zero timeline events. Keep // `role` as a fallback for tolerance across grok versions. type?: string; role?: string; content?: string | GrokContentPart[] | null; } interface GrokSummary { sessionId?: string; session_id?: string; title?: string; summary?: string; model?: string; modelId?: string; current_model_id?: string; createdAt?: unknown; created_at?: unknown; updatedAt?: unknown; updated_at?: unknown; } export function getGrokDefaultScanRoots(): string[] { return [join(homedir(), ".grok", "sessions")]; } async function existsDir(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); } catch { return false; } } async function listSubdirs(path: string): Promise { try { const entries = await readdir(path, { withFileTypes: true }); return entries .filter((e) => e.isDirectory() && !e.name.startsWith(".")) .map((e) => e.name); } catch { return []; } } function grokRole(role: string | undefined): ChatRole { if (role === "user") return "user"; if (role === "assistant") return "assistant"; if (role === "tool") return "tool"; return "system"; } function extractGrokText(content: GrokChatMessage["content"]): string { if (typeof content === "string") return clampText(content.trim(), 32000); if (Array.isArray(content)) { const parts = content .map((part) => { if (typeof part === "string") return part; if (part && typeof part === "object" && typeof part.text === "string") { return part.text; } return ""; }) .map((t) => t.trim()) .filter(Boolean); return clampText(parts.join("\n\n"), 32000); } return ""; } /** * Resolve the working directory for a session group directory. Prefers the * `.cwd` sidecar file (written when the encoded name is too long); otherwise * URL-decodes the directory name. */ async function resolveGroupCwd(groupDir: string): Promise { try { const raw = await readFile(join(groupDir, ".cwd"), "utf-8"); const trimmed = raw.trim(); if (trimmed) return trimmed; } catch { // no .cwd sidecar — fall through to decoding the directory name } try { return decodeURIComponent(basename(groupDir)); } catch { return undefined; } } /** * Assign a timestamp to message `index` of `total`, interpolating linearly * between the session start and end. Falls back to `start` when the span is * degenerate (single message or start === end). */ function interpolateTimestamp( start: Date, end: Date, index: number, total: number, ): Date { const span = end.getTime() - start.getTime(); if (total <= 1 || span <= 0) return new Date(start.getTime()); const step = span / (total - 1); return new Date(start.getTime() + Math.round(step * index)); } export async function parseGrokSessionDir( sessionDir: string, cwd?: string, ): Promise { const sessionIdFromDir = basename(sessionDir); const historyPath = join(sessionDir, "chat_history.jsonl"); let raw: string; try { raw = await readFile(historyPath, "utf-8"); } catch { // A session directory without a chat_history file has nothing to ingest // (e.g. only summary/plan written before the first turn). Skip quietly. return null; } const lines = raw.split("\n").filter((l) => l.trim()); if (lines.length === 0) return null; // summary.json is optional metadata; parse defensively. let summary: GrokSummary = {}; try { summary = JSON.parse( await readFile(join(sessionDir, "summary.json"), "utf-8"), ) as GrokSummary; } catch { summary = {}; } const sessionId = summary.sessionId ?? summary.session_id ?? sessionIdFromDir; const title = summary.title ?? summary.summary; const model = summary.current_model_id ?? summary.model ?? summary.modelId; let fileMtime: Date | undefined; try { fileMtime = (await stat(historyPath)).mtime; } catch { fileMtime = undefined; } const startedAt = toDateOrUndefined(summary.createdAt) ?? toDateOrUndefined(summary.created_at) ?? fileMtime; const endedAt = toDateOrUndefined(summary.updatedAt) ?? toDateOrUndefined(summary.updated_at) ?? fileMtime; // Parse messages first so we can interpolate timestamps across the count. interface ParsedMessage { role: ChatRole; text: string; } const parsed: ParsedMessage[] = []; let skippedLines = 0; let firstSkippedSample: string | undefined; for (const line of lines) { let records: GrokChatMessage[]; try { const value = JSON.parse(line) as GrokChatMessage | GrokChatMessage[]; // chat_history is normally one message per line, but tolerate a line // that is itself an array of messages. records = Array.isArray(value) ? value : [value]; } catch { skippedLines++; if (firstSkippedSample === undefined) { firstSkippedSample = line.slice(0, 120); } continue; } for (const record of records) { if (!record || typeof record !== "object") continue; const text = extractGrokText(record.content); if (!text) continue; parsed.push({ role: grokRole(record.type ?? record.role), text }); } } if (skippedLines > 0) { console.warn( `[grok] skipped ${skippedLines} malformed line(s) in ${historyPath}` + (firstSkippedSample ? ` (first: ${firstSkippedSample})` : ""), ); } if (parsed.length === 0) return null; const anchor = startedAt ?? endedAt ?? new Date(); const spanStart = startedAt ?? anchor; const spanEnd = endedAt ?? anchor; const messages: ChatMessage[] = parsed.map((m, index) => ({ externalMessageId: stableMessageId(sessionId, index, `grok-${index}`), role: m.role, timestamp: interpolateTimestamp(spanStart, spanEnd, index, parsed.length), text: m.text, })); return { providerId: "grok", externalThreadId: sessionId, projectPath: cwd, title, conversationFile: historyPath, startedAt: messages[0].timestamp, endedAt: messages[messages.length - 1].timestamp, metadata: { model, cwd }, messages, }; } export async function* getGrokSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const roots = options?.dataRoots ?? getGrokDefaultScanRoots(); for (const root of roots) { // The sessions tree only exists once Grok has run at least once. if (!(await existsDir(root))) continue; for (const groupName of await listSubdirs(root)) { const groupDir = join(root, groupName); const cwd = await resolveGroupCwd(groupDir); for (const sessionName of await listSubdirs(groupDir)) { const sessionDir = join(groupDir, sessionName); if (since) { try { const s = await stat(join(sessionDir, "chat_history.jsonl")); if (s.mtime < since) continue; } catch { continue; } } const session = await parseGrokSessionDir(sessionDir, cwd); if (!session) continue; if (!isInScanRoots(session.projectPath, scanRoots)) continue; if (since) { session.messages = session.messages.filter( (m) => m.timestamp >= since, ); if (session.messages.length === 0) continue; } yield session; } } } }