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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/sources/hermes/parser.mjs — Hermes 源解析器(四合一迁移向导)。
|
|
3
|
+
//
|
|
4
|
+
// Hermes 范围 = skills 与记忆目录(不含会话):`~/.hermes/state.db` 不在范围,
|
|
5
|
+
// 永不读取。skills/ 是嵌套类别目录(skills/<category>/<name>/SKILL.md,类别
|
|
6
|
+
// 层数不定);memories/ 下 MEMORY.md / USER.md 是 `§` 分隔的记忆条目文件,内容
|
|
7
|
+
// 原样迁移。只读白名单只含这两个目录:config.yaml / .env / state.db / pending/ /
|
|
8
|
+
// skill-bundles/ / journey 数据等凭据与内部状态永不出现在白名单里。
|
|
9
|
+
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { assertAllowedRead, digestText, emptyDetection, recordError } from '../contract.mjs'
|
|
15
|
+
import { classifySkill, skipSkillEntry } from '../../skill-migrate.mjs'
|
|
16
|
+
|
|
17
|
+
export const source = 'hermes'
|
|
18
|
+
|
|
19
|
+
/** 目录/文件缺失(ENOENT/ENOTDIR)→ 视为空,不记错误。 */
|
|
20
|
+
function isMissingError(err) {
|
|
21
|
+
return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Hermes 数据根定位($HERMES_HOME / ~/.hermes)。 */
|
|
25
|
+
export function locateHome(env = process.env, home = homedir()) {
|
|
26
|
+
const raw = env.HERMES_HOME
|
|
27
|
+
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
28
|
+
return path.resolve(raw)
|
|
29
|
+
}
|
|
30
|
+
return path.join(home, '.hermes')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 只读白名单:仅 skills 与 memories 两个目录。 */
|
|
34
|
+
export function whitelist(home) {
|
|
35
|
+
return [
|
|
36
|
+
path.join(home, 'skills'),
|
|
37
|
+
path.join(home, 'memories'),
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读取单个 SKILL.md,产出技能条目(name 缺 frontmatter 时取叶目录名)。
|
|
43
|
+
* @param skillsRoot - skills 根目录(id 以其为相对基准)。
|
|
44
|
+
* @param file - SKILL.md 绝对路径。
|
|
45
|
+
* @param roots - 白名单根列表。
|
|
46
|
+
* @param detection - 用于 recordError。
|
|
47
|
+
* @param signal - 可选 AbortSignal。
|
|
48
|
+
* @returns 技能条目;读取失败记错误并返回 null。
|
|
49
|
+
*/
|
|
50
|
+
async function readSkill(skillsRoot, file, roots, detection, signal) {
|
|
51
|
+
let content
|
|
52
|
+
try {
|
|
53
|
+
content = await readFile(assertAllowedRead(roots, file), { encoding: 'utf8', ...(signal ? { signal } : {}) })
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (signal?.aborted) throw err
|
|
56
|
+
recordError(detection, 'skill:' + file, err)
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
const { compatible, name, description } = classifySkill(content)
|
|
60
|
+
const dir = path.dirname(file)
|
|
61
|
+
const id = path.relative(skillsRoot, dir).split(path.sep).join('/')
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
dir,
|
|
65
|
+
file,
|
|
66
|
+
name: name || path.basename(dir),
|
|
67
|
+
description,
|
|
68
|
+
compatible,
|
|
69
|
+
digest: digestText(content),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 递归遍历 skills/,跳过 `.` 开头目录/文件与 README.md/MEMORY.md(skipSkillEntry)。
|
|
75
|
+
* @returns SKILL.md 条目数组;目录缺失返回空数组。
|
|
76
|
+
*/
|
|
77
|
+
async function walkSkills(skillsRoot, dir, roots, out, detection, signal) {
|
|
78
|
+
if (signal?.aborted) return
|
|
79
|
+
let entries
|
|
80
|
+
try {
|
|
81
|
+
entries = await readdir(dir, { withFileTypes: true, ...(signal ? { signal } : {}) })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (isMissingError(err)) return
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (signal?.aborted) return
|
|
89
|
+
if (skipSkillEntry(entry.name)) continue
|
|
90
|
+
const full = path.join(dir, entry.name)
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
await walkSkills(skillsRoot, full, roots, out, detection, signal)
|
|
93
|
+
} else if (entry.isFile() && entry.name === 'SKILL.md') {
|
|
94
|
+
const skill = await readSkill(skillsRoot, full, roots, detection, signal)
|
|
95
|
+
if (skill) out.push(skill)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 扫描 skills/ 目录(缺失→空数组;读目录失败记错误)。 */
|
|
101
|
+
async function scanSkills(skillsRoot, detection, roots, signal) {
|
|
102
|
+
const out = []
|
|
103
|
+
try {
|
|
104
|
+
await walkSkills(skillsRoot, skillsRoot, roots, out, detection, signal)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (signal?.aborted) throw err
|
|
107
|
+
recordError(detection, 'skills', err)
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 扫描 memories/MEMORY.md 与 memories/USER.md(§ 分隔条目,缺失→跳过)。 */
|
|
113
|
+
async function scanMemories(memoriesRoot, detection, roots, signal) {
|
|
114
|
+
const out = []
|
|
115
|
+
for (const [id, kind] of [['MEMORY.md', 'hermes-memory'], ['USER.md', 'hermes-user']]) {
|
|
116
|
+
if (signal?.aborted) return out
|
|
117
|
+
const file = path.join(memoriesRoot, id)
|
|
118
|
+
let content
|
|
119
|
+
try {
|
|
120
|
+
content = await readFile(assertAllowedRead(roots, file), { encoding: 'utf8', ...(signal ? { signal } : {}) })
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (signal?.aborted) throw err
|
|
123
|
+
if (isMissingError(err)) continue
|
|
124
|
+
recordError(detection, 'memories/' + id, err)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
out.push({
|
|
128
|
+
id,
|
|
129
|
+
file,
|
|
130
|
+
kind,
|
|
131
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
132
|
+
digest: digestText(content),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 扫描 Hermes 数据根(skills + memories,不含会话)。
|
|
140
|
+
* @param home - 数据根目录。
|
|
141
|
+
* @param opts - `{ signal }`。
|
|
142
|
+
* @returns 统一 Detection;目录缺失返回空数组。
|
|
143
|
+
*/
|
|
144
|
+
export async function detect(home, { signal } = {}) {
|
|
145
|
+
const detection = emptyDetection(source, home)
|
|
146
|
+
const roots = whitelist(home)
|
|
147
|
+
const [skillsRoot, memoriesRoot] = roots
|
|
148
|
+
detection.homeExists = existsSync(home)
|
|
149
|
+
detection.skills = await scanSkills(skillsRoot, detection, roots, signal)
|
|
150
|
+
detection.memories = await scanMemories(memoriesRoot, detection, roots, signal)
|
|
151
|
+
return detection
|
|
152
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/sources/opencode/convert.mjs — OpenCode 会话 → DSH 会话事件(零 DSH 依赖)。
|
|
3
|
+
//
|
|
4
|
+
// OpenCode message/part 行 → 统一回合中间结构 → synthesizeSession(与
|
|
5
|
+
// Claude/Codex 共用事件纪律:turn/step 配对、tool/call 恰好一条结果、seq 连续)。
|
|
6
|
+
// 兼容两条存储路径:opencode.db(node:sqlite 只读)与旧版 storage JSON 文件。
|
|
7
|
+
//
|
|
8
|
+
// 映射(据 OpenCode message-v2 契约,2026 实测):
|
|
9
|
+
// - 用户 message(text parts,跳过 synthetic/ignored)→ 新回合提问;
|
|
10
|
+
// - assistant message → 一个或多个 step:step-start 开新步,text → text 块,
|
|
11
|
+
// reasoning → reasoning 块(明文,保留为日志内容、不进摘要),
|
|
12
|
+
// tool part(state.status=completed → 调用+结果;error → isError 结果;
|
|
13
|
+
// pending/running → 仅声明调用,合成错误结果兜底);
|
|
14
|
+
// - 标题:session.title 或首条提问截断。
|
|
15
|
+
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
18
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
19
|
+
import { synthesizeSession, appendTitleEvent, mintSessionId, SESSION_FORMAT_VERSION } from '../../convert.mjs'
|
|
20
|
+
import { truncateText } from '../contract.mjs'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从数据库读取一个会话的全部 message/part 行(按 (time_created,id) 排序)。
|
|
24
|
+
* @param dbPath - opencode.db 绝对路径。
|
|
25
|
+
* @param sessionId - session.id。
|
|
26
|
+
* @returns `{ session, messages }`;messages = [{ id, data, parts: [...] }]。
|
|
27
|
+
*/
|
|
28
|
+
export function loadDbSessionRows(dbPath, sessionId) {
|
|
29
|
+
const db = new DatabaseSync(dbPath, { readOnly: true })
|
|
30
|
+
try {
|
|
31
|
+
const session = db.prepare(
|
|
32
|
+
'SELECT id, title, directory, time_created FROM session WHERE id = ?',
|
|
33
|
+
).get(sessionId)
|
|
34
|
+
if (!session) return null
|
|
35
|
+
const rows = db.prepare(
|
|
36
|
+
'SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC',
|
|
37
|
+
).all(sessionId)
|
|
38
|
+
const partStmt = db.prepare(
|
|
39
|
+
'SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC',
|
|
40
|
+
)
|
|
41
|
+
const partsByMessage = new Map()
|
|
42
|
+
for (const row of partStmt.all(sessionId)) {
|
|
43
|
+
if (!partsByMessage.has(row.message_id)) partsByMessage.set(row.message_id, [])
|
|
44
|
+
partsByMessage.get(row.message_id).push(row.data)
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
session,
|
|
48
|
+
messages: rows.map((row) => ({
|
|
49
|
+
id: row.id,
|
|
50
|
+
data: safeJson(row.data),
|
|
51
|
+
parts: (partsByMessage.get(row.id) ?? []).map(safeJson).filter(Boolean),
|
|
52
|
+
})),
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
try { db.close() } catch { /* 关闭失败无碍 */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 旧版 JSON 布局读取:session 元数据 + message/part 文件。 */
|
|
60
|
+
export async function loadLegacySessionRows(dataHome, sessionId) {
|
|
61
|
+
const sessionFile = path.join(dataHome, 'storage', 'session', 'global', `${sessionId}.json`)
|
|
62
|
+
const messageDir = path.join(dataHome, 'storage', 'message', sessionId)
|
|
63
|
+
let session
|
|
64
|
+
try {
|
|
65
|
+
session = JSON.parse(await readFile(sessionFile, 'utf8'))
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
const rows = []
|
|
70
|
+
let names = []
|
|
71
|
+
try {
|
|
72
|
+
names = (await readdir(messageDir)).filter((n) => n.startsWith('msg_') && n.endsWith('.json')).sort()
|
|
73
|
+
} catch {
|
|
74
|
+
names = []
|
|
75
|
+
}
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
try {
|
|
78
|
+
const msg = JSON.parse(await readFile(path.join(messageDir, name), 'utf8'))
|
|
79
|
+
const parts = []
|
|
80
|
+
const partDir = path.join(dataHome, 'storage', 'part', msg.id ?? name.replace(/\.json$/, ''))
|
|
81
|
+
let partNames = []
|
|
82
|
+
try {
|
|
83
|
+
partNames = (await readdir(partDir)).filter((n) => n.startsWith('prt_') && n.endsWith('.json')).sort()
|
|
84
|
+
} catch {
|
|
85
|
+
partNames = []
|
|
86
|
+
}
|
|
87
|
+
for (const pn of partNames) {
|
|
88
|
+
try {
|
|
89
|
+
parts.push(JSON.parse(await readFile(path.join(partDir, pn), 'utf8')))
|
|
90
|
+
} catch {
|
|
91
|
+
// 畸形 part 文件:跳过(转换期容忍)。
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
rows.push({ id: msg.id, data: { role: msg.role ?? 'assistant', modelID: msg.modelID, providerID: msg.providerID }, parts })
|
|
95
|
+
} catch {
|
|
96
|
+
// 畸形 message 文件:跳过。
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { session, messages: rows }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function safeJson(text) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(String(text ?? ''))
|
|
105
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 文本块是否可作提问内容(排除 synthetic/ignored 与空文本)。 */
|
|
112
|
+
function usableText(part) {
|
|
113
|
+
return part
|
|
114
|
+
&& part.type === 'text'
|
|
115
|
+
&& typeof part.text === 'string'
|
|
116
|
+
&& part.synthetic !== true
|
|
117
|
+
&& part.ignored !== true
|
|
118
|
+
&& part.text.trim().length > 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 把 message/part 行合成为平衡的 DSH 会话事件日志。
|
|
123
|
+
* @param loaded - loadDbSessionRows / loadLegacySessionRows 的输出。
|
|
124
|
+
* @param args - `{ sessionId? }`(目标 id 覆盖)。
|
|
125
|
+
* @returns convertXxxJsonl 同构输出 `{ meta, events, turns, title, messages, toolCalls, skipped, skippedLines, typeCounts, repaired, sourceId }`。
|
|
126
|
+
*/
|
|
127
|
+
export function convertOpencodeRows(loaded, args = {}) {
|
|
128
|
+
if (!loaded) {
|
|
129
|
+
return {
|
|
130
|
+
meta: { version: SESSION_FORMAT_VERSION, id: args.sessionId ?? 'import-opencode', createdAt: Date.now() },
|
|
131
|
+
events: [], turns: [], title: null, messages: 0, toolCalls: 0,
|
|
132
|
+
skipped: 0, skippedLines: [], typeCounts: {}, repaired: { synthesized: 0, duplicateResults: 0, orphanResults: 0 }, sourceId: null,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const { session, messages } = loaded
|
|
136
|
+
const sourceId = session?.id ?? null
|
|
137
|
+
const createdAt = typeof session?.time_created === 'number' && session.time_created > 0
|
|
138
|
+
? session.time_created
|
|
139
|
+
: undefined
|
|
140
|
+
const title = session?.title ? truncateText(session.title, 120) : null
|
|
141
|
+
|
|
142
|
+
const turns = []
|
|
143
|
+
let cur = null
|
|
144
|
+
let model = null
|
|
145
|
+
let firstPrompt = null
|
|
146
|
+
|
|
147
|
+
const openTurn = (prompt) => {
|
|
148
|
+
cur = { prompt, steps: [] }
|
|
149
|
+
turns.push(cur)
|
|
150
|
+
}
|
|
151
|
+
const openStep = () => {
|
|
152
|
+
const step = { content: [], toolCalls: [], toolResults: [] }
|
|
153
|
+
cur.steps.push(step)
|
|
154
|
+
return step
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const row of messages) {
|
|
158
|
+
const data = row.data ?? {}
|
|
159
|
+
const role = data.role
|
|
160
|
+
const parts = row.parts ?? []
|
|
161
|
+
if (role === 'user') {
|
|
162
|
+
const prompt = parts.filter(usableText).map((p) => p.text).join('\n').trim()
|
|
163
|
+
if (prompt) {
|
|
164
|
+
if (firstPrompt === null) firstPrompt = prompt
|
|
165
|
+
openTurn(prompt)
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (role !== 'assistant' || !cur) continue
|
|
170
|
+
if (typeof data.modelID === 'string' && !model) model = data.modelID
|
|
171
|
+
|
|
172
|
+
let step = null
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
if (!part || typeof part !== 'object') continue
|
|
175
|
+
if (part.type === 'step-start') {
|
|
176
|
+
step = openStep()
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (part.type === 'step-finish') {
|
|
180
|
+
step = null
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (!step) step = openStep()
|
|
184
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
185
|
+
step.content.push({ type: 'text', text: part.text })
|
|
186
|
+
} else if (part.type === 'reasoning' && typeof part.text === 'string') {
|
|
187
|
+
step.content.push({ type: 'reasoning', text: part.text })
|
|
188
|
+
} else if (part.type === 'tool') {
|
|
189
|
+
const state = part.state ?? {}
|
|
190
|
+
const callId = String(part.callID ?? `opencode-${part.id ?? 'call'}`)
|
|
191
|
+
step.toolCalls.push({
|
|
192
|
+
id: callId,
|
|
193
|
+
name: part.tool ?? 'unknown',
|
|
194
|
+
arguments: typeof state.input === 'string' ? state.input : JSON.stringify(state.input ?? {}),
|
|
195
|
+
})
|
|
196
|
+
if (state.status === 'completed') {
|
|
197
|
+
step.toolResults.push({
|
|
198
|
+
toolCallId: callId,
|
|
199
|
+
content: [{ type: 'text', text: typeof state.output === 'string' ? state.output : JSON.stringify(state.output ?? '') }],
|
|
200
|
+
isError: false,
|
|
201
|
+
})
|
|
202
|
+
} else if (state.status === 'error') {
|
|
203
|
+
step.toolResults.push({
|
|
204
|
+
toolCallId: callId,
|
|
205
|
+
content: [{ type: 'text', text: typeof state.error === 'string' ? state.error : JSON.stringify(state.error ?? '') }],
|
|
206
|
+
isError: true,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
// pending/running:仅声明调用 → synthesizeTurnEvents 补合成错误结果。
|
|
210
|
+
}
|
|
211
|
+
// step 之后不再回到 null:同一 assistant message 内未标 step-start 的内容
|
|
212
|
+
// 归入当前步(上游会反复 step-start/step-finish,容错起见保持单调)。
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const meta = {
|
|
217
|
+
version: SESSION_FORMAT_VERSION,
|
|
218
|
+
id: args.sessionId ?? mintSessionId(sourceId ?? 'opencode'),
|
|
219
|
+
createdAt: createdAt ?? Date.now(),
|
|
220
|
+
}
|
|
221
|
+
if (session?.directory) meta.cwd = session.directory
|
|
222
|
+
|
|
223
|
+
const synthesized = synthesizeSession({
|
|
224
|
+
meta,
|
|
225
|
+
turns,
|
|
226
|
+
title: null, // 标题统一由 appendTitleEvent 追加一次(避免重复标题事件)
|
|
227
|
+
provider: 'opencode',
|
|
228
|
+
model,
|
|
229
|
+
skipped: 0,
|
|
230
|
+
records: messages.length,
|
|
231
|
+
skippedLines: [],
|
|
232
|
+
typeCounts: {},
|
|
233
|
+
})
|
|
234
|
+
appendTitleEvent(synthesized, title ?? truncateText(firstPrompt ?? '', 120))
|
|
235
|
+
return { ...synthesized, sourceId }
|
|
236
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// lib/sources/opencode/mapper.mjs — OpenCode 源映射器(四合一迁移向导,纯函数)。
|
|
3
|
+
//
|
|
4
|
+
// 清单 → 迁移计划:
|
|
5
|
+
// - sessions → import-session(provider 'opencode';转换在 index.mjs 执行期经
|
|
6
|
+
// loadDbSessionRows / loadLegacySessionRows + convertOpencodeRows 完成;
|
|
7
|
+
// 幂等走一期 imports.json,无 digest)。
|
|
8
|
+
// - agents(<configHome>/agent/*.md)→ convert-copy:转换为 DSH 技能
|
|
9
|
+
// (合成 name/description frontmatter;正文原样)。
|
|
10
|
+
// - commands(<configHome>/command/*.md)→ 纯提示词 → register-command;
|
|
11
|
+
// 含 shell(```! 围栏/shebang)→ unsupported。
|
|
12
|
+
// - instructions(全局 AGENTS.md)→ append-section(项目级 AGENTS.md DSH
|
|
13
|
+
// 原生读取,不迁移)。
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises'
|
|
16
|
+
import { planKey } from '../contract.mjs'
|
|
17
|
+
import { defaultAgentsMdPath } from '../../agmd-section.mjs'
|
|
18
|
+
import { skillTargetPath, kebabName } from '../../skill-migrate.mjs'
|
|
19
|
+
import { commandPlan } from '../../commands-migrate.mjs'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 映射 OpenCode 检测清单为迁移计划。
|
|
23
|
+
* @param source - 'opencode'。
|
|
24
|
+
* @param detection - opencode/parser.mjs 的 detect() 输出。
|
|
25
|
+
* @param opts - `{ skillsDir, agentsMdPath }`。
|
|
26
|
+
* @returns `{ plans, errors }`。
|
|
27
|
+
*/
|
|
28
|
+
export async function mapSource(source, detection, opts = {}) {
|
|
29
|
+
const plans = []
|
|
30
|
+
const errors = []
|
|
31
|
+
const agentsMdPath = opts.agentsMdPath ?? defaultAgentsMdPath()
|
|
32
|
+
const skillsDir = opts.skillsDir
|
|
33
|
+
|
|
34
|
+
for (const session of detection.sessions ?? []) {
|
|
35
|
+
plans.push({
|
|
36
|
+
key: planKey(source, 'session', `${session.format}:${session.sessionId ?? session.id}`),
|
|
37
|
+
from: source,
|
|
38
|
+
kind: 'session',
|
|
39
|
+
action: 'import-session',
|
|
40
|
+
source: {
|
|
41
|
+
file: session.file,
|
|
42
|
+
sessionId: session.sessionId ?? session.id,
|
|
43
|
+
storage: session.storage,
|
|
44
|
+
dataHome: detection.home,
|
|
45
|
+
title: session.title,
|
|
46
|
+
cwd: session.cwd,
|
|
47
|
+
format: session.format,
|
|
48
|
+
importKey: `${session.storage}:${session.sessionId ?? session.id}`,
|
|
49
|
+
turns: session.turns,
|
|
50
|
+
},
|
|
51
|
+
target: {},
|
|
52
|
+
provider: 'opencode',
|
|
53
|
+
title: session.title,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const skill of detection.skills ?? []) {
|
|
58
|
+
plans.push({
|
|
59
|
+
key: planKey(source, 'skill', skill.id),
|
|
60
|
+
from: source,
|
|
61
|
+
kind: 'skill',
|
|
62
|
+
action: 'convert-copy',
|
|
63
|
+
source: { file: skill.file, dir: skill.dir, name: skill.name },
|
|
64
|
+
target: skillsDir ? { path: skillTargetPath(skillsDir, kebabName(skill.name)) } : {},
|
|
65
|
+
digest: skill.digest,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const command of detection.commands ?? []) {
|
|
70
|
+
try {
|
|
71
|
+
const content = await readFile(command.file, 'utf8')
|
|
72
|
+
plans.push(commandPlan(source, 'command', command.id, {
|
|
73
|
+
file: command.file,
|
|
74
|
+
name: command.name,
|
|
75
|
+
promptOnly: command.promptOnly,
|
|
76
|
+
prompt: content,
|
|
77
|
+
digest: command.digest,
|
|
78
|
+
}))
|
|
79
|
+
} catch (err) {
|
|
80
|
+
errors.push(`command:${command.file}: ${String((err && err.message) || err)}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const instruction of detection.instructions ?? []) {
|
|
85
|
+
try {
|
|
86
|
+
const content = await readFile(instruction.file, 'utf8')
|
|
87
|
+
plans.push({
|
|
88
|
+
key: planKey(source, 'instruction', instruction.id),
|
|
89
|
+
from: source,
|
|
90
|
+
kind: 'instruction',
|
|
91
|
+
action: 'append-section',
|
|
92
|
+
source: { file: instruction.file, kind: instruction.kind },
|
|
93
|
+
target: { path: agentsMdPath },
|
|
94
|
+
content,
|
|
95
|
+
digest: instruction.digest,
|
|
96
|
+
})
|
|
97
|
+
} catch (err) {
|
|
98
|
+
errors.push(`instruction:${instruction.file}: ${String((err && err.message) || err)}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { plans, ...(errors.length > 0 ? { errors } : {}) }
|
|
102
|
+
}
|