@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,274 @@
1
+ // Cursor chat / composer history.
2
+ // Source: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb (SQLite)
3
+ // ~/Library/Application Support/Cursor/User/workspaceStorage/*/state.vscdb
4
+ //
5
+ // Tables used:
6
+ // composerHeaders – one row per composer: composerId, workspaceId, createdAt, lastUpdatedAt,
7
+ // isArchived, isSubagent, value (JSON head with name/subtitle/workspace)
8
+ // cursorDiskKV – key=composerData:<id> : composer metadata + fullConversationHeadersOnly
9
+ // – key=bubbleId:<composerId>:<bubbleId> : individual message bubble
10
+ //
11
+ // Bubble types: 1=user, 2=assistant.
12
+ // capabilityType: 15=tool call/result (toolFormerData has id/name/rawArgs/status),
13
+ // 30=thinking (thinking field as JSON string with {text}).
14
+ //
15
+ // De-duplication vs "cursor" native source: that source reads ~/.cursor/projects JSONL
16
+ // (agent-transcript format). composer data in state.vscdb is the chat UI and is never
17
+ // stored under ~/.cursor/projects – no overlap.
18
+
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { execFileSync } from "node:child_process";
23
+ import { createHash } from "node:crypto";
24
+ import { FORMAT } from "../normalize.mjs";
25
+
26
+ const GLOBAL_DB = "Library/Application Support/Cursor/User/globalStorage/state.vscdb";
27
+ const WORKSPACE_DB_GLOB = "Library/Application Support/Cursor/User/workspaceStorage";
28
+
29
+ function dbRoots(home) {
30
+ const roots = [];
31
+ const global = path.join(home, GLOBAL_DB);
32
+ if (fs.existsSync(global)) roots.push(global);
33
+ const wsRoot = path.join(home, WORKSPACE_DB_GLOB);
34
+ try {
35
+ for (const d of fs.readdirSync(wsRoot)) {
36
+ const p = path.join(wsRoot, d, "state.vscdb");
37
+ if (fs.existsSync(p)) roots.push(p);
38
+ }
39
+ } catch { /* no workspace storage */ }
40
+ return roots;
41
+ }
42
+
43
+ /** workspaceStorage/<id>/workspace.json → the folder (or first folder of a .code-workspace) that id stands for. */
44
+ function workspaceFolders(home) {
45
+ const out = new Map();
46
+ const wsRoot = path.join(home, WORKSPACE_DB_GLOB);
47
+ let ids = []; try { ids = fs.readdirSync(wsRoot); } catch { return out; }
48
+ for (const id of ids) {
49
+ try {
50
+ const j = JSON.parse(fs.readFileSync(path.join(wsRoot, id, "workspace.json"), "utf8"));
51
+ let uri = j.folder;
52
+ if (!uri && j.workspace) { const w = JSON.parse(fs.readFileSync(new URL(j.workspace), "utf8")); uri = w.folders?.[0]?.path ? new URL(w.folders[0].path, j.workspace).href : null; }
53
+ if (uri?.startsWith("file://")) out.set(id, decodeURIComponent(new URL(uri).pathname));
54
+ } catch { /* remote or deleted workspace */ }
55
+ }
56
+ return out;
57
+ }
58
+ const uriPath = (u) => (typeof u === "string" ? (u.startsWith("file://") ? decodeURIComponent(new URL(u).pathname) : u) : u?.fsPath || u?.path || "");
59
+
60
+ /** Copy db (and WAL/SHM if present) to a temp path so sqlite3 can access it without locks.
61
+ * Note: -readonly fails in WAL mode because sqlite needs to write a checkpoint; we use
62
+ * a fresh copy instead and open without -readonly. */
63
+ function tempCopy(dbPath) {
64
+ const suffix = createHash("md5").update(dbPath).digest("hex").slice(0, 8);
65
+ const tmp = path.join(os.tmpdir(), `cursor-chat-${suffix}.vscdb`);
66
+ try { fs.copyFileSync(dbPath, tmp); } catch { return null; }
67
+ // Copy WAL and SHM if present so sqlite3 can read a consistent snapshot
68
+ for (const ext of ["-wal", "-shm"]) {
69
+ try { fs.copyFileSync(dbPath + ext, tmp + ext); } catch { /* ok if absent */ }
70
+ }
71
+ return tmp;
72
+ }
73
+
74
+ function queryJson(db, sql) {
75
+ try {
76
+ const out = execFileSync("sqlite3", ["-json", db, sql], { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
77
+ return JSON.parse(out || "[]");
78
+ } catch { return []; }
79
+ }
80
+
81
+ function parseThinking(raw) {
82
+ if (!raw) return "";
83
+ if (typeof raw === "object") return raw.text || JSON.stringify(raw);
84
+ try {
85
+ const p = JSON.parse(raw);
86
+ if (p && typeof p.text === "string") return p.text;
87
+ return raw;
88
+ } catch { return raw; }
89
+ }
90
+
91
+ function parseInput(rawArgs) {
92
+ if (!rawArgs) return {};
93
+ try { return JSON.parse(rawArgs); } catch { return { _raw: rawArgs }; }
94
+ }
95
+
96
+ /** Convert bubbles (ordered by fullConversationHeadersOnly) into trace/v1 messages. */
97
+ function buildMessages(headers, bubbleMap) {
98
+ const messages = [];
99
+
100
+ for (const h of headers) {
101
+ const bub = bubbleMap.get(h.bubbleId);
102
+ if (!bub) continue;
103
+
104
+ const ts = h.createdAt || bub.createdAt || null;
105
+ const btype = bub.type ?? h.type;
106
+ const capType = bub.capabilityType ?? h.grouping?.capabilityType ?? null;
107
+
108
+ if (btype === 1) {
109
+ // User message
110
+ const text = bub.text || bub.richText || "";
111
+ const attachments = [];
112
+ for (const img of (bub.images || [])) {
113
+ attachments.push({ kind: "image", media_type: img.mediaType || "image/png", name: img.fileName || "" });
114
+ }
115
+ if (text || attachments.length) {
116
+ messages.push({ role: "user", ts, text, ...(attachments.length ? { attachments } : {}) });
117
+ }
118
+ continue;
119
+ }
120
+
121
+ if (btype === 2) {
122
+ if (capType === 30) {
123
+ // Thinking bubble
124
+ const thinking = parseThinking(bub.thinking);
125
+ if (thinking) messages.push({ role: "assistant", ts, text: "", thinking });
126
+ continue;
127
+ }
128
+
129
+ if (capType === 15) {
130
+ // Tool call + result bubble
131
+ const tfd = bub.toolFormerData;
132
+ if (tfd && tfd.toolCallId) {
133
+ const callId = tfd.toolCallId;
134
+ const name = tfd.name || "tool";
135
+ const input = parseInput(tfd.rawArgs);
136
+ const isError = tfd.status === "failed" || tfd.status === "error";
137
+
138
+ // Attach call to preceding assistant bubble or create new one
139
+ const last = messages[messages.length - 1];
140
+ if (last?.role === "assistant" && !last.text && !last.thinking) {
141
+ (last.tool_calls ||= []).push({ id: callId, name, input });
142
+ } else {
143
+ messages.push({ role: "assistant", ts, text: "", tool_calls: [{ id: callId, name, input }] });
144
+ }
145
+ // Result – the binary is protobuf; emit empty text to satisfy tool_use_id linkage
146
+ messages.push({ role: "tool", ts, tool_use_id: callId, tool_name: name, is_error: isError, text: "" });
147
+ }
148
+ continue;
149
+ }
150
+
151
+ // Normal assistant text / fallback
152
+ const text = bub.text || "";
153
+ const thinking = parseThinking(bub.thinking);
154
+ if (text || thinking) {
155
+ messages.push({ role: "assistant", ts, text, ...(thinking ? { thinking } : {}) });
156
+ }
157
+ }
158
+ }
159
+ return messages;
160
+ }
161
+
162
+ function* collectDb(dbPath, home) {
163
+ const tmp = tempCopy(dbPath);
164
+ if (!tmp) return;
165
+ try {
166
+ // Load composer metadata
167
+ const headers = queryJson(tmp, "SELECT composerId, workspaceId, createdAt, lastUpdatedAt, isArchived, value FROM composerHeaders WHERE isArchived=0 ORDER BY lastUpdatedAt ASC");
168
+ if (!headers.length) return;
169
+
170
+ const composerIds = headers.map((h) => h.composerId);
171
+
172
+ // Load all bubbleId keys for these composers in one query
173
+ const idList = composerIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(",");
174
+ const bubbleRows = queryJson(tmp, `SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' AND substr(key, 10, instr(substr(key,10),':')-1) IN (${idList}) LIMIT 200000`);
175
+
176
+ // Build per-composer bubble maps
177
+ const perComposer = new Map();
178
+ for (const row of bubbleRows) {
179
+ // key = bubbleId:<composerId>:<bubbleId>
180
+ const rest = row.key.slice("bubbleId:".length);
181
+ const colon = rest.indexOf(":");
182
+ if (colon < 0) continue;
183
+ const cid = rest.slice(0, colon);
184
+ const bid = rest.slice(colon + 1);
185
+ let bub;
186
+ try { bub = JSON.parse(row.value); } catch { continue; }
187
+ let map = perComposer.get(cid);
188
+ if (!map) { map = new Map(); perComposer.set(cid, map); }
189
+ map.set(bid, bub);
190
+ }
191
+
192
+ // Load composerData for each (to get fullConversationHeadersOnly order)
193
+ const cdRows = queryJson(tmp, `SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND substr(key, 14) IN (${idList})`);
194
+ const composerData = new Map();
195
+ for (const row of cdRows) {
196
+ const cid = row.key.slice("composerData:".length);
197
+ try { composerData.set(cid, JSON.parse(row.value)); } catch { }
198
+ }
199
+
200
+ const folders = workspaceFolders(home);
201
+ // Pre-edit contents Cursor keeps for every file a composer changed, referenced by contentKey.
202
+ const contentKeys = [...composerData.values()].flatMap((cd) => Object.values(cd?.originalFileStates ?? {}).map((f) => f?.contentKey)).filter((k) => typeof k === "string");
203
+ const originals = new Map();
204
+ for (let i = 0; i < contentKeys.length; i += 400) {
205
+ const list = contentKeys.slice(i, i + 400).map((k) => `'${k.replace(/'/g, "''")}'`).join(",");
206
+ for (const r of queryJson(tmp, `SELECT key, value FROM cursorDiskKV WHERE key IN (${list})`)) originals.set(r.key, r.value);
207
+ }
208
+ for (const h of headers) {
209
+ const cid = h.composerId;
210
+ const bubMap = perComposer.get(cid) || new Map();
211
+ const cd = composerData.get(cid);
212
+ const orderedHeaders = cd?.fullConversationHeadersOnly || [];
213
+ if (!orderedHeaders.length && bubMap.size === 0) continue;
214
+
215
+ let meta;
216
+ try { meta = JSON.parse(h.value); } catch { meta = {}; }
217
+
218
+ const messages = buildMessages(orderedHeaders, bubMap);
219
+ if (!messages.some((m) => m.role === "user")) continue;
220
+
221
+ const models = [...new Set(
222
+ [...bubMap.values()]
223
+ .map((b) => b.modelType)
224
+ .filter((m) => m && typeof m === "string")
225
+ )];
226
+
227
+ const started = h.createdAt ? new Date(Number(h.createdAt)).toISOString() : null;
228
+ const ended = h.lastUpdatedAt ? new Date(Number(h.lastUpdatedAt)).toISOString() : null;
229
+ const wsId = cd?.workspaceIdentifier ?? meta.workspaceIdentifier ?? {};
230
+ const repos = (cd?.trackedGitRepos ?? []).filter((r) => r?.repoPath);
231
+ const workspace = uriPath(wsId.uri) || folders.get(wsId.id || h.workspaceId) || repos[0]?.repoPath || "";
232
+ const touched = [
233
+ ...Object.entries(cd?.originalFileStates ?? {}).map(([uri, f]) => {
234
+ const raw = f?.contentKey ? originals.get(f.contentKey) : null;
235
+ let before = null; try { before = raw == null ? null : typeof JSON.parse(raw) === "string" ? JSON.parse(raw) : raw; } catch { before = raw; }
236
+ return { path: uriPath(uri), op: "write", ...(f?.isNewlyCreated ? { created: true } : before != null ? { original_text: before } : {}) };
237
+ }),
238
+ ...(cd?.newlyCreatedFiles ?? []).map((f) => ({ path: uriPath(f?.uri), op: "write", created: true })),
239
+ ...(cd?.context?.fileSelections ?? []).map((f) => ({ path: uriPath(f?.uri ?? f), op: "read" })),
240
+ ].filter((f) => f.path);
241
+ const modelName = cd?.modelConfig?.modelName;
242
+
243
+ yield {
244
+ format: FORMAT,
245
+ harness: "cursor-chat",
246
+ session_id: `cursor-chat-${createHash("sha256").update(`cursor-chat:${cid}`).digest("hex").slice(0, 16)}`,
247
+ model: (modelName && modelName !== "default" ? modelName : "") || models[0] || "",
248
+ ...(models.length > 1 ? { models } : {}),
249
+ title: meta.name || meta.subtitle || "",
250
+ started_at: started,
251
+ ended_at: ended,
252
+ cwd: workspace,
253
+ ...(repos.length ? { git: { root: repos[0].repoPath, branch: repos[0].branches?.at(-1)?.branchName || "" }, tracked_git_repos: repos.map((r) => ({ path: r.repoPath, branches: (r.branches ?? []).map((b) => b.branchName) })) } : {}),
254
+ ...(touched.length ? { touched_files: touched } : {}),
255
+ ...(cd?.contextTokensUsed != null ? { context_usage: { tokens_used: cd.contextTokensUsed, token_limit: cd.contextTokenLimit, breakdown: cd.promptTokenBreakdown?.categories } } : {}),
256
+ source: dbPath,
257
+ messages,
258
+ };
259
+ }
260
+ } finally {
261
+ try { fs.unlinkSync(tmp); } catch { }
262
+ }
263
+ }
264
+
265
+ export default {
266
+ id: "cursor-chat",
267
+ name: "Cursor Chat / Composer",
268
+ kind: "local",
269
+ roots: (home) => dbRoots(home),
270
+
271
+ *collect(root, ctx = {}) {
272
+ yield* collectDb(root, ctx.home || root.slice(0, root.indexOf("/Library/")) || os.homedir());
273
+ },
274
+ };
@@ -0,0 +1,4 @@
1
+ // Cursor agent transcripts: 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: "cursor", name: "Cursor agent transcripts", kind: "native", roots: (home) => [path.join(home, ".cursor/projects")] };
@@ -0,0 +1,172 @@
1
+ // DeepSeek chat export: Settings → Data → Export → conversations.json.
2
+ // Uses a mapping-node tree (like ChatGPT) but messages carry a `fragments` array
3
+ // instead of a `content.parts` array. Fragment types include REQUEST (user input),
4
+ // RESPONSE (assistant reply), THINK (chain-of-thought reasoning → thinking field),
5
+ // and SEARCH (web search → tool_call + tool result pair).
6
+ // Evidence: task description (format confirmed from real DeepSeek exports) and
7
+ // greasyfork.org/en/scripts/523474 (multi-segment thinking chain note)
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { FORMAT } from "../normalize.mjs";
11
+
12
+ const CONV_FILE = /(?:^|\/)conversations\.json$/;
13
+
14
+ /** Follow current_node (if present) or the latest-updated leaf back to root. */
15
+ function mainBranch(mapping, currentNode) {
16
+ const ids = Object.keys(mapping);
17
+ // Prefer explicit current_node; fall back to latest created_at leaf
18
+ let startId = currentNode;
19
+ if (!startId || !mapping[startId]) {
20
+ const leaves = ids.filter((id) => !mapping[id].children?.length);
21
+ if (!leaves.length) startId = ids[ids.length - 1];
22
+ else startId = leaves.reduce((a, b) => {
23
+ const ta = mapping[a].message?.created_at || 0;
24
+ const tb = mapping[b].message?.created_at || 0;
25
+ return tb > ta ? b : a;
26
+ }, leaves[0]);
27
+ }
28
+ const chain = [];
29
+ let id = startId;
30
+ const seen = new Set();
31
+ while (id && !seen.has(id)) {
32
+ seen.add(id);
33
+ const node = mapping[id];
34
+ if (!node) break;
35
+ if (node.message) chain.unshift(node);
36
+ id = node.parent;
37
+ }
38
+ return chain;
39
+ }
40
+
41
+ function deepseekSession(conv) {
42
+ const mapping = conv.mapping || {};
43
+ const branch = mainBranch(mapping, conv.current_node);
44
+ const messages = [];
45
+ let callCounter = 0;
46
+
47
+ for (const node of branch) {
48
+ const m = node.message;
49
+ if (!m) continue;
50
+ const role = (m.role || "").toLowerCase();
51
+ const fragments = Array.isArray(m.fragments) ? m.fragments : [];
52
+ const ts = m.created_at ? (typeof m.created_at === "number" ? new Date(m.created_at * 1000).toISOString() : m.created_at) : undefined;
53
+
54
+ if (role === "user") {
55
+ const parts = fragments
56
+ .filter((f) => f.type === "REQUEST" || !f.type)
57
+ .map((f) => f.content || f.text || "")
58
+ .filter(Boolean);
59
+ const text = parts.join("\n") || (m.content || m.text || "");
60
+ if (text) messages.push({ role: "user", ts, text });
61
+ continue;
62
+ }
63
+
64
+ if (role === "assistant") {
65
+ let text = "";
66
+ let thinking = "";
67
+ const toolCalls = [];
68
+ const pendingResults = [];
69
+
70
+ for (const f of fragments) {
71
+ const ftype = (f.type || "").toUpperCase();
72
+ const fcontent = f.content || f.text || "";
73
+
74
+ if (ftype === "RESPONSE") {
75
+ text += (text ? "\n" : "") + fcontent;
76
+ } else if (ftype === "THINK") {
77
+ thinking += (thinking ? "\n" : "") + fcontent;
78
+ } else if (ftype === "SEARCH") {
79
+ // SEARCH → tool_call (web_search) + tool result
80
+ callCounter++;
81
+ const callId = `ds-search-${node.id || callCounter}`;
82
+ let input, resultText;
83
+ try {
84
+ // f may have { query, results } or just { content }
85
+ if (f.query || f.results !== undefined) {
86
+ input = { query: f.query || "" };
87
+ resultText = typeof f.results === "string" ? f.results : JSON.stringify(f.results ?? "");
88
+ } else {
89
+ // Try to parse content as JSON
90
+ const parsed = typeof fcontent === "string" ? JSON.parse(fcontent) : fcontent;
91
+ input = { query: parsed.query || fcontent };
92
+ resultText = typeof parsed.results === "string" ? parsed.results : JSON.stringify(parsed.results ?? parsed);
93
+ }
94
+ } catch {
95
+ input = { query: fcontent };
96
+ resultText = fcontent;
97
+ }
98
+ toolCalls.push({ id: callId, name: "web_search", input });
99
+ pendingResults.push({ role: "tool", ts, tool_use_id: callId, tool_name: "web_search", is_error: false, text: resultText.slice(0, 1_000_000) });
100
+ } else if (!ftype) {
101
+ // Untyped fragment: treat as response text
102
+ text += (text ? "\n" : "") + fcontent;
103
+ }
104
+ }
105
+
106
+ // Fallback: use message-level content if no fragments produced text
107
+ if (!text && !thinking && !toolCalls.length) {
108
+ const fallback = m.content || m.text || "";
109
+ if (fallback) text = typeof fallback === "string" ? fallback : JSON.stringify(fallback);
110
+ }
111
+
112
+ if (text || thinking || toolCalls.length) {
113
+ messages.push({
114
+ role: "assistant", ts, text,
115
+ ...(thinking ? { thinking } : {}),
116
+ ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
117
+ });
118
+ }
119
+ for (const r of pendingResults) messages.push(r);
120
+ continue;
121
+ }
122
+ }
123
+
124
+ const models = [...new Set(
125
+ branch.map((n) => n.message?.model || n.message?.model_slug).filter(Boolean)
126
+ )];
127
+
128
+ return {
129
+ format: FORMAT,
130
+ harness: "deepseek",
131
+ model: conv.model || models[0] || "",
132
+ ...(models.length > 1 ? { models } : {}),
133
+ session_id: `deepseek-${createHash("sha256").update(`deepseek:${conv.id || conv.title || ""}`).digest("hex").slice(0, 16)}`,
134
+ title: conv.title || "",
135
+ started_at: conv.create_time ? (typeof conv.create_time === "number" ? new Date(conv.create_time * 1000).toISOString() : conv.create_time) : null,
136
+ ended_at: conv.update_time ? (typeof conv.update_time === "number" ? new Date(conv.update_time * 1000).toISOString() : conv.update_time) : null,
137
+ cwd: "",
138
+ source: "deepseek-export",
139
+ messages,
140
+ };
141
+ }
142
+
143
+ export function deepseekSessions(convs) {
144
+ return convs.map((c) => {
145
+ try { return deepseekSession(c); } catch { return null; }
146
+ }).filter((s) => s && s.messages.some((m) => m.role === "user") && s.messages.some((m) => m.role === "assistant"));
147
+ }
148
+
149
+ function readConversations(l) {
150
+ const f = l.names.find((n) => CONV_FILE.test(n));
151
+ if (!f) return [];
152
+ let data;
153
+ try { data = JSON.parse(l.read(f)); } catch { return []; }
154
+ return Array.isArray(data) ? data : [];
155
+ }
156
+
157
+ export default {
158
+ id: "deepseek",
159
+ name: "DeepSeek",
160
+ kind: "export",
161
+ how: "chat.deepseek.com → Settings → Data → Export → download conversations.json",
162
+ sniff(l) {
163
+ const f = l.names.find((n) => CONV_FILE.test(n));
164
+ if (!f) return false;
165
+ const head = l.read(f).slice(0, 200_000);
166
+ // "fragments" is unique to DeepSeek; "chat_messages" = claude-ai; "current_node" without "fragments" = chatgpt
167
+ return head.includes('"fragments"') && !head.includes('"chat_messages"');
168
+ },
169
+ *collect(input, { listing: l }) {
170
+ yield* deepseekSessions(readConversations(l));
171
+ },
172
+ };
@@ -0,0 +1,145 @@
1
+ // DSH (DeepSeek Harness) stores each session as a zstd-compressed JSONL file:
2
+ // ~/.dsh/sessions/<encoded-cwd>/<session-id>/session.jsonl.zstd
3
+ //
4
+ // Each line in the decompressed JSONL is one event:
5
+ // { type:"session", id, createdAt, cwd, delegationDepth, parentSession? }
6
+ // { type:"user/message", seq, time, data:{content:[{type:"text",text}], role, id} }
7
+ // { type:"assistant/message",seq, time, data:{turn,step,message:{role,content,id},usage?} }
8
+ // content blocks: {type:"text",text} | {type:"reasoning",text}
9
+ // { type:"tool/call", seq, time, data:{callId,name,arguments} }
10
+ // { type:"tool/result", seq, time, data:{message:{content:[{type:"tool-result",
11
+ // toolCallId,content:[{type:"text",text}],isError}]}} }
12
+ // (other event types are structural markers and are skipped)
13
+ //
14
+ // Uses Node's built-in zstd (22.15+), or the `zstd` CLI on older Nodes.
15
+ // Performance: large sessions have 100k+ lines but only a small fraction are relevant.
16
+ // We fast-path by reading the type value directly from the raw line before JSON.parse.
17
+
18
+ import fs from "node:fs";
19
+ import zlib from "node:zlib";
20
+ import path from "node:path";
21
+ import { execFileSync } from "node:child_process";
22
+ import { FORMAT } from "../normalize.mjs";
23
+
24
+ // Max decompressed size per session; 300 MB covers even the largest known sessions.
25
+ const MAX_BUF = 300 * 1024 * 1024;
26
+ // Only these event types carry data we need; everything else (hook/invoked, reasoning-chunks, etc.) is skipped.
27
+ const KEEP = new Set(["session", "user/message", "assistant/message", "tool/call", "tool/result"]);
28
+ // All DSH JSONL lines start with this prefix; type value starts at character index 9.
29
+ const TYPE_PREFIX = '{"type":"';
30
+
31
+ // DSH appends a zstd frame per write. Node's zstdDecompressSync stops after the first frame, so prefer the zstd CLI
32
+ // and, without it, decompress frame by frame (split at the frame magic; a false split just merges with the next piece).
33
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
34
+ let hasCli = null;
35
+ function decompress(zstdFile) {
36
+ if (hasCli !== false) {
37
+ try { return execFileSync("zstd", ["-d", "-c", zstdFile], { stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAX_BUF }); }
38
+ catch (e) { if (e.code !== "ENOENT") return null; hasCli = false; }
39
+ }
40
+ if (!zlib.zstdDecompressSync) return null;
41
+ const buf = fs.readFileSync(zstdFile), out = [];
42
+ let start = 0, pending = null;
43
+ for (let i = buf.indexOf(MAGIC, 1); start < buf.length; i = buf.indexOf(MAGIC, i + 1)) {
44
+ const end = i < 0 ? buf.length : i;
45
+ const piece = pending ? Buffer.concat([pending, buf.subarray(start, end)]) : buf.subarray(start, end);
46
+ try { out.push(zlib.zstdDecompressSync(piece)); pending = null; } catch { pending = piece; }
47
+ start = end;
48
+ if (i < 0) break;
49
+ }
50
+ return out.length ? Buffer.concat(out) : null;
51
+ }
52
+
53
+ function text(content) {
54
+ if (typeof content === "string") return content;
55
+ if (Array.isArray(content)) return content.map((p) => (typeof p === "string" ? p : p?.type === "text" ? (p.text ?? "") : "")).filter(Boolean).join("\n");
56
+ return "";
57
+ }
58
+
59
+ function* sessionsFromGroupDir(groupDir) {
60
+ let sessIds; try { sessIds = fs.readdirSync(groupDir, { withFileTypes: true }); } catch { return; }
61
+ for (const e of sessIds) {
62
+ if (!e.isDirectory()) continue;
63
+ const zstdPath = path.join(groupDir, e.name, "session.jsonl.zstd");
64
+ if (!fs.existsSync(zstdPath)) continue;
65
+ const buf = decompress(zstdPath);
66
+ if (!buf) continue;
67
+
68
+ const messages = [];
69
+ const callNames = new Map();
70
+ let sessionId = e.name, cwd = "", createdAt = null, parentSession = null, model = "";
71
+
72
+ for (const line of buf.toString("utf8").split("\n")) {
73
+ // Fast-path: extract the type value at a fixed offset before paying for JSON.parse.
74
+ // All DSH lines start with {"type":"<value>", so the value begins at index 9.
75
+ if (line.length < 10 || !line.startsWith(TYPE_PREFIX)) continue;
76
+ const q = line.indexOf('"', 9);
77
+ if (q < 0) continue;
78
+ const type = line.slice(9, q);
79
+ if (!KEEP.has(type)) continue;
80
+ let ev; try { ev = JSON.parse(line); } catch { continue; }
81
+ const { data = {}, time } = ev;
82
+ const ts = time ? new Date(typeof time === "number" ? time : time).toISOString() : undefined;
83
+
84
+ if (type === "session") {
85
+ sessionId = ev.id || sessionId;
86
+ cwd = ev.cwd || "";
87
+ createdAt = ev.createdAt ? new Date(ev.createdAt).toISOString() : null;
88
+ parentSession = ev.parentSession || null;
89
+ continue;
90
+ }
91
+ if (type === "user/message") {
92
+ const t2 = text(data.content);
93
+ if (t2) messages.push({ role: "user", ts, text: t2 });
94
+ } else if (type === "assistant/message") {
95
+ const msg = data.message ?? {};
96
+ model ||= msg.model || "";
97
+ const content = Array.isArray(msg.content) ? msg.content : [];
98
+ const t2 = content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("\n");
99
+ const thinking = content.filter((p) => p?.type === "reasoning").map((p) => p.text ?? "").join("\n");
100
+ if (t2 || thinking) messages.push({ role: "assistant", ts, text: t2, ...(thinking ? { thinking } : {}) });
101
+ // Record usage
102
+ if (data.usage) { /* usage is per-turn; aggregate if needed */ }
103
+ } else if (type === "tool/call") {
104
+ const id = data.callId ?? "";
105
+ const name = data.name ?? "";
106
+ let input = data.arguments ?? {};
107
+ if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* keep */ } }
108
+ callNames.set(id, name);
109
+ const last = messages[messages.length - 1];
110
+ const call = { id, name, input };
111
+ if (last?.role === "assistant" && !last.text) (last.tool_calls ||= []).push(call);
112
+ else messages.push({ role: "assistant", ts, text: "", tool_calls: [call] });
113
+ } else if (type === "tool/result") {
114
+ const msg = data.message ?? {};
115
+ const content = Array.isArray(msg.content) ? msg.content : [];
116
+ for (const p of content) {
117
+ if (p?.type !== "tool-result") continue;
118
+ const id = p.toolCallId ?? "";
119
+ const out = text(p.content);
120
+ messages.push({ role: "tool", ts, tool_use_id: id, tool_name: callNames.get(id), is_error: !!p.isError, text: out.slice(0, 1_000_000) });
121
+ }
122
+ }
123
+ }
124
+
125
+ if (!messages.some((m) => m.role === "user")) continue;
126
+ yield {
127
+ format: FORMAT, harness: "dsh", session_id: sessionId, model, title: "", started_at: createdAt, ended_at: null, cwd,
128
+ ...(parentSession ? { parent_session_id: parentSession } : {}), messages,
129
+ };
130
+ }
131
+ }
132
+
133
+ export default {
134
+ id: "dsh",
135
+ name: "DeepSeek Harness (DSH)",
136
+ kind: "local",
137
+ roots: (home) => [path.join(home, ".dsh", "sessions")],
138
+ *collect(root) {
139
+ let groups; try { groups = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
140
+ for (const g of groups) {
141
+ if (!g.isDirectory()) continue;
142
+ yield* sessionsFromGroupDir(path.join(root, g.name));
143
+ }
144
+ },
145
+ };