@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.
- package/README.md +347 -0
- package/dist/bin/1session.d.ts +2 -0
- package/dist/bin/1session.js +408 -0
- package/dist/src/aggregator.d.ts +12 -0
- package/dist/src/aggregator.js +136 -0
- package/dist/src/classify.d.ts +2 -0
- package/dist/src/classify.js +11 -0
- package/dist/src/distiller.d.ts +7 -0
- package/dist/src/distiller.js +122 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +17 -0
- package/dist/src/ledger.d.ts +19 -0
- package/dist/src/ledger.js +192 -0
- package/dist/src/overview.d.ts +6 -0
- package/dist/src/overview.js +324 -0
- package/dist/src/parsers/antigravity.d.ts +4 -0
- package/dist/src/parsers/antigravity.js +297 -0
- package/dist/src/parsers/claude.d.ts +2 -0
- package/dist/src/parsers/claude.js +214 -0
- package/dist/src/parsers/codex.d.ts +2 -0
- package/dist/src/parsers/codex.js +316 -0
- package/dist/src/parsers/provider.d.ts +24 -0
- package/dist/src/parsers/provider.js +5 -0
- package/dist/src/resolver.d.ts +42 -0
- package/dist/src/resolver.js +145 -0
- package/dist/src/search.d.ts +59 -0
- package/dist/src/search.js +257 -0
- package/dist/src/store/db.d.ts +10 -0
- package/dist/src/store/db.js +89 -0
- package/dist/src/store/edges.d.ts +46 -0
- package/dist/src/store/edges.js +153 -0
- package/dist/src/store/facts.d.ts +8 -0
- package/dist/src/store/facts.js +39 -0
- package/dist/src/store/indexer.d.ts +40 -0
- package/dist/src/store/indexer.js +62 -0
- package/dist/src/store/read.d.ts +29 -0
- package/dist/src/store/read.js +70 -0
- package/dist/src/store/rows.d.ts +35 -0
- package/dist/src/store/rows.js +55 -0
- package/dist/src/store/schema.d.ts +19 -0
- package/dist/src/store/schema.js +142 -0
- package/dist/src/store/write.d.ts +22 -0
- package/dist/src/store/write.js +72 -0
- package/dist/src/turns.d.ts +14 -0
- package/dist/src/turns.js +156 -0
- package/dist/src/types.d.ts +294 -0
- package/dist/src/types.js +13 -0
- package/dist/src/util/jsonl.d.ts +6 -0
- package/dist/src/util/jsonl.js +32 -0
- package/dist/src/util/paths.d.ts +15 -0
- package/dist/src/util/paths.js +69 -0
- package/dist/src/util/text.d.ts +7 -0
- package/dist/src/util/text.js +48 -0
- package/dist/src/writes.d.ts +35 -0
- package/dist/src/writes.js +204 -0
- package/package.json +58 -0
|
@@ -0,0 +1,214 @@
|
|
|
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 } from '../util/paths.js';
|
|
6
|
+
import { oneLine, stripPromptEnvelope } from '../util/text.js';
|
|
7
|
+
import { emptyProviderStats, } from '../types.js';
|
|
8
|
+
import { toolArgsOf } from './provider.js';
|
|
9
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
10
|
+
/** Claude prefixes a failed shell result with its exit status. */
|
|
11
|
+
const EXIT_CODE = /^\s*Exit code (\d+)/;
|
|
12
|
+
/** `tool_result.content` is either a string or a list of text blocks. */
|
|
13
|
+
function flattenResult(content) {
|
|
14
|
+
if (typeof content === 'string')
|
|
15
|
+
return content;
|
|
16
|
+
if (Array.isArray(content)) {
|
|
17
|
+
return content
|
|
18
|
+
.map((block) => (typeof block === 'string' ? block : (block?.text ?? '')))
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join('\n');
|
|
21
|
+
}
|
|
22
|
+
return content == null ? '' : JSON.stringify(content);
|
|
23
|
+
}
|
|
24
|
+
export const claudeAdapter = {
|
|
25
|
+
provider: 'claude',
|
|
26
|
+
async listCandidates() {
|
|
27
|
+
let projects;
|
|
28
|
+
try {
|
|
29
|
+
projects = await fs.readdir(PROJECTS_DIR);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const found = [];
|
|
35
|
+
for (const project of projects) {
|
|
36
|
+
const dir = path.join(PROJECTS_DIR, project);
|
|
37
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
if (!file.endsWith('.jsonl'))
|
|
40
|
+
continue;
|
|
41
|
+
const full = path.join(dir, file);
|
|
42
|
+
const stat = await fs.stat(full).catch(() => undefined);
|
|
43
|
+
if (!stat?.isFile())
|
|
44
|
+
continue;
|
|
45
|
+
found.push({
|
|
46
|
+
id: file.replace(/\.jsonl$/, ''),
|
|
47
|
+
path: full,
|
|
48
|
+
mtimeMs: stat.mtimeMs,
|
|
49
|
+
sizeBytes: stat.size,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
54
|
+
},
|
|
55
|
+
/**
|
|
56
|
+
* Only the head of the file is read, so the title is the opening request —
|
|
57
|
+
* a session's own `custom-title`/`ai-title` is picked up by {@link parse}.
|
|
58
|
+
*/
|
|
59
|
+
async scanRef(candidate) {
|
|
60
|
+
let title;
|
|
61
|
+
let workspace;
|
|
62
|
+
let createdAt;
|
|
63
|
+
for await (const raw of readJsonl(candidate.path, { maxLines: 60 })) {
|
|
64
|
+
const entry = raw;
|
|
65
|
+
if (entry.cwd)
|
|
66
|
+
workspace ??= canonicalizePath(entry.cwd);
|
|
67
|
+
createdAt ??= entry.timestamp;
|
|
68
|
+
if (entry.type === 'custom-title' && entry.customTitle)
|
|
69
|
+
title = entry.customTitle;
|
|
70
|
+
if (!title && entry.type === 'user' && !entry.isMeta && typeof entry.message?.content === 'string') {
|
|
71
|
+
title = oneLine(stripPromptEnvelope(entry.message.content), 120);
|
|
72
|
+
}
|
|
73
|
+
if (title && workspace)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
id: candidate.id,
|
|
78
|
+
provider: 'claude',
|
|
79
|
+
path: candidate.path,
|
|
80
|
+
title,
|
|
81
|
+
workspace,
|
|
82
|
+
createdAt,
|
|
83
|
+
updatedAt: new Date(candidate.mtimeMs).toISOString(),
|
|
84
|
+
sizeBytes: candidate.sizeBytes,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
async parse(candidate) {
|
|
88
|
+
const turns = [];
|
|
89
|
+
const stats = emptyProviderStats();
|
|
90
|
+
const tokens = { input: 0, output: 0, total: 0 };
|
|
91
|
+
let cacheRead = 0;
|
|
92
|
+
// One assistant message can appear on several entries; count its usage once.
|
|
93
|
+
const countedUsage = new Set();
|
|
94
|
+
let title;
|
|
95
|
+
let firstPrompt;
|
|
96
|
+
let workspace;
|
|
97
|
+
let createdAt;
|
|
98
|
+
let updatedAt;
|
|
99
|
+
const push = (turn) => {
|
|
100
|
+
turns.push({ ...turn, index: turns.length, id: `${candidate.id}#${turns.length}` });
|
|
101
|
+
};
|
|
102
|
+
for await (const raw of readJsonl(candidate.path)) {
|
|
103
|
+
const entry = raw;
|
|
104
|
+
if (entry.cwd)
|
|
105
|
+
workspace ??= canonicalizePath(entry.cwd);
|
|
106
|
+
if (entry.timestamp) {
|
|
107
|
+
createdAt ??= entry.timestamp;
|
|
108
|
+
updatedAt = entry.timestamp;
|
|
109
|
+
}
|
|
110
|
+
if (entry.type === 'custom-title' && entry.customTitle)
|
|
111
|
+
title = entry.customTitle;
|
|
112
|
+
if (entry.type === 'ai-title' && typeof entry.title === 'string')
|
|
113
|
+
title ??= entry.title;
|
|
114
|
+
if (entry.gitBranch && !stats.branches.includes(entry.gitBranch))
|
|
115
|
+
stats.branches.push(entry.gitBranch);
|
|
116
|
+
const model = entry.message?.model;
|
|
117
|
+
if (model && !stats.models.includes(model))
|
|
118
|
+
stats.models.push(model);
|
|
119
|
+
const usage = entry.message?.usage;
|
|
120
|
+
const usageKey = entry.message?.id ?? entry.requestId ?? entry.uuid;
|
|
121
|
+
if (usage && usageKey && !countedUsage.has(usageKey)) {
|
|
122
|
+
countedUsage.add(usageKey);
|
|
123
|
+
// Cache reads are prefix replays, not input: counting them as input
|
|
124
|
+
// turns a 200k-context session into "116M tokens".
|
|
125
|
+
tokens.input += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
126
|
+
tokens.output += usage.output_tokens ?? 0;
|
|
127
|
+
cacheRead += usage.cache_read_input_tokens ?? 0;
|
|
128
|
+
}
|
|
129
|
+
if (entry.isSidechain)
|
|
130
|
+
stats.extras.sidechain = (stats.extras.sidechain ?? 0) + 1;
|
|
131
|
+
if (entry.type === 'attachment' && entry.attachment?.type) {
|
|
132
|
+
const key = `attachment:${entry.attachment.type}`;
|
|
133
|
+
stats.extras[key] = (stats.extras[key] ?? 0) + 1;
|
|
134
|
+
}
|
|
135
|
+
if (entry.type === 'file-history-snapshot') {
|
|
136
|
+
stats.extras.fileSnapshots = (stats.extras.fileSnapshots ?? 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
if (entry.isMeta)
|
|
139
|
+
continue;
|
|
140
|
+
const timestamp = entry.timestamp;
|
|
141
|
+
const content = entry.message?.content;
|
|
142
|
+
if (entry.type === 'user' && typeof content === 'string') {
|
|
143
|
+
const text = stripPromptEnvelope(content);
|
|
144
|
+
if (!text)
|
|
145
|
+
continue;
|
|
146
|
+
firstPrompt ??= oneLine(text, 120);
|
|
147
|
+
push({ kind: 'user', text, timestamp });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(content))
|
|
151
|
+
continue;
|
|
152
|
+
for (const block of content) {
|
|
153
|
+
switch (block.type) {
|
|
154
|
+
case 'text':
|
|
155
|
+
if (block.text?.trim())
|
|
156
|
+
push({ kind: 'assistant', text: block.text, timestamp });
|
|
157
|
+
break;
|
|
158
|
+
case 'thinking':
|
|
159
|
+
if (block.thinking?.trim())
|
|
160
|
+
push({ kind: 'thinking', text: block.thinking, timestamp });
|
|
161
|
+
break;
|
|
162
|
+
case 'tool_use':
|
|
163
|
+
push({
|
|
164
|
+
kind: 'tool_call',
|
|
165
|
+
toolName: block.name,
|
|
166
|
+
toolArgs: toolArgsOf(block.input),
|
|
167
|
+
timestamp,
|
|
168
|
+
});
|
|
169
|
+
break;
|
|
170
|
+
case 'tool_result': {
|
|
171
|
+
const text = flattenResult(block.content);
|
|
172
|
+
const exit = EXIT_CODE.exec(text);
|
|
173
|
+
push({
|
|
174
|
+
kind: 'tool_result',
|
|
175
|
+
toolResult: text,
|
|
176
|
+
isError: block.is_error === true,
|
|
177
|
+
timestamp,
|
|
178
|
+
...(exit ? { exitCode: Number(exit[1]) } : {}),
|
|
179
|
+
});
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
ref: {
|
|
189
|
+
id: candidate.id,
|
|
190
|
+
provider: 'claude',
|
|
191
|
+
path: candidate.path,
|
|
192
|
+
title: title ?? firstPrompt,
|
|
193
|
+
workspace,
|
|
194
|
+
createdAt,
|
|
195
|
+
updatedAt: updatedAt ?? new Date(candidate.mtimeMs).toISOString(),
|
|
196
|
+
sizeBytes: candidate.sizeBytes,
|
|
197
|
+
},
|
|
198
|
+
turns,
|
|
199
|
+
artifacts: [],
|
|
200
|
+
stats: {
|
|
201
|
+
...stats,
|
|
202
|
+
...(tokens.input || tokens.output
|
|
203
|
+
? {
|
|
204
|
+
tokens: {
|
|
205
|
+
...tokens,
|
|
206
|
+
total: tokens.input + tokens.output,
|
|
207
|
+
...(cacheRead ? { cacheRead } : {}),
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
};
|
|
@@ -0,0 +1,316 @@
|
|
|
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 } from '../util/paths.js';
|
|
6
|
+
import { looksLikeInstructions, oneLine, stripPromptEnvelope } from '../util/text.js';
|
|
7
|
+
import { toolArgsOf } from './provider.js';
|
|
8
|
+
import { emptyProviderStats, } from '../types.js';
|
|
9
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.codex', 'sessions');
|
|
10
|
+
const ROLLOUT = /^rollout-.*?-([0-9a-fA-F-]{36})\.jsonl$/;
|
|
11
|
+
const SSH_HOST = /\b[\w.-]+@((?:\d{1,3}(?:\.\d{1,3}){3})|(?:[\w-]+(?:\.[\w-]+)+))/;
|
|
12
|
+
function durationOf(duration) {
|
|
13
|
+
if (!duration)
|
|
14
|
+
return undefined;
|
|
15
|
+
const ms = (duration.secs ?? 0) * 1000 + (duration.nanos ?? 0) / 1e6;
|
|
16
|
+
return ms > 0 ? Math.round(ms) : undefined;
|
|
17
|
+
}
|
|
18
|
+
const CHANGE_KINDS = {
|
|
19
|
+
add: 'add',
|
|
20
|
+
update: 'update',
|
|
21
|
+
delete: 'delete',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Absorbs the structured side-channel: authoritative file changes, command
|
|
25
|
+
* executions with pids, turn boundaries and token accounting. These mirror the
|
|
26
|
+
* conversation stream, so they feed statistics only — never the turn list.
|
|
27
|
+
*/
|
|
28
|
+
function absorbEvent(line, stats, tokens) {
|
|
29
|
+
const payload = line.payload ?? {};
|
|
30
|
+
if (line.type === 'token_usage_record') {
|
|
31
|
+
const usage = payload.usage;
|
|
32
|
+
if (usage) {
|
|
33
|
+
tokens.input += usage.input_tokens ?? 0;
|
|
34
|
+
tokens.output += usage.output_tokens ?? 0;
|
|
35
|
+
tokens.total += usage.total_tokens ?? 0;
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (line.type === 'event_msg' && payload.type === 'thread_settings_applied') {
|
|
40
|
+
const model = payload.thread_settings?.model;
|
|
41
|
+
if (model && !stats.models.includes(model))
|
|
42
|
+
stats.models.push(model);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (line.type === 'event_msg' && payload.type === 'task_started') {
|
|
46
|
+
stats.turnBoundaries.push({
|
|
47
|
+
id: payload.turn_id,
|
|
48
|
+
startedAt: line.timestamp,
|
|
49
|
+
completed: false,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (line.type === 'event_msg' && payload.type === 'task_complete') {
|
|
54
|
+
const turnId = payload.turn_id;
|
|
55
|
+
// Pair by id: this session starts 12 turns but completes only 9, so
|
|
56
|
+
// matching by position would attribute durations to the wrong turns.
|
|
57
|
+
const match = stats.turnBoundaries.find((boundary) => boundary.id === turnId && !boundary.completed);
|
|
58
|
+
if (match) {
|
|
59
|
+
match.completed = true;
|
|
60
|
+
match.endedAt = line.timestamp;
|
|
61
|
+
match.durationMs = payload.duration_ms;
|
|
62
|
+
match.lastMessage = payload.last_agent_message;
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (line.type !== 'event_msg' || payload.type !== 'item_completed')
|
|
67
|
+
return;
|
|
68
|
+
const item = (payload.item ?? {});
|
|
69
|
+
switch (item.type) {
|
|
70
|
+
case 'FileChange':
|
|
71
|
+
for (const [file, detail] of Object.entries(item.changes ?? {})) {
|
|
72
|
+
const size = detail?.content?.length;
|
|
73
|
+
stats.fileChanges.push({
|
|
74
|
+
path: canonicalizePath(file),
|
|
75
|
+
change: CHANGE_KINDS[detail?.type ?? ''] ?? 'update',
|
|
76
|
+
...(size === undefined ? {} : { sizeBytes: size }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
case 'CommandExecution': {
|
|
81
|
+
stats.commandExecutions = (stats.commandExecutions ?? 0) + 1;
|
|
82
|
+
const command = Array.isArray(item.command) ? item.command.join(' ') : (item.command ?? '');
|
|
83
|
+
const record = {
|
|
84
|
+
eventIndex: -1, // filled in by the ledger, which knows the event stream
|
|
85
|
+
turn: 0,
|
|
86
|
+
command,
|
|
87
|
+
provenance: 'observed',
|
|
88
|
+
extractor: 'item:CommandExecution',
|
|
89
|
+
...(item.process_id ? { pid: item.process_id } : {}),
|
|
90
|
+
...(item.cwd ? { cwd: canonicalizePath(item.cwd) } : {}),
|
|
91
|
+
...(typeof item.exit_code === 'number' ? { exitCode: item.exit_code } : {}),
|
|
92
|
+
...(durationOf(item.duration) ? { durationMs: durationOf(item.duration) } : {}),
|
|
93
|
+
...(item.stderr?.trim() ? { stderr: item.stderr.trim() } : {}),
|
|
94
|
+
...(line.timestamp ? { timestamp: line.timestamp } : {}),
|
|
95
|
+
};
|
|
96
|
+
const host = SSH_HOST.exec(command)?.[1];
|
|
97
|
+
if (host)
|
|
98
|
+
record.host = host;
|
|
99
|
+
stats.commands.push(record);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case 'Extension':
|
|
103
|
+
stats.extras[`web:${item.kind ?? 'unknown'}`] = (stats.extras[`web:${item.kind ?? 'unknown'}`] ?? 0) + 1;
|
|
104
|
+
break;
|
|
105
|
+
case 'Reasoning':
|
|
106
|
+
case 'AgentMessage':
|
|
107
|
+
case 'UserMessage':
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
if (item.type)
|
|
111
|
+
stats.extras[item.type] = (stats.extras[item.type] ?? 0) + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function textOf(content) {
|
|
115
|
+
if (typeof content === 'string')
|
|
116
|
+
return content;
|
|
117
|
+
if (!Array.isArray(content))
|
|
118
|
+
return '';
|
|
119
|
+
return content
|
|
120
|
+
.map((block) => block?.text ?? '')
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join('\n');
|
|
123
|
+
}
|
|
124
|
+
function parseArguments(value) {
|
|
125
|
+
if (typeof value === 'string') {
|
|
126
|
+
try {
|
|
127
|
+
return toolArgsOf(JSON.parse(value)) ?? { input: value };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { input: value };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return toolArgsOf(value);
|
|
134
|
+
}
|
|
135
|
+
/** Walks `sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`. */
|
|
136
|
+
async function walkRollouts(dir, out, depth = 0) {
|
|
137
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const full = path.join(dir, entry.name);
|
|
140
|
+
if (entry.isDirectory() && depth < 4)
|
|
141
|
+
await walkRollouts(full, out, depth + 1);
|
|
142
|
+
else if (entry.isFile() && ROLLOUT.test(entry.name))
|
|
143
|
+
out.push(full);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export const codexAdapter = {
|
|
147
|
+
provider: 'codex',
|
|
148
|
+
async listCandidates() {
|
|
149
|
+
const files = [];
|
|
150
|
+
await walkRollouts(SESSIONS_DIR, files);
|
|
151
|
+
const found = [];
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
const stat = await fs.stat(file).catch(() => undefined);
|
|
154
|
+
if (!stat)
|
|
155
|
+
continue;
|
|
156
|
+
found.push({
|
|
157
|
+
id: ROLLOUT.exec(path.basename(file))?.[1] ?? path.basename(file),
|
|
158
|
+
path: file,
|
|
159
|
+
mtimeMs: stat.mtimeMs,
|
|
160
|
+
sizeBytes: stat.size,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
164
|
+
},
|
|
165
|
+
async scanRef(candidate) {
|
|
166
|
+
let title;
|
|
167
|
+
let workspace;
|
|
168
|
+
let createdAt;
|
|
169
|
+
for await (const raw of readJsonl(candidate.path, { maxLines: 120 })) {
|
|
170
|
+
const line = raw;
|
|
171
|
+
const payload = line.payload ?? {};
|
|
172
|
+
if (line.type === 'session_meta') {
|
|
173
|
+
createdAt ??= payload.timestamp ?? line.timestamp;
|
|
174
|
+
if (typeof payload.cwd === 'string')
|
|
175
|
+
workspace ??= canonicalizePath(payload.cwd);
|
|
176
|
+
}
|
|
177
|
+
if (!workspace && line.type === 'turn_context' && typeof payload.cwd === 'string') {
|
|
178
|
+
workspace = canonicalizePath(payload.cwd);
|
|
179
|
+
}
|
|
180
|
+
if (!title && line.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
|
|
181
|
+
const text = stripPromptEnvelope(textOf(payload.content));
|
|
182
|
+
if (text && !looksLikeInstructions(text))
|
|
183
|
+
title = oneLine(text, 120);
|
|
184
|
+
}
|
|
185
|
+
if (title && workspace)
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
id: candidate.id,
|
|
190
|
+
provider: 'codex',
|
|
191
|
+
path: candidate.path,
|
|
192
|
+
title,
|
|
193
|
+
workspace,
|
|
194
|
+
createdAt: createdAt ?? timestampFromFilename(candidate.path),
|
|
195
|
+
updatedAt: new Date(candidate.mtimeMs).toISOString(),
|
|
196
|
+
sizeBytes: candidate.sizeBytes,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
async parse(candidate) {
|
|
200
|
+
const turns = [];
|
|
201
|
+
const stats = emptyProviderStats();
|
|
202
|
+
const tokens = { input: 0, output: 0, total: 0 };
|
|
203
|
+
let title;
|
|
204
|
+
let workspace;
|
|
205
|
+
let createdAt;
|
|
206
|
+
let updatedAt;
|
|
207
|
+
const push = (turn) => {
|
|
208
|
+
turns.push({ ...turn, index: turns.length, id: `${candidate.id}#${turns.length}` });
|
|
209
|
+
};
|
|
210
|
+
for await (const raw of readJsonl(candidate.path)) {
|
|
211
|
+
const line = raw;
|
|
212
|
+
const payload = line.payload ?? {};
|
|
213
|
+
const timestamp = line.timestamp;
|
|
214
|
+
if (timestamp) {
|
|
215
|
+
createdAt ??= timestamp;
|
|
216
|
+
updatedAt = timestamp;
|
|
217
|
+
}
|
|
218
|
+
if (line.type === 'session_meta' || line.type === 'turn_context') {
|
|
219
|
+
if (typeof payload.cwd === 'string')
|
|
220
|
+
workspace ??= canonicalizePath(payload.cwd);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (line.type !== 'response_item') {
|
|
224
|
+
absorbEvent(line, stats, tokens);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
switch (payload.type) {
|
|
228
|
+
case 'message': {
|
|
229
|
+
const text = stripPromptEnvelope(textOf(payload.content));
|
|
230
|
+
if (!text)
|
|
231
|
+
break;
|
|
232
|
+
if (payload.role === 'assistant') {
|
|
233
|
+
push({ kind: 'assistant', text, timestamp });
|
|
234
|
+
}
|
|
235
|
+
else if (payload.role === 'user') {
|
|
236
|
+
if (looksLikeInstructions(text))
|
|
237
|
+
break;
|
|
238
|
+
title ??= oneLine(text, 120);
|
|
239
|
+
push({ kind: 'user', text, timestamp });
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case 'reasoning': {
|
|
244
|
+
const summary = Array.isArray(payload.summary)
|
|
245
|
+
? payload.summary.map((b) => b?.text ?? '').filter(Boolean).join('\n')
|
|
246
|
+
: '';
|
|
247
|
+
if (summary)
|
|
248
|
+
push({ kind: 'thinking', text: summary, timestamp });
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case 'function_call':
|
|
252
|
+
push({
|
|
253
|
+
kind: 'tool_call',
|
|
254
|
+
toolName: payload.name,
|
|
255
|
+
toolArgs: parseArguments(payload.arguments),
|
|
256
|
+
timestamp,
|
|
257
|
+
});
|
|
258
|
+
break;
|
|
259
|
+
case 'custom_tool_call':
|
|
260
|
+
push({
|
|
261
|
+
kind: 'tool_call',
|
|
262
|
+
toolName: payload.name,
|
|
263
|
+
toolArgs: { input: payload.input },
|
|
264
|
+
timestamp,
|
|
265
|
+
});
|
|
266
|
+
break;
|
|
267
|
+
case 'local_shell_call':
|
|
268
|
+
push({
|
|
269
|
+
kind: 'tool_call',
|
|
270
|
+
toolName: 'shell',
|
|
271
|
+
toolArgs: toolArgsOf(payload.action) ?? {},
|
|
272
|
+
timestamp,
|
|
273
|
+
});
|
|
274
|
+
break;
|
|
275
|
+
case 'function_call_output':
|
|
276
|
+
case 'custom_tool_call_output': {
|
|
277
|
+
const output = textOf(payload.output) || String(payload.output ?? '');
|
|
278
|
+
push({
|
|
279
|
+
kind: 'tool_result',
|
|
280
|
+
toolResult: output,
|
|
281
|
+
// A bare "error" substring matches ordinary prose; require a real
|
|
282
|
+
// non-zero exit or a fatal marker at the start of a line.
|
|
283
|
+
isError: /"exit_code":\s*[1-9]/.test(output.slice(0, 400)) ||
|
|
284
|
+
/^(?:Traceback|fatal:|error:|[\w.]+Error:)/im.test(output.slice(0, 400)),
|
|
285
|
+
timestamp,
|
|
286
|
+
});
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
default:
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
ref: {
|
|
295
|
+
id: candidate.id,
|
|
296
|
+
provider: 'codex',
|
|
297
|
+
path: candidate.path,
|
|
298
|
+
title,
|
|
299
|
+
workspace,
|
|
300
|
+
createdAt,
|
|
301
|
+
updatedAt: updatedAt ?? new Date(candidate.mtimeMs).toISOString(),
|
|
302
|
+
sizeBytes: candidate.sizeBytes,
|
|
303
|
+
},
|
|
304
|
+
turns,
|
|
305
|
+
artifacts: [],
|
|
306
|
+
stats: { ...stats, ...(tokens.total ? { tokens } : {}) },
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
function timestampFromFilename(file) {
|
|
311
|
+
const stamp = /rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})/.exec(path.basename(file))?.[1];
|
|
312
|
+
if (!stamp)
|
|
313
|
+
return undefined;
|
|
314
|
+
const [date, time] = stamp.split('T');
|
|
315
|
+
return `${date}T${time?.replace(/-/g, ':')}`;
|
|
316
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentProvider, NormalizedSession, SessionRef } from '../types.js';
|
|
2
|
+
/** A session file found on disk, before any parsing. */
|
|
3
|
+
export interface SessionCandidate {
|
|
4
|
+
id: string;
|
|
5
|
+
path: string;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
sizeBytes: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ProviderAdapter {
|
|
10
|
+
provider: AgentProvider;
|
|
11
|
+
/** Cheap `stat`-only discovery, newest first. */
|
|
12
|
+
listCandidates(): Promise<SessionCandidate[]>;
|
|
13
|
+
/** Reads just enough of the head of a file to fill a {@link SessionRef}. */
|
|
14
|
+
scanRef(candidate: SessionCandidate): Promise<SessionRef>;
|
|
15
|
+
/** Full parse into ordered turns. */
|
|
16
|
+
parse(candidate: SessionCandidate): Promise<NormalizedSession>;
|
|
17
|
+
/**
|
|
18
|
+
* Extra identity for providers whose `parse` reads more than the transcript
|
|
19
|
+
* (antigravity also picks up artifacts next to it). Folded into the index
|
|
20
|
+
* fingerprint so those side files cannot go stale unnoticed.
|
|
21
|
+
*/
|
|
22
|
+
auxFingerprint?(candidate: SessionCandidate): Promise<string | undefined>;
|
|
23
|
+
}
|
|
24
|
+
export declare function toolArgsOf(value: unknown): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ProviderAdapter, SessionCandidate } from './parsers/provider.js';
|
|
2
|
+
import type { AgentProvider, NormalizedSession, SessionRef } from './types.js';
|
|
3
|
+
export declare const adapters: ProviderAdapter[];
|
|
4
|
+
export interface ListOptions {
|
|
5
|
+
limit?: number;
|
|
6
|
+
/**
|
|
7
|
+
* How many session files may be opened. Defaults to a small budget so an
|
|
8
|
+
* unfiltered `list` stays cheap; pass `Infinity` when completeness matters
|
|
9
|
+
* more than latency (indexing, search).
|
|
10
|
+
*/
|
|
11
|
+
scan?: number;
|
|
12
|
+
workspace?: string;
|
|
13
|
+
provider?: AgentProvider;
|
|
14
|
+
/** Only sessions updated at or after this moment (`24h`, `7d`, ISO date). */
|
|
15
|
+
since?: string | Date;
|
|
16
|
+
}
|
|
17
|
+
export interface ResolvedSession {
|
|
18
|
+
ref: SessionRef;
|
|
19
|
+
adapter: ProviderAdapter;
|
|
20
|
+
candidate: SessionCandidate;
|
|
21
|
+
}
|
|
22
|
+
/** Accepts `24h`, `90m`, `7d`, `2026-09-01` or a Date. */
|
|
23
|
+
export declare function parseSince(since: string | Date | undefined): number | undefined;
|
|
24
|
+
/** Discovery that keeps the adapter handle, so callers can parse without a second scan. */
|
|
25
|
+
export declare function listResolvedSessions(options?: ListOptions): Promise<ResolvedSession[]>;
|
|
26
|
+
export declare function listRecentSessions(options?: ListOptions): Promise<SessionRef[]>;
|
|
27
|
+
export declare function findSessionsByWorkspace(workspacePath: string, options?: Omit<ListOptions, 'workspace'>): Promise<SessionRef[]>;
|
|
28
|
+
export declare function findResolvedByWorkspace(workspacePath: string, options?: Omit<ListOptions, 'workspace'>): Promise<ResolvedSession[]>;
|
|
29
|
+
/** Locates a session by full id, id prefix, or native file path. */
|
|
30
|
+
export declare function resolveSession(sessionId: string): Promise<ResolvedSession | undefined>;
|
|
31
|
+
export declare function parseSession(sessionId: string): Promise<NormalizedSession>;
|
|
32
|
+
export interface LoadOptions {
|
|
33
|
+
/** Set false to bypass the index entirely (CI, read-only checkouts). */
|
|
34
|
+
useIndex?: boolean;
|
|
35
|
+
force?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The one seam every command loads sessions through. Whether the events come
|
|
39
|
+
* from the index or straight off disk, the object handed back is the same, so
|
|
40
|
+
* `buildOverview` / `fileLedger` / `summarizeTurns` never learn about caching.
|
|
41
|
+
*/
|
|
42
|
+
export declare function loadSession(sessionId: string, options?: LoadOptions): Promise<NormalizedSession>;
|