@agentsky/sky 2.20.2 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{chunk-WTFBVTMI.js → chunk-EBH3KXSU.js} +2 -2
  2. package/dist/{chunk-HB7SPKQ5.js → chunk-XT3W75V3.js} +1 -1
  3. package/dist/{chunk-GHTAIKQ6.js → chunk-YMYQT6KR.js} +70 -34
  4. package/dist/index.js +917 -717
  5. package/dist/{src-QBXMNV2V.js → src-TGRDQ5XR.js} +1 -1
  6. package/dist/{trace-HTWXINXI.js → trace-PDIOL2TC.js} +2 -2
  7. package/dist/{trace-library-QR54Q4Z3.js → trace-library-TA3QUBHJ.js} +97 -11
  8. package/package.json +6 -4
  9. package/vendor/trace-collect/SOURCE.json +6 -0
  10. package/vendor/trace-collect/collect.mjs +75 -0
  11. package/vendor/trace-collect/identity.d.ts +8 -0
  12. package/vendor/trace-collect/identity.mjs +23 -0
  13. package/vendor/trace-collect/muse.mjs +87 -0
  14. package/vendor/trace-collect/normalize.d.ts +6 -0
  15. package/vendor/trace-collect/normalize.mjs +312 -0
  16. package/vendor/trace-collect/scrub.d.ts +2 -0
  17. package/vendor/trace-collect/scrub.mjs +186 -0
  18. package/vendor/trace-collect/sinks/agentsky.mjs +156 -0
  19. package/vendor/trace-collect/sources/_cline-family.mjs +182 -0
  20. package/vendor/trace-collect/sources/_project-root.mjs +40 -0
  21. package/vendor/trace-collect/sources/amp.mjs +18 -0
  22. package/vendor/trace-collect/sources/chatgpt.mjs +203 -0
  23. package/vendor/trace-collect/sources/claude-ai.mjs +94 -0
  24. package/vendor/trace-collect/sources/claude-code.mjs +4 -0
  25. package/vendor/trace-collect/sources/claude-desktop.mjs +87 -0
  26. package/vendor/trace-collect/sources/cline.mjs +14 -0
  27. package/vendor/trace-collect/sources/codex.mjs +4 -0
  28. package/vendor/trace-collect/sources/continue.mjs +117 -0
  29. package/vendor/trace-collect/sources/copilot-chat.mjs +145 -0
  30. package/vendor/trace-collect/sources/cursor-chat.mjs +274 -0
  31. package/vendor/trace-collect/sources/cursor.mjs +4 -0
  32. package/vendor/trace-collect/sources/deepseek.mjs +172 -0
  33. package/vendor/trace-collect/sources/dsh.mjs +145 -0
  34. package/vendor/trace-collect/sources/gemini-cli.mjs +113 -0
  35. package/vendor/trace-collect/sources/gemini.mjs +150 -0
  36. package/vendor/trace-collect/sources/goose.mjs +93 -0
  37. package/vendor/trace-collect/sources/grok-cli.mjs +110 -0
  38. package/vendor/trace-collect/sources/grok.mjs +83 -0
  39. package/vendor/trace-collect/sources/hermes.mjs +4 -0
  40. package/vendor/trace-collect/sources/index.mjs +119 -0
  41. package/vendor/trace-collect/sources/jan.mjs +134 -0
  42. package/vendor/trace-collect/sources/kilo-code.mjs +14 -0
  43. package/vendor/trace-collect/sources/kiro.mjs +77 -0
  44. package/vendor/trace-collect/sources/lm-studio.mjs +84 -0
  45. package/vendor/trace-collect/sources/muse.mjs +11 -0
  46. package/vendor/trace-collect/sources/opencode.mjs +109 -0
  47. package/vendor/trace-collect/sources/qwen-code.mjs +95 -0
  48. package/vendor/trace-collect/sources/roo-code.mjs +14 -0
  49. package/vendor/trace-collect/sources/zed.mjs +70 -0
@@ -0,0 +1,113 @@
1
+ // Gemini CLI stores sessions under ~/.gemini/tmp/<project-dir>/chats/.
2
+ // Each session is either a JSON file { sessionId, projectHash, startTime, lastUpdated, messages[] }
3
+ // or a JSONL file whose first line is the session header and subsequent lines are individual
4
+ // messages (or $set partial-update lines, which we skip).
5
+ //
6
+ // Message shape (both formats):
7
+ // user – { id, timestamp, type:"user", content: [{text}] }
8
+ // gemini – { id, timestamp, type:"gemini", content: string, thoughts?, model?, tokens?,
9
+ // toolCalls?: [{id, name, args, result, status, ...}] }
10
+ // error – { id, timestamp, type:"error", content: string } (harness error; kept)
11
+ //
12
+ // Tool results are embedded inside the gemini message's toolCalls[i].result which is an array of
13
+ // { functionResponse: { id, name, response: { output } } }. We emit a tool result message for each.
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { FORMAT } from "../normalize.mjs";
18
+ import { projectRootResolver } from "./_project-root.mjs";
19
+
20
+ function text(content) {
21
+ if (typeof content === "string") return content;
22
+ if (Array.isArray(content)) return content.map((p) => p?.text ?? "").filter(Boolean).join("\n");
23
+ return "";
24
+ }
25
+
26
+ /** Parse one session file (JSON or JSONL) and return messages + metadata. */
27
+ function parseSession(filePath) {
28
+ const raw = fs.readFileSync(filePath, "utf8");
29
+ const firstChar = raw.trimStart()[0];
30
+ // JSON format: single JSON object
31
+ if (firstChar === "{") {
32
+ try {
33
+ const obj = JSON.parse(raw);
34
+ return { sessionId: obj.sessionId, projectHash: obj.projectHash, startTime: obj.startTime, lastUpdated: obj.lastUpdated, msgs: obj.messages || [] };
35
+ } catch { /* fall through */ }
36
+ }
37
+ // JSONL format: first line is header, rest are messages or $set updates
38
+ const lines = raw.trim().split("\n");
39
+ let header = null;
40
+ const msgs = [];
41
+ for (const line of lines) {
42
+ const t = line.trim();
43
+ if (!t) continue;
44
+ let obj; try { obj = JSON.parse(t); } catch { continue; }
45
+ if (obj.$set) continue; // partial update delta – skip
46
+ if (!header) { header = obj; continue; }
47
+ msgs.push(obj);
48
+ }
49
+ return { sessionId: header?.sessionId, projectHash: header?.projectHash, startTime: header?.startTime, lastUpdated: header?.lastUpdated, msgs };
50
+ }
51
+
52
+ /** Walk a project directory's chats/ folder and yield sessions. */
53
+ function* sessionsFromChatsDir(chatsDir, cwdOf = () => "") {
54
+ let entries; try { entries = fs.readdirSync(chatsDir); } catch { return; }
55
+ for (const name of entries) {
56
+ if (!/\.(json|jsonl)$/.test(name)) continue;
57
+ const filePath = path.join(chatsDir, name);
58
+ let parsed; try { parsed = parseSession(filePath); } catch { continue; }
59
+ const { sessionId, startTime, lastUpdated, msgs } = parsed;
60
+ if (!sessionId) continue;
61
+
62
+ const messages = [];
63
+ let model = "";
64
+ for (const m of msgs) {
65
+ const ts = m.timestamp;
66
+ if (m.type === "user") {
67
+ const t = text(m.content);
68
+ if (t) messages.push({ role: "user", ts, text: t });
69
+ } else if (m.type === "gemini" || m.type === "assistant") {
70
+ model ||= m.model || "";
71
+ const t = text(m.content);
72
+ const calls = (m.toolCalls || []).map((tc) => ({ id: String(tc.id), name: tc.name, input: tc.args ?? {} }));
73
+ const thinking = m.thoughts && typeof m.thoughts === "string" && m.thoughts.trim() ? m.thoughts.trim() : undefined;
74
+ if (t || calls.length || thinking) {
75
+ messages.push({ role: "assistant", ts, text: t, ...(thinking ? { thinking } : {}), ...(calls.length ? { tool_calls: calls } : {}) });
76
+ }
77
+ // Emit tool results from embedded toolCalls[i].result
78
+ for (const tc of m.toolCalls || []) {
79
+ const results = Array.isArray(tc.result) ? tc.result : [];
80
+ for (const r of results) {
81
+ const fr = r?.functionResponse;
82
+ if (!fr) continue;
83
+ const out = fr.response?.output ?? JSON.stringify(fr.response ?? "");
84
+ messages.push({ role: "tool", ts, tool_use_id: String(fr.id ?? tc.id), tool_name: fr.name ?? tc.name, is_error: tc.status === "error", text: String(out).slice(0, 1_000_000) });
85
+ }
86
+ }
87
+ } else if (m.type === "error") {
88
+ // Keep harness errors as assistant messages (they are recorded in the session)
89
+ const t = text(m.content);
90
+ if (t) messages.push({ role: "assistant", ts, text: t });
91
+ }
92
+ }
93
+ if (!messages.some((m) => m.role === "user")) continue;
94
+ yield { format: FORMAT, harness: "gemini-cli", session_id: sessionId, model, title: "", started_at: startTime, ended_at: lastUpdated, cwd: cwdOf(path.basename(path.dirname(chatsDir)), parsed.projectHash, messages), messages };
95
+ }
96
+ }
97
+
98
+ export default {
99
+ id: "gemini-cli",
100
+ name: "Gemini CLI",
101
+ kind: "local",
102
+ roots: (home) => [path.join(home, ".gemini", "tmp")],
103
+ *collect(root) {
104
+ const resolve = projectRootResolver(path.dirname(root), path.dirname(path.dirname(root)));
105
+ // Each entry under root that is a directory is a project; each project has a chats/ subdir.
106
+ let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
107
+ for (const e of entries) {
108
+ if (!e.isDirectory()) continue;
109
+ const chatsDir = path.join(root, e.name, "chats");
110
+ if (fs.existsSync(chatsDir)) yield* sessionsFromChatsDir(chatsDir, resolve);
111
+ }
112
+ },
113
+ };
@@ -0,0 +1,150 @@
1
+ // Google Gemini chat history via Google Takeout → My Activity → Gemini Apps.
2
+ // Export the "My Activity" section in JSON format; the file lands at
3
+ // Takeout/My Activity/Gemini Apps/MyActivity.json
4
+ // (or MyActivity.html if you kept the default HTML format).
5
+ // The JSON is a flat array of activity records per Google's Data Portability schema
6
+ // (developers.google.com/data-portability/schema-reference/my_activity).
7
+ // Each record has header, title (the user prompt/query), titleUrl, time, subtitles[].
8
+ // There is no conversation-id, so entries are grouped into sessions by 30-minute gaps.
9
+ // The HTML variant encodes assistant responses as inline HTML; this adapter strips tags.
10
+ // Evidence: developers.google.com/data-portability/schema-reference/my_activity
11
+ // and workalizer.com/blog/apps-tools/fixing-empty-gemini-history-exports-from-google-takeout
12
+
13
+ const GEMINI_FILE = /(?:^|\/)MyActivity\.(json|html)$/i;
14
+ const GEMINI_HEADER = /gemini|bard/i;
15
+ // Records within this many milliseconds of each other belong to the same session
16
+ const SESSION_GAP_MS = 30 * 60 * 1000;
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { FORMAT } from "../normalize.mjs";
20
+
21
+ function stripHtml(s) {
22
+ if (!s) return "";
23
+ return s.replace(/<[^>]{0,200}>/g, " ").replace(/\s+/g, " ").trim();
24
+ }
25
+
26
+ /** Parse the HTML My Activity file: extract .content-cell text blocks as activity entries. */
27
+ function parseHtml(html) {
28
+ const entries = [];
29
+ // Each activity block is in a div with class content-cell or similar
30
+ const block = /<div[^>]*class="[^"]*content-cell[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
31
+ let m;
32
+ while ((m = block.exec(html)) !== null) {
33
+ const inner = m[1];
34
+ // Pull the text after any heading link as the user query
35
+ const text = stripHtml(inner).replace(/^(Google Gemini|Bard|Used Gemini Apps?)\s*/i, "").trim();
36
+ if (!text || text.length < 3) continue;
37
+ // Try to find a timestamp nearby
38
+ const tsMatch = /<span[^>]*>([\w, :]+(?:AM|PM|UTC|GMT)?)<\/span>/i.exec(inner);
39
+ entries.push({ title: text, time: tsMatch ? tsMatch[1] : null });
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ /** Parse the JSON My Activity file. */
45
+ function parseJson(raw) {
46
+ let data;
47
+ try { data = JSON.parse(raw); } catch { return []; }
48
+ // Top-level array of activity records (Google standard) or {conversations:[...]}
49
+ if (Array.isArray(data)) {
50
+ return data
51
+ .filter((r) => r && GEMINI_HEADER.test(r.header || ""))
52
+ .map((r) => ({
53
+ title: r.title || "",
54
+ time: r.time || null,
55
+ subtitles: Array.isArray(r.subtitles) ? r.subtitles : [],
56
+ details: Array.isArray(r.details) ? r.details : [],
57
+ }));
58
+ }
59
+ // Some guides suggest a {conversations:[{title, create_time, entries:[{role, text}]}]} shape
60
+ if (Array.isArray(data.conversations)) {
61
+ const result = [];
62
+ for (const conv of data.conversations) {
63
+ const entries = Array.isArray(conv.entries) ? conv.entries : [];
64
+ for (const e of entries) {
65
+ result.push({ title: e.text || e.content || "", time: conv.create_time || null, role: e.role || "user" });
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+ return [];
71
+ }
72
+
73
+ /** Group activity entries into sessions by time proximity; each entry becomes a user message. */
74
+ function buildSessions(entries, source) {
75
+ if (!entries.length) return [];
76
+ // Sort by time ascending
77
+ const sorted = [...entries].sort((a, b) => {
78
+ const ta = a.time ? Date.parse(a.time) : 0;
79
+ const tb = b.time ? Date.parse(b.time) : 0;
80
+ return ta - tb;
81
+ });
82
+ const sessions = [];
83
+ let current = null;
84
+ for (const e of sorted) {
85
+ const ts = e.time ? (Number.isFinite(Date.parse(e.time)) ? new Date(Date.parse(e.time)).toISOString() : null) : null;
86
+ const tms = ts ? Date.parse(ts) : 0;
87
+ // Start new session if gap > SESSION_GAP_MS or no current
88
+ if (!current || (tms && current._lastTs && tms - current._lastTs > SESSION_GAP_MS)) {
89
+ current = { entries: [], _firstTs: tms, _lastTs: tms, _source: source };
90
+ sessions.push(current);
91
+ }
92
+ current.entries.push({ ...e, ts });
93
+ if (tms) current._lastTs = tms;
94
+ }
95
+ return sessions.map((s) => buildSession(s));
96
+ }
97
+
98
+ function buildSession(s) {
99
+ const messages = [];
100
+ let firstTs = null, lastTs = null;
101
+ for (const e of s.entries) {
102
+ if (!firstTs) firstTs = e.ts;
103
+ lastTs = e.ts;
104
+ // Determine role
105
+ const isUser = !e.role || e.role.toLowerCase() === "user" || e.role.toLowerCase() === "human";
106
+ const isAssistant = e.role && (e.role.toLowerCase() === "model" || e.role.toLowerCase() === "assistant" || e.role.toLowerCase() === "ai");
107
+ const text = (e.title || "").replace(/^(Searched for|Chatted with Google Gemini about|Used Google Gemini)\s*/i, "").trim() || e.title || "";
108
+ // Subtitles can carry extra context
109
+ const subText = e.subtitles?.map((sub) => (typeof sub === "string" ? sub : sub?.name || "")).filter(Boolean).join("\n") || "";
110
+ const fullText = [text, subText].filter(Boolean).join("\n").trim();
111
+ if (!fullText) continue;
112
+ if (isAssistant) messages.push({ role: "assistant", ts: e.ts, text: fullText });
113
+ else messages.push({ role: "user", ts: e.ts, text: fullText });
114
+ }
115
+ const seed = (firstTs || "") + (s.entries[0]?.title || "");
116
+ return {
117
+ format: FORMAT,
118
+ harness: "gemini",
119
+ model: "",
120
+ session_id: `gemini-${createHash("sha256").update(`gemini:${seed}`).digest("hex").slice(0, 16)}`,
121
+ title: s.entries[0]?.title?.slice(0, 100) || "",
122
+ started_at: firstTs,
123
+ ended_at: lastTs,
124
+ cwd: "",
125
+ source: "gemini-export",
126
+ messages,
127
+ };
128
+ }
129
+
130
+ export default {
131
+ id: "gemini",
132
+ name: "Gemini (Google)",
133
+ kind: "export",
134
+ how: "takeout.google.com → Deselect all → My Activity → (Multiple formats → JSON) → All activity data → Gemini Apps → Create export",
135
+ sniff(l) {
136
+ const f = l.names.find((n) => GEMINI_FILE.test(n));
137
+ if (!f) return false;
138
+ // Confirm it contains Gemini/Bard content
139
+ const head = l.read(f).slice(0, 100_000);
140
+ return GEMINI_HEADER.test(head);
141
+ },
142
+ *collect(input, { listing: l }) {
143
+ const f = l.names.find((n) => GEMINI_FILE.test(n));
144
+ if (!f) return;
145
+ const raw = l.read(f);
146
+ const isHtml = f.toLowerCase().endsWith(".html");
147
+ const entries = isHtml ? parseHtml(raw) : parseJson(raw);
148
+ yield* buildSessions(entries, f);
149
+ },
150
+ };
@@ -0,0 +1,93 @@
1
+ // Goose (Block/Square AI) stores sessions in a SQLite database at
2
+ // ~/.local/share/goose/sessions/sessions.db.
3
+ //
4
+ // Tables of interest:
5
+ // sessions – id, name, working_dir, created_at, updated_at, provider_name, total_tokens,
6
+ // input_tokens, output_tokens, parent_session_id
7
+ // messages – session_id, role, content_json (OpenAI-style content array), created_timestamp,
8
+ // tokens, message_id
9
+ //
10
+ // content_json is an array of blocks: text {type:"text", text}, tool_use, tool_result, etc.
11
+ // created_timestamp is a Unix millisecond integer.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { execFileSync } from "node:child_process";
16
+ import { FORMAT } from "../normalize.mjs";
17
+
18
+ function query(db, sql) {
19
+ try {
20
+ const out = execFileSync("sqlite3", ["-json", db, sql], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
21
+ return JSON.parse(out || "[]");
22
+ } catch { return []; }
23
+ }
24
+
25
+ function textFromContent(content) {
26
+ if (typeof content === "string") return content;
27
+ if (!Array.isArray(content)) return "";
28
+ return content.map((p) => (typeof p === "string" ? p : p?.type === "text" ? (p.text ?? "") : "")).filter(Boolean).join("\n");
29
+ }
30
+
31
+ export default {
32
+ id: "goose",
33
+ name: "Goose (Block AI)",
34
+ kind: "local",
35
+ roots: (home) => [path.join(home, ".local", "share", "goose", "sessions")],
36
+ *collect(root) {
37
+ const db = path.join(root, "sessions.db");
38
+ if (!fs.existsSync(db)) return;
39
+
40
+ const sessions = query(db, "SELECT id, name, working_dir, created_at, updated_at, provider_name, total_tokens, input_tokens, output_tokens, parent_session_id FROM sessions WHERE archived_at IS NULL ORDER BY created_at");
41
+ if (!sessions.length) return;
42
+
43
+ for (const sess of sessions) {
44
+ const rows = query(db, `SELECT role, content_json, created_timestamp, tokens, message_id FROM messages WHERE session_id=${JSON.stringify(sess.id)} ORDER BY created_timestamp, id`);
45
+ if (!rows.length) continue;
46
+
47
+ const messages = [];
48
+ const callNames = new Map();
49
+ let model = "";
50
+
51
+ for (const row of rows) {
52
+ const ts = row.created_timestamp ? new Date(Number(row.created_timestamp)).toISOString() : undefined;
53
+ let content; try { content = JSON.parse(row.content_json); } catch { content = []; }
54
+ if (!Array.isArray(content)) content = [{ type: "text", text: String(content) }];
55
+
56
+ if (row.role === "user") {
57
+ const t = textFromContent(content);
58
+ if (t) messages.push({ role: "user", ts, text: t });
59
+ } else if (row.role === "assistant") {
60
+ const textParts = content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n");
61
+ const tool_calls = content.filter((p) => p?.type === "tool_use").map((c) => {
62
+ callNames.set(c.id, c.name);
63
+ return { id: c.id, name: c.name, input: c.input ?? {} };
64
+ });
65
+ if (textParts || tool_calls.length) {
66
+ messages.push({ role: "assistant", ts, text: textParts, ...(tool_calls.length ? { tool_calls } : {}) });
67
+ }
68
+ } else if (row.role === "tool") {
69
+ // Goose encodes tool results as role:"tool" with content_json being an array of tool_result blocks
70
+ for (const p of content) {
71
+ if (p?.type !== "tool_result") continue;
72
+ const t = textFromContent(p.content);
73
+ messages.push({ role: "tool", ts, tool_use_id: p.tool_use_id, tool_name: callNames.get(p.tool_use_id), is_error: !!p.is_error, text: t.slice(0, 1_000_000) });
74
+ }
75
+ }
76
+ }
77
+
78
+ if (!messages.some((m) => m.role === "user")) continue;
79
+
80
+ const usage = {};
81
+ if (sess.input_tokens) usage.input_tokens = Number(sess.input_tokens);
82
+ if (sess.output_tokens) usage.output_tokens = Number(sess.output_tokens);
83
+ const parentId = sess.parent_session_id || undefined;
84
+
85
+ yield {
86
+ format: FORMAT, harness: "goose", session_id: sess.id, model,
87
+ title: sess.name || "", started_at: sess.created_at, ended_at: sess.updated_at,
88
+ cwd: sess.working_dir || "", ...(parentId ? { parent_session_id: parentId } : {}),
89
+ ...(Object.keys(usage).length ? { usage } : {}), messages,
90
+ };
91
+ }
92
+ },
93
+ };
@@ -0,0 +1,110 @@
1
+ // Grok CLI (x.ai) stores each session under
2
+ // ~/.grok/sessions/<encoded-cwd>/<session-id>/
3
+ // with two key files:
4
+ // summary.json – { id, title, model, created_at, updated_at, parent_session_id, ... }
5
+ // updates.jsonl – ACP session update stream; each line is a JSON event:
6
+ // { type: "user_message", data: { content: [{type:"text",text}], ... } }
7
+ // { type: "assistant_message", data: { message: { content: [{type:"text",text}|...] }, usage? } }
8
+ // { type: "tool_call", data: { id, name, input } }
9
+ // { type: "tool_result", data: { tool_use_id, output, is_error } }
10
+ // (other structural events like turn_start/end, step_start/end are skipped)
11
+ //
12
+ // The sessions/ dir also holds a session_search.sqlite FTS index which we skip.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { FORMAT } from "../normalize.mjs";
17
+
18
+ function text(content) {
19
+ if (typeof content === "string") return content;
20
+ if (Array.isArray(content)) return content.map((p) => (typeof p === "string" ? p : p?.type === "text" ? (p.text ?? "") : "")).filter(Boolean).join("\n");
21
+ return "";
22
+ }
23
+
24
+ function* sessionsFromGroup(groupDir) {
25
+ let entries; try { entries = fs.readdirSync(groupDir, { withFileTypes: true }); } catch { return; }
26
+ for (const e of entries) {
27
+ if (!e.isDirectory()) continue;
28
+ const sessDir = path.join(groupDir, e.name);
29
+ const summaryPath = path.join(sessDir, "summary.json");
30
+ const updatesPath = path.join(sessDir, "updates.jsonl");
31
+ if (!fs.existsSync(updatesPath)) continue;
32
+ let summary = {}; try { summary = JSON.parse(fs.readFileSync(summaryPath, "utf8")); } catch { /* no summary */ }
33
+
34
+ const messages = [];
35
+ const callNames = new Map();
36
+ let model = summary.model || "";
37
+ let started_at = summary.created_at || null;
38
+ let ended_at = summary.updated_at || null;
39
+
40
+ let raw; try { raw = fs.readFileSync(updatesPath, "utf8"); } catch { continue; }
41
+ for (const line of raw.split("\n")) {
42
+ const t = line.trim();
43
+ if (!t) continue;
44
+ let ev; try { ev = JSON.parse(t); } catch { continue; }
45
+ const { type, data = {}, time } = ev;
46
+ const ts = time ? new Date(typeof time === "number" ? time : time).toISOString() : undefined;
47
+ if (ts) { started_at ||= ts; ended_at = ts; }
48
+
49
+ if (type === "user_message" || type === "user/message") {
50
+ const t2 = text(data.content ?? data.text ?? "");
51
+ if (t2) messages.push({ role: "user", ts, text: t2 });
52
+ } else if (type === "assistant_message" || type === "assistant/message") {
53
+ const msg = data.message ?? data;
54
+ model ||= msg.model || "";
55
+ const t2 = text(msg.content ?? msg.text ?? "");
56
+ const thinking = (Array.isArray(msg.content) ? msg.content : []).filter((p) => p?.type === "reasoning" || p?.type === "thinking").map((p) => p.text ?? "").join("\n");
57
+ if (t2 || thinking) messages.push({ role: "assistant", ts, text: t2, ...(thinking ? { thinking } : {}) });
58
+ } else if (type === "tool_call" || type === "tool/call") {
59
+ const id = data.callId ?? data.id ?? "";
60
+ const name = data.name ?? "";
61
+ let input = data.arguments ?? data.input ?? {};
62
+ if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* keep */ } }
63
+ callNames.set(id, name);
64
+ const last = messages[messages.length - 1];
65
+ const call = { id, name, input };
66
+ if (last?.role === "assistant" && !last.text) (last.tool_calls ||= []).push(call);
67
+ else messages.push({ role: "assistant", ts, text: "", tool_calls: [call] });
68
+ } else if (type === "tool_result" || type === "tool/result") {
69
+ const msg = data.message ?? data;
70
+ const content = Array.isArray(msg.content) ? msg.content : [];
71
+ // Grok wraps results in tool-result blocks inside data.message.content[]
72
+ const resultBlocks = content.filter((p) => p?.type === "tool-result" || p?.type === "tool_result");
73
+ if (resultBlocks.length) {
74
+ for (const p of resultBlocks) {
75
+ const rid = p.toolCallId ?? p.tool_use_id ?? data.callId ?? data.id ?? "";
76
+ const inner = Array.isArray(p.content) ? p.content.map((c) => c?.text ?? String(c ?? "")).join("") : String(p.content ?? "");
77
+ messages.push({ role: "tool", ts, tool_use_id: rid, tool_name: callNames.get(rid), is_error: !!(p.isError || data.is_error), text: inner.slice(0, 1_000_000) });
78
+ }
79
+ } else {
80
+ // Flat format: data.output or data.tool_use_id
81
+ const id = data.tool_use_id ?? data.callId ?? data.id ?? "";
82
+ const out = typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? "");
83
+ messages.push({ role: "tool", ts, tool_use_id: id, tool_name: callNames.get(id), is_error: !!data.is_error, text: out.slice(0, 1_000_000) });
84
+ }
85
+ }
86
+ }
87
+
88
+ if (!messages.some((m) => m.role === "user")) continue;
89
+ const sessionId = summary.id || e.name;
90
+ const parentId = summary.parent_session_id || undefined;
91
+ yield { format: FORMAT, harness: "grok-cli", session_id: sessionId, model, title: summary.title || "", started_at, ended_at, cwd: summary.cwd || "", ...(parentId ? { parent_session_id: parentId } : {}), messages };
92
+ }
93
+ }
94
+
95
+ export default {
96
+ id: "grok-cli",
97
+ name: "Grok CLI (xAI)",
98
+ kind: "local",
99
+ roots: (home) => [path.join(home, ".grok")],
100
+ *collect(root) {
101
+ const sessionsDir = path.join(root, "sessions");
102
+ if (!fs.existsSync(sessionsDir)) return;
103
+ let groups; try { groups = fs.readdirSync(sessionsDir, { withFileTypes: true }); } catch { return; }
104
+ for (const g of groups) {
105
+ // Skip the sqlite FTS index and WAL files
106
+ if (!g.isDirectory()) continue;
107
+ yield* sessionsFromGroup(path.join(sessionsDir, g.name));
108
+ }
109
+ },
110
+ };
@@ -0,0 +1,83 @@
1
+ // xAI Grok data export: accounts.x.ai → Settings → Data → Export Data.
2
+ // The download is a zip (or single JSON file) containing prod-grok-backend.json with
3
+ // a top-level `conversations` array; each entry holds a `responses` array of turn objects.
4
+ // Timestamps use MongoDB extended JSON: { "$date": { "$numberLong": "<ms-epoch>" } }.
5
+ // Evidence: ai-chat-importer.com/blog/how-to-export-grok-conversations
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { FORMAT } from "../normalize.mjs";
9
+
10
+ const GROK_FILE = /(?:^|\/)prod-grok-backend\.json$/;
11
+
12
+ /** Decode a MongoDB extended-JSON timestamp to an ISO string (or null). */
13
+ function decodeMongoTs(v) {
14
+ if (!v) return null;
15
+ if (typeof v === "number") return new Date(v).toISOString();
16
+ if (typeof v === "string") return Number.isFinite(Number(v)) ? new Date(Number(v)).toISOString() : v;
17
+ // { "$date": { "$numberLong": "..." } }
18
+ const inner = v["$date"];
19
+ if (!inner) return null;
20
+ if (typeof inner === "string") return Number.isFinite(Number(inner)) ? new Date(Number(inner)).toISOString() : inner;
21
+ const long = inner["$numberLong"];
22
+ return long != null ? new Date(Number(long)).toISOString() : null;
23
+ }
24
+
25
+ function grokSession(conv) {
26
+ const messages = [];
27
+ for (const r of conv.responses || []) {
28
+ const role = (r.sender || r.role || "").toLowerCase();
29
+ const text = typeof r.message === "string" ? r.message : (r.content || r.text || "");
30
+ const ts = decodeMongoTs(r.create_time || r.created_at || r.timestamp);
31
+ if (!text) continue;
32
+ if (role === "human" || role === "user") {
33
+ messages.push({ role: "user", ts, text });
34
+ } else if (role === "assistant" || role === "ai" || role === "grok") {
35
+ messages.push({ role: "assistant", ts, text });
36
+ }
37
+ }
38
+ const started_at = decodeMongoTs(conv.create_time || conv.created_at);
39
+ const ended_at = decodeMongoTs(conv.update_time || conv.updated_at);
40
+ return {
41
+ format: FORMAT,
42
+ harness: "grok",
43
+ model: conv.model || "",
44
+ session_id: `grok-${createHash("sha256").update(`grok:${conv.id || conv.title || ""}`).digest("hex").slice(0, 16)}`,
45
+ title: conv.title || "",
46
+ started_at,
47
+ ended_at,
48
+ cwd: "",
49
+ source: "grok-export",
50
+ messages,
51
+ };
52
+ }
53
+
54
+ export function grokSessions(convs) {
55
+ return convs.map((c) => {
56
+ try { return grokSession(c); } catch { return null; }
57
+ }).filter((s) => s && s.messages.some((m) => m.role === "user") && s.messages.some((m) => m.role === "assistant"));
58
+ }
59
+
60
+ function readGrokData(l) {
61
+ const f = l.names.find((n) => GROK_FILE.test(n));
62
+ if (!f) return [];
63
+ const raw = l.read(f);
64
+ let data;
65
+ try { data = JSON.parse(raw); } catch { return []; }
66
+ // Top level can be { conversations: [...] } or just [...]
67
+ if (Array.isArray(data)) return data;
68
+ if (Array.isArray(data.conversations)) return data.conversations;
69
+ return [];
70
+ }
71
+
72
+ export default {
73
+ id: "grok",
74
+ name: "Grok (xAI)",
75
+ kind: "export",
76
+ how: "accounts.x.ai → Data → Download account data → Export; download the zip from the email",
77
+ sniff(l) {
78
+ return l.names.some((n) => GROK_FILE.test(n));
79
+ },
80
+ *collect(input, { listing: l }) {
81
+ yield* grokSessions(readGrokData(l));
82
+ },
83
+ };
@@ -0,0 +1,4 @@
1
+ // Hermes: read in place by the scan (lib/normalize.mjs); listed here so every agent shows up in one registry.
2
+ import path from "node:path";
3
+
4
+ export default { id: "hermes", name: "Hermes", kind: "native", roots: (home) => [path.join(home, ".hermes/sessions")] };