dsh-mobile-plugin 0.3.1 → 0.4.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/config/agent-presets/standard/agent.cordis.yml +7 -5
- package/host.js +86 -3
- package/lib/host-compat.cjs +132 -0
- package/package.json +8 -5
|
@@ -166,9 +166,10 @@
|
|
|
166
166
|
# the `user-questions/request` hook (0.2.x shape) and answers it — only the
|
|
167
167
|
# tool row was missing.
|
|
168
168
|
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
169
|
+
# 这里的 `name` 是**占位**:无头宿主启动时会把同代那一份的入口绝对路径写进
|
|
170
|
+
# 一份副本(`host.js` 的 syncPresetDir),挂载的是运行时自己的那一代。这个包的
|
|
171
|
+
# peerDependencies 逐版本紧咬,对不上时 `dsh-app-boot` 会**整行禁用**(不是降级),
|
|
172
|
+
# 而钉死哪一代都不可能覆盖每台机器上装的 DSH —— 判据交给运行期,别在这里写死。
|
|
172
173
|
- id: tool-ask-user
|
|
173
174
|
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
174
175
|
# `present` 是模型**显式声明交付文件**的那一行。桌面 `standard` preset 里一直有它
|
|
@@ -180,7 +181,8 @@
|
|
|
180
181
|
# `deliverables/presented` 事件追加进会话日志。卡片该不该出现,由**模型叫不叫这个工具**
|
|
181
182
|
# 决定,不由「有没有文件被改」决定 —— 这是桌面的口径。
|
|
182
183
|
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
184
|
+
# 版本同样由运行期决定(见上面 tool-ask-user 那段):0.2.1 的 `present` 要
|
|
185
|
+
# `dsh-agent` 恰好是同一代,对不上时 `dsh-app-boot` 会整行禁用,模型只会说
|
|
186
|
+
# 「没有这个工具」。`name` 是占位,宿主启动时换成本次运行时的入口。
|
|
185
187
|
- id: tool-present
|
|
186
188
|
name: '@deepseek-ai/dsh-tool-present'
|
package/host.js
CHANGED
|
@@ -14,21 +14,26 @@
|
|
|
14
14
|
* package's `cordis.patch.yml`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { createRequire } from 'node:module'
|
|
18
19
|
import { dirname, join } from 'node:path'
|
|
19
20
|
import { fileURLToPath } from 'node:url'
|
|
20
21
|
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
|
21
22
|
import { installProcessGuard } from './lib/host-guard.cjs'
|
|
23
|
+
import { DIRECTORY_PICKER_ROW, TOOL_ROWS, pinInsertedRows, syncPresetDir } from './lib/host-compat.cjs'
|
|
22
24
|
import { dropBareNameSelfMounts } from './lib/host-patches.cjs'
|
|
23
25
|
import {
|
|
24
26
|
assertEntriesActivated,
|
|
25
27
|
boot,
|
|
28
|
+
evaluatePluginCompatibility,
|
|
29
|
+
getDshRuntimeVersion,
|
|
26
30
|
initProfile,
|
|
27
31
|
installFailLoud,
|
|
28
32
|
loadLayeredEnv,
|
|
29
33
|
loadOverlayPatches,
|
|
30
34
|
loadProfile,
|
|
31
35
|
PROFILE_TEMPLATES,
|
|
36
|
+
readProfileCompatibility,
|
|
32
37
|
resolveProfileDir,
|
|
33
38
|
} from '@deepseek-ai/dsh-app-boot'
|
|
34
39
|
|
|
@@ -90,6 +95,82 @@ try {
|
|
|
90
95
|
const rootConfig = join(profileDir, 'cordis.root.yml')
|
|
91
96
|
writeFileSync(rootConfig, '[]\n')
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* 官方工具行跟着**运行时**换代,本包不再自带一份钉死的副本。
|
|
100
|
+
*
|
|
101
|
+
* 这三个包(提问工具 / 交付文件工具 / 目录浏览后端)互相之间用精确版本咬死,
|
|
102
|
+
* 而装载前的预检对不上的处理不是降级、是**整行禁用** —— 现象是手机上
|
|
103
|
+
* 「模型说没有这个工具」「添加工作区列不出目录」,桌面上却一切正常。钉在哪一代
|
|
104
|
+
* 都不对:官方每抬一次代,钉死的副本就失效一次,而一台机器上装的 DSH 是哪一代
|
|
105
|
+
* 我们发版时并不知道。
|
|
106
|
+
*
|
|
107
|
+
* 判据直接用 `dsh-app-boot` 自己导出的 `evaluatePluginCompatibility`,与装载前那次
|
|
108
|
+
* 预检**同源**:自己写一套 semver 只会让两边分叉,这里放过的行照样被预检禁掉。
|
|
109
|
+
* 先问运行时那棵树,再问本包自带的那份,都不行就返回 null —— 那一行保持原样,
|
|
110
|
+
* 交给官方预检去禁用并打印原因,等于退回旧行为,不会更差。
|
|
111
|
+
*/
|
|
112
|
+
let runtimeRequire = null
|
|
113
|
+
try {
|
|
114
|
+
runtimeRequire = createRequire(new URL(import.meta.resolve('@deepseek-ai/dsh-app-boot')))
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// 定位不到不是致命:下面每一行都会退回本包自带的那份,最差回归旧行为。
|
|
117
|
+
process.stderr.write(`${BIN_NAME}: 定位不到运行时那棵依赖树(${error?.message || error}),这一轮只按本包自带的解析\n`)
|
|
118
|
+
}
|
|
119
|
+
const bundledRequire = createRequire(import.meta.url)
|
|
120
|
+
const runtimeVersion = getDshRuntimeVersion()
|
|
121
|
+
let exemptions = {}
|
|
122
|
+
try {
|
|
123
|
+
exemptions = readProfileCompatibility(profileDir).exemptions
|
|
124
|
+
} catch (error) {
|
|
125
|
+
process.stderr.write(`${BIN_NAME}: 读不了 profile 的兼容性豁免文件,这一轮按无豁免处理:${error?.message || error}\n`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resolveGeneration = (scope, require, name) => {
|
|
129
|
+
if (!require) return null
|
|
130
|
+
try {
|
|
131
|
+
const manifestPath = require.resolve(`${name}/package.json`)
|
|
132
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
133
|
+
const issue = evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)
|
|
134
|
+
if (issue !== undefined && !issue.exempted) return null
|
|
135
|
+
return { scope, name, version: manifest.version, entry: require.resolve(name) }
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pinnedRows = new Map()
|
|
142
|
+
for (const { id, name } of [...TOOL_ROWS, DIRECTORY_PICKER_ROW]) {
|
|
143
|
+
const hit = resolveGeneration('运行时', runtimeRequire, name)
|
|
144
|
+
?? resolveGeneration('本包自带', bundledRequire, name)
|
|
145
|
+
if (hit) pinnedRows.set(id, hit)
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
hit
|
|
148
|
+
? `${BIN_NAME}: ${name} → 用${hit.scope}的 ${hit.version}\n`
|
|
149
|
+
: `${BIN_NAME}: ${name} 没解析到与 dsh ${runtimeVersion} 同代的一份,这一行维持原样(装载预检会说明它为什么被禁用)\n`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 预设里的两行默认按裸名写:Loader 从它自己的位置解析,命中哪一份不由我们决定。
|
|
154
|
+
// 解析到了同代的那份就改写一份副本给它,路径写死成那一份的入口。
|
|
155
|
+
const presetRoots = {}
|
|
156
|
+
for (const { id } of TOOL_ROWS) {
|
|
157
|
+
const hit = pinnedRows.get(id)
|
|
158
|
+
if (hit) presetRoots[id] = hit.entry
|
|
159
|
+
}
|
|
160
|
+
const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined)
|
|
161
|
+
? syncPresetDir(
|
|
162
|
+
join(PACKAGE_DIR, 'config', 'agent-presets'),
|
|
163
|
+
join(profileDir, '.mobile-agent-presets'),
|
|
164
|
+
presetRoots,
|
|
165
|
+
)
|
|
166
|
+
: join(PACKAGE_DIR, 'config', 'agent-presets')
|
|
167
|
+
|
|
168
|
+
// patch 层里的 insert 行按包名写(`cordis.mobile-host.yml` 的目录浏览器),
|
|
169
|
+
// 同样换成解析到的那份入口。只改 insert:patch 里其它形态的 name 是断言,
|
|
170
|
+
// 改它会让整条 patch 被静默跳过。
|
|
171
|
+
const entryByName = {}
|
|
172
|
+
for (const hit of pinnedRows.values()) entryByName[hit.name] = hit.entry
|
|
173
|
+
|
|
93
174
|
const patches = [
|
|
94
175
|
...profile.layers.flatMap(layer => layer.patches),
|
|
95
176
|
...profile.patches,
|
|
@@ -123,16 +204,18 @@ try {
|
|
|
123
204
|
id: 'agent-presets',
|
|
124
205
|
config: {
|
|
125
206
|
default: 'standard',
|
|
126
|
-
roots: [{ path:
|
|
207
|
+
roots: [{ path: presetDir, trust: 'system' }],
|
|
127
208
|
},
|
|
128
209
|
},
|
|
129
210
|
]
|
|
130
211
|
|
|
212
|
+
const pinnedPatches = pinInsertedRows(patches, entryByName)
|
|
213
|
+
|
|
131
214
|
process.stderr.write(
|
|
132
215
|
`${BIN_NAME}: 挂载方式 = ${selfInstalled ? `profile 里装的那份(${installedDir},带稳定外壳,面板里能自更新)` : '源码树直挂(无外壳,更新靠同步源码 + 重启宿主)'}\n`,
|
|
133
216
|
)
|
|
134
217
|
|
|
135
|
-
const ctx = await boot(BIN_NAME, rootConfig,
|
|
218
|
+
const ctx = await boot(BIN_NAME, rootConfig, pinnedPatches, (hostCtx) => {
|
|
136
219
|
// A headless mobile host has no command line: nothing passes flags and
|
|
137
220
|
// nobody is watching a help screen. The web startup plugin still waits on
|
|
138
221
|
// `cmdlineArgs` before it will provide `webStartup`, so the service must
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 让「官方工具行」跟着运行时换代,而不是跟着本包发版那一刻。
|
|
5
|
+
*
|
|
6
|
+
* 这三个包(提问工具、交付文件工具、目录浏览后端)有个共同点:它们互相之间用
|
|
7
|
+
* **精确版本**咬死(`dsh-tool-ask-user@0.2.1-rc.2` 要求 `dsh-agent` 恰好是
|
|
8
|
+
* `0.2.1-rc.2`),而 `dsh-app-boot` 在装载前会把每一个要挂的行拿出来跟运行时对
|
|
9
|
+
* 一遍——对不上不是降级,是**整行禁用**,只留一行
|
|
10
|
+
* `disabling profile plugin row "tool-ask-user"` 的启动日志。现象就是手机上
|
|
11
|
+
* 「模型说没有这个工具」「添加工作区列不出目录」,而桌面上一切正常。
|
|
12
|
+
*
|
|
13
|
+
* 所以本包不再自带一份钉死的副本:
|
|
14
|
+
*
|
|
15
|
+
* - `host.js` 启动时先问运行时那棵树要**同代**的那一份(判据直接用
|
|
16
|
+
* `dsh-app-boot` 自己导出的兼容性判定,跟装载前那次检查完全同源);
|
|
17
|
+
* - 拿到了就把预设行与该 patch 行的 `name` 换成那份的**入口绝对路径**,
|
|
18
|
+
* Loader 按路径装载,绕开「从谁的位置按裸名解析」这个坑;
|
|
19
|
+
* - 拿不到就保持原样,让官方那次检查去禁用并打印原因——退回当前行为,不会更差。
|
|
20
|
+
*
|
|
21
|
+
* 这个文件只放纯函数(复制目录、改文本、改 patch 行),不 import 任何 ESM 包:
|
|
22
|
+
* `host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs')
|
|
26
|
+
const path = require('node:path')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 预设里那两行的 id 与包名。
|
|
30
|
+
*
|
|
31
|
+
* 两个都要,因为它们是**同一类**:都是官方工具、都精确咬版本、都不在 `dsh-web-app`
|
|
32
|
+
* 的预设里(手机这份预设是本包自带的)。漏掉任何一个,对应能力的消失方式都一样安静。
|
|
33
|
+
*/
|
|
34
|
+
const TOOL_ROWS = Object.freeze([
|
|
35
|
+
Object.freeze({ id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' }),
|
|
36
|
+
Object.freeze({ id: 'tool-present', name: '@deepseek-ai/dsh-tool-present' }),
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
/** 无头宿主里被钉成 browse 的目录选择后端;同样精确咬版本。 */
|
|
40
|
+
const DIRECTORY_PICKER_ROW = Object.freeze({
|
|
41
|
+
id: 'directory-picker-browse',
|
|
42
|
+
name: '@deepseek-ai/dsh-host-directory-picker-browse',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const ROW_ID = /^(\s*)-\s+id:\s*(\S+)\s*$/
|
|
46
|
+
const ROW_NAME = /^(\s+)name:\s*'([^']+)'\s*$/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 把 `- id: <id>` 紧跟着的那个 `name:` 换成给定值。
|
|
50
|
+
*
|
|
51
|
+
* 逐行扫而不是解析 YAML 再序列化:这份预设带着大量解释性注释,解析一遍再写回去
|
|
52
|
+
* 会把注释和空行全洗掉,而那些注释正是下一个人理解这几行为什么存在的唯一线索。
|
|
53
|
+
* 只认「上一行是目标 id」的 name,所以同一个包名在别处出现(注释、其它行)不会被误伤。
|
|
54
|
+
*
|
|
55
|
+
* @param {string} source 预设文件内容
|
|
56
|
+
* @param {Record<string, string>} targets 行 id → 新的 name(入口绝对路径)
|
|
57
|
+
* @returns {{ text: string, changed: string[] }}
|
|
58
|
+
*/
|
|
59
|
+
function rewriteToolNames(source, targets) {
|
|
60
|
+
const changed = []
|
|
61
|
+
let currentId = null
|
|
62
|
+
const text = source
|
|
63
|
+
.split('\n')
|
|
64
|
+
.map(line => {
|
|
65
|
+
const id = ROW_ID.exec(line)
|
|
66
|
+
if (id) {
|
|
67
|
+
currentId = id[2]
|
|
68
|
+
return line
|
|
69
|
+
}
|
|
70
|
+
if (currentId === null) return line
|
|
71
|
+
const name = ROW_NAME.exec(line)
|
|
72
|
+
if (!name || targets[currentId] === undefined) return line
|
|
73
|
+
if (name[2] === targets[currentId]) return line
|
|
74
|
+
changed.push(currentId)
|
|
75
|
+
// 单引号是 YAML 的转义形式:值里的 ' 要写成 ''。
|
|
76
|
+
return `${name[1]}name: '${targets[currentId].replace(/'/g, "''")}'`
|
|
77
|
+
})
|
|
78
|
+
.join('\n')
|
|
79
|
+
return { text, changed }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把预设目录同步一份到 `outDir`,并把目标行换成本次运行时的入口。
|
|
84
|
+
*
|
|
85
|
+
* 每次启动都重写:两份 yml 加起来不到 8KB,而「只在内容变化时写」需要记住上一次
|
|
86
|
+
* 解析出来的运行时入口,多一份状态就多一处能不一致的地方。写完才用,不写就不存在。
|
|
87
|
+
*
|
|
88
|
+
* @param {string} presetDir 包内 `config/agent-presets`
|
|
89
|
+
* @param {string} outDir 宿主可写的目标目录
|
|
90
|
+
* @param {Record<string, string>} targets 行 id → 入口绝对路径
|
|
91
|
+
* @returns {string} 真正该交给 `agent-presets` roots 的目录
|
|
92
|
+
*/
|
|
93
|
+
function syncPresetDir(presetDir, outDir, targets) {
|
|
94
|
+
fs.rmSync(outDir, { recursive: true, force: true })
|
|
95
|
+
fs.cpSync(presetDir, outDir, { recursive: true })
|
|
96
|
+
if (Object.keys(targets).length === 0) return outDir
|
|
97
|
+
for (const entry of fs.readdirSync(outDir, { recursive: true, withFileTypes: true })) {
|
|
98
|
+
if (!entry.isFile() || !entry.name.endsWith('.yml')) continue
|
|
99
|
+
const file = path.join(entry.parentPath ?? entry.path, entry.name)
|
|
100
|
+
const { text, changed } = rewriteToolNames(fs.readFileSync(file, 'utf8'), targets)
|
|
101
|
+
if (changed.length > 0) fs.writeFileSync(file, text)
|
|
102
|
+
}
|
|
103
|
+
return outDir
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 把 patch 层里命中目标的 `name` 换成入口绝对路径。
|
|
108
|
+
*
|
|
109
|
+
* 只改 `insert` 里的行:patch 里其它形态的 `name` 是**断言**(与原名同才会应用),
|
|
110
|
+
* 改它等于让整条 patch 被静默跳过。改名只对「新插一行」有意义。
|
|
111
|
+
*
|
|
112
|
+
* @param {Array} patches 已加载的 patch 层
|
|
113
|
+
* @param {Record<string, string>} targets 包名 → 入口绝对路径
|
|
114
|
+
* @returns {Array} 新数组,不改原对象
|
|
115
|
+
*/
|
|
116
|
+
function pinInsertedRows(patches, targets) {
|
|
117
|
+
const swap = (row) => {
|
|
118
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return row
|
|
119
|
+
const next = targets[row.name] === undefined ? row : { ...row, name: targets[row.name] }
|
|
120
|
+
if (Array.isArray(next.insert)) return { ...next, insert: next.insert.map(swap) }
|
|
121
|
+
return next
|
|
122
|
+
}
|
|
123
|
+
return patches.map(swap)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
TOOL_ROWS,
|
|
128
|
+
DIRECTORY_PICKER_ROW,
|
|
129
|
+
rewriteToolNames,
|
|
130
|
+
syncPresetDir,
|
|
131
|
+
pinInsertedRows,
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"dsh": ">=0.2.0 <0.3.0"
|
|
8
|
+
},
|
|
6
9
|
"type": "module",
|
|
7
10
|
"main": "lib/index.js",
|
|
8
11
|
"bin": {
|
|
@@ -46,8 +49,6 @@
|
|
|
46
49
|
"release:check": "node scripts/preflight-release.mjs"
|
|
47
50
|
},
|
|
48
51
|
"dependencies": {
|
|
49
|
-
"@deepseek-ai/dsh-tool-ask-user": "0.2.1-rc.2",
|
|
50
|
-
"@deepseek-ai/dsh-tool-present": "0.2.1-rc.2",
|
|
51
52
|
"qrcode": "1.5.4",
|
|
52
53
|
"ws": "8.18.0"
|
|
53
54
|
},
|
|
@@ -73,7 +74,9 @@
|
|
|
73
74
|
}
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
76
|
-
"@deepseek-ai/dsh-host-directory-picker-browse": "0.
|
|
77
|
+
"@deepseek-ai/dsh-host-directory-picker-browse": "^0.2.1-rc.3",
|
|
78
|
+
"@deepseek-ai/dsh-tool-ask-user": "^0.2.1-rc.3",
|
|
79
|
+
"@deepseek-ai/dsh-tool-present": "^0.2.1-rc.3",
|
|
77
80
|
"yaml": "2.9.1"
|
|
78
81
|
}
|
|
79
82
|
}
|