dsh-claude-move 0.2.1

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.
@@ -0,0 +1,725 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // convert.mjs — 外部聊天记录 → DSH 会话事件(纯函数,无宿主依赖)
3
+ //
4
+ // Vendored from Nwflower/dsh-chat-import (MIT, see THIRD_PARTY_NOTICES.md) and
5
+ // extended in place. Upstream split it from the plugin entry so the mapping
6
+ // core stays independently testable: no DSH imports in this module.
7
+ //
8
+ // 与 index.mjs 分离是为了可独立单元测试:本模块不 import 任何 DSH 包。
9
+ // 每个源格式一个 `convertXxxJsonl(raw, args)`:把原始 JSONL 文本解析成统一
10
+ // 的回合中间结构,再交给共享的 synthesizeSession 合成 DSH 事件日志,
11
+ // 保证所有源(Claude Code / Codex-ChatGPT)事件纪律一致。
12
+
13
+ export const SESSION_FORMAT_VERSION = 0
14
+
15
+ /** 畸形行明细上报上限(完整计数不受限)。 */
16
+ export const MALFORMED_REPORT_CAP = 200
17
+
18
+ export function parseTime(iso) {
19
+ if (typeof iso === 'string') {
20
+ const n = Date.parse(iso)
21
+ if (Number.isFinite(n)) return n
22
+ }
23
+ return Date.now()
24
+ }
25
+
26
+ // 把源 sessionId 折成合法的 DSH SessionId 片段。
27
+ export function mintSessionId(sourceId) {
28
+ const slug = String(sourceId || '')
29
+ .replace(/[^a-zA-Z0-9_-]/g, '')
30
+ .slice(0, 64)
31
+ return 'import-' + (slug || String(Date.now()))
32
+ }
33
+
34
+ // Claude content block → DSH content block。文本→text、思考→reasoning、工具调用→tool-call。
35
+ export function mapContentBlock(block) {
36
+ if (!block) return null
37
+ if (block.type === 'text' && typeof block.text === 'string') return { type: 'text', text: block.text }
38
+ if (block.type === 'thinking' && typeof block.thinking === 'string') return { type: 'reasoning', text: block.thinking }
39
+ if (block.type === 'tool_use') {
40
+ return { type: 'tool-call', id: block.id, name: block.name, arguments: JSON.stringify(block.input ?? {}) }
41
+ }
42
+ return null
43
+ }
44
+
45
+ /** 中断工具调用的合成结果文案(导入的历史里该调用没有返回记录)。 */
46
+ export const SYNTHETIC_TOOL_RESULT_TEXT = '(interrupted: no tool result in transcript)(中断的工具调用:transcript 未记录返回结果)'
47
+
48
+ // 把单个回合合成为平衡的事件数组(seq 从 startSeq 起)。流式转换(C3)与
49
+ // 全量 synthesizeSession 共用,保证两条路径事件纪律一致。
50
+ //
51
+ // 工具调用平衡(issue#1):OpenAI 兼容协议要求助手消息里每个 tool_call_id
52
+ // 恰好跟一条 tool 消息。Claude transcript 可能缺结果(回合被中断)、重复
53
+ // 结果或孤儿结果(无对应声明的 tool_result),直接导入会令会话在续聊时
54
+ // 永久 400。这里在合成期修复:每个声明的 tool/call 恰好产出一条
55
+ // tool/result —— 有真实结果取第一条,重复的丢弃计数,缺失的补一条合成
56
+ // 错误结果(isError);无对应声明的孤儿结果同样丢弃计数。
57
+ function synthesizeTurnEvents(meta, turn, t, startSeq, provider, mname, repaired) {
58
+ const events = []
59
+ let seq = startSeq
60
+ const push = (type, data, surface, sourceEventSeqs) => {
61
+ const ev = { type, seq: seq++, time: meta.createdAt, data }
62
+ if (surface) ev.surfaceOp = 'append'
63
+ if (sourceEventSeqs) ev.sourceEventSeqs = sourceEventSeqs
64
+ events.push(ev)
65
+ return ev
66
+ }
67
+ const bump = (key) => { if (repaired) repaired[key] = (repaired[key] ?? 0) + 1 }
68
+
69
+ push('turn/start', { turn })
70
+ if (t.steps.length === 0) {
71
+ // 只有提问、没有回复的轮次
72
+ push('user/message', {
73
+ id: 'import:' + meta.id + ':u' + turn,
74
+ role: 'user',
75
+ content: [{ type: 'text', text: t.prompt }],
76
+ source: { kind: 'user' },
77
+ }, true)
78
+ } else {
79
+ for (let i = 0; i < t.steps.length; i++) {
80
+ const stepNum = i + 1
81
+ const step = t.steps[i]
82
+ push('step/start', { turn, step: stepNum })
83
+ if (i === 0) {
84
+ push('user/message', {
85
+ id: 'import:' + meta.id + ':u' + turn,
86
+ role: 'user',
87
+ content: [{ type: 'text', text: t.prompt }],
88
+ source: { kind: 'user' },
89
+ }, true)
90
+ }
91
+ push('assistant/message', {
92
+ turn,
93
+ step: stepNum,
94
+ message: {
95
+ id: 'import:' + meta.id + ':a' + turn + ':' + stepNum,
96
+ role: 'assistant',
97
+ content: step.content,
98
+ source: { kind: 'model', provider, model: mname },
99
+ },
100
+ }, true)
101
+ const callSeqByCallId = {}
102
+ const declaredIds = new Set()
103
+ for (const tc of step.toolCalls) {
104
+ const ev = push('tool/call', {
105
+ turn,
106
+ step: stepNum,
107
+ callId: tc.id,
108
+ name: tc.name,
109
+ arguments: tc.arguments,
110
+ })
111
+ callSeqByCallId[tc.id] = ev.seq
112
+ declaredIds.add(tc.id)
113
+ }
114
+ // 每个声明的调用恰好一条结果:真实结果去重取首条,缺失补合成错误结果。
115
+ const firstResultByCallId = new Map()
116
+ for (const tr of step.toolResults) {
117
+ if (!declaredIds.has(tr.toolCallId)) {
118
+ bump('orphanResults')
119
+ continue
120
+ }
121
+ if (firstResultByCallId.has(tr.toolCallId)) {
122
+ bump('duplicateResults')
123
+ continue
124
+ }
125
+ firstResultByCallId.set(tr.toolCallId, tr)
126
+ }
127
+ for (const tc of step.toolCalls) {
128
+ const callSeq = callSeqByCallId[tc.id]
129
+ const real = firstResultByCallId.get(tc.id)
130
+ if (real) {
131
+ push('tool/result', {
132
+ turn,
133
+ step: stepNum,
134
+ message: {
135
+ id: 'import:' + meta.id + ':t' + turn + ':' + stepNum + ':' + real.toolCallId,
136
+ role: 'user',
137
+ content: [{
138
+ type: 'tool-result',
139
+ toolCallId: real.toolCallId,
140
+ content: real.content,
141
+ ...(real.isError ? { isError: true } : {}),
142
+ }],
143
+ source: { kind: 'tool', callId: real.toolCallId },
144
+ },
145
+ }, true, [callSeq])
146
+ } else {
147
+ bump('synthesized')
148
+ push('tool/result', {
149
+ turn,
150
+ step: stepNum,
151
+ message: {
152
+ id: 'import:' + meta.id + ':t' + turn + ':' + stepNum + ':' + tc.id,
153
+ role: 'user',
154
+ content: [{
155
+ type: 'tool-result',
156
+ toolCallId: tc.id,
157
+ content: [{ type: 'text', text: SYNTHETIC_TOOL_RESULT_TEXT }],
158
+ isError: true,
159
+ }],
160
+ source: { kind: 'tool', callId: tc.id },
161
+ },
162
+ }, true, [callSeq])
163
+ }
164
+ }
165
+ push('step/end', { turn, step: stepNum })
166
+ }
167
+ }
168
+ push('turn/end', { turn, reason: { kind: 'completed' } })
169
+ return events
170
+ }
171
+
172
+ // 把「回合中间结构」合成平衡的 DSH 事件日志(seq 从 0 连续;surface 事件带
173
+ // surfaceOp:'append';tool/result 用 sourceEventSeqs 关联其 tool/call)。
174
+ // turns: [{ prompt, steps: [{ content, toolCalls, toolResults }] }]
175
+ // 四合一向导的 opencode 映射器复用本函数,保证各源事件纪律一致。
176
+ export function synthesizeSession({ meta, turns, title, provider, model, skipped, records, skippedLines, typeCounts }) {
177
+ const events = []
178
+ let seq = 0
179
+ const mname = model || provider
180
+ const repaired = { synthesized: 0, duplicateResults: 0, orphanResults: 0 }
181
+
182
+ for (let turn = 1; turn <= turns.length; turn++) {
183
+ const evs = synthesizeTurnEvents(meta, turn, turns[turn - 1], seq, provider, mname, repaired)
184
+ events.push(...evs)
185
+ seq += evs.length
186
+ }
187
+
188
+ // 标题:custom-title / ai-title → session/title 事件(钉住,避免自动回退标题覆盖)。
189
+ const normalizedTitle = (title || '').trim()
190
+ if (normalizedTitle.length > 0) {
191
+ events.push({
192
+ type: 'session/title', seq: seq++, time: meta.createdAt,
193
+ data: { title: normalizedTitle, messageSeqs: [], source: { kind: 'user' } },
194
+ })
195
+ }
196
+
197
+ return {
198
+ meta,
199
+ events,
200
+ turns,
201
+ title,
202
+ messages: events.filter((e) => e.type === 'user/message' || e.type === 'assistant/message' || e.type === 'tool/result').length,
203
+ toolCalls: events.filter((e) => e.type === 'tool/call').length,
204
+ skipped,
205
+ records,
206
+ repaired,
207
+ ...(skippedLines !== undefined ? { skippedLines } : {}),
208
+ ...(typeCounts !== undefined ? { typeCounts } : {}),
209
+ }
210
+ }
211
+
212
+ // 逐行解析 JSONL:直连人类提问(type==='user' 且 content 为字符串)开新轮;每条
213
+ // assistant 消息 = 一步;其后的 tool_result 挂到最近一步。
214
+ //
215
+ // 扩展(相对上游):畸形行带行号明细(F10)、标题 custom-title > ai-title、
216
+ // 按记录类型计数(S5 权限类统计、未知类型统计)。
217
+ //
218
+ // 实现走 createClaudeStreamConverter(C3):与流式导入共用同一状态机,保证
219
+ // 两条路径事件纪律一致;全量模式 keepTurns 保留完整回合结构供 handoff/tail 使用。
220
+ export function convertClaudeJsonl(raw, args = {}) {
221
+ const batches = []
222
+ const converter = createClaudeStreamConverter({
223
+ sessionId: args.sessionId,
224
+ keepTurns: true,
225
+ onBatch: (events) => { batches.push(events) },
226
+ })
227
+ converter.feed(raw)
228
+ const result = converter.end()
229
+ return {
230
+ meta: result.meta,
231
+ events: batches.flat(),
232
+ turns: result.turns,
233
+ title: result.title,
234
+ messages: result.messages,
235
+ toolCalls: result.toolCalls,
236
+ skipped: result.skipped,
237
+ records: result.records,
238
+ skippedLines: result.skippedLines,
239
+ typeCounts: result.typeCounts,
240
+ repaired: result.repaired,
241
+ sourceId: result.sourceId,
242
+ }
243
+ }
244
+
245
+ /**
246
+ * 导入自校验(issue#1):验证合成的事件日志满足续聊协议不变式——
247
+ * 每个 tool/call 恰好一条 tool/result(不重不漏)、tool/result 的
248
+ * sourceEventSeqs 指向同轮声明的 tool/call、seq 从 startSeq 连续、
249
+ * turn/step 配对。转换器按构造保证这些不变式,本校验供测试与导入前断言使用。
250
+ * @param events - 合成的 DSH 事件数组。
251
+ * @param startSeq - 期望的首事件 seq(增量批次为存储长度,默认 0)。
252
+ * @returns 违规明细数组(空数组 = 通过)。
253
+ */
254
+ export function validateSessionEvents(events, startSeq = 0) {
255
+ const issues = []
256
+ if (!Array.isArray(events)) return ['events is not an array']
257
+ let seq = startSeq
258
+ const calls = new Map() // callId → seq
259
+ const answered = new Map() // callId → 已见结果数
260
+ let openTurns = 0
261
+ let openSteps = 0
262
+ for (const ev of events) {
263
+ if (!ev || typeof ev !== 'object') {
264
+ issues.push(`event at seq ${seq} is not an object`)
265
+ continue
266
+ }
267
+ if (ev.seq !== seq) issues.push(`seq gap at ${seq}: got ${ev.seq}`)
268
+ seq++
269
+ if (ev.type === 'turn/start') {
270
+ openTurns++
271
+ openSteps = 0
272
+ } else if (ev.type === 'turn/end') {
273
+ if (openTurns <= 0) issues.push(`turn/end without turn/start at seq ${ev.seq}`)
274
+ openTurns--
275
+ if (openSteps !== 0) issues.push(`turn ended with ${openSteps} open steps at seq ${ev.seq}`)
276
+ openSteps = 0
277
+ } else if (ev.type === 'step/start') {
278
+ openSteps++
279
+ } else if (ev.type === 'step/end') {
280
+ if (openSteps <= 0) issues.push(`step/end without step/start at seq ${ev.seq}`)
281
+ openSteps--
282
+ } else if (ev.type === 'tool/call') {
283
+ const id = ev.data?.callId
284
+ if (typeof id !== 'string' || id.length === 0) {
285
+ issues.push(`tool/call without callId at seq ${ev.seq}`)
286
+ continue
287
+ }
288
+ if (calls.has(id)) issues.push(`duplicate tool/call ${id} at seq ${ev.seq}`)
289
+ calls.set(id, ev.seq)
290
+ answered.set(id, 0)
291
+ } else if (ev.type === 'tool/result') {
292
+ const id = ev.data?.message?.content?.[0]?.toolCallId
293
+ if (typeof id !== 'string' || id.length === 0) {
294
+ issues.push(`tool/result without toolCallId at seq ${ev.seq}`)
295
+ continue
296
+ }
297
+ if (!answered.has(id)) {
298
+ issues.push(`tool/result ${id} has no tool/call at seq ${ev.seq}`)
299
+ continue
300
+ }
301
+ answered.set(id, answered.get(id) + 1)
302
+ const ref = Array.isArray(ev.sourceEventSeqs) ? ev.sourceEventSeqs[0] : undefined
303
+ if (ref !== calls.get(id)) {
304
+ issues.push(`tool/result ${id} sourceEventSeqs ${ref} does not match tool/call ${calls.get(id)} at seq ${ev.seq}`)
305
+ }
306
+ }
307
+ }
308
+ for (const [id, n] of answered) {
309
+ if (n === 0) issues.push(`tool/call ${id} has no tool/result`)
310
+ if (n > 1) issues.push(`tool/call ${id} has ${n} tool/result events`)
311
+ }
312
+ if (openTurns !== 0 || openSteps !== 0) issues.push(`unbalanced end: ${openTurns} turns, ${openSteps} steps open`)
313
+ return issues
314
+ }
315
+
316
+ /**
317
+ * Claude JSONL 流式转换器(C3):逐行 feed、按回合边界合成事件、经 onBatch
318
+ * 分批回调。onBatch 同步触发且返回值被忽略——需要顺序落盘的调用方在回调里
319
+ * 自行串行 append(Promise 链),并在 end() 后等待该链。内存 O(当前回合 +
320
+ * 单个批次),不再 O(文件);skipTurns>0 时前 N 个回合只计数不合成(续写
321
+ * 前缀),新事件 seq 从 startSeq 起。
322
+ *
323
+ * @param options - `sessionId`(目标会话 id 覆盖)、`fallbackSessionId`
324
+ * (源 id 缺失时的稳定 id)、`keepTurns`(保留完整回合结构,默认 false)、
325
+ * `skipTurns`(跳过已落盘的回合数)、`startSeq`(新事件起始 seq)、
326
+ * `batchEvents`(批大小,默认 10000)、`onBatch`(批次回调)。
327
+ * @returns `{ feed, end, meta }`;end() 返回 `{ meta, title, sourceId, turns,
328
+ * messages, toolCalls, skipped, skippedLines, typeCounts, records, emittedEvents }`
329
+ * ——turns 在 keepTurns 时为回合数组、否则为回合数。
330
+ */
331
+ export function createClaudeStreamConverter({
332
+ sessionId, fallbackSessionId, keepTurns = false,
333
+ skipTurns = 0, startSeq = 0, batchEvents = 10000, onBatch,
334
+ } = {}) {
335
+ const state = {
336
+ sourceId: null,
337
+ cwd: null,
338
+ createdAt: null,
339
+ model: null,
340
+ aiTitle: null,
341
+ customTitle: null,
342
+ typeCounts: {},
343
+ cur: null,
344
+ lastStep: null,
345
+ }
346
+ const turns = keepTurns ? [] : null
347
+ let skippedCount = 0
348
+ const skippedLines = []
349
+ let records = 0
350
+ let lineNo = 0
351
+ let turnCount = 0
352
+ let messages = 0
353
+ let toolCalls = 0
354
+ let nextSeq = startSeq
355
+ let emittedEvents = 0
356
+ let pending = []
357
+ let carry = ''
358
+ let meta = null
359
+ const repaired = { synthesized: 0, duplicateResults: 0, orphanResults: 0 }
360
+
361
+ const metaFor = () => {
362
+ if (meta === null) {
363
+ const id = sessionId
364
+ || (state.sourceId ? mintSessionId(state.sourceId) : fallbackSessionId)
365
+ || mintSessionId(String(Date.now()))
366
+ meta = { version: SESSION_FORMAT_VERSION, id, createdAt: state.createdAt ?? Date.now() }
367
+ if (state.cwd) meta.cwd = state.cwd
368
+ }
369
+ return meta
370
+ }
371
+
372
+ const flush = () => {
373
+ if (pending.length === 0) return
374
+ const batch = pending
375
+ pending = []
376
+ if (typeof onBatch === 'function') onBatch(batch)
377
+ }
378
+
379
+ const finalizeTurn = (t) => {
380
+ turnCount++
381
+ if (turns) turns.push(t)
382
+ if (turnCount <= skipTurns) return
383
+ const evs = synthesizeTurnEvents(metaFor(), turnCount, t, nextSeq, 'claude-code', state.model, repaired)
384
+ nextSeq += evs.length
385
+ messages += evs.filter((e) => e.type === 'user/message' || e.type === 'assistant/message' || e.type === 'tool/result').length
386
+ toolCalls += evs.filter((e) => e.type === 'tool/call').length
387
+ emittedEvents += evs.length
388
+ pending.push(...evs)
389
+ if (pending.length >= batchEvents) flush()
390
+ }
391
+
392
+ const feedLine = (rawLine) => {
393
+ lineNo++
394
+ const t = rawLine.trim()
395
+ if (!t) return
396
+ let rec
397
+ try {
398
+ rec = JSON.parse(t)
399
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
400
+ throw new SyntaxError('record is not a JSON object')
401
+ }
402
+ } catch (err) {
403
+ skippedCount++
404
+ if (skippedLines.length < MALFORMED_REPORT_CAP) {
405
+ skippedLines.push({ line: lineNo, error: String((err && err.message) || err) })
406
+ }
407
+ return
408
+ }
409
+ records++
410
+ const recType = typeof rec.type === 'string' ? rec.type : 'unknown'
411
+ state.typeCounts[recType] = (state.typeCounts[recType] ?? 0) + 1
412
+
413
+ if (typeof rec.sessionId === 'string' && !state.sourceId) state.sourceId = rec.sessionId
414
+ if (typeof rec.cwd === 'string' && !state.cwd) state.cwd = rec.cwd
415
+ if (typeof rec.timestamp === 'string' && state.createdAt === null) state.createdAt = parseTime(rec.timestamp)
416
+ if (rec.type === 'custom-title' && typeof rec.customTitle === 'string' && state.customTitle === null) {
417
+ state.customTitle = rec.customTitle
418
+ } else if (rec.type === 'ai-title' && typeof rec.aiTitle === 'string' && state.aiTitle === null) {
419
+ state.aiTitle = rec.aiTitle
420
+ }
421
+ const recModel = rec.message?.model ?? rec.model
422
+ if (typeof recModel === 'string' && !state.model) state.model = recModel
423
+
424
+ if (rec.type === 'user' && rec.message && typeof rec.message.content === 'string') {
425
+ // 直连人类提问 → 新轮
426
+ if (state.cur) finalizeTurn(state.cur)
427
+ state.cur = { prompt: rec.message.content, steps: [] }
428
+ state.lastStep = null
429
+ } else if (rec.type === 'assistant' && state.cur) {
430
+ // 一条 assistant 消息 = 一步
431
+ const step = { content: [], toolCalls: [], toolResults: [] }
432
+ if (Array.isArray(rec.message?.content)) {
433
+ for (const block of rec.message.content) {
434
+ const mapped = mapContentBlock(block)
435
+ if (!mapped) continue
436
+ if (mapped.type === 'tool-call') {
437
+ step.content.push(mapped) // 助手内容里的 tool-call block
438
+ step.toolCalls.push(mapped) // 同时作为 tool/call 事件
439
+ } else {
440
+ step.content.push(mapped) // text / reasoning block
441
+ }
442
+ }
443
+ } else if (typeof rec.message?.content === 'string') {
444
+ step.content.push({ type: 'text', text: rec.message.content })
445
+ }
446
+ state.cur.steps.push(step)
447
+ state.lastStep = step
448
+ } else if (rec.type === 'user' && Array.isArray(rec.message?.content) && state.cur && state.lastStep) {
449
+ // 工具结果:挂在最近一步
450
+ for (const block of rec.message.content) {
451
+ if (block && block.type === 'tool_result') {
452
+ const inner = (Array.isArray(block.content) ? block.content : [])
453
+ .map(mapContentBlock)
454
+ .filter(Boolean)
455
+ state.lastStep.toolResults.push({
456
+ toolCallId: block.tool_use_id,
457
+ content: inner,
458
+ isError: block.is_error === true,
459
+ })
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ return {
466
+ /**
467
+ * 喂入一段文本(可跨行边界分块);同步执行,onBatch 立即触发。
468
+ * @param chunk - 原始文本块。
469
+ */
470
+ feed(chunk) {
471
+ const text = carry + String(chunk ?? '')
472
+ const lines = text.split('\n')
473
+ carry = lines.pop() ?? ''
474
+ for (const line of lines) feedLine(line)
475
+ },
476
+ /**
477
+ * 结束输入:落盘最后的未完成回合与标题,flush 剩余批次。
478
+ * @returns 统计与元数据(见工厂 JSDoc)。
479
+ */
480
+ end() {
481
+ if (carry.length > 0) {
482
+ feedLine(carry)
483
+ carry = ''
484
+ }
485
+ if (state.cur) {
486
+ finalizeTurn(state.cur)
487
+ state.cur = null
488
+ }
489
+ const title = state.customTitle ?? state.aiTitle
490
+ const normalized = (title || '').trim()
491
+ if (normalized.length > 0 && turnCount > skipTurns) {
492
+ const m = metaFor()
493
+ pending.push({
494
+ type: 'session/title', seq: nextSeq++, time: m.createdAt,
495
+ data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
496
+ })
497
+ emittedEvents++
498
+ }
499
+ flush()
500
+ return {
501
+ meta: metaFor(),
502
+ title: title ?? null,
503
+ sourceId: state.sourceId ?? null,
504
+ turns: turns ?? turnCount,
505
+ messages,
506
+ toolCalls,
507
+ skipped: skippedCount,
508
+ skippedLines,
509
+ typeCounts: state.typeCounts,
510
+ records,
511
+ emittedEvents,
512
+ repaired,
513
+ }
514
+ },
515
+ /** 转换器将使用的会话 meta(可改 meta.id 以避让冲突,随后事件沿用新 id)。 */
516
+ meta: metaFor,
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 从一次完整转换中截取「第 fromTurn 轮及之后」的事件尾部,并重新编号:
522
+ * seq 从 fromSeq 连续(供增量 append 续写既有 DSH 会话日志)。轮次边界由
523
+ * turn/start 事件确定——不是每个事件都带 data.turn(如 user/message 就没有)。
524
+ * 末尾的 session/title 事件(无 turn)也一并带上(标题 last-wins,重复追加
525
+ * 无害)。工具结果事件的 sourceEventSeqs 重映射到尾部新 seq;指向尾部之外的
526
+ * 引用按原样保留(回合边界截取下不应出现)。
527
+ * @param converted - convertClaudeJsonl / convertCodexJsonl 输出。
528
+ * @param fromTurn - 尾部起始轮次(1 起)。
529
+ * @param fromSeq - 尾部第一个事件的 seq。
530
+ * @returns `{ events, firstTurn }`;无新事件时 events 为空数组。
531
+ */export function tailSessionEvents(converted, { fromTurn, fromSeq }) {
532
+ const keep = []
533
+ const oldToNew = new Map()
534
+ let currentTurn = null
535
+ for (const ev of converted.events ?? []) {
536
+ if (ev && ev.type === 'turn/start' && ev.data && typeof ev.data.turn === 'number') {
537
+ currentTurn = ev.data.turn
538
+ }
539
+ if (ev && ev.type === 'session/title') {
540
+ oldToNew.set(ev.seq, fromSeq + keep.length)
541
+ keep.push(ev)
542
+ continue
543
+ }
544
+ if (currentTurn !== null && currentTurn >= fromTurn) {
545
+ oldToNew.set(ev.seq, fromSeq + keep.length)
546
+ keep.push(ev)
547
+ }
548
+ }
549
+ return {
550
+ firstTurn: fromTurn,
551
+ events: keep.map((ev, i) => {
552
+ const next = { ...ev, seq: fromSeq + i }
553
+ if (Array.isArray(ev.sourceEventSeqs)) {
554
+ next.sourceEventSeqs = ev.sourceEventSeqs.map((s) => oldToNew.has(s) ? oldToNew.get(s) : s)
555
+ }
556
+ return next
557
+ }),
558
+ }
559
+ }
560
+
561
+ /**
562
+ * 为一次完整转换追加 session/title 事件(四合一向导:Codex/OpenCode 会话标题)。
563
+ * 源格式没有标题记录时用首个用户提问兜底;seq 接在最后事件之后。
564
+ * @param converted - convertXxxJsonl 输出(就地追加)。
565
+ * @param title - 标题文本。
566
+ * @returns 原 converted 对象。
567
+ */
568
+ export function appendTitleEvent(converted, title) {
569
+ const t = String(title ?? '').trim()
570
+ if (!converted || t.length === 0) return converted
571
+ const last = converted.events?.[converted.events.length - 1]
572
+ converted.events.push({
573
+ type: 'session/title', seq: (last?.seq ?? -1) + 1, time: converted.meta?.createdAt ?? Date.now(),
574
+ data: { title: t, messageSeqs: [], source: { kind: 'user' } },
575
+ })
576
+ return converted
577
+ }
578
+
579
+ // Codex / ChatGPT CLI rollout JSONL → 统一的回合中间结构。// 行 envelope:{ timestamp, type, payload }。只消费 response_item(模型产物)与
580
+ // session_meta / turn_context(元数据);event_msg 的 user_message / agent_message
581
+ // 是 response_item 的重复(schema 笔记明确警告会重复计数),一律忽略。
582
+ // 用户消息里以 `<` 开头的块(<environment_context>、<user_instructions>、
583
+ // <system-reminder> 等)是 harness 注入,不是人类输入,跳过。
584
+ export function convertCodexJsonl(raw, args = {}) {
585
+ const recs = []
586
+ let skipped = 0
587
+ const skippedLines = []
588
+ const typeCounts = {}
589
+ let lineNo = 0
590
+ for (const line of raw.split('\n')) {
591
+ lineNo++
592
+ const t = line.trim()
593
+ if (!t) continue
594
+ try {
595
+ const rec = JSON.parse(t)
596
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
597
+ throw new SyntaxError('record is not a JSON object')
598
+ }
599
+ recs.push(rec)
600
+ } catch (err) {
601
+ skipped++
602
+ if (skippedLines.length < MALFORMED_REPORT_CAP) {
603
+ skippedLines.push({ line: lineNo, error: String((err && err.message) || err) })
604
+ }
605
+ }
606
+ }
607
+
608
+ let sourceId = null
609
+ let cwd = null
610
+ let createdAt = null
611
+ let model = null
612
+ let title = null
613
+
614
+ // callId → 它所属的 step(跨行配对 function_call_output)
615
+ const callSteps = new Map()
616
+
617
+ const turns = []
618
+ let cur = null
619
+ let lastStep = null
620
+
621
+ // 新开一个「用户提问」回合。
622
+ const openTurn = (prompt) => {
623
+ cur = { prompt, steps: [] }
624
+ turns.push(cur)
625
+ lastStep = null
626
+ }
627
+
628
+ // 追加一步 assistant 产物(文本 / 工具调用);没有当前回合时忽略。
629
+ const openStep = () => {
630
+ const step = { content: [], toolCalls: [], toolResults: [] }
631
+ cur.steps.push(step)
632
+ lastStep = step
633
+ return step
634
+ }
635
+
636
+ for (const rec of recs) {
637
+ const env = rec && rec.type
638
+ const payload = rec && rec.payload
639
+ if (env === 'session_meta' && payload) {
640
+ if (!sourceId && typeof payload.id === 'string') sourceId = payload.id
641
+ if (!cwd && typeof payload.cwd === 'string') cwd = payload.cwd
642
+ if (createdAt === null) createdAt = parseTime(payload.timestamp ?? rec.timestamp)
643
+ continue
644
+ }
645
+ if (env === 'turn_context' && payload) {
646
+ if (!model && typeof payload.model === 'string') model = payload.model
647
+ continue
648
+ }
649
+ if (env !== 'response_item' || !payload) continue
650
+
651
+ if (payload.type === 'message') {
652
+ if (payload.role === 'user' && Array.isArray(payload.content)) {
653
+ // 过滤 harness 注入,剩余文本合并为用户提问
654
+ const parts = []
655
+ for (const block of payload.content) {
656
+ if (block && block.type === 'input_text' && typeof block.text === 'string') {
657
+ if (!block.text.startsWith('<')) parts.push(block.text)
658
+ }
659
+ }
660
+ const prompt = parts.join('\n').trim()
661
+ if (prompt) openTurn(prompt)
662
+ } else if (payload.role === 'assistant' && cur) {
663
+ const step = openStep()
664
+ for (const block of payload.content) {
665
+ if (block && block.type === 'output_text' && typeof block.text === 'string') {
666
+ step.content.push({ type: 'text', text: block.text })
667
+ }
668
+ }
669
+ }
670
+ // developer(系统注入)忽略
671
+ } else if ((payload.type === 'function_call' || payload.type === 'custom_tool_call') && cur) {
672
+ // 挂到最近的 assistant 步骤(一步 = assistant 消息 + 其工具调用);没有则新开一步
673
+ const step = lastStep || openStep()
674
+ const callId = payload.call_id
675
+ let argumentsText
676
+ if (payload.type === 'function_call') {
677
+ argumentsText = typeof payload.arguments === 'string' ? payload.arguments : JSON.stringify(payload.arguments ?? {})
678
+ } else {
679
+ // custom_tool_call(如 apply_patch):arguments 是自由格式 input
680
+ argumentsText = JSON.stringify(payload.input ?? {})
681
+ }
682
+ const mapped = {
683
+ id: callId,
684
+ name: payload.name || 'unknown',
685
+ arguments: argumentsText,
686
+ }
687
+ step.toolCalls.push(mapped)
688
+ if (callId) callSteps.set(callId, step)
689
+ } else if ((payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') && cur) {
690
+ const callId = payload.call_id
691
+ const step = callSteps.get(callId) || lastStep || openStep()
692
+ // output 可能是纯字符串,也可能是 {"output": "...", "metadata": {...}} JSON 字符串
693
+ let text
694
+ const out = payload.output
695
+ if (typeof out === 'string') {
696
+ let parsed = null
697
+ try { parsed = JSON.parse(out) } catch (_) { /* 纯文本 */ }
698
+ text = parsed && typeof parsed === 'object' && typeof parsed.output === 'string'
699
+ ? parsed.output
700
+ : out
701
+ } else if (out && typeof out === 'object' && typeof out.output === 'string') {
702
+ text = out.output
703
+ } else {
704
+ text = typeof out === 'string' ? out : JSON.stringify(out ?? '')
705
+ }
706
+ step.toolResults.push({
707
+ toolCallId: callId,
708
+ content: [{ type: 'text', text }],
709
+ isError: false,
710
+ })
711
+ }
712
+ // reasoning(内容加密,通常不可读)与其余事件忽略
713
+ }
714
+
715
+ const sessionId = args.sessionId || mintSessionId(sourceId)
716
+ const meta = { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: createdAt ?? Date.now() }
717
+ if (cwd) meta.cwd = cwd
718
+
719
+ return {
720
+ ...synthesizeSession({
721
+ meta, turns, title, provider: 'codex', model, skipped, records: recs.length, skippedLines, typeCounts,
722
+ }),
723
+ sourceId: sourceId ?? null,
724
+ }
725
+ }