@adrive/cli 1.0.0 → 1.1.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.
@@ -1,168 +1,130 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- // OpenAI Codex CLI session format:
4
- // ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO-date>-<session-id>.jsonl
5
- //
6
- // The first line is a `session_meta` record carrying the session id, cli
7
- // version, cwd, and base instructions. Subsequent lines carry the turn
8
- // stream as `{ timestamp, type, payload }`. We extract user + assistant
9
- // text from `type: 'turn'` records (with `payload.role` + `payload.content`)
10
- // and from `type: 'response_item'` records (with `payload.type: 'message'`
11
- // and `payload.content[*].text`). Older Codex versions vary slightly; we
12
- // only require that text fields exist where we expect them and drop the
13
- // rest. Tool calls / reasoning / system events are skipped — agents only
14
- // need the natural-language turns.
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
- years = await fs.readdir(root);
16
+ return await fs.readdir(dir);
26
17
  }
27
18
  catch {
28
19
  return [];
29
20
  }
30
- for (const y of years) {
31
- const ydir = join(root, y);
32
- let months;
33
- try {
34
- months = await fs.readdir(ydir);
35
- }
36
- catch {
37
- continue;
38
- }
39
- for (const m of months) {
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 root = join(home, '.codex', 'sessions');
71
- const files = await walkRolloutFiles(root);
72
- const out = [];
73
- for (const full of files) {
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
- if (since && stat.mtime <= since)
82
- continue;
83
- const base = full.split('/').pop() ?? full;
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 out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
60
+ return [...byId.values()].sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
96
61
  },
97
62
  async parseSession(entry) {
98
- let raw;
99
- try {
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
- for (const line of raw.split(/\r?\n/)) {
108
- if (!line.trim())
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
- catch {
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
- const type = obj.type ?? '';
118
- const ts = obj.timestamp ?? undefined;
119
- const payload = (obj.payload ?? {});
120
- if (type === 'session_meta') {
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
- // Two known shapes for natural-language turns:
127
- // 1. payload.role + payload.content (string or array)
128
- // 2. payload.type === 'message' with payload.content[*].text
129
- let role;
130
- let text = '';
131
- if (typeof payload.role === 'string' && payload.content !== undefined) {
132
- role = payload.role;
133
- const c = payload.content;
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
- else if (payload.type === 'message') {
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
- if (role && text.trim()) {
160
- turns.push({ role, text, ts });
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
- if (turns.length === 0)
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
- // Cursor stores chat history in a per-workspace SQLite database at
2
- // ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/state.vscdb
3
- // (macOS) or the analogous path on Linux. Reading it requires `node:sqlite`
4
- // (Node 22.5+) or `better-sqlite3`. The CLI's `engines.node` is currently
5
- // `>=20` so neither is available out of the box.
6
- //
7
- // Per CEO plan D3 + Codex tension T3 + finding #11, this surface is brittle
8
- // reverse-engineering and intentionally kept narrow. The full implementation
9
- // lands in Phase 2.5 (tracked in TODOS.md). For now this source exposes a
10
- // clear error so users hit explicit "not yet" rather than silent zero hits.
11
- const CURSOR_NOT_IMPLEMENTED_MESSAGE = 'Cursor transcript ingestion is not yet available. Reading Cursor SQLite ' +
12
- "history requires Node 22.5+ (node:sqlite) or a native dependency. " +
13
- 'Tracking under Phase 2.5 of the v1 plan.';
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(_opts) {
17
- throw new Error(CURSOR_NOT_IMPLEMENTED_MESSAGE);
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(_entry) {
20
- throw new Error(CURSOR_NOT_IMPLEMENTED_MESSAGE);
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(home, opts)` — find candidate session files on disk
5
- // 2. `parseSession(entry)` — turn one session file into a TranscriptDoc
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 centralized in
8
- // lib/transcripts/ingest.ts so each source stays small and the cross-cutting
9
- // concerns can't drift between platforms.
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 {};