convoptics 0.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.
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+
5
+ async function* walkJsonl(root) {
6
+ const rootDir = await fs.opendir(root);
7
+ for await (const entry of rootDir) {
8
+ if (!entry.isDirectory()) continue;
9
+ const projectDir = await fs.opendir(path.join(root, entry.name));
10
+ for await (const file of projectDir) {
11
+ if (!file.isFile() || !file.name.endsWith('.jsonl')) continue;
12
+ yield path.join(root, entry.name, file.name);
13
+ }
14
+ }
15
+ }
16
+
17
+ export async function* scanSessions(root) {
18
+ for await (const filePath of walkJsonl(root)) {
19
+ const fd = await fs.open(filePath, 'r');
20
+ try {
21
+ const stream = fd.createReadStream();
22
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
23
+ let cwd = null;
24
+ let gitBranch = null;
25
+ let sessionId = null;
26
+ let version = null;
27
+ let startedAt = null;
28
+ let endedAt = null;
29
+ let summary = null;
30
+ let messageCount = 0;
31
+ let malformed = 0;
32
+ const tokens = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
33
+ const tokensByModel = {};
34
+
35
+ for await (const line of lines) {
36
+ if (!line.trim()) continue;
37
+
38
+ let record;
39
+ try {
40
+ record = JSON.parse(line);
41
+ } catch {
42
+ malformed += 1;
43
+ continue;
44
+ }
45
+
46
+ messageCount += 1;
47
+
48
+ if (!startedAt && record.timestamp) startedAt = record.timestamp;
49
+ if (cwd === null && record.cwd) cwd = record.cwd;
50
+ if (gitBranch === null && record.gitBranch) gitBranch = record.gitBranch;
51
+ if (sessionId === null && record.sessionId) sessionId = record.sessionId;
52
+ if (version === null && record.version) version = record.version;
53
+ if (summary === null && record.type === 'summary' && record.summary) {
54
+ summary = record.summary;
55
+ }
56
+
57
+ if (record.timestamp) endedAt = record.timestamp;
58
+
59
+ const usage = record.message && record.message.usage;
60
+ if (usage) {
61
+ const inp = usage.input_tokens ?? 0;
62
+ const out = usage.output_tokens ?? 0;
63
+ const cw = usage.cache_creation_input_tokens ?? 0;
64
+ const cr = usage.cache_read_input_tokens ?? 0;
65
+ tokens.input += inp;
66
+ tokens.output += out;
67
+ tokens.cacheCreation += cw;
68
+ tokens.cacheRead += cr;
69
+ const model = (record.message.model && String(record.message.model)) || 'unknown';
70
+ if (!tokensByModel[model]) {
71
+ tokensByModel[model] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
72
+ }
73
+ tokensByModel[model].input += inp;
74
+ tokensByModel[model].output += out;
75
+ tokensByModel[model].cacheCreation += cw;
76
+ tokensByModel[model].cacheRead += cr;
77
+ }
78
+ }
79
+
80
+ yield {
81
+ path: filePath,
82
+ sessionId: sessionId || path.basename(filePath, '.jsonl'),
83
+ projectFolder: cwd ? path.basename(cwd) : null,
84
+ cwd,
85
+ gitBranch,
86
+ version,
87
+ startedAt,
88
+ endedAt,
89
+ summary,
90
+ messageCount,
91
+ malformedCount: malformed,
92
+ tokens,
93
+ tokensByModel,
94
+ };
95
+ } finally {
96
+ await fd.close();
97
+ }
98
+ }
99
+ }
package/src/cli.js ADDED
@@ -0,0 +1,267 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import readline from 'node:readline';
5
+ import { Command } from 'commander';
6
+ import { parseQuery } from './query.js';
7
+ import { match } from './matcher.js';
8
+ import * as claudeCodeAdapter from './claude-code/scanner.js';
9
+ import { exportSession as exportClaudeCodeSession } from './claude-code/exporter.js';
10
+ import * as cursorAdapter from './cursor/scanner.js';
11
+ import { exportSession as exportCursorSession } from './cursor/exporter.js';
12
+ import { costForSession, resolveModel } from './claude-code/pricing.js';
13
+
14
+ const ADAPTERS = {
15
+ 'claude-code': {
16
+ scan: claudeCodeAdapter.scanSessions,
17
+ exportSession: exportClaudeCodeSession,
18
+ defaultRoot: () => path.join(os.homedir(), '.claude', 'projects'),
19
+ rootLabel: 'Claude Code projects root',
20
+ supportsCost: true,
21
+ },
22
+ cursor: {
23
+ scan: cursorAdapter.scanSessions,
24
+ exportSession: exportCursorSession,
25
+ defaultRoot: () => cursorAdapter.defaultCursorRoot(),
26
+ rootLabel: 'Cursor user-data root',
27
+ supportsCost: false,
28
+ },
29
+ };
30
+
31
+ function pAll(items, fn, concurrency) {
32
+ const results = [];
33
+ const executing = [];
34
+ let i = 0;
35
+
36
+ const enqueue = async () => {
37
+ if (i >= items.length) return;
38
+ const item = items[i++];
39
+ const p = Promise.resolve().then(() => fn(item));
40
+ results.push(p);
41
+ const e = p.then(() => executing.splice(executing.indexOf(e), 1));
42
+ executing.push(e);
43
+ if (executing.length >= concurrency) {
44
+ await Promise.race(executing);
45
+ }
46
+ return enqueue();
47
+ };
48
+
49
+ return enqueue().then(() => Promise.all(results));
50
+ }
51
+
52
+ async function streamJsonlToStdout(session) {
53
+ const stream = await fs.open(session.path, 'r');
54
+ try {
55
+ const reader = readline.createInterface({ input: stream.createReadStream(), crlfDelay: Infinity });
56
+ for await (const line of reader) {
57
+ process.stdout.write(`${line}\n`);
58
+ }
59
+ } finally {
60
+ await stream.close();
61
+ }
62
+ }
63
+
64
+ function emitCursorSessionJson(session) {
65
+ const { _cursor, ...publicFields } = session;
66
+ process.stdout.write(`${JSON.stringify(publicFields)}\n`);
67
+ }
68
+
69
+ function formatSummary(session) {
70
+ return `${session.sessionId.slice(0, 8)} ${session.gitBranch ?? 'nobranch'} ${session.startedAt ?? ''} ${session.summary ?? ''}`;
71
+ }
72
+
73
+ function fmtNum(n) {
74
+ return Number(n || 0).toLocaleString('en-US');
75
+ }
76
+
77
+ function fmtCost(usd) {
78
+ if (!usd) return '$0.00';
79
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
80
+ return `$${usd.toFixed(2)}`;
81
+ }
82
+
83
+ function dominantModel(tokensByModel) {
84
+ const entries = Object.entries(tokensByModel || {});
85
+ if (entries.length === 0) return '—';
86
+ let best = entries[0];
87
+ let bestSum = (best[1].input || 0) + (best[1].output || 0);
88
+ for (const entry of entries.slice(1)) {
89
+ const sum = (entry[1].input || 0) + (entry[1].output || 0);
90
+ if (sum > bestSum) {
91
+ best = entry;
92
+ bestSum = sum;
93
+ }
94
+ }
95
+ const resolved = resolveModel(best[0]);
96
+ const label = resolved ? resolved.key : best[0];
97
+ return entries.length > 1 ? `${label} (+${entries.length - 1})` : label;
98
+ }
99
+
100
+ function renderOutputOnly(matches) {
101
+ const lines = ['# Session usage', ''];
102
+ lines.push('| date | session | branch | model | input | output | cache R | cache W | cost |');
103
+ lines.push('|---|---|---|---|---:|---:|---:|---:|---:|');
104
+
105
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, cost: 0 };
106
+ const unknown = new Set();
107
+
108
+ for (const session of matches) {
109
+ const tokens = session.tokens ?? { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
110
+ const { cost, unknownModels } = costForSession(session.tokensByModel ?? {});
111
+ for (const m of unknownModels) unknown.add(m);
112
+ totals.input += tokens.input || 0;
113
+ totals.output += tokens.output || 0;
114
+ totals.cacheRead += tokens.cacheRead || 0;
115
+ totals.cacheCreation += tokens.cacheCreation || 0;
116
+ totals.cost += cost;
117
+ const date = session.startedAt ? session.startedAt.slice(0, 10) : '';
118
+ lines.push(
119
+ `| ${date} | ${session.sessionId.slice(0, 8)} | ${session.gitBranch ?? ''} | ${dominantModel(session.tokensByModel)} | ${fmtNum(tokens.input)} | ${fmtNum(tokens.output)} | ${fmtNum(tokens.cacheRead)} | ${fmtNum(tokens.cacheCreation)} | ${fmtCost(cost)} |`,
120
+ );
121
+ }
122
+
123
+ lines.push('', '## Totals', '');
124
+ lines.push('| metric | value |');
125
+ lines.push('|---|---:|');
126
+ lines.push(`| sessions | ${fmtNum(matches.length)} |`);
127
+ lines.push(`| input tokens | ${fmtNum(totals.input)} |`);
128
+ lines.push(`| output tokens | ${fmtNum(totals.output)} |`);
129
+ lines.push(`| cache read tokens | ${fmtNum(totals.cacheRead)} |`);
130
+ lines.push(`| cache write tokens | ${fmtNum(totals.cacheCreation)} |`);
131
+ lines.push(`| cost (USD) | ${fmtCost(totals.cost)} |`);
132
+
133
+ return { text: lines.join('\n') + '\n', unknown: [...unknown] };
134
+ }
135
+
136
+ async function main() {
137
+ const program = new Command();
138
+ program
139
+ .name('convoptics')
140
+ .description('Extract Claude Code conversations by query and export Markdown transcripts.')
141
+ .argument('[filters...]', 'filter tokens such as tool:claude-code branch:main date>=2026-05-01')
142
+ .option('--root <path>', 'override ~/.claude/projects')
143
+ .option('--out <dir>', 'override output directory')
144
+ .option('--dry-run', 'list matches, do not write files')
145
+ .option('--json', 'emit JSONL of matches to stdout instead of markdown files')
146
+ .option('--output-only', 'print a token + cost summary table to stdout; do not write markdown files')
147
+ .option('--limit <n>', 'stop after N matches', Number)
148
+ .option('--full', 'do not truncate tool results')
149
+ .option('-v, --verbose', 'show scan progress to stderr')
150
+ .version('0.1.0');
151
+
152
+ program.parse(process.argv);
153
+ const filters = program.args;
154
+ const opts = program.opts();
155
+
156
+ let filterSpec;
157
+ try {
158
+ filterSpec = parseQuery(filters);
159
+ } catch (error) {
160
+ console.error(`Error: ${error.message}`);
161
+ process.exit(1);
162
+ }
163
+
164
+ const adapter = ADAPTERS[filterSpec.tool];
165
+ if (!adapter) {
166
+ console.error(`Error: no adapter registered for tool:${filterSpec.tool}`);
167
+ process.exit(1);
168
+ }
169
+
170
+ if (opts.outputOnly && !adapter.supportsCost) {
171
+ console.error(`Error: --output-only is not supported for tool:${filterSpec.tool} (token usage is not exported)`);
172
+ process.exit(1);
173
+ }
174
+
175
+ const root = opts.root ? path.resolve(opts.root) : adapter.defaultRoot();
176
+ try {
177
+ const rootStat = await fs.stat(root);
178
+ if (!rootStat.isDirectory()) {
179
+ throw new Error('not a directory');
180
+ }
181
+ } catch {
182
+ console.error(`Error: ${adapter.rootLabel} does not exist: ${root}`);
183
+ process.exit(2);
184
+ }
185
+
186
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '');
187
+ const outDir = opts.out
188
+ ? path.resolve(opts.out)
189
+ : path.join(os.homedir(), 'Downloads', `convos-${timestamp}`);
190
+ const matches = [];
191
+ let scanned = 0;
192
+
193
+ for await (const session of adapter.scan(root)) {
194
+ scanned += 1;
195
+ if (opts.verbose) {
196
+ console.error(`scanned ${session.path}`);
197
+ if (session.malformedCount) {
198
+ console.error(` malformed lines: ${session.malformedCount}`);
199
+ }
200
+ }
201
+ if (match(filterSpec, session)) {
202
+ matches.push(session);
203
+ if (opts.verbose) {
204
+ console.error(` matched ${session.sessionId}`);
205
+ }
206
+ if (opts.limit && matches.length >= opts.limit) break;
207
+ }
208
+ }
209
+
210
+ if (opts.dryRun) {
211
+ for (const session of matches) {
212
+ process.stdout.write(`${session.sessionId} ${session.gitBranch ?? 'nobranch'} ${session.startedAt ?? ''} ${session.summary ?? ''}\n`);
213
+ }
214
+ console.error(`Found ${matches.length} matching sessions from ${scanned} scanned.`);
215
+ process.exit(0);
216
+ }
217
+
218
+ if (opts.json) {
219
+ for (const session of matches) {
220
+ if (filterSpec.tool === 'cursor') {
221
+ emitCursorSessionJson(session);
222
+ } else {
223
+ await streamJsonlToStdout(session);
224
+ }
225
+ }
226
+ console.error(`Emitted JSONL for ${matches.length} matching sessions from ${scanned} scanned.`);
227
+ process.exit(0);
228
+ }
229
+
230
+ if (opts.outputOnly) {
231
+ const { text, unknown } = renderOutputOnly(matches);
232
+ process.stdout.write(text);
233
+ if (unknown.length) {
234
+ console.error(`Warning: cost excludes ${unknown.length} unpriced model(s): ${unknown.join(', ')}`);
235
+ }
236
+ console.error(`Summarized ${matches.length} matching sessions from ${scanned} scanned.`);
237
+ process.exit(0);
238
+ }
239
+
240
+ await fs.mkdir(outDir, { recursive: true });
241
+
242
+ let exportResults;
243
+ try {
244
+ const exportOpts = { full: opts.full, noDiffs: filterSpec.diffs === 'no-diffs' };
245
+ exportResults = await pAll(matches, (session) => adapter.exportSession(session, outDir, exportOpts), 8);
246
+ } catch (error) {
247
+ console.error(`Error exporting sessions: ${error.message}`);
248
+ process.exit(3);
249
+ }
250
+
251
+ const indexLines = [
252
+ '# Export index',
253
+ '',
254
+ '| filename | date | branch | summary |',
255
+ '|---|---|---|---|',
256
+ ...exportResults.map((result, index) => {
257
+ const session = matches[index];
258
+ return `| ${result.filename} | ${session.startedAt ? session.startedAt.slice(0, 10) : ''} | ${session.gitBranch ?? ''} | ${session.summary ?? ''} |`;
259
+ }),
260
+ ];
261
+ await fs.writeFile(path.join(outDir, '_index.md'), indexLines.join('\n') + '\n', 'utf8');
262
+
263
+ console.log(`Exported ${exportResults.length} of ${scanned} scanned sessions to ${outDir}`);
264
+ process.exit(0);
265
+ }
266
+
267
+ main();
@@ -0,0 +1,264 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+
5
+ const MAX_TOOL_RESULT = 4096;
6
+ const EXPECTED_SCHEMA_VERSION = 3;
7
+ const DIFF_FIELDS = [
8
+ 'editTrailContexts',
9
+ 'fileDiffTrajectories',
10
+ 'gitDiffs',
11
+ 'humanChanges',
12
+ 'diffsSinceLastApply',
13
+ 'assistantSuggestedDiffs',
14
+ ];
15
+
16
+ function slugifyBranch(branch) {
17
+ if (!branch) return 'nobranch';
18
+ return branch
19
+ .replace(/\//g, '-')
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]/g, '')
22
+ .replace(/-+/g, '-');
23
+ }
24
+
25
+ function yamlQuote(value) {
26
+ if (value === null || value === undefined) return '""';
27
+ const str = String(value);
28
+ return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
29
+ }
30
+
31
+ function truncateIfNeeded(text, full) {
32
+ if (full || text.length <= MAX_TOOL_RESULT) return text;
33
+ const truncated = text.slice(0, MAX_TOOL_RESULT);
34
+ const remaining = text.length - MAX_TOOL_RESULT;
35
+ return `${truncated}… [${remaining} more chars truncated]`;
36
+ }
37
+
38
+ function asJsonBlock(value) {
39
+ try {
40
+ return JSON.stringify(value, null, 2);
41
+ } catch {
42
+ return String(value);
43
+ }
44
+ }
45
+
46
+ function parseJsonValue(value) {
47
+ if (value === null || value === undefined) return null;
48
+ const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
49
+ try {
50
+ return JSON.parse(text);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function fenced(lang, body) {
57
+ return ['', '```' + lang, body, '```', ''].join('\n');
58
+ }
59
+
60
+ function roleForBubble(bubble) {
61
+ if (bubble.type === 1) return 'user';
62
+ if (bubble.type === 2) return 'assistant';
63
+ return null;
64
+ }
65
+
66
+ function collectThinking(bubble) {
67
+ const parts = [];
68
+ if (typeof bubble.thinking === 'string' && bubble.thinking.trim()) {
69
+ parts.push(bubble.thinking);
70
+ } else if (bubble.thinking && typeof bubble.thinking === 'object') {
71
+ parts.push(asJsonBlock(bubble.thinking));
72
+ }
73
+ if (Array.isArray(bubble.allThinkingBlocks)) {
74
+ for (const block of bubble.allThinkingBlocks) {
75
+ if (typeof block === 'string') parts.push(block);
76
+ else if (block && typeof block.text === 'string') parts.push(block.text);
77
+ else if (block) parts.push(asJsonBlock(block));
78
+ }
79
+ }
80
+ return parts.join('\n\n').trim();
81
+ }
82
+
83
+ function hasContent(value) {
84
+ if (value === null || value === undefined) return false;
85
+ if (Array.isArray(value)) return value.some(hasContent);
86
+ if (typeof value === 'object') return Object.values(value).some(hasContent);
87
+ if (typeof value === 'string') return value.length > 0;
88
+ return true;
89
+ }
90
+
91
+ function hasAnyDiffField(bubble) {
92
+ return DIFF_FIELDS.some((field) => hasContent(bubble[field]));
93
+ }
94
+
95
+ function renderBubble(bubble, opts) {
96
+ const out = [];
97
+ const text = typeof bubble.text === 'string' ? bubble.text.trim() : '';
98
+ if (text) out.push(text);
99
+
100
+ const thinking = collectThinking(bubble);
101
+ if (thinking) out.push(fenced('thinking', thinking));
102
+
103
+ const hasCapabilityPayload =
104
+ hasContent(bubble.capabilities) ||
105
+ hasContent(bubble.capabilityContexts) ||
106
+ hasContent(bubble.capabilityStatuses);
107
+ if (hasCapabilityPayload) {
108
+ const capabilityName =
109
+ (Array.isArray(bubble.capabilities) && bubble.capabilities[0]?.name) ||
110
+ bubble.capabilityType ||
111
+ 'capability';
112
+ const payload = {};
113
+ if (bubble.capabilityType !== undefined) payload.capabilityType = bubble.capabilityType;
114
+ if (hasContent(bubble.capabilities)) payload.capabilities = bubble.capabilities;
115
+ if (hasContent(bubble.capabilityContexts)) payload.capabilityContexts = bubble.capabilityContexts;
116
+ if (hasContent(bubble.capabilityStatuses)) payload.capabilityStatuses = bubble.capabilityStatuses;
117
+ out.push(fenced(`tool:${capabilityName}`, asJsonBlock(payload)));
118
+ }
119
+
120
+ if (Array.isArray(bubble.toolResults) && bubble.toolResults.length > 0) {
121
+ const body = truncateIfNeeded(asJsonBlock(bubble.toolResults), opts.full);
122
+ out.push(fenced('result', body));
123
+ }
124
+
125
+ if (hasAnyDiffField(bubble)) {
126
+ if (opts.noDiffs) {
127
+ out.push(fenced('diff', '[diffs redacted]'));
128
+ } else {
129
+ const payload = {};
130
+ for (const field of DIFF_FIELDS) {
131
+ if (bubble[field] !== undefined && bubble[field] !== null) {
132
+ payload[field] = bubble[field];
133
+ }
134
+ }
135
+ const body = truncateIfNeeded(asJsonBlock(payload), opts.full);
136
+ out.push(fenced('diff', body));
137
+ }
138
+ }
139
+
140
+ return out.join('\n').trim();
141
+ }
142
+
143
+ async function resolveFilename(session, outDir) {
144
+ const date = session.startedAt ? session.startedAt.slice(0, 10) : 'unknown-date';
145
+ const prefix = session.sessionId.slice(0, 8);
146
+ const branchSlug = slugifyBranch(session.gitBranch);
147
+ const base = `${date}_${prefix}_${branchSlug}`;
148
+ let candidate = `${base}.md`;
149
+ let suffix = 1;
150
+ // eslint-disable-next-line no-constant-condition
151
+ while (true) {
152
+ const fullPath = path.join(outDir, candidate);
153
+ try {
154
+ await fs.access(fullPath);
155
+ suffix += 1;
156
+ candidate = `${base}_${suffix}.md`;
157
+ } catch {
158
+ return candidate;
159
+ }
160
+ }
161
+ }
162
+
163
+ function loadBubbles(dbPath, composerId) {
164
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
165
+ try {
166
+ const rows = db
167
+ .prepare('SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ORDER BY key')
168
+ .all(`bubbleId:${composerId}:%`);
169
+ const byId = new Map();
170
+ for (const row of rows) {
171
+ const bubble = parseJsonValue(row.value);
172
+ if (!bubble) continue;
173
+ const id = bubble.bubbleId || row.key.slice(`bubbleId:${composerId}:`.length);
174
+ byId.set(id, bubble);
175
+ }
176
+ return byId;
177
+ } finally {
178
+ db.close();
179
+ }
180
+ }
181
+
182
+ export async function exportSession(session, outDir, opts = {}) {
183
+ const cursor = session._cursor;
184
+ if (!cursor || !cursor.dbPath) {
185
+ throw new Error('cursor exporter requires session._cursor.dbPath');
186
+ }
187
+
188
+ const filename = await resolveFilename(session, outDir);
189
+ const tempPath = path.join(outDir, `${filename}.tmp`);
190
+ const finalPath = path.join(outDir, filename);
191
+ const stream = await fs.open(tempPath, 'w');
192
+ let succeeded = false;
193
+
194
+ try {
195
+ const frontmatter = [
196
+ '---',
197
+ `sessionId: ${yamlQuote(session.sessionId)}`,
198
+ `cwd: ${yamlQuote(session.cwd ?? '')}`,
199
+ `gitBranch: ${yamlQuote(session.gitBranch ?? '')}`,
200
+ `version: ${yamlQuote(session.version ?? '')}`,
201
+ `startedAt: ${yamlQuote(session.startedAt ?? '')}`,
202
+ `endedAt: ${yamlQuote(session.endedAt ?? '')}`,
203
+ `summary: ${yamlQuote(session.summary ?? '')}`,
204
+ 'tokensInput: 0',
205
+ 'tokensOutput: 0',
206
+ 'tokensCacheCreation: 0',
207
+ 'tokensCacheRead: 0',
208
+ '---',
209
+ '',
210
+ ].join('\n');
211
+ await stream.write(frontmatter);
212
+
213
+ const bubbles = loadBubbles(cursor.dbPath, cursor.composerId);
214
+ const orderedIds = [...(cursor.conversationOrder ?? [])];
215
+ const seen = new Set();
216
+ const orderedBubbles = [];
217
+ for (const id of orderedIds) {
218
+ if (seen.has(id)) continue;
219
+ const bubble = bubbles.get(id);
220
+ if (!bubble) continue;
221
+ seen.add(id);
222
+ orderedBubbles.push(bubble);
223
+ }
224
+ for (const [id, bubble] of bubbles) {
225
+ if (seen.has(id)) continue;
226
+ orderedBubbles.push(bubble);
227
+ }
228
+
229
+ const schemaWarnings = new Set();
230
+ let lastRole = null;
231
+ for (const bubble of orderedBubbles) {
232
+ if (typeof bubble._v === 'number' && bubble._v !== EXPECTED_SCHEMA_VERSION) {
233
+ schemaWarnings.add(bubble._v);
234
+ }
235
+ const role = roleForBubble(bubble);
236
+ if (!role) continue;
237
+ const body = renderBubble(bubble, opts);
238
+ if (!body) continue;
239
+
240
+ const heading = role === 'user' ? '## User' : '## Assistant';
241
+ if (heading !== lastRole) {
242
+ await stream.write(`${heading}\n\n`);
243
+ lastRole = heading;
244
+ }
245
+ await stream.write(`${body}\n\n`);
246
+ }
247
+
248
+ if (schemaWarnings.size > 0) {
249
+ const versions = [...schemaWarnings].join(', ');
250
+ process.stderr.write(
251
+ `Warning: cursor session ${session.sessionId} contains bubbles with unexpected _v values: ${versions}\n`,
252
+ );
253
+ }
254
+
255
+ succeeded = true;
256
+ } finally {
257
+ await stream.close().catch(() => {});
258
+ if (!succeeded) await fs.unlink(tempPath).catch(() => {});
259
+ }
260
+
261
+ await fs.rename(tempPath, finalPath);
262
+ const { size: bytes } = await fs.stat(finalPath);
263
+ return { filename, bytes };
264
+ }