@1agents/session-reader 0.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.
Files changed (56) hide show
  1. package/README.md +347 -0
  2. package/dist/bin/1session.d.ts +2 -0
  3. package/dist/bin/1session.js +408 -0
  4. package/dist/src/aggregator.d.ts +12 -0
  5. package/dist/src/aggregator.js +136 -0
  6. package/dist/src/classify.d.ts +2 -0
  7. package/dist/src/classify.js +11 -0
  8. package/dist/src/distiller.d.ts +7 -0
  9. package/dist/src/distiller.js +122 -0
  10. package/dist/src/index.d.ts +19 -0
  11. package/dist/src/index.js +17 -0
  12. package/dist/src/ledger.d.ts +19 -0
  13. package/dist/src/ledger.js +192 -0
  14. package/dist/src/overview.d.ts +6 -0
  15. package/dist/src/overview.js +324 -0
  16. package/dist/src/parsers/antigravity.d.ts +4 -0
  17. package/dist/src/parsers/antigravity.js +297 -0
  18. package/dist/src/parsers/claude.d.ts +2 -0
  19. package/dist/src/parsers/claude.js +214 -0
  20. package/dist/src/parsers/codex.d.ts +2 -0
  21. package/dist/src/parsers/codex.js +316 -0
  22. package/dist/src/parsers/provider.d.ts +24 -0
  23. package/dist/src/parsers/provider.js +5 -0
  24. package/dist/src/resolver.d.ts +42 -0
  25. package/dist/src/resolver.js +145 -0
  26. package/dist/src/search.d.ts +59 -0
  27. package/dist/src/search.js +257 -0
  28. package/dist/src/store/db.d.ts +10 -0
  29. package/dist/src/store/db.js +89 -0
  30. package/dist/src/store/edges.d.ts +46 -0
  31. package/dist/src/store/edges.js +153 -0
  32. package/dist/src/store/facts.d.ts +8 -0
  33. package/dist/src/store/facts.js +39 -0
  34. package/dist/src/store/indexer.d.ts +40 -0
  35. package/dist/src/store/indexer.js +62 -0
  36. package/dist/src/store/read.d.ts +29 -0
  37. package/dist/src/store/read.js +70 -0
  38. package/dist/src/store/rows.d.ts +35 -0
  39. package/dist/src/store/rows.js +55 -0
  40. package/dist/src/store/schema.d.ts +19 -0
  41. package/dist/src/store/schema.js +142 -0
  42. package/dist/src/store/write.d.ts +22 -0
  43. package/dist/src/store/write.js +72 -0
  44. package/dist/src/turns.d.ts +14 -0
  45. package/dist/src/turns.js +156 -0
  46. package/dist/src/types.d.ts +294 -0
  47. package/dist/src/types.js +13 -0
  48. package/dist/src/util/jsonl.d.ts +6 -0
  49. package/dist/src/util/jsonl.js +32 -0
  50. package/dist/src/util/paths.d.ts +15 -0
  51. package/dist/src/util/paths.js +69 -0
  52. package/dist/src/util/text.d.ts +7 -0
  53. package/dist/src/util/text.js +48 -0
  54. package/dist/src/writes.d.ts +35 -0
  55. package/dist/src/writes.js +204 -0
  56. package/package.json +58 -0
@@ -0,0 +1,324 @@
1
+ import path from 'node:path';
2
+ import { clip, oneLine } from './util/text.js';
3
+ import { analyzableCommand, fileWrites } from './writes.js';
4
+ import { summarizeTurns } from './turns.js';
5
+ import { classifyUserTurn } from './classify.js';
6
+ import { commandLedger, displayPath, errorLedger, fileLedger, jobCounts, jobLedger } from './ledger.js';
7
+ const ABS_PATH = /(?:\/[\w.\-一-鿿]+){2,}/g;
8
+ const SSH_HOST = /\b(?:ssh|scp|rsync)\b[^\n;|]*?\b[\w.-]+@((?:\d{1,3}(?:\.\d{1,3}){3})|(?:[\w-]+(?:\.[\w-]+)+))/g;
9
+ const NOISE_DIR = /^\/(?:usr|bin|sbin|etc|proc|sys|dev|opt\/homebrew|Library|System)(?:\/|$)/;
10
+ const REAL_ROOT = /^\/(?:home|Users|root|var|tmp|private|data|mnt|srv|opt|workspace|app)(?:\/|$)/;
11
+ const UUID_SEGMENT = /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/;
12
+ const COMMIT_RESULT = /\[([\w./-]+)\s+([0-9a-f]{7,40})\]\s*(.+)/;
13
+ function collectAnchors(session, summaries) {
14
+ const dirs = new Map();
15
+ const files = new Map();
16
+ const hosts = new Map();
17
+ const turnAt = (index) => summaries.find((turn) => index >= turn.events[0] && index <= turn.events[1])?.no ?? 1;
18
+ const note = (map, key, turn) => {
19
+ const seen = map.get(key);
20
+ if (seen) {
21
+ seen.hits++;
22
+ seen.firstTurn = Math.min(seen.firstTurn, turn);
23
+ seen.lastTurn = Math.max(seen.lastTurn, turn);
24
+ }
25
+ else {
26
+ map.set(key, { hits: 1, firstTurn: turn, lastTurn: turn });
27
+ }
28
+ };
29
+ for (const turn of session.turns) {
30
+ const body = `${turn.toolArgs ? JSON.stringify(turn.toolArgs) : ''}\n${turn.toolResult ?? ''}`;
31
+ if (!body.trim())
32
+ continue;
33
+ const at = turnAt(turn.index);
34
+ const command = analyzableCommand(turn);
35
+ for (const match of command ? command.matchAll(SSH_HOST) : []) {
36
+ if (match[1])
37
+ hosts.set(match[1], (hosts.get(match[1]) ?? 0) + 1);
38
+ }
39
+ for (const match of body.matchAll(ABS_PATH)) {
40
+ const found = match[0];
41
+ if (NOISE_DIR.test(found) || !REAL_ROOT.test(found) || UUID_SEGMENT.test(found))
42
+ continue;
43
+ if (/\.\w{1,6}$/.test(found)) {
44
+ note(files, found, at);
45
+ const dir = path.dirname(found);
46
+ if (REAL_ROOT.test(dir))
47
+ note(dirs, dir, at);
48
+ }
49
+ else {
50
+ note(dirs, found, at);
51
+ }
52
+ }
53
+ }
54
+ const top = (map, kind) => [...map.entries()]
55
+ .sort((a, b) => b[1].hits - a[1].hits)
56
+ .slice(0, 10)
57
+ .map(([value, seen]) => ({
58
+ path: value,
59
+ hits: seen.hits,
60
+ kind,
61
+ firstTurn: seen.firstTurn,
62
+ lastTurn: seen.lastTurn,
63
+ // Anchors are mentions in text; nobody was seen acting on them.
64
+ provenance: 'candidate',
65
+ }));
66
+ return {
67
+ hosts: [...hosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([host]) => host),
68
+ paths: [...top(dirs, 'dir'), ...top(files, 'file')],
69
+ };
70
+ }
71
+ /**
72
+ * A commit shows up twice — in the `git commit -m` call and in the `[branch sha]`
73
+ * the result echoes back. Key on the message so the echo fills in the sha
74
+ * instead of adding a second row for the same commit.
75
+ */
76
+ function collectCommits(turns) {
77
+ const byMessage = new Map();
78
+ const key = (message) => message.slice(0, 60);
79
+ for (const turn of turns) {
80
+ const echoed = COMMIT_RESULT.exec(turn.toolResult ?? '');
81
+ if (echoed?.[2] && echoed[3]) {
82
+ const message = oneLine(echoed[3], 120);
83
+ const existing = byMessage.get(key(message));
84
+ if (existing)
85
+ existing.sha ??= echoed[2];
86
+ else
87
+ byMessage.set(key(message), { sha: echoed[2], message, timestamp: turn.timestamp });
88
+ continue;
89
+ }
90
+ const command = analyzableCommand(turn);
91
+ const matched = command ? /git commit[^\n]*?-m\s+(?:'([^']+)'|"([^"]+)")/.exec(command) : undefined;
92
+ const text = matched?.[1] ?? matched?.[2];
93
+ if (!text)
94
+ continue;
95
+ const message = oneLine(text, 120);
96
+ if (!byMessage.has(key(message)))
97
+ byMessage.set(key(message), { message, timestamp: turn.timestamp });
98
+ }
99
+ return [...byMessage.values()];
100
+ }
101
+ /** Strips the codex `exec` envelope so the actual error text survives. */
102
+ function unwrapResult(result) {
103
+ let text = (result ?? '').replace(/^Script completed\s*Wall time [\d.]+ seconds\s*Output:\s*/i, '');
104
+ const outputs = [...text.matchAll(/"output"\s*:\s*"((?:[^"\\]|\\.)*)"/g)]
105
+ .map((match) => match[1] ?? '')
106
+ .filter(Boolean)
107
+ .sort((a, b) => b.length - a.length);
108
+ if (outputs[0])
109
+ text = outputs[0].replace(/\\n/g, ' ').replace(/\\"/g, '"');
110
+ return text.replace(/^Created At:.*?Completed At:.*?\n/s, '');
111
+ }
112
+ function collectPitfalls(turns) {
113
+ const out = [];
114
+ const seen = new Set();
115
+ for (const turn of turns) {
116
+ if (turn.kind !== 'tool_result' || !turn.isError)
117
+ continue;
118
+ const text = oneLine(unwrapResult(turn.toolResult), 200);
119
+ const key = text.slice(0, 60);
120
+ if (!text || seen.has(key))
121
+ continue;
122
+ seen.add(key);
123
+ out.push(text);
124
+ }
125
+ return out;
126
+ }
127
+ function buildStats(session, files, turnCount) {
128
+ const jobs = jobLedger(session);
129
+ const commands = commandLedger(session);
130
+ // One write action per extraction, before the ledger dedupes by path.
131
+ const writeActions = session.turns.reduce((total, turn) => total + fileWrites(turn).length, 0) +
132
+ session.stats.fileChanges.length;
133
+ const events = { user: 0, assistant: 0, thinking: 0, tool_call: 0, tool_result: 0 };
134
+ for (const turn of session.turns)
135
+ events[turn.kind]++;
136
+ const provider = session.stats;
137
+ return {
138
+ turns: turnCount,
139
+ events,
140
+ // Single definition, shared with `1session files`.
141
+ filesChanged: files.length,
142
+ fileChangeEvents: writeActions,
143
+ filesByProvenance: files.reduce((counts, file) => ({ ...counts, [file.provenance]: counts[file.provenance] + 1 }), { observed: 0, derived: 0, candidate: 0 }),
144
+ commands: commands.length,
145
+ // One definition of "failed", shared with `1session errors`.
146
+ errors: errorLedger(session).length,
147
+ commits: collectCommits(session.turns),
148
+ branches: provider.branches,
149
+ models: provider.models,
150
+ ...(provider.tokens ? { tokens: provider.tokens } : {}),
151
+ artifacts: session.artifacts,
152
+ uploads: provider.uploads,
153
+ jobs,
154
+ jobCounts: jobCounts(jobs),
155
+ extras: provider.extras,
156
+ };
157
+ }
158
+ /** The last thing of each kind that happened — facts, not a verdict. */
159
+ function finalState(session, summaries) {
160
+ const commands = commandLedger(session);
161
+ const reversed = [...session.turns].reverse();
162
+ const lastOk = [...commands].reverse().find((record) => record.exitCode === 0);
163
+ const lastFailed = [...commands].reverse().find((record) => (record.exitCode ?? 0) !== 0);
164
+ const files = fileLedger(session);
165
+ return {
166
+ lastUser: reversed.find((event) => event.kind === 'user' && event.text?.trim()),
167
+ lastAssistant: reversed.find((event) => event.kind === 'assistant' && event.text?.trim()),
168
+ lastToolCall: reversed.find((event) => event.kind === 'tool_call'),
169
+ lastOk,
170
+ lastFailed,
171
+ lastFile: files.at(-1),
172
+ unfinishedTurns: summaries.filter((turn) => turn.status !== 'completed'),
173
+ };
174
+ }
175
+ function formatCount(value) {
176
+ return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
177
+ }
178
+ function render(overview, session, summaries) {
179
+ const workspace = session.ref.workspace;
180
+ const { session: ref, stats, goal, corrections, nudges, pastes, anchors, pitfalls } = overview;
181
+ const files = overview.writes;
182
+ const final = finalState(session, summaries);
183
+ const byGroup = (group) => files.filter((file) => file.group === group);
184
+ const cmd = (record) => record
185
+ ? `\`exit ${record.exitCode ?? '?'}\` ${oneLine(record.command, 140)}` +
186
+ ` \`E${record.eventIndex} · T${record.turn}\``
187
+ : '(无)';
188
+ const lines = [
189
+ `# 会话事实 · ${ref.provider}:${ref.id.slice(0, 8)}`,
190
+ '',
191
+ `**${ref.title ?? '(无标题)'}**`,
192
+ '',
193
+ `会话已结束(最后事件 ${ref.updatedAt ?? '?'})| ${stats.jobCounts.unknown} 个作业无完成回执` +
194
+ `| ${final.unfinishedTurns.length} 轮未正常收尾`,
195
+ '',
196
+ `- 工作区:${ref.workspace ?? '未知'} 时间:${ref.createdAt ?? '?'} → ${ref.updatedAt ?? '?'}`,
197
+ ...(stats.models.length ? [`- 模型:${stats.models.join(', ')}`] : []),
198
+ ...(stats.branches.length ? [`- 分支:${stats.branches.join(', ')}`] : []),
199
+ ...(anchors.hosts.length ? [`- 远程主机:${anchors.hosts.join(', ')}`] : []),
200
+ '',
201
+ '## 末态(最后发生的事实)',
202
+ '',
203
+ `- 最后一条用户请求:${oneLine(final.lastUser?.text, 150) || '(无)'}`,
204
+ `- 最后一条 assistant:${oneLine(final.lastAssistant?.text, 150) || '(无)'}`,
205
+ `- 最后一次工具调用:${final.lastToolCall?.toolName ?? '(无)'} \`E${final.lastToolCall?.index ?? '?'}\``,
206
+ `- 最后一条成功命令:${cmd(final.lastOk)}`,
207
+ `- 最后一条失败命令:${cmd(final.lastFailed)}`,
208
+ `- 最后改动的文件:${final.lastFile ? `${displayPath(final.lastFile, workspace)} \`T${final.lastFile.turn}\`` : '(无)'}`,
209
+ ...(final.unfinishedTurns.length
210
+ ? [`- 未正常收尾的轮次:${final.unfinishedTurns.map((turn) => `T${turn.no}(${turn.status})`).join('、')}`]
211
+ : []),
212
+ '',
213
+ '> 当前主线 / 阻塞 / 下一步属语义层,本阶段不生成。',
214
+ '> 每条事实后的 `E<事件号> · T<轮次>` 可直接下钻:`1session turn <id> <T> --event <E>`。',
215
+ '',
216
+ '## 统计',
217
+ '',
218
+ '| 轮次 | 事件 | 文件 | 命令 | 失败 | 提交 | 产物 | 上传 | 作业 |',
219
+ '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
220
+ `| ${stats.turns} | ${Object.values(stats.events).reduce((a, b) => a + b, 0)} | ` +
221
+ `${stats.filesChanged}(${stats.fileChangeEvents} 次写) | ` +
222
+ `${stats.commands} | ${stats.errors} | ${stats.commits.length} | ${stats.artifacts.length} | ` +
223
+ `${stats.uploads.length} | ${stats.jobs.length} |`,
224
+ '',
225
+ `文件溯源:observed ${stats.filesByProvenance.observed} · derived ${stats.filesByProvenance.derived}`,
226
+ '',
227
+ `事件构成:${Object.entries(stats.events).map(([kind, n]) => `${kind} ${n}`).join(' · ')}` +
228
+ (stats.tokens
229
+ ? ` | token:入 ${formatCount(stats.tokens.input)} / 出 ${formatCount(stats.tokens.output)}` +
230
+ (stats.tokens.cacheRead ? `(另有 ${formatCount(stats.tokens.cacheRead)} 缓存复用)` : '')
231
+ : ''),
232
+ ...(Object.keys(stats.extras).length
233
+ ? ['', `其他:${Object.entries(stats.extras).map(([k, n]) => `${k} ${n}`).join(' · ')}`]
234
+ : []),
235
+ '',
236
+ '## 目标(最初的用户请求,非当前主线)',
237
+ '',
238
+ goal || '(未能识别)',
239
+ '',
240
+ ];
241
+ if (corrections.length) {
242
+ lines.push('## 指令轨迹', '', ...corrections.map((note, i) => `- **U${i + 1}** \`${note.timestamp?.slice(11, 19) ?? ''}\` ${note.text}`), '', `(另有 ${nudges} 条纯推进指令、${pastes} 条回灌的报告,已折叠)`, '');
243
+ }
244
+ lines.push('## 资源(candidate:文本中提及,非动作证据)', '', anchors.paths.length
245
+ ? anchors.paths
246
+ .map((anchor) => {
247
+ const tail = anchor.lastTurn === summaries.length
248
+ ? 'active'
249
+ : `末见于 T${anchor.lastTurn},此后未再出现`;
250
+ return `- ${anchor.kind === 'dir' ? '📁' : '📄'} ${anchor.path} 首见 T${anchor.firstTurn} · ${anchor.hits} 次 · ${tail}`;
251
+ })
252
+ .join('\n')
253
+ : '- (无)', '');
254
+ if (stats.jobs.length) {
255
+ const counts = stats.jobCounts;
256
+ lines.push(`## 异步作业(completed ${counts.completed} · failed ${counts.failed} · running ${counts.running} · unknown ${counts.unknown})`, '', ...stats.jobs
257
+ .slice(0, 12)
258
+ .map((job) => `- [${job.status}] ${job.log ?? job.id}${job.pid ? ` pid ${job.pid}` : ''}` +
259
+ `${job.host ? ` @${job.host}` : ''}` +
260
+ `${job.discoveredFrom !== undefined ? ` \`E${job.discoveredFrom}\`` : ''}` +
261
+ `\n ${job.evidence.join(';')}`), ...(stats.jobs.length > 12 ? [`- …另有 ${stats.jobs.length - 12} 个,用 1session jobs 查看`] : []), '');
262
+ }
263
+ if (stats.commits.length) {
264
+ lines.push('## Git 提交', '', ...stats.commits.map((commit) => `- ${commit.sha ? `\`${commit.sha.slice(0, 7)}\` ` : ''}${commit.message}`), '');
265
+ }
266
+ const project = byGroup('project');
267
+ const runtime = byGroup('runtime');
268
+ const logs = byGroup('log');
269
+ lines.push('## 关键产物', '');
270
+ if (stats.artifacts.length) {
271
+ lines.push('### 会话产物', '', ...stats.artifacts.map((artifact) => `- [${artifact.kind}] ${artifact.name}${artifact.summary ? `\n ${oneLine(artifact.summary, 200)}` : ''}`), '');
272
+ }
273
+ lines.push('### 项目文件', '', project.length
274
+ ? project
275
+ .map((file) => `- ${displayPath(file, workspace)} \`${file.extractor}${file.eventIndex >= 0 ? ` E${file.eventIndex}` : ''} · T${file.turn}\``)
276
+ .join('\n')
277
+ : '- (无)', '', '### 运行态文件', '', runtime.length
278
+ ? runtime
279
+ .slice(0, 15)
280
+ .map((file) => `- ${displayPath(file, workspace)} \`${file.extractor}\``)
281
+ .join('\n') +
282
+ (runtime.length > 15 ? `\n- …另有 ${runtime.length - 15} 个` : '')
283
+ : '- (无)', '', `### 日志与临时:${logs.length} 个(1session files ${ref.id.slice(0, 8)} --group log 查看)`, '');
284
+ const errors = errorLedger(session);
285
+ if (errors.length) {
286
+ lines.push(`## 错误(${errors.length} 条,exit_code ≠ 0)`, '', ...errors
287
+ .slice(0, 12)
288
+ .map((record) => `- \`exit ${record.exitCode ?? '?'}\` ${oneLine(record.command, 120)} \`E${record.eventIndex} · T${record.turn}\`` +
289
+ `${record.laterSucceeded ? ' → 同前缀命令后续成功过' : ''}` +
290
+ `${record.stderr ? `\n ${oneLine(record.stderr, 150)}` : ''}`), ...(errors.length > 12 ? [`- …另有 ${errors.length - 12} 条,用 1session errors 查看`] : []), '');
291
+ }
292
+ else if (pitfalls.length) {
293
+ lines.push('## 错误', '', ...pitfalls.slice(0, 8).map((pitfall) => `- ${pitfall}`), '');
294
+ }
295
+ return lines.join('\n');
296
+ }
297
+ /**
298
+ * Tier one of the three-level view: everything about a session that is worth
299
+ * knowing before deciding which turn to open.
300
+ */
301
+ export function buildOverview(session) {
302
+ const files = fileLedger(session);
303
+ const userTurns = session.turns.filter((turn) => turn.kind === 'user' && turn.text?.trim());
304
+ const notes = userTurns.slice(1).map((turn) => ({
305
+ timestamp: turn.timestamp,
306
+ kind: classifyUserTurn(turn.text),
307
+ text: oneLine(turn.text, 400),
308
+ }));
309
+ const lastAssistant = [...session.turns].reverse().find((turn) => turn.kind === 'assistant' && turn.text?.trim());
310
+ const summaries = summarizeTurns(session);
311
+ const overview = {
312
+ session: session.ref,
313
+ stats: buildStats(session, files, summaries.length),
314
+ goal: clip(userTurns[0]?.text, 1200),
315
+ corrections: notes.filter((note) => note.kind === 'correction'),
316
+ nudges: notes.filter((note) => note.kind === 'nudge').length,
317
+ pastes: notes.filter((note) => note.kind === 'paste').length,
318
+ anchors: collectAnchors(session, summaries),
319
+ writes: files,
320
+ pitfalls: collectPitfalls(session.turns),
321
+ lastWord: clip(lastAssistant?.text, 800),
322
+ };
323
+ return { ...overview, markdown: render(overview, session, summaries) };
324
+ }
@@ -0,0 +1,4 @@
1
+ import type { ProviderAdapter } from './provider.js';
2
+ export declare const antigravityAdapter: ProviderAdapter;
3
+ /** Antigravity shortens long step output in the transcript; this is the full copy. */
4
+ export declare function readFullStepOutput(transcriptPath: string, stepIndex: number): Promise<string | undefined>;
@@ -0,0 +1,297 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readJsonl } from '../util/jsonl.js';
5
+ import { canonicalizePath, findRepoRoot } from '../util/paths.js';
6
+ import { clip, oneLine, stripPromptEnvelope } from '../util/text.js';
7
+ import { emptyProviderStats, } from '../types.js';
8
+ const BRAIN_DIR = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
9
+ const TRANSCRIPTS = ['transcript.jsonl', 'transcript_full.jsonl'];
10
+ /** Args whose value is an absolute path we can use to locate the repository. */
11
+ const PATH_ARGS = ['AbsolutePath', 'DirectoryPath', 'SearchPath', 'TargetFile', 'FilePath'];
12
+ /** Antigravity prints the exit status and a timestamp pair into the result text. */
13
+ const EXIT_CODE = /The command exited with code (\d+)/;
14
+ const TIME_PAIR = /Created At:\s*(\S+)\s*\nCompleted At:\s*(\S+)/;
15
+ const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp|svg)$/i;
16
+ const TEXT_EXT = /\.(?:md|txt|json|csv)$/i;
17
+ function artifactKind(name) {
18
+ if (IMAGE_EXT.test(name))
19
+ return 'image';
20
+ return /\.md$/i.test(name) ? 'markdown' : 'other';
21
+ }
22
+ /** Background jobs: `tasks/task-N.log` is the output, `messages/` the receipts. */
23
+ async function readTasks(systemDir) {
24
+ const tasks = new Map();
25
+ for (const name of await fs.readdir(path.join(systemDir, 'tasks')).catch(() => [])) {
26
+ const id = name.replace(/\.log$/, '');
27
+ tasks.set(id, { id, log: path.join(systemDir, 'tasks', name), finished: false });
28
+ }
29
+ for (const name of await fs.readdir(path.join(systemDir, 'messages')).catch(() => [])) {
30
+ const file = path.join(systemDir, 'messages', name);
31
+ const notice = await fs
32
+ .readFile(file, 'utf8')
33
+ .then((raw) => JSON.parse(raw))
34
+ .catch(() => undefined);
35
+ if (!notice?.sender)
36
+ continue;
37
+ const id = notice.sender.split('/').pop() ?? notice.sender;
38
+ const existing = tasks.get(id) ?? { id, finished: false };
39
+ const title = notice.renderDetails?.messageTitle;
40
+ // A notice only exists once the job has reported back.
41
+ tasks.set(id, { ...existing, finished: true, ...(title === undefined ? {} : { title }) });
42
+ }
43
+ return [...tasks.values()];
44
+ }
45
+ async function readUploads(sessionDir) {
46
+ const uploads = [];
47
+ for (const dir of ['.user_uploaded', '.tempmediaStorage']) {
48
+ const full = path.join(sessionDir, dir);
49
+ for (const name of await fs.readdir(full).catch(() => [])) {
50
+ if (name.startsWith('.'))
51
+ continue;
52
+ const stat = await fs.stat(path.join(full, name)).catch(() => undefined);
53
+ uploads.push({
54
+ name,
55
+ path: path.join(full, name),
56
+ ...(stat ? { sizeBytes: stat.size } : {}),
57
+ });
58
+ }
59
+ }
60
+ return uploads;
61
+ }
62
+ /** Antigravity double-encodes every tool argument as a JSON string. */
63
+ function unwrapArgs(args) {
64
+ const out = {};
65
+ for (const [key, raw] of Object.entries(args ?? {})) {
66
+ if (typeof raw === 'string') {
67
+ try {
68
+ out[key] = JSON.parse(raw);
69
+ continue;
70
+ }
71
+ catch {
72
+ /* plain string */
73
+ }
74
+ }
75
+ out[key] = raw;
76
+ }
77
+ return out;
78
+ }
79
+ function workspaceFromCall(name, args) {
80
+ if (name === 'run_command' && typeof args.Cwd === 'string' && args.Cwd) {
81
+ return canonicalizePath(args.Cwd);
82
+ }
83
+ for (const key of PATH_ARGS) {
84
+ const value = args[key];
85
+ if (typeof value === 'string' && value) {
86
+ const root = findRepoRoot(value);
87
+ if (root)
88
+ return root;
89
+ }
90
+ }
91
+ return undefined;
92
+ }
93
+ async function transcriptPath(dir) {
94
+ for (const name of TRANSCRIPTS) {
95
+ const file = path.join(dir, '.system_generated', 'logs', name);
96
+ try {
97
+ await fs.access(file);
98
+ return file;
99
+ }
100
+ catch {
101
+ /* try next */
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+ /** Every file the agent left in the session directory, not just markdown. */
107
+ async function readArtifacts(dir) {
108
+ let entries;
109
+ try {
110
+ entries = await fs.readdir(dir);
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ const artifacts = [];
116
+ const names = entries.filter((name) => !name.startsWith('.') && !name.endsWith('.metadata.json')).sort();
117
+ for (const name of names) {
118
+ const file = path.join(dir, name);
119
+ const stat = await fs.stat(file).catch(() => undefined);
120
+ if (!stat?.isFile())
121
+ continue;
122
+ const kind = artifactKind(name);
123
+ const meta = await fs
124
+ .readFile(`${file}.metadata.json`, 'utf8')
125
+ .then((raw) => JSON.parse(raw))
126
+ .catch(() => undefined);
127
+ artifacts.push({
128
+ name,
129
+ path: file,
130
+ kind,
131
+ sizeBytes: stat.size,
132
+ ...(meta?.summary ? { summary: meta.summary } : {}),
133
+ ...(meta?.updatedAt ? { updatedAt: meta.updatedAt } : {}),
134
+ // Binary artifacts are listed, never loaded.
135
+ ...(kind !== 'image' && TEXT_EXT.test(name)
136
+ ? { content: await fs.readFile(file, 'utf8').catch(() => undefined) }
137
+ : {}),
138
+ });
139
+ }
140
+ return artifacts;
141
+ }
142
+ export const antigravityAdapter = {
143
+ provider: 'antigravity',
144
+ async listCandidates() {
145
+ let dirs;
146
+ try {
147
+ dirs = await fs.readdir(BRAIN_DIR);
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ const found = [];
153
+ for (const id of dirs) {
154
+ if (id.startsWith('.'))
155
+ continue;
156
+ const file = await transcriptPath(path.join(BRAIN_DIR, id));
157
+ if (!file)
158
+ continue;
159
+ const stat = await fs.stat(file).catch(() => undefined);
160
+ if (!stat)
161
+ continue;
162
+ found.push({ id, path: file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size });
163
+ }
164
+ return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
165
+ },
166
+ async scanRef(candidate) {
167
+ let title;
168
+ let workspace;
169
+ let createdAt;
170
+ for await (const raw of readJsonl(candidate.path, { maxLines: 400 })) {
171
+ const step = raw;
172
+ createdAt ??= step.created_at;
173
+ if (!title && step.type === 'USER_INPUT' && step.content) {
174
+ title = oneLine(stripPromptEnvelope(step.content), 120);
175
+ }
176
+ for (const call of step.tool_calls ?? []) {
177
+ workspace ??= workspaceFromCall(call.name, unwrapArgs(call.args));
178
+ }
179
+ if (title && workspace)
180
+ break;
181
+ }
182
+ return {
183
+ id: candidate.id,
184
+ provider: 'antigravity',
185
+ path: candidate.path,
186
+ title,
187
+ workspace,
188
+ createdAt,
189
+ updatedAt: new Date(candidate.mtimeMs).toISOString(),
190
+ sizeBytes: candidate.sizeBytes,
191
+ };
192
+ },
193
+ /**
194
+ * `parse` also reads the artifacts sitting next to the transcript, so the
195
+ * transcript's own size and mtime are not the whole identity.
196
+ */
197
+ async auxFingerprint(candidate) {
198
+ const dir = path.dirname(path.dirname(path.dirname(candidate.path)));
199
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => undefined);
200
+ if (!entries)
201
+ return undefined;
202
+ let newest = 0;
203
+ let count = 0;
204
+ for (const entry of entries) {
205
+ if (!entry.isFile())
206
+ continue;
207
+ const stat = await fs.stat(path.join(dir, entry.name)).catch(() => undefined);
208
+ if (!stat)
209
+ continue;
210
+ count++;
211
+ newest = Math.max(newest, Math.round(stat.mtimeMs));
212
+ }
213
+ return `${count}:${newest}`;
214
+ },
215
+ async parse(candidate) {
216
+ const turns = [];
217
+ let title;
218
+ let workspace;
219
+ let createdAt;
220
+ const stats = emptyProviderStats();
221
+ const push = (turn) => {
222
+ turns.push({ ...turn, index: turns.length, id: `${candidate.id}#${turns.length}` });
223
+ };
224
+ for await (const raw of readJsonl(candidate.path)) {
225
+ const step = raw;
226
+ const timestamp = step.created_at;
227
+ createdAt ??= timestamp;
228
+ const sourceIndex = step.step_index;
229
+ const truncated = (step.truncated_fields?.length ?? 0) > 0;
230
+ if (step.type && !['USER_INPUT', 'PLANNER_RESPONSE', 'GENERIC'].includes(step.type)) {
231
+ stats.extras[step.type] = (stats.extras[step.type] ?? 0) + 1;
232
+ }
233
+ if (step.type === 'USER_INPUT' && step.content) {
234
+ const text = stripPromptEnvelope(step.content);
235
+ title ??= oneLine(text, 120);
236
+ push({ kind: 'user', text, timestamp, sourceIndex });
237
+ continue;
238
+ }
239
+ if (step.thinking)
240
+ push({ kind: 'thinking', text: step.thinking, timestamp, sourceIndex });
241
+ if (step.type === 'PLANNER_RESPONSE' && step.content) {
242
+ push({ kind: 'assistant', text: step.content, timestamp, sourceIndex, truncated });
243
+ }
244
+ for (const call of step.tool_calls ?? []) {
245
+ const args = unwrapArgs(call.args);
246
+ workspace ??= workspaceFromCall(call.name, args);
247
+ push({ kind: 'tool_call', toolName: call.name, toolArgs: args, timestamp, sourceIndex, truncated });
248
+ }
249
+ if (step.type === 'GENERIC' && step.content) {
250
+ const exit = EXIT_CODE.exec(step.content);
251
+ const times = TIME_PAIR.exec(step.content);
252
+ const exitCode = exit ? Number(exit[1]) : undefined;
253
+ const durationMs = times
254
+ ? Math.max(0, Date.parse(times[2]) - Date.parse(times[1])) || undefined
255
+ : undefined;
256
+ push({
257
+ kind: 'tool_result',
258
+ toolResult: step.content,
259
+ // Prefer the printed exit status; fall back to wording only without one.
260
+ isError: exitCode !== undefined
261
+ ? exitCode !== 0
262
+ : /Encountered error|Error:|command failed/i.test(step.content.slice(0, 400)),
263
+ timestamp,
264
+ sourceIndex,
265
+ truncated,
266
+ ...(exitCode !== undefined ? { exitCode } : {}),
267
+ ...(durationMs !== undefined ? { durationMs } : {}),
268
+ });
269
+ }
270
+ }
271
+ const dir = path.resolve(path.dirname(candidate.path), '..', '..');
272
+ const systemDir = path.join(dir, '.system_generated');
273
+ const artifacts = await readArtifacts(dir);
274
+ stats.backgroundTasks = await readTasks(systemDir);
275
+ stats.uploads = await readUploads(dir);
276
+ return {
277
+ ref: {
278
+ id: candidate.id,
279
+ provider: 'antigravity',
280
+ path: candidate.path,
281
+ title: title ?? clip(artifacts[0]?.name, 120),
282
+ workspace,
283
+ createdAt,
284
+ updatedAt: turns.at(-1)?.timestamp ?? new Date(candidate.mtimeMs).toISOString(),
285
+ sizeBytes: candidate.sizeBytes,
286
+ },
287
+ turns,
288
+ artifacts,
289
+ stats,
290
+ };
291
+ },
292
+ };
293
+ /** Antigravity shortens long step output in the transcript; this is the full copy. */
294
+ export async function readFullStepOutput(transcriptPath, stepIndex) {
295
+ const systemDir = path.resolve(path.dirname(transcriptPath), '..');
296
+ return fs.readFile(path.join(systemDir, 'steps', String(stepIndex), 'output.txt'), 'utf8').catch(() => undefined);
297
+ }
@@ -0,0 +1,2 @@
1
+ import { type ProviderAdapter } from './provider.js';
2
+ export declare const claudeAdapter: ProviderAdapter;