dsh-jev-prune 0.1.0

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/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "dsh-jev-prune",
3
+ "version": "0.1.0",
4
+ "description": "Jev-judged context compaction for DeepSeek Harness. Two layers: prune stale tool results (semantic instead of size-only), and replace spent tool-call ranges with a deterministic receipt instead of a model-written summary.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./index.js": "./index.js",
10
+ "./*.js": "./src/*.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "src",
16
+ "test",
17
+ "scripts",
18
+ "cordis.patch.yml",
19
+ "assets",
20
+ "examples",
21
+ "docs",
22
+ "demo",
23
+ "README.md",
24
+ "README_zh.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": "^22.19.0 || >=24.0.0"
30
+ },
31
+ "scripts": {
32
+ "check": "node test/check.js",
33
+ "smoke": "node test/smoke_apply.mjs",
34
+ "demo": "node demo/run.mjs",
35
+ "coverage": "node --experimental-test-coverage --test --test-coverage-include=src/jev.js --test-coverage-include=src/prune.js --test-coverage-include=src/receipt.js --test-coverage-include=src/state.js --test-coverage-lines=90 --test-coverage-branches=75 --test-coverage-functions=75 test/check.js test/smoke_apply.mjs"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
39
+ "@deepseek-ai/schemastery": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
43
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
44
+ "@deepseek-ai/schemastery": "3.18.2"
45
+ },
46
+ "license": "MIT",
47
+ "author": {
48
+ "name": "yangyu666"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/yangyu666/dsh-jev-prune.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/yangyu666/dsh-jev-prune/issues"
56
+ },
57
+ "homepage": "https://github.com/yangyu666/dsh-jev-prune#readme",
58
+ "keywords": [
59
+ "deepseek-harness",
60
+ "dsh",
61
+ "dsh-plugin",
62
+ "cordis",
63
+ "compaction",
64
+ "context",
65
+ "jev",
66
+ "typesafe"
67
+ ],
68
+ "dsh": {
69
+ "bundle": {
70
+ "patch": "./cordis.patch.yml"
71
+ },
72
+ "compatibility": {
73
+ "testedAgainst": "@deepseek-ai/dsh@0.1.5-rc.2",
74
+ "note": "DSH 0.1.x is a pre-release line; plugin-facing surfaces (event shapes, service names) may change between rc versions. After upgrading DSH, run `node test/check.js` and the smoke test, and use the `jev_probe_shapes` tool once in a real session."
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * inspect_session.mjs —— 直接读 DSH 的持久化会话日志做取证(零 LLM 成本)。
3
+ *
4
+ * 为什么需要它:
5
+ * · `jev_probe_shapes` 打的是**事件级**形状(type / 块类型 / 键名),看不到**块内部**字段;
6
+ * 而工具名解析要靠 `tool-call` 块的 id/name 与 `tool-result` 的 toolCallId/source.callId 配对。
7
+ * · 想验证"回执有没有真的落进会话",一条命令就能查,不必再跑一轮 LLM 会话去让模型转述。
8
+ *
9
+ * ⚠️ DSH 的 `session.v3.jsonl.zstd` 是**多帧拼接**的 zstd(每追加一批就新起一帧),
10
+ * 而 Node 的 `zstdDecompressSync` / 流式解压**只认第一帧**(实测第二帧报
11
+ * "Unknown frame descriptor")。所以这里自己按 zstd 帧格式切帧后逐帧解压。
12
+ *
13
+ * 用法:
14
+ * node scripts/inspect_session.mjs <会话目录 | session.v3.jsonl.zstd>
15
+ */
16
+
17
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { zstdDecompressSync } from 'node:zlib'
20
+
21
+ // ---------------------------------------------------------------- zstd 多帧切分
22
+
23
+ const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
24
+
25
+ /**
26
+ * 把多帧 zstd 缓冲切成单帧数组。
27
+ * 帧格式:magic(4) + FHD(1) + [Window_Descriptor(1)] + [Dictionary_ID(0/1/2/4)] + [FCS(0/1/2/4/8)] + blocks
28
+ * 每个 block 头 3 字节小端:bit0=last, bits1-2=type, bits3-23=size;
29
+ * raw/compressed block 后跟 size 字节,RLE block 后跟 1 字节。
30
+ * 尾随的 Content_Checksum(4) 在 FHD 的 bit2 置位时存在。
31
+ */
32
+ export function splitZstdFrames(buffer) {
33
+ const frames = []
34
+ let offset = 0
35
+ while (offset + 4 <= buffer.length) {
36
+ const start = buffer.indexOf(MAGIC, offset)
37
+ if (start < 0) break
38
+ let p = start + 4
39
+ if (p >= buffer.length) break
40
+ const fhd = buffer[p]
41
+ p += 1
42
+ const fcsFlag = (fhd >> 6) & 0b11
43
+ const singleSegment = (fhd >> 5) & 1
44
+ const hasChecksum = (fhd >> 2) & 1
45
+ const dictIdFlag = fhd & 0b11
46
+
47
+ if (singleSegment === 0) p += 1 // Window_Descriptor
48
+ p += dictIdFlag === 0 ? 0 : dictIdFlag === 1 ? 1 : dictIdFlag === 2 ? 2 : 4
49
+ p += fcsFlag === 0 ? (singleSegment === 1 ? 1 : 0)
50
+ : fcsFlag === 1 ? 2
51
+ : fcsFlag === 2 ? 4 : 8
52
+
53
+ if (p > buffer.length) break
54
+ for (;;) {
55
+ if (p + 3 > buffer.length) {
56
+ p = buffer.length
57
+ break
58
+ }
59
+ const header = buffer.readUIntLE(p, 3)
60
+ p += 3
61
+ const last = header & 1
62
+ const blockType = (header >> 1) & 0b11
63
+ const blockSize = header >> 3
64
+ if (blockType === 0 || blockType === 2) p += blockSize
65
+ else if (blockType === 1) p += 1
66
+ else throw new Error(`未知的 zstd block 类型 ${blockType}`)
67
+ if (last === 1) break
68
+ if (p > buffer.length) {
69
+ p = buffer.length
70
+ break
71
+ }
72
+ }
73
+ if (hasChecksum) p += 4
74
+ frames.push(buffer.subarray(start, Math.min(p, buffer.length)))
75
+ offset = Math.min(p, buffer.length)
76
+ if (offset <= start) break
77
+ }
78
+ return frames
79
+ }
80
+
81
+ /** 逐帧解压并拼起来。 */
82
+ export function decompressZstdJsonl(buffer) {
83
+ const frames = splitZstdFrames(buffer)
84
+ const parts = []
85
+ for (const frame of frames) {
86
+ try {
87
+ parts.push(zstdDecompressSync(frame).toString('utf8'))
88
+ } catch {
89
+ // 单帧坏了就跳过,不让取证停摆
90
+ }
91
+ }
92
+ return parts.join('')
93
+ }
94
+
95
+ // ---------------------------------------------------------------- 会话读解
96
+
97
+ export function readSessionEvents(pathOrDir) {
98
+ let file = pathOrDir
99
+ if (statSync(pathOrDir).isDirectory()) {
100
+ const entry = readdirSync(pathOrDir).find((f) => f.endsWith('.jsonl.zstd'))
101
+ if (entry == null) throw new Error(`目录里没有 .jsonl.zstd:${pathOrDir}`)
102
+ file = join(pathOrDir, entry)
103
+ }
104
+ const text = decompressZstdJsonl(readFileSync(file))
105
+ const events = []
106
+ let header = null
107
+ for (const line of text.split('\n')) {
108
+ if (line.trim().length === 0) continue
109
+ let parsed
110
+ try {
111
+ parsed = JSON.parse(line)
112
+ } catch {
113
+ continue
114
+ }
115
+ if (parsed?.type === 'session') header = parsed
116
+ else if (parsed?.type != null && typeof parsed.seq === 'number') events.push(parsed)
117
+ else if (parsed?.type != null) events.push(parsed)
118
+ }
119
+ return { file, header, events }
120
+ }
121
+
122
+ /** 取事件的内容块(与 state.js 的 contentOf 同口径)。 */
123
+ function blocksOf(event) {
124
+ const data = event?.data ?? {}
125
+ if (event?.type === 'user/message') return data.content ?? null
126
+ if (event?.type === 'assistant/message' || event?.type === 'tool/result') return data.message?.content ?? null
127
+ return null
128
+ }
129
+
130
+ // ---------------------------------------------------------------- CLI
131
+
132
+ const isMain = process.argv[1] != null && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/').split('/').pop())
133
+ // issue #10:CLI 触发条件此前是 `isMain || process.argv[2] != null`——任何 importer
134
+ // 只要命令行带了第二个参数(例如 verify_real_shapes.mjs 的正常用法),import 求值时
135
+ // 就会替它跑 CLI、把进程带崩。CLI 入口只留给直接执行本文件的场景。
136
+ if (isMain) {
137
+ const target = process.argv[2]
138
+ if (target == null) {
139
+ console.error('用法: node scripts/inspect_session.mjs <会话目录 | session.v3.jsonl.zstd>')
140
+ process.exit(1)
141
+ }
142
+ const { file, header, events } = readSessionEvents(target)
143
+ console.log(`文件: ${file}`)
144
+ // issue #11:createdAt 缺失/非法时此前直接 RangeError 整个工具退出——
145
+ // 而取证工具存在的意义恰恰是查"日志为什么不对",头部字段缺失是常见病因
146
+ const createdAt = header?.createdAt != null && Number.isFinite(new Date(header.createdAt).getTime())
147
+ ? new Date(header.createdAt).toISOString()
148
+ : 'unknown'
149
+ if (header != null) console.log(`会话: ${header.id} cwd=${header.cwd} ${createdAt}`)
150
+ console.log(`事件数: ${events.length}\n`)
151
+
152
+ const byType = {}
153
+ for (const e of events) byType[e.type] = (byType[e.type] ?? 0) + 1
154
+ console.log('事件类型分布:', JSON.stringify(byType))
155
+ const onSurface = events.filter((e) => e.surfaceOp == null && e.shadowedRange == null)
156
+ console.log(`(其中 ${onSurface.length} 条不带 surfaceOp/shadowedRange)\n`)
157
+
158
+ console.log('=== tool-call 块(原样字段名)===')
159
+ let n = 0
160
+ for (const e of events) {
161
+ const blocks = blocksOf(e)
162
+ if (!Array.isArray(blocks)) continue
163
+ for (const b of blocks) {
164
+ if (b?.type !== 'tool-call') continue
165
+ console.log(` seq=${e.seq} 块键=${JSON.stringify(Object.keys(b))}`)
166
+ console.log(` id=${JSON.stringify(b.id)} name=${JSON.stringify(b.name)} arguments 类型=${typeof b.arguments}`)
167
+ if (++n >= 2) break
168
+ }
169
+ if (n >= 2) break
170
+ }
171
+
172
+ console.log('\n=== tool-result 块 ===')
173
+ n = 0
174
+ for (const e of events) {
175
+ const blocks = blocksOf(e)
176
+ if (!Array.isArray(blocks)) continue
177
+ for (const b of blocks) {
178
+ if (b?.type !== 'tool-result') continue
179
+ console.log(` seq=${e.seq}
180
+
181
+ source=${JSON.stringify(e.data?.message?.source)} 块键=${JSON.stringify(Object.keys(b))}`)
182
+ console.log(` toolCallId=${JSON.stringify(b.toolCallId)} isError=${JSON.stringify(b.isError)} 正文块数=${Array.isArray(b.content) ? b.content.length : '-'}`)
183
+ if (++n >= 2) break
184
+ }
185
+ if (n >= 2) break
186
+ }
187
+
188
+ // 工具名解析真值:id ↔ source.callId 能不能配上
189
+ console.log('\n=== 工具名解析(真值)===')
190
+ const nameById = new Map()
191
+ for (const e of events) {
192
+ const blocks = blocksOf(e)
193
+ if (!Array.isArray(blocks)) continue
194
+ for (const b of blocks) {
195
+ if (b?.type !== 'tool-call') continue
196
+ const id = b.id ?? b.callId ?? b.toolCallId
197
+ if (id != null && typeof b.name === 'string') nameById.set(id, b.name)
198
+ }
199
+ }
200
+ const names = new Set(nameById.values())
201
+ console.log(` 索引大小=${nameById.size} 索引到的工具名=${JSON.stringify([...names])}`)
202
+ let resolved = 0
203
+ let unresolved = 0
204
+ for (const e of events) {
205
+ if (e.type !== 'tool/result') continue
206
+ const callId = e.data?.message?.source?.callId
207
+ if (nameById.has(callId)) resolved += 1
208
+ else unresolved += 1
209
+ }
210
+ console.log(` tool/result 里 source.callId 能配对上的: ${resolved} 条;配对不上的: ${unresolved} 条`)
211
+ if (unresolved > 0) {
212
+ const sample = events.find((e) => e.type === 'tool/result' && !nameById.has(e.data?.message?.source?.callId))
213
+ console.log(` 反例: seq=${sample?.seq} source.callId=${JSON.stringify(sample?.data?.message?.source?.callId)}`)
214
+ console.log(` 索引里的 id 样例: ${JSON.stringify([...nameById.keys()].slice(0, 5))}`)
215
+ }
216
+
217
+ // checkpoint 与回执
218
+ const summaries = events.filter((e) => e.type === 'compaction/summary')
219
+ const checkpoints = events.filter((e) => e.type === 'user/message'
220
+ && e.data?.source?.kind === 'plugin' && e.data.source.plugin === 'compact')
221
+ console.log(`\n=== 压缩产物 ===\n compaction/summary: ${summaries.length} 条;checkpoint user/message: ${checkpoints.length} 条`)
222
+ for (const s of summaries) {
223
+ const text = (s.data?.summary ?? []).map((b) => (typeof b === 'string' ? b : b?.text ?? '')).join('')
224
+ console.log(` summary seq=${s.seq} provider=${JSON.stringify(s.data?.provider)} model=${JSON.stringify(s.data?.model)}`)
225
+ console.log(` shadowedRange=${JSON.stringify(s.data?.shadowedRange)} shadowedSeqs=${JSON.stringify(s.data?.shadowedSeqs)}`)
226
+ console.log(` 摘要前 160 字: ${text.slice(0, 160).replace(/\n/g, ' ⏎ ')}`)
227
+ }
228
+ for (const c of checkpoints) {
229
+ const text = (c.data?.content ?? []).map((b) => b?.text ?? '').join('')
230
+ console.log(` checkpoint seq=${c.seq} compactionId=${JSON.stringify(c.data?.source?.compactionId)}`)
231
+ console.log(` 正文前 200 字: ${text.slice(0, 200).replace(/\n/g, ' ⏎ ')}`)
232
+ }
233
+ const prunes = events.filter((e) => e.type === 'compaction/prune')
234
+ console.log(` compaction/prune(第一层裁剪的影子定价): ${prunes.length} 条`)
235
+ }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import {existsSync,readdirSync,readFileSync,statSync} from 'node:fs';
3
+ import {dirname,resolve,relative} from 'node:path';
4
+ import {fileURLToPath} from 'node:url';
5
+
6
+ const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');
7
+ const walk=dir=>readdirSync(dir).flatMap(name=>{
8
+ const path=resolve(dir,name);
9
+ if(['.git','node_modules','ci'].includes(name)) return [];
10
+ return statSync(path).isDirectory()?walk(path):[path];
11
+ });
12
+ for(const file of walk(root).filter(file=>file.endsWith('.md'))){
13
+ for(const match of readFileSync(file,'utf8').matchAll(/\]\(([^)]+)\)/g)){
14
+ const target=match[1].split('#')[0];
15
+ if(!target||/^[a-z]+:/.test(target)||target.includes('<'))continue;
16
+ assert.ok(existsSync(resolve(dirname(file),target)),relative(root,file)+' links to missing '+target);
17
+ }
18
+ }
19
+ assert.equal((await import('dsh-jev-prune')).name,'jev-prune');
20
+ assert.equal((await import('dsh-jev-prune/index.js')).name,'jev-prune');
21
+ for(const module of ['jev','prune','receipt','state']){
22
+ assert.ok(Object.keys(await import('dsh-jev-prune/'+module+'.js')).length>0);
23
+ }
24
+ console.log('Markdown file links, plugin entry and legacy public subpath exports verified.');
@@ -0,0 +1,99 @@
1
+ /**
2
+ * verify_real_shapes.mjs —— 用**真实会话日志**离线验工具名解析(零 LLM 成本)。
3
+ *
4
+ * 起因:真实 DSH 里的工具名是 `pwsh` / `read`(小写,shell 叫 pwsh),
5
+ * 而我猜的白名单是 Claude Code 风格的 PascalCase —— 白名单一个都匹配不上,
6
+ * 第二层因此静默永不触发。字段名这类东西**必须拿真值,不能猜**。
7
+ *
8
+ * 这个脚本把会话日志喂给插件自己的 state.js,验证:
9
+ * ① buildToolNameIndex 能否从真实事件里建出索引
10
+ * ② toolNameOf 能否解析出工具名(而不是 'unknown')
11
+ * ③ selectCandidates 会挑出哪些节点、解析出的名字是什么
12
+ *
13
+ * 用法: node scripts/verify_real_shapes.mjs <会话目录 | .jsonl.zstd> [...更多会话]
14
+ */
15
+
16
+ import { readSessionEvents } from './inspect_session.mjs'
17
+ import { buildToolNameIndex, selectCandidates, toolNameOf, resultChars } from '../src/state.js'
18
+
19
+ const targets = process.argv.slice(2)
20
+ if (targets.length === 0) {
21
+ console.error('用法: node scripts/verify_real_shapes.mjs <会话目录 | .jsonl.zstd> [...]')
22
+ process.exit(1)
23
+ }
24
+
25
+ const MARKER = '[' + '… Jev 判定该工具结果已过期' // 只要前缀就够(避免和实现里的常量耦合)
26
+
27
+ // 真实 DSH 的工具名(从会话日志取证得到,不是猜的)
28
+ const REAL_DSH_TOOLS = ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'list', 'fetch', 'web_search']
29
+ // 我原来猜的(Claude Code 风格 PascalCase)——留着做对照,证明这个 bug 有多隐蔽
30
+ const DEFAULT_COMPACT_TOOLS_GUESS = ['Read', 'Grep', 'Glob', 'Bash', 'List', 'Ls', 'Search',
31
+ 'WebFetch', 'WebSearch', 'Fetch', 'Tree', 'Stat']
32
+
33
+ // 日志里 `surfaceOp` 是**字符串**("append" / "replace"),不是对象 —— 别按对象判空。
34
+ const SURFACE_TYPES = new Set(['system/message', 'user/message', 'assistant/message', 'tool/result', 'compaction/summary'])
35
+
36
+ /** 从会话日志重建 surface(replace 事件把它声明的 sourceEventSeqs 移出)。 */
37
+ function reconstructSurface(events) {
38
+ const present = new Set()
39
+ for (const e of events) {
40
+ if (typeof e.seq !== 'number' || !SURFACE_TYPES.has(e.type)) continue
41
+ if (e.surfaceOp === 'replace') {
42
+ for (const src of e.sourceEventSeqs ?? []) present.delete(src)
43
+ } else {
44
+ present.add(e.seq)
45
+ }
46
+ }
47
+ return [...present].sort((a, b) => a - b)
48
+ }
49
+
50
+ for (const target of targets) {
51
+ let loaded
52
+ try {
53
+ loaded = readSessionEvents(target)
54
+ } catch (error) {
55
+ console.log(`跳过 ${target}:${error.message}`)
56
+ continue
57
+ }
58
+ const { file, events } = loaded
59
+ const bySeq = new Map(events.filter((e) => typeof e.seq === 'number').map((e) => [e.seq, e]))
60
+ const surface = reconstructSurface(events)
61
+
62
+ console.log(`\n════════ ${file.split(/[\\/]/).pop()} ════════`)
63
+ console.log(`事件 ${events.length} 条,surface 候选 ${surface.length} 个节点`)
64
+
65
+ const nameByCallId = buildToolNameIndex(events)
66
+ console.log(`buildToolNameIndex → ${nameByCallId.size} 条,名字集合 = ${JSON.stringify([...new Set(nameByCallId.values())])}`)
67
+
68
+ const eventAt = (seq) => bySeq.get(seq) ?? null
69
+ let resolved = 0
70
+ let unknown = 0
71
+ for (const seq of surface) {
72
+ const event = eventAt(seq)
73
+ if (event?.type !== 'tool/result') continue
74
+ const name = toolNameOf(event, nameByCallId)
75
+ if (name === 'unknown') unknown += 1
76
+ else resolved += 1
77
+ }
78
+ console.log(`toolNameOf 解析:成功 ${resolved} 条 / 未解析 ${unknown} 条`)
79
+
80
+ const candidates = selectCandidates({
81
+ surface,
82
+ eventAt,
83
+ events,
84
+ preserveRecent: 2,
85
+ neverPruneTools: ['edit', 'write', 'multiedit', 'applypatch'],
86
+ marker: MARKER,
87
+ nameByCallId,
88
+ })
89
+ console.log(`selectCandidates → ${candidates.length} 个:`)
90
+ for (const c of candidates.slice(0, 12)) {
91
+ console.log(` s${String(c.seq).padStart(4)} ${c.tool.padEnd(18)} ${String(resultChars(eventAt(c.seq))).padStart(7)} 字符`)
92
+ }
93
+
94
+ // 白名单命中检查(这才是第二层能不能触发的实际判据)
95
+ for (const allowlist of [REAL_DSH_TOOLS, DEFAULT_COMPACT_TOOLS_GUESS]) {
96
+ const hit = candidates.filter((c) => allowlist.includes(c.tool)).length
97
+ console.log(` 白名单[${allowlist === REAL_DSH_TOOLS ? '真实 DSH 名' : '我原来猜的 PascalCase'}] 命中 ${hit}/${candidates.length}`)
98
+ }
99
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * wire_profile.mjs —— 绕开 pnpm,把 dsh-jev-prune 手工接进一个 DSH profile。
3
+ *
4
+ * 为什么需要它:`dsh plugin add` 会转发给 pnpm,而 pnpm 在本机装了三次都没成功
5
+ * (第一次被并发 install 冲掉、第二次被 npm 当多余包清掉、第三次 24 分钟无输出被终止)。
6
+ * 而 DSH 的插件加载契约其实是两件很朴素的事:
7
+ * ① 包能在 profile 的解析范围内被 import 到
8
+ * ② profile 的 cordis.patch.yml 里有一条把它 insert 进配置树
9
+ * 这两件事手工做完全等价,只是绕开了 pnpm 那层。
10
+ *
11
+ * 代价:偏离官方支持路径(`dsh plugin add`),所以升级 DSH 后要重新跑一次。
12
+ *
13
+ * 用法:
14
+ * node scripts/wire_profile.mjs <DSH_HOME> <profile名> [--plugin <插件目录>]
15
+ * --plugin 省略时默认为**本脚本所在目录**(即插件仓库根)。
16
+ * 例:
17
+ * node scripts/wire_profile.mjs ../../_dshhome jevtest
18
+ * node scripts/wire_profile.mjs ~/.dsh web --plugin /path/to/dsh-jev-prune
19
+ */
20
+
21
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { dirname, join, resolve } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const here = dirname(dirname(fileURLToPath(import.meta.url)))
27
+ const args = process.argv.slice(2)
28
+
29
+ // 手工解析参数(保持零依赖):位置参数 <DSH_HOME> [profile名],可选 --plugin <dir>
30
+ let dshHome
31
+ let profileName
32
+ let pluginDir
33
+ for (let i = 0; i < args.length; i += 1) {
34
+ const arg = args[i]
35
+ if (arg === '--plugin' || arg === '-p') {
36
+ pluginDir = args[i + 1]
37
+ if (pluginDir == null) {
38
+ console.error('❌ --plugin 需要一个目录参数')
39
+ process.exit(2)
40
+ }
41
+ i += 1
42
+ } else if (dshHome == null) {
43
+ dshHome = arg
44
+ } else if (profileName == null) {
45
+ profileName = arg
46
+ } else {
47
+ console.error(`❌ 多余的参数:${arg}`)
48
+ process.exit(2)
49
+ }
50
+ }
51
+
52
+ const pluginSrc = resolve(pluginDir ?? here)
53
+ const packageName = JSON.parse(readFileSync(join(pluginSrc, 'package.json'), 'utf8')).name
54
+
55
+ // issue #12:profile 名此前在用法行写"可选",实际 join() 会拿到 undefined 抛原始 TypeError——
56
+ // 要么给默认要么明确必填,这里选择明确必填 + 友好报错
57
+ if (!dshHome || !profileName) {
58
+ console.error('用法: node scripts/wire_profile.mjs <DSH_HOME> <profile名> [--plugin <插件目录>]')
59
+ if (!dshHome) console.error(' 缺少 <DSH_HOME>(DSH 的主目录,含 profiles/ 的那个)')
60
+ if (!profileName) console.error(' 缺少 <profile名>(dsh --profile <名字> 用的那个名字,必填)')
61
+ process.exit(2)
62
+ }
63
+
64
+ // issue #12:Node 的 path.resolve 不展开 ~,示例却写着 ~/.dsh —— 先展开
65
+ if (dshHome === '~' || dshHome.startsWith('~/') || dshHome.startsWith('~\\')) {
66
+ dshHome = join(homedir(), dshHome.slice(1).replace(/^[\\/]/, ''))
67
+ }
68
+
69
+ const profileDir = join(resolve(dshHome), 'profiles', profileName)
70
+ if (!existsSync(profileDir)) {
71
+ console.error(`❌ profile 目录不存在:${profileDir}`)
72
+ console.error(' 先用 dsh --profile <name> --from-default-profile headless --dump-config 创建它')
73
+ process.exit(1)
74
+ }
75
+
76
+ // ---------------------------------------------------------------- 1. 落包
77
+ // 用复制而不是软链:Node 默认把软链解析成真实路径,那样插件里的
78
+ // `import '@deepseek-ai/...'` 会从插件源目录往上找、找不到 DSH 的 node_modules。
79
+ //
80
+ // 放两个位置(DSH 的 profile 解析规则没有公开文档,而它源码里有个
81
+ // `healProfilesModuleFallback`,说明确实存在回退链):
82
+ // ① profile 自己的 node_modules —— pnpm 正常安装时的位置
83
+ // ② $DSH_HOME/node_modules —— 回退链上的位置
84
+ // 复制清单**从 package.json 的 files 字段推导**,而不是硬写:
85
+ // 硬写清单会漂移——实测加过 prune.js 之后忘了同步,装进 profile 后启动直接
86
+ // ERR_MODULE_NOT_FOUND。files 字段本来就该是唯一的真相来源。
87
+ const pkg = JSON.parse(readFileSync(join(pluginSrc, 'package.json'), 'utf8'))
88
+ const declared = Array.isArray(pkg.files) ? pkg.files : []
89
+ const files = [...new Set([...declared, 'package.json', 'cordis.patch.yml', 'README.md'])]
90
+ const missing = files.filter((f) => !existsSync(join(pluginSrc, f)))
91
+ if (missing.length > 0) {
92
+ console.error(`❌ package.json 的 files 里声明了但文件不存在:${missing.join(', ')}`)
93
+ process.exit(1)
94
+ }
95
+ // 顺带守住另一类漂移:代码里的相对 import 必须都能落到实际文件上
96
+ // (issue #13:此前只扫 .js,4 个 .mjs 的相对 import 全部漏检)
97
+ const srcFiles = files.filter((f) => f.endsWith('.js') || f.endsWith('.mjs'))
98
+ const brokenImports = []
99
+ for (const f of srcFiles) {
100
+ const text = readFileSync(join(pluginSrc, f), 'utf8')
101
+ for (const m of text.matchAll(/from\s+'\.\/([^']+)'/g)) {
102
+ if (!existsSync(join(pluginSrc, m[1]))) brokenImports.push(`${f} → ./${m[1]}`)
103
+ }
104
+ }
105
+ if (brokenImports.length > 0) {
106
+ console.error(`❌ 相对 import 指向不存在的文件(复制后会在宿主里启动失败):\n ${brokenImports.join('\n ')}`)
107
+ process.exit(1)
108
+ }
109
+
110
+ const targets = [
111
+ join(profileDir, 'node_modules', packageName),
112
+ join(resolve(dshHome), 'node_modules', packageName),
113
+ ]
114
+ for (const pluginDest of targets) {
115
+ mkdirSync(pluginDest, { recursive: true })
116
+ for (const file of files) {
117
+ // files 里可能有目录条目(如 assets/)——目录用递归拷贝,文件用 copyFileSync
118
+ if (statSync(join(pluginSrc, file)).isDirectory()) {
119
+ cpSync(join(pluginSrc, file), join(pluginDest, file), { recursive: true })
120
+ } else {
121
+ copyFileSync(join(pluginSrc, file), join(pluginDest, file))
122
+ }
123
+ }
124
+ console.log(` 已落包 ${pluginDest}(${files.length} 个条目)`)
125
+ }
126
+ console.log(`✅ 插件已复制到 ${targets.length} 个候选解析位置`)
127
+
128
+ // ---------------------------------------------------------------- 2. 改 patch
129
+ const patchPath = join(profileDir, 'cordis.patch.yml')
130
+ const original = readFileSync(patchPath, 'utf8')
131
+ const entry = `- insert:\n - id: jev-prune\n name: ${packageName}\n`
132
+ // 模板文件开头是注释,判断"是不是空数组"必须先剥掉注释和空行,
133
+ // 否则会把条目追加在 `[]` 后面 → 顶层两个节点 → YAML 解析失败。
134
+ const comments = original.split('\n').filter((l) => l.trim().startsWith('#')).join('\n')
135
+ const effective = original
136
+ .split('\n')
137
+ .filter((l) => !l.trim().startsWith('#'))
138
+ .join('\n')
139
+ .trim()
140
+
141
+ let next
142
+ if (effective.includes(packageName) && !effective.split('\n').some((l) => l.trim() === '[]')) {
143
+ next = original
144
+ console.log('✅ cordis.patch.yml 里已有该条目,跳过')
145
+ } else {
146
+ // 重建:保留注释头,丢掉空数组占位行(`[]` 与条目并存会让顶层出现两个节点 → YAML 解析失败),
147
+ // 然后把我们的 insert 条目放进去。已经存在的同 id 条目先删掉再重加,保证幂等。
148
+ const kept = original
149
+ .split('\n')
150
+ .filter((l) => l.trim().startsWith('#'))
151
+ .join('\n')
152
+ .trimEnd()
153
+ const rest = original
154
+ .split('\n')
155
+ .filter((l) => !l.trim().startsWith('#') && l.trim() !== '[]' && l.trim() !== '')
156
+ .join('\n')
157
+ .trim()
158
+ const withoutOurs = rest
159
+ .split(/\n(?=- insert:)/) // 按顶层条目切
160
+ .filter((block) => !block.includes(packageName))
161
+ .join('\n')
162
+ .trim()
163
+ next = `${kept}\n${withoutOurs ? `${withoutOurs}\n` : ''}${entry}`
164
+ console.log(rest === '' ? '✅ 已写入 cordis.patch.yml(原先是空数组)' : '✅ 已重建 cordis.patch.yml')
165
+ }
166
+ writeFileSync(patchPath, next, 'utf8')
167
+ console.log(`\n--- ${patchPath} ---\n${next}`)
168
+
169
+ // ---------------------------------------------------------------- 3. 下一步提示
170
+ console.log('下一步:')
171
+ console.log(` 1. 确认进了配置树:`)
172
+ console.log(` DSH_HOME=${resolve(dshHome)} dsh --profile ${profileName} --dump-config | grep -A1 jev-prune`)
173
+ console.log(` 2. 真实跑一次会话,用 jev_probe_shapes 工具校正事件字段名`)