jeo-code 0.9.10 → 0.9.12
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 +30 -0
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/package.json +2 -1
- package/src/agent/gjc-session-import.ts +1632 -0
- package/src/agent/session.ts +48 -7
- package/src/ai/model-cache.ts +57 -20
- package/src/ai/model-catalog.ts +93 -36
- package/src/ai/model-discovery.ts +31 -4
- package/src/commands/launch/input.ts +38 -6
- package/src/commands/launch/jobs-slash.ts +66 -0
- package/src/commands/launch/session-slash.ts +146 -2
- package/src/commands/launch.ts +29 -4
- package/src/tui/components/input-box.ts +47 -10
- package/src/tui/components/slash.ts +3 -1
- package/src/tui/components/welcome.ts +2 -2
- package/src/types/playwriter.d.ts +15 -0
- package/src/util/file-attachment.ts +26 -6
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as fsSync from "node:fs";
|
|
4
|
+
import type { Dirent } from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
findImportedSessionByProvenance,
|
|
9
|
+
newSessionId,
|
|
10
|
+
sessionPath,
|
|
11
|
+
type SessionHeader,
|
|
12
|
+
} from "./session";
|
|
13
|
+
import type { Message, ToolResultRecord, ToolUseRecord } from "../ai/types";
|
|
14
|
+
|
|
15
|
+
export interface GjcImportEnv {
|
|
16
|
+
[key: string]: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GjcSessionLeaf {
|
|
20
|
+
rootPath: string;
|
|
21
|
+
sourcePath: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
leafId: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
selected: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ListGjcSessionsOptions {
|
|
29
|
+
/** Explicitly injected roots for tests; highest precedence. */
|
|
30
|
+
roots?: string[];
|
|
31
|
+
/** Include all source session leaves regardless of `cwd` filter. */
|
|
32
|
+
anyCwd?: boolean;
|
|
33
|
+
/** Working directory for `cwd` filtering. */
|
|
34
|
+
cwd?: string;
|
|
35
|
+
/** Environment override for tests. */
|
|
36
|
+
env?: GjcImportEnv;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolveGjcSessionOptions extends ListGjcSessionsOptions {}
|
|
40
|
+
|
|
41
|
+
export interface ImportGjcSessionOptions {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
leafId?: string;
|
|
44
|
+
roots?: string[];
|
|
45
|
+
cwd?: string;
|
|
46
|
+
anyCwd?: boolean;
|
|
47
|
+
env?: GjcImportEnv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GjcSessionRefResolution =
|
|
51
|
+
| { kind: "ok"; match: GjcSessionLeaf }
|
|
52
|
+
| { kind: "ambiguous-session"; matches: string[] }
|
|
53
|
+
| { kind: "ambiguous-leaf"; matches: string[] }
|
|
54
|
+
| { kind: "not-found" };
|
|
55
|
+
|
|
56
|
+
export interface ImportGjcSessionResult {
|
|
57
|
+
sessionId: string;
|
|
58
|
+
path: string;
|
|
59
|
+
sourceSessionId: string;
|
|
60
|
+
sourceLeafId: string;
|
|
61
|
+
sourceSha256: string;
|
|
62
|
+
reused: boolean;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface JsonRecord {
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ParsedSourceHeader {
|
|
71
|
+
sessionId: string;
|
|
72
|
+
version: number;
|
|
73
|
+
cwd: string;
|
|
74
|
+
title?: string;
|
|
75
|
+
selectedLeafId?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface SourceNode {
|
|
79
|
+
id: string;
|
|
80
|
+
type: string;
|
|
81
|
+
parentId?: string;
|
|
82
|
+
raw: JsonRecord;
|
|
83
|
+
sourceLine: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ParsedCompaction {
|
|
87
|
+
firstKeptEntryId: string;
|
|
88
|
+
summary?: string;
|
|
89
|
+
sourceLine: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ParsedModelChange {
|
|
93
|
+
role: "default" | "omitted";
|
|
94
|
+
model?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ParsedSourceSession {
|
|
98
|
+
sourcePath: string;
|
|
99
|
+
sourceBytes: Buffer;
|
|
100
|
+
header: ParsedSourceHeader;
|
|
101
|
+
nodes: Map<string, SourceNode>;
|
|
102
|
+
leaves: string[];
|
|
103
|
+
compactions: ParsedCompaction[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ParsedEntryParts {
|
|
107
|
+
sourceRole: string;
|
|
108
|
+
content: string;
|
|
109
|
+
reasoning: string;
|
|
110
|
+
images: Array<{ mediaType: string; data: string }>;
|
|
111
|
+
toolCalls: ToolUseRecord[];
|
|
112
|
+
toolResults: Array<{ id: string; output: string; isError: boolean }>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface ImageAttachmentLike {
|
|
116
|
+
mediaType: string;
|
|
117
|
+
data: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SUPPORTED_VERSIONS = [5] as const;
|
|
121
|
+
|
|
122
|
+
function isObject(value: unknown): value is JsonRecord {
|
|
123
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function asString(value: unknown): string | undefined {
|
|
127
|
+
return typeof value === "string" ? value : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function asTrimmedString(value: unknown): string | undefined {
|
|
131
|
+
if (typeof value !== "string") return undefined;
|
|
132
|
+
const trimmed = value.trim();
|
|
133
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
137
|
+
return typeof value === "boolean" ? value : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isLikelyBase64Payload(data: string): boolean {
|
|
141
|
+
const compact = data.replace(/\s+/g, "");
|
|
142
|
+
if (compact.length === 0) return false;
|
|
143
|
+
if (compact.length % 4 !== 0) return false;
|
|
144
|
+
return /^[A-Za-z0-9+/]*={0,2}$/.test(compact);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function exists(filePath: string): Promise<boolean> {
|
|
148
|
+
return fs
|
|
149
|
+
.stat(filePath)
|
|
150
|
+
.then(() => true)
|
|
151
|
+
.catch(() => false);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
function normalizeRoots(roots: string[]): string[] {
|
|
156
|
+
const seen = new Set<string>();
|
|
157
|
+
const normalized: string[] = [];
|
|
158
|
+
for (const root of roots) {
|
|
159
|
+
const resolved = path.resolve(root);
|
|
160
|
+
if (seen.has(resolved)) continue;
|
|
161
|
+
seen.add(resolved);
|
|
162
|
+
normalized.push(resolved);
|
|
163
|
+
}
|
|
164
|
+
return normalized;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveGjcCodingAgentRoots(
|
|
168
|
+
opts: Pick<ListGjcSessionsOptions, "roots" | "env"> = {},
|
|
169
|
+
): string[] {
|
|
170
|
+
const env = opts.env ?? process.env;
|
|
171
|
+
|
|
172
|
+
if (opts.roots && opts.roots.length > 0) {
|
|
173
|
+
return normalizeRoots(opts.roots);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const agentDir = env.GJC_CODING_AGENT_DIR || env.PI_CODING_AGENT_DIR;
|
|
177
|
+
if (agentDir) return [path.resolve(agentDir, "sessions")];
|
|
178
|
+
|
|
179
|
+
const home = env.HOME;
|
|
180
|
+
if (!home) return [];
|
|
181
|
+
|
|
182
|
+
const configName = env.GJC_CONFIG_DIR || env.PI_CONFIG_DIR || ".gjc";
|
|
183
|
+
const xdgDataHome = env.XDG_DATA_HOME;
|
|
184
|
+
if (xdgDataHome && fsSync.existsSync(path.join(xdgDataHome, "gjc"))) {
|
|
185
|
+
return [path.join(xdgDataHome, "gjc", "sessions")];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [path.join(home, configName, "agent", "sessions")];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface PatchOp {
|
|
192
|
+
path: string;
|
|
193
|
+
value: unknown;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readPatchOperations(raw: unknown, sourceFile: string, sourceLine: number): PatchOp[] {
|
|
197
|
+
if (isObject(raw) && "op" in raw && "path" in raw) {
|
|
198
|
+
const op = asTrimmedString(raw.op);
|
|
199
|
+
const patchPath = asTrimmedString(raw.path);
|
|
200
|
+
if (!op || !patchPath) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Unsupported patch operation in ${path.basename(sourceFile)}:${sourceLine}: missing op/path`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (op !== "replace" && op !== "add") {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Unsupported patch operation '${op}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return [{ path: patchPath, value: (raw as JsonRecord).value }];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (Array.isArray(raw)) {
|
|
214
|
+
return raw.map((entry, index) => {
|
|
215
|
+
if (!isObject(entry)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Malformed patch entry #${index} in ${path.basename(sourceFile)}:${sourceLine}: expected object`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const op = asTrimmedString(entry.op);
|
|
221
|
+
const patchPath = asTrimmedString(entry.path);
|
|
222
|
+
if (!op || !patchPath) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Unsupported patch entry #${index} in ${path.basename(sourceFile)}:${sourceLine}: missing op/path`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (op !== "replace" && op !== "add") {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Unsupported patch operation '${op}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return { path: patchPath, value: entry.value };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isObject(raw) && "patch" in raw) {
|
|
237
|
+
return readPatchOperations((raw as JsonRecord).patch, sourceFile, sourceLine);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (isObject(raw)) {
|
|
241
|
+
return Object.entries(raw).map(([key, value]) => ({ path: key.startsWith("/") ? key : `/${key}`, value }));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(`Unsupported patch payload in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function requireHeaderVersion(version: unknown, sourceFile: string): number {
|
|
248
|
+
const numeric =
|
|
249
|
+
typeof version === "number" ? version : typeof version === "string" ? Number(version) : Number.NaN;
|
|
250
|
+
|
|
251
|
+
if (!Number.isInteger(numeric)) {
|
|
252
|
+
throw new Error(`Malformed session header version in ${path.basename(sourceFile)}.`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const parsed = numeric;
|
|
256
|
+
const expected = SUPPORTED_VERSIONS[0];
|
|
257
|
+
if (parsed !== expected) {
|
|
258
|
+
if (parsed < expected) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Unsupported GJC session version ${parsed} (too old) in ${path.basename(sourceFile)}.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Unsupported GJC session version ${parsed} (future) in ${path.basename(sourceFile)}.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return parsed;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyHeaderPatch(
|
|
271
|
+
header: ParsedSourceHeader,
|
|
272
|
+
rawPatch: unknown,
|
|
273
|
+
sourceFile: string,
|
|
274
|
+
sourceLine: number,
|
|
275
|
+
): void {
|
|
276
|
+
const ops = readPatchOperations(rawPatch, sourceFile, sourceLine);
|
|
277
|
+
for (const op of ops) {
|
|
278
|
+
if (!op.path.startsWith("/")) {
|
|
279
|
+
throw new Error(`Unsupported header patch target '${op.path}' in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (op.path === "/selectedLeafId" || op.path === "/selected_leaf_id") {
|
|
283
|
+
const next = asTrimmedString(op.value);
|
|
284
|
+
if (!next) {
|
|
285
|
+
throw new Error(`Unsupported header patch payload for ${op.path} in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
286
|
+
}
|
|
287
|
+
header.selectedLeafId = next;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (op.path === "/title") {
|
|
292
|
+
if (op.value !== undefined && typeof op.value !== "string") {
|
|
293
|
+
throw new Error(`Unsupported header patch payload for ${op.path} in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
294
|
+
}
|
|
295
|
+
header.title = asString(op.value)?.trim() || undefined;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (op.path === "/cwd") {
|
|
300
|
+
const next = asTrimmedString(op.value);
|
|
301
|
+
if (!next) {
|
|
302
|
+
throw new Error(`Unsupported header patch payload for ${op.path} in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
303
|
+
}
|
|
304
|
+
header.cwd = next;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
throw new Error(`Unsupported header patch target '${op.path}' in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function applyEntryPatch(
|
|
313
|
+
raw: JsonRecord,
|
|
314
|
+
rawPatch: unknown,
|
|
315
|
+
sourceFile: string,
|
|
316
|
+
sourceLine: number,
|
|
317
|
+
): void {
|
|
318
|
+
if (isObject(rawPatch) && "message" in rawPatch) {
|
|
319
|
+
const message = rawPatch.message;
|
|
320
|
+
if (!isObject(message) || typeof message.role !== "string" || !Object.prototype.hasOwnProperty.call(message, "content")) {
|
|
321
|
+
throw new Error(`Unsupported entry patch message in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
322
|
+
}
|
|
323
|
+
raw.role = message.role;
|
|
324
|
+
raw.content = message.content;
|
|
325
|
+
delete raw.blocks;
|
|
326
|
+
delete raw.reasoning;
|
|
327
|
+
delete raw.images;
|
|
328
|
+
for (const key of ["toolCallId", "toolName", "isError", "is_error", "output"]) {
|
|
329
|
+
if (Object.prototype.hasOwnProperty.call(message, key)) raw[key] = message[key];
|
|
330
|
+
else delete raw[key];
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const ops = readPatchOperations(rawPatch, sourceFile, sourceLine);
|
|
335
|
+
for (const op of ops) {
|
|
336
|
+
if (!op.path.startsWith("/")) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Unsupported entry patch target '${op.path}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
switch (op.path) {
|
|
343
|
+
case "/content": {
|
|
344
|
+
if (typeof op.value !== "string" && !Array.isArray(op.value)) {
|
|
345
|
+
throw new Error(`Unsupported value for /content in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
346
|
+
}
|
|
347
|
+
raw.content = op.value;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "/blocks": {
|
|
351
|
+
if (!Array.isArray(op.value)) {
|
|
352
|
+
throw new Error(`Unsupported value for /blocks in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
353
|
+
}
|
|
354
|
+
raw.blocks = op.value;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "/role": {
|
|
358
|
+
const next = asTrimmedString(op.value);
|
|
359
|
+
if (!next) {
|
|
360
|
+
throw new Error(`Unsupported value for /role in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
361
|
+
}
|
|
362
|
+
raw.role = next;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case "/text": {
|
|
366
|
+
const next = asTrimmedString(op.value);
|
|
367
|
+
if (!next) {
|
|
368
|
+
throw new Error(`Unsupported value for /text in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
369
|
+
}
|
|
370
|
+
raw.content = [{ type: "text", text: next }];
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "/reasoning": {
|
|
374
|
+
if (typeof op.value !== "string") {
|
|
375
|
+
throw new Error(`Unsupported value for /reasoning in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
376
|
+
}
|
|
377
|
+
raw.reasoning = op.value;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case "/images": {
|
|
381
|
+
if (!Array.isArray(op.value)) {
|
|
382
|
+
throw new Error(`Unsupported value for /images in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
383
|
+
}
|
|
384
|
+
raw.images = op.value;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
default:
|
|
388
|
+
throw new Error(
|
|
389
|
+
`Unsupported entry patch target '${op.path}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parseModelChange(raw: JsonRecord, sourceFile: string, sourceLine: number): ParsedModelChange {
|
|
396
|
+
const rawRole = asTrimmedString(raw.role)?.toLowerCase() ?? "default";
|
|
397
|
+
if (rawRole !== "default" && rawRole !== "omitted") {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Unsupported model_change role '${rawRole}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (rawRole === "omitted") {
|
|
404
|
+
return { role: "omitted" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const model = asTrimmedString(raw.model) ?? asTrimmedString(raw.value);
|
|
408
|
+
if (!model) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Missing model in model_change record in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { role: "default", model };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseParentId(raw: JsonRecord): string | undefined {
|
|
418
|
+
const parent = (raw as JsonRecord).parent ?? (raw as JsonRecord).parent_id ?? (raw as JsonRecord).parentId;
|
|
419
|
+
if (parent == null) return undefined;
|
|
420
|
+
|
|
421
|
+
const parentId = asTrimmedString(parent);
|
|
422
|
+
if (!parentId) {
|
|
423
|
+
if (parent === null) return undefined;
|
|
424
|
+
throw new Error(`Malformed parent link ${String(parent)}.`);
|
|
425
|
+
}
|
|
426
|
+
return parentId;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function parseBlocks(raw: JsonRecord, sourceFile: string, sourceLine: number): unknown[] {
|
|
430
|
+
if (Object.prototype.hasOwnProperty.call(raw, "blocks")) {
|
|
431
|
+
const blocks = raw.blocks;
|
|
432
|
+
if (blocks === undefined) return [];
|
|
433
|
+
if (!Array.isArray(blocks)) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`Malformed blocks field in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return blocks;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (Object.prototype.hasOwnProperty.call(raw, "content")) {
|
|
442
|
+
const content = raw.content;
|
|
443
|
+
if (typeof content === "string") {
|
|
444
|
+
return [{ type: "text", text: content }];
|
|
445
|
+
}
|
|
446
|
+
if (Array.isArray(content)) {
|
|
447
|
+
return content;
|
|
448
|
+
}
|
|
449
|
+
if (content === undefined) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
throw new Error(
|
|
453
|
+
`Malformed content field in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (Object.prototype.hasOwnProperty.call(raw, "text")) {
|
|
458
|
+
if (typeof raw.text === "string") {
|
|
459
|
+
return [{ type: "text", text: raw.text }];
|
|
460
|
+
}
|
|
461
|
+
if (raw.text === undefined) {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
throw new Error(
|
|
465
|
+
`Malformed text field in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function parseImageBlock(
|
|
473
|
+
block: JsonRecord,
|
|
474
|
+
sourceFile: string,
|
|
475
|
+
sourceLine: number,
|
|
476
|
+
): ImageAttachmentLike {
|
|
477
|
+
const mediaType =
|
|
478
|
+
asString(block.mediaType) ||
|
|
479
|
+
asString(block.mime_type) ||
|
|
480
|
+
asString(block.mimeType) ||
|
|
481
|
+
asString((block as JsonRecord)["mime-type"]);
|
|
482
|
+
|
|
483
|
+
if (!mediaType) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`Unsupported image block in ${path.basename(sourceFile)}:${sourceLine}: missing mediaType.`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const data =
|
|
490
|
+
asTrimmedString(block.data) ||
|
|
491
|
+
asTrimmedString(block.base64) ||
|
|
492
|
+
asTrimmedString(block.base64_data);
|
|
493
|
+
|
|
494
|
+
if (!data) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Unsupported external image reference in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!isLikelyBase64Payload(data)) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`Unsupported external image reference in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!mediaType.startsWith("image/")) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`Unsupported non-image block type '${mediaType}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { mediaType, data };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function parseToolCall(block: JsonRecord, sourceFile: string, sourceLine: number): ToolUseRecord {
|
|
516
|
+
const id = asTrimmedString(block.id) || asTrimmedString(block.callId) || asTrimmedString(block.call_id);
|
|
517
|
+
if (!id) {
|
|
518
|
+
throw new Error(`toolCall block missing id in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const tool = asTrimmedString(block.name) || asTrimmedString(block.tool);
|
|
522
|
+
if (!tool) {
|
|
523
|
+
throw new Error(`toolCall '${id}' missing name in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const argsRaw = (block as JsonRecord).arguments ?? (block as JsonRecord).args;
|
|
527
|
+
if (!isObject(argsRaw)) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`toolCall '${id}' arguments must be an object in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
id,
|
|
535
|
+
tool,
|
|
536
|
+
arguments: argsRaw as Record<string, unknown>,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function parseToolResultBlock(
|
|
541
|
+
block: JsonRecord,
|
|
542
|
+
sourceFile: string,
|
|
543
|
+
sourceLine: number,
|
|
544
|
+
): { id: string; output: string; isError: boolean } {
|
|
545
|
+
const id = asTrimmedString(block.id) || asTrimmedString(block.callId) || asTrimmedString(block.call_id);
|
|
546
|
+
if (!id) {
|
|
547
|
+
throw new Error(`toolResult block missing id in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!Object.prototype.hasOwnProperty.call(block, "output")) {
|
|
551
|
+
throw new Error(`toolResult '${id}' missing output in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const output = asString((block as JsonRecord).output);
|
|
555
|
+
if (output === undefined) {
|
|
556
|
+
throw new Error(`toolResult '${id}' output must be a string in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const isError = asBoolean((block as JsonRecord).isError ?? (block as JsonRecord).is_error) ?? false;
|
|
560
|
+
return { id, output, isError };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function parseEntryParts(
|
|
564
|
+
raw: JsonRecord,
|
|
565
|
+
sourceLine: number,
|
|
566
|
+
sourceFile: string,
|
|
567
|
+
): ParsedEntryParts {
|
|
568
|
+
const sourceRole = asTrimmedString(raw.role)?.toLowerCase() ?? "user";
|
|
569
|
+
const blocks = parseBlocks(raw, sourceFile, sourceLine);
|
|
570
|
+
|
|
571
|
+
let content = "";
|
|
572
|
+
const reasoningParts: string[] = [];
|
|
573
|
+
const textParts: string[] = [];
|
|
574
|
+
const toolCalls: ToolUseRecord[] = [];
|
|
575
|
+
const toolResults: Array<{ id: string; output: string; isError: boolean }> = [];
|
|
576
|
+
const images: ImageAttachmentLike[] = [];
|
|
577
|
+
|
|
578
|
+
for (const block of blocks) {
|
|
579
|
+
if (!isObject(block)) {
|
|
580
|
+
throw new Error(`Unsupported block in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const blockType = (asTrimmedString(block.type) || asTrimmedString(block.kind))?.toLowerCase();
|
|
584
|
+
|
|
585
|
+
if (blockType === "text" || blockType === "content") {
|
|
586
|
+
const text = asString(block.text) || asString(block.content);
|
|
587
|
+
if (text === undefined) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Malformed text block in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
textParts.push(text);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (blockType === "thinking" || blockType === "reasoning") {
|
|
597
|
+
const reason = asString(block.text) || asString(block.thinking) || asString(block.reasoning);
|
|
598
|
+
if (reason === undefined) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`Malformed thinking block in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
reasoningParts.push(reason);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (blockType === "toolcall" || blockType === "tool_call") {
|
|
608
|
+
toolCalls.push(parseToolCall(block, sourceFile, sourceLine));
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (blockType === "toolresult" || blockType === "tool_result") {
|
|
613
|
+
toolResults.push(parseToolResultBlock(block, sourceFile, sourceLine));
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const mediaType = asString(block.mediaType) || asString(block.mime_type) || asString(block.mimeType);
|
|
618
|
+
if (blockType === "image" || mediaType?.startsWith("image/")) {
|
|
619
|
+
images.push(parseImageBlock(block, sourceFile, sourceLine));
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
throw new Error(
|
|
624
|
+
`Unsupported block type '${blockType ?? "(missing)"}' in ${path.basename(sourceFile)}:${sourceLine}.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (raw.reasoning !== undefined) {
|
|
629
|
+
if (typeof raw.reasoning !== "string") {
|
|
630
|
+
throw new Error(`Malformed reasoning in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
631
|
+
}
|
|
632
|
+
reasoningParts.push(raw.reasoning);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (raw.images !== undefined) {
|
|
636
|
+
if (!Array.isArray(raw.images)) {
|
|
637
|
+
throw new Error(`Unsupported images payload in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
638
|
+
}
|
|
639
|
+
for (const image of raw.images) {
|
|
640
|
+
if (!isObject(image)) {
|
|
641
|
+
throw new Error(`Unsupported images payload in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
642
|
+
}
|
|
643
|
+
images.push(parseImageBlock(image, sourceFile, sourceLine));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (sourceRole === "toolresult" || sourceRole === "tool_result") {
|
|
647
|
+
if (toolResults.length > 0) {
|
|
648
|
+
if (raw.toolCallId !== undefined || raw.tool_call_id !== undefined) {
|
|
649
|
+
throw new Error(`Malformed toolResult entry in ${path.basename(sourceFile)}:${sourceLine}: duplicate result payload.`);
|
|
650
|
+
}
|
|
651
|
+
} else {
|
|
652
|
+
const id = asTrimmedString(raw.toolCallId) || asTrimmedString(raw.tool_call_id);
|
|
653
|
+
if (!id) {
|
|
654
|
+
throw new Error(`toolResult entry missing toolCallId in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
655
|
+
}
|
|
656
|
+
if (raw.isError !== undefined && typeof raw.isError !== "boolean" && typeof raw.is_error !== "boolean") {
|
|
657
|
+
throw new Error(`Malformed toolResult isError in ${path.basename(sourceFile)}:${sourceLine}.`);
|
|
658
|
+
}
|
|
659
|
+
const directOutput = asString(raw.output);
|
|
660
|
+
toolResults.push({
|
|
661
|
+
id,
|
|
662
|
+
output: directOutput ?? textParts.join("\n"),
|
|
663
|
+
isError: asBoolean(raw.isError ?? raw.is_error) ?? false,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (textParts.length > 0) {
|
|
669
|
+
content = textParts.join("\n");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (content === "" && asString(raw.content) !== undefined && blocks.length === 0) {
|
|
673
|
+
content = raw.content as string;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const reasoning = reasoningParts.join("\n");
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
sourceRole,
|
|
680
|
+
content,
|
|
681
|
+
reasoning,
|
|
682
|
+
images,
|
|
683
|
+
toolCalls,
|
|
684
|
+
toolResults,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function normalizeMessageRecord(record: JsonRecord, type: string): JsonRecord {
|
|
689
|
+
if (type !== "message" || !isObject(record.message)) return record;
|
|
690
|
+
return {
|
|
691
|
+
...record,
|
|
692
|
+
...record.message,
|
|
693
|
+
type: "message",
|
|
694
|
+
id: record.id,
|
|
695
|
+
...(record.parentId !== undefined ? { parentId: record.parentId } : {}),
|
|
696
|
+
...(record.parent_id !== undefined ? { parent_id: record.parent_id } : {}),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function parseSourceSessionFromBytes(
|
|
700
|
+
bytes: Buffer,
|
|
701
|
+
sourcePath: string,
|
|
702
|
+
strict: boolean,
|
|
703
|
+
): ParsedSourceSession {
|
|
704
|
+
const lines = bytes.toString("utf8").split("\n");
|
|
705
|
+
let header: ParsedSourceHeader | null = null;
|
|
706
|
+
const nodes = new Map<string, SourceNode>();
|
|
707
|
+
const children = new Map<string, number>();
|
|
708
|
+
const pendingEntryPatches = new Map<string, unknown[]>();
|
|
709
|
+
const seenIds = new Set<string>();
|
|
710
|
+
const compactions: ParsedCompaction[] = [];
|
|
711
|
+
|
|
712
|
+
for (let i = 0; i < lines.length; i++) {
|
|
713
|
+
const rawLine = lines[i];
|
|
714
|
+
if (!rawLine.trim()) continue;
|
|
715
|
+
|
|
716
|
+
const lineNo = i + 1;
|
|
717
|
+
let parsed: unknown;
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
parsed = JSON.parse(rawLine);
|
|
721
|
+
} catch {
|
|
722
|
+
if (!strict) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
throw new Error(`Malformed JSON in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (!isObject(parsed)) {
|
|
729
|
+
if (!strict) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
throw new Error(`Malformed record in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const record = parsed as JsonRecord;
|
|
736
|
+
const rawType = asTrimmedString(record.type) || asTrimmedString(record.record_type);
|
|
737
|
+
const type = rawType ? rawType.toLowerCase() : "entry";
|
|
738
|
+
|
|
739
|
+
if (type === "session") {
|
|
740
|
+
if (header) {
|
|
741
|
+
if (strict) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Duplicate session header in ${path.basename(sourcePath)}:${lineNo}.`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const version = requireHeaderVersion(record.version, sourcePath);
|
|
750
|
+
const sessionId = asTrimmedString(record.id) || asTrimmedString(record.session_id);
|
|
751
|
+
if (!sessionId) {
|
|
752
|
+
if (strict) {
|
|
753
|
+
throw new Error(
|
|
754
|
+
`Malformed session header: missing id in ${path.basename(sourcePath)}:${lineNo}.`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const cwd = asTrimmedString(record.cwd);
|
|
761
|
+
if (!cwd) {
|
|
762
|
+
if (strict) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`Malformed session header: missing cwd in ${path.basename(sourcePath)}:${lineNo}.`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const selectedLeafId = asTrimmedString(record.selectedLeafId) || asTrimmedString(record.selected_leaf_id);
|
|
771
|
+
header = {
|
|
772
|
+
sessionId,
|
|
773
|
+
version,
|
|
774
|
+
cwd,
|
|
775
|
+
title: asTrimmedString(record.title),
|
|
776
|
+
selectedLeafId: selectedLeafId ?? undefined,
|
|
777
|
+
};
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (type === "header_patch") {
|
|
782
|
+
if (!header) {
|
|
783
|
+
if (strict) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
`header_patch before session header in ${path.basename(sourcePath)}:${lineNo}.`,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
applyHeaderPatch(header, (record as JsonRecord).patch ?? record, sourcePath, lineNo);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (type === "entry_patch") {
|
|
795
|
+
if (!header) {
|
|
796
|
+
if (strict) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`entry_patch before session header in ${path.basename(sourcePath)}:${lineNo}.`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const targetId =
|
|
805
|
+
asTrimmedString(record.entryId) ||
|
|
806
|
+
asTrimmedString(record.entry_id) ||
|
|
807
|
+
asTrimmedString(record.entry) ||
|
|
808
|
+
asTrimmedString(record.id);
|
|
809
|
+
if (!targetId) {
|
|
810
|
+
if (strict) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`entry_patch missing target id in ${path.basename(sourcePath)}:${lineNo}.`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const queued = pendingEntryPatches.get(targetId) ?? [];
|
|
819
|
+
queued.push((record as JsonRecord).patch ?? record);
|
|
820
|
+
pendingEntryPatches.set(targetId, queued);
|
|
821
|
+
|
|
822
|
+
const target = nodes.get(targetId);
|
|
823
|
+
if (!target) continue;
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
const queue = pendingEntryPatches.get(targetId) ?? [];
|
|
827
|
+
for (const patch of queue) {
|
|
828
|
+
applyEntryPatch(target.raw, patch, sourcePath, lineNo);
|
|
829
|
+
}
|
|
830
|
+
pendingEntryPatches.delete(targetId);
|
|
831
|
+
} catch (err) {
|
|
832
|
+
if (strict) throw err;
|
|
833
|
+
}
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (type === "compaction") {
|
|
838
|
+
if (!header) {
|
|
839
|
+
if (strict) {
|
|
840
|
+
throw new Error(`compaction before session header in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
841
|
+
}
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const firstKeptEntryId = asTrimmedString(record.firstKeptEntryId) || asTrimmedString(record.first_kept_entry_id);
|
|
846
|
+
if (!firstKeptEntryId) {
|
|
847
|
+
if (strict) {
|
|
848
|
+
throw new Error(`Malformed compaction record in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
849
|
+
}
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
compactions.push({
|
|
854
|
+
firstKeptEntryId,
|
|
855
|
+
summary: asTrimmedString(record.summary),
|
|
856
|
+
sourceLine: lineNo,
|
|
857
|
+
});
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const id = asTrimmedString(record.id);
|
|
862
|
+
const normalizedRecord = normalizeMessageRecord(record, type);
|
|
863
|
+
const looksLikeModelChange = type === "model_change";
|
|
864
|
+
const looksLikeEntry =
|
|
865
|
+
type === "entry" || type === "message" ||
|
|
866
|
+
(looksLikeModelChange === false && asTrimmedString(normalizedRecord.role) !== undefined);
|
|
867
|
+
|
|
868
|
+
if (!looksLikeEntry && !looksLikeModelChange) {
|
|
869
|
+
if (strict) {
|
|
870
|
+
throw new Error(`Unsupported record type '${type}' in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
871
|
+
}
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (!id) {
|
|
876
|
+
if (strict) {
|
|
877
|
+
throw new Error(`Record missing id in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
878
|
+
}
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (seenIds.has(id)) {
|
|
883
|
+
if (strict) {
|
|
884
|
+
throw new Error(`Duplicate record id '${id}' in ${path.basename(sourcePath)}:${lineNo}.`);
|
|
885
|
+
}
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
let parentId: string | undefined;
|
|
890
|
+
try {
|
|
891
|
+
parentId = parseParentId(record);
|
|
892
|
+
} catch (err) {
|
|
893
|
+
if (strict) throw err;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const node: SourceNode = {
|
|
898
|
+
id,
|
|
899
|
+
type,
|
|
900
|
+
parentId,
|
|
901
|
+
raw: normalizedRecord,
|
|
902
|
+
sourceLine: lineNo,
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
nodes.set(id, node);
|
|
906
|
+
seenIds.add(id);
|
|
907
|
+
|
|
908
|
+
if (parentId) {
|
|
909
|
+
children.set(parentId, (children.get(parentId) ?? 0) + 1);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (looksLikeModelChange) {
|
|
913
|
+
try {
|
|
914
|
+
parseModelChange(record, sourcePath, lineNo);
|
|
915
|
+
} catch (err) {
|
|
916
|
+
if (strict) throw err;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (pendingEntryPatches.has(id) && type !== "model_change") {
|
|
921
|
+
try {
|
|
922
|
+
const queue = pendingEntryPatches.get(id) ?? [];
|
|
923
|
+
for (const patch of queue) {
|
|
924
|
+
applyEntryPatch(node.raw, patch, sourcePath, lineNo);
|
|
925
|
+
}
|
|
926
|
+
pendingEntryPatches.delete(id);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
if (strict) throw err;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (!header) {
|
|
934
|
+
throw new Error(`Missing session header in ${path.basename(sourcePath)}.`);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (strict) {
|
|
938
|
+
for (const [targetId] of pendingEntryPatches) {
|
|
939
|
+
if (targetId) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
`entry_patch targets unknown id '${targetId}' in ${path.basename(sourcePath)}.`,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const leaves = [...nodes.keys()].filter(id => !children.has(id));
|
|
948
|
+
return {
|
|
949
|
+
sourcePath,
|
|
950
|
+
sourceBytes: bytes,
|
|
951
|
+
header,
|
|
952
|
+
nodes,
|
|
953
|
+
leaves,
|
|
954
|
+
compactions,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function startsWithFold(value: string, prefix: string): boolean {
|
|
959
|
+
return value.toLowerCase().startsWith(prefix.toLowerCase());
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function walkJsonlFiles(dir: string): Promise<string[]> {
|
|
963
|
+
let entries: Dirent[];
|
|
964
|
+
try {
|
|
965
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
966
|
+
} catch {
|
|
967
|
+
return [];
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const out: string[] = [];
|
|
971
|
+
for (const entry of entries) {
|
|
972
|
+
const joined = path.join(dir, entry.name);
|
|
973
|
+
|
|
974
|
+
if (entry.isSymbolicLink()) {
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (entry.isDirectory()) {
|
|
979
|
+
out.push(...await walkJsonlFiles(joined));
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".jsonl") {
|
|
984
|
+
out.push(joined);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
interface DiscoverableSessionHeader {
|
|
992
|
+
sessionId: string;
|
|
993
|
+
cwd: string;
|
|
994
|
+
selectedLeafId?: string;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function discoverSessionHeader(bytes: Buffer): DiscoverableSessionHeader | undefined {
|
|
998
|
+
for (const line of bytes.toString("utf8").split("\n")) {
|
|
999
|
+
if (!line.trim()) continue;
|
|
1000
|
+
let parsed: unknown;
|
|
1001
|
+
try {
|
|
1002
|
+
parsed = JSON.parse(line);
|
|
1003
|
+
} catch {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (!isObject(parsed)) continue;
|
|
1007
|
+
const type = asTrimmedString(parsed.type)?.toLowerCase();
|
|
1008
|
+
if (type !== "session") continue;
|
|
1009
|
+
const sessionId = asTrimmedString(parsed.id) || asTrimmedString(parsed.session_id);
|
|
1010
|
+
const cwd = asTrimmedString(parsed.cwd);
|
|
1011
|
+
if (!sessionId || !cwd) return undefined;
|
|
1012
|
+
const selectedLeafId =
|
|
1013
|
+
asTrimmedString(parsed.selectedLeafId) ||
|
|
1014
|
+
asTrimmedString(parsed.selected_leaf_id);
|
|
1015
|
+
return { sessionId, cwd, selectedLeafId };
|
|
1016
|
+
}
|
|
1017
|
+
return undefined;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export async function listGjcSessions(
|
|
1021
|
+
options: ListGjcSessionsOptions = {},
|
|
1022
|
+
): Promise<GjcSessionLeaf[]> {
|
|
1023
|
+
const roots = resolveGjcCodingAgentRoots(options);
|
|
1024
|
+
const includeAll = options.anyCwd ?? false;
|
|
1025
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
1026
|
+
|
|
1027
|
+
const matches: GjcSessionLeaf[] = [];
|
|
1028
|
+
const seen = new Set<string>();
|
|
1029
|
+
|
|
1030
|
+
for (const root of roots) {
|
|
1031
|
+
const files = await walkJsonlFiles(root);
|
|
1032
|
+
|
|
1033
|
+
for (const filePath of files) {
|
|
1034
|
+
let bytes: Buffer;
|
|
1035
|
+
try {
|
|
1036
|
+
bytes = await fs.readFile(filePath);
|
|
1037
|
+
} catch {
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
let parsed: ParsedSourceSession;
|
|
1042
|
+
try {
|
|
1043
|
+
parsed = parseSourceSessionFromBytes(bytes, filePath, false);
|
|
1044
|
+
} catch {
|
|
1045
|
+
const discovered = discoverSessionHeader(bytes);
|
|
1046
|
+
if (!discovered || (!includeAll && path.resolve(discovered.cwd) !== cwd)) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
const key = `${filePath}\x00${discovered.selectedLeafId ?? ""}`;
|
|
1050
|
+
if (seen.has(key)) continue;
|
|
1051
|
+
seen.add(key);
|
|
1052
|
+
matches.push({
|
|
1053
|
+
rootPath: root,
|
|
1054
|
+
sourcePath: filePath,
|
|
1055
|
+
sessionId: discovered.sessionId,
|
|
1056
|
+
leafId: discovered.selectedLeafId ?? "",
|
|
1057
|
+
cwd: discovered.cwd,
|
|
1058
|
+
selected: discovered.selectedLeafId !== undefined,
|
|
1059
|
+
});
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (!includeAll && path.resolve(parsed.header.cwd) !== cwd) {
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (parsed.leaves.length === 0) {
|
|
1068
|
+
const discovered = discoverSessionHeader(bytes);
|
|
1069
|
+
if (!discovered) continue;
|
|
1070
|
+
const key = `${filePath}\x00${discovered.selectedLeafId ?? ""}`;
|
|
1071
|
+
if (seen.has(key)) continue;
|
|
1072
|
+
seen.add(key);
|
|
1073
|
+
matches.push({
|
|
1074
|
+
rootPath: root,
|
|
1075
|
+
sourcePath: filePath,
|
|
1076
|
+
sessionId: discovered.sessionId,
|
|
1077
|
+
leafId: discovered.selectedLeafId ?? "",
|
|
1078
|
+
cwd: discovered.cwd,
|
|
1079
|
+
selected: discovered.selectedLeafId !== undefined,
|
|
1080
|
+
});
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
for (const leafId of parsed.leaves) {
|
|
1085
|
+
const key = `${filePath}\x00${leafId}`;
|
|
1086
|
+
if (seen.has(key)) continue;
|
|
1087
|
+
seen.add(key);
|
|
1088
|
+
|
|
1089
|
+
matches.push({
|
|
1090
|
+
rootPath: root,
|
|
1091
|
+
sourcePath: filePath,
|
|
1092
|
+
sessionId: parsed.header.sessionId,
|
|
1093
|
+
leafId,
|
|
1094
|
+
cwd: parsed.header.cwd,
|
|
1095
|
+
selected: leafId === (parsed.header.selectedLeafId ?? ""),
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
matches.sort((a, b) => {
|
|
1102
|
+
if (a.sessionId !== b.sessionId) return a.sessionId.localeCompare(b.sessionId);
|
|
1103
|
+
return a.leafId.localeCompare(b.leafId);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
return matches;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
export async function resolveGjcSessionRef(
|
|
1110
|
+
sessionIdOrPrefix: string,
|
|
1111
|
+
leafIdOrPrefix: string | undefined,
|
|
1112
|
+
options: ResolveGjcSessionOptions = {},
|
|
1113
|
+
): Promise<GjcSessionRefResolution> {
|
|
1114
|
+
const sessionPrefix = sessionIdOrPrefix.trim();
|
|
1115
|
+
if (!sessionPrefix) return { kind: "not-found" };
|
|
1116
|
+
|
|
1117
|
+
const candidates = await listGjcSessions(options);
|
|
1118
|
+
const exactSessionMatches = candidates.filter(item => item.sessionId === sessionPrefix);
|
|
1119
|
+
const sessionMatches =
|
|
1120
|
+
exactSessionMatches.length > 0
|
|
1121
|
+
? exactSessionMatches
|
|
1122
|
+
: candidates.filter(item => startsWithFold(item.sessionId, sessionPrefix));
|
|
1123
|
+
|
|
1124
|
+
if (sessionMatches.length === 0) {
|
|
1125
|
+
return { kind: "not-found" };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const uniqueSessionIds = Array.from(new Set(sessionMatches.map(item => item.sessionId)));
|
|
1129
|
+
if (uniqueSessionIds.length !== 1) {
|
|
1130
|
+
return { kind: "ambiguous-session", matches: uniqueSessionIds };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const sessionOnly = sessionMatches.filter(item => item.sessionId === uniqueSessionIds[0]!);
|
|
1134
|
+
|
|
1135
|
+
if (!leafIdOrPrefix || !leafIdOrPrefix.trim()) {
|
|
1136
|
+
const selected = sessionOnly.find(item => item.selected);
|
|
1137
|
+
if (selected) {
|
|
1138
|
+
return { kind: "ok", match: selected };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (sessionOnly.length === 1) {
|
|
1142
|
+
return { kind: "ok", match: sessionOnly[0]! };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const uniqueLeafs = Array.from(new Set(sessionOnly.map(item => item.leafId)));
|
|
1146
|
+
return { kind: "ambiguous-leaf", matches: uniqueLeafs };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const leafPrefix = leafIdOrPrefix.trim();
|
|
1150
|
+
const exactLeafMatches = sessionOnly.filter(item => item.leafId === leafPrefix);
|
|
1151
|
+
if (exactLeafMatches.length > 1) {
|
|
1152
|
+
return { kind: "ambiguous-leaf", matches: exactLeafMatches.map(item => item.leafId) };
|
|
1153
|
+
}
|
|
1154
|
+
const leafMatches = exactLeafMatches.length === 1
|
|
1155
|
+
? exactLeafMatches
|
|
1156
|
+
: sessionOnly.filter(item => startsWithFold(item.leafId, leafPrefix));
|
|
1157
|
+
|
|
1158
|
+
if (leafMatches.length === 0) return { kind: "not-found" };
|
|
1159
|
+
if (leafMatches.length > 1) {
|
|
1160
|
+
return { kind: "ambiguous-leaf", matches: leafMatches.map(item => item.leafId) };
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return { kind: "ok", match: leafMatches[0]! };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function resolveLeafFromSession(session: ParsedSourceSession, requestedLeafId: string | undefined): string {
|
|
1167
|
+
if (requestedLeafId) {
|
|
1168
|
+
const prefix = requestedLeafId.trim();
|
|
1169
|
+
const exact = session.leaves.find(leaf => leaf === prefix);
|
|
1170
|
+
if (exact) {
|
|
1171
|
+
return exact;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const matches = session.leaves.filter(leaf => startsWithFold(leaf, prefix));
|
|
1175
|
+
if (matches.length === 0) {
|
|
1176
|
+
throw new Error(`Leaf id '${requestedLeafId}' not found in source session ${session.header.sessionId}.`);
|
|
1177
|
+
}
|
|
1178
|
+
if (matches.length > 1) {
|
|
1179
|
+
throw new Error(`Leaf id '${requestedLeafId}' is ambiguous in source session ${session.header.sessionId}.`);
|
|
1180
|
+
}
|
|
1181
|
+
return matches[0]!;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (session.header.selectedLeafId) {
|
|
1185
|
+
const selectedLeafId = session.header.selectedLeafId;
|
|
1186
|
+
const exactSelected = session.leaves.find(leaf => leaf === selectedLeafId);
|
|
1187
|
+
if (exactSelected) {
|
|
1188
|
+
return exactSelected;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const selectedMatches = session.leaves.filter(leaf => startsWithFold(leaf, selectedLeafId));
|
|
1192
|
+
if (selectedMatches.length === 0) {
|
|
1193
|
+
throw new Error(
|
|
1194
|
+
`Selected leaf '${selectedLeafId}' is not present in source session ${session.header.sessionId}.`,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
if (selectedMatches.length > 1) {
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
`Selected leaf '${selectedLeafId}' is ambiguous in source session ${session.header.sessionId}.`,
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
return selectedMatches[0]!;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (session.leaves.length === 0) {
|
|
1206
|
+
throw new Error(`Source session ${session.header.sessionId} has no leaves.`);
|
|
1207
|
+
}
|
|
1208
|
+
if (session.leaves.length !== 1) {
|
|
1209
|
+
throw new Error(
|
|
1210
|
+
`Source session ${session.header.sessionId} has multiple leaves; a leaf id is required.`,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return session.leaves[0]!;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function chainFromLeaf(session: ParsedSourceSession, leafId: string): SourceNode[] {
|
|
1218
|
+
const chain: SourceNode[] = [];
|
|
1219
|
+
const seen = new Set<string>();
|
|
1220
|
+
let current: string | undefined = leafId;
|
|
1221
|
+
|
|
1222
|
+
while (current) {
|
|
1223
|
+
if (seen.has(current)) {
|
|
1224
|
+
throw new Error(`Detected parent cycle in source session ${session.header.sessionId}.`);
|
|
1225
|
+
}
|
|
1226
|
+
seen.add(current);
|
|
1227
|
+
|
|
1228
|
+
const node = session.nodes.get(current);
|
|
1229
|
+
if (!node) {
|
|
1230
|
+
throw new Error(`Source chain references missing id '${current}' in ${path.basename(session.sourcePath)}.`);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
chain.push(node);
|
|
1234
|
+
current = node.parentId;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
chain.reverse();
|
|
1238
|
+
return chain;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function mapEntryToMessage(node: SourceNode, sourceFile: string): ParsedEntryParts {
|
|
1242
|
+
const entry = parseEntryParts(node.raw, node.sourceLine, sourceFile);
|
|
1243
|
+
|
|
1244
|
+
const allowedRoles = ["user", "assistant", "developer", "toolresult", "tool_result"];
|
|
1245
|
+
if (!allowedRoles.includes(entry.sourceRole)) {
|
|
1246
|
+
throw new Error(
|
|
1247
|
+
`Unsupported source role '${entry.sourceRole}' in ${path.basename(sourceFile)}:${node.sourceLine}.`,
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
return entry;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function messageFromUserOrDeveloper(
|
|
1255
|
+
role: "user" | "developer",
|
|
1256
|
+
entry: ParsedEntryParts,
|
|
1257
|
+
): Message {
|
|
1258
|
+
if (role === "developer") {
|
|
1259
|
+
// NOTE: GJC `developer` role is normalized to a Jeo `system` message.
|
|
1260
|
+
return {
|
|
1261
|
+
role: "system",
|
|
1262
|
+
content: entry.content,
|
|
1263
|
+
...(entry.images.length > 0 ? { images: entry.images } : {}),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
return {
|
|
1268
|
+
role: "user",
|
|
1269
|
+
content: entry.content,
|
|
1270
|
+
...(entry.images.length > 0 ? { images: entry.images } : {}),
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function messageFromAssistant(entry: ParsedEntryParts): Message {
|
|
1275
|
+
if (entry.images.length > 0) {
|
|
1276
|
+
throw new Error("assistant entries cannot include image blocks.");
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const message: Message = {
|
|
1280
|
+
role: "assistant",
|
|
1281
|
+
content: entry.content,
|
|
1282
|
+
...(entry.reasoning ? { reasoning: entry.reasoning } : {}),
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
if (entry.toolCalls.length > 0) {
|
|
1286
|
+
message.toolUse = entry.toolCalls;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
return message;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function messageFromToolResult(entry: ParsedEntryParts, sourceNodeId: string): Message {
|
|
1293
|
+
if (entry.toolResults.length === 0) {
|
|
1294
|
+
throw new Error(`toolResult entry '${sourceNodeId}' is missing tool result payload.`);
|
|
1295
|
+
}
|
|
1296
|
+
if (entry.toolResults.length !== 1) {
|
|
1297
|
+
throw new Error(
|
|
1298
|
+
`toolResult entry '${sourceNodeId}' has multiple tool results; expected exactly one.`,
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const toolResult = entry.toolResults[0]!;
|
|
1303
|
+
const toolResults: ToolResultRecord[] = [
|
|
1304
|
+
{
|
|
1305
|
+
id: toolResult.id,
|
|
1306
|
+
output: toolResult.output,
|
|
1307
|
+
isError: toolResult.isError,
|
|
1308
|
+
},
|
|
1309
|
+
];
|
|
1310
|
+
|
|
1311
|
+
return {
|
|
1312
|
+
role: "user",
|
|
1313
|
+
content: entry.content,
|
|
1314
|
+
...(entry.images.length > 0 ? { images: entry.images } : {}),
|
|
1315
|
+
toolResults,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function deriveModelFromChain(chain: SourceNode[], sourcePath: string): string | undefined {
|
|
1320
|
+
let selected: string | undefined;
|
|
1321
|
+
for (const node of chain) {
|
|
1322
|
+
if (node.type !== "model_change") continue;
|
|
1323
|
+
const change = parseModelChange(node.raw, sourcePath, node.sourceLine);
|
|
1324
|
+
if (change.role === "omitted") {
|
|
1325
|
+
selected = undefined;
|
|
1326
|
+
} else {
|
|
1327
|
+
selected = change.model;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return selected;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function convertChainToMessages(
|
|
1334
|
+
chain: SourceNode[],
|
|
1335
|
+
sourcePath: string,
|
|
1336
|
+
compactions: ParsedCompaction[],
|
|
1337
|
+
): { messages: Message[]; replacesThrough: number | undefined; compactionSummary: string | undefined } {
|
|
1338
|
+
const messages: Message[] = [];
|
|
1339
|
+
const nodeToMessageIndex = new Map<string, number>();
|
|
1340
|
+
const pendingCalls = new Map<string, ToolUseRecord>();
|
|
1341
|
+
const handledToolResults = new Set<string>();
|
|
1342
|
+
let firstSystemIndex = -1;
|
|
1343
|
+
|
|
1344
|
+
const orderedCompactions = compactions.filter(Boolean);
|
|
1345
|
+
|
|
1346
|
+
for (const node of chain) {
|
|
1347
|
+
if (node.type === "model_change" || node.type === "compaction") {
|
|
1348
|
+
if (node.type === "model_change") {
|
|
1349
|
+
parseModelChange(node.raw, sourcePath, node.sourceLine);
|
|
1350
|
+
}
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const parsed = mapEntryToMessage(node, sourcePath);
|
|
1355
|
+
|
|
1356
|
+
if (parsed.sourceRole === "assistant") {
|
|
1357
|
+
if (pendingCalls.size > 0) {
|
|
1358
|
+
const remaining = [...pendingCalls.keys()];
|
|
1359
|
+
throw new Error(
|
|
1360
|
+
`Non-contiguous tool result: pending calls {${remaining.join(", ")}} before '${node.id}'.`,
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const message = messageFromAssistant(parsed);
|
|
1365
|
+
const index = messages.length;
|
|
1366
|
+
messages.push(message);
|
|
1367
|
+
nodeToMessageIndex.set(node.id, index);
|
|
1368
|
+
|
|
1369
|
+
for (const call of parsed.toolCalls) {
|
|
1370
|
+
if (pendingCalls.has(call.id)) {
|
|
1371
|
+
throw new Error(
|
|
1372
|
+
`Duplicate tool call id '${call.id}' in assistant entry '${node.id}'.`,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
if (handledToolResults.has(call.id)) {
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
`Duplicate tool call id '${call.id}' in assistant entry '${node.id}'.`,
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
pendingCalls.set(call.id, call);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (parsed.sourceRole === "toolresult" || parsed.sourceRole === "tool_result") {
|
|
1387
|
+
if (pendingCalls.size === 0) {
|
|
1388
|
+
throw new Error(`orphan toolResult entry '${node.id}' encountered without pending tool calls.`);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (parsed.toolResults.length !== 1) {
|
|
1392
|
+
throw new Error(`toolResult entry '${node.id}' is malformed: expected a single tool result payload.`);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const toolResult = parsed.toolResults[0]!;
|
|
1396
|
+
if (!pendingCalls.has(toolResult.id)) {
|
|
1397
|
+
throw new Error(`Unknown toolResult id '${toolResult.id}' in entry '${node.id}'.`);
|
|
1398
|
+
}
|
|
1399
|
+
if (handledToolResults.has(toolResult.id)) {
|
|
1400
|
+
throw new Error(`Duplicate toolResult id '${toolResult.id}' in source chain.`);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const message = messageFromToolResult(parsed, node.id);
|
|
1404
|
+
const index = messages.length;
|
|
1405
|
+
messages.push(message);
|
|
1406
|
+
nodeToMessageIndex.set(node.id, index);
|
|
1407
|
+
|
|
1408
|
+
pendingCalls.delete(toolResult.id);
|
|
1409
|
+
handledToolResults.add(toolResult.id);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (pendingCalls.size > 0) {
|
|
1414
|
+
const remaining = [...pendingCalls.keys()];
|
|
1415
|
+
throw new Error(
|
|
1416
|
+
`Missing toolResult entries for tool calls: ${remaining.join(", ")} before '${node.id}'.`,
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const message = messageFromUserOrDeveloper(parsed.sourceRole as "user" | "developer", parsed);
|
|
1421
|
+
const index = messages.length;
|
|
1422
|
+
messages.push(message);
|
|
1423
|
+
nodeToMessageIndex.set(node.id, index);
|
|
1424
|
+
if (message.role === "system") {
|
|
1425
|
+
if (firstSystemIndex === -1) {
|
|
1426
|
+
firstSystemIndex = index;
|
|
1427
|
+
} else {
|
|
1428
|
+
throw new Error("Source chain has a non-first system/developer message.");
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (pendingCalls.size > 0) {
|
|
1434
|
+
const remaining = [...pendingCalls.keys()];
|
|
1435
|
+
throw new Error(`Missing toolResult entries for tool calls: ${remaining.join(", ")}.`);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (firstSystemIndex > 0) {
|
|
1439
|
+
throw new Error("Source chain has a non-first system/developer message.");
|
|
1440
|
+
}
|
|
1441
|
+
let replacesThrough: number | undefined;
|
|
1442
|
+
let compactionSummary: string | undefined;
|
|
1443
|
+
|
|
1444
|
+
for (const marker of orderedCompactions) {
|
|
1445
|
+
const firstKeptMessageIndex = nodeToMessageIndex.get(marker.firstKeptEntryId);
|
|
1446
|
+
if (firstKeptMessageIndex === undefined) {
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
replacesThrough = firstKeptMessageIndex - 1;
|
|
1451
|
+
compactionSummary = marker.summary;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Jeo's loader retains the first system message when applying a compaction
|
|
1455
|
+
// marker, so the imported boundary may include its source index.
|
|
1456
|
+
|
|
1457
|
+
return {
|
|
1458
|
+
messages,
|
|
1459
|
+
replacesThrough,
|
|
1460
|
+
compactionSummary,
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function buildImportHeader(
|
|
1465
|
+
source: ParsedSourceSession,
|
|
1466
|
+
sourceLeafId: string,
|
|
1467
|
+
sourceSha256: string,
|
|
1468
|
+
sourceTime: string,
|
|
1469
|
+
model: string | undefined,
|
|
1470
|
+
targetCwd: string,
|
|
1471
|
+
): SessionHeader {
|
|
1472
|
+
const header: SessionHeader = {
|
|
1473
|
+
type: "session",
|
|
1474
|
+
version: 1,
|
|
1475
|
+
id: "",
|
|
1476
|
+
timestamp: sourceTime,
|
|
1477
|
+
cwd: targetCwd,
|
|
1478
|
+
...(source.header.title === undefined ? {} : { title: source.header.title }),
|
|
1479
|
+
...(model === undefined ? {} : { model }),
|
|
1480
|
+
sourceSystem: "gjc",
|
|
1481
|
+
sourceSessionId: source.header.sessionId,
|
|
1482
|
+
sourceLeafId,
|
|
1483
|
+
sourceSha256,
|
|
1484
|
+
importTimestamp: sourceTime,
|
|
1485
|
+
};
|
|
1486
|
+
return header;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function buildImportLines(
|
|
1490
|
+
header: SessionHeader,
|
|
1491
|
+
messages: Message[],
|
|
1492
|
+
replacesThrough: number | undefined,
|
|
1493
|
+
compactionSummary: string | undefined,
|
|
1494
|
+
): string {
|
|
1495
|
+
const lines: string[] = [JSON.stringify(header)];
|
|
1496
|
+
const ts = header.timestamp;
|
|
1497
|
+
|
|
1498
|
+
for (const message of messages) {
|
|
1499
|
+
lines.push(
|
|
1500
|
+
JSON.stringify({
|
|
1501
|
+
type: "message",
|
|
1502
|
+
timestamp: ts,
|
|
1503
|
+
message,
|
|
1504
|
+
} satisfies { type: "message"; timestamp: string; message: Message }),
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
if (replacesThrough !== undefined) {
|
|
1509
|
+
lines.push(
|
|
1510
|
+
JSON.stringify({
|
|
1511
|
+
type: "compaction",
|
|
1512
|
+
timestamp: ts,
|
|
1513
|
+
seq: 1,
|
|
1514
|
+
summary: compactionSummary ?? "Imported from GJC source",
|
|
1515
|
+
replacesThrough,
|
|
1516
|
+
}),
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return `${lines.join("\n")}\n`;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function randomSuffix(): string {
|
|
1524
|
+
return Math.random().toString(36).slice(2);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
async function writeSessionAtomically(cwd: string, sessionId: string, text: string): Promise<string> {
|
|
1528
|
+
const finalPath = sessionPath(sessionId, cwd);
|
|
1529
|
+
const tmpPath = `${finalPath}.${randomSuffix()}.tmp`;
|
|
1530
|
+
|
|
1531
|
+
await fs.mkdir(path.dirname(finalPath), { recursive: true, mode: 0o700 });
|
|
1532
|
+
try {
|
|
1533
|
+
await fs.writeFile(tmpPath, text, "utf8");
|
|
1534
|
+
|
|
1535
|
+
if (await exists(finalPath)) {
|
|
1536
|
+
throw new Error(`Refusing to overwrite existing session file ${finalPath}.`);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
await fs.rename(tmpPath, finalPath);
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
await fs.unlink(tmpPath).catch(() => {});
|
|
1542
|
+
throw err;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return finalPath;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
export async function importGjcSession(
|
|
1549
|
+
options: ImportGjcSessionOptions,
|
|
1550
|
+
): Promise<ImportGjcSessionResult> {
|
|
1551
|
+
const resolution = await resolveGjcSessionRef(options.sessionId, options.leafId, {
|
|
1552
|
+
roots: options.roots,
|
|
1553
|
+
cwd: options.cwd,
|
|
1554
|
+
// Explicit test roots represent a caller-selected source corpus; production
|
|
1555
|
+
// callers retain the current-working-directory guard unless --any-cwd is set.
|
|
1556
|
+
anyCwd: options.anyCwd ?? (options.roots !== undefined),
|
|
1557
|
+
env: options.env,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
if (resolution.kind !== "ok") {
|
|
1561
|
+
if (resolution.kind === "ambiguous-session") {
|
|
1562
|
+
throw new Error(`Ambiguous session id '${options.sessionId}'.`);
|
|
1563
|
+
}
|
|
1564
|
+
if (resolution.kind === "ambiguous-leaf") {
|
|
1565
|
+
throw new Error(`Ambiguous leaf id '${options.leafId ?? ""}' for session '${options.sessionId}'.`);
|
|
1566
|
+
}
|
|
1567
|
+
throw new Error(`Source session '${options.sessionId}' not found.`);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const source = parseSourceSessionFromBytes(
|
|
1571
|
+
await fs.readFile(resolution.match.sourcePath),
|
|
1572
|
+
resolution.match.sourcePath,
|
|
1573
|
+
true,
|
|
1574
|
+
);
|
|
1575
|
+
|
|
1576
|
+
const sourceLeafId = resolveLeafFromSession(source, options.leafId);
|
|
1577
|
+
const chain = chainFromLeaf(source, sourceLeafId);
|
|
1578
|
+
const model = deriveModelFromChain(chain, source.sourcePath);
|
|
1579
|
+
const conversion = convertChainToMessages(chain, source.sourcePath, source.compactions);
|
|
1580
|
+
|
|
1581
|
+
const importTimestamp = new Date().toISOString();
|
|
1582
|
+
const sourceSha256 = createHash("sha256").update(source.sourceBytes).digest("hex");
|
|
1583
|
+
const targetCwd = path.resolve(options.cwd ?? process.cwd());
|
|
1584
|
+
|
|
1585
|
+
const reuse = await findImportedSessionByProvenance(targetCwd, {
|
|
1586
|
+
sourceSystem: "gjc",
|
|
1587
|
+
sourceSessionId: source.header.sessionId,
|
|
1588
|
+
sourceLeafId,
|
|
1589
|
+
sourceSha256,
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
if (reuse) {
|
|
1593
|
+
return {
|
|
1594
|
+
sessionId: reuse.id,
|
|
1595
|
+
path: sessionPath(reuse.id, targetCwd),
|
|
1596
|
+
sourceSessionId: source.header.sessionId,
|
|
1597
|
+
sourceLeafId,
|
|
1598
|
+
sourceSha256,
|
|
1599
|
+
reused: true,
|
|
1600
|
+
createdAt: reuse.timestamp,
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const header = buildImportHeader(source, sourceLeafId, sourceSha256, importTimestamp, model, targetCwd);
|
|
1605
|
+
|
|
1606
|
+
let sessionId = newSessionId();
|
|
1607
|
+
let finalPath: string | undefined;
|
|
1608
|
+
for (let attempts = 0; attempts < 32; attempts++) {
|
|
1609
|
+
const candidatePath = sessionPath(sessionId, targetCwd);
|
|
1610
|
+
if (!(await exists(candidatePath))) {
|
|
1611
|
+
header.id = sessionId;
|
|
1612
|
+
const payload = buildImportLines(header, conversion.messages, conversion.replacesThrough, conversion.compactionSummary);
|
|
1613
|
+
finalPath = await writeSessionAtomically(targetCwd, sessionId, payload);
|
|
1614
|
+
break;
|
|
1615
|
+
}
|
|
1616
|
+
sessionId = newSessionId();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (!finalPath) {
|
|
1620
|
+
throw new Error("Could not allocate a unique import session id.");
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
return {
|
|
1624
|
+
sessionId,
|
|
1625
|
+
path: finalPath,
|
|
1626
|
+
sourceSessionId: source.header.sessionId,
|
|
1627
|
+
sourceLeafId,
|
|
1628
|
+
sourceSha256,
|
|
1629
|
+
reused: false,
|
|
1630
|
+
createdAt: importTimestamp,
|
|
1631
|
+
};
|
|
1632
|
+
}
|