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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +201 -0
- package/NOTICE +23 -0
- package/README.es.md +291 -0
- package/README.hi.md +292 -0
- package/README.md +317 -0
- package/README.pt.md +291 -0
- package/README.zh.md +311 -0
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/assets/social-card.png +0 -0
- package/client/client.js +451 -0
- package/cordis.patch.yml +5 -0
- package/index.mjs +2891 -0
- package/lib/agmd-section.mjs +144 -0
- package/lib/commands-migrate.mjs +85 -0
- package/lib/context.mjs +156 -0
- package/lib/convert.mjs +725 -0
- package/lib/discovery.mjs +619 -0
- package/lib/frontmatter.mjs +58 -0
- package/lib/handoff.mjs +136 -0
- package/lib/imports-store.mjs +64 -0
- package/lib/manifest.mjs +73 -0
- package/lib/persona.mjs +37 -0
- package/lib/report.mjs +63 -0
- package/lib/settings.mjs +147 -0
- package/lib/skill-migrate.mjs +128 -0
- package/lib/skills-provider.mjs +219 -0
- package/lib/sources/claude/mapper.mjs +102 -0
- package/lib/sources/claude/parser.mjs +190 -0
- package/lib/sources/codex/mapper.mjs +120 -0
- package/lib/sources/codex/parser.mjs +451 -0
- package/lib/sources/contract.mjs +145 -0
- package/lib/sources/hermes/mapper.mjs +61 -0
- package/lib/sources/hermes/parser.mjs +152 -0
- package/lib/sources/opencode/convert.mjs +236 -0
- package/lib/sources/opencode/mapper.mjs +102 -0
- package/lib/sources/opencode/parser.mjs +266 -0
- package/lib/wizard.mjs +329 -0
- package/package.json +66 -0
package/lib/convert.mjs
ADDED
|
@@ -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
|
+
}
|