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,144 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/agmd-section.mjs — $DSH_HOME/AGENTS.md 管理段读写(零 DSH 依赖)。
3
+ //
4
+ // 迁移记忆/指令文件到 DSH 全局 AGENTS.md:每个条目一个带标记注释的管理段,
5
+ // 追加写、幂等替换、冲突 diff。绝不重写管理段之外的既有内容。
6
+ //
7
+ // 段格式:
8
+ // <!-- dsh-move:managed:start <key> -->
9
+ // <content>
10
+ // <!-- source: <sourceFile> -->
11
+ // <!-- dsh-move:managed:end <key> -->
12
+
13
+ import path from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { digestText } from './sources/contract.mjs'
16
+
17
+ /**
18
+ * DSH 全局 AGENTS.md 默认路径:$DSH_HOME/AGENTS.md,DSH_HOME 缺失时 ~/.dsh/AGENTS.md。
19
+ * @param env - 环境对象,缺省 process.env。
20
+ * @returns 绝对路径。
21
+ */
22
+ export function defaultAgentsMdPath(env = process.env) {
23
+ const base = env.DSH_HOME || path.join(homedir(), '.dsh')
24
+ return path.join(base, 'AGENTS.md')
25
+ }
26
+
27
+ /** 段渲染:内容 + 来源行 + 首尾标记(行间无多余空行,摘要比较稳定)。 */
28
+ export function renderSection(key, content, sourceFile) {
29
+ const src = sourceFile ? `<!-- source: ${sourceFile} -->\n` : ''
30
+ return `<!-- dsh-move:managed:start ${key} -->\n${String(content ?? '').trim()}\n${src}<!-- dsh-move:managed:end ${key} -->`
31
+ }
32
+
33
+ /** 段内文本(与 renderSection 的标记间内容一致,供摘要比较)。 */
34
+ export function sectionInner(content, sourceFile) {
35
+ return String(content ?? '').trim() + (sourceFile ? `\n<!-- source: ${sourceFile} -->` : '')
36
+ }
37
+
38
+ /**
39
+ * 解析现有 AGENTS.md:定位全部管理段(含未闭合段容错)。
40
+ * @param text - 全文。
41
+ * @returns `{ sections: Map<key, {startLine, endLine, raw}>, ordered: string[] }`。
42
+ */
43
+ export function parseSections(text) {
44
+ const raw = String(text ?? '')
45
+ const lines = raw.split(/\r?\n/)
46
+ const sections = new Map()
47
+ const ordered = []
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const m = lines[i].match(/^<!--\s*dsh-move:managed:start\s+([\w:./\\-]+)\s*-->$/)
50
+ if (!m) continue
51
+ const key = m[1]
52
+ const startLine = i
53
+ let endLine = -1
54
+ for (let j = i + 1; j < lines.length; j++) {
55
+ if (lines[j].trim() === `<!-- dsh-move:managed:end ${key} -->`) {
56
+ endLine = j
57
+ break
58
+ }
59
+ }
60
+ if (endLine < 0) continue // 未闭合:按普通文本处理,不吞掉。
61
+ sections.set(key, { startLine, endLine, raw: lines.slice(startLine + 1, endLine).join('\n') })
62
+ ordered.push(key)
63
+ i = endLine
64
+ }
65
+ return { sections, ordered, lines }
66
+ }
67
+
68
+ /**
69
+ * 计算把某段写入 AGENTS.md 的计划。
70
+ * @param current - 现有全文(可为空)。
71
+ * @param key - 段 key。
72
+ * @param content - 新段内容。
73
+ * @param sourceFile - 来源文件(渲染进段内)。
74
+ * @returns 无既有段 → `{ status: 'new', text }`;同摘要 → `{ status: 'unchanged' }`;
75
+ * 有既有段且不同 → `{ status: 'replace', text, oldContent, newContent, diff }`。
76
+ */
77
+ export function planSection(current, key, content, sourceFile) {
78
+ const rendered = renderSection(key, content, sourceFile)
79
+ const { sections, lines } = parseSections(current)
80
+ const existing = sections.get(key)
81
+ if (!existing) {
82
+ const base = current && current.trim().length > 0
83
+ ? current.replace(/\s+$/, '') + '\n\n'
84
+ : ''
85
+ return { status: 'new', text: base + rendered + '\n' }
86
+ }
87
+ if (digestText(existing.raw) === digestText(sectionInner(content, sourceFile))) {
88
+ return { status: 'unchanged' }
89
+ }
90
+ const rebuilt = []
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const hit = [...sections.values()].find((s) => s.startLine === i)
93
+ if (hit) {
94
+ rebuilt.push(hit.startLine === existing.startLine && hit.endLine === existing.endLine
95
+ ? rendered
96
+ : lines.slice(hit.startLine, hit.endLine + 1).join('\n'))
97
+ i = hit.endLine
98
+ continue
99
+ }
100
+ rebuilt.push(lines[i])
101
+ }
102
+ const text = rebuilt.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n'
103
+ return {
104
+ status: 'replace',
105
+ text,
106
+ oldContent: existing.raw,
107
+ newContent: content.trim(),
108
+ diff: lineDiff(existing.raw, rendered),
109
+ }
110
+ }
111
+
112
+ /** 段合并(merge 冲突解法):旧段内容后接新内容(记忆条目追加语义)。 */
113
+ export function mergedSection(current, key, content, sourceFile) {
114
+ const { sections } = parseSections(current)
115
+ const existing = sections.get(key)
116
+ const merged = existing
117
+ ? existing.raw.replace(/<!-- source: .* -->\s*$/, '').trimEnd() + '\n\n' + String(content ?? '').trim()
118
+ : String(content ?? '').trim()
119
+ return planSection(current, key, merged, sourceFile)
120
+ }
121
+
122
+ /**
123
+ * 计算两个文本的简化行级 diff(预览用,不含上下文行)。
124
+ * @param oldText - 旧内容。
125
+ * @param newText - 新内容。
126
+ * @returns `- 旧行` / `+ 新行` 数组(上限 200 行)。
127
+ */
128
+ export function lineDiff(oldText, newText, cap = 200) {
129
+ const a = String(oldText ?? '').split(/\r?\n/)
130
+ const b = String(newText ?? '').split(/\r?\n/)
131
+ const diff = []
132
+ const len = Math.max(a.length, b.length)
133
+ for (let i = 0; i < len && diff.length < cap; i++) {
134
+ if (i >= a.length) {
135
+ diff.push('+ ' + b[i])
136
+ } else if (i >= b.length) {
137
+ diff.push('- ' + a[i])
138
+ } else if (a[i] !== b[i]) {
139
+ diff.push('- ' + a[i])
140
+ if (diff.length < cap) diff.push('+ ' + b[i])
141
+ }
142
+ }
143
+ return diff
144
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/commands-migrate.mjs — 四源钩子/命令 → DSH 命令/不支持清单(零 DSH 依赖)。
3
+ //
4
+ // 映射规则:
5
+ // - 纯提示词命令(无 shell/脚本执行)→ DSH 命令:迁移后把提示词注入当前会话
6
+ // (与一期 /resume-claude 的注入机制一致),绝不自动执行任何脚本。
7
+ // - 含 shell 的命令、事件/权限钩子 → 明确「不支持」清单:报告列出原因与建议,
8
+ // 绝不静默丢弃,也绝不把未审查脚本注册成可执行命令。
9
+ // - DSH 事件面(tools/post-execute 等)不能由插件给宿主补挂钩子,因此
10
+ // Codex hooks / Claude settings.json hooks 一律进不支持清单并附建议。
11
+
12
+ import { kebabName } from './skill-migrate.mjs'
13
+
14
+ /** 命令名 → DSH 命令名(kebab-case)。 */
15
+ export function toDshCommandName(raw) {
16
+ return kebabName(raw, 'migrated-command')
17
+ }
18
+
19
+ /**
20
+ * 分类一个命令/钩子文件:
21
+ * @param content - 文件原文。
22
+ * @param name - 命令名。
23
+ * @returns `{ promptOnly, prompt, hasShell }`。
24
+ * OpenCode 命令的 ` ```!...``` ` 围栏 = 终端命令;Codex command.md 含 shebang
25
+ * 或 `#!/` 行视为 shell;其余视为纯提示词。
26
+ */
27
+ export function classifyCommand(content, name = 'command') {
28
+ const text = String(content ?? '')
29
+ const hasShell = /```![^\n]*[\s\S]*?```/.test(text) || /^\s*#![^\n]*\n/.test(text)
30
+ return {
31
+ promptOnly: !hasShell,
32
+ prompt: text.trim(),
33
+ hasShell,
34
+ name: toDshCommandName(name),
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 生成命令迁移计划(纯提示词 → register-command;含 shell → unsupported)。
40
+ * @param source - 源标识。
41
+ * @param kind - 'command'。
42
+ * @param id - 命令 id(文件路径或名称)。
43
+ * @param entry - classifyCommand 结果 + 源文件。
44
+ * @returns 迁移计划对象。
45
+ */
46
+ export function commandPlan(source, kind, id, entry) {
47
+ const base = {
48
+ source: { file: entry.file, name: entry.name },
49
+ digest: entry.digest,
50
+ }
51
+ if (entry.promptOnly) {
52
+ return {
53
+ key: `${source}:${kind}:${id}`,
54
+ from: source,
55
+ kind,
56
+ action: 'register-command',
57
+ target: { commandName: entry.name },
58
+ content: entry.prompt,
59
+ ...base,
60
+ }
61
+ }
62
+ return {
63
+ key: `${source}:${kind}:${id}`,
64
+ from: source,
65
+ kind,
66
+ action: 'unsupported',
67
+ target: { commandName: entry.name },
68
+ content: entry.prompt,
69
+ reason: '命令含 shell 脚本:迁移器不注册可执行命令(安全边界),请人工审查后在 DSH 中重建',
70
+ ...base,
71
+ }
72
+ }
73
+
74
+ /** 钩子 → 不支持清单条目的统一文案(附 DSH 对应面建议)。 */
75
+ export function hookUnsupportedPlan(source, id, file, reason) {
76
+ return {
77
+ key: `${source}:hook:${id}`,
78
+ from: source,
79
+ kind: 'hook',
80
+ action: 'unsupported',
81
+ source: { file },
82
+ reason: reason
83
+ ?? '事件/权限钩子在 DSH 无插件级等价 seam(宿主 tools/post-execute 瀑布需 composition 级接入),未自动迁移',
84
+ }
85
+ }
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/context.mjs — memory 与 CLAUDE.md 的同步注入核心(F11/F13,零 DSH 依赖)。
3
+ //
4
+ // rc.6 的 systemPrompt 提供者是同步签名且组装不 await(实测),因此本模块
5
+ // 只使用 statSync/readFileSync + mtime 缓存:每次请求按 mtime 重读变化文件,
6
+ // 新记忆即时生效且不阻塞事件循环超过毫秒级。注入思路沿用
7
+ // YYTbit/dsh-plugin-claude-bridge(MIT,见 THIRD_PARTY_NOTICES.md)。
8
+
9
+ import { statSync, readFileSync, existsSync } from 'node:fs'
10
+ import { readdirSync } from 'node:fs'
11
+ import path from 'node:path'
12
+ import { parseFrontmatter, extractMetadataType } from './frontmatter.mjs'
13
+
14
+ /** memory 类型优先级(F11):feedback > project > reference > user。 */
15
+ export const MEMORY_TYPE_PRIORITY = Object.freeze({
16
+ feedback: 0,
17
+ project: 1,
18
+ reference: 2,
19
+ user: 3,
20
+ })
21
+
22
+ /** 默认 memory 注入字节上限(F11)。 */
23
+ export const DEFAULT_MEMORY_MAX_BYTES = 8192
24
+
25
+ /** 默认 memory 注入范围(B3):只注入当前会话 cwd 对应项目的记忆。 */
26
+ export const DEFAULT_MEMORY_SCOPE = 'current-project'
27
+
28
+ /**
29
+ * 选择参与注入的 memory 目录(B3):'current-project' 只取当前项目目录
30
+ * (无对应项目时回退全部目录保底,避免非 Claude 项目会话丢失记忆);
31
+ * 'all' 取全部目录、当前项目排最前。
32
+ * @param dirs - 全部 memory 目录(memoryDirsSync 输出)。
33
+ * @param currentDir - 当前会话 cwd 对应的 memory 目录(可为 null)。
34
+ * @param scope - 'current-project' | 'all'。
35
+ * @returns 排序后的目录数组。
36
+ */
37
+ export function selectMemoryDirs(dirs, currentDir, scope = DEFAULT_MEMORY_SCOPE) {
38
+ const current = currentDir !== null && currentDir !== undefined && dirs.includes(currentDir)
39
+ ? currentDir
40
+ : null
41
+ if (scope === 'all') {
42
+ return current ? [current, ...dirs.filter((d) => d !== current)] : [...dirs]
43
+ }
44
+ return current ? [current] : [...dirs]
45
+ }
46
+
47
+ /**
48
+ * 同步文件缓存:path → { mtimeMs, ctimeMs, size, text }。
49
+ * mtime+ctime+size 全部未变才复用(ctime 覆盖同毫秒等尺寸重写)。
50
+ * @returns `{ read(file) }`;文件不存在/不可读返回 null。
51
+ */
52
+ export function makeFileCache() {
53
+ const cache = new Map()
54
+ return {
55
+ read(file) {
56
+ try {
57
+ const st = statSync(file)
58
+ if (!st.isFile()) return null
59
+ const prev = cache.get(file)
60
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.ctimeMs === st.ctimeMs && prev.size === st.size) {
61
+ return prev.text
62
+ }
63
+ const text = readFileSync(file, 'utf8')
64
+ cache.set(file, { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, text })
65
+ return text
66
+ } catch {
67
+ // 缺失/不可读:按不存在处理,不抛(注入层容错)。
68
+ return null
69
+ }
70
+ },
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 同步读取一个 memory 目录的全部条目(frontmatter 解析、空体跳过)。
76
+ * @param dir - `~/.claude/projects/<slug>/memory`。
77
+ * @param cache - makeFileCache 实例。
78
+ * @returns `[{ name, type, content, path }]`;目录缺失返回空数组。
79
+ */
80
+ export function readMemoriesSync(dir, cache) {
81
+ let entries
82
+ try {
83
+ entries = readdirSync(dir)
84
+ } catch {
85
+ return []
86
+ }
87
+ const memories = []
88
+ for (const file of entries) {
89
+ if (!file.endsWith('.md') || file === 'MEMORY.md') continue
90
+ const filePath = path.join(dir, file)
91
+ const content = cache.read(filePath)
92
+ if (content === null) continue
93
+ const { meta, body } = parseFrontmatter(content)
94
+ if (body.trim().length === 0) continue
95
+ memories.push({
96
+ name: meta.name || file.replace(/\.md$/, ''),
97
+ type: extractMetadataType(meta),
98
+ content: body,
99
+ path: filePath,
100
+ })
101
+ }
102
+ return memories
103
+ }
104
+
105
+ /**
106
+ * 渲染记忆上下文段(F11):按类型优先级排序,字节上限内保留完整条目。
107
+ * @param memories - readMemoriesSync 输出。
108
+ * @param maxBytes - 默认 DEFAULT_MEMORY_MAX_BYTES。
109
+ * @returns 渲染文本;无记忆返回 ''。
110
+ */
111
+ export function renderMemories(memories, maxBytes = DEFAULT_MEMORY_MAX_BYTES) {
112
+ if (memories.length === 0) return ''
113
+ const sorted = [...memories].sort((a, b) => (
114
+ (MEMORY_TYPE_PRIORITY[a.type] ?? 99) - (MEMORY_TYPE_PRIORITY[b.type] ?? 99)
115
+ ))
116
+ const lines = ['# Agent Memory (from Claude Code)', '']
117
+ let bytes = 0
118
+ for (const memory of sorted) {
119
+ const block = [
120
+ `## ${memory.name} (${memory.type})`,
121
+ '',
122
+ memory.content,
123
+ '',
124
+ ].join('\n')
125
+ if (bytes + Buffer.byteLength(block, 'utf8') > maxBytes) break
126
+ lines.push(...block.split('\n'))
127
+ bytes += Buffer.byteLength(block, 'utf8')
128
+ }
129
+ return lines.join('\n').trimEnd()
130
+ }
131
+
132
+ /**
133
+ * 渲染 CLAUDE.md 指令段(F13):项目级在前(当前项目优先),全局在后。
134
+ * @param projectText - 项目 `.claude/CLAUDE.md` 文本(可为 null)。
135
+ * @param globalText - 全局 `~/.claude/CLAUDE.md` 文本(可为 null)。
136
+ * @returns 渲染文本;两者皆空返回 ''。
137
+ */
138
+ export function renderClaudeMd(projectText, globalText) {
139
+ const parts = []
140
+ if (projectText && projectText.trim().length > 0) {
141
+ parts.push('# Project Instructions (from Claude Code)\n\n' + projectText.trim())
142
+ }
143
+ if (globalText && globalText.trim().length > 0) {
144
+ parts.push('# Global Instructions (from Claude Code)\n\n' + globalText.trim())
145
+ }
146
+ return parts.join('\n\n')
147
+ }
148
+
149
+ /**
150
+ * 判断一个路径是否存在(同步,供提供者内联判断)。
151
+ * @param file - 绝对路径。
152
+ * @returns boolean。
153
+ */
154
+ export function fileExists(file) {
155
+ return typeof file === 'string' && file.length > 0 && existsSync(file)
156
+ }