@agentsky/sky 2.20.2 → 2.21.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.
- package/dist/{chunk-HB7SPKQ5.js → chunk-6JMFB3CV.js} +1 -1
- package/dist/{chunk-WTFBVTMI.js → chunk-ZZ6UF7QA.js} +1 -1
- package/dist/index.js +24 -13
- package/dist/{trace-HTWXINXI.js → trace-NMBMU4GS.js} +1 -1
- package/dist/{trace-library-QR54Q4Z3.js → trace-library-OWYB75AR.js} +96 -10
- package/package.json +3 -2
- package/vendor/trace-collect/SOURCE.json +6 -0
- package/vendor/trace-collect/collect.mjs +75 -0
- package/vendor/trace-collect/identity.d.ts +8 -0
- package/vendor/trace-collect/identity.mjs +23 -0
- package/vendor/trace-collect/muse.mjs +87 -0
- package/vendor/trace-collect/normalize.d.ts +6 -0
- package/vendor/trace-collect/normalize.mjs +312 -0
- package/vendor/trace-collect/scrub.d.ts +2 -0
- package/vendor/trace-collect/scrub.mjs +186 -0
- package/vendor/trace-collect/sinks/agentsky.mjs +156 -0
- package/vendor/trace-collect/sources/_cline-family.mjs +182 -0
- package/vendor/trace-collect/sources/_project-root.mjs +40 -0
- package/vendor/trace-collect/sources/amp.mjs +18 -0
- package/vendor/trace-collect/sources/chatgpt.mjs +203 -0
- package/vendor/trace-collect/sources/claude-ai.mjs +94 -0
- package/vendor/trace-collect/sources/claude-code.mjs +4 -0
- package/vendor/trace-collect/sources/claude-desktop.mjs +87 -0
- package/vendor/trace-collect/sources/cline.mjs +14 -0
- package/vendor/trace-collect/sources/codex.mjs +4 -0
- package/vendor/trace-collect/sources/continue.mjs +117 -0
- package/vendor/trace-collect/sources/copilot-chat.mjs +145 -0
- package/vendor/trace-collect/sources/cursor-chat.mjs +274 -0
- package/vendor/trace-collect/sources/cursor.mjs +4 -0
- package/vendor/trace-collect/sources/deepseek.mjs +172 -0
- package/vendor/trace-collect/sources/dsh.mjs +145 -0
- package/vendor/trace-collect/sources/gemini-cli.mjs +113 -0
- package/vendor/trace-collect/sources/gemini.mjs +150 -0
- package/vendor/trace-collect/sources/goose.mjs +93 -0
- package/vendor/trace-collect/sources/grok-cli.mjs +110 -0
- package/vendor/trace-collect/sources/grok.mjs +83 -0
- package/vendor/trace-collect/sources/hermes.mjs +4 -0
- package/vendor/trace-collect/sources/index.mjs +119 -0
- package/vendor/trace-collect/sources/jan.mjs +134 -0
- package/vendor/trace-collect/sources/kilo-code.mjs +14 -0
- package/vendor/trace-collect/sources/kiro.mjs +77 -0
- package/vendor/trace-collect/sources/lm-studio.mjs +84 -0
- package/vendor/trace-collect/sources/muse.mjs +11 -0
- package/vendor/trace-collect/sources/opencode.mjs +109 -0
- package/vendor/trace-collect/sources/qwen-code.mjs +95 -0
- package/vendor/trace-collect/sources/roo-code.mjs +14 -0
- package/vendor/trace-collect/sources/zed.mjs +70 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// AgentSky workspace trace library (docs/asteroids/architecture/trace-library.md in SkillBoss-AI/guild).
|
|
2
|
+
// Uses the `sky` CLI's login and its trace-sync routing, so a session lands in the same workspace `sky trace sync`
|
|
3
|
+
// would pick. Claude Code and Codex are skipped while `sky trace sync` is on: it already uploads them.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const SKY = () => process.env.SKY_CONFIG_DIR ?? path.join(os.homedir(), ".config", "sky");
|
|
12
|
+
const readJson = (p, d) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return d; } };
|
|
13
|
+
const skyConfig = () => readJson(path.join(SKY(), "config.json"), {});
|
|
14
|
+
const syncConfig = () => readJson(path.join(SKY(), "trace-sync.json"), { enabled: false, rules: [] });
|
|
15
|
+
const token = () => process.env.SKY_API_TOKEN ?? skyConfig().token;
|
|
16
|
+
const apiBase = () => (process.env.SKY_API_URL ?? skyConfig().apiBase ?? "https://api.agentsky.dev/v1").replace(/\/$/, "");
|
|
17
|
+
|
|
18
|
+
// Same limits the device side of `sky trace sync` applies.
|
|
19
|
+
const MESSAGE_MAX = 8 * 1024, TOOL_MAX = 2 * 1024, MESSAGES_MAX = 20_000, BODY_MAX = 7 * 1024 * 1024, CHECK_BATCH = 200, CONCURRENCY = 4;
|
|
20
|
+
const SKY_HARNESS = { "claude-code": "claude_code", codex: "codex" };
|
|
21
|
+
|
|
22
|
+
function up(dir, fn) { for (let d = dir; d && d !== path.dirname(d); d = path.dirname(d)) { const r = fn(d); if (r !== undefined) return r; } }
|
|
23
|
+
function remoteOf(cwd) {
|
|
24
|
+
return up(cwd, (d) => {
|
|
25
|
+
if (!fs.existsSync(path.join(d, ".git"))) return undefined;
|
|
26
|
+
let cfg = path.join(d, ".git", "config");
|
|
27
|
+
try { if (!fs.statSync(path.join(d, ".git")).isDirectory()) { const g = fs.readFileSync(path.join(d, ".git"), "utf8").match(/^gitdir:\s*(.+)$/m)?.[1]?.trim(); const gd = path.resolve(d, g); const common = fs.existsSync(path.join(gd, "commondir")) ? fs.readFileSync(path.join(gd, "commondir"), "utf8").trim() : "."; cfg = path.join(path.resolve(gd, common), "config"); } } catch { return null; }
|
|
28
|
+
try { return fs.readFileSync(cfg, "utf8").match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/)?.[1] ?? null; } catch { return null; }
|
|
29
|
+
}) ?? "";
|
|
30
|
+
}
|
|
31
|
+
/** .agentsky.json at the repo, then the first rule matching the folder or git remote, then the fallback. */
|
|
32
|
+
export function route(cwd, config) {
|
|
33
|
+
if (cwd) {
|
|
34
|
+
const pinned = up(cwd, (d) => { const f = path.join(d, ".agentsky.json"); if (fs.existsSync(f)) { const w = readJson(f, {}).workspace; if (w) return w; } if (fs.existsSync(path.join(d, ".git"))) return null; });
|
|
35
|
+
if (pinned) return pinned;
|
|
36
|
+
const hay = `${cwd}\n${remoteOf(cwd)}`.toLowerCase();
|
|
37
|
+
const rule = (config.rules ?? []).find((r) => hay.includes(String(r.match).toLowerCase()));
|
|
38
|
+
if (rule) return rule.workspace;
|
|
39
|
+
}
|
|
40
|
+
return config.fallback ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const gitCache = new Map();
|
|
44
|
+
/** Branch, commit and origin of the repository a session ran in, read from git (the session's own record wins). */
|
|
45
|
+
function gitOf(t) {
|
|
46
|
+
const cwd = t.cwd;
|
|
47
|
+
if (!cwd || !fs.existsSync(cwd)) return t.git ?? {};
|
|
48
|
+
if (!gitCache.has(cwd)) {
|
|
49
|
+
const run = (...a) => { const r = spawnSync("git", ["-C", cwd, ...a], { encoding: "utf8", timeout: 4000 }); return r.status === 0 ? r.stdout.trim() : ""; };
|
|
50
|
+
gitCache.set(cwd, { branch: run("rev-parse", "--abbrev-ref", "HEAD"), commit: run("rev-parse", "HEAD"), remote: run("remote", "get-url", "origin").replace(/\/\/[^@/]+@/, "//") });
|
|
51
|
+
}
|
|
52
|
+
const g = gitCache.get(cwd);
|
|
53
|
+
return { branch: t.git?.branch || g.branch, commit: t.git?.commit || g.commit, remote: t.git?.remote || g.remote };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cap = (s, n) => { const b = Buffer.from(s, "utf8"); return b.length <= n ? [s, false] : [b.subarray(0, n).toString("utf8").replace(/�+$/, ""), true]; };
|
|
57
|
+
const home = (p) => (p && (p === os.homedir() || p.startsWith(os.homedir() + path.sep)) ? `~${p.slice(os.homedir().length)}` : p);
|
|
58
|
+
|
|
59
|
+
/** trace/v1 → the library's upload body. Tool calls and results are tool rows, reasoning and attachments ride as text. */
|
|
60
|
+
export function toLibrary(t, device) {
|
|
61
|
+
const rows = [];
|
|
62
|
+
let partial = false;
|
|
63
|
+
const push = (role, text, at) => {
|
|
64
|
+
const s = String(text ?? "").replace(/\u0000/g, "").trim();
|
|
65
|
+
if (!s) return;
|
|
66
|
+
if (rows.length >= MESSAGES_MAX) { partial = true; return; }
|
|
67
|
+
const [c, cut] = cap(s, role === "tool" ? TOOL_MAX : MESSAGE_MAX);
|
|
68
|
+
if (cut) partial = true;
|
|
69
|
+
rows.push({ role, text: c, ...(at ? { at: new Date(at).toISOString() } : {}) });
|
|
70
|
+
};
|
|
71
|
+
for (const m of t.messages) {
|
|
72
|
+
const at = m.ts && Number.isFinite(Date.parse(m.ts)) ? m.ts : undefined;
|
|
73
|
+
if (m.role === "user") {
|
|
74
|
+
push("user", m.text, at);
|
|
75
|
+
for (const a of m.attachments ?? []) push("user", `[attachment ${a.name || a.kind}]${a.data && /^text\//.test(a.media_type || "") ? `\n${Buffer.from(a.data, "base64").toString("utf8")}` : ""}`, at);
|
|
76
|
+
} else if (m.role === "assistant") {
|
|
77
|
+
if (m.thinking) push("assistant", `[reasoning]\n${m.thinking}`, at);
|
|
78
|
+
push("assistant", m.text, at);
|
|
79
|
+
for (const c of m.tool_calls ?? []) push("tool", `${c.name} ${typeof c.input === "string" ? c.input : JSON.stringify(c.input ?? {})}`, at);
|
|
80
|
+
} else if (m.role === "tool") push("tool", m.text, at);
|
|
81
|
+
}
|
|
82
|
+
const hash = createHash("sha256");
|
|
83
|
+
hash.update(`${t.cwd ?? ""}\u0002${t.model ?? ""}\u0002`);
|
|
84
|
+
for (const r of rows) hash.update(`${r.role}\u0000${r.text}\u0001`);
|
|
85
|
+
const s = t.stats;
|
|
86
|
+
const g = gitOf(t);
|
|
87
|
+
const body = {
|
|
88
|
+
sha256: hash.digest("hex"), ...(t.title ? { title: t.title.slice(0, 300) } : {}),
|
|
89
|
+
...(t.cwd ? { cwd: home(t.cwd).slice(0, 1024), project: path.basename(t.cwd).slice(0, 200) } : {}),
|
|
90
|
+
...(g.branch && g.branch !== "HEAD" ? { git_branch: g.branch.slice(0, 300) } : {}),
|
|
91
|
+
...(g.commit ? { git_commit: g.commit.slice(0, 80) } : {}),
|
|
92
|
+
...(g.remote ? { git_remote: g.remote.slice(0, 1024) } : {}),
|
|
93
|
+
...(t.model_provider ? { model_provider: String(t.model_provider).slice(0, 100) } : {}),
|
|
94
|
+
...(t.effort ? { effort: String(t.effort).slice(0, 100) } : {}),
|
|
95
|
+
client: (t.client || t.harness).slice(0, 200),
|
|
96
|
+
...(t.model ? { model: t.model.slice(0, 200) } : {}),
|
|
97
|
+
...(s.models?.length ? { models: s.models.slice(0, 50) } : {}),
|
|
98
|
+
...(s.harness_version ? { harness_version: String(s.harness_version).slice(0, 80) } : {}),
|
|
99
|
+
...(s.usage && Object.keys(s.usage).length ? { usage: s.usage } : {}),
|
|
100
|
+
device: device.slice(0, 200), ...(t.started_at ? { started_at: new Date(t.started_at).toISOString() } : {}),
|
|
101
|
+
...(t.ended_at ? { ended_at: new Date(t.ended_at).toISOString() } : {}),
|
|
102
|
+
user_turns: s.user_turns, tool_calls: s.tool_calls, partial, messages: rows,
|
|
103
|
+
};
|
|
104
|
+
while (Buffer.byteLength(JSON.stringify(body)) > BODY_MAX && body.messages.length > 1) { body.messages = body.messages.slice(0, Math.floor(body.messages.length * 0.8)); body.partial = true; }
|
|
105
|
+
return body;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function call(method, p, workspace, body) {
|
|
109
|
+
for (let attempt = 0; ; attempt++) {
|
|
110
|
+
const res = await fetch(`${apiBase()}${p}`, { method, headers: { authorization: `Bearer ${token()}`, "x-universe": workspace, "user-agent": "trace-collect", ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined }).catch((e) => ({ ok: false, status: 0, statusText: e.message, json: async () => ({}) }));
|
|
111
|
+
if (res.ok) return res.json();
|
|
112
|
+
if ((res.status === 429 || res.status >= 500 || res.status === 0) && attempt < 3) { await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); continue; }
|
|
113
|
+
const e = await res.json().catch(() => ({}));
|
|
114
|
+
throw Object.assign(new Error(`${res.status} ${e.error?.code ?? res.statusText}`), { status: res.status });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default {
|
|
119
|
+
id: "agentsky",
|
|
120
|
+
name: "AgentSky workspace trace library",
|
|
121
|
+
/** The library has no parent link, so subagent and helper sessions are uploaded as traces of their own. */
|
|
122
|
+
children: true,
|
|
123
|
+
ready: () => (token() ? { ok: true } : { ok: false, why: "not logged in to AgentSky — run `sky auth login`" }),
|
|
124
|
+
/** `sky trace sync` owns Claude Code and Codex on a device where it is on. */
|
|
125
|
+
skipHarness: (opts) => (opts.all || !syncConfig().enabled ? [] : Object.keys(SKY_HARNESS)),
|
|
126
|
+
async push(list, { scrub, dry = false, workspace = null, log = () => {} }) {
|
|
127
|
+
const config = syncConfig(), device = config.device ?? os.hostname();
|
|
128
|
+
const out = { uploaded: 0, unchanged: 0, owned: 0, local: 0, failed: 0, by_workspace: {}, errors: [] };
|
|
129
|
+
const ready = new Map();
|
|
130
|
+
for (const { trace } of list) {
|
|
131
|
+
const t = scrub(trace);
|
|
132
|
+
const ws = workspace ?? route(trace.cwd, config);
|
|
133
|
+
if (!ws) { out.local++; continue; }
|
|
134
|
+
const harness = SKY_HARNESS[trace.harness] ?? trace.harness.replace(/[^a-z0-9_-]/g, "_").slice(0, 40);
|
|
135
|
+
const id = String(trace.native_session_id || trace.session_id).slice(0, 200);
|
|
136
|
+
ready.set(ws, [...(ready.get(ws) ?? []), { harness, id, body: toLibrary({ ...t, cwd: trace.cwd, stats: trace.stats }, device) }]);
|
|
137
|
+
}
|
|
138
|
+
for (const [ws, items] of ready) for (let i = 0; i < items.length; i += CHECK_BATCH) {
|
|
139
|
+
const batch = items.slice(i, i + CHECK_BATCH);
|
|
140
|
+
let st;
|
|
141
|
+
try { st = (await call("POST", "/trace-library/check", ws, { sessions: batch.map((b) => ({ harness: b.harness, session_id: b.id, sha256: b.body.sha256 })) })).sessions; }
|
|
142
|
+
catch (e) { out.failed += batch.length; out.errors.push(`${ws}: check ${e.message}`); if ([401, 403, 503].includes(e.status)) break; continue; }
|
|
143
|
+
const send = batch.filter((b, k) => { const s = st[k]?.status; if (s === "unchanged") { out.unchanged++; return false; } if (s === "owned") { out.owned++; return false; } return true; });
|
|
144
|
+
if (dry) { out.uploaded += send.length; out.by_workspace[ws] = (out.by_workspace[ws] ?? 0) + send.length; continue; }
|
|
145
|
+
let next = 0;
|
|
146
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, send.length) }, async () => {
|
|
147
|
+
while (next < send.length) {
|
|
148
|
+
const b = send[next++];
|
|
149
|
+
try { await call("PUT", `/trace-library/${b.harness}/${encodeURIComponent(b.id)}`, ws, b.body); out.uploaded++; out.by_workspace[ws] = (out.by_workspace[ws] ?? 0) + 1; log(`${b.harness}/${b.id} → ${ws}`); }
|
|
150
|
+
catch (e) { out.failed++; out.errors.push(`${b.harness}/${b.id} → ${ws}: ${e.message}`); }
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Shared helper for the Cline extension family: Cline, Roo Code, Kilo Code.
|
|
2
|
+
// All three store task history in the same directory layout and JSON formats.
|
|
3
|
+
//
|
|
4
|
+
// Layout (under a VS Code-family app's User/globalStorage/):
|
|
5
|
+
// <extensionId>/tasks/<taskId>/
|
|
6
|
+
// api_conversation_history.json – Anthropic Messages format: {role, content}[]
|
|
7
|
+
// ui_messages.json – UI-facing messages (may add context; optional)
|
|
8
|
+
// task_metadata.json – {id, ts, model, cwd, tokensIn, tokensOut, …}
|
|
9
|
+
//
|
|
10
|
+
// api_conversation_history.json is the authoritative transcript.
|
|
11
|
+
// task_metadata.json provides timestamps, model and token usage.
|
|
12
|
+
// ui_messages.json provides the original human ask as the title seed.
|
|
13
|
+
//
|
|
14
|
+
// Extensions:
|
|
15
|
+
// Cline: saoudrizwan.claude-dev
|
|
16
|
+
// Roo Code: rooveterinaryinc.roo-cline
|
|
17
|
+
// Kilo Code: kilocode.kilo-code
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { FORMAT } from "../normalize.mjs";
|
|
23
|
+
|
|
24
|
+
// VS Code-family app data dirs on macOS (and Linux equivalents)
|
|
25
|
+
const VSCODE_APPS_MAC = [
|
|
26
|
+
"Library/Application Support/Code/User/globalStorage",
|
|
27
|
+
"Library/Application Support/Code - Insiders/User/globalStorage",
|
|
28
|
+
"Library/Application Support/Cursor/User/globalStorage",
|
|
29
|
+
"Library/Application Support/Windsurf/User/globalStorage",
|
|
30
|
+
"Library/Application Support/VSCodium/User/globalStorage",
|
|
31
|
+
];
|
|
32
|
+
const VSCODE_APPS_LINUX = [
|
|
33
|
+
".config/Code/User/globalStorage",
|
|
34
|
+
".config/Code - Insiders/User/globalStorage",
|
|
35
|
+
".config/Cursor/User/globalStorage",
|
|
36
|
+
".config/Windsurf/User/globalStorage",
|
|
37
|
+
".config/VSCodium/User/globalStorage",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function clineRoots(home, extensionId) {
|
|
41
|
+
const apps = [...VSCODE_APPS_MAC, ...VSCODE_APPS_LINUX];
|
|
42
|
+
const roots = [];
|
|
43
|
+
for (const app of apps) {
|
|
44
|
+
const p = path.join(home, app, extensionId);
|
|
45
|
+
if (fs.existsSync(p)) roots.push(p);
|
|
46
|
+
}
|
|
47
|
+
return roots;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJson(p) {
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function blockText(content) {
|
|
55
|
+
if (typeof content === "string") return content;
|
|
56
|
+
if (!Array.isArray(content)) return content == null ? "" : JSON.stringify(content);
|
|
57
|
+
return content.map((p) => {
|
|
58
|
+
if (typeof p === "string") return p;
|
|
59
|
+
if (p?.type === "text") return p.text || "";
|
|
60
|
+
if (p?.type === "image") return `[image: ${p?.source?.media_type || ""}]`;
|
|
61
|
+
if (p?.type === "tool_use") return ""; // handled below
|
|
62
|
+
if (p?.type === "tool_result") return ""; // handled below
|
|
63
|
+
return "";
|
|
64
|
+
}).filter(Boolean).join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Convert an Anthropic Messages array into trace/v1 messages. */
|
|
68
|
+
function convertMessages(apiMsgs) {
|
|
69
|
+
const messages = [];
|
|
70
|
+
const callNames = new Map();
|
|
71
|
+
for (const m of apiMsgs) {
|
|
72
|
+
if (!m || typeof m !== "object") continue;
|
|
73
|
+
const role = m.role;
|
|
74
|
+
const content = Array.isArray(m.content) ? m.content : [{ type: "text", text: String(m.content ?? "") }];
|
|
75
|
+
|
|
76
|
+
if (role === "user") {
|
|
77
|
+
// May contain text or tool_result blocks
|
|
78
|
+
const results = content.filter((p) => p?.type === "tool_result");
|
|
79
|
+
if (results.length) {
|
|
80
|
+
for (const p of results) {
|
|
81
|
+
const out = typeof p.content === "string" ? p.content : blockText(p.content);
|
|
82
|
+
messages.push({ role: "tool", tool_use_id: p.tool_use_id || "", tool_name: callNames.get(p.tool_use_id) || "", is_error: !!p.is_error, text: out.slice(0, 1_000_000) });
|
|
83
|
+
}
|
|
84
|
+
// Check if there's also text content (rare but possible)
|
|
85
|
+
const texts = content.filter((p) => p?.type === "text" && p.text);
|
|
86
|
+
if (texts.length) {
|
|
87
|
+
const t = texts.map((p) => p.text).join("\n");
|
|
88
|
+
if (t.trim()) messages.push({ role: "user", text: t });
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const text = blockText(content);
|
|
93
|
+
const attachments = content.filter((p) => p?.type === "image" && p.source).map((p) => ({
|
|
94
|
+
kind: "image",
|
|
95
|
+
media_type: p.source?.media_type || "",
|
|
96
|
+
name: "",
|
|
97
|
+
}));
|
|
98
|
+
if (text || attachments.length) {
|
|
99
|
+
messages.push({ role: "user", text: text || "", ...(attachments.length ? { attachments } : {}) });
|
|
100
|
+
}
|
|
101
|
+
} else if (role === "assistant") {
|
|
102
|
+
const tool_calls = [];
|
|
103
|
+
const thinkingParts = [];
|
|
104
|
+
let t = "";
|
|
105
|
+
for (const p of content) {
|
|
106
|
+
if (p?.type === "text") t += (t ? "\n" : "") + (p.text || "");
|
|
107
|
+
else if (p?.type === "thinking" && p.thinking) thinkingParts.push(p.thinking);
|
|
108
|
+
else if (p?.type === "tool_use") {
|
|
109
|
+
const callId = p.id || `call-${tool_calls.length}`;
|
|
110
|
+
callNames.set(callId, p.name || "tool");
|
|
111
|
+
tool_calls.push({ id: callId, name: p.name || "tool", input: p.input ?? {} });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const thinking = thinkingParts.join("\n");
|
|
115
|
+
if (t || tool_calls.length || thinking) {
|
|
116
|
+
messages.push({ role: "assistant", text: t, ...(thinking ? { thinking } : {}), ...(tool_calls.length ? { tool_calls } : {}) });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return messages;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Read one task directory and yield a session. */
|
|
124
|
+
export function* collectTaskDir(taskDir, harness) {
|
|
125
|
+
const histPath = path.join(taskDir, "api_conversation_history.json");
|
|
126
|
+
if (!fs.existsSync(histPath)) return;
|
|
127
|
+
|
|
128
|
+
const history = readJson(histPath);
|
|
129
|
+
if (!Array.isArray(history)) return;
|
|
130
|
+
|
|
131
|
+
const meta = readJson(path.join(taskDir, "task_metadata.json")) || {};
|
|
132
|
+
const taskId = meta.id || path.basename(taskDir);
|
|
133
|
+
|
|
134
|
+
const messages = convertMessages(history);
|
|
135
|
+
if (!messages.some((m) => m.role === "user")) return;
|
|
136
|
+
|
|
137
|
+
// Timestamps: prefer task_metadata, fall back to file mtime
|
|
138
|
+
let started = meta.ts ? new Date(meta.ts).toISOString() : null;
|
|
139
|
+
if (!started) {
|
|
140
|
+
try { started = new Date(fs.statSync(histPath).birthtimeMs).toISOString(); } catch { }
|
|
141
|
+
}
|
|
142
|
+
let ended = meta.updatedAt ? new Date(meta.updatedAt).toISOString() : null;
|
|
143
|
+
if (!ended) {
|
|
144
|
+
try { ended = new Date(fs.statSync(histPath).mtimeMs).toISOString(); } catch { }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const model = meta.model || meta.apiModelId || "";
|
|
148
|
+
const cwd = meta.cwd || meta.dirContext || "";
|
|
149
|
+
const usage = {};
|
|
150
|
+
if (meta.tokensIn != null) usage.input_tokens = Number(meta.tokensIn);
|
|
151
|
+
if (meta.tokensOut != null) usage.output_tokens = Number(meta.tokensOut);
|
|
152
|
+
if (meta.cacheReads != null) usage.cache_read_tokens = Number(meta.cacheReads);
|
|
153
|
+
if (meta.cacheWrites != null) usage.cache_write_tokens = Number(meta.cacheWrites);
|
|
154
|
+
|
|
155
|
+
yield {
|
|
156
|
+
format: FORMAT,
|
|
157
|
+
harness,
|
|
158
|
+
session_id: createHash("sha256").update(`${harness}:${taskId}`).digest("hex").slice(0, 16),
|
|
159
|
+
model,
|
|
160
|
+
title: meta.task || "",
|
|
161
|
+
started_at: started,
|
|
162
|
+
ended_at: ended,
|
|
163
|
+
cwd,
|
|
164
|
+
...(Object.keys(usage).length ? { usage } : {}),
|
|
165
|
+
source: taskDir,
|
|
166
|
+
messages,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Collect all tasks under a root dir (the extension's data dir). */
|
|
171
|
+
export function* collectClineRoot(root, harness) {
|
|
172
|
+
const tasksDir = path.join(root, "tasks");
|
|
173
|
+
let taskIds;
|
|
174
|
+
try { taskIds = fs.readdirSync(tasksDir); } catch { return; }
|
|
175
|
+
for (const id of taskIds) {
|
|
176
|
+
const dir = path.join(tasksDir, id);
|
|
177
|
+
try {
|
|
178
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
179
|
+
} catch { continue; }
|
|
180
|
+
try { yield* collectTaskDir(dir, harness); } catch { /* skip bad task */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Gemini CLI and Qwen Code name a project's folder under <config>/tmp by sha256(project root) (older) or by a short
|
|
2
|
+
// name with a .project_root file and a projects.json map (newer). Turn either back into the real folder, so the
|
|
3
|
+
// session's workspace, instruction files and touched files can be read. Never return the hash itself.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
10
|
+
const dirs = (d) => { try { return fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => path.join(d, e.name)); } catch { return []; } };
|
|
11
|
+
|
|
12
|
+
/** sha256(folder) → folder over the places a project plausibly lives; `extra` adds known folders. Matches a hash prefix too. */
|
|
13
|
+
export function folderByHash(home, extra = []) {
|
|
14
|
+
const candidates = new Set([home, ...extra]);
|
|
15
|
+
for (const a of dirs(home)) { candidates.add(a); for (const b of dirs(a)) candidates.add(b); }
|
|
16
|
+
for (const w of [path.join(home, ".agents", "worktrees"), path.join(home, ".claude", "worktrees")]) for (const d of dirs(w)) candidates.add(d);
|
|
17
|
+
for (const repo of dirs(home)) for (const d of dirs(path.join(repo, ".claude", "worktrees"))) candidates.add(d);
|
|
18
|
+
const full = [...candidates].map((c) => [sha(c), c]);
|
|
19
|
+
return (h) => (h && /^[a-f0-9]{16,64}$/.test(h) ? full.find(([x]) => x.startsWith(h))?.[1] ?? "" : "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function projectRootResolver(configDir, home) {
|
|
23
|
+
let byName = {};
|
|
24
|
+
try { const p = JSON.parse(fs.readFileSync(path.join(configDir, "projects.json"), "utf8")).projects ?? {}; byName = Object.fromEntries(Object.entries(p).map(([root, name]) => [name, root])); } catch {}
|
|
25
|
+
let byHash = null;
|
|
26
|
+
const lookup = (h) => (byHash ??= folderByHash(home, Object.values(byName)))(h);
|
|
27
|
+
return (projectDir, projectHash, messages = []) => {
|
|
28
|
+
try { const r = fs.readFileSync(path.join(configDir, "tmp", projectDir, ".project_root"), "utf8").trim(); if (r) return r; } catch {}
|
|
29
|
+
if (byName[projectDir]) return byName[projectDir];
|
|
30
|
+
const h = /^[a-f0-9]{64}$/.test(projectHash || "") ? projectHash : /^[a-f0-9]{64}$/.test(projectDir) ? projectDir : "";
|
|
31
|
+
const found = lookup(h);
|
|
32
|
+
if (found) return found;
|
|
33
|
+
// Last resort: the deepest folder every absolute file path in the session shares.
|
|
34
|
+
const abs = messages.flatMap((m) => m.tool_calls ?? []).flatMap((c) => Object.values(c.input ?? {})).filter((v) => typeof v === "string" && v.startsWith("/") && v.length < 500);
|
|
35
|
+
if (!abs.length) return "";
|
|
36
|
+
let prefix = path.dirname(abs[0]);
|
|
37
|
+
for (const p of abs) while (prefix !== "/" && !p.startsWith(prefix + "/")) prefix = path.dirname(prefix);
|
|
38
|
+
return prefix === "/" || prefix === home ? "" : prefix;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Amp (Sourcegraph) keeps threads in the cloud; the only local file is a device-id.json.
|
|
2
|
+
// There are no local transcripts to collect, so this adapter registers the source in the
|
|
3
|
+
// registry but collect() always yields nothing.
|
|
4
|
+
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { FORMAT } from "../normalize.mjs";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
id: "amp",
|
|
10
|
+
name: "Amp (Sourcegraph)",
|
|
11
|
+
kind: "local",
|
|
12
|
+
roots: (home) => [path.join(home, ".local", "share", "amp")],
|
|
13
|
+
// eslint-disable-next-line require-yield
|
|
14
|
+
*collect(_root) { /* Amp stores threads in the cloud; nothing to collect locally */ },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Re-export FORMAT to keep the import live (satisfies linters).
|
|
18
|
+
export { FORMAT };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// ChatGPT data export (Settings → Data Controls → Export data). Produces a zip whose
|
|
2
|
+
// conversations.json is an array; each has a `mapping` node-graph with parent/children
|
|
3
|
+
// and a `current_node` id pointing to the last active message. Newer exports also split
|
|
4
|
+
// large histories across conversations-0.json, conversations-1.json, etc.
|
|
5
|
+
// Evidence: community.openai.com/t/decoding-exported-data-by-parsing-conversations-json-and-or-chat-html/403144
|
|
6
|
+
// and scrapbox.io/oquno/ChatGPT%E3%81%8B%E3%82%89%E3%82%A8%E3%82%AF%E3%82%B9%E3%83%9D%E3%83%BC%E3%83%88%E3%81%97%E3%81%9Fconversations.json
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { FORMAT } from "../normalize.mjs";
|
|
13
|
+
|
|
14
|
+
const CONV_FILE = /(?:^|\/)conversations(?:-\d+)?\.json$/;
|
|
15
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|heic|svg)$/i;
|
|
16
|
+
|
|
17
|
+
/** Follow current_node back to root to reconstruct the main branch in order. */
|
|
18
|
+
function mainBranch(mapping, currentNode) {
|
|
19
|
+
const byId = mapping;
|
|
20
|
+
const chain = [];
|
|
21
|
+
let id = currentNode;
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
while (id && !seen.has(id)) {
|
|
24
|
+
seen.add(id);
|
|
25
|
+
const node = byId[id];
|
|
26
|
+
if (!node) break;
|
|
27
|
+
if (node.message?.content) chain.unshift(node);
|
|
28
|
+
id = node.parent;
|
|
29
|
+
}
|
|
30
|
+
return chain;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read text from ChatGPT content object; handles text, code, tether, thoughts, etc. */
|
|
34
|
+
function contentText(content) {
|
|
35
|
+
if (!content) return "";
|
|
36
|
+
const { content_type, parts, text } = content;
|
|
37
|
+
if (typeof text === "string" && text) return text;
|
|
38
|
+
if (!Array.isArray(parts)) return "";
|
|
39
|
+
const pieces = [];
|
|
40
|
+
for (const p of parts) {
|
|
41
|
+
if (typeof p === "string") pieces.push(p);
|
|
42
|
+
else if (p && typeof p === "object") {
|
|
43
|
+
// image_asset_pointer → skip text; handled separately as attachment
|
|
44
|
+
if (p.content_type === "image_asset_pointer" || p.asset_pointer) continue;
|
|
45
|
+
if (typeof p.text === "string") pieces.push(p.text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return pieces.filter(Boolean).join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Collect image_asset_pointer parts as attachments (name only; bytes are not in the export). */
|
|
52
|
+
function imageAttachments(content) {
|
|
53
|
+
if (!content || !Array.isArray(content.parts)) return [];
|
|
54
|
+
return content.parts
|
|
55
|
+
.filter((p) => p && typeof p === "object" && (p.content_type === "image_asset_pointer" || p.asset_pointer))
|
|
56
|
+
.map((p) => ({
|
|
57
|
+
kind: "image",
|
|
58
|
+
media_type: "image/*",
|
|
59
|
+
name: p.asset_pointer ? String(p.asset_pointer).split("/").pop() : (p.filename || "image"),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read all conversations arrays from the listing (handles split files too). */
|
|
64
|
+
function readAllConversations(input, l) {
|
|
65
|
+
const files = l.names.filter((n) => CONV_FILE.test(n)).sort();
|
|
66
|
+
const convs = [];
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
try {
|
|
69
|
+
const arr = JSON.parse(l.read(f));
|
|
70
|
+
if (Array.isArray(arr)) convs.push(...arr);
|
|
71
|
+
} catch { /* skip bad file */ }
|
|
72
|
+
}
|
|
73
|
+
return convs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Convert one ChatGPT conversation to trace/v1 format. */
|
|
77
|
+
function chatgptSession(conv) {
|
|
78
|
+
const mapping = conv.mapping || {};
|
|
79
|
+
const branch = mainBranch(mapping, conv.current_node);
|
|
80
|
+
const messages = [];
|
|
81
|
+
const toolCallIds = new Map(); // message_id → generated call id for matching results
|
|
82
|
+
|
|
83
|
+
for (const node of branch) {
|
|
84
|
+
const m = node.message;
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const role = m.author?.role;
|
|
87
|
+
const content = m.content || {};
|
|
88
|
+
const ts = m.create_time ? new Date(m.create_time * 1000).toISOString() : undefined;
|
|
89
|
+
const modelSlug = m.metadata?.model_slug || "";
|
|
90
|
+
|
|
91
|
+
if (role === "system") {
|
|
92
|
+
// Only emit user-authored system prompts
|
|
93
|
+
if (m.metadata?.is_user_system_message) {
|
|
94
|
+
const t = contentText(content);
|
|
95
|
+
if (t) messages.push({ role: "user", ts, text: t });
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (role === "user") {
|
|
101
|
+
const t = contentText(content);
|
|
102
|
+
const attachments = imageAttachments(content);
|
|
103
|
+
if (t || attachments.length) {
|
|
104
|
+
messages.push({ role: "user", ts, text: t, ...(attachments.length ? { attachments } : {}) });
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (role === "assistant") {
|
|
110
|
+
const ct = content.content_type;
|
|
111
|
+
// Thinking / reasoning blocks
|
|
112
|
+
if (ct === "thoughts" || ct === "reasoning") {
|
|
113
|
+
const thinking = contentText(content);
|
|
114
|
+
if (thinking) {
|
|
115
|
+
const last = messages[messages.length - 1];
|
|
116
|
+
if (last?.role === "assistant") last.thinking = (last.thinking ? last.thinking + "\n" : "") + thinking;
|
|
117
|
+
else messages.push({ role: "assistant", ts, text: "", thinking, ...(modelSlug ? { model: modelSlug } : {}) });
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Tool call: assistant message whose recipient is a specific tool
|
|
123
|
+
const recipient = m.recipient;
|
|
124
|
+
if (recipient && recipient !== "all") {
|
|
125
|
+
const callId = `chatgpt-call-${m.id || node.id}`;
|
|
126
|
+
toolCallIds.set(node.id, callId);
|
|
127
|
+
const inputText = contentText(content);
|
|
128
|
+
let input;
|
|
129
|
+
try { input = inputText ? JSON.parse(inputText) : {}; } catch { input = inputText ? { query: inputText } : {}; }
|
|
130
|
+
const last = messages[messages.length - 1];
|
|
131
|
+
const call = { id: callId, name: recipient, input };
|
|
132
|
+
if (last?.role === "assistant" && !last.text) (last.tool_calls ||= []).push(call);
|
|
133
|
+
else messages.push({ role: "assistant", ts, text: "", tool_calls: [call], ...(modelSlug ? { model: modelSlug } : {}) });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const t = contentText(content);
|
|
138
|
+
const thinkingInMeta = m.metadata?.reasoning_recap || "";
|
|
139
|
+
const attachments = imageAttachments(content);
|
|
140
|
+
if (t || attachments.length || thinkingInMeta) {
|
|
141
|
+
messages.push({
|
|
142
|
+
role: "assistant", ts, text: t,
|
|
143
|
+
...(attachments.length ? { attachments } : {}),
|
|
144
|
+
...(thinkingInMeta ? { thinking: thinkingInMeta } : {}),
|
|
145
|
+
...(modelSlug ? { model: modelSlug } : {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (role === "tool") {
|
|
152
|
+
// Find the closest ancestor call id
|
|
153
|
+
const parentNode = mapping[node.parent];
|
|
154
|
+
const callId = toolCallIds.get(node.parent) || (parentNode ? `chatgpt-call-${parentNode.id || node.parent}` : `chatgpt-call-${node.id}`);
|
|
155
|
+
const toolName = m.author?.name || "tool";
|
|
156
|
+
const t = contentText(content);
|
|
157
|
+
messages.push({ role: "tool", ts, tool_use_id: callId, tool_name: toolName, is_error: content.content_type === "system_error", text: (t || "").slice(0, 1_000_000) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const models = [...new Set(
|
|
162
|
+
branch.map((n) => n.message?.metadata?.model_slug).filter(Boolean)
|
|
163
|
+
)];
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
format: FORMAT,
|
|
167
|
+
harness: "chatgpt",
|
|
168
|
+
model: conv.default_model_slug || models[0] || "",
|
|
169
|
+
...(models.length > 1 ? { models } : {}),
|
|
170
|
+
session_id: `chatgpt-${createHash("sha256").update(`chatgpt:${conv.id || conv.title || ""}`).digest("hex").slice(0, 16)}`,
|
|
171
|
+
title: conv.title || "",
|
|
172
|
+
started_at: conv.create_time ? new Date(conv.create_time * 1000).toISOString() : null,
|
|
173
|
+
ended_at: conv.update_time ? new Date(conv.update_time * 1000).toISOString() : null,
|
|
174
|
+
cwd: "",
|
|
175
|
+
source: "chatgpt-export",
|
|
176
|
+
...(conv.gizmo_id ? { gizmo_id: conv.gizmo_id } : {}),
|
|
177
|
+
...(conv.project_id ? { project_id: conv.project_id } : {}),
|
|
178
|
+
messages,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function chatgptSessions(convs) {
|
|
183
|
+
return convs.map((c) => {
|
|
184
|
+
try { return chatgptSession(c); } catch { return null; }
|
|
185
|
+
}).filter((s) => s && s.messages.some((m) => m.role === "user") && s.messages.some((m) => m.role === "assistant"));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default {
|
|
189
|
+
id: "chatgpt",
|
|
190
|
+
name: "ChatGPT (OpenAI)",
|
|
191
|
+
kind: "export",
|
|
192
|
+
how: "chat.openai.com → Settings → Data Controls → Export data; download the zip from the email",
|
|
193
|
+
sniff(l) {
|
|
194
|
+
const convFile = l.names.find((n) => CONV_FILE.test(n));
|
|
195
|
+
if (!convFile) return false;
|
|
196
|
+
const head = l.read(convFile).slice(0, 200_000);
|
|
197
|
+
// "current_node" is ChatGPT-specific; "chat_messages" = claude-ai; "fragments" = deepseek
|
|
198
|
+
return head.includes('"current_node"') && head.includes('"mapping"') && !head.includes('"chat_messages"') && !head.includes('"fragments"');
|
|
199
|
+
},
|
|
200
|
+
*collect(input, { listing: l }) {
|
|
201
|
+
yield* chatgptSessions(readAllConversations(input, l));
|
|
202
|
+
},
|
|
203
|
+
};
|