@sayknow-cli/coding-agent 0.5.1 → 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 +185 -14
- package/dist/types/cli/update-cli.d.ts +8 -1
- package/dist/types/commands/session.d.ts +7 -0
- 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/config/telegram-autostart.d.ts +9 -1
- package/dist/types/dap/client.d.ts +1 -0
- package/dist/types/lsp/client.d.ts +5 -0
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- 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/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- 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 +9 -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/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -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 +169 -4
- package/package.json +16 -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/commands/session.ts +88 -2
- 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 +72 -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/config/telegram-autostart.ts +11 -4
- package/src/dap/client.ts +78 -30
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/lsp/client.ts +17 -29
- package/src/main.ts +1 -1
- package/src/modes/action-registry.ts +2 -0
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/components/welcome.ts +5 -0
- package/src/modes/controllers/event-controller.ts +1 -1
- 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/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/modes/types.ts +5 -1
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/runtime-mcp/transports/stdio.ts +96 -33
- package/src/sdk/broker/lifecycle.ts +6 -5
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +25 -18
- package/src/session/agent-session.ts +120 -25
- 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/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/launch-worktree.ts +277 -4
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/acp-builtins.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +109 -5
- package/src/slash-commands/types.ts +5 -0
- package/src/tools/ask.ts +188 -10
- package/src/tools/eval.ts +2 -2
- 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
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex adapter (issue #3709).
|
|
3
|
+
*
|
|
4
|
+
* Parses the Codex CLI rollout transcript: one JSON object per line
|
|
5
|
+
* (`rollout-*.jsonl`, e.g. `~/.codex/sessions/2026/08/01/rollout-….jsonl`).
|
|
6
|
+
* This is Codex's own observable transcript format; the file the user passes is
|
|
7
|
+
* treated as an explicit export and is only read.
|
|
8
|
+
*
|
|
9
|
+
* Strictness contract: records that fail to parse or lack required fields are
|
|
10
|
+
* quarantined with deterministic digests — never silently dropped. Unknown
|
|
11
|
+
* record/event types are mapped when their payload shape is recognized and
|
|
12
|
+
* quarantined otherwise.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { redactImportedText } from "./redact";
|
|
17
|
+
import {
|
|
18
|
+
type ImportedConversation,
|
|
19
|
+
type ImportedMessage,
|
|
20
|
+
type ImportQuarantineRecord,
|
|
21
|
+
SESSION_IMPORT_QUARANTINE_MAX_RECORDS,
|
|
22
|
+
type SessionImportCounts,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
type JsonRecord = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
export interface CodexParseResult {
|
|
28
|
+
conversation: ImportedConversation;
|
|
29
|
+
quarantine: ImportQuarantineRecord[];
|
|
30
|
+
counts: SessionImportCounts;
|
|
31
|
+
redactionKinds: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_EVIDENCE_LINE_CHARS = 400;
|
|
35
|
+
|
|
36
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function textFromContentBlocks(content: unknown): string {
|
|
41
|
+
if (typeof content === "string") return content;
|
|
42
|
+
if (!Array.isArray(content)) return "";
|
|
43
|
+
const parts: string[] = [];
|
|
44
|
+
for (const block of content) {
|
|
45
|
+
if (!isRecord(block)) continue;
|
|
46
|
+
// Codex rollout uses output_text/input_text; accept plain text blocks too.
|
|
47
|
+
if (
|
|
48
|
+
typeof block.text === "string" &&
|
|
49
|
+
(block.type === "output_text" || block.type === "input_text" || block.type === "text")
|
|
50
|
+
) {
|
|
51
|
+
parts.push(block.text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return parts.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hasUnsupportedMessageContent(content: unknown): boolean {
|
|
58
|
+
if (typeof content === "string") return content.trim().length === 0;
|
|
59
|
+
if (!Array.isArray(content) || content.length === 0) return true;
|
|
60
|
+
let supported = 0;
|
|
61
|
+
for (const block of content) {
|
|
62
|
+
if (
|
|
63
|
+
isRecord(block) &&
|
|
64
|
+
typeof block.text === "string" &&
|
|
65
|
+
(block.type === "output_text" || block.type === "input_text" || block.type === "text")
|
|
66
|
+
) {
|
|
67
|
+
supported++;
|
|
68
|
+
} else {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return supported === 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function messageFingerprint(role: "user" | "assistant", text: string, timestamp?: string): string {
|
|
76
|
+
const parsedTimestamp = timestamp === undefined ? undefined : Date.parse(timestamp);
|
|
77
|
+
const timestampKey =
|
|
78
|
+
parsedTimestamp !== undefined && Number.isFinite(parsedTimestamp)
|
|
79
|
+
? new Date(parsedTimestamp).toISOString()
|
|
80
|
+
: (timestamp ?? "");
|
|
81
|
+
return JSON.stringify([role, text, timestampKey]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function canonicalResponseMessageCounts(text: string): Map<string, number> {
|
|
85
|
+
const counts = new Map<string, number>();
|
|
86
|
+
for (const rawLine of text.split("\n")) {
|
|
87
|
+
const line = rawLine.trim();
|
|
88
|
+
if (!line) continue;
|
|
89
|
+
let record: unknown;
|
|
90
|
+
try {
|
|
91
|
+
record = JSON.parse(line);
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(record) || record.type !== "response_item" || !isRecord(record.payload)) continue;
|
|
96
|
+
const payload = record.payload;
|
|
97
|
+
if (payload.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
|
|
98
|
+
const visibleText = textFromContentBlocks(payload.content).trim();
|
|
99
|
+
if (!visibleText) continue;
|
|
100
|
+
const timestamp = typeof record.timestamp === "string" ? record.timestamp : undefined;
|
|
101
|
+
const key = messageFingerprint(payload.role, visibleText, timestamp);
|
|
102
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
return counts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function summarizeArguments(args: unknown): string {
|
|
108
|
+
if (typeof args !== "string" || args.length === 0) return "";
|
|
109
|
+
const trimmed = args.trim();
|
|
110
|
+
try {
|
|
111
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
112
|
+
if (isRecord(parsed)) {
|
|
113
|
+
const command = parsed.command;
|
|
114
|
+
if (Array.isArray(command) && command.every(part => typeof part === "string")) {
|
|
115
|
+
return command.join(" ");
|
|
116
|
+
}
|
|
117
|
+
if (typeof command === "string") return command;
|
|
118
|
+
if (typeof parsed.cmd === "string") return parsed.cmd;
|
|
119
|
+
if (typeof parsed.path === "string") return parsed.path;
|
|
120
|
+
if (typeof parsed.file_path === "string") return parsed.file_path;
|
|
121
|
+
if (typeof parsed.input === "string") return parsed.input;
|
|
122
|
+
if (typeof parsed.query === "string") return parsed.query;
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Fall through to the bounded raw form.
|
|
126
|
+
}
|
|
127
|
+
return trimmed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function boundEvidence(text: string): string {
|
|
131
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
132
|
+
if (singleLine.length <= MAX_EVIDENCE_LINE_CHARS) return singleLine;
|
|
133
|
+
return `${singleLine.slice(0, MAX_EVIDENCE_LINE_CHARS - 1)}…`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class EvidenceCollector {
|
|
137
|
+
readonly #lines: string[] = [];
|
|
138
|
+
#bytes = 0;
|
|
139
|
+
omitted = 0;
|
|
140
|
+
|
|
141
|
+
static readonly MAX_LINES = 100;
|
|
142
|
+
static readonly MAX_BYTES = 8 * 1024;
|
|
143
|
+
|
|
144
|
+
add(line: string): boolean {
|
|
145
|
+
if (line.length === 0) return true;
|
|
146
|
+
if (
|
|
147
|
+
this.#lines.length >= EvidenceCollector.MAX_LINES ||
|
|
148
|
+
this.#bytes + line.length > EvidenceCollector.MAX_BYTES
|
|
149
|
+
) {
|
|
150
|
+
this.omitted++;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
this.#lines.push(line);
|
|
154
|
+
this.#bytes += line.length;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get lines(): string[] {
|
|
159
|
+
return this.#lines;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Parse a Codex rollout JSONL transcript into the provider-neutral IR. */
|
|
164
|
+
export function parseCodexRollout(text: string): CodexParseResult {
|
|
165
|
+
const messages: ImportedMessage[] = [];
|
|
166
|
+
const quarantine: ImportQuarantineRecord[] = [];
|
|
167
|
+
const counts: SessionImportCounts = { mapped: 0, quarantined: 0, redacted: 0, omitted: 0 };
|
|
168
|
+
const redactionKinds = new Set<string>();
|
|
169
|
+
const redact = (input: string): string => {
|
|
170
|
+
const result = redactImportedText(input);
|
|
171
|
+
counts.redacted += result.redacted;
|
|
172
|
+
for (const kind of result.kinds) redactionKinds.add(kind);
|
|
173
|
+
return result.value;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let sourceSessionId: string | undefined;
|
|
177
|
+
let title: string | undefined;
|
|
178
|
+
let cwd: string | undefined;
|
|
179
|
+
let current: ImportedMessage | undefined;
|
|
180
|
+
const unmatchedCanonicalMessages = canonicalResponseMessageCounts(text);
|
|
181
|
+
const anchoredCanonicalMessages = new Map<string, number>();
|
|
182
|
+
const emittedCanonicalMessages = new Map<string, number>();
|
|
183
|
+
const seenEventMessages = new Set<string>();
|
|
184
|
+
let evidence = new EvidenceCollector();
|
|
185
|
+
|
|
186
|
+
const closeEvidence = (): void => {
|
|
187
|
+
counts.omitted += evidence.omitted;
|
|
188
|
+
if (evidence.lines.length > 0) {
|
|
189
|
+
if (current?.role !== "assistant") {
|
|
190
|
+
const toolOnly: ImportedMessage = { role: "assistant", text: "", toolEvidence: [...evidence.lines] };
|
|
191
|
+
current = toolOnly;
|
|
192
|
+
messages.push(toolOnly);
|
|
193
|
+
} else {
|
|
194
|
+
current.toolEvidence = [...(current.toolEvidence ?? []), ...evidence.lines];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
evidence = new EvidenceCollector();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Assemble visible text into the running conversation. `mapped` is counted by the caller per recognized record. */
|
|
201
|
+
const pushMessage = (role: "user" | "assistant", rawText: string, timestamp?: string): void => {
|
|
202
|
+
const sanitized = redact(rawText).trim();
|
|
203
|
+
if (role === "user") {
|
|
204
|
+
closeEvidence();
|
|
205
|
+
if (!sanitized) {
|
|
206
|
+
current = undefined;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
current = { role, text: sanitized, ...(timestamp ? { timestamp } : {}) };
|
|
210
|
+
messages.push(current);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!sanitized) {
|
|
214
|
+
closeEvidence();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (current?.role === "assistant") {
|
|
218
|
+
current.text = current.text ? `${current.text}\n\n${sanitized}` : sanitized;
|
|
219
|
+
} else {
|
|
220
|
+
current = { role, text: sanitized, ...(timestamp ? { timestamp } : {}) };
|
|
221
|
+
messages.push(current);
|
|
222
|
+
}
|
|
223
|
+
closeEvidence();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const quarantineRecord = (record: number, line: string, reason: ImportQuarantineRecord["reason"]): void => {
|
|
227
|
+
if (quarantine.length < SESSION_IMPORT_QUARANTINE_MAX_RECORDS) {
|
|
228
|
+
quarantine.push({
|
|
229
|
+
record,
|
|
230
|
+
byteLength: Buffer.byteLength(line, "utf8"),
|
|
231
|
+
sha256: sha256Hex(line),
|
|
232
|
+
reason,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
counts.quarantined++;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const lines = text.split("\n");
|
|
239
|
+
for (let index = 0; index < lines.length; index++) {
|
|
240
|
+
const rawLine = lines[index]!;
|
|
241
|
+
const line = rawLine.trim();
|
|
242
|
+
if (line.length === 0) continue;
|
|
243
|
+
const recordNumber = index + 1;
|
|
244
|
+
let record: unknown;
|
|
245
|
+
try {
|
|
246
|
+
record = JSON.parse(line);
|
|
247
|
+
} catch {
|
|
248
|
+
quarantineRecord(recordNumber, rawLine, "invalid_json");
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!isRecord(record) || typeof record.type !== "string") {
|
|
252
|
+
quarantineRecord(recordNumber, rawLine, "invalid_json");
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const timestamp = typeof record.timestamp === "string" ? record.timestamp : undefined;
|
|
256
|
+
|
|
257
|
+
switch (record.type) {
|
|
258
|
+
case "session_meta": {
|
|
259
|
+
const payload = record.payload;
|
|
260
|
+
if (!isRecord(payload)) {
|
|
261
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
if (typeof payload.id === "string") sourceSessionId = redact(payload.id).trim();
|
|
265
|
+
if (typeof payload.cwd === "string") cwd = redact(payload.cwd).trim();
|
|
266
|
+
if (typeof payload.title === "string" && payload.title.trim().length > 0) {
|
|
267
|
+
title = redact(payload.title).trim();
|
|
268
|
+
}
|
|
269
|
+
counts.mapped++;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "response_item": {
|
|
273
|
+
const payload = record.payload;
|
|
274
|
+
if (!isRecord(payload) || typeof payload.type !== "string") {
|
|
275
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
switch (payload.type) {
|
|
279
|
+
case "message": {
|
|
280
|
+
const role = payload.role;
|
|
281
|
+
if (role !== "user" && role !== "assistant") {
|
|
282
|
+
// developer/system prompts are provider framing, not conversation.
|
|
283
|
+
counts.mapped++;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
counts.mapped++;
|
|
287
|
+
const visibleText = textFromContentBlocks(payload.content);
|
|
288
|
+
const fingerprint = messageFingerprint(role, visibleText.trim(), timestamp);
|
|
289
|
+
const anchoredMatches = anchoredCanonicalMessages.get(fingerprint) ?? 0;
|
|
290
|
+
if (anchoredMatches > 0) {
|
|
291
|
+
anchoredCanonicalMessages.set(fingerprint, anchoredMatches - 1);
|
|
292
|
+
counts.omitted++;
|
|
293
|
+
} else {
|
|
294
|
+
pushMessage(role, visibleText, timestamp);
|
|
295
|
+
emittedCanonicalMessages.set(fingerprint, (emittedCanonicalMessages.get(fingerprint) ?? 0) + 1);
|
|
296
|
+
}
|
|
297
|
+
if (hasUnsupportedMessageContent(payload.content)) {
|
|
298
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case "reasoning": {
|
|
303
|
+
// Model-internal reasoning is never imported (non-goal).
|
|
304
|
+
counts.mapped++;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case "function_call": {
|
|
308
|
+
const name = redact(boundEvidence(typeof payload.name === "string" ? payload.name : "tool"));
|
|
309
|
+
if (typeof payload.arguments !== "string") {
|
|
310
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
const summary = redact(boundEvidence(summarizeArguments(payload.arguments)));
|
|
314
|
+
if (!evidence.add(summary ? `$ ${name} ${summary}` : `$ ${name}`)) {
|
|
315
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
316
|
+
}
|
|
317
|
+
counts.mapped++;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case "function_call_output":
|
|
321
|
+
case "custom_tool_call_output": {
|
|
322
|
+
const output = isRecord(payload.output)
|
|
323
|
+
? JSON.stringify(payload.output)
|
|
324
|
+
: typeof payload.output === "string"
|
|
325
|
+
? payload.output
|
|
326
|
+
: undefined;
|
|
327
|
+
if (output === undefined) {
|
|
328
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
const summary = redact(boundEvidence(output));
|
|
332
|
+
if (!evidence.add(summary ? `→ ${summary}` : "→ [empty output]")) {
|
|
333
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
334
|
+
}
|
|
335
|
+
counts.mapped++;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case "custom_tool_call": {
|
|
339
|
+
const name = redact(boundEvidence(typeof payload.name === "string" ? payload.name : "tool"));
|
|
340
|
+
if (typeof payload.input !== "string") {
|
|
341
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
const summary = redact(boundEvidence(payload.input));
|
|
345
|
+
if (!evidence.add(summary ? `$ ${name} ${summary}` : `$ ${name}`)) {
|
|
346
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
347
|
+
}
|
|
348
|
+
counts.mapped++;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case "local_shell_call": {
|
|
352
|
+
const action = isRecord(payload.action) ? payload.action : undefined;
|
|
353
|
+
if (
|
|
354
|
+
!action ||
|
|
355
|
+
!Array.isArray(action.command) ||
|
|
356
|
+
!action.command.every(part => typeof part === "string")
|
|
357
|
+
) {
|
|
358
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
const summary = redact(boundEvidence(action.command.join(" ")));
|
|
362
|
+
if (!evidence.add(summary ? `$ ${summary}` : "$ shell")) {
|
|
363
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
364
|
+
}
|
|
365
|
+
counts.mapped++;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "web_search_call": {
|
|
369
|
+
if (!evidence.add("[web search]")) quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
370
|
+
counts.mapped++;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case "event_msg": {
|
|
379
|
+
const payload = record.payload;
|
|
380
|
+
if (!isRecord(payload) || typeof payload.type !== "string") {
|
|
381
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
switch (payload.type) {
|
|
385
|
+
case "user_message":
|
|
386
|
+
case "agent_message": {
|
|
387
|
+
if (typeof payload.message !== "string" || !payload.message.trim()) {
|
|
388
|
+
quarantineRecord(recordNumber, rawLine, "missing_fields");
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
counts.mapped++;
|
|
392
|
+
const role = payload.type === "user_message" ? "user" : "assistant";
|
|
393
|
+
const fingerprint = messageFingerprint(role, payload.message.trim(), timestamp);
|
|
394
|
+
if (seenEventMessages.has(fingerprint)) {
|
|
395
|
+
counts.omitted++;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
seenEventMessages.add(fingerprint);
|
|
399
|
+
const canonicalMatches = unmatchedCanonicalMessages.get(fingerprint) ?? 0;
|
|
400
|
+
const emittedMatches = emittedCanonicalMessages.get(fingerprint) ?? 0;
|
|
401
|
+
if (canonicalMatches > 0 && emittedMatches > 0) {
|
|
402
|
+
unmatchedCanonicalMessages.set(fingerprint, canonicalMatches - 1);
|
|
403
|
+
emittedCanonicalMessages.set(fingerprint, emittedMatches - 1);
|
|
404
|
+
counts.omitted++;
|
|
405
|
+
} else if (canonicalMatches > 0) {
|
|
406
|
+
unmatchedCanonicalMessages.set(fingerprint, canonicalMatches - 1);
|
|
407
|
+
anchoredCanonicalMessages.set(fingerprint, (anchoredCanonicalMessages.get(fingerprint) ?? 0) + 1);
|
|
408
|
+
pushMessage(role, payload.message, timestamp);
|
|
409
|
+
} else {
|
|
410
|
+
pushMessage(role, payload.message, timestamp);
|
|
411
|
+
}
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case "agent_reasoning":
|
|
415
|
+
case "agent_reasoning_delta":
|
|
416
|
+
case "agent_reasoning_raw_content":
|
|
417
|
+
case "agent_reasoning_raw_content_delta":
|
|
418
|
+
case "agent_reasoning_section_break":
|
|
419
|
+
case "token_count":
|
|
420
|
+
// Reasoning/token telemetry is model-internal or display-only.
|
|
421
|
+
counts.mapped++;
|
|
422
|
+
break;
|
|
423
|
+
default:
|
|
424
|
+
// Recognized event envelope, unmapped payload variant.
|
|
425
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case "turn_context":
|
|
430
|
+
case "world_state":
|
|
431
|
+
// Provider environment framing; not conversation content.
|
|
432
|
+
counts.mapped++;
|
|
433
|
+
break;
|
|
434
|
+
default:
|
|
435
|
+
quarantineRecord(recordNumber, rawLine, "unknown_record");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
closeEvidence();
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
conversation: {
|
|
442
|
+
provider: "codex",
|
|
443
|
+
format: "codex-rollout-jsonl",
|
|
444
|
+
...(sourceSessionId ? { sourceSessionId } : {}),
|
|
445
|
+
...(title ? { title } : {}),
|
|
446
|
+
...(cwd ? { cwd } : {}),
|
|
447
|
+
messages,
|
|
448
|
+
},
|
|
449
|
+
quarantine,
|
|
450
|
+
counts,
|
|
451
|
+
redactionKinds: [...redactionKinds].sort(),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function sha256Hex(text: string): string {
|
|
456
|
+
// Deterministic per-record digest for quarantine evidence (content never stored).
|
|
457
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
458
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** `/import-session <transcript-file> [--provider codex|claude]` command flow. */
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseCommandArgs } from "../utils/command-args";
|
|
4
|
+
import { formatSessionImportError, importExternalSession } from "./service";
|
|
5
|
+
import type { SessionImportCompleted, SessionImportProviderId } from "./types";
|
|
6
|
+
|
|
7
|
+
export const IMPORT_SESSION_USAGE = "Usage: /import-session <transcript-file> [--provider codex|claude]";
|
|
8
|
+
|
|
9
|
+
export type ParsedImportSessionArgs =
|
|
10
|
+
| { kind: "ok"; sourcePath: string; provider?: SessionImportProviderId }
|
|
11
|
+
| { kind: "error"; message: string };
|
|
12
|
+
|
|
13
|
+
export function parseImportSessionArgs(args: string): ParsedImportSessionArgs {
|
|
14
|
+
const tokens = parseCommandArgs(args);
|
|
15
|
+
let provider: SessionImportProviderId | undefined;
|
|
16
|
+
const positional: string[] = [];
|
|
17
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
18
|
+
const token = tokens[index]!;
|
|
19
|
+
if (token === "--provider" || token.startsWith("--provider=")) {
|
|
20
|
+
const value = token === "--provider" ? tokens[++index] : token.slice("--provider=".length);
|
|
21
|
+
if (value !== "codex" && value !== "claude")
|
|
22
|
+
return { kind: "error", message: `--provider must be codex or claude.\n${IMPORT_SESSION_USAGE}` };
|
|
23
|
+
if (provider && provider !== value)
|
|
24
|
+
return { kind: "error", message: `Conflicting --provider values.\n${IMPORT_SESSION_USAGE}` };
|
|
25
|
+
provider = value;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (token.startsWith("--"))
|
|
29
|
+
return { kind: "error", message: `Unknown option: ${token}\n${IMPORT_SESSION_USAGE}` };
|
|
30
|
+
positional.push(token);
|
|
31
|
+
}
|
|
32
|
+
if (positional.length === 0) return { kind: "error", message: IMPORT_SESSION_USAGE };
|
|
33
|
+
if (positional.length > 1)
|
|
34
|
+
return {
|
|
35
|
+
kind: "error",
|
|
36
|
+
message: `Expected exactly one transcript file; got ${positional.length} arguments.\n${IMPORT_SESSION_USAGE}`,
|
|
37
|
+
};
|
|
38
|
+
return { kind: "ok", sourcePath: positional[0]!, ...(provider ? { provider } : {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SessionImportCommandOutcome =
|
|
42
|
+
| { kind: "imported"; result: SessionImportCompleted }
|
|
43
|
+
| { kind: "error"; message: string };
|
|
44
|
+
|
|
45
|
+
/** Imports a new session only. The caller owns the current-session switch/resume seam. */
|
|
46
|
+
export async function runSessionImportCommand(args: string, cwd: string): Promise<SessionImportCommandOutcome> {
|
|
47
|
+
const parsed = parseImportSessionArgs(args);
|
|
48
|
+
if (parsed.kind === "error") return parsed;
|
|
49
|
+
try {
|
|
50
|
+
return {
|
|
51
|
+
kind: "imported",
|
|
52
|
+
result: await importExternalSession({
|
|
53
|
+
sourcePath: path.resolve(cwd, parsed.sourcePath),
|
|
54
|
+
...(parsed.provider ? { provider: parsed.provider } : {}),
|
|
55
|
+
cwd,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { kind: "error", message: formatSessionImportError(error) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic source-format detection (issue #3709).
|
|
3
|
+
*
|
|
4
|
+
* Detection inspects only the already-read source bytes: a bounded leading
|
|
5
|
+
* sample decides JSONL-vs-JSON and provider by envelope markers. An explicit
|
|
6
|
+
* `--provider` selection narrows candidates; a sample that matches a different
|
|
7
|
+
* provider than requested fails closed with `format_mismatch`. Detection never
|
|
8
|
+
* guesses: a sample matching no supported envelope fails with
|
|
9
|
+
* `unsupported_format` rather than falling back to a speculative parse.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { SessionImportError, type SessionImportFormatId, type SessionImportProviderId } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Leading bytes consulted for envelope sniffing. */
|
|
15
|
+
const DETECTION_SAMPLE_BYTES = 256 * 1024;
|
|
16
|
+
|
|
17
|
+
type JsonRecord = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionImportDetection {
|
|
24
|
+
provider: SessionImportProviderId;
|
|
25
|
+
format: SessionImportFormatId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function detectJsonlFormat(text: string): SessionImportDetection | undefined {
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
let sawCodex = false;
|
|
31
|
+
let sawClaude = false;
|
|
32
|
+
let examined = 0;
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (trimmed.length === 0) continue;
|
|
36
|
+
let value: unknown;
|
|
37
|
+
try {
|
|
38
|
+
value = JSON.parse(trimmed);
|
|
39
|
+
} catch {
|
|
40
|
+
// A malformed early line does not decide detection; the adapter reports it.
|
|
41
|
+
if (examined >= 8) break;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!isRecord(value)) continue;
|
|
45
|
+
examined++;
|
|
46
|
+
if (value.type === "session_meta" && isRecord(value.payload))
|
|
47
|
+
return { provider: "codex", format: "codex-rollout-jsonl" };
|
|
48
|
+
if (value.type === "response_item" || value.type === "turn_context" || value.type === "world_state")
|
|
49
|
+
sawCodex = true;
|
|
50
|
+
if (value.type === "event_msg") sawCodex = true;
|
|
51
|
+
if (
|
|
52
|
+
(value.type === "user" || value.type === "assistant" || value.type === "summary" || value.type === "system") &&
|
|
53
|
+
(value.sessionId !== undefined ||
|
|
54
|
+
value.parentUuid !== undefined ||
|
|
55
|
+
value.leafUuid !== undefined ||
|
|
56
|
+
isRecord(value.message))
|
|
57
|
+
) {
|
|
58
|
+
sawClaude = true;
|
|
59
|
+
}
|
|
60
|
+
if (sawCodex && sawClaude) break;
|
|
61
|
+
if (examined >= 32) break;
|
|
62
|
+
}
|
|
63
|
+
if (sawCodex && !sawClaude) return { provider: "codex", format: "codex-rollout-jsonl" };
|
|
64
|
+
if (sawClaude && !sawCodex) return { provider: "claude", format: "claude-code-jsonl" };
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function looksLikeClaudeExport(value: unknown): boolean {
|
|
69
|
+
const conversations = Array.isArray(value) ? value : [value];
|
|
70
|
+
if (conversations.length === 0 || !conversations.every(isRecord)) return false;
|
|
71
|
+
return conversations.every(conversation => {
|
|
72
|
+
if (!Array.isArray(conversation.chat_messages)) return false;
|
|
73
|
+
// `uuid`+`chat_messages` is the claude.ai export envelope; a bare
|
|
74
|
+
// chat_messages array without any sender markers is not enough.
|
|
75
|
+
const senders = conversation.chat_messages.slice(0, 8);
|
|
76
|
+
return (
|
|
77
|
+
typeof conversation.uuid === "string" ||
|
|
78
|
+
senders.some(message => isRecord(message) && (message.sender === "human" || message.sender === "assistant"))
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect the provider/format of an explicit transcript file.
|
|
85
|
+
*
|
|
86
|
+
* @param text Full source text (already size-bounded by the caller).
|
|
87
|
+
* @param requestedProvider Explicit provider selection; undefined = auto.
|
|
88
|
+
*/
|
|
89
|
+
export function detectSessionImportFormat(
|
|
90
|
+
text: string,
|
|
91
|
+
requestedProvider?: SessionImportProviderId,
|
|
92
|
+
): SessionImportDetection {
|
|
93
|
+
const sample = text.slice(0, DETECTION_SAMPLE_BYTES);
|
|
94
|
+
let detected: SessionImportDetection | undefined;
|
|
95
|
+
|
|
96
|
+
const trimmed = sample.trimStart();
|
|
97
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
98
|
+
// Ambiguous between "JSON document" and "JSONL": try whole-document parse
|
|
99
|
+
// only against the sample when the whole text is the sample; otherwise a
|
|
100
|
+
// leading `{`/`[` with newline-delimited records falls through to JSONL.
|
|
101
|
+
let documentValue: unknown;
|
|
102
|
+
let isJsonDocument = false;
|
|
103
|
+
try {
|
|
104
|
+
documentValue = JSON.parse(text);
|
|
105
|
+
isJsonDocument = true;
|
|
106
|
+
} catch {
|
|
107
|
+
isJsonDocument = false;
|
|
108
|
+
}
|
|
109
|
+
if (isJsonDocument && looksLikeClaudeExport(documentValue)) {
|
|
110
|
+
detected = { provider: "claude", format: "claude-export-json" };
|
|
111
|
+
}
|
|
112
|
+
// A whole-document JSON parse also succeeds for a single-line JSONL
|
|
113
|
+
// transcript, so a non-export document falls through to JSONL detection
|
|
114
|
+
// before anything is declared unsupported.
|
|
115
|
+
}
|
|
116
|
+
if (!detected) detected = detectJsonlFormat(sample);
|
|
117
|
+
|
|
118
|
+
if (!detected) {
|
|
119
|
+
throw new SessionImportError(
|
|
120
|
+
"unsupported_format",
|
|
121
|
+
"detect",
|
|
122
|
+
"Could not detect a supported Codex or Claude transcript format. Pass --provider codex|claude to declare the expected provider, or export the session again.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (requestedProvider && detected.provider !== requestedProvider) {
|
|
126
|
+
throw new SessionImportError(
|
|
127
|
+
"format_mismatch",
|
|
128
|
+
"detect",
|
|
129
|
+
`The source looks like a ${detected.provider} transcript (${detected.format}) but --provider ${requestedProvider} was requested.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return detected;
|
|
133
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { type ClaudeParseResult, parseClaudeCodeTranscript, parseClaudeExport } from "./claude";
|
|
2
|
+
export { type CodexParseResult, parseCodexRollout } from "./codex";
|
|
3
|
+
export {
|
|
4
|
+
IMPORT_SESSION_USAGE,
|
|
5
|
+
type ParsedImportSessionArgs,
|
|
6
|
+
parseImportSessionArgs,
|
|
7
|
+
runSessionImportCommand,
|
|
8
|
+
type SessionImportCommandOutcome,
|
|
9
|
+
} from "./command";
|
|
10
|
+
export { detectSessionImportFormat, type SessionImportDetection } from "./detect";
|
|
11
|
+
export { IMPORT_SANITIZER_VERSION, redactImportedText, sanitizeImportedString } from "./redact";
|
|
12
|
+
export {
|
|
13
|
+
formatSessionImportError,
|
|
14
|
+
formatSessionImportSummary,
|
|
15
|
+
IMPORT_CONVERTER_VERSION,
|
|
16
|
+
importExternalSession,
|
|
17
|
+
materializeSessionImport,
|
|
18
|
+
prepareSessionImport,
|
|
19
|
+
SESSION_IMPORT_COMPLETION_CUSTOM_TYPE,
|
|
20
|
+
SESSION_IMPORT_CONTEXT_CUSTOM_TYPE,
|
|
21
|
+
SESSION_IMPORT_PROVENANCE_CUSTOM_TYPE,
|
|
22
|
+
SESSION_IMPORT_QUARANTINE_CUSTOM_TYPE,
|
|
23
|
+
SESSION_IMPORT_SOURCE_MAX_BYTES,
|
|
24
|
+
type SessionImportRequest,
|
|
25
|
+
type SessionImportTestProbe,
|
|
26
|
+
} from "./service";
|
|
27
|
+
export * from "./types";
|