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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,366 @@
1
+ # convoptics
2
+
3
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
4
+
5
+ Convoptics scans your local Claude Code and Cursor history, filters sessions
6
+ with a small `key:value` query language, and exports the matches as clean
7
+ Markdown transcripts.
8
+
9
+ ## Quick start
10
+
11
+ ```bash
12
+ # Run directly from the repo
13
+ node bin/convoptics.js tool:claude-code branch:feature/payments
14
+ node bin/convoptics.js tool:cursor branch:feature/payments
15
+
16
+ # Or link it once and use the global command
17
+ npm link
18
+ convoptics tool:claude-code branch:feature/payments
19
+ convoptics tool:cursor cwd:projectA
20
+ ```
21
+
22
+ Output (defaults to your Downloads folder):
23
+
24
+ ```text
25
+ ~/Downloads/convos-2026-05-14T143000/
26
+ ├── 2026-05-12_a3f9c1d0_feature-payments.md
27
+ ├── 2026-05-13_b7c2e840_feature-payments.md
28
+ └── _index.md
29
+ ```
30
+
31
+ > On macOS, the first time your terminal app writes to `~/Downloads` the OS
32
+ > shows a one-time prompt ("Terminal would like to access files in your
33
+ > Downloads folder"). Click Allow once; it won't ask again. Linux and Windows
34
+ > don't prompt. Reading from `~/.claude/projects/` never triggers a prompt.
35
+
36
+ ## Installation
37
+
38
+ Requires Node.js 18 or newer.
39
+
40
+ ```bash
41
+ git clone <repo> && cd convoptics
42
+ npm install
43
+ npm link # optional: exposes `convoptics` globally
44
+ ```
45
+
46
+ Runtime dependencies:
47
+ - [`commander`](https://www.npmjs.com/package/commander) for argv parsing.
48
+ - [`better-sqlite3`](https://www.npmjs.com/package/better-sqlite3) to read
49
+ Cursor's SQLite databases. This is a native module; `npm install` will
50
+ download a prebuilt binary for common platforms or compile from source if
51
+ needed (which requires Python + a C++ toolchain).
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ convoptics [filters...] [options]
57
+ ```
58
+
59
+ A `tool:` filter is required; everything else is optional.
60
+
61
+ ### Filters
62
+
63
+ | Key | Operator(s) | Meaning |
64
+ |-----------|------------------------|-------------------------------------------------------------------------|
65
+ | `tool` | `:` `=` | Required. One of `claude-code` or `cursor`. |
66
+ | `branch` | `:` `=` | Exact branch name or glob (`*` matches one path segment, `**` matches across `/`). Case-insensitive. For Cursor this is the branch when the session was created; it does not track later branch switches. |
67
+ | `cwd` | `:` `=` | Case-insensitive substring of the session's working directory. |
68
+ | `project` | `:` `=` | Exact match of the project folder basename (the last path segment of `cwd`, e.g. `projectA` for `/Users/bren/projectA`). Use `cwd:` for a case-insensitive substring match on the full path. |
69
+ | `session` | `:` `=` | Session-id prefix match. |
70
+ | `version` | `:` `=` | Exact Claude Code version string. Not supported for `tool:cursor` (no per-session client version is recorded). |
71
+ | `date` | `:` `=` `>` `>=` `<` `<=` | ISO date (`YYYY-MM-DD`). Comparisons use the session's `startedAt`. |
72
+ | `diffs` | `:` `=` | `diffs` (default) keeps full file-edit content; `no-diffs` redacts file edits to a placeholder so transcripts can be shared without leaking source. For Claude Code this targets `Edit`/`MultiEdit`/`Write`/`NotebookEdit` tool inputs; for Cursor it targets the per-bubble `editTrailContexts`, `fileDiffTrajectories`, `gitDiffs`, `humanChanges`, `diffsSinceLastApply`, and `assistantSuggestedDiffs` fields. |
73
+
74
+ Repeating a key creates an OR: `branch:main branch:feature/*` matches sessions on
75
+ either branch. Different keys combine with AND.
76
+
77
+ ### Options
78
+
79
+ | Flag | Description |
80
+ |---------------------|----------------------------------------------------------|
81
+ | `--root <path>` | Override the tool's data root. Default for `tool:claude-code` is `~/.claude/projects`. Default for `tool:cursor` is `~/Library/Application Support/Cursor/User` on macOS, `~/.config/Cursor/User` on Linux, `%APPDATA%/Cursor/User` on Windows. |
82
+ | `--out <dir>` | Override the output directory (default: `~/Downloads/convos-<timestamp>`). |
83
+ | `--dry-run` | List matches without writing files. |
84
+ | `--json` | Emit raw session data to stdout instead of Markdown. For Claude Code, the original JSONL; for Cursor, one JSON object per line with session metadata. |
85
+ | `--limit <n>` | Stop after N matches. |
86
+ | `--full` | Do not truncate large tool results in the Markdown output. |
87
+ | `--output-only` | Print a Markdown token + cost summary table to stdout. Skips writing transcripts. **Claude Code only** — Cursor sessions do not record reliable token counts, so this flag is rejected with `tool:cursor`. |
88
+ | `-v`, `--verbose` | Show scan progress on stderr. |
89
+
90
+ ### Examples
91
+
92
+ ```bash
93
+ # Everything on `working` in the last 2 days (today = 2026-05-14)
94
+ convoptics tool:claude-code branch:working date>=2026-05-12
95
+
96
+ # All feature branches, a single day
97
+ convoptics tool:claude-code 'branch:feature/*' date:2026-05-12
98
+
99
+ # A specific session prefix, full tool output, custom out dir
100
+ convoptics tool:claude-code session:a1b2 --full --out ./payments-debug
101
+
102
+ # Preview without writing anything
103
+ convoptics tool:claude-code branch:main --dry-run -v
104
+
105
+ # Pipe raw JSONL to another tool
106
+ convoptics tool:claude-code branch:working --json | jq '.message.role'
107
+
108
+ # Share a session externally without exposing source code
109
+ convoptics tool:claude-code session:a1b2 diffs:no-diffs
110
+
111
+ # Just see how many tokens / how much money a query covers (no files written)
112
+ convoptics tool:claude-code branch:working date>=2026-05-12 --output-only
113
+
114
+ # All Cursor sessions for a project, with secrets in edits redacted
115
+ convoptics tool:cursor cwd:projectA diffs:no-diffs
116
+ ```
117
+
118
+ > **Shell quoting.** Quote glob patterns (`'branch:feature/*'`) so the shell
119
+ > does not expand `*`. Quote any token using `>`, `>=`, `<`, or `<=` (e.g.
120
+ > `'date>=2026-05-12'`) — otherwise zsh/bash treat it as an output redirection.
121
+ > Tokens using `:` or `=` are safe unquoted.
122
+
123
+ ## How it works
124
+
125
+ Each tool has its own scanner + exporter under `src/<tool>/`. The CLI parses
126
+ the query, picks the adapter based on `tool:`, and pipes sessions through a
127
+ shared matcher into the adapter's exporter.
128
+
129
+ ```
130
+ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
131
+ │ scanner │ -> │ query │ -> │ matcher │ -> │ exporter │
132
+ │ (per │ │ │ │ │ │ (per │
133
+ │ tool) │ │ parse │ │ filter │ │ tool) │
134
+ └──────────┘ │ argv │ │ spec │ └──────────┘
135
+ │ into │ │ applied │ per-session
136
+ │ a spec │ │ to each │ Markdown +
137
+ └──────────┘ │ session │ index
138
+ └──────────┘
139
+ ```
140
+
141
+ **Claude Code** writes one `.jsonl` file per session into
142
+ `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`. The Claude Code scanner
143
+ streams those files line-by-line, extracting metadata only; the exporter
144
+ re-streams them to render Markdown.
145
+
146
+ **Cursor** stores sessions across SQLite databases: a global
147
+ `globalStorage/state.vscdb` holds session headers (`composerData:<uuid>`) and
148
+ individual message bubbles (`bubbleId:<uuid>:<bubbleId>`), and one
149
+ `workspaceStorage/<hash>/state.vscdb` per workspace holds the registry that
150
+ joins each composer to its `cwd` (via the sibling `workspace.json`) and
151
+ `createdOnBranch`. The Cursor scanner reads the workspace registries first to
152
+ build that lookup, then walks the global DB; the exporter re-opens the global
153
+ DB to load bubbles in conversation order. See
154
+ [docs/cursor-schema.md](docs/cursor-schema.md) for the on-disk layout this
155
+ adapter is pinned against.
156
+
157
+ ### Modules
158
+
159
+ - [src/cli.js](src/cli.js) — argv parsing, adapter dispatch, output. Uses a
160
+ small in-process concurrency limiter (`pAll`, default 8) to export sessions
161
+ in parallel.
162
+ - [src/query.js](src/query.js) — `parseQuery(argv)` turns tokens like
163
+ `branch:main` and `date>=2026-05-01` into a structured filter spec. Repeated
164
+ keys accumulate into arrays (OR). Validates keys, operators, the required
165
+ `tool` filter, and rejects per-tool-incompatible keys.
166
+ - [src/matcher.js](src/matcher.js) — `match(filter, session)` applies the spec.
167
+ Tool-agnostic: operates on the common session shape that every adapter
168
+ yields. Branch globs are compiled to anchored, case-insensitive regexes:
169
+ `*` becomes `[^/]*`, `**` becomes `.*`, and regex metacharacters in the rest
170
+ of the pattern are escaped.
171
+ - [src/claude-code/scanner.js](src/claude-code/scanner.js) — async generator.
172
+ Streams every `.jsonl` under `~/.claude/projects` with `readline`, extracting
173
+ only metadata (`sessionId`, `cwd`, `gitBranch`, `version`, `startedAt`,
174
+ `endedAt`, `summary`, `messageCount`, `malformedCount`, plus per-model token
175
+ usage). Full message content is never buffered.
176
+ - [src/claude-code/exporter.js](src/claude-code/exporter.js) — for each match,
177
+ re-streams the JSONL and writes a Markdown transcript. Writes go to a `.tmp`
178
+ file first and are renamed atomically on success, so an interrupted run
179
+ never leaves partial exports.
180
+ - [src/claude-code/pricing.js](src/claude-code/pricing.js) — USD-per-million
181
+ pricing table for `--output-only` (Claude Code only).
182
+ - [src/cursor/scanner.js](src/cursor/scanner.js) — opens each
183
+ `workspaceStorage/<hash>/state.vscdb` read-only with `better-sqlite3`, builds
184
+ a `composerId → {cwd, branch, name}` registry, then streams
185
+ `composerData:<uuid>` rows from the global DB. Token counts are emitted as
186
+ zero — Cursor's per-bubble `tokenCount` is unreliable (~96% report zero).
187
+ - [src/cursor/exporter.js](src/cursor/exporter.js) — re-opens the global DB,
188
+ loads `bubbleId:<sessionId>:*` rows for the session, orders them by the
189
+ header's `conversation[]` cache (with leftover bubbles appended), and
190
+ renders each `type:1`/`type:2` bubble as Markdown. Same atomic
191
+ `.tmp` → rename pattern as the Claude Code exporter. Warns to stderr if any
192
+ bubble has an unexpected schema version (`_v` ≠ 3).
193
+
194
+ ### Filename scheme
195
+
196
+ Each export is named `<YYYY-MM-DD>_<sessionId[:8]>_<branch-slug>.md`.
197
+ Branch slugs lowercase the branch and replace `/` with `-`. If the same name
198
+ already exists, a numeric suffix (`_2`, `_3`, …) is appended.
199
+
200
+ ### Output format
201
+
202
+ Each Markdown file starts with quoted YAML frontmatter, followed by the
203
+ conversation grouped by role:
204
+
205
+ ```markdown
206
+ ---
207
+ sessionId: "a1b2c3d4"
208
+ cwd: "/Users/bren/projectA"
209
+ gitBranch: "feature/payments"
210
+ version: "0.30.0"
211
+ startedAt: "2026-05-12T08:00:00Z"
212
+ endedAt: "2026-05-12T08:00:02Z"
213
+ summary: "payments-refactor"
214
+ tokensInput: 12345
215
+ tokensOutput: 6789
216
+ tokensCacheCreation: 1024
217
+ tokensCacheRead: 4096
218
+ ---
219
+
220
+ ## User
221
+
222
+ Please refactor the payment handler.
223
+
224
+ ## Assistant
225
+
226
+ Sure, I will update the handler.
227
+ ```
228
+
229
+ Token counts are summed across every assistant turn that reports a `usage`
230
+ block in the source JSONL.
231
+
232
+ ### Token usage and cost (`--output-only`)
233
+
234
+ > **Claude Code only.** Cursor stores per-bubble `tokenCount` values that are
235
+ > `{0,0}` for ~96% of assistant bubbles, so cost reporting would be misleading.
236
+ > The flag is rejected with `tool:cursor` until this changes upstream.
237
+
238
+ `--output-only` skips writing Markdown and instead prints a Markdown report to
239
+ stdout: one row per matching session, followed by a totals table.
240
+
241
+ ```text
242
+ # Session usage
243
+
244
+ | date | session | branch | model | input | output | cache R | cache W | cost |
245
+ |---|---|---|---|---:|---:|---:|---:|---:|
246
+ | 2026-05-12 | a1b2c3d4 | feature/payments | claude-opus-4-7 | 12,345 | 6,789 | 4,096 | 1,024 | $0.45 |
247
+ | 2026-05-13 | b7c2e840 | feature/payments | claude-sonnet-4-6 | 20,000 | 10,000 | 0 | 0 | $0.21 |
248
+
249
+ ## Totals
250
+
251
+ | metric | value |
252
+ |---|---:|
253
+ | sessions | 2 |
254
+ | input tokens | 32,345 |
255
+ | ... | ... |
256
+ | cost (USD) | $0.66 |
257
+ ```
258
+
259
+ The `model` column shows each session's *dominant* model — the one with the
260
+ most input + output tokens — and appends `(+N)` if other models also appeared
261
+ in that session. Cost is computed per-model and summed.
262
+
263
+ Redirect to a file for sharing: `convoptics ... --output-only > usage.md`.
264
+
265
+ #### Pricing table
266
+
267
+ Rates live in [src/pricing.js](src/pricing.js) as USD per million tokens.
268
+ Model resolution does a longest-prefix match, so dated variants like
269
+ `claude-opus-4-7-20251022` map to the `claude-opus-4-7` row. If a session's
270
+ model isn't in the table, its tokens are still reported but its cost is
271
+ excluded and a warning is printed to stderr. Add new models by appending to
272
+ the `PRICING` object.
273
+
274
+ ### Redacting diffs
275
+
276
+ Use `diffs:no-diffs` to share a transcript without leaking the source you
277
+ edited. File-edit tool calls (`Edit`, `MultiEdit`, `Write`, `NotebookEdit`)
278
+ have their inputs replaced by a one-line summary:
279
+
280
+ ````markdown
281
+ ```tool:Edit
282
+ [diff redacted: 4 lines added, 3 lines removed]
283
+ ```
284
+ ````
285
+
286
+ Other tool calls and their results are unchanged. Token totals in the
287
+ frontmatter are still emitted.
288
+
289
+ Tool use and tool results render as fenced blocks:
290
+
291
+ ````markdown
292
+ ```tool:git
293
+ {
294
+ "command": "status"
295
+ }
296
+ ```
297
+
298
+ ```result
299
+ All tests passed.
300
+ ```
301
+ ````
302
+
303
+ Sidechain messages are prefixed with a `### sidechain` heading. Consecutive
304
+ messages from the same role share a single role heading.
305
+
306
+ ### Truncation
307
+
308
+ Tool results longer than 4 KB are truncated with a trailing
309
+ `… [N more chars truncated]` marker. Pass `--full` to disable this.
310
+
311
+ ### Index file
312
+
313
+ Every run also writes `_index.md` with a table of `filename | date | branch |
314
+ summary` rows in match order.
315
+
316
+ ### Errors and resilience
317
+
318
+ - Missing projects root → exit code `2` with a clear message.
319
+ - Export failure → exit code `3`; the partial `.tmp` file is removed.
320
+ - Invalid query tokens → exit code `1` with the offending token in the message.
321
+ - Malformed JSONL lines are counted (`malformedCount`) and skipped; `--verbose`
322
+ prints the per-file count.
323
+
324
+ ## Project layout
325
+
326
+ ```
327
+ bin/convoptics.js # entry point shim
328
+ src/cli.js # argv → adapter dispatch
329
+ src/query.js # parseQuery
330
+ src/matcher.js # tool-agnostic match + glob compilation
331
+ src/claude-code/scanner.js # walkJsonl + scanSessions (async generators)
332
+ src/claude-code/exporter.js # exportSession + Markdown rendering
333
+ src/claude-code/pricing.js # USD-per-million pricing for --output-only
334
+ src/cursor/scanner.js # SQLite-backed scanSessions + defaultCursorRoot
335
+ src/cursor/exporter.js # exportSession (bubble rendering)
336
+ docs/cursor-schema.md # Cursor on-disk schema reference
337
+ test/ # node:test suites + fixtures
338
+ .github/workflows/ci.yml # Node 18 + 20 matrix
339
+ ```
340
+
341
+ ## Running the tests
342
+
343
+ ```bash
344
+ npm test
345
+ ```
346
+
347
+ The suite uses Node's built-in `node:test` runner against fixtures under
348
+ [test/fixtures/projects/](test/fixtures/projects/).
349
+
350
+ ## Adding support for other tools
351
+
352
+ The `tool:` key dispatches to an adapter under `src/<tool>/`. Each adapter
353
+ ships its own `scanner.js` (an async generator yielding the common session
354
+ shape — see the Modules section) and `exporter.js` (writing Markdown for one
355
+ session into the output directory). Register the adapter in the `ADAPTERS`
356
+ map in [src/cli.js](src/cli.js), add the tool name to `VALID_TOOLS` in
357
+ [src/query.js](src/query.js), and declare any per-tool incompatible filter
358
+ keys in `TOOL_UNSUPPORTED_KEYS`. The matcher and query layers are
359
+ tool-agnostic and don't need changes.
360
+
361
+ Two adapters ship today: `claude-code` (JSONL files under `~/.claude/projects`)
362
+ and `cursor` (SQLite databases under Cursor's user-data directory).
363
+
364
+ ## License
365
+
366
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "convoptics",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "convoptics": "./bin/convoptics.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test test/"
13
+ },
14
+ "dependencies": {
15
+ "better-sqlite3": "^12.10.0",
16
+ "commander": "^12.0.0"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "src/",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/brenoneill/convoptics.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/brenoneill/convoptics/issues"
30
+ },
31
+ "homepage": "https://github.com/brenoneill/convoptics#readme",
32
+ "keywords": [],
33
+ "author": "Brendan O'Neill <brendanoneill94@gmail.com>",
34
+ "license": "MIT",
35
+ "description": "A CLI for extracting Claude Code conversations by query."
36
+ }
@@ -0,0 +1,201 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+
5
+ const MAX_TOOL_RESULT = 4096;
6
+
7
+ function slugifyBranch(branch) {
8
+ if (!branch) return 'nobranch';
9
+ return branch
10
+ .replace(/\//g, '-')
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9-]/g, '')
13
+ .replace(/-+/g, '-');
14
+ }
15
+
16
+ function asString(value) {
17
+ if (value === null || value === undefined) return '';
18
+ if (typeof value === 'string') return value;
19
+ try {
20
+ return JSON.stringify(value, null, 2);
21
+ } catch {
22
+ return String(value);
23
+ }
24
+ }
25
+
26
+ function truncateIfNeeded(text, full) {
27
+ if (full || text.length <= MAX_TOOL_RESULT) return text;
28
+ const truncated = text.slice(0, MAX_TOOL_RESULT);
29
+ const remaining = text.length - MAX_TOOL_RESULT;
30
+ return `${truncated}… [${remaining} more chars truncated]`;
31
+ }
32
+
33
+ function countLines(value) {
34
+ if (value === null || value === undefined || value === '') return 0;
35
+ return String(value).split('\n').length;
36
+ }
37
+
38
+ function diffStatsForToolUse(block) {
39
+ const name = block.name || block.toolName || block.tool?.name || '';
40
+ const input = block.input ?? block.args ?? {};
41
+ if (!input || typeof input !== 'object') return null;
42
+ if (name === 'Edit') {
43
+ return { added: countLines(input.new_string), removed: countLines(input.old_string) };
44
+ }
45
+ if (name === 'MultiEdit' && Array.isArray(input.edits)) {
46
+ let added = 0;
47
+ let removed = 0;
48
+ for (const edit of input.edits) {
49
+ added += countLines(edit?.new_string);
50
+ removed += countLines(edit?.old_string);
51
+ }
52
+ return { added, removed };
53
+ }
54
+ if (name === 'Write') {
55
+ return { added: countLines(input.content), removed: 0 };
56
+ }
57
+ if (name === 'NotebookEdit') {
58
+ return { added: countLines(input.new_source), removed: countLines(input.old_source) };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function renderBlock(block, opts) {
64
+ if (typeof block === 'string') {
65
+ return block;
66
+ }
67
+
68
+ if (block.type === 'text') {
69
+ return block.text || '';
70
+ }
71
+
72
+ if (block.type === 'tool_use') {
73
+ const name = block.name || block.toolName || block.tool?.name || 'unknown';
74
+ if (opts.noDiffs) {
75
+ const stats = diffStatsForToolUse(block);
76
+ if (stats) {
77
+ const body = `[diff redacted: ${stats.added} lines added, ${stats.removed} lines removed]`;
78
+ return ['', '```tool:' + name, body, '```', ''].join('\n');
79
+ }
80
+ }
81
+ const input = asString(block.input ?? block.args ?? block.payload ?? block.content ?? '');
82
+ return ['','```tool:' + name, input, '```', ''].join('\n');
83
+ }
84
+
85
+ if (block.type === 'tool_result') {
86
+ let output = block.output ?? block.result ?? block.text ?? block.content ?? '';
87
+ output = asString(output);
88
+ output = truncateIfNeeded(output, opts.full);
89
+ return ['','```result', output, '```', ''].join('\n');
90
+ }
91
+
92
+ return asString(block.text ?? block.content ?? block);
93
+ }
94
+
95
+ function renderMessage(message, opts) {
96
+ const lines = [];
97
+ if (message.isSidechain) {
98
+ lines.push('### sidechain', '');
99
+ }
100
+
101
+ const content = message.content;
102
+ if (typeof content === 'string') {
103
+ lines.push(content);
104
+ } else if (Array.isArray(content)) {
105
+ for (const block of content) {
106
+ lines.push(renderBlock(block, opts));
107
+ }
108
+ } else {
109
+ lines.push(asString(content));
110
+ }
111
+
112
+ return lines.join('\n').trim() + '\n';
113
+ }
114
+
115
+ async function resolveFilename(session, outDir) {
116
+ const date = session.startedAt ? session.startedAt.slice(0, 10) : 'unknown-date';
117
+ const prefix = session.sessionId.slice(0, 8);
118
+ const branchSlug = slugifyBranch(session.gitBranch);
119
+ const base = `${date}_${prefix}_${branchSlug}`;
120
+ let candidate = `${base}.md`;
121
+ let suffix = 1;
122
+ while (true) {
123
+ const fullPath = path.join(outDir, candidate);
124
+ try {
125
+ await fs.access(fullPath);
126
+ suffix += 1;
127
+ candidate = `${base}_${suffix}.md`;
128
+ continue;
129
+ } catch {
130
+ return candidate;
131
+ }
132
+ }
133
+ }
134
+
135
+ function yamlQuote(value) {
136
+ if (value === null || value === undefined) return '""';
137
+ const str = String(value);
138
+ return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
139
+ }
140
+
141
+ export async function exportSession(session, outDir, opts = {}) {
142
+ const filename = await resolveFilename(session, outDir);
143
+ const tempPath = path.join(outDir, `${filename}.tmp`);
144
+ const finalPath = path.join(outDir, filename);
145
+ const stream = await fs.open(tempPath, 'w');
146
+ let readStream = null;
147
+ let succeeded = false;
148
+ try {
149
+ const tokens = session.tokens ?? { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
150
+ const frontmatter = [
151
+ '---',
152
+ `sessionId: ${yamlQuote(session.sessionId)}`,
153
+ `cwd: ${yamlQuote(session.cwd ?? '')}`,
154
+ `gitBranch: ${yamlQuote(session.gitBranch ?? '')}`,
155
+ `version: ${yamlQuote(session.version ?? '')}`,
156
+ `startedAt: ${yamlQuote(session.startedAt ?? '')}`,
157
+ `endedAt: ${yamlQuote(session.endedAt ?? '')}`,
158
+ `summary: ${yamlQuote(session.summary ?? '')}`,
159
+ `tokensInput: ${tokens.input ?? 0}`,
160
+ `tokensOutput: ${tokens.output ?? 0}`,
161
+ `tokensCacheCreation: ${tokens.cacheCreation ?? 0}`,
162
+ `tokensCacheRead: ${tokens.cacheRead ?? 0}`,
163
+ '---',
164
+ '',
165
+ ].join('\n');
166
+ await stream.write(frontmatter);
167
+
168
+ readStream = await fs.open(session.path, 'r');
169
+ const lines = readline.createInterface({ input: readStream.createReadStream(), crlfDelay: Infinity });
170
+ let lastRole = null;
171
+
172
+ for await (const line of lines) {
173
+ if (!line.trim()) continue;
174
+ let record;
175
+ try {
176
+ record = JSON.parse(line);
177
+ } catch {
178
+ continue;
179
+ }
180
+ if (!record.message || !record.message.role) continue;
181
+ const role = record.message.role;
182
+ const heading = role === 'user' ? '## User' : role === 'assistant' ? '## Assistant' : `## ${role}`;
183
+ if (heading !== lastRole) {
184
+ await stream.write(`${heading}\n\n`);
185
+ lastRole = heading;
186
+ }
187
+ await stream.write(renderMessage(record.message, opts));
188
+ await stream.write('\n');
189
+ }
190
+
191
+ succeeded = true;
192
+ } finally {
193
+ if (readStream) await readStream.close().catch(() => {});
194
+ await stream.close().catch(() => {});
195
+ if (!succeeded) await fs.unlink(tempPath).catch(() => {});
196
+ }
197
+
198
+ await fs.rename(tempPath, finalPath);
199
+ const { size: bytes } = await fs.stat(finalPath);
200
+ return { filename, bytes };
201
+ }
@@ -0,0 +1,58 @@
1
+ // Prices in USD per 1,000,000 tokens.
2
+ // Add new models here as they ship; resolveModel does a longest-prefix match,
3
+ // so dated variants like "claude-opus-4-7-20251022" map to "claude-opus-4-7".
4
+ export const PRICING = {
5
+ 'claude-opus-4-7': { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10.0 },
6
+ 'claude-opus-4-6': { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10.0 },
7
+ 'claude-opus-4-5': { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10.0 },
8
+ 'claude-opus-4-1': { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30.0 },
9
+ 'claude-opus-4': { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30.0 },
10
+ 'claude-sonnet-4-6': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6.0 },
11
+ 'claude-sonnet-4-5': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6.0 },
12
+ 'claude-sonnet-4': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6.0 },
13
+ 'claude-3-7-sonnet': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6.0 },
14
+ 'claude-3-5-sonnet': { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6.0 },
15
+ 'claude-haiku-4-5': { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2.0 },
16
+ 'claude-3-5-haiku': { input: 0.8, output: 4.0, cacheRead: 0.08, cacheWrite: 1.0, cacheWrite1h: 1.6 },
17
+ };
18
+
19
+ const SORTED_KEYS = Object.keys(PRICING).sort((a, b) => b.length - a.length);
20
+
21
+ export function resolveModel(name) {
22
+ if (!name) return null;
23
+ if (PRICING[name]) return { key: name, rates: PRICING[name] };
24
+ for (const key of SORTED_KEYS) {
25
+ if (name.startsWith(key)) return { key, rates: PRICING[key] };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ // tokens shape: { input, output, cacheCreation, cacheRead }
31
+ // cacheCreation is priced at the 5-minute cacheWrite rate; Claude Code's JSONL
32
+ // does not distinguish 5-minute from 1-hour cache creation today.
33
+ export function costForTokens(tokens, rates) {
34
+ return (
35
+ (tokens.input ?? 0) * rates.input +
36
+ (tokens.output ?? 0) * rates.output +
37
+ (tokens.cacheRead ?? 0) * rates.cacheRead +
38
+ (tokens.cacheCreation ?? 0) * rates.cacheWrite
39
+ ) / 1_000_000;
40
+ }
41
+
42
+ // tokensByModel: { [modelName]: tokens }
43
+ // Returns { cost, unknownModels: string[] }.
44
+ export function costForSession(tokensByModel) {
45
+ let cost = 0;
46
+ const unknownModels = [];
47
+ for (const [model, tokens] of Object.entries(tokensByModel || {})) {
48
+ const resolved = resolveModel(model);
49
+ if (!resolved) {
50
+ if ((tokens.input || tokens.output || tokens.cacheRead || tokens.cacheCreation) > 0) {
51
+ unknownModels.push(model);
52
+ }
53
+ continue;
54
+ }
55
+ cost += costForTokens(tokens, resolved.rates);
56
+ }
57
+ return { cost, unknownModels };
58
+ }