@sayknow-cli/coding-agent 0.5.2 → 0.5.6

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +55 -16
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  4. package/dist/types/config/keybindings.d.ts +10 -0
  5. package/dist/types/config/model-registry.d.ts +1 -1
  6. package/dist/types/config/models-config-schema.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +9 -0
  8. package/dist/types/dap/client.d.ts +1 -0
  9. package/dist/types/lsp/client.d.ts +5 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  12. package/dist/types/modes/interactive-mode.d.ts +13 -1
  13. package/dist/types/modes/types.d.ts +7 -1
  14. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  15. package/dist/types/session/agent-session.d.ts +8 -0
  16. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  17. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  18. package/dist/types/session/session-manager.d.ts +9 -0
  19. package/dist/types/session/session-storage.d.ts +17 -0
  20. package/dist/types/session-import/claude.d.ts +26 -0
  21. package/dist/types/session-import/codex.d.ts +22 -0
  22. package/dist/types/session-import/command.d.ts +20 -0
  23. package/dist/types/session-import/detect.d.ts +22 -0
  24. package/dist/types/session-import/index.d.ts +7 -0
  25. package/dist/types/session-import/redact.d.ts +23 -0
  26. package/dist/types/session-import/service.d.ts +28 -0
  27. package/dist/types/session-import/types.d.ts +125 -0
  28. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  30. package/dist/types/slash-commands/types.d.ts +5 -0
  31. package/dist/types/tools/ask.d.ts +5 -0
  32. package/package.json +13 -7
  33. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  34. package/src/cli/config-cli.ts +23 -8
  35. package/src/cli/update-cli.ts +265 -21
  36. package/src/config/atomic-yaml-patch.ts +167 -30
  37. package/src/config/keybindings.ts +10 -0
  38. package/src/config/model-profiles.ts +69 -1
  39. package/src/config/model-registry.ts +60 -38
  40. package/src/config/models-config-schema.ts +1 -1
  41. package/src/config/settings-schema.ts +9 -0
  42. package/src/config/settings.ts +13 -2
  43. package/src/dap/client.ts +78 -30
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/lsp/client.ts +17 -29
  46. package/src/modes/action-registry.ts +2 -0
  47. package/src/modes/components/welcome.ts +5 -0
  48. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  49. package/src/modes/controllers/input-controller.ts +71 -6
  50. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  51. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  52. package/src/modes/controllers/selector-controller.ts +47 -19
  53. package/src/modes/interactive-mode.ts +54 -4
  54. package/src/modes/types.ts +5 -1
  55. package/src/runtime-mcp/transports/stdio.ts +96 -33
  56. package/src/sdk/broker/lifecycle.ts +6 -5
  57. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  58. package/src/sdk/session.ts +25 -18
  59. package/src/session/agent-session.ts +89 -14
  60. package/src/session/internal/managed-session-scope.ts +41 -6
  61. package/src/session/internal/managed-session-storage.ts +14 -0
  62. package/src/session/session-manager.ts +113 -11
  63. package/src/session/session-storage.ts +70 -0
  64. package/src/session-import/claude.ts +382 -0
  65. package/src/session-import/codex.ts +458 -0
  66. package/src/session-import/command.ts +61 -0
  67. package/src/session-import/detect.ts +133 -0
  68. package/src/session-import/index.ts +27 -0
  69. package/src/session-import/redact.ts +125 -0
  70. package/src/session-import/service.ts +749 -0
  71. package/src/session-import/types.ts +163 -0
  72. package/src/skc-runtime/launch-worktree.ts +277 -4
  73. package/src/slash-commands/acp-builtins.ts +5 -1
  74. package/src/slash-commands/builtin-registry.ts +100 -1
  75. package/src/slash-commands/types.ts +5 -0
  76. package/src/tools/ask.ts +5 -0
  77. package/src/export/html/template.css +0 -1060
  78. package/src/export/html/template.html +0 -47
  79. package/src/export/html/template.js +0 -2348
  80. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  81. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  82. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  83. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  84. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  85. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  86. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -165,6 +165,8 @@ export interface SessionStorage {
165
165
  readBytesSync?(path: string): Uint8Array;
166
166
  /** Exact bytes and descriptor-bound identity captured from one opened regular file. */
167
167
  readSnapshotSync?(path: string): SessionStorageSnapshot;
168
+ /** Exact descriptor-bound bytes, refusing growth or allocation beyond maxBytes. */
169
+ readSnapshotBoundedSync?(path: string, maxBytes: number): SessionStorageSnapshot;
168
170
  statSync(path: string): SessionStorageStat;
169
171
  listFilesSync(dir: string, pattern: string): string[];
170
172
  /** List matching files with mtimes without issuing one JavaScript stat call per path. */
@@ -174,6 +176,8 @@ export interface SessionStorage {
174
176
  * authorization inventory; the forgiving {@link listFilesSync} stays display-only.
175
177
  */
176
178
  listFilesStrictSync?(dir: string, pattern: string): string[];
179
+ /** Strict bounded scan; `truncated` means completeness was not granted. */
180
+ listFilesStrictBoundedSync?(dir: string, pattern: string, maxFiles: number): { files: string[]; truncated: boolean };
177
181
 
178
182
  exists(path: string): Promise<boolean>;
179
183
  readText(path: string): Promise<string>;
@@ -696,6 +700,39 @@ export class FileSessionStorage implements SessionStorage {
696
700
  }
697
701
  }
698
702
 
703
+ readSnapshotBoundedSync(fpath: string, maxBytes: number): SessionStorageSnapshot {
704
+ const flags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
705
+ const fd = fs.openSync(fpath, flags);
706
+ try {
707
+ const initial = statFromNode(fs.fstatSync(fd, { bigint: true }));
708
+ if (!initial.isFile) throw new Error(`Not a regular file: ${fpath}`);
709
+ if (initial.size > maxBytes) throw new Error("Snapshot exceeds the strict byte limit.");
710
+ const chunks: Buffer[] = [];
711
+ let total = 0;
712
+ while (total <= maxBytes) {
713
+ const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, maxBytes - total + 1));
714
+ const bytesRead = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
715
+ if (bytesRead === 0) break;
716
+ total += bytesRead;
717
+ if (total > maxBytes) throw new Error("Snapshot exceeds the strict byte limit.");
718
+ chunks.push(chunk.subarray(0, bytesRead));
719
+ }
720
+ const terminal = statFromNode(fs.fstatSync(fd, { bigint: true }));
721
+ if (
722
+ initial.dev !== terminal.dev ||
723
+ initial.ino !== terminal.ino ||
724
+ initial.size !== terminal.size ||
725
+ initial.mtimeNs !== terminal.mtimeNs ||
726
+ total !== terminal.size
727
+ ) {
728
+ throw new Error("Snapshot changed during the bounded read.");
729
+ }
730
+ return { bytes: Buffer.concat(chunks, total), stat: terminal };
731
+ } finally {
732
+ fs.closeSync(fd);
733
+ }
734
+ }
735
+
699
736
  statSync(path: string): SessionStorageStat {
700
737
  return statFromNode(fs.statSync(path, { bigint: true }));
701
738
  }
@@ -729,6 +766,26 @@ export class FileSessionStorage implements SessionStorage {
729
766
  return Array.from(new Bun.Glob(pattern).scanSync(dir)).map(name => path.join(dir, name));
730
767
  }
731
768
 
769
+ listFilesStrictBoundedSync(dir: string, pattern: string, maxFiles: number): { files: string[]; truncated: boolean } {
770
+ const handle = fs.opendirSync(dir);
771
+ const files: string[] = [];
772
+ let entries = 0;
773
+ try {
774
+ for (;;) {
775
+ const entry = handle.readSync();
776
+ if (!entry) break;
777
+ entries++;
778
+ if (entries > maxFiles * 4 + 128) return { files, truncated: true };
779
+ if (!matchesPattern(entry.name, pattern)) continue;
780
+ files.push(path.join(dir, entry.name));
781
+ if (files.length > maxFiles) return { files, truncated: true };
782
+ }
783
+ return { files, truncated: false };
784
+ } finally {
785
+ handle.closeSync();
786
+ }
787
+ }
788
+
732
789
  async exists(path: string): Promise<boolean> {
733
790
  try {
734
791
  await fs.promises.access(path);
@@ -1469,6 +1526,12 @@ export class MemorySessionStorage implements SessionStorage {
1469
1526
  return { bytes: Buffer.from(entry.content), stat: this.#statFor(entry) };
1470
1527
  }
1471
1528
 
1529
+ readSnapshotBoundedSync(path: string, maxBytes: number): SessionStorageSnapshot {
1530
+ const snapshot = this.readSnapshotSync(path);
1531
+ if (snapshot.bytes.byteLength > maxBytes) throw new Error("Snapshot exceeds the strict byte limit.");
1532
+ return snapshot;
1533
+ }
1534
+
1472
1535
  statSync(path: string): SessionStorageStat {
1473
1536
  const entry = this.#files.get(path);
1474
1537
  if (!entry) throw new Error(`File not found: ${path}`);
@@ -1498,6 +1561,13 @@ export class MemorySessionStorage implements SessionStorage {
1498
1561
  return this.listFilesSync(dir, pattern);
1499
1562
  }
1500
1563
 
1564
+ listFilesStrictBoundedSync(dir: string, pattern: string, maxFiles: number): { files: string[]; truncated: boolean } {
1565
+ const files = this.listFilesSync(dir, pattern);
1566
+ return files.length > maxFiles
1567
+ ? { files: files.slice(0, maxFiles + 1), truncated: true }
1568
+ : { files, truncated: false };
1569
+ }
1570
+
1501
1571
  exists(path: string): Promise<boolean> {
1502
1572
  return Promise.resolve(this.existsSync(path));
1503
1573
  }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Claude adapter (issue #3709).
3
+ *
4
+ * Parses two first-party observable Claude transcript/export shapes:
5
+ *
6
+ * 1. `claude-code-jsonl` — Claude Code session transcripts
7
+ * (`~/.claude/projects/<project-slug>/<session-uuid>.jsonl`), one JSON object
8
+ * per line with `type: "user" | "assistant" | "summary" | "system" | …`.
9
+ * 2. `claude-export-json` — the claude.ai data export (`conversations.json`):
10
+ * a JSON array of conversations (or a single conversation object) whose
11
+ * `chat_messages` carry `sender: "human" | "assistant"` and `content` blocks.
12
+ *
13
+ * The same quarantine contract as the Codex adapter applies: unmappable
14
+ * records/messages are counted and digested, never silently dropped.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { redactImportedText } from "./redact";
19
+ import {
20
+ type ImportedConversation,
21
+ type ImportedMessage,
22
+ type ImportQuarantineRecord,
23
+ SESSION_IMPORT_QUARANTINE_MAX_RECORDS,
24
+ type SessionImportCounts,
25
+ } from "./types";
26
+
27
+ type JsonRecord = Record<string, unknown>;
28
+
29
+ export interface ClaudeParseResult {
30
+ conversation: ImportedConversation;
31
+ quarantine: ImportQuarantineRecord[];
32
+ counts: SessionImportCounts;
33
+ redactionKinds: string[];
34
+ }
35
+
36
+ const MAX_EVIDENCE_LINE_CHARS = 400;
37
+ const MAX_EVIDENCE_LINES = 100;
38
+ const MAX_EVIDENCE_BYTES = 8 * 1024;
39
+
40
+ function isRecord(value: unknown): value is JsonRecord {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function sha256Hex(text: string): string {
45
+ return createHash("sha256").update(text, "utf8").digest("hex");
46
+ }
47
+
48
+ function boundEvidence(text: string): string {
49
+ const singleLine = text.replace(/\s+/g, " ").trim();
50
+ if (singleLine.length <= MAX_EVIDENCE_LINE_CHARS) return singleLine;
51
+ return `${singleLine.slice(0, MAX_EVIDENCE_LINE_CHARS - 1)}…`;
52
+ }
53
+
54
+ class ParseSink {
55
+ readonly messages: ImportedMessage[] = [];
56
+ readonly quarantine: ImportQuarantineRecord[] = [];
57
+ readonly counts: SessionImportCounts = { mapped: 0, quarantined: 0, redacted: 0, omitted: 0 };
58
+ readonly redactionKinds = new Set<string>();
59
+ #evidence: string[] = [];
60
+ #evidenceBytes = 0;
61
+ #current: ImportedMessage | undefined;
62
+
63
+ redact(input: string): string {
64
+ const result = redactImportedText(input);
65
+ this.counts.redacted += result.redacted;
66
+ for (const kind of result.kinds) this.redactionKinds.add(kind);
67
+ return result.value;
68
+ }
69
+
70
+ quarantineRecord(record: number, raw: string, reason: ImportQuarantineRecord["reason"]): void {
71
+ if (this.quarantine.length < SESSION_IMPORT_QUARANTINE_MAX_RECORDS) {
72
+ this.quarantine.push({
73
+ record,
74
+ byteLength: Buffer.byteLength(raw, "utf8"),
75
+ sha256: sha256Hex(raw),
76
+ reason,
77
+ });
78
+ }
79
+ this.counts.quarantined++;
80
+ }
81
+
82
+ addEvidence(line: string): boolean {
83
+ if (line.length === 0) return true;
84
+ if (this.#evidence.length >= MAX_EVIDENCE_LINES || this.#evidenceBytes + line.length > MAX_EVIDENCE_BYTES) {
85
+ this.counts.omitted++;
86
+ return false;
87
+ }
88
+ this.#evidence.push(line);
89
+ this.#evidenceBytes += line.length;
90
+ return true;
91
+ }
92
+
93
+ closeEvidence(): void {
94
+ if (this.#evidence.length > 0) {
95
+ if (this.#current?.role !== "assistant") {
96
+ const toolOnly: ImportedMessage = { role: "assistant", text: "", toolEvidence: [...this.#evidence] };
97
+ this.#current = toolOnly;
98
+ this.messages.push(toolOnly);
99
+ } else {
100
+ this.#current.toolEvidence = [...(this.#current.toolEvidence ?? []), ...this.#evidence];
101
+ }
102
+ }
103
+ this.#evidence = [];
104
+ this.#evidenceBytes = 0;
105
+ }
106
+
107
+ /** Assemble visible text into the running conversation. `mapped` is counted by the caller. */
108
+ pushMessage(role: "user" | "assistant", rawText: string, timestamp?: string): void {
109
+ const sanitized = this.redact(rawText).trim();
110
+ if (role === "user") {
111
+ this.closeEvidence();
112
+ if (!sanitized) {
113
+ this.#current = undefined;
114
+ return;
115
+ }
116
+ this.#current = { role, text: sanitized, ...(timestamp ? { timestamp } : {}) };
117
+ this.messages.push(this.#current);
118
+ return;
119
+ }
120
+ if (!sanitized) {
121
+ this.closeEvidence();
122
+ return;
123
+ }
124
+ if (this.#current?.role === "assistant") {
125
+ this.#current.text = this.#current.text ? `${this.#current.text}\n\n${sanitized}` : sanitized;
126
+ } else {
127
+ this.#current = { role, text: sanitized, ...(timestamp ? { timestamp } : {}) };
128
+ this.messages.push(this.#current);
129
+ }
130
+ this.closeEvidence();
131
+ }
132
+ }
133
+
134
+ function toolUseSummary(input: unknown): string {
135
+ if (!isRecord(input)) return "";
136
+ if (typeof input.command === "string") return input.command;
137
+ if (typeof input.file_path === "string") return input.file_path;
138
+ if (typeof input.path === "string") return input.path;
139
+ if (typeof input.pattern === "string") return input.pattern;
140
+ if (typeof input.query === "string") return input.query;
141
+ if (typeof input.url === "string") return input.url;
142
+ if (typeof input.prompt === "string") return boundEvidence(input.prompt);
143
+ if (typeof input.content === "string") return boundEvidence(input.content);
144
+ return boundEvidence(JSON.stringify(input));
145
+ }
146
+
147
+ function toolResultText(content: unknown): string {
148
+ if (typeof content === "string") return content;
149
+ if (!Array.isArray(content)) return "";
150
+ const parts: string[] = [];
151
+ for (const block of content) {
152
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
153
+ }
154
+ return parts.join("\n");
155
+ }
156
+
157
+ /** Parse a Claude Code session JSONL transcript. */
158
+ export function parseClaudeCodeTranscript(text: string): ClaudeParseResult {
159
+ const sink = new ParseSink();
160
+ let sourceSessionId: string | undefined;
161
+ let title: string | undefined;
162
+ let cwd: string | undefined;
163
+
164
+ const lines = text.split("\n");
165
+ for (let index = 0; index < lines.length; index++) {
166
+ const rawLine = lines[index]!;
167
+ const line = rawLine.trim();
168
+ if (line.length === 0) continue;
169
+ const recordNumber = index + 1;
170
+ let record: unknown;
171
+ try {
172
+ record = JSON.parse(line);
173
+ } catch {
174
+ sink.quarantineRecord(recordNumber, rawLine, "invalid_json");
175
+ continue;
176
+ }
177
+ if (!isRecord(record) || typeof record.type !== "string") {
178
+ sink.quarantineRecord(recordNumber, rawLine, "invalid_json");
179
+ continue;
180
+ }
181
+ if (typeof record.sessionId === "string" && !sourceSessionId)
182
+ sourceSessionId = sink.redact(record.sessionId).trim();
183
+ if (typeof record.cwd === "string" && !cwd) cwd = sink.redact(record.cwd).trim();
184
+ const timestamp = typeof record.timestamp === "string" ? record.timestamp : undefined;
185
+
186
+ switch (record.type) {
187
+ case "summary": {
188
+ if (typeof record.summary === "string" && record.summary.trim().length > 0) {
189
+ title = sink.redact(record.summary).trim();
190
+ }
191
+ sink.counts.mapped++;
192
+ break;
193
+ }
194
+ case "user":
195
+ case "assistant": {
196
+ const message = record.message;
197
+ if (!isRecord(message)) {
198
+ sink.quarantineRecord(recordNumber, rawLine, "missing_fields");
199
+ break;
200
+ }
201
+ if (message.role !== record.type) {
202
+ sink.quarantineRecord(recordNumber, rawLine, "missing_fields");
203
+ break;
204
+ }
205
+ sink.counts.mapped++;
206
+ const isMeta = record.isMeta === true || record.isSidechain === true;
207
+ if (isMeta) break; // command/system bookkeeping rows are not conversation.
208
+ const role: "user" | "assistant" = record.type === "user" ? "user" : "assistant";
209
+ const content = message.content;
210
+ if (typeof content === "string") {
211
+ if (!content.trim()) sink.quarantineRecord(recordNumber, rawLine, "missing_fields");
212
+ sink.pushMessage(role, content, timestamp);
213
+ break;
214
+ }
215
+ if (!Array.isArray(content) || content.length === 0) {
216
+ sink.quarantineRecord(recordNumber, rawLine, "missing_fields");
217
+ break;
218
+ }
219
+ let unsupportedContent = false;
220
+ for (const block of content) {
221
+ if (!isRecord(block) || typeof block.type !== "string") {
222
+ unsupportedContent = true;
223
+ continue;
224
+ }
225
+ switch (block.type) {
226
+ case "text":
227
+ if (typeof block.text === "string") sink.pushMessage(role, block.text, timestamp);
228
+ else unsupportedContent = true;
229
+ break;
230
+ case "thinking":
231
+ // Model-internal reasoning is never imported (non-goal).
232
+ break;
233
+ case "tool_use": {
234
+ if (typeof block.name !== "string" || !block.name.trim() || !isRecord(block.input)) {
235
+ unsupportedContent = true;
236
+ break;
237
+ }
238
+ const name = sink.redact(boundEvidence(block.name));
239
+ const summary = sink.redact(boundEvidence(toolUseSummary(block.input)));
240
+ if (!sink.addEvidence(`$ ${name} ${summary}`)) unsupportedContent = true;
241
+ break;
242
+ }
243
+ case "tool_result": {
244
+ const summary = sink.redact(boundEvidence(toolResultText(block.content)));
245
+ if (!summary || !sink.addEvidence(`→ ${summary}`)) unsupportedContent = true;
246
+ break;
247
+ }
248
+ default:
249
+ if (block.type === "image" || block.type === "document") {
250
+ if (!sink.addEvidence(`[${block.type}]`)) unsupportedContent = true;
251
+ } else {
252
+ unsupportedContent = true;
253
+ }
254
+ }
255
+ }
256
+ if (unsupportedContent) sink.quarantineRecord(recordNumber, rawLine, "unknown_record");
257
+ sink.closeEvidence();
258
+ break;
259
+ }
260
+ case "system":
261
+ case "file-history-snapshot":
262
+ case "queue-operation":
263
+ // Provider hooks/bookkeeping; not conversation content.
264
+ sink.counts.mapped++;
265
+ break;
266
+ default:
267
+ sink.quarantineRecord(recordNumber, rawLine, "unknown_record");
268
+ }
269
+ }
270
+ sink.closeEvidence();
271
+
272
+ return {
273
+ conversation: {
274
+ provider: "claude",
275
+ format: "claude-code-jsonl",
276
+ ...(sourceSessionId ? { sourceSessionId } : {}),
277
+ ...(title ? { title } : {}),
278
+ ...(cwd ? { cwd } : {}),
279
+ messages: sink.messages,
280
+ },
281
+ quarantine: sink.quarantine,
282
+ counts: sink.counts,
283
+ redactionKinds: [...sink.redactionKinds].sort(),
284
+ };
285
+ }
286
+
287
+ function claudeExportMessageText(message: JsonRecord): { text: string; unsupported: boolean } {
288
+ let unsupported = false;
289
+ if (Array.isArray(message.content)) {
290
+ const parts: string[] = [];
291
+ for (const block of message.content) {
292
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
293
+ parts.push(block.text);
294
+ } else if (isRecord(block) && (block.type === "image" || block.type === "document")) {
295
+ parts.push(`[${block.type}]`);
296
+ } else {
297
+ unsupported = true;
298
+ }
299
+ }
300
+ if (parts.length > 0) return { text: parts.join("\n"), unsupported };
301
+ }
302
+ if (typeof message.text === "string") return { text: message.text, unsupported };
303
+ return { text: "", unsupported: true };
304
+ }
305
+
306
+ /** Parse a claude.ai data-export conversation (array or single object). */
307
+ export function parseClaudeExport(text: string): ClaudeParseResult {
308
+ const sink = new ParseSink();
309
+ let parsed: unknown;
310
+ try {
311
+ parsed = JSON.parse(text);
312
+ } catch {
313
+ sink.quarantineRecord(1, text, "invalid_json");
314
+ return emptyResult(sink);
315
+ }
316
+
317
+ const conversations = Array.isArray(parsed) ? parsed : [parsed];
318
+ if (conversations.length === 0 || !conversations.every(isRecord)) {
319
+ sink.quarantineRecord(1, text, "missing_fields");
320
+ return emptyResult(sink);
321
+ }
322
+ // The export's array and chat-message order are authoritative and stable.
323
+ const ordered = conversations;
324
+
325
+ let title: string | undefined;
326
+ let sourceSessionId: string | undefined;
327
+ let recordNumber = 0;
328
+ for (const conversation of ordered) {
329
+ if (typeof conversation.uuid === "string" && !sourceSessionId)
330
+ sourceSessionId = sink.redact(conversation.uuid).trim();
331
+ if (typeof conversation.name === "string" && conversation.name.trim().length > 0 && !title) {
332
+ title = sink.redact(conversation.name).trim();
333
+ }
334
+ const chatMessages = conversation.chat_messages;
335
+ if (!Array.isArray(chatMessages)) {
336
+ sink.quarantineRecord(++recordNumber, JSON.stringify(conversation), "missing_fields");
337
+ continue;
338
+ }
339
+ for (const message of chatMessages) {
340
+ recordNumber++;
341
+ if (!isRecord(message)) {
342
+ sink.quarantineRecord(recordNumber, String(message), "invalid_json");
343
+ continue;
344
+ }
345
+ const sender = message.sender;
346
+ if (sender !== "human" && sender !== "assistant") {
347
+ sink.quarantineRecord(recordNumber, JSON.stringify(message), "unknown_record");
348
+ continue;
349
+ }
350
+ sink.counts.mapped++;
351
+ const timestamp = typeof message.created_at === "string" ? message.created_at : undefined;
352
+ const mapped = claudeExportMessageText(message);
353
+ if (mapped.unsupported || !mapped.text.trim()) {
354
+ sink.quarantineRecord(recordNumber, JSON.stringify(message), "missing_fields");
355
+ }
356
+ if (mapped.text) sink.pushMessage(sender === "human" ? "user" : "assistant", mapped.text, timestamp);
357
+ }
358
+ }
359
+ sink.closeEvidence();
360
+
361
+ return {
362
+ conversation: {
363
+ provider: "claude",
364
+ format: "claude-export-json",
365
+ ...(sourceSessionId ? { sourceSessionId } : {}),
366
+ ...(title ? { title } : {}),
367
+ messages: sink.messages,
368
+ },
369
+ quarantine: sink.quarantine,
370
+ counts: sink.counts,
371
+ redactionKinds: [...sink.redactionKinds].sort(),
372
+ };
373
+ }
374
+
375
+ function emptyResult(sink: ParseSink): ClaudeParseResult {
376
+ return {
377
+ conversation: { provider: "claude", format: "claude-export-json", messages: [] },
378
+ quarantine: sink.quarantine,
379
+ counts: sink.counts,
380
+ redactionKinds: [],
381
+ };
382
+ }