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 +21 -0
- package/README.md +217 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +221 -0
- package/dist/hermes.d.ts +220 -0
- package/dist/hermes.js +690 -0
- package/package.json +37 -5
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
|
-
#
|
|
1
|
+
# curt-hermes
|
|
2
2
|
|
|
3
|
-
|
|
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
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();
|