curt-hermes 0.0.0-stage → 1.0.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 Jordan Newell
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 CHANGED
@@ -1,3 +1,218 @@
1
- # Temporary Holding Version
1
+ # curt-hermes
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Two-direction bridge between the [Hermes](https://github.com/NousResearch/hermes)
4
+ agent store and the [`.curt`](https://github.com/JordanNewell/dot_curt)
5
+ single-file conversation format. Built on
6
+ [`curt-format`](https://www.npmjs.com/package/curt-format) (reference
7
+ implementation, v1 core). Part of the `curt-*` converter family (`curt-convert`
8
+ for Claude Code, `curt-openclaw` for OpenClaw, `curt-wildchat` for
9
+ WildChat-1M).
10
+
11
+ **Hub-and-spoke positioning:** `.curt` is the archival hub. Direction 1
12
+ exports Hermes sessions *out* into portable `.curt` archives. Direction 2
13
+ emits the de-facto Claude Code session JSONL that `hermes sessions import`
14
+ natively consumes — so `.curt` archives go back INTO Hermes (or any
15
+ Claude-Code-transcript importer) without upstream changes.
16
+
17
+ ```sh
18
+ npm install -g curt-hermes # or: npx curt-hermes
19
+
20
+ # Direction 1: Hermes state.db -> .curt files
21
+ curt-hermes ~/.hermes/state.db -o ./out
22
+ curt-hermes ~/.hermes/state.db --source cli|cron|discord|subagent
23
+ curt-hermes ~/.hermes/state.db --session <sessionId>
24
+ curt-hermes ~/.hermes/state.db --sample 5
25
+ curt-hermes ~/.hermes/state.db --stdout > one.curt
26
+
27
+ # Direction 2: .curt -> Claude Code session JSONL (for hermes sessions import)
28
+ curt-hermes --emit-cc-jsonl ./out # a directory of .curt files
29
+ curt-hermes --emit-cc-jsonl one.curt -o ./jsonl
30
+ ```
31
+
32
+ Direction 1 opens the SQLite store **read-only** (`mode=ro`; WAL sidecars are
33
+ read, never written). Every `.curt` output is re-parsed with `parseCurt`
34
+ before it is written; a file that fails round-trip validation is never
35
+ written. Output names follow the Curtis bulk convention
36
+ (`YYYY-MM-DD <slug> <suffix>.curt` via `curtFilename()`); a filename
37
+ collision gets a `-2`/`-3` suffix instead of silently overwriting. Direction 2
38
+ validates each emitted JSONL for line-parseability and `uuid`/`parentUuid`
39
+ chain integrity before writing. A summary line with converted/skipped/
40
+ round-trip-failure counts goes to stderr; per-session problems are counted,
41
+ never fatal.
42
+
43
+ **Database driver:** `node:sqlite` (`DatabaseSync`) — built into Node ≥ 22.5,
44
+ zero native dependencies. A deliberate choice over `better-sqlite3`: no
45
+ compile step, and the read-only URI mode matches the access posture.
46
+
47
+ ## Provenance
48
+
49
+ Input schema verified read-only against a live Hermes gateway store
50
+ (`~/.hermes/state.db`, **schema_version 30**, 8,602 sessions /
51
+ 157,870 messages) on **2026-11-01**. Real-corpus validation used a remote
52
+ subset (all sessions with `source IN ('cli','discord')` and their messages +
53
+ `session_model_usage` rows — 87 sessions, 1,810 messages) extracted with
54
+ read-only SQLite ATTACH on the host, copied out, converted, and deleted from
55
+ the host afterward. Real content stays out of this repo (gitignored `work/`
56
+ by convention); `demos/` are fully synthetic.
57
+
58
+ Schema findings that shaped the converter:
59
+
60
+ - `sessions.started_at` / `ended_at` are REAL epoch **seconds** — normalized
61
+ to epoch milliseconds (`× 1000`) everywhere.
62
+ - `messages.content` carries **no prefix sentinel** in schema_version 30: it
63
+ is either a plain string or a direct JSON document (tool payloads like
64
+ `{output, exit_code}`; assistant structured replies). JSON documents are
65
+ re-serialized canonically as the message text and counted. (The code-level
66
+ `_CONTENT_JSON_PREFIX` guard from the Hermes writer is honored
67
+ defensively: a sentinel-prefixed payload would fail `JSON.parse` and be
68
+ emitted verbatim, never dropped.)
69
+ - `messages.tool_calls` (assistant rows) is a JSON array of OpenAI-style
70
+ calls **plus Hermes plumbing**:
71
+ `{id, call_id, response_item_id, type, function:{name, arguments},
72
+ extra_content?}`. Verified: `messages.tool_call_id` links on
73
+ `tool_calls[].id` (never `call_id`). Emitted .curt form is
74
+ `[{id, name, arguments}]` with `arguments` parsed to a value; plumbing
75
+ fields are counted as redacted.
76
+ - Untrusted tool output is wrapped in
77
+ `<untrusted_tool_result source="...">` … `</untrusted_tool_result>` —
78
+ format metadata; the wrapper pair is stripped and counted, the inner body
79
+ is the content.
80
+ - `role` distribution: `user` / `assistant` / `tool` / `session_meta`
81
+ (presence markers, NULL content — skipped+counted). No `system` rows exist
82
+ in the verified store; they would be skipped+counted if they appeared.
83
+ - `reasoning` / `reasoning_content` (assistant) are plain text — joined to
84
+ the message `thinking` passthrough.
85
+ - The documented `created_source` column does not exist in schema_version 30
86
+ (plan-vs-reality drift); the converter relies only on verified columns.
87
+
88
+ ## Mapping
89
+
90
+ | Hermes (`state.db`) | .curt | Notes |
91
+ |---|---|---|
92
+ | `sessions.id` | `conversation.id` | |
93
+ | `sessions.title` (when present; `title_source` `user`/`llm` are both titles, not raw content) | `conversation.title` | Falls back to first user text, single line, 60-char cap. Sessions without a title: derived (verified store: 63 of 87 subset sessions). |
94
+ | `sessions.started_at` / `ended_at` (epoch s → ms) | `conversation.createdAt` / `updatedAt` | Falls back to min/max message timestamps. |
95
+ | `sessions.model` | `conversation.model` | Messages carry no per-row model column in this schema. |
96
+ | message `role:user` | message `role:"user"` | Content verbatim (or JSON re-serialized). |
97
+ | message `role:assistant` | message `role:"assistant"` | Content plain or JSON-as-text. |
98
+ | assistant `reasoning` + `reasoning_content` | message `thinking` (extra) | Joined `\n\n`. |
99
+ | assistant `tool_calls` | message `toolCalls` (extra) | `[{id, name, arguments}]` — draft `tool-linkage` passthrough convention; plumbing (`call_id`, `response_item_id`, `type`, `extra_content`) dropped+counted. |
100
+ | assistant `token_count` | message `tokens.totalTokens` (extra) | Single per-message number; maps to the stable `usage-metadata` `tokens` shape. |
101
+ | assistant `finish_reason` | message `stopReason` (extra) | |
102
+ | message `role:tool` | message `role:"tool"` | Extras: `toolCallId` (links `tool_calls[].id`), `toolName`. `<untrusted_tool_result>` wrapper stripped+counted. |
103
+ | `role:session_meta` (and any unknown role) | — | Skipped and counted (`skippedRoles`). |
104
+ | `sessions.source`, `chat_type`, `end_reason` | `conversation.hermes` (extra) | Generic surface metadata only. |
105
+ | `sessions.input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens` | `conversation.hermesTokens` (extra) | Numbers only. |
106
+ | `sessions.estimated_cost_usd` | `conversation.hermesCostUsd` (extra) | |
107
+ | `session_model_usage` rows | `conversation.hermesModelUsage` (extra) | Only emitted when the session used >1 model (5 of 87 in the verified subset); `[{model, apiCallCount, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, estimatedCostUsd}]`. |
108
+ | row order (`messages.id`) | `messages` array order | |
109
+ | — | message `id` | Synthesized `"<sessionId>-<n>"` over emitted messages. |
110
+
111
+ ### Direction 2 mapping (.curt → Claude Code JSONL)
112
+
113
+ | .curt | Claude Code JSONL | Notes |
114
+ |---|---|---|
115
+ | conversation | one `.jsonl` file | Curtis bulk-convention name, `.jsonl` extension. |
116
+ | `conversation.id` | `sessionId` on every line | |
117
+ | message | one line | `type:"user"` / `type:"assistant"`; `uuid`/`parentUuid` chain (deterministic UUIDs — re-runs are byte-identical), ISO `timestamp`, `isSidechain:false`, `userType:"external"`. `cwd`/`gitBranch` emitted empty, `version` = generator. |
118
+ | message `content` (user) | `message.content` string | |
119
+ | message `thinking` | `message.content[].{type:"thinking", thinking}` block | |
120
+ | message `content` (assistant) | `message.content[].{type:"text", text}` block | |
121
+ | message `toolCalls` | `message.content[].{type:"tool_use", id, name, input}` blocks | `arguments` → `input`. |
122
+ | message `role:"tool"` | `type:"user"` line with `message.content[].{type:"tool_result", tool_use_id, content}` | `isError` → `is_error`. |
123
+ | message `model`, `stopReason`, `tokens` | `message.model`, `message.stop_reason`, `message.usage.output_tokens` | |
124
+ | `role:"system"` | `type:"system"` line with `content` | None exist in the verified store; handled defensively. |
125
+
126
+ Each emitted file is validated before writing: every line parses, the
127
+ `uuid`/`parentUuid` chain is intact, duplicates rejected. Orphan
128
+ `tool_result`s (aborted calls in the archive) are carried through and
129
+ counted, not fatal. Emitting the de-facto interchange means `hermes sessions
130
+ import` consumes `.curt` archives without any upstream Hermes changes.
131
+
132
+ ### Extensions
133
+
134
+ The top-level `extensions` array declares `["usage-metadata"]` (stable in
135
+ `EXTENSIONS.md`) because `tokens` is emitted. The **`tool-linkage` extension
136
+ is draft**, so its field names (`toolCalls` on assistant messages,
137
+ `toolCallId` on tool messages) ride as plain passthrough extras without
138
+ declaring the extension — the same convention curt-openclaw uses. If/when
139
+ `tool-linkage` reaches stable, declaring it is a one-line change.
140
+
141
+ ## Known losses
142
+
143
+ Deliberate — identifiers, paths, prompts, and plumbing that must not leak
144
+ into a portable file. Counted in `redactedFields` (aggregate only; never
145
+ logged verbatim):
146
+
147
+ - Never read as values at all (NULL-ness only, for the drop ledger):
148
+ `system_prompt`, `origin_json`, `display_name`, `user_id`, `session_key`,
149
+ `chat_id`, `thread_id`, `billing_provider`, `billing_base_url`,
150
+ `tool_names`. Also never touched: `cwd`, `git_branch`, `git_repo_root`,
151
+ `model_config`, `profile_name`, `parent_session_id`, `handoff_*`,
152
+ `transport_profile`, `last_activity_*`, `compression_*`.
153
+ - `tool_calls` plumbing: `call_id`, `response_item_id`, `type`,
154
+ `extra_content`.
155
+ - `<untrusted_tool_result source="...">` wrapper tag + source attribute
156
+ (the body is kept).
157
+ - Structural, not redaction:
158
+ - `session_meta` rows (presence markers with NULL content) are skipped and
159
+ counted.
160
+ - Empty-content user/assistant rows (nothing to say) are dropped and
161
+ counted (`emptyDropped`) — 1 in the verified subset.
162
+ - A `toolCall` with no matching `tool` row (aborted call) still emits its
163
+ `toolCalls` entry; counted as `unmatchedToolCalls`. A `tool` row whose
164
+ `tool_call_id` was never seen is counted as `unmatchedToolResults` but
165
+ still emitted.
166
+ - JSON contents are re-serialized (key order canonicalized); text is never
167
+ altered.
168
+ - Per-message model attribution does not exist in this schema — only the
169
+ session-level `model` (and multi-model rollups via
170
+ `hermesModelUsage`).
171
+ - Malformed rows (unparseable `tool_calls` JSON, non-string content) are
172
+ counted, never fatal — 0 in the verified subset.
173
+
174
+ ## Privacy posture
175
+
176
+ Direction 1 writes content-faithful files: conversation text, tool calls and
177
+ outputs, and thinking blocks all land in `.curt` output — treat output as
178
+ record-equivalent to the source store. The converter's own metadata layer
179
+ never emits identifiers, keys, session/chat/thread ids, system prompts, or
180
+ billing endpoints; credential columns are not even SELECTed as values
181
+ (NULL-ness only). Direction 2 re-emits that same content into JSONL; nothing
182
+ is added except synthetic chain UUIDs and neutral `cwd`/`gitBranch`/`version`
183
+ fields. The verified-corpus run was spot-scanned: no banned identifier values
184
+ appear in any output's metadata layer.
185
+
186
+ ## Development
187
+
188
+ ```sh
189
+ npm i
190
+ npm test # vitest, 36 tests over a synthetic node:sqlite fixture DB
191
+ npm run build # tsc -> dist/
192
+ ```
193
+
194
+ The fixture DB is built by `test/fixtures.ts` (schema mirrors the real
195
+ store; all content synthetic). Regenerate the staged demos with:
196
+
197
+ ```sh
198
+ node --experimental-strip-types test/make-demo.ts
199
+ node dist/cli.js demos/synthetic-fixture/state.db -o demos/synthetic-flow
200
+ node dist/cli.js --emit-cc-jsonl demos/synthetic-flow -o demos/synthetic-cc-jsonl
201
+ rm -rf demos/synthetic-fixture # regenerable; also keeps *.db out of the tree
202
+ ```
203
+
204
+ Real-corpus runs write into an untracked, gitignored `work/` directory by
205
+ convention.
206
+
207
+ ## Part of the .curt suite
208
+
209
+ - [`curt-format`](https://github.com/JordanNewell/dot_curt) — the .curt format spec + reference implementation ([npm](https://www.npmjs.com/package/curt-format))
210
+ - [`curt-convert`](https://github.com/JordanNewell/curt-convert) — Claude Code transcripts → .curt ([npm](https://www.npmjs.com/package/curt-convert))
211
+ - [`curt-openclaw`](https://github.com/JordanNewell/curt-openclaw) — OpenClaw session transcripts → .curt ([npm](https://www.npmjs.com/package/curt-openclaw))
212
+ - [`curt-wildchat`](https://github.com/JordanNewell/curt-wildchat) — WildChat-1M records → .curt ([npm](https://www.npmjs.com/package/curt-wildchat))
213
+ - [`curt-vcon`](https://github.com/JordanNewell/curt-vcon) — .curt ⇄ vCon (IETF) bridge ([npm](https://www.npmjs.com/package/curt-vcon))
214
+ - [`curt-openwith`](https://github.com/JordanNewell/curt-openwith) — double-click any .curt file: offline viewer, Windows + macOS + Linux
215
+
216
+ MIT — see [LICENSE](LICENSE). See also
217
+ [ACCESS-AND-LICENSE.md](ACCESS-AND-LICENSE.md) for the input-data access and
218
+ content-licensing posture.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ /** curt-hermes CLI: Hermes state.db -> .curt files; .curt -> Claude Code JSONL. */
3
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /** curt-hermes CLI: Hermes state.db -> .curt files; .curt -> Claude Code JSONL. */
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { extname, join, resolve } from "node:path";
5
+ import { curtFilename, parseCurt } from "curt-format";
6
+ import { GENERATOR, convertDb, emitClaudeCodeJsonl, resolveCurtWritePath, serializeValidated, validateCcJsonl, } from "./hermes.js";
7
+ function usage() {
8
+ console.error(`curt-hermes — Hermes agent store <-> .curt conversations
9
+
10
+ Direction 1 (export/archive): Hermes state.db -> .curt files
11
+ curt-hermes <state.db>
12
+ curt-hermes <state.db> -o ./out
13
+ curt-hermes <state.db> --source cli|cron|discord|subagent
14
+ curt-hermes <state.db> --session <sessionId>
15
+ curt-hermes <state.db> --sample 5
16
+ curt-hermes <state.db> --stdout > one.curt
17
+
18
+ Input is the Hermes SQLite store (~/.hermes/state.db). The database is opened
19
+ read-only. Credential and identifier columns (system_prompt, origin_json,
20
+ display_name, user_id, session_key, chat_id, thread_id, billing_*, cwd, git
21
+ fields) are never read as values — only their NULL-ness, for drop accounting.
22
+
23
+ Direction 2 (bridge into Hermes): .curt -> Claude Code session JSONL
24
+ curt-hermes --emit-cc-jsonl <file.curt|dir> [-o out]
25
+
26
+ Emits the de-facto Claude Code interchange format that 'hermes sessions
27
+ import' natively consumes, so .curt archives go INTO Hermes without upstream
28
+ changes. One .jsonl per conversation.
29
+
30
+ Options:
31
+ -o, --out <dir> Output directory (default: alongside the input)
32
+ --source <s> Direction 1: only sessions with sessions.source = s
33
+ --session <id> Direction 1: only the session with this id
34
+ --sample <n> Direction 1: only the first n matching sessions
35
+ --stdout Direction 1: print .curt bodies instead of writing
36
+ --emit-cc-jsonl Direction 2: .curt file(s) -> Claude Code JSONL
37
+ -h, --help Show this help`);
38
+ process.exit(2);
39
+ }
40
+ function parseArgs(argv) {
41
+ const options = { stdout: false };
42
+ let input;
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (a === "-h" || a === "--help")
46
+ usage();
47
+ if (a === "--stdout") {
48
+ options.stdout = true;
49
+ }
50
+ else if (a === "--emit-cc-jsonl") {
51
+ options.emitCcJsonl = true;
52
+ }
53
+ else if (a === "-o" || a === "--out") {
54
+ const v = argv[++i];
55
+ if (!v)
56
+ usage();
57
+ options.outDir = v;
58
+ }
59
+ else if (a === "--source") {
60
+ const v = argv[++i];
61
+ if (!v)
62
+ usage();
63
+ options.source = v;
64
+ }
65
+ else if (a === "--session") {
66
+ const v = argv[++i];
67
+ if (!v)
68
+ usage();
69
+ options.session = v;
70
+ }
71
+ else if (a === "--sample") {
72
+ const v = argv[++i];
73
+ const n = v ? Number(v) : NaN;
74
+ if (!Number.isInteger(n) || n < 1)
75
+ usage();
76
+ options.sample = n;
77
+ }
78
+ else if (a.startsWith("-")) {
79
+ console.error(`Unknown option: ${a}`);
80
+ usage();
81
+ }
82
+ else if (input === undefined) {
83
+ input = a;
84
+ }
85
+ else {
86
+ console.error(`Unexpected extra argument: ${a}`);
87
+ usage();
88
+ }
89
+ }
90
+ return { input, options };
91
+ }
92
+ // ---- Direction 1 ----
93
+ function runConvert(dbPath, options) {
94
+ const { items, converted, skipped, totals } = convertDb(dbPath, options);
95
+ let roundtripFailures = 0;
96
+ let collisions = 0;
97
+ for (const { result } of items) {
98
+ const body = serializeValidated(result.conversation);
99
+ if (!body) {
100
+ roundtripFailures++;
101
+ console.error(`round-trip validation failed for ${result.conversation.id}; not written`);
102
+ continue;
103
+ }
104
+ if (options.stdout) {
105
+ console.log(body);
106
+ }
107
+ else {
108
+ const outDir = resolve(options.outDir ?? ".");
109
+ mkdirSync(outDir, { recursive: true });
110
+ const file = join(outDir, curtFilename(result.conversation));
111
+ const target = resolveCurtWritePath(file, body);
112
+ if (target.collided) {
113
+ collisions++;
114
+ console.error(`filename collision for ${result.conversation.id}; wrote ${target.path} instead`);
115
+ }
116
+ writeFileSync(target.path, body);
117
+ console.log(target.path);
118
+ }
119
+ }
120
+ console.error(`${GENERATOR}: ${converted} converted, ${skipped} skipped, ${roundtripFailures} round-trip failures` +
121
+ (collisions > 0 ? `, ${collisions} filename collisions (suffixed)` : "") +
122
+ ` (messages: ${totals.roles.user} user / ${totals.roles.assistant} assistant / ${totals.roles.tool} tool; ` +
123
+ `${totals.skippedRoles} meta/system rows, ${totals.malformedRows} malformed rows, ` +
124
+ `${totals.jsonContents} JSON contents, ${totals.untrustedWrappers} untrusted wrappers stripped, ` +
125
+ `${totals.unmatchedToolCalls} unmatched toolCalls, ${totals.unmatchedToolResults} unmatched toolResults, ` +
126
+ `${totals.emptyDropped} empty dropped, ${totals.redactedFields} identifier fields dropped)`);
127
+ // Per-session problems are counted, never fatal.
128
+ return 0;
129
+ }
130
+ // ---- Direction 2 ----
131
+ function jsonlName(conversationId, createdAt, title) {
132
+ // Curtis bulk-convention name, .curt -> .jsonl.
133
+ const fake = {
134
+ id: conversationId,
135
+ title,
136
+ createdAt: Number.isFinite(createdAt) ? createdAt : 0,
137
+ };
138
+ const name = curtFilename(fake);
139
+ return `${name.slice(0, -".curt".length)}.jsonl`;
140
+ }
141
+ function runEmitCcJsonl(input, options) {
142
+ const st = statSync(input);
143
+ const curtFiles = [];
144
+ if (st.isDirectory()) {
145
+ for (const f of readdirSync(input).sort()) {
146
+ if (extname(f) === ".curt")
147
+ curtFiles.push(join(input, f));
148
+ }
149
+ }
150
+ else if (extname(input) === ".curt") {
151
+ curtFiles.push(input);
152
+ }
153
+ else {
154
+ console.error(`Not a .curt file or directory of .curt files: ${input}`);
155
+ return 2;
156
+ }
157
+ if (curtFiles.length === 0) {
158
+ console.error(`No .curt files found in ${input}`);
159
+ return 2;
160
+ }
161
+ const outDir = options.outDir ? resolve(options.outDir) : st.isDirectory() ? resolve(input) : resolve(input, "..");
162
+ if (!options.stdout)
163
+ mkdirSync(outDir, { recursive: true });
164
+ let written = 0;
165
+ let rejected = 0;
166
+ for (const file of curtFiles) {
167
+ let body;
168
+ try {
169
+ body = readFileSync(file, "utf8");
170
+ }
171
+ catch {
172
+ console.error(`unreadable: ${file}`);
173
+ rejected++;
174
+ continue;
175
+ }
176
+ const parsed = parseCurt(body);
177
+ if (!parsed) {
178
+ console.error(`not a valid .curt file, skipped: ${file}`);
179
+ rejected++;
180
+ continue;
181
+ }
182
+ const jsonl = emitClaudeCodeJsonl(parsed);
183
+ const check = validateCcJsonl(jsonl);
184
+ if (check.errors.length > 0) {
185
+ console.error(`structural validation failed for ${parsed.id}; not written (${check.errors.length} errors)`);
186
+ rejected++;
187
+ continue;
188
+ }
189
+ if (check.orphanToolResults > 0) {
190
+ console.error(`${parsed.id}: ${check.orphanToolResults} orphan tool_result(s) carried through from the archive`);
191
+ }
192
+ if (options.stdout) {
193
+ process.stdout.write(jsonl);
194
+ }
195
+ else {
196
+ const target = join(outDir, jsonlName(parsed.id, parsed.createdAt, parsed.title));
197
+ writeFileSync(target, jsonl);
198
+ console.log(target);
199
+ }
200
+ written++;
201
+ }
202
+ console.error(`${GENERATOR}: ${written} JSONL emitted (${curtFiles.length - written - rejected} empty), ${rejected} rejected`);
203
+ return 0;
204
+ }
205
+ function main() {
206
+ const { input, options } = parseArgs(process.argv.slice(2));
207
+ if (options.emitCcJsonl) {
208
+ if (!input)
209
+ usage();
210
+ return runEmitCcJsonl(resolve(input), options);
211
+ }
212
+ if (!input)
213
+ usage();
214
+ const dbPath = resolve(input);
215
+ if (!existsSync(dbPath)) {
216
+ console.error(`No such file: ${dbPath}`);
217
+ return 2;
218
+ }
219
+ return runConvert(dbPath, options);
220
+ }
221
+ process.exitCode = main();