@adrive/cli 1.0.0 → 1.1.1
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/README.md +10 -11
- package/dist/commands/auth-login.js +9 -4
- package/dist/commands/context.js +0 -6
- package/dist/commands/hook.js +152 -0
- package/dist/commands/init.js +106 -82
- package/dist/commands/resume.js +41 -0
- package/dist/commands/sync.js +24 -0
- package/dist/index.js +26 -21
- package/dist/lib/api-client.js +17 -1
- package/dist/lib/auto-context.js +36 -53
- package/dist/lib/config.js +12 -0
- package/dist/lib/consent.js +60 -0
- package/dist/lib/detect-agents.js +73 -153
- package/dist/lib/harness-commands.js +63 -0
- package/dist/lib/harness-wiring.js +194 -0
- package/dist/lib/ignore.js +24 -4
- package/dist/lib/session-cwd.js +33 -0
- package/dist/lib/skill.js +41 -100
- package/dist/lib/sync.js +433 -0
- package/dist/lib/transcripts/claude.js +51 -77
- package/dist/lib/transcripts/codex.js +95 -133
- package/dist/lib/transcripts/cursor.js +68 -18
- package/dist/lib/transcripts/gemini.js +116 -0
- package/dist/lib/transcripts/opencode.js +95 -0
- package/dist/lib/transcripts/types.js +5 -5
- package/dist/lib/transcripts/util.js +102 -0
- package/dist/lib/write-file.js +55 -0
- package/package.json +11 -10
- package/dist/commands/transcripts.js +0 -56
- package/dist/lib/transcripts/ingest.js +0 -115
|
@@ -1,168 +1,130 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
// Per Codex finding #11, the on-disk format may shift between CLI versions.
|
|
17
|
-
// The parser stays permissive: unknown record types are silently ignored
|
|
18
|
-
// rather than crashing the ingest.
|
|
19
|
-
const SESSION_ID_RE = /^rollout-.+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
20
|
-
async function walkRolloutFiles(root) {
|
|
21
|
-
// Walks the YYYY/MM/DD tree depth-first. Tolerant of missing dirs.
|
|
22
|
-
const acc = [];
|
|
23
|
-
let years;
|
|
3
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, patchPaths, readText, toolSummary } from './util.js';
|
|
4
|
+
// Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl, with
|
|
5
|
+
// older rollouts zstd-compressed to .jsonl.zst. Lines are
|
|
6
|
+
// `{ timestamp, type, payload }`: session_meta (id, cwd), response_item
|
|
7
|
+
// (message | function_call | custom_tool_call), and event_msg
|
|
8
|
+
// item_completed whose FileChange item is the 0.160 file-edit signal. The
|
|
9
|
+
// format shifts between CLI versions, so unknown records are ignored.
|
|
10
|
+
const ROLLOUT_RE = /^rollout-.+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i;
|
|
11
|
+
// Codex injects environment/instruction dumps as user messages; a part that
|
|
12
|
+
// is one whole <tag>...</tag> block is harness boilerplate, not the user.
|
|
13
|
+
const BOILERPLATE_RE = /^\s*(?:<([a-z][\w-]*)[\s>][\s\S]*<\/\1>\s*$|# AGENTS\.md instructions)/i;
|
|
14
|
+
async function readdirSafe(dir) {
|
|
24
15
|
try {
|
|
25
|
-
|
|
16
|
+
return await fs.readdir(dir);
|
|
26
17
|
}
|
|
27
18
|
catch {
|
|
28
19
|
return [];
|
|
29
20
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const mdir = join(ydir, m);
|
|
41
|
-
let days;
|
|
42
|
-
try {
|
|
43
|
-
days = await fs.readdir(mdir);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
for (const d of days) {
|
|
49
|
-
const ddir = join(mdir, d);
|
|
50
|
-
let files;
|
|
51
|
-
try {
|
|
52
|
-
files = await fs.readdir(ddir);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
for (const f of files) {
|
|
58
|
-
if (f.startsWith('rollout-') && f.endsWith('.jsonl')) {
|
|
59
|
-
acc.push(join(ddir, f));
|
|
60
|
-
}
|
|
21
|
+
}
|
|
22
|
+
async function walkRollouts(root) {
|
|
23
|
+
const acc = [];
|
|
24
|
+
for (const y of await readdirSafe(root)) {
|
|
25
|
+
for (const m of await readdirSafe(join(root, y))) {
|
|
26
|
+
for (const d of await readdirSafe(join(root, y, m))) {
|
|
27
|
+
const dir = join(root, y, m, d);
|
|
28
|
+
for (const f of await readdirSafe(dir)) {
|
|
29
|
+
if (ROLLOUT_RE.test(f))
|
|
30
|
+
acc.push(join(dir, f));
|
|
61
31
|
}
|
|
62
32
|
}
|
|
63
33
|
}
|
|
64
34
|
}
|
|
65
35
|
return acc;
|
|
66
36
|
}
|
|
37
|
+
function parseArgs(raw) {
|
|
38
|
+
if (typeof raw !== 'string')
|
|
39
|
+
return raw;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
67
47
|
export const codexSource = {
|
|
68
48
|
platform: 'codex',
|
|
69
49
|
async enumerate({ home, since }) {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let stat;
|
|
75
|
-
try {
|
|
76
|
-
stat = await fs.stat(full);
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
50
|
+
const byId = new Map();
|
|
51
|
+
for (const path of await walkRollouts(join(home, '.codex', 'sessions'))) {
|
|
52
|
+
const mtime = await mtimeOf(path);
|
|
53
|
+
if (!mtime || (since && mtime <= since))
|
|
79
54
|
continue;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const idMatch = base.match(SESSION_ID_RE);
|
|
85
|
-
const sessionId = idMatch ? idMatch[1] : base.replace(/\.jsonl$/, '');
|
|
86
|
-
// `label` is best-effort — fill from the cwd in session_meta on parse.
|
|
87
|
-
out.push({
|
|
88
|
-
manifestKey: `codex:${sessionId}`,
|
|
89
|
-
sessionId,
|
|
90
|
-
path: full,
|
|
91
|
-
mtime: stat.mtime,
|
|
92
|
-
label: 'codex session',
|
|
93
|
-
});
|
|
55
|
+
const sessionId = ROLLOUT_RE.exec(path.split('/').pop())[1];
|
|
56
|
+
const prev = byId.get(sessionId);
|
|
57
|
+
if (!prev || prev.mtime < mtime)
|
|
58
|
+
byId.set(sessionId, { sessionId, path, mtime });
|
|
94
59
|
}
|
|
95
|
-
return
|
|
60
|
+
return [...byId.values()].sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
96
61
|
},
|
|
97
62
|
async parseSession(entry) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
raw = await fs.readFile(entry.path, 'utf8');
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
63
|
+
const raw = await readText(entry.path);
|
|
64
|
+
if (raw === null)
|
|
103
65
|
return null;
|
|
104
|
-
|
|
105
|
-
const turns = [];
|
|
66
|
+
const doc = new DocBuilder();
|
|
106
67
|
let cwd;
|
|
107
|
-
|
|
108
|
-
|
|
68
|
+
let metaId;
|
|
69
|
+
for (const obj of jsonLines(raw)) {
|
|
70
|
+
const ts = asStr(obj.timestamp);
|
|
71
|
+
const payload = asObj(obj.payload) ?? {};
|
|
72
|
+
// Forked rollouts repeat their parents' session_meta after their own.
|
|
73
|
+
if (obj.type === 'session_meta') {
|
|
74
|
+
cwd ??= asStr(payload.cwd);
|
|
75
|
+
metaId ??= asStr(payload.id);
|
|
109
76
|
continue;
|
|
110
|
-
let obj;
|
|
111
|
-
try {
|
|
112
|
-
obj = JSON.parse(line);
|
|
113
77
|
}
|
|
114
|
-
|
|
78
|
+
if (obj.type === 'event_msg' && payload.type === 'item_completed') {
|
|
79
|
+
const item = asObj(payload.item);
|
|
80
|
+
const changes = item?.type === 'FileChange' ? asObj(item.changes) : undefined;
|
|
81
|
+
if (changes) {
|
|
82
|
+
const tools = Object.keys(changes).map((path) => {
|
|
83
|
+
doc.touch(path);
|
|
84
|
+
return { name: `file_${asStr(asObj(changes[path])?.type) ?? 'change'}`, path };
|
|
85
|
+
});
|
|
86
|
+
doc.addTurn('assistant', '', ts, tools);
|
|
87
|
+
}
|
|
115
88
|
continue;
|
|
116
89
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const c = payload.cwd;
|
|
122
|
-
if (typeof c === 'string')
|
|
123
|
-
cwd = c;
|
|
90
|
+
if (obj.type !== 'response_item' && obj.type !== 'turn')
|
|
91
|
+
continue;
|
|
92
|
+
if (payload.type === 'function_call') {
|
|
93
|
+
doc.addTurn('assistant', '', ts, [{ name: asStr(payload.name) ?? 'tool', summary: toolSummary(parseArgs(payload.arguments)) }]);
|
|
124
94
|
continue;
|
|
125
95
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (typeof c === 'string')
|
|
135
|
-
text = c;
|
|
136
|
-
else if (Array.isArray(c)) {
|
|
137
|
-
for (const part of c) {
|
|
138
|
-
if (typeof part === 'string')
|
|
139
|
-
text += part;
|
|
140
|
-
else if (part && typeof part === 'object' && 'text' in part) {
|
|
141
|
-
text += String(part.text);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
96
|
+
if (payload.type === 'custom_tool_call') {
|
|
97
|
+
const name = asStr(payload.name) ?? 'tool';
|
|
98
|
+
const input = asStr(payload.input) ?? '';
|
|
99
|
+
if (name === 'apply_patch') {
|
|
100
|
+
const paths = patchPaths(input);
|
|
101
|
+
for (const p of paths)
|
|
102
|
+
doc.touch(p);
|
|
103
|
+
doc.addTurn('assistant', '', ts, paths.map((path) => ({ name, path })));
|
|
144
104
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const innerRole = payload.role;
|
|
148
|
-
if (typeof innerRole === 'string')
|
|
149
|
-
role = innerRole;
|
|
150
|
-
const c = payload.content;
|
|
151
|
-
if (Array.isArray(c)) {
|
|
152
|
-
for (const part of c) {
|
|
153
|
-
if (part && typeof part === 'object' && 'text' in part) {
|
|
154
|
-
text += String(part.text);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
105
|
+
else {
|
|
106
|
+
doc.addTurn('assistant', '', ts, [{ name, summary: toolSummary(input) }]);
|
|
157
107
|
}
|
|
108
|
+
continue;
|
|
158
109
|
}
|
|
159
|
-
|
|
160
|
-
|
|
110
|
+
const role = asStr(payload.role);
|
|
111
|
+
if (role !== 'user' && role !== 'assistant')
|
|
112
|
+
continue;
|
|
113
|
+
if (payload.type !== 'message' && obj.type !== 'turn')
|
|
114
|
+
continue;
|
|
115
|
+
let text = '';
|
|
116
|
+
if (typeof payload.content === 'string') {
|
|
117
|
+
text = BOILERPLATE_RE.test(payload.content) ? '' : payload.content;
|
|
161
118
|
}
|
|
119
|
+
else if (Array.isArray(payload.content)) {
|
|
120
|
+
for (const part of payload.content) {
|
|
121
|
+
const t = asStr(asObj(part)?.text);
|
|
122
|
+
if (t && !BOILERPLATE_RE.test(t))
|
|
123
|
+
text += t;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
doc.addTurn(role, text, ts);
|
|
162
127
|
}
|
|
163
|
-
|
|
164
|
-
return null;
|
|
165
|
-
const labeled = cwd ? { ...entry, label: cwd } : entry;
|
|
166
|
-
return { entry: labeled, turns };
|
|
128
|
+
return doc.build(metaId ? { ...entry, sessionId: metaId } : entry, cwd);
|
|
167
129
|
},
|
|
168
130
|
};
|
|
@@ -1,23 +1,73 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, patchPaths, readText, toolSummary } from './util.js';
|
|
4
|
+
// Cursor agent: ~/.cursor/projects/<path-slug>/agent-transcripts/<conv>/<conv>.jsonl
|
|
5
|
+
// (or flat <conv>.jsonl). Lines are `{ role, message: { content: [...] } }`
|
|
6
|
+
// with no timestamps and no cwd; sync falls back to the cwd the hook recorded
|
|
7
|
+
// (see session-cwd.ts).
|
|
8
|
+
const PATH_TOOLS = new Set(['Write', 'StrReplace', 'Delete']);
|
|
9
|
+
const USER_QUERY_RE = /^\s*<user_query>\s*([\s\S]*?)\s*<\/user_query>\s*$/;
|
|
10
|
+
async function readdirSafe(dir) {
|
|
11
|
+
try {
|
|
12
|
+
return await fs.readdir(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
14
18
|
export const cursorSource = {
|
|
15
19
|
platform: 'cursor',
|
|
16
|
-
async enumerate(
|
|
17
|
-
|
|
20
|
+
async enumerate({ home, since }) {
|
|
21
|
+
const root = join(home, '.cursor', 'projects');
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const slug of await readdirSafe(root)) {
|
|
24
|
+
const dir = join(root, slug, 'agent-transcripts');
|
|
25
|
+
for (const name of await readdirSafe(dir)) {
|
|
26
|
+
const sessionId = name.replace(/\.jsonl$/, '');
|
|
27
|
+
const path = name.endsWith('.jsonl') ? join(dir, name) : join(dir, name, `${name}.jsonl`);
|
|
28
|
+
const mtime = await mtimeOf(path);
|
|
29
|
+
if (!mtime || (since && mtime <= since))
|
|
30
|
+
continue;
|
|
31
|
+
out.push({ sessionId, path, mtime });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
18
35
|
},
|
|
19
|
-
async parseSession(
|
|
20
|
-
|
|
36
|
+
async parseSession(entry) {
|
|
37
|
+
const raw = await readText(entry.path);
|
|
38
|
+
if (raw === null)
|
|
39
|
+
return null;
|
|
40
|
+
const doc = new DocBuilder();
|
|
41
|
+
for (const obj of jsonLines(raw)) {
|
|
42
|
+
const role = asStr(obj.role);
|
|
43
|
+
if (role !== 'user' && role !== 'assistant')
|
|
44
|
+
continue;
|
|
45
|
+
const content = asObj(obj.message)?.content;
|
|
46
|
+
let text = typeof content === 'string' ? content : '';
|
|
47
|
+
const tools = [];
|
|
48
|
+
for (const part of Array.isArray(content) ? content : []) {
|
|
49
|
+
const p = asObj(part);
|
|
50
|
+
if (p?.type === 'text') {
|
|
51
|
+
text += asStr(p.text) ?? '';
|
|
52
|
+
}
|
|
53
|
+
else if (p?.type === 'tool_use') {
|
|
54
|
+
const name = asStr(p.name) ?? 'tool';
|
|
55
|
+
if (name === 'ApplyPatch') {
|
|
56
|
+
const input = typeof p.input === 'string' ? p.input : Object.values(asObj(p.input) ?? {}).filter((v) => typeof v === 'string').join('\n');
|
|
57
|
+
const paths = patchPaths(input);
|
|
58
|
+
for (const path of paths)
|
|
59
|
+
doc.touch(path);
|
|
60
|
+
tools.push(...(paths.length > 0 ? paths.map((path) => ({ name, path })) : [{ name }]));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const path = PATH_TOOLS.has(name) ? asStr(asObj(p.input)?.path) : undefined;
|
|
64
|
+
doc.touch(path);
|
|
65
|
+
tools.push(path ? { name, path } : { name, summary: toolSummary(p.input) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
doc.addTurn(role, text.replace(USER_QUERY_RE, '$1'), undefined, tools);
|
|
70
|
+
}
|
|
71
|
+
return doc.build(entry);
|
|
21
72
|
},
|
|
22
73
|
};
|
|
23
|
-
export { CURSOR_NOT_IMPLEMENTED_MESSAGE };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readSessionCwds } from '../session-cwd.js';
|
|
5
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, readText, toolSummary } from './util.js';
|
|
6
|
+
// Gemini CLI: ~/.gemini/tmp/<project_hash>/chats/session-*.jsonl, one message
|
|
7
|
+
// record per line plus `$set`/`$patch`/`$rewindTo` meta lines; legacy chats
|
|
8
|
+
// are a single .json `{ sessionId, messages }`. The project hash is sha256 of
|
|
9
|
+
// the project root, so cwd is recovered by hashing cwds the hook recorded.
|
|
10
|
+
const CHAT_RE = /^session-.+\.jsonl?$/;
|
|
11
|
+
// Both formats put the session id first, so the head is enough to name a chat
|
|
12
|
+
// without parsing it; enumerate, parse and the hook must agree on this id.
|
|
13
|
+
const HEAD_BYTES = 16 * 1024;
|
|
14
|
+
const SESSION_ID_RE = /"sessionId"\s*:\s*"([^"\\]+)"/;
|
|
15
|
+
function sessionIdOf(head, path) {
|
|
16
|
+
return SESSION_ID_RE.exec(head)?.[1] ?? (path.split(/[\\/]/).pop() ?? path).replace(/^session-/, '').replace(/\.jsonl?$/, '');
|
|
17
|
+
}
|
|
18
|
+
async function readHead(path) {
|
|
19
|
+
const handle = await fs.open(path, 'r');
|
|
20
|
+
try {
|
|
21
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
22
|
+
const { bytesRead } = await handle.read(buf, 0, HEAD_BYTES, 0);
|
|
23
|
+
return buf.subarray(0, bytesRead).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await handle.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const FILE_TOOLS = new Set(['write_file', 'replace']);
|
|
30
|
+
function messageText(content) {
|
|
31
|
+
if (typeof content === 'string')
|
|
32
|
+
return content;
|
|
33
|
+
if (!Array.isArray(content))
|
|
34
|
+
return '';
|
|
35
|
+
return content.map((p) => (typeof p === 'string' ? p : asStr(asObj(p)?.text) ?? '')).join('');
|
|
36
|
+
}
|
|
37
|
+
function addMessage(doc, msg) {
|
|
38
|
+
if (msg.type !== 'user' && msg.type !== 'gemini')
|
|
39
|
+
return;
|
|
40
|
+
const tools = [];
|
|
41
|
+
for (const raw of Array.isArray(msg.toolCalls) ? msg.toolCalls : []) {
|
|
42
|
+
const call = asObj(raw);
|
|
43
|
+
const name = asStr(call?.name) ?? 'tool';
|
|
44
|
+
const args = asObj(call?.args);
|
|
45
|
+
const path = FILE_TOOLS.has(name) ? asStr(args?.file_path) : undefined;
|
|
46
|
+
doc.touch(path);
|
|
47
|
+
tools.push(path ? { name, path } : { name, summary: toolSummary(args) });
|
|
48
|
+
}
|
|
49
|
+
doc.addTurn(msg.type === 'user' ? 'user' : 'assistant', messageText(msg.content), asStr(msg.timestamp), tools);
|
|
50
|
+
}
|
|
51
|
+
export const geminiSource = {
|
|
52
|
+
platform: 'gemini-cli',
|
|
53
|
+
async enumerate({ home, since, configDir }) {
|
|
54
|
+
const tmp = join(home, '.gemini', 'tmp');
|
|
55
|
+
let hashes;
|
|
56
|
+
try {
|
|
57
|
+
hashes = await fs.readdir(tmp);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const cwdByHash = new Map();
|
|
63
|
+
for (const cwd of Object.values(configDir ? await readSessionCwds(configDir) : {})) {
|
|
64
|
+
cwdByHash.set(createHash('sha256').update(cwd).digest('hex'), cwd);
|
|
65
|
+
}
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const h of hashes) {
|
|
68
|
+
let files;
|
|
69
|
+
try {
|
|
70
|
+
files = await fs.readdir(join(tmp, h, 'chats'));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
if (!CHAT_RE.test(f))
|
|
77
|
+
continue;
|
|
78
|
+
const path = join(tmp, h, 'chats', f);
|
|
79
|
+
const mtime = await mtimeOf(path);
|
|
80
|
+
if (!mtime || (since && mtime <= since))
|
|
81
|
+
continue;
|
|
82
|
+
const head = await readHead(path).catch(() => '');
|
|
83
|
+
out.push({ sessionId: sessionIdOf(head, path), path, mtime, cwd: cwdByHash.get(h) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
87
|
+
},
|
|
88
|
+
async parseSession(entry) {
|
|
89
|
+
const raw = await readText(entry.path);
|
|
90
|
+
if (raw === null)
|
|
91
|
+
return null;
|
|
92
|
+
const doc = new DocBuilder();
|
|
93
|
+
if (entry.path.endsWith('.json')) {
|
|
94
|
+
let legacy;
|
|
95
|
+
try {
|
|
96
|
+
legacy = asObj(JSON.parse(raw));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
for (const m of Array.isArray(legacy?.messages) ? legacy.messages : []) {
|
|
102
|
+
const msg = asObj(m);
|
|
103
|
+
if (msg)
|
|
104
|
+
addMessage(doc, msg);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
for (const line of jsonLines(raw)) {
|
|
109
|
+
if ('$set' in line || '$patch' in line || '$rewindTo' in line)
|
|
110
|
+
continue;
|
|
111
|
+
addMessage(doc, line);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return doc.build({ ...entry, sessionId: sessionIdOf(Buffer.from(raw).subarray(0, HEAD_BYTES).toString('utf8'), entry.path) });
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { DocBuilder, asObj, asStr, patchPaths, toolSummary } from './util.js';
|
|
3
|
+
const EXEC_TIMEOUT_MS = 15_000;
|
|
4
|
+
const defaultExec = (args, cwd) => new Promise((resolve, reject) => {
|
|
5
|
+
execFile('opencode', [...args, '--pure'], { cwd, timeout: EXEC_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8' }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
6
|
+
});
|
|
7
|
+
function parseJson(out) {
|
|
8
|
+
const start = out.search(/[[{]/);
|
|
9
|
+
return start < 0 ? null : JSON.parse(out.slice(start));
|
|
10
|
+
}
|
|
11
|
+
function isoFromMs(ms) {
|
|
12
|
+
return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
|
|
13
|
+
}
|
|
14
|
+
function toolEntry(doc, name, input) {
|
|
15
|
+
if (name === 'write' || name === 'edit') {
|
|
16
|
+
const path = asStr(input?.filePath);
|
|
17
|
+
doc.touch(path);
|
|
18
|
+
return [path ? { name, path } : { name }];
|
|
19
|
+
}
|
|
20
|
+
if (name === 'patch' || name === 'apply_patch') {
|
|
21
|
+
const patch = Object.values(input ?? {}).filter((v) => typeof v === 'string').join('\n');
|
|
22
|
+
const paths = patchPaths(patch);
|
|
23
|
+
for (const p of paths)
|
|
24
|
+
doc.touch(p);
|
|
25
|
+
return paths.length > 0 ? paths.map((path) => ({ name, path })) : [{ name }];
|
|
26
|
+
}
|
|
27
|
+
return [{ name, summary: toolSummary(input) }];
|
|
28
|
+
}
|
|
29
|
+
export function createOpencodeSource(exec = defaultExec) {
|
|
30
|
+
return {
|
|
31
|
+
platform: 'opencode',
|
|
32
|
+
// shortcut: `opencode session list` only lists the project of `cwd`, so a
|
|
33
|
+
// sweep sees the sessions of the directory the hook fired in; reading
|
|
34
|
+
// OpenCode's storage directly would cover all projects.
|
|
35
|
+
async enumerate({ since, cwd }) {
|
|
36
|
+
let list;
|
|
37
|
+
try {
|
|
38
|
+
list = parseJson(await exec(['session', 'list', '--format', 'json'], cwd));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(list))
|
|
44
|
+
return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const raw of list) {
|
|
47
|
+
const s = asObj(raw);
|
|
48
|
+
const id = asStr(s?.id);
|
|
49
|
+
const updated = s?.updated;
|
|
50
|
+
if (!id || typeof updated !== 'number')
|
|
51
|
+
continue;
|
|
52
|
+
const mtime = new Date(updated);
|
|
53
|
+
if (since && mtime <= since)
|
|
54
|
+
continue;
|
|
55
|
+
out.push({ sessionId: id, path: id, mtime, cwd: asStr(s?.directory) });
|
|
56
|
+
}
|
|
57
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
58
|
+
},
|
|
59
|
+
async parseSession(entry) {
|
|
60
|
+
let data;
|
|
61
|
+
try {
|
|
62
|
+
data = asObj(parseJson(await exec(['export', entry.sessionId], entry.cwd)));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!data)
|
|
68
|
+
return null;
|
|
69
|
+
const info = asObj(data.info);
|
|
70
|
+
const doc = new DocBuilder();
|
|
71
|
+
for (const rawMsg of Array.isArray(data.messages) ? data.messages : []) {
|
|
72
|
+
const msg = asObj(rawMsg);
|
|
73
|
+
const mInfo = asObj(msg?.info);
|
|
74
|
+
const role = asStr(mInfo?.role) ?? 'assistant';
|
|
75
|
+
const ts = isoFromMs(asObj(mInfo?.time)?.created);
|
|
76
|
+
let text = '';
|
|
77
|
+
const tools = [];
|
|
78
|
+
for (const rawPart of Array.isArray(msg?.parts) ? msg.parts : []) {
|
|
79
|
+
const part = asObj(rawPart);
|
|
80
|
+
if (!part || part.synthetic)
|
|
81
|
+
continue;
|
|
82
|
+
if (part.type === 'text')
|
|
83
|
+
text += asStr(part.text) ?? '';
|
|
84
|
+
else if (part.type === 'tool')
|
|
85
|
+
tools.push(...toolEntry(doc, asStr(part.tool) ?? 'tool', asObj(asObj(part.state)?.input)));
|
|
86
|
+
}
|
|
87
|
+
doc.addTurn(role, text, ts, tools);
|
|
88
|
+
}
|
|
89
|
+
const updated = isoFromMs(asObj(info?.time)?.updated);
|
|
90
|
+
const entryOut = updated ? { ...entry, mtime: new Date(updated) } : entry;
|
|
91
|
+
return doc.build(entryOut, asStr(info?.directory), asStr(info?.title));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export const opencodeSource = createOpencodeSource();
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Functional TranscriptSource interface — per eng-review decision E4.
|
|
2
2
|
//
|
|
3
3
|
// A source knows two things:
|
|
4
|
-
// 1. `enumerate(
|
|
5
|
-
// 2. `parseSession(entry)` — turn one session
|
|
4
|
+
// 1. `enumerate(opts)` — find candidate sessions on disk
|
|
5
|
+
// 2. `parseSession(entry)` — turn one session into a TranscriptDoc
|
|
6
6
|
//
|
|
7
|
-
// Everything else (redact, encrypt, manifest, upload) is
|
|
8
|
-
// lib/
|
|
9
|
-
//
|
|
7
|
+
// Everything else (redact, encrypt, manifest, upload, run upsert) is
|
|
8
|
+
// centralized in lib/sync.ts so the cross-cutting concerns can't drift
|
|
9
|
+
// between harnesses.
|
|
10
10
|
export {};
|