@apinggithub/mydsh-auto-model-router 0.3.2 → 0.3.3

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/README.zh.md CHANGED
@@ -389,14 +389,39 @@ contextGuard: { enabled: true, headroom: 0.85 }
389
389
 
390
390
  ```powershell
391
391
  # 1. 从 npm 装(推荐;peer 范围已放宽为宿主自适应,不需要 allow-version 豁免)
392
- dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
392
+ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.3'
393
393
 
394
394
  # 2. 可选:按档位拆账
395
395
  dsh plugin --profile desktop add '@apinggithub/dsh-tier-cost@0.1.0'
396
396
 
397
- # 3. 重启 DSH(bundle 只在启动时组合;配置改动用热加载即可)
397
+ # 3. 改完 cordis.patch.yml 后先校验(见下),再重启
398
+ node scripts/check-patch.mjs --profile desktop
399
+
400
+ # 4. 重启 DSH(bundle 只在启动时组合;配置改动用热加载即可)
398
401
  ```
399
402
 
403
+ ### 改配置前必读:先校验,再重启
404
+
405
+ `cordis.patch.yml` 里一个少的换行就能让宿主**启动失败**:
406
+
407
+ ```
408
+ DesktopHostFatalError: failed to parse overlay … YAMLException: bad indentation (454:44)
409
+ ```
410
+
411
+ 更麻烦的是崩溃对话框里有 **"Disable third-party plugins"** 按钮——点它会把所有第三方
412
+ bundle 从 profile 里摘掉,插件(含 Auto 入口)随之消失,需要手工恢复。
413
+
414
+ 所以每次改完都跑一次(会用宿主自己的解析器校验,并清理临时 profile):
415
+
416
+ ```powershell
417
+ node scripts/check-patch.mjs --profile desktop
418
+ # PASS profile "desktop" composes (194 top-level entries)
419
+ # FAIL … 会打印宿主的原始错误
420
+ ```
421
+
422
+ 参数:`--home <DSH_HOME>`、`--profile <name>`、`--file <patch>`(只做静态检查)、`--keep`(保留临时 profile 便于排查)。
423
+
424
+
400
425
  也可以从源码装:`git clone …` 后 `dsh plugin --profile desktop add ./mydsh-auto-model-router`。
401
426
 
402
427
  ### 每台机器必须自带档位块
package/config.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // fallbackChain: [route, ...] (model-level, appended with the default)
23
23
  // maxRetries: per-route retry budget on request-error
24
24
 
25
- export const PLUGIN_VERSION = '0.3.2'
25
+ export const PLUGIN_VERSION = '0.3.3'
26
26
 
27
27
  export const LEVEL_IDS = Object.freeze(['L0', 'L1', 'L2', 'L3'])
28
28
  export const COST_MODES = Object.freeze(['cost-first', 'quality-first', 'balanced'])
@@ -26,7 +26,12 @@ dsh plugin --profile desktop add '@apinggithub/mydsh-auto-model-router@0.3.1'
26
26
  # 2. append the levels block to the profile patch
27
27
  notepad $env:DSH_HOME\profiles\desktop\cordis.patch.yml
28
28
 
29
- # 3. restart DSH once (bundles compose at boot; later edits hot-reload)
29
+ # 3. ALWAYS validate it before restarting — a YAML typo here stops DSH from
30
+ # starting, and the crash dialog's "disable third-party plugins" button then
31
+ # strips every bundle (the router disappears from the model picker)
32
+ node scripts/check-patch.mjs --profile desktop
33
+
34
+ # 4. restart DSH once (bundles compose at boot; later edits hot-reload)
30
35
  ```
31
36
 
32
37
  Check what the plugin sees at any time:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apinggithub/mydsh-auto-model-router",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Hybrid model router for DeepSeek Harness: declarative rules, user-configurable cost control (cost-first / quality-first / balanced), optional LLM task classifier, and failure fallback chains.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -26,6 +26,7 @@
26
26
  "client.js",
27
27
  "cordis.patch.yml",
28
28
  "examples",
29
+ "scripts",
29
30
  "README.md",
30
31
  "README.zh.md"
31
32
  ],
@@ -41,6 +42,7 @@
41
42
  "scripts": {
42
43
  "test": "node --test *.test.mjs",
43
44
  "build:client": "node build-client.mjs",
45
+ "check:patch": "node scripts/check-patch.mjs",
44
46
  "publish:all": "npm publish --access public && cd tier-cost && npm publish --access public"
45
47
  },
46
48
  "peerDependencies": {
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // Validate a profile's cordis.patch.yml with the host's own parser.
3
+ //
4
+ // This exists because a single missing newline in the patch (a `- { … }` entry
5
+ // glued to the next key) makes DSH fail at startup with
6
+ // DesktopHostFatalError: failed to parse overlay … YAMLException
7
+ // and the desktop app offers to "disable third-party plugins", which strips
8
+ // every bundle. Catching it here costs two seconds.
9
+ //
10
+ // The check is authoritative rather than re-implemented: it copies the profile
11
+ // into a throw-away sibling profile and runs `dsh --dump-config`, so the real
12
+ // loader parses the real overlay with the real merge rules.
13
+ //
14
+ // Usage:
15
+ // node scripts/check-patch.mjs # profile "desktop", $DSH_HOME
16
+ // node scripts/check-patch.mjs --profile web
17
+ // node scripts/check-patch.mjs --home E:\dsh --profile desktop
18
+ // node scripts/check-patch.mjs --file path\to\cordis.patch.yml # syntax only
19
+ //
20
+ // Exit code 0 = the host parsed and composed the profile; 1 = it would not boot.
21
+ import { execFileSync } from 'node:child_process'
22
+ import {
23
+ copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync,
24
+ } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ function parseArgs(argv) {
29
+ const out = { profile: 'desktop', home: undefined, file: undefined, keep: false }
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ const arg = argv[i]
32
+ if (arg === '--profile') out.profile = argv[++i]
33
+ else if (arg === '--home') out.home = argv[++i]
34
+ else if (arg === '--file') out.file = argv[++i]
35
+ else if (arg === '--keep') out.keep = true
36
+ else if (arg === '--help' || arg === '-h') out.help = true
37
+ else throw new Error(`unknown argument: ${arg}`)
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Cheap structural checks that catch the mistakes people actually make. */
43
+ function staticChecks(text) {
44
+ const problems = []
45
+ if (text.trim() === '') {
46
+ problems.push('the file is empty (an empty overlay must still be `[]`)')
47
+ return problems
48
+ }
49
+ if (text.includes('\t')) problems.push('contains a TAB character; YAML forbids tabs for indentation')
50
+ if (text.charCodeAt(0) === 0xfeff) problems.push('starts with a UTF-8 BOM; write it without one')
51
+
52
+ const lines = text.split(/\r?\n/)
53
+ lines.forEach((line, index) => {
54
+ // A `}` followed by more content on the same line is the classic
55
+ // "missing newline between a flow mapping and the next key" breakage.
56
+ if (/}[ \t]+[A-Za-z_][\w-]*:/.test(line)) {
57
+ problems.push(`line ${index + 1}: a flow mapping is glued to the next key — missing newline`)
58
+ }
59
+ })
60
+ return problems
61
+ }
62
+
63
+ /** Windows needs a junction for node_modules; POSIX can use a symlink. */
64
+ function linkModules(target, linkPath) {
65
+ if (process.platform === 'win32') {
66
+ execFileSync('cmd', ['/c', 'mklink', '/J', linkPath, target], { stdio: 'pipe' })
67
+ } else {
68
+ execFileSync('ln', ['-s', target, linkPath], { stdio: 'pipe' })
69
+ }
70
+ }
71
+
72
+ function unlinkModules(linkPath) {
73
+ if (!existsSync(linkPath)) return
74
+ const stat = lstatSync(linkPath)
75
+ if (process.platform === 'win32' && stat.isSymbolicLink()) {
76
+ execFileSync('cmd', ['/c', 'rmdir', linkPath], { stdio: 'pipe' })
77
+ } else if (stat.isSymbolicLink()) {
78
+ unlinkSync(linkPath)
79
+ } else {
80
+ rmSync(linkPath, { recursive: true, force: true })
81
+ }
82
+ }
83
+
84
+ function main() {
85
+ const args = parseArgs(process.argv.slice(2))
86
+ if (args.help) {
87
+ console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(1, 22).join('\n').replace(/^\/\/ ?/gm, ''))
88
+ return 0
89
+ }
90
+
91
+ const home = args.home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
92
+
93
+ if (args.file !== undefined) {
94
+ const patchPath = resolve(args.file)
95
+ if (!existsSync(patchPath)) {
96
+ console.error(`FAIL ${patchPath} does not exist`)
97
+ return 1
98
+ }
99
+ const problems = staticChecks(readFileSync(patchPath, 'utf8'))
100
+ if (problems.length > 0) {
101
+ console.error(`FAIL ${patchPath}`)
102
+ for (const problem of problems) console.error(` - ${problem}`)
103
+ return 1
104
+ }
105
+ console.log(`PASS ${patchPath} (static checks only; run without --file to compose the profile)`)
106
+ return 0
107
+ }
108
+
109
+ const profileDir = join(home, 'profiles', args.profile)
110
+ const patchPath = join(profileDir, 'cordis.patch.yml')
111
+ if (!existsSync(patchPath)) {
112
+ console.error(`FAIL no profile patch at ${patchPath}`)
113
+ return 1
114
+ }
115
+
116
+ const problems = staticChecks(readFileSync(patchPath, 'utf8'))
117
+ if (problems.length > 0) {
118
+ console.error(`FAIL static checks on ${patchPath}`)
119
+ for (const problem of problems) console.error(` - ${problem}`)
120
+ return 1
121
+ }
122
+
123
+ const scratchName = `${args.profile}-check-${process.pid}`
124
+ const scratchDir = join(home, 'profiles', scratchName)
125
+ const modulesLink = join(scratchDir, 'node_modules')
126
+ const realModules = join(profileDir, 'node_modules')
127
+
128
+ const cleanup = () => {
129
+ if (args.keep) {
130
+ console.log(`kept ${scratchDir}`)
131
+ return
132
+ }
133
+ try { unlinkModules(modulesLink) } catch { /* already gone */ }
134
+ try { rmSync(scratchDir, { recursive: true, force: true }) } catch { /* best effort */ }
135
+ }
136
+
137
+ try {
138
+ mkdirSync(scratchDir, { recursive: true })
139
+ for (const name of ['package.json', 'cordis.patch.yml', 'cordis.yml']) {
140
+ const from = join(profileDir, name)
141
+ if (existsSync(from)) copyFileSync(from, join(scratchDir, name))
142
+ }
143
+ if (existsSync(realModules)) linkModules(realModules, modulesLink)
144
+
145
+ let output = ''
146
+ let ok = true
147
+ try {
148
+ // `dsh` is a .cmd shim on Windows, which execFileSync cannot spawn
149
+ // directly: go through cmd /c there.
150
+ const command = process.platform === 'win32' ? 'cmd' : 'dsh'
151
+ const argv = process.platform === 'win32'
152
+ ? ['/c', 'dsh', '--profile', scratchName, '--dump-config']
153
+ : ['--profile', scratchName, '--dump-config']
154
+ output = execFileSync(command, argv, {
155
+ encoding: 'utf8',
156
+ stdio: ['ignore', 'pipe', 'pipe'],
157
+ env: { ...process.env, DSH_HOME: home },
158
+ timeout: 180_000,
159
+ })
160
+ } catch (error) {
161
+ ok = false
162
+ output = `${error.stdout ?? ''}${error.stderr ?? ''}` || String(error.message ?? error)
163
+ }
164
+
165
+ if (!ok) {
166
+ console.error(`FAIL the host cannot compose profile "${args.profile}"`)
167
+ const detail = output.split('\n').filter((line) => line.trim() !== '').slice(-25).join('\n')
168
+ console.error(detail)
169
+ return 1
170
+ }
171
+
172
+ const entries = (output.match(/^- id: /gm) ?? []).length
173
+ console.log(`PASS profile "${args.profile}" composes (${entries} top-level entries)`)
174
+ console.log(` ${patchPath}`)
175
+ return 0
176
+ } finally {
177
+ cleanup()
178
+ }
179
+ }
180
+
181
+ try {
182
+ process.exitCode = main()
183
+ } catch (error) {
184
+ console.error(`FAIL ${error?.message ?? error}`)
185
+ process.exitCode = 1
186
+ }