@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.
- package/CHANGELOG.md +55 -16
- package/dist/types/cli/update-cli.d.ts +8 -1
- package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
- package/dist/types/config/keybindings.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +1 -1
- package/dist/types/config/models-config-schema.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/dap/client.d.ts +1 -0
- package/dist/types/lsp/client.d.ts +5 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +13 -1
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +8 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +9 -0
- package/dist/types/session/session-storage.d.ts +17 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +22 -0
- package/dist/types/session-import/command.d.ts +20 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +28 -0
- package/dist/types/session-import/types.d.ts +125 -0
- package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
- package/dist/types/slash-commands/types.d.ts +5 -0
- package/dist/types/tools/ask.d.ts +5 -0
- package/package.json +13 -7
- package/scripts/generate-sdk-operation-inventory.ts +5 -6
- package/src/cli/config-cli.ts +23 -8
- package/src/cli/update-cli.ts +265 -21
- package/src/config/atomic-yaml-patch.ts +167 -30
- package/src/config/keybindings.ts +10 -0
- package/src/config/model-profiles.ts +69 -1
- package/src/config/model-registry.ts +60 -38
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +9 -0
- package/src/config/settings.ts +13 -2
- package/src/dap/client.ts +78 -30
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/lsp/client.ts +17 -29
- package/src/modes/action-registry.ts +2 -0
- package/src/modes/components/welcome.ts +5 -0
- package/src/modes/controllers/goal-mode-controller.ts +7 -2
- package/src/modes/controllers/input-controller.ts +71 -6
- package/src/modes/controllers/plan-mode-controller.ts +8 -1
- package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +47 -19
- package/src/modes/interactive-mode.ts +54 -4
- package/src/modes/types.ts +5 -1
- package/src/runtime-mcp/transports/stdio.ts +96 -33
- package/src/sdk/broker/lifecycle.ts +6 -5
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +25 -18
- package/src/session/agent-session.ts +89 -14
- package/src/session/internal/managed-session-scope.ts +41 -6
- package/src/session/internal/managed-session-storage.ts +14 -0
- package/src/session/session-manager.ts +113 -11
- package/src/session/session-storage.ts +70 -0
- package/src/session-import/claude.ts +382 -0
- package/src/session-import/codex.ts +458 -0
- package/src/session-import/command.ts +61 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +27 -0
- package/src/session-import/redact.ts +125 -0
- package/src/session-import/service.ts +749 -0
- package/src/session-import/types.ts +163 -0
- package/src/skc-runtime/launch-worktree.ts +277 -4
- package/src/slash-commands/acp-builtins.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +100 -1
- package/src/slash-commands/types.ts +5 -0
- package/src/tools/ask.ts +5 -0
- package/src/export/html/template.css +0 -1060
- package/src/export/html/template.html +0 -47
- package/src/export/html/template.js +0 -2348
- package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
- package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
- package/vendor/insane-search/engine/tests/test_u1.py +0 -200
- package/vendor/insane-search/engine/tests/test_u4.py +0 -131
- package/vendor/insane-search/engine/tests/test_u5.py +0 -163
- package/vendor/insane-search/engine/tests/test_u7.py +0 -124
- 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
|
+
}
|