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 +21 -0
- package/README.md +366 -0
- package/bin/convoptics.js +2 -0
- package/package.json +36 -0
- package/src/claude-code/exporter.js +201 -0
- package/src/claude-code/pricing.js +58 -0
- package/src/claude-code/scanner.js +99 -0
- package/src/cli.js +267 -0
- package/src/cursor/exporter.js +264 -0
- package/src/cursor/scanner.js +167 -0
- package/src/matcher.js +63 -0
- package/src/query.js +108 -0
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
|
+
[](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).
|
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
|
+
}
|