dsh-vibe-math 2.7.3 → 2.8.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/LICENSE +21 -21
- package/README.en.md +682 -682
- package/README.md +683 -683
- package/docs/AUDIT-CHECKLIST.md +758 -744
- package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
- package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
- package/docs/audit-methodology.md +93 -0
- package/docs/final-paper.md +172 -172
- package/docs/formal-verification.md +613 -588
- package/docs/generate_framework_diagram_v2.py +114 -114
- package/docs/generate_framework_diagram_v3.py +127 -127
- package/docs/math-computation.md +195 -194
- package/docs/release-notes/README.en.md +2 -0
- package/docs/release-notes/README.md +2 -0
- package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
- package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
- package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
- package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
- package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
- package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
- package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
- package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
- package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
- package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
- package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
- package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
- package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
- package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
- package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
- package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
- package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
- package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
- package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.1.en.md +49 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.1.md +49 -0
- package/docs/release-playbook.md +111 -0
- package/docs/slv-playbook.md +117 -0
- package/docs/test-timing.md +158 -157
- package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
- package/installer.js +792 -792
- package/package.json +18 -3
- package/prompt-corpus-v2/formal-verify-v2.json +2 -2
- package/prompt-corpus-v2/formal-verify-v2.md +4 -0
- package/prompt-corpus-v3/formal-verify-v3.json +7 -7
- package/prompt-corpus-v3/formal-verify-v3.md +14 -0
- package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
- package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
- package/scripts/clean-temp.mjs +234 -0
- package/scripts/release-check.mjs +181 -0
- package/tests/audit-engine-faces.mjs +298 -0
- package/tests/audit-formal-sensitivity.mjs +345 -342
- package/tests/audit-installer-compat.test.mjs +256 -256
- package/tests/audit-math-computation-sensitivity.mjs +1 -1
- package/tests/audit-persona-sensitivity.mjs +323 -323
- package/tests/audit-persona-surface.test.mjs +431 -431
- package/tests/audit-prompt-duplication.mutants.mjs +3 -2
- package/tests/audit-prompt-invariants.mjs +589 -546
- package/tests/audit-registration.mjs +78 -78
- package/tests/audit-spec-traceability.mjs +193 -193
- package/tests/audit-status-report-fields.mjs +172 -172
- package/tests/audit-v5-integrity.mjs +795 -795
- package/tests/audit-v5-sensitivity.mjs +433 -432
- package/tests/e2e-v5-round2.test.mjs +2453 -2426
- package/tests/formal-verify-v2.test.mjs +1806 -1800
- package/tests/formal-verify-v3.test.mjs +1757 -1750
- package/tests/formal-verify-v4.test.mjs +2114 -2106
- package/tests/formal-verify-v5.test.mjs +774 -767
- package/tests/math-computation-discovery.mutants.mjs +3 -3
- package/tests/math-computation-shared.test.mjs +122 -5
- package/tests/math-computation-v2.test.mjs +14 -2
- package/tests/math-computation-v3.test.mjs +14 -2
- package/tests/math-computation-v4.test.mjs +739 -739
- package/tests/prompt-v5-integrity.test.mjs +1291 -1291
- package/tests/release-check.mutants.mjs +78 -0
- package/tests/run-tests.mjs +393 -332
- package/tests/selfdrive-v5.mjs +560 -560
- package/tests/temp-hygiene.mutants.mjs +80 -0
- package/tests/v2-fix-probes.mutants.mjs +4 -0
- package/tests/v2-fix-probes.test.mjs +1034 -1033
- package/tests/v2v3-interrupt.mutants.mjs +111 -0
- package/tests/v3-fix-probes.mutants.mjs +99 -95
- package/tests/v3-fix-probes.test.mjs +891 -890
- package/tests/v4-final-paper.mutants.mjs +116 -0
- package/tests/v4-final-paper.test.mjs +628 -0
- package/tests/v5-institute-fixes.mutants.mjs +218 -190
- package/vibe-math-v2/agent.cordis.yml +376 -376
- package/vibe-math-v2/math-computation.js +17 -15
- package/vibe-math-v2/preset.yml +2 -2
- package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
- package/vibe-math-v2/vibe-math-v2.js +5078 -5011
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
- package/vibe-math-v3/agent.cordis.yml +420 -420
- package/vibe-math-v3/math-computation.js +17 -15
- package/vibe-math-v3/preset.yml +2 -2
- package/vibe-math-v3/vibe-math-v3.js +6215 -6149
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
- package/vibe-math-v4/agent.cordis.yml +438 -438
- package/vibe-math-v4/math-computation.js +17 -15
- package/vibe-math-v4/preset.yml +2 -2
- package/vibe-math-v4/vibe-math-v4.js +4520 -4422
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
- package/vibe-math-v5/math-computation.js +17 -15
- package/vibe-math-v5/vibe-math-v5.js +93 -11
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
package/installer.js
CHANGED
|
@@ -1,793 +1,793 @@
|
|
|
1
|
-
// dsh-vibe-math merged bundle installer — VERSIONED AUTO-UPDATE.
|
|
2
|
-
// When this bundle is installed (e.g. `dsh plugin --profile <name> add dsh-vibe-math`, or from the
|
|
3
|
-
// dsh-market), this plugin copies ALL FOUR agent presets out of the package into
|
|
4
|
-
// the DSH preset root, so the user immediately gets four presets in the picker:
|
|
5
|
-
// vibe-math-v2/ (probability-driven architecture)
|
|
6
|
-
// vibe-math-v3/ (THIRD-generation: paper-style Markdown knowledge base +
|
|
7
|
-
// planner-agent scheduling + universal theory/method library)
|
|
8
|
-
// vibe-math-v4/ (FOURTH-generation: persistent self-organizing resident
|
|
9
|
-
// subagents — message bus / meetings / unanimous-consensus
|
|
10
|
-
// verification / per-resident libraries)
|
|
11
|
-
// vibe-math-v5/ (FIFTH-generation: research institute — academician who
|
|
12
|
-
// assigns work, permanent researchers who vote, temp workers,
|
|
13
|
-
// group chat + meetings + m-vote consensus)
|
|
14
|
-
//
|
|
15
|
-
// (vibe-math-v1 — the classic pipeline — was removed at v2.0.0; this bundle now
|
|
16
|
-
// ships v2/v3/v4/v5.)
|
|
17
|
-
//
|
|
18
|
-
// UPDATE POLICY (state recorded in <presetRoot>/.vibe-math-installed.json):
|
|
19
|
-
// - FORCE-REPLACE ON VERSION CHANGE. When the recorded version differs from this package's
|
|
20
|
-
// version — or there is no record at all (an install made by an older installer) — every
|
|
21
|
-
// managed file is overwritten with the shipped bytes. This is deliberately NOT conditional on
|
|
22
|
-
// the file being unmodified. Two reasons:
|
|
23
|
-
// · a preset assembled from two different versions (the old policy updated a file's
|
|
24
|
-
// neighbours and kept the file the user had touched) is exactly the state that fails to
|
|
25
|
-
// mount or misbehaves subtly, and the user has no way to see that from the outside;
|
|
26
|
-
// · editing a shipped preset in place is not the supported way to customize one — DSH
|
|
27
|
-
// provides a real one (copy the preset: the picker's copy action, or a new directory
|
|
28
|
-
// under <presetRoot>), which leaves the managed set updatable.
|
|
29
|
-
// - Nothing is destroyed silently: before a file whose bytes are not what the installer last
|
|
30
|
-
// wrote is REPLACED — or DELETED along with a preset this bundle no longer ships — the user's
|
|
31
|
-
// copy is kept under <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in
|
|
32
|
-
// the log. A file that still matches the hash recorded for it is a copy of the package, so
|
|
33
|
-
// removing it destroys nothing and needs no backup.
|
|
34
|
-
// - Files are written ATOMICALLY: a uniquely named temp file is written and then renamed over the
|
|
35
|
-
// destination, so an interrupted boot (a kill, a full disk, two sessions writing at once) can
|
|
36
|
-
// never leave a half-written preset for the next same-version run to mistake for a user edit.
|
|
37
|
-
// - same version: no-op (idempotent) — restarting DSH never rewrites a preset file, never churns
|
|
38
|
-
// the preset's generation stamp and never rewrites this state file (it is written only when its
|
|
39
|
-
// content actually changes). Missing files are ALWAYS restored, at any version.
|
|
40
|
-
// - force a full refresh at any time: delete the preset dirs and restart DSH.
|
|
41
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs'
|
|
42
|
-
import { createRequire } from 'node:module'
|
|
43
|
-
import { createHash } from 'node:crypto'
|
|
44
|
-
import { homedir } from 'node:os'
|
|
45
|
-
import { dirname, join } from 'node:path'
|
|
46
|
-
import { fileURLToPath } from 'node:url'
|
|
47
|
-
|
|
48
|
-
export const name = 'vibe-math-preset-installer'
|
|
49
|
-
|
|
50
|
-
// Exported so the shipped policy suite can prove the managed list still covers what each preset
|
|
51
|
-
// needs at runtime (a file present in the preset directory but missing here is copied by nobody,
|
|
52
|
-
// and the installed preset then cannot mount).
|
|
53
|
-
export const PRESETS = [
|
|
54
|
-
{
|
|
55
|
-
src: 'vibe-math-v2',
|
|
56
|
-
dst: 'vibe-math-v2',
|
|
57
|
-
// math-computation.js + math-engines.js are PER-PRESET copies of the shared module (they must be
|
|
58
|
-
// listed here: on the <=0.1.6 line only these files are copied into .agent-presets/<id>/, and the
|
|
59
|
-
// preset JS imports them relatively). guard: tests/audit-math-computation-parity.mjs
|
|
60
|
-
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v2.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
src: 'vibe-math-v3',
|
|
64
|
-
dst: 'vibe-math-v3',
|
|
65
|
-
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v3.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
src: 'vibe-math-v4',
|
|
69
|
-
dst: 'vibe-math-v4',
|
|
70
|
-
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v4.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
src: 'vibe-math-v5',
|
|
74
|
-
dst: 'vibe-math-v5',
|
|
75
|
-
// 架构图.md belongs here for the same reason 实现方案.md does: the installer's policy is to put
|
|
76
|
-
// the preset's documentation next to the preset, and the shipped v5 directory carries both.
|
|
77
|
-
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v5.js', '实现方案.md', '架构图.md', 'math-computation.js', 'math-engines.js'],
|
|
78
|
-
},
|
|
79
|
-
]
|
|
80
|
-
|
|
81
|
-
const STATE_FILE = '.vibe-math-installed.json'
|
|
82
|
-
// where a replaced user edit is preserved; a leading dot keeps DSH's preset discovery from ever
|
|
83
|
-
// treating it as a preset directory (ids must match [a-z0-9][a-z0-9-]*)
|
|
84
|
-
const BACKUP_DIR = '.vibe-math-backup'
|
|
85
|
-
|
|
86
|
-
function sha256(buf) { return createHash('sha256').update(buf).digest('hex') }
|
|
87
|
-
|
|
88
|
-
let presetTmpSeq = 0
|
|
89
|
-
/**
|
|
90
|
-
* Write one preset file ATOMICALLY: a uniquely named temp sibling, then a rename over the
|
|
91
|
-
* destination. An in-place `writeFileSync` can be interrupted (a killed or crashed DSH, a full disk,
|
|
92
|
-
* two sessions writing the same path) and leave a TRUNCATED preset behind — and the next
|
|
93
|
-
* same-version boot would take those bytes for the user's own edit and never repair them (a
|
|
94
|
-
* same-version run only restores MISSING files). The pid plus a per-process sequence make the temp
|
|
95
|
-
* name unique, so concurrent writers cannot rename each other's half-written file. A leftover temp
|
|
96
|
-
* (possible only after SIGKILL between the two calls) is inert: DSH reads the named preset files.
|
|
97
|
-
*/
|
|
98
|
-
function writePresetFile(dest, buf) {
|
|
99
|
-
const tmp = dest + '.' + process.pid + '.' + (presetTmpSeq++) + '.vibe-math-tmp'
|
|
100
|
-
try {
|
|
101
|
-
writeFileSync(tmp, buf)
|
|
102
|
-
renameSync(tmp, dest)
|
|
103
|
-
} catch (e) {
|
|
104
|
-
try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
|
|
105
|
-
throw e
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Preserve one file that is about to be replaced by the shipped version, under
|
|
111
|
-
* `<presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>`.
|
|
112
|
-
*
|
|
113
|
-
* Returns `'written'` (a copy was made now), `'kept'` (a copy for this version was already there —
|
|
114
|
-
* the earliest edit is the one worth keeping, and a re-run must not overwrite it with an
|
|
115
|
-
* already-replaced file) or `'failed'`. A caller must not report `'kept'` as a failure: the user's
|
|
116
|
-
* bytes are preserved, just from an earlier run.
|
|
117
|
-
*/
|
|
118
|
-
function backupReplacedFile(presetRoot, fromVersion, presetDir, fileName, buf) {
|
|
119
|
-
try {
|
|
120
|
-
const dir = join(presetRoot, BACKUP_DIR, String(fromVersion || 'unversioned'), presetDir)
|
|
121
|
-
const dst = join(dir, fileName)
|
|
122
|
-
if (existsSync(dst)) return 'kept' // the earliest copy for this version is the one worth keeping
|
|
123
|
-
mkdirSync(dir, { recursive: true })
|
|
124
|
-
writeFileSync(dst, buf)
|
|
125
|
-
return 'written'
|
|
126
|
-
} catch (e) {
|
|
127
|
-
return 'failed' // a backup failure must never stop the update; it is reported by the caller
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function readState(path) {
|
|
132
|
-
try {
|
|
133
|
-
const raw = readFileSync(path, 'utf8')
|
|
134
|
-
const obj = JSON.parse(raw)
|
|
135
|
-
if (obj && typeof obj === 'object' && obj.files && typeof obj.files === 'object') return obj
|
|
136
|
-
} catch (e) { /* missing or corrupt — treat as no state (baseline) */ }
|
|
137
|
-
return null
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Does the new payload equal the recorded one? Compared field by field (never as JSON text) so key
|
|
142
|
-
* ORDER cannot fake a change, and the timestamp is ignored on purpose: `updatedAt` records when the
|
|
143
|
-
* file was written, not that anything happened.
|
|
144
|
-
*/
|
|
145
|
-
function sameStatePayload(previous, next) {
|
|
146
|
-
if (!previous || !next) return false
|
|
147
|
-
if (String(previous.version || '') !== String(next.version || '')) return false
|
|
148
|
-
const a = previous.files || {}
|
|
149
|
-
const b = next.files || {}
|
|
150
|
-
const ka = Object.keys(a).sort()
|
|
151
|
-
const kb = Object.keys(b).sort()
|
|
152
|
-
if (ka.length !== kb.length) return false
|
|
153
|
-
for (let i = 0; i < ka.length; i++) {
|
|
154
|
-
if (ka[i] !== kb[i]) return false
|
|
155
|
-
const ra = a[ka[i]] || {}
|
|
156
|
-
const rb = b[kb[i]] || {}
|
|
157
|
-
if (String(ra.hash || '') !== String(rb.hash || '')) return false
|
|
158
|
-
if (String(ra.provenance || '') !== String(rb.provenance || '')) return false
|
|
159
|
-
}
|
|
160
|
-
return true
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
let stateTmpSeq = 0
|
|
164
|
-
let stateWriteWarned = false
|
|
165
|
-
/**
|
|
166
|
-
* Persist the state file, atomically. Three things here are deliberate:
|
|
167
|
-
* · an UNCHANGED payload is not written at all — a same-version boot must not churn this file (the
|
|
168
|
-
* "restarting DSH writes nothing" promise covers the state file too, not only the presets);
|
|
169
|
-
* · the temp name carries the pid and a sequence, so two concurrent boots cannot rename each
|
|
170
|
-
* other's half-written file (with a fixed `path + '.tmp'` the loser's rename failed and the
|
|
171
|
-
* whole write vanished without a word);
|
|
172
|
-
* · a failure is REPORTED once instead of being swallowed. The copy step already did its work; a
|
|
173
|
-
* missing state only means the next boot re-compares everything (at worst it backs up one extra
|
|
174
|
-
* original, it never overwrites a user edit).
|
|
175
|
-
*/
|
|
176
|
-
function writeState(path, state, previous, logger) {
|
|
177
|
-
if (sameStatePayload(previous, state)) return 'unchanged'
|
|
178
|
-
const tmp = path + '.' + process.pid + '.' + (stateTmpSeq++) + '.tmp'
|
|
179
|
-
try {
|
|
180
|
-
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8')
|
|
181
|
-
renameSync(tmp, path)
|
|
182
|
-
return 'written'
|
|
183
|
-
} catch (e) {
|
|
184
|
-
try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
|
|
185
|
-
if (!stateWriteWarned) {
|
|
186
|
-
stateWriteWarned = true
|
|
187
|
-
logger?.warn?.('[dsh-vibe-math] 状态文件写入失败(' + path + '):' + String((e && e.message) || e) +
|
|
188
|
-
'。preset 文件本身已按上面的日志处理完毕;下一次启动会按「没有记录」重新比对(最坏情况是多备份一份原文,不会覆盖你的改动)。')
|
|
189
|
-
}
|
|
190
|
-
return 'failed'
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// DSH 适配性自检(版本 + preset 形态 + 能力)。
|
|
195
|
-
// 检查 preset 运行时需要的宿主服务与关键 API 形状是否可用,缺失时打 warning。
|
|
196
|
-
// Best-effort DSH host-version detection, in order of authority:
|
|
197
|
-
// 1. `process.env.DSH_VERSION` — an explicit override, always honoured first;
|
|
198
|
-
// 2. the `pluginManager` service's bundle list — every host bundle carries the runtime version and
|
|
199
|
-
// `@deepseek-ai/dsh-base` is present in every base-backed profile (service since 0.1.7);
|
|
200
|
-
// 3. `@deepseek-ai/dsh-app-boot`'s exported `getDshRuntimeVersion()` — the documented API; it only
|
|
201
|
-
// resolves when this plugin's module scope can reach the host packages;
|
|
202
|
-
// 4. the installed `@deepseek-ai/dsh/package.json` — the historical probe, still the only source
|
|
203
|
-
// that works on a global <= 0.1.6 install.
|
|
204
|
-
// When none resolves, the capability self-check below is still the authoritative gate.
|
|
205
|
-
const __require = createRequire(import.meta.url)
|
|
206
|
-
|
|
207
|
-
// The detected version is only ever used for diagnostics, and this probe sits on the installer ROW's
|
|
208
|
-
// activation path: a service that never answers must not hold the boot there. On timeout (or on a
|
|
209
|
-
// rejection) the next source is tried instead — no verdict depends on this value.
|
|
210
|
-
const LIST_BUNDLES_TIMEOUT_MS = 1500
|
|
211
|
-
function withTimeout(promise, ms) {
|
|
212
|
-
return new Promise((resolve) => {
|
|
213
|
-
let settled = false
|
|
214
|
-
let timer
|
|
215
|
-
const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value) } }
|
|
216
|
-
timer = setTimeout(() => { if (!settled) { settled = true; resolve(undefined) } }, ms)
|
|
217
|
-
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
218
|
-
Promise.resolve(promise).then(done, () => done(undefined))
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export async function detectDshVersion(ctx) {
|
|
223
|
-
// Installer review (finding): several sources can report this host's version, and "first available
|
|
224
|
-
// wins" hid a CONFLICTING host state (the same lesson the `engines.dsh`-vs-`dshReleases` rule already
|
|
225
|
-
// records below). Probe them ALL, keep the first available authoritative (priority unchanged), and
|
|
226
|
-
// record the rest so a disagreement is visible instead of silent.
|
|
227
|
-
const probes = []
|
|
228
|
-
const add = (source, version) => { const v = (version === undefined || version === null) ? '' : String(version).trim(); if (v) probes.push({ source: source, version: v }) }
|
|
229
|
-
try { add('DSH_VERSION', process.env.DSH_VERSION) } catch (e) {}
|
|
230
|
-
try {
|
|
231
|
-
const pm = (ctx && ctx.get) ? ctx.get('pluginManager') : undefined
|
|
232
|
-
if (pm && typeof pm.listBundles === 'function') {
|
|
233
|
-
// withTimeout: a slow or stuck pluginManager falls through to the next source instead of
|
|
234
|
-
// stalling this row's activation (non-fatal either way — see the constant's comment).
|
|
235
|
-
const bundles = await withTimeout(pm.listBundles(), LIST_BUNDLES_TIMEOUT_MS)
|
|
236
|
-
const host = (bundles || []).find((b) => b && (b.name === '@deepseek-ai/dsh-base' || b.name === '@deepseek-ai/dsh'))
|
|
237
|
-
if (host && host.version) add('pluginManager:' + host.name, host.version)
|
|
238
|
-
}
|
|
239
|
-
} catch (e) { /* no such service, or it cannot list yet */ }
|
|
240
|
-
try {
|
|
241
|
-
const boot = await import('@deepseek-ai/dsh-app-boot')
|
|
242
|
-
if (boot && typeof boot.getDshRuntimeVersion === 'function') add('dsh-app-boot', boot.getDshRuntimeVersion())
|
|
243
|
-
} catch (e) { /* host packages are not reachable from this plugin's module scope */ }
|
|
244
|
-
try {
|
|
245
|
-
const p = __require.resolve('@deepseek-ai/dsh/package.json')
|
|
246
|
-
add('@deepseek-ai/dsh/package.json', JSON.parse(readFileSync(p, 'utf8')).version || '')
|
|
247
|
-
} catch (e) { /* not a global install layout — rely on the capability check */ }
|
|
248
|
-
if (!probes.length) return undefined
|
|
249
|
-
const first = probes[0]
|
|
250
|
-
const conflicts = probes.slice(1).filter((x) => x.version !== first.version)
|
|
251
|
-
const out = { version: first.version, source: first.source, probes: probes.slice() }
|
|
252
|
-
// Assemble the disagreement slot ONLY when there is one (no empty slot for agreeing hosts).
|
|
253
|
-
if (conflicts.length) out.disagreement = { primary: first.source, conflicts: conflicts.slice() }
|
|
254
|
-
return out
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* How this host declares agent presets.
|
|
259
|
-
* 'rows' — DSH >= 0.1.7: composition rows declared in this package's cordis.patch.yml (the rows
|
|
260
|
-
* name our own preset-declaration module, which calls `agentPresets.register`).
|
|
261
|
-
* Nothing reads the preset directory any more.
|
|
262
|
-
* 'directory' — DSH <= 0.1.6: <DSH_HOME>/.agent-presets/<id>/agent.cordis.yml, which is what the
|
|
263
|
-
* copy in apply() installs.
|
|
264
|
-
*
|
|
265
|
-
* Judged from the LOADER TREE first, then from the one capability that actually differs between the
|
|
266
|
-
* two lines: the `agentPresets` service. That service exists on BOTH lines with different meaning (a
|
|
267
|
-
* directory scanner below 0.1.7, a row registry from 0.1.7 on) and may not be up yet when a bundle is
|
|
268
|
-
* activated on its own, which is why the tree is asked first — but when only the service can be seen,
|
|
269
|
-
* `register` (never `list`, which both have) is what tells them apart. Missing the row line would skip
|
|
270
|
-
* the only working install path, so this decision has to be the reliable one.
|
|
271
|
-
*/
|
|
272
|
-
export function detectPresetMechanism(ctx) {
|
|
273
|
-
// 1) the LOADER TREE: only the row-based line mounts the agent-preset package (or its registry).
|
|
274
|
-
// This is visible before any plugin activates, which matters because a bundle activation can run
|
|
275
|
-
// this installer before the services are up (then ctx.get('agentPresets') is still undefined).
|
|
276
|
-
try {
|
|
277
|
-
const loader = (ctx && ctx.get) ? ctx.get('loader') : undefined
|
|
278
|
-
if (loader && typeof loader.entries === 'function') {
|
|
279
|
-
const entries = loader.entries()
|
|
280
|
-
for (const entry of entries) {
|
|
281
|
-
const name = entry && entry.options ? entry.options.name : undefined
|
|
282
|
-
if (name === '@deepseek-ai/dsh-agent-preset' || name === '@deepseek-ai/dsh-agent-preset-registry') return 'rows'
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
} catch (e) { /* no loader service: fall through to the service probe */ }
|
|
286
|
-
// 2) the service itself, when it is already available. The PRECISE capability difference between
|
|
287
|
-
// the two lines is `register`: the >= 0.1.7 row registry has it, the <= 0.1.6 directory scanner
|
|
288
|
-
// is INFERRED to lack it. The record is partial — a real 0.1.6-alpha.2 boot exposed the service
|
|
289
|
-
// and its `list()` returned the directory roster (_oneoff/roster-016a2.json), but that probe
|
|
290
|
-
// never ASKED for `register`; the inference follows from the package difference (npm:
|
|
291
|
-
// 0.1.6-alpha.2 ships no `@deepseek-ai/dsh-agent-preset`, while 0.1.7-rc.2 adds it). The
|
|
292
|
-
// misclassification this guards against is therefore a LATENT RISK, not an observed failure:
|
|
293
|
-
// testing `list` (which BOTH lines have) would classify a service-ready old host as the row
|
|
294
|
-
// line, and apply() would then return before writing a single preset directory — "installed but
|
|
295
|
-
// invisible", the 2.4.0 defect mirrored onto the older line. That outcome was never observed:
|
|
296
|
-
// the recorded 0.1.6 boot DID write all four preset directories. Never test a capability both
|
|
297
|
-
// lines share.
|
|
298
|
-
try {
|
|
299
|
-
const ap = (ctx && ctx.get) ? ctx.get('agentPresets') : undefined
|
|
300
|
-
if (ap !== undefined) return typeof ap.register === 'function' ? 'rows' : 'directory'
|
|
301
|
-
} catch (e) { /* no service to ask: the directory form */ }
|
|
302
|
-
return 'directory'
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Minimal semver-range matcher for the host requirement.
|
|
307
|
-
*
|
|
308
|
-
* WHY THIS EXISTS: the DSH requirement a host is judged against lives in `package.json`
|
|
309
|
-
* (`engines.dsh` / `dsh.engines.dsh`) — the same declaration dsh-market shows on a plugin's card.
|
|
310
|
-
* Judging the host by a *second* source (the `dsh.compatibility.dshReleases` map) let the two
|
|
311
|
-
* disagree: the card could say "compatible" while this self-check warned, or the reverse. So the
|
|
312
|
-
* range is evaluated here, and the map remains only a fallback for manifests that declare no range.
|
|
313
|
-
*
|
|
314
|
-
* Supported: `*`, exact, `^`, `~`, `>=`, `>`, `<=`, `<`, whitespace-separated sets, `||`
|
|
315
|
-
* alternatives — everything the ecosystem publishes (see `dshmarket`'s own
|
|
316
|
-
* `^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2` shape). Anything else returns null = unknown
|
|
317
|
-
* (reported, never asserted), never a silent "incompatible".
|
|
318
|
-
*
|
|
319
|
-
* PRERELEASE RULE (npm's, applied per comparator SET — one `||` alternative is one set): a version
|
|
320
|
-
* carrying a prerelease tag satisfies a set only when at least one comparator in that set shares its
|
|
321
|
-
* [major, minor, patch] tuple AND carries a prerelease of its own. This is why
|
|
322
|
-
* `>=0.1.2-rc.1 <0.2.0` does NOT match `0.1.5-rc.2`, and why this package declares explicit
|
|
323
|
-
* per-tuple branches instead. Callers pass `includePrerelease: true` for host checks because every
|
|
324
|
-
* published DSH release line is itself a prerelease.
|
|
325
|
-
*/
|
|
326
|
-
export function satisfiesDshRange(version, range, options = {}) {
|
|
327
|
-
const v = parseSemver(version)
|
|
328
|
-
if (v === null || typeof range !== 'string' || range.trim() === '') return null
|
|
329
|
-
const versionHasPre = v.pre.length > 0
|
|
330
|
-
let sawUnknown = false
|
|
331
|
-
for (const set of range.split('||')) {
|
|
332
|
-
const parts = set.trim().split(/\s+/).filter((p) => p !== '')
|
|
333
|
-
if (parts.length === 0) return true // an empty alternative is `*`
|
|
334
|
-
const parsed = parts.map(comparator)
|
|
335
|
-
if (parsed.some((p) => p === null)) { sawUnknown = true; continue }
|
|
336
|
-
if (versionHasPre && options.includePrerelease !== true) {
|
|
337
|
-
const admitted = parsed.some((p) => p.target !== null && p.target.pre.length > 0 &&
|
|
338
|
-
p.target.major === v.major && p.target.minor === v.minor && p.target.patch === v.patch)
|
|
339
|
-
if (!admitted) continue
|
|
340
|
-
}
|
|
341
|
-
if (parsed.every((p) => matchesComparator(v, p))) return true
|
|
342
|
-
}
|
|
343
|
-
return sawUnknown ? null : false
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function parseSemver(value) {
|
|
347
|
-
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value == null ? '' : value).trim())
|
|
348
|
-
if (m === null) return null
|
|
349
|
-
const pre = m[4] === undefined ? [] : m[4].split('.')
|
|
350
|
-
for (const id of pre) if (/^\d+$/.test(id) && id.length > 1 && id[0] === '0') return null
|
|
351
|
-
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre }
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function compareSemver(a, b) {
|
|
355
|
-
if (a.major !== b.major) return a.major < b.major ? -1 : 1
|
|
356
|
-
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
|
|
357
|
-
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
|
|
358
|
-
if (a.pre.length === 0 && b.pre.length === 0) return 0
|
|
359
|
-
if (a.pre.length === 0) return 1 // a release outranks its prereleases
|
|
360
|
-
if (b.pre.length === 0) return -1
|
|
361
|
-
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
362
|
-
const x = a.pre[i], y = b.pre[i]
|
|
363
|
-
if (x === undefined) return -1
|
|
364
|
-
if (y === undefined) return 1
|
|
365
|
-
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
|
|
366
|
-
if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d < 0 ? -1 : 1; continue }
|
|
367
|
-
if (xn !== yn) return xn ? -1 : 1 // numeric identifiers sort below alphanumeric ones
|
|
368
|
-
if (x !== y) return x < y ? -1 : 1
|
|
369
|
-
}
|
|
370
|
-
return 0
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** One comparator such as `^0.1.0-rc.7`. Returns null when the target is not a version. */
|
|
374
|
-
function comparator(part) {
|
|
375
|
-
const p = part.trim()
|
|
376
|
-
// `*`, `x`, `X` and an empty token mean "any version" (npm: still not a prerelease unless the
|
|
377
|
-
// caller opts into includePrerelease — the set-level gate below is what enforces that).
|
|
378
|
-
if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: 'any', target: null }
|
|
379
|
-
const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
|
|
380
|
-
const op = m === null || m[1] === undefined ? '' : m[1]
|
|
381
|
-
const target = parseSemver(m === null ? '' : m[2])
|
|
382
|
-
return target === null ? null : { op, target }
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function matchesComparator(v, { op, target }) {
|
|
386
|
-
if (op === 'any') return true
|
|
387
|
-
const c = compareSemver(v, target)
|
|
388
|
-
switch (op) {
|
|
389
|
-
case '': return c === 0
|
|
390
|
-
case '>=': return c >= 0
|
|
391
|
-
case '>': return c > 0
|
|
392
|
-
case '<=': return c <= 0
|
|
393
|
-
case '<': return c < 0
|
|
394
|
-
case '^': {
|
|
395
|
-
const upper = target.major > 0
|
|
396
|
-
? { major: target.major + 1, minor: 0, patch: 0, pre: [ '0' ] }
|
|
397
|
-
: target.minor > 0
|
|
398
|
-
? { major: 0, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
|
|
399
|
-
: { major: 0, minor: 0, patch: target.patch + 1, pre: [ '0' ] }
|
|
400
|
-
return c >= 0 && compareSemver(v, upper) < 0
|
|
401
|
-
}
|
|
402
|
-
case '~': {
|
|
403
|
-
const upper = { major: target.major, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
|
|
404
|
-
return c >= 0 && compareSemver(v, upper) < 0
|
|
405
|
-
}
|
|
406
|
-
default: return false
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* The host verdict for a DSH version against a package manifest.
|
|
412
|
-
* Prefers the declared range (`engines.dsh`, then `dsh.engines.dsh`); falls back to the
|
|
413
|
-
* `dsh.compatibility.dshReleases` map so packages that declare only that keep working.
|
|
414
|
-
*/
|
|
415
|
-
export function dshVersionVerdict(version, manifest) {
|
|
416
|
-
const pkg = manifest && typeof manifest === 'object' ? manifest : {}
|
|
417
|
-
const declared = (pkg.engines && typeof pkg.engines.dsh === 'string' && pkg.engines.dsh.trim() !== '')
|
|
418
|
-
? pkg.engines.dsh
|
|
419
|
-
: (pkg.dsh && pkg.dsh.engines && typeof pkg.dsh.engines.dsh === 'string' && pkg.dsh.engines.dsh.trim() !== '')
|
|
420
|
-
? pkg.dsh.engines.dsh
|
|
421
|
-
: null
|
|
422
|
-
if (declared !== null) {
|
|
423
|
-
// includePrerelease: the whole published DSH line is prerelease builds.
|
|
424
|
-
const sat = satisfiesDshRange(version, declared, { includePrerelease: true })
|
|
425
|
-
return {
|
|
426
|
-
basis: 'engines',
|
|
427
|
-
requirement: declared,
|
|
428
|
-
status: sat === true ? 'compatible' : sat === false ? 'incompatible' : 'unknown',
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
const rel = (pkg.dsh && pkg.dsh.compatibility && pkg.dsh.compatibility.dshReleases) || {}
|
|
432
|
-
const status = rel[version]
|
|
433
|
-
if (status === 'compatible' || status === 'incompatible') {
|
|
434
|
-
return { basis: 'dshReleases', requirement: null, status }
|
|
435
|
-
}
|
|
436
|
-
return { basis: 'dshReleases', requirement: null, status: status === 'unknown' ? 'unknown' : 'undeclared' }
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function checkHostCapabilities(ctx, logger) {
|
|
440
|
-
const problems = []
|
|
441
|
-
// 1) DSH version compatibility (best-effort, only when the version is detectable).
|
|
442
|
-
// The AUTHORITATIVE source is the declared range `engines.dsh` / `dsh.engines.dsh` — the same
|
|
443
|
-
// field dsh-market renders on the plugin card, so the two verdicts cannot disagree. The
|
|
444
|
-
// `dsh.compatibility.dshReleases` map is the fallback for manifests without a range.
|
|
445
|
-
let manifest = {}
|
|
446
|
-
try { manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')) } catch (e) {}
|
|
447
|
-
const dshRel = (manifest.dsh && manifest.dsh.compatibility && manifest.dsh.compatibility.dshReleases) || {}
|
|
448
|
-
const supported = Object.keys(dshRel).sort()
|
|
449
|
-
const detected = await detectDshVersion(ctx)
|
|
450
|
-
const dshVersion = detected && detected.version
|
|
451
|
-
const versionSource = detected && detected.source
|
|
452
|
-
const mechanism = detectPresetMechanism(ctx)
|
|
453
|
-
if (dshVersion) {
|
|
454
|
-
const verdict = dshVersionVerdict(dshVersion, manifest)
|
|
455
|
-
if (verdict.status === 'incompatible') {
|
|
456
|
-
problems.push(verdict.basis === 'engines'
|
|
457
|
-
? '当前 DSH 版本 v' + dshVersion + ' 不满足本包声明的宿主版本要求(engines.dsh = ' + verdict.requirement + ')。'
|
|
458
|
-
: '当前 DSH 版本 v' + dshVersion + ' 被本包声明为 incompatible;请使用 ' + supported.join(' / ') + '。')
|
|
459
|
-
} else if (verdict.status === 'undeclared' || (verdict.status === 'unknown' && verdict.basis === 'dshReleases')) {
|
|
460
|
-
problems.push('当前 DSH 版本 v' + dshVersion + ' 尚未被本包声明为兼容(dshReleases 仅声明 ' + supported.join(' / ') + ');建议使用 ' + supported.join(' / ') + ',或将该版本在 dshReleases 中标注后再自行验证。')
|
|
461
|
-
} else if (verdict.status === 'unknown' && verdict.basis === 'engines') {
|
|
462
|
-
problems.push('当前 DSH 版本 v' + dshVersion + ' 无法与 engines.dsh 的范围比对(声明值 ' + verdict.requirement + ' 不是本安装器能解析的范围);请按该范围自行确认。')
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
// 2) capability self-check (the authoritative mounting gate; also covers hosts whose version
|
|
466
|
-
// could not be read). subagents / agents / tools / commands / fs shapes + v4 capabilities.
|
|
467
|
-
// `required: true` services are the mounting gate; the rest are optional services the presets
|
|
468
|
-
// read with ctx.get(). Their absence does not stop a mount but degrades SILENTLY, so report
|
|
469
|
-
// them instead of letting the field discover them: without `subprocess` no directory-creation
|
|
470
|
-
// shell runs, without `sandboxPolicy` writes carry no explicit fence, without `compaction` the
|
|
471
|
-
// v4 real /compact path is inert.
|
|
472
|
-
const checks = [
|
|
473
|
-
// subagents 服务的续做/唤醒方法是 sendMessage(sender, targetId, content, {signal});
|
|
474
|
-
// followup 不是 subagents 服务的方法(它只是 Agent 对象方法)。同时探测两者,能用一个即可。
|
|
475
|
-
{ svc: 'subagents', methods: ['startContinuable', 'interrupt'], required: true },
|
|
476
|
-
{ svc: 'agents', methods: ['roots'], required: true },
|
|
477
|
-
{ svc: 'tools', methods: ['register'], required: true },
|
|
478
|
-
{ svc: 'commands', methods: ['register'], required: true },
|
|
479
|
-
{ svc: 'fs', methods: ['resolve', 'stat', 'readText', 'writeText', 'listDir'], required: true },
|
|
480
|
-
{ svc: 'subprocess', methods: ['spawn'], required: false },
|
|
481
|
-
{ svc: 'sandboxPolicy', methods: ['resolve'], required: false },
|
|
482
|
-
{ svc: 'compaction', methods: ['compactIfNeeded'], required: false },
|
|
483
|
-
// v5 keeps its institute state in a hardened JSON file (State/<institute>.v5state.json) and does
|
|
484
|
-
// NOT use the session-projection registry or the session store any more: DSH refuses to load a
|
|
485
|
-
// session whose log carries event types outside its known set, so writing institute events into
|
|
486
|
-
// the user's session used to make that session unresumable. Neither service is checked here.
|
|
487
|
-
]
|
|
488
|
-
const degradations = []
|
|
489
|
-
for (let i = 0; i < checks.length; i++) {
|
|
490
|
-
const svc = checks[i].svc
|
|
491
|
-
const methods = checks[i].methods
|
|
492
|
-
const required = checks[i].required === true
|
|
493
|
-
const report = required ? ((m) => problems.push(m)) : ((m) => degradations.push(m))
|
|
494
|
-
let s
|
|
495
|
-
try { s = (ctx && ctx.get) ? ctx.get(svc) : undefined } catch (e) { s = undefined }
|
|
496
|
-
if (s === undefined) { report('宿主缺少服务 ' + svc); continue }
|
|
497
|
-
for (let j = 0; j < methods.length; j++) {
|
|
498
|
-
if (typeof s[methods[j]] !== 'function') report(svc + '.' + methods[j] + ' 不可用(宿主版本可能过旧)')
|
|
499
|
-
}
|
|
500
|
-
// subagents continuation (wake) API: sendMessage (modern) OR followup (legacy) must exist.
|
|
501
|
-
if (svc === 'subagents' && typeof s.sendMessage !== 'function' && typeof s.followup !== 'function') {
|
|
502
|
-
problems.push('subagents 缺少续做/唤醒方法(需 sendMessage 或 followup 至少其一)')
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
// fs API shape: DSH 0.1.1 起 resolve 返回 {targetKey, displayPath} 对象(旧版返回字符串路径)
|
|
506
|
-
try {
|
|
507
|
-
const f = (ctx && ctx.get) ? ctx.get('fs') : undefined
|
|
508
|
-
if (f && typeof f.resolve === 'function') {
|
|
509
|
-
const r = await f.resolve('x', { cwd: process.cwd() })
|
|
510
|
-
if (typeof r !== 'object' || r === null || typeof r.targetKey !== 'string') {
|
|
511
|
-
problems.push('fs.resolve 返回形状不符(期望 {targetKey, displayPath},v3/v4 预设要求 DSH ≥ 0.1.1)')
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
} catch (e) { problems.push('fs.resolve 能力检测失败:' + String((e && e.message) || e)) }
|
|
515
|
-
// v4 依赖 subagents.startContinuable 的 agentOptions / toolFilter 能力(DSH 0.1.2 起由
|
|
516
|
-
// dsh-subagent 声明 SubagentCapabilities.agentOptions;spawn/fork 进程内 provider 均支持。
|
|
517
|
-
// 缺省 provider 名按 spawn 探测;探测失败不视为致命(等价于回退到再试一次、只警告)。
|
|
518
|
-
try {
|
|
519
|
-
const sa = (ctx && ctx.get) ? ctx.get('subagents') : undefined
|
|
520
|
-
if (sa && typeof sa.list === 'function') {
|
|
521
|
-
const names = (sa.list ? sa.list() : [])
|
|
522
|
-
const name = names.indexOf('spawn') !== -1 ? 'spawn' : (names[0] || '')
|
|
523
|
-
if (name && typeof sa.getProvider === 'function') {
|
|
524
|
-
const cap = (sa.getProvider(name) || {}).capabilities
|
|
525
|
-
if (cap && cap.agentOptions === false) problems.push('subagents provider "' + name + '" 不支持 agentOptions(v4 指定常驻模型/路由需要)')
|
|
526
|
-
if (cap && cap.toolFilter === false) problems.push('subagents provider "' + name + '" 不支持 toolFilter(v4 常驻工具权限需要)')
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
} catch (e) { /* 探测失败不致命 */ }
|
|
530
|
-
if (degradations.length > 0) {
|
|
531
|
-
logger?.warn?.('[dsh-vibe-math] 可选宿主服务缺失,功能会静默降级(不影响挂载):' + degradations.join(';') + '。subprocess 缺失则无法用 shell 创建目录树(仅靠 fs 自动建父目录兜底);sandboxPolicy 缺失则插件写入不带显式围栏;compaction 缺失则 v4 的真实 /compact 路径与 v5 的真实压缩不生效(v5 回退到自述浓缩)')
|
|
532
|
-
}
|
|
533
|
-
if (problems.length > 0) {
|
|
534
|
-
logger?.warn?.('[dsh-vibe-math] 宿主自检:' + problems.length + ' 项不满足(' + problems.join(';') + ')。v2/v3/v4/v5 预设依赖这些宿主服务/API,旧版或未经声明兼容的 DSH 可能无法挂载' + (dshVersion ? '(当前检测到 DSH v' + dshVersion + ',来源 ' + versionSource + ',本包适配 ' + (supported.length ? supported.join(' / ') : '(未声明)') + ')' : '') + '。')
|
|
535
|
-
} else {
|
|
536
|
-
logger?.info?.('[dsh-vibe-math] 宿主自检通过(“自检通过” ≠ “兼容”:这里只证明服务/API 的**形状**,不证明**行为**语义):subagents / agents / tools / commands / fs 服务及关键 API 均可用' + (degradations.length === 0 ? ',可选服务 subprocess / sandboxPolicy / compaction 亦齐备' : '(可选服务有缺失,见上方警告)') + (dshVersion ? '(当前 DSH v' + dshVersion + ',来源 ' + versionSource + ';本包已声明兼容 ' + supported.join(' / ') + ')' : '') + '。')
|
|
537
|
-
}
|
|
538
|
-
return { dshVersion, versionSource, mechanism }
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
export async function apply(ctx) {
|
|
542
|
-
const logger = ctx && ctx.logger
|
|
543
|
-
try {
|
|
544
|
-
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
545
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
546
|
-
const presetRoot = join(dshHome, '.agent-presets')
|
|
547
|
-
const stateFile = join(presetRoot, STATE_FILE)
|
|
548
|
-
|
|
549
|
-
// WHICH MECHANISM THIS HOST USES decides whether the directory work below means anything, so it
|
|
550
|
-
// is decided (and reported) before anything is written. The host self-check runs here too.
|
|
551
|
-
const host = await checkHostCapabilities(ctx, logger)
|
|
552
|
-
if (host && host.mechanism === 'rows') {
|
|
553
|
-
logger?.info?.('[dsh-vibe-math] preset 声明方式:本宿主以组合行声明 agent preset' +
|
|
554
|
-
(host.dshVersion ? '(DSH v' + host.dshVersion + ')' : '') + ',本包随附的 cordis.patch.yml 已声明四个 preset(dsh-vibe-math/preset-declaration);' +
|
|
555
|
-
'跳过 ' + presetRoot + ' 目录同步(该目录自 DSH 0.1.7 起不再被读取)。')
|
|
556
|
-
if (existsSync(stateFile)) {
|
|
557
|
-
logger?.info?.('[dsh-vibe-math] 提示:' + presetRoot + ' 里还留着旧版 DSH(≤ 0.1.6)读取过的 preset 副本,' +
|
|
558
|
-
'当前宿主不会再读它们,可以安全删除(要自定义 preset,请在预设选择器里复制一份,或改 profile 的 cordis.patch.yml)。')
|
|
559
|
-
}
|
|
560
|
-
return
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// current package version (the source of truth for "is this an upgrade?")
|
|
564
|
-
let pkgVersion = ''
|
|
565
|
-
try { pkgVersion = String((JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version) || '') } catch (e) { pkgVersion = '' }
|
|
566
|
-
|
|
567
|
-
const state = readState(stateFile)
|
|
568
|
-
const prevFiles = (state && state.files) || {}
|
|
569
|
-
const isUpgrade = state !== null && pkgVersion !== '' && state.version !== pkgVersion
|
|
570
|
-
const isBaseline = state === null // no recorded history → refresh everything
|
|
571
|
-
// A version change (or a first sighting) REPLACES the managed files; only a same-version boot
|
|
572
|
-
// leaves the working tree alone. See UPDATE POLICY at the top of this file.
|
|
573
|
-
const refresh = isBaseline || isUpgrade
|
|
574
|
-
const fromVersion = (state && state.version) || '(unversioned)'
|
|
575
|
-
if (pkgVersion === '') {
|
|
576
|
-
// Without a version there is nothing to compare against, so this run must NOT replace
|
|
577
|
-
// anything (a wrong guess would overwrite files for no reason); it still restores missing
|
|
578
|
-
// ones and keeps the previously recorded version, so the next readable run reports the
|
|
579
|
-
// right "from" version.
|
|
580
|
-
logger?.warn?.('[dsh-vibe-math] 读不到本包版本(package.json 缺失或损坏):本次不做版本比对,只补回缺失的 preset 文件。')
|
|
581
|
-
}
|
|
582
|
-
// A recorded version NEWER than this package means this run replaces preset bytes BACKWARDS.
|
|
583
|
-
// The policy still does it — "the preset directory equals the installed package" is the whole
|
|
584
|
-
// point — but a silent downgrade (an old bundle still installed in the profile while the presets
|
|
585
|
-
// were synced from a newer one) is exactly the surprise worth naming.
|
|
586
|
-
if (isUpgrade) {
|
|
587
|
-
const recorded = parseSemver(fromVersion)
|
|
588
|
-
const installed = parseSemver(pkgVersion)
|
|
589
|
-
if (recorded !== null && installed !== null && compareSemver(recorded, installed) > 0) {
|
|
590
|
-
logger?.warn?.('[dsh-vibe-math] 记录里的版本(' + fromVersion + ')比本包版本(' + pkgVersion + ')新,本次会把 preset 换回旧字节。' +
|
|
591
|
-
'若这不是你想要的,请先升级 profile 里的依赖:dsh plugin --profile <name> add dsh-vibe-math@latest,再重启 DSH。')
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const nextFiles = {}
|
|
596
|
-
let installed = 0, updated = 0, kept = 0
|
|
597
|
-
let userEditedKept = 0
|
|
598
|
-
const replacedEdits = []
|
|
599
|
-
const unknownPrev = []
|
|
600
|
-
const backupFailures = []
|
|
601
|
-
|
|
602
|
-
for (const p of PRESETS) {
|
|
603
|
-
const srcDir = join(here, p.src)
|
|
604
|
-
const dstDir = join(presetRoot, p.dst)
|
|
605
|
-
if (!existsSync(srcDir)) continue
|
|
606
|
-
mkdirSync(dstDir, { recursive: true })
|
|
607
|
-
for (const f of p.files) {
|
|
608
|
-
const s = join(srcDir, f)
|
|
609
|
-
const d = join(dstDir, f)
|
|
610
|
-
if (!existsSync(s)) continue
|
|
611
|
-
const key = p.src + '/' + f
|
|
612
|
-
const cur = readFileSync(s)
|
|
613
|
-
const curHash = sha256(cur)
|
|
614
|
-
if (!existsSync(d)) {
|
|
615
|
-
// missing file: always restore, whatever the version
|
|
616
|
-
writePresetFile(d, cur)
|
|
617
|
-
installed += 1
|
|
618
|
-
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
619
|
-
continue
|
|
620
|
-
}
|
|
621
|
-
const destBuf = readFileSync(d)
|
|
622
|
-
const destHash = sha256(destBuf)
|
|
623
|
-
const prev = prevFiles[key]
|
|
624
|
-
const prevRec = (prev && typeof prev === 'object') ? prev : { hash: prev, provenance: 'package' }
|
|
625
|
-
if (destHash === curHash) {
|
|
626
|
-
// already the shipped bytes: never rewrite, so the file's mtime (which keys the preset's
|
|
627
|
-
// DSH generation) stays put
|
|
628
|
-
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
629
|
-
continue
|
|
630
|
-
}
|
|
631
|
-
if (!refresh) {
|
|
632
|
-
// same version: nothing is being updated, so a file that differs from the package is left
|
|
633
|
-
// exactly as it is. The recorded hash stays "what this installer last wrote" (or unknown),
|
|
634
|
-
// so the drift is still recognised — and backed up — at the next version change.
|
|
635
|
-
//
|
|
636
|
-
// PROVENANCE, recorded honestly: when the on-disk bytes are NOT what this installer last
|
|
637
|
-
// wrote, something else edited this managed file, so it is marked user-owned — which is
|
|
638
|
-
// what makes the stale-preset cleanup below KEEP it instead of unlinking it. A legacy
|
|
639
|
-
// state with no hash cannot tell, so it stays 'package' (it is still backed up before any
|
|
640
|
-
// version-change replacement; the cleanup backs it up before deleting too).
|
|
641
|
-
kept += 1
|
|
642
|
-
const userEdited = typeof prevRec.hash === 'string' && destHash !== prevRec.hash
|
|
643
|
-
if (userEdited) userEditedKept += 1
|
|
644
|
-
nextFiles[key] = userEdited
|
|
645
|
-
? { hash: prevRec.hash, provenance: 'user' }
|
|
646
|
-
: typeof prevRec.hash === 'string' ? { hash: prevRec.hash, provenance: 'package' } : { provenance: 'package' }
|
|
647
|
-
continue
|
|
648
|
-
}
|
|
649
|
-
// replacing: preserve the user's bytes when they are not what this installer last wrote.
|
|
650
|
-
// The two cases are NOT the same claim and are counted apart: bytes that differ from the
|
|
651
|
-
// recorded hash are a provable edit, while a legacy state WITHOUT a hash cannot tell — that
|
|
652
|
-
// file is backed up all the same, but the log must not call it "被改过" (it cannot know).
|
|
653
|
-
if (typeof prevRec.hash !== 'string') {
|
|
654
|
-
if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
|
|
655
|
-
unknownPrev.push(key)
|
|
656
|
-
} else if (destHash !== prevRec.hash) {
|
|
657
|
-
if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
|
|
658
|
-
replacedEdits.push(key)
|
|
659
|
-
}
|
|
660
|
-
writePresetFile(d, cur)
|
|
661
|
-
updated += 1
|
|
662
|
-
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Clean up preset dirs that this bundle NO LONGER manages (e.g. vibe-math-v1 after it was
|
|
667
|
-
// removed at v2.0.0). The copy loop only adds/updates PRESETS; it never deletes a preset that
|
|
668
|
-
// was dropped, so an old removed preset would linger in the picker forever. Here we remove the
|
|
669
|
-
// files this installer previously recorded as package-owned under a prefix that is no longer in
|
|
670
|
-
// PRESETS, then drop the dir if it became empty. Files recorded as user-owned are KEPT.
|
|
671
|
-
//
|
|
672
|
-
// DELETION IS THE ONE PATH THAT DOES NOT REPLACE, so it obeys the same rule as the replacement
|
|
673
|
-
// path: a file whose bytes are not what this installer last wrote is copied to
|
|
674
|
-
// <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> FIRST and named in the log. A file
|
|
675
|
-
// that still matches its recorded hash IS the package's copy, so removing it destroys nothing.
|
|
676
|
-
const currentPrefixes = new Set(PRESETS.map(p => p.src + '/'))
|
|
677
|
-
let removedFiles = 0
|
|
678
|
-
let matchedPackageFiles = 0
|
|
679
|
-
let keptUserFiles = 0
|
|
680
|
-
const removedDirs = []
|
|
681
|
-
const backedUpStale = []
|
|
682
|
-
const backupFailedStale = []
|
|
683
|
-
const unreadableStale = []
|
|
684
|
-
const keptUserDirs = new Set()
|
|
685
|
-
const stale = new Map() // prefix -> [keys]
|
|
686
|
-
for (const key of Object.keys(prevFiles)) {
|
|
687
|
-
const slash = key.indexOf('/')
|
|
688
|
-
if (slash === -1) continue
|
|
689
|
-
const prefix = key.slice(0, slash + 1)
|
|
690
|
-
if (currentPrefixes.has(prefix)) continue
|
|
691
|
-
if (!stale.has(prefix)) stale.set(prefix, [])
|
|
692
|
-
stale.get(prefix).push(key)
|
|
693
|
-
}
|
|
694
|
-
for (const [prefix, keys] of stale) {
|
|
695
|
-
let dirEmpty = true
|
|
696
|
-
for (const key of keys) {
|
|
697
|
-
const rec = (prevFiles[key] && typeof prevFiles[key] === 'object') ? prevFiles[key] : { provenance: 'package' }
|
|
698
|
-
if (rec.provenance === 'user') { dirEmpty = false; keptUserFiles += 1; keptUserDirs.add(prefix.slice(0, -1)); continue } // 用户文件 → 保留
|
|
699
|
-
const f = join(presetRoot, key)
|
|
700
|
-
if (existsSync(f)) {
|
|
701
|
-
let buf = null
|
|
702
|
-
try { buf = readFileSync(f) } catch (e) { /* unreadable: treated as "cannot prove it is ours" */ }
|
|
703
|
-
// A file that still matches its recorded hash IS the copy this installer wrote, so deleting
|
|
704
|
-
// it destroys nothing. Anything else — edited bytes, or a legacy record with no hash that
|
|
705
|
-
// cannot tell — is copied to the backup root first. Deletion is allowed ONLY when nothing of
|
|
706
|
-
// the user's can be lost: a package copy, or bytes that really were preserved. An unreadable
|
|
707
|
-
// file cannot be backed up, and a FAILED backup means the bytes were not preserved either —
|
|
708
|
-
// both are KEPT and named in the log. A stale directory left behind is cosmetic; destroyed
|
|
709
|
-
// user bytes are not. (The REPLACEMENT path above keeps its own rule — a failed backup must
|
|
710
|
-
// not block a version upgrade — because there the file is overwritten, not removed.)
|
|
711
|
-
const isPackageCopy = buf !== null && typeof rec.hash === 'string' && sha256(buf) === rec.hash
|
|
712
|
-
let mayDelete = false
|
|
713
|
-
if (isPackageCopy) {
|
|
714
|
-
matchedPackageFiles += 1
|
|
715
|
-
mayDelete = true
|
|
716
|
-
} else if (buf !== null) {
|
|
717
|
-
const cut = key.lastIndexOf('/')
|
|
718
|
-
const backup = backupReplacedFile(presetRoot, fromVersion, key.slice(0, cut), key.slice(cut + 1), buf)
|
|
719
|
-
if (backup === 'failed') backupFailedStale.push(key)
|
|
720
|
-
else { backedUpStale.push(key); mayDelete = true }
|
|
721
|
-
} else {
|
|
722
|
-
unreadableStale.push(key)
|
|
723
|
-
}
|
|
724
|
-
if (mayDelete) { try { unlinkSync(f); removedFiles += 1 } catch (e) {} }
|
|
725
|
-
}
|
|
726
|
-
if (existsSync(f)) dirEmpty = false
|
|
727
|
-
}
|
|
728
|
-
const dir = join(presetRoot, prefix.slice(0, -1))
|
|
729
|
-
if (dirEmpty && existsSync(dir)) { try { rmdirSync(dir); removedDirs.push(dir) } catch (e) {} }
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() }, state, logger)
|
|
733
|
-
|
|
734
|
-
const cleanupNotes = []
|
|
735
|
-
if (matchedPackageFiles > 0) cleanupNotes.push(matchedPackageFiles + ' 个仍是本安装器写入的字节(删掉的不是你的改动)')
|
|
736
|
-
if (backedUpStale.length > 0) cleanupNotes.push(backedUpStale.length + ' 个的字节与上一次安装不同(被改过),原文已备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + backedUpStale.join(', '))
|
|
737
|
-
if (backupFailedStale.length > 0) cleanupNotes.push(backupFailedStale.length + ' 个被改过的文件**备份失败**(原文未保留,已跳过删除并保留原文件):' + backupFailedStale.join(', '))
|
|
738
|
-
if (unreadableStale.length > 0) cleanupNotes.push(unreadableStale.length + ' 个文件**无法读取**(既不能证明是本安装器写入的,也无法备份),已跳过删除并保留原文件:' + unreadableStale.join(', '))
|
|
739
|
-
if (keptUserFiles > 0) cleanupNotes.push(keptUserFiles + ' 个记为用户所有的文件被保留(' + [...keptUserDirs].join(', ') + '),要清理请手动删除')
|
|
740
|
-
if (removedFiles > 0 || removedDirs.length > 0 || cleanupNotes.length > 0) {
|
|
1
|
+
// dsh-vibe-math merged bundle installer — VERSIONED AUTO-UPDATE.
|
|
2
|
+
// When this bundle is installed (e.g. `dsh plugin --profile <name> add dsh-vibe-math`, or from the
|
|
3
|
+
// dsh-market), this plugin copies ALL FOUR agent presets out of the package into
|
|
4
|
+
// the DSH preset root, so the user immediately gets four presets in the picker:
|
|
5
|
+
// vibe-math-v2/ (probability-driven architecture)
|
|
6
|
+
// vibe-math-v3/ (THIRD-generation: paper-style Markdown knowledge base +
|
|
7
|
+
// planner-agent scheduling + universal theory/method library)
|
|
8
|
+
// vibe-math-v4/ (FOURTH-generation: persistent self-organizing resident
|
|
9
|
+
// subagents — message bus / meetings / unanimous-consensus
|
|
10
|
+
// verification / per-resident libraries)
|
|
11
|
+
// vibe-math-v5/ (FIFTH-generation: research institute — academician who
|
|
12
|
+
// assigns work, permanent researchers who vote, temp workers,
|
|
13
|
+
// group chat + meetings + m-vote consensus)
|
|
14
|
+
//
|
|
15
|
+
// (vibe-math-v1 — the classic pipeline — was removed at v2.0.0; this bundle now
|
|
16
|
+
// ships v2/v3/v4/v5.)
|
|
17
|
+
//
|
|
18
|
+
// UPDATE POLICY (state recorded in <presetRoot>/.vibe-math-installed.json):
|
|
19
|
+
// - FORCE-REPLACE ON VERSION CHANGE. When the recorded version differs from this package's
|
|
20
|
+
// version — or there is no record at all (an install made by an older installer) — every
|
|
21
|
+
// managed file is overwritten with the shipped bytes. This is deliberately NOT conditional on
|
|
22
|
+
// the file being unmodified. Two reasons:
|
|
23
|
+
// · a preset assembled from two different versions (the old policy updated a file's
|
|
24
|
+
// neighbours and kept the file the user had touched) is exactly the state that fails to
|
|
25
|
+
// mount or misbehaves subtly, and the user has no way to see that from the outside;
|
|
26
|
+
// · editing a shipped preset in place is not the supported way to customize one — DSH
|
|
27
|
+
// provides a real one (copy the preset: the picker's copy action, or a new directory
|
|
28
|
+
// under <presetRoot>), which leaves the managed set updatable.
|
|
29
|
+
// - Nothing is destroyed silently: before a file whose bytes are not what the installer last
|
|
30
|
+
// wrote is REPLACED — or DELETED along with a preset this bundle no longer ships — the user's
|
|
31
|
+
// copy is kept under <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in
|
|
32
|
+
// the log. A file that still matches the hash recorded for it is a copy of the package, so
|
|
33
|
+
// removing it destroys nothing and needs no backup.
|
|
34
|
+
// - Files are written ATOMICALLY: a uniquely named temp file is written and then renamed over the
|
|
35
|
+
// destination, so an interrupted boot (a kill, a full disk, two sessions writing at once) can
|
|
36
|
+
// never leave a half-written preset for the next same-version run to mistake for a user edit.
|
|
37
|
+
// - same version: no-op (idempotent) — restarting DSH never rewrites a preset file, never churns
|
|
38
|
+
// the preset's generation stamp and never rewrites this state file (it is written only when its
|
|
39
|
+
// content actually changes). Missing files are ALWAYS restored, at any version.
|
|
40
|
+
// - force a full refresh at any time: delete the preset dirs and restart DSH.
|
|
41
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs'
|
|
42
|
+
import { createRequire } from 'node:module'
|
|
43
|
+
import { createHash } from 'node:crypto'
|
|
44
|
+
import { homedir } from 'node:os'
|
|
45
|
+
import { dirname, join } from 'node:path'
|
|
46
|
+
import { fileURLToPath } from 'node:url'
|
|
47
|
+
|
|
48
|
+
export const name = 'vibe-math-preset-installer'
|
|
49
|
+
|
|
50
|
+
// Exported so the shipped policy suite can prove the managed list still covers what each preset
|
|
51
|
+
// needs at runtime (a file present in the preset directory but missing here is copied by nobody,
|
|
52
|
+
// and the installed preset then cannot mount).
|
|
53
|
+
export const PRESETS = [
|
|
54
|
+
{
|
|
55
|
+
src: 'vibe-math-v2',
|
|
56
|
+
dst: 'vibe-math-v2',
|
|
57
|
+
// math-computation.js + math-engines.js are PER-PRESET copies of the shared module (they must be
|
|
58
|
+
// listed here: on the <=0.1.6 line only these files are copied into .agent-presets/<id>/, and the
|
|
59
|
+
// preset JS imports them relatively). guard: tests/audit-math-computation-parity.mjs
|
|
60
|
+
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v2.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
src: 'vibe-math-v3',
|
|
64
|
+
dst: 'vibe-math-v3',
|
|
65
|
+
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v3.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
src: 'vibe-math-v4',
|
|
69
|
+
dst: 'vibe-math-v4',
|
|
70
|
+
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v4.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
src: 'vibe-math-v5',
|
|
74
|
+
dst: 'vibe-math-v5',
|
|
75
|
+
// 架构图.md belongs here for the same reason 实现方案.md does: the installer's policy is to put
|
|
76
|
+
// the preset's documentation next to the preset, and the shipped v5 directory carries both.
|
|
77
|
+
files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v5.js', '实现方案.md', '架构图.md', 'math-computation.js', 'math-engines.js'],
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const STATE_FILE = '.vibe-math-installed.json'
|
|
82
|
+
// where a replaced user edit is preserved; a leading dot keeps DSH's preset discovery from ever
|
|
83
|
+
// treating it as a preset directory (ids must match [a-z0-9][a-z0-9-]*)
|
|
84
|
+
const BACKUP_DIR = '.vibe-math-backup'
|
|
85
|
+
|
|
86
|
+
function sha256(buf) { return createHash('sha256').update(buf).digest('hex') }
|
|
87
|
+
|
|
88
|
+
let presetTmpSeq = 0
|
|
89
|
+
/**
|
|
90
|
+
* Write one preset file ATOMICALLY: a uniquely named temp sibling, then a rename over the
|
|
91
|
+
* destination. An in-place `writeFileSync` can be interrupted (a killed or crashed DSH, a full disk,
|
|
92
|
+
* two sessions writing the same path) and leave a TRUNCATED preset behind — and the next
|
|
93
|
+
* same-version boot would take those bytes for the user's own edit and never repair them (a
|
|
94
|
+
* same-version run only restores MISSING files). The pid plus a per-process sequence make the temp
|
|
95
|
+
* name unique, so concurrent writers cannot rename each other's half-written file. A leftover temp
|
|
96
|
+
* (possible only after SIGKILL between the two calls) is inert: DSH reads the named preset files.
|
|
97
|
+
*/
|
|
98
|
+
function writePresetFile(dest, buf) {
|
|
99
|
+
const tmp = dest + '.' + process.pid + '.' + (presetTmpSeq++) + '.vibe-math-tmp'
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(tmp, buf)
|
|
102
|
+
renameSync(tmp, dest)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
|
|
105
|
+
throw e
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Preserve one file that is about to be replaced by the shipped version, under
|
|
111
|
+
* `<presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>`.
|
|
112
|
+
*
|
|
113
|
+
* Returns `'written'` (a copy was made now), `'kept'` (a copy for this version was already there —
|
|
114
|
+
* the earliest edit is the one worth keeping, and a re-run must not overwrite it with an
|
|
115
|
+
* already-replaced file) or `'failed'`. A caller must not report `'kept'` as a failure: the user's
|
|
116
|
+
* bytes are preserved, just from an earlier run.
|
|
117
|
+
*/
|
|
118
|
+
function backupReplacedFile(presetRoot, fromVersion, presetDir, fileName, buf) {
|
|
119
|
+
try {
|
|
120
|
+
const dir = join(presetRoot, BACKUP_DIR, String(fromVersion || 'unversioned'), presetDir)
|
|
121
|
+
const dst = join(dir, fileName)
|
|
122
|
+
if (existsSync(dst)) return 'kept' // the earliest copy for this version is the one worth keeping
|
|
123
|
+
mkdirSync(dir, { recursive: true })
|
|
124
|
+
writeFileSync(dst, buf)
|
|
125
|
+
return 'written'
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return 'failed' // a backup failure must never stop the update; it is reported by the caller
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readState(path) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = readFileSync(path, 'utf8')
|
|
134
|
+
const obj = JSON.parse(raw)
|
|
135
|
+
if (obj && typeof obj === 'object' && obj.files && typeof obj.files === 'object') return obj
|
|
136
|
+
} catch (e) { /* missing or corrupt — treat as no state (baseline) */ }
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Does the new payload equal the recorded one? Compared field by field (never as JSON text) so key
|
|
142
|
+
* ORDER cannot fake a change, and the timestamp is ignored on purpose: `updatedAt` records when the
|
|
143
|
+
* file was written, not that anything happened.
|
|
144
|
+
*/
|
|
145
|
+
function sameStatePayload(previous, next) {
|
|
146
|
+
if (!previous || !next) return false
|
|
147
|
+
if (String(previous.version || '') !== String(next.version || '')) return false
|
|
148
|
+
const a = previous.files || {}
|
|
149
|
+
const b = next.files || {}
|
|
150
|
+
const ka = Object.keys(a).sort()
|
|
151
|
+
const kb = Object.keys(b).sort()
|
|
152
|
+
if (ka.length !== kb.length) return false
|
|
153
|
+
for (let i = 0; i < ka.length; i++) {
|
|
154
|
+
if (ka[i] !== kb[i]) return false
|
|
155
|
+
const ra = a[ka[i]] || {}
|
|
156
|
+
const rb = b[kb[i]] || {}
|
|
157
|
+
if (String(ra.hash || '') !== String(rb.hash || '')) return false
|
|
158
|
+
if (String(ra.provenance || '') !== String(rb.provenance || '')) return false
|
|
159
|
+
}
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let stateTmpSeq = 0
|
|
164
|
+
let stateWriteWarned = false
|
|
165
|
+
/**
|
|
166
|
+
* Persist the state file, atomically. Three things here are deliberate:
|
|
167
|
+
* · an UNCHANGED payload is not written at all — a same-version boot must not churn this file (the
|
|
168
|
+
* "restarting DSH writes nothing" promise covers the state file too, not only the presets);
|
|
169
|
+
* · the temp name carries the pid and a sequence, so two concurrent boots cannot rename each
|
|
170
|
+
* other's half-written file (with a fixed `path + '.tmp'` the loser's rename failed and the
|
|
171
|
+
* whole write vanished without a word);
|
|
172
|
+
* · a failure is REPORTED once instead of being swallowed. The copy step already did its work; a
|
|
173
|
+
* missing state only means the next boot re-compares everything (at worst it backs up one extra
|
|
174
|
+
* original, it never overwrites a user edit).
|
|
175
|
+
*/
|
|
176
|
+
function writeState(path, state, previous, logger) {
|
|
177
|
+
if (sameStatePayload(previous, state)) return 'unchanged'
|
|
178
|
+
const tmp = path + '.' + process.pid + '.' + (stateTmpSeq++) + '.tmp'
|
|
179
|
+
try {
|
|
180
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8')
|
|
181
|
+
renameSync(tmp, path)
|
|
182
|
+
return 'written'
|
|
183
|
+
} catch (e) {
|
|
184
|
+
try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
|
|
185
|
+
if (!stateWriteWarned) {
|
|
186
|
+
stateWriteWarned = true
|
|
187
|
+
logger?.warn?.('[dsh-vibe-math] 状态文件写入失败(' + path + '):' + String((e && e.message) || e) +
|
|
188
|
+
'。preset 文件本身已按上面的日志处理完毕;下一次启动会按「没有记录」重新比对(最坏情况是多备份一份原文,不会覆盖你的改动)。')
|
|
189
|
+
}
|
|
190
|
+
return 'failed'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// DSH 适配性自检(版本 + preset 形态 + 能力)。
|
|
195
|
+
// 检查 preset 运行时需要的宿主服务与关键 API 形状是否可用,缺失时打 warning。
|
|
196
|
+
// Best-effort DSH host-version detection, in order of authority:
|
|
197
|
+
// 1. `process.env.DSH_VERSION` — an explicit override, always honoured first;
|
|
198
|
+
// 2. the `pluginManager` service's bundle list — every host bundle carries the runtime version and
|
|
199
|
+
// `@deepseek-ai/dsh-base` is present in every base-backed profile (service since 0.1.7);
|
|
200
|
+
// 3. `@deepseek-ai/dsh-app-boot`'s exported `getDshRuntimeVersion()` — the documented API; it only
|
|
201
|
+
// resolves when this plugin's module scope can reach the host packages;
|
|
202
|
+
// 4. the installed `@deepseek-ai/dsh/package.json` — the historical probe, still the only source
|
|
203
|
+
// that works on a global <= 0.1.6 install.
|
|
204
|
+
// When none resolves, the capability self-check below is still the authoritative gate.
|
|
205
|
+
const __require = createRequire(import.meta.url)
|
|
206
|
+
|
|
207
|
+
// The detected version is only ever used for diagnostics, and this probe sits on the installer ROW's
|
|
208
|
+
// activation path: a service that never answers must not hold the boot there. On timeout (or on a
|
|
209
|
+
// rejection) the next source is tried instead — no verdict depends on this value.
|
|
210
|
+
const LIST_BUNDLES_TIMEOUT_MS = 1500
|
|
211
|
+
function withTimeout(promise, ms) {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
let settled = false
|
|
214
|
+
let timer
|
|
215
|
+
const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value) } }
|
|
216
|
+
timer = setTimeout(() => { if (!settled) { settled = true; resolve(undefined) } }, ms)
|
|
217
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
218
|
+
Promise.resolve(promise).then(done, () => done(undefined))
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function detectDshVersion(ctx) {
|
|
223
|
+
// Installer review (finding): several sources can report this host's version, and "first available
|
|
224
|
+
// wins" hid a CONFLICTING host state (the same lesson the `engines.dsh`-vs-`dshReleases` rule already
|
|
225
|
+
// records below). Probe them ALL, keep the first available authoritative (priority unchanged), and
|
|
226
|
+
// record the rest so a disagreement is visible instead of silent.
|
|
227
|
+
const probes = []
|
|
228
|
+
const add = (source, version) => { const v = (version === undefined || version === null) ? '' : String(version).trim(); if (v) probes.push({ source: source, version: v }) }
|
|
229
|
+
try { add('DSH_VERSION', process.env.DSH_VERSION) } catch (e) {}
|
|
230
|
+
try {
|
|
231
|
+
const pm = (ctx && ctx.get) ? ctx.get('pluginManager') : undefined
|
|
232
|
+
if (pm && typeof pm.listBundles === 'function') {
|
|
233
|
+
// withTimeout: a slow or stuck pluginManager falls through to the next source instead of
|
|
234
|
+
// stalling this row's activation (non-fatal either way — see the constant's comment).
|
|
235
|
+
const bundles = await withTimeout(pm.listBundles(), LIST_BUNDLES_TIMEOUT_MS)
|
|
236
|
+
const host = (bundles || []).find((b) => b && (b.name === '@deepseek-ai/dsh-base' || b.name === '@deepseek-ai/dsh'))
|
|
237
|
+
if (host && host.version) add('pluginManager:' + host.name, host.version)
|
|
238
|
+
}
|
|
239
|
+
} catch (e) { /* no such service, or it cannot list yet */ }
|
|
240
|
+
try {
|
|
241
|
+
const boot = await import('@deepseek-ai/dsh-app-boot')
|
|
242
|
+
if (boot && typeof boot.getDshRuntimeVersion === 'function') add('dsh-app-boot', boot.getDshRuntimeVersion())
|
|
243
|
+
} catch (e) { /* host packages are not reachable from this plugin's module scope */ }
|
|
244
|
+
try {
|
|
245
|
+
const p = __require.resolve('@deepseek-ai/dsh/package.json')
|
|
246
|
+
add('@deepseek-ai/dsh/package.json', JSON.parse(readFileSync(p, 'utf8')).version || '')
|
|
247
|
+
} catch (e) { /* not a global install layout — rely on the capability check */ }
|
|
248
|
+
if (!probes.length) return undefined
|
|
249
|
+
const first = probes[0]
|
|
250
|
+
const conflicts = probes.slice(1).filter((x) => x.version !== first.version)
|
|
251
|
+
const out = { version: first.version, source: first.source, probes: probes.slice() }
|
|
252
|
+
// Assemble the disagreement slot ONLY when there is one (no empty slot for agreeing hosts).
|
|
253
|
+
if (conflicts.length) out.disagreement = { primary: first.source, conflicts: conflicts.slice() }
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* How this host declares agent presets.
|
|
259
|
+
* 'rows' — DSH >= 0.1.7: composition rows declared in this package's cordis.patch.yml (the rows
|
|
260
|
+
* name our own preset-declaration module, which calls `agentPresets.register`).
|
|
261
|
+
* Nothing reads the preset directory any more.
|
|
262
|
+
* 'directory' — DSH <= 0.1.6: <DSH_HOME>/.agent-presets/<id>/agent.cordis.yml, which is what the
|
|
263
|
+
* copy in apply() installs.
|
|
264
|
+
*
|
|
265
|
+
* Judged from the LOADER TREE first, then from the one capability that actually differs between the
|
|
266
|
+
* two lines: the `agentPresets` service. That service exists on BOTH lines with different meaning (a
|
|
267
|
+
* directory scanner below 0.1.7, a row registry from 0.1.7 on) and may not be up yet when a bundle is
|
|
268
|
+
* activated on its own, which is why the tree is asked first — but when only the service can be seen,
|
|
269
|
+
* `register` (never `list`, which both have) is what tells them apart. Missing the row line would skip
|
|
270
|
+
* the only working install path, so this decision has to be the reliable one.
|
|
271
|
+
*/
|
|
272
|
+
export function detectPresetMechanism(ctx) {
|
|
273
|
+
// 1) the LOADER TREE: only the row-based line mounts the agent-preset package (or its registry).
|
|
274
|
+
// This is visible before any plugin activates, which matters because a bundle activation can run
|
|
275
|
+
// this installer before the services are up (then ctx.get('agentPresets') is still undefined).
|
|
276
|
+
try {
|
|
277
|
+
const loader = (ctx && ctx.get) ? ctx.get('loader') : undefined
|
|
278
|
+
if (loader && typeof loader.entries === 'function') {
|
|
279
|
+
const entries = loader.entries()
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
const name = entry && entry.options ? entry.options.name : undefined
|
|
282
|
+
if (name === '@deepseek-ai/dsh-agent-preset' || name === '@deepseek-ai/dsh-agent-preset-registry') return 'rows'
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch (e) { /* no loader service: fall through to the service probe */ }
|
|
286
|
+
// 2) the service itself, when it is already available. The PRECISE capability difference between
|
|
287
|
+
// the two lines is `register`: the >= 0.1.7 row registry has it, the <= 0.1.6 directory scanner
|
|
288
|
+
// is INFERRED to lack it. The record is partial — a real 0.1.6-alpha.2 boot exposed the service
|
|
289
|
+
// and its `list()` returned the directory roster (_oneoff/roster-016a2.json), but that probe
|
|
290
|
+
// never ASKED for `register`; the inference follows from the package difference (npm:
|
|
291
|
+
// 0.1.6-alpha.2 ships no `@deepseek-ai/dsh-agent-preset`, while 0.1.7-rc.2 adds it). The
|
|
292
|
+
// misclassification this guards against is therefore a LATENT RISK, not an observed failure:
|
|
293
|
+
// testing `list` (which BOTH lines have) would classify a service-ready old host as the row
|
|
294
|
+
// line, and apply() would then return before writing a single preset directory — "installed but
|
|
295
|
+
// invisible", the 2.4.0 defect mirrored onto the older line. That outcome was never observed:
|
|
296
|
+
// the recorded 0.1.6 boot DID write all four preset directories. Never test a capability both
|
|
297
|
+
// lines share.
|
|
298
|
+
try {
|
|
299
|
+
const ap = (ctx && ctx.get) ? ctx.get('agentPresets') : undefined
|
|
300
|
+
if (ap !== undefined) return typeof ap.register === 'function' ? 'rows' : 'directory'
|
|
301
|
+
} catch (e) { /* no service to ask: the directory form */ }
|
|
302
|
+
return 'directory'
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Minimal semver-range matcher for the host requirement.
|
|
307
|
+
*
|
|
308
|
+
* WHY THIS EXISTS: the DSH requirement a host is judged against lives in `package.json`
|
|
309
|
+
* (`engines.dsh` / `dsh.engines.dsh`) — the same declaration dsh-market shows on a plugin's card.
|
|
310
|
+
* Judging the host by a *second* source (the `dsh.compatibility.dshReleases` map) let the two
|
|
311
|
+
* disagree: the card could say "compatible" while this self-check warned, or the reverse. So the
|
|
312
|
+
* range is evaluated here, and the map remains only a fallback for manifests that declare no range.
|
|
313
|
+
*
|
|
314
|
+
* Supported: `*`, exact, `^`, `~`, `>=`, `>`, `<=`, `<`, whitespace-separated sets, `||`
|
|
315
|
+
* alternatives — everything the ecosystem publishes (see `dshmarket`'s own
|
|
316
|
+
* `^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2` shape). Anything else returns null = unknown
|
|
317
|
+
* (reported, never asserted), never a silent "incompatible".
|
|
318
|
+
*
|
|
319
|
+
* PRERELEASE RULE (npm's, applied per comparator SET — one `||` alternative is one set): a version
|
|
320
|
+
* carrying a prerelease tag satisfies a set only when at least one comparator in that set shares its
|
|
321
|
+
* [major, minor, patch] tuple AND carries a prerelease of its own. This is why
|
|
322
|
+
* `>=0.1.2-rc.1 <0.2.0` does NOT match `0.1.5-rc.2`, and why this package declares explicit
|
|
323
|
+
* per-tuple branches instead. Callers pass `includePrerelease: true` for host checks because every
|
|
324
|
+
* published DSH release line is itself a prerelease.
|
|
325
|
+
*/
|
|
326
|
+
export function satisfiesDshRange(version, range, options = {}) {
|
|
327
|
+
const v = parseSemver(version)
|
|
328
|
+
if (v === null || typeof range !== 'string' || range.trim() === '') return null
|
|
329
|
+
const versionHasPre = v.pre.length > 0
|
|
330
|
+
let sawUnknown = false
|
|
331
|
+
for (const set of range.split('||')) {
|
|
332
|
+
const parts = set.trim().split(/\s+/).filter((p) => p !== '')
|
|
333
|
+
if (parts.length === 0) return true // an empty alternative is `*`
|
|
334
|
+
const parsed = parts.map(comparator)
|
|
335
|
+
if (parsed.some((p) => p === null)) { sawUnknown = true; continue }
|
|
336
|
+
if (versionHasPre && options.includePrerelease !== true) {
|
|
337
|
+
const admitted = parsed.some((p) => p.target !== null && p.target.pre.length > 0 &&
|
|
338
|
+
p.target.major === v.major && p.target.minor === v.minor && p.target.patch === v.patch)
|
|
339
|
+
if (!admitted) continue
|
|
340
|
+
}
|
|
341
|
+
if (parsed.every((p) => matchesComparator(v, p))) return true
|
|
342
|
+
}
|
|
343
|
+
return sawUnknown ? null : false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseSemver(value) {
|
|
347
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value == null ? '' : value).trim())
|
|
348
|
+
if (m === null) return null
|
|
349
|
+
const pre = m[4] === undefined ? [] : m[4].split('.')
|
|
350
|
+
for (const id of pre) if (/^\d+$/.test(id) && id.length > 1 && id[0] === '0') return null
|
|
351
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function compareSemver(a, b) {
|
|
355
|
+
if (a.major !== b.major) return a.major < b.major ? -1 : 1
|
|
356
|
+
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
|
|
357
|
+
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
|
|
358
|
+
if (a.pre.length === 0 && b.pre.length === 0) return 0
|
|
359
|
+
if (a.pre.length === 0) return 1 // a release outranks its prereleases
|
|
360
|
+
if (b.pre.length === 0) return -1
|
|
361
|
+
for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
|
|
362
|
+
const x = a.pre[i], y = b.pre[i]
|
|
363
|
+
if (x === undefined) return -1
|
|
364
|
+
if (y === undefined) return 1
|
|
365
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
|
|
366
|
+
if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d < 0 ? -1 : 1; continue }
|
|
367
|
+
if (xn !== yn) return xn ? -1 : 1 // numeric identifiers sort below alphanumeric ones
|
|
368
|
+
if (x !== y) return x < y ? -1 : 1
|
|
369
|
+
}
|
|
370
|
+
return 0
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** One comparator such as `^0.1.0-rc.7`. Returns null when the target is not a version. */
|
|
374
|
+
function comparator(part) {
|
|
375
|
+
const p = part.trim()
|
|
376
|
+
// `*`, `x`, `X` and an empty token mean "any version" (npm: still not a prerelease unless the
|
|
377
|
+
// caller opts into includePrerelease — the set-level gate below is what enforces that).
|
|
378
|
+
if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: 'any', target: null }
|
|
379
|
+
const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
|
|
380
|
+
const op = m === null || m[1] === undefined ? '' : m[1]
|
|
381
|
+
const target = parseSemver(m === null ? '' : m[2])
|
|
382
|
+
return target === null ? null : { op, target }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function matchesComparator(v, { op, target }) {
|
|
386
|
+
if (op === 'any') return true
|
|
387
|
+
const c = compareSemver(v, target)
|
|
388
|
+
switch (op) {
|
|
389
|
+
case '': return c === 0
|
|
390
|
+
case '>=': return c >= 0
|
|
391
|
+
case '>': return c > 0
|
|
392
|
+
case '<=': return c <= 0
|
|
393
|
+
case '<': return c < 0
|
|
394
|
+
case '^': {
|
|
395
|
+
const upper = target.major > 0
|
|
396
|
+
? { major: target.major + 1, minor: 0, patch: 0, pre: [ '0' ] }
|
|
397
|
+
: target.minor > 0
|
|
398
|
+
? { major: 0, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
|
|
399
|
+
: { major: 0, minor: 0, patch: target.patch + 1, pre: [ '0' ] }
|
|
400
|
+
return c >= 0 && compareSemver(v, upper) < 0
|
|
401
|
+
}
|
|
402
|
+
case '~': {
|
|
403
|
+
const upper = { major: target.major, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
|
|
404
|
+
return c >= 0 && compareSemver(v, upper) < 0
|
|
405
|
+
}
|
|
406
|
+
default: return false
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The host verdict for a DSH version against a package manifest.
|
|
412
|
+
* Prefers the declared range (`engines.dsh`, then `dsh.engines.dsh`); falls back to the
|
|
413
|
+
* `dsh.compatibility.dshReleases` map so packages that declare only that keep working.
|
|
414
|
+
*/
|
|
415
|
+
export function dshVersionVerdict(version, manifest) {
|
|
416
|
+
const pkg = manifest && typeof manifest === 'object' ? manifest : {}
|
|
417
|
+
const declared = (pkg.engines && typeof pkg.engines.dsh === 'string' && pkg.engines.dsh.trim() !== '')
|
|
418
|
+
? pkg.engines.dsh
|
|
419
|
+
: (pkg.dsh && pkg.dsh.engines && typeof pkg.dsh.engines.dsh === 'string' && pkg.dsh.engines.dsh.trim() !== '')
|
|
420
|
+
? pkg.dsh.engines.dsh
|
|
421
|
+
: null
|
|
422
|
+
if (declared !== null) {
|
|
423
|
+
// includePrerelease: the whole published DSH line is prerelease builds.
|
|
424
|
+
const sat = satisfiesDshRange(version, declared, { includePrerelease: true })
|
|
425
|
+
return {
|
|
426
|
+
basis: 'engines',
|
|
427
|
+
requirement: declared,
|
|
428
|
+
status: sat === true ? 'compatible' : sat === false ? 'incompatible' : 'unknown',
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const rel = (pkg.dsh && pkg.dsh.compatibility && pkg.dsh.compatibility.dshReleases) || {}
|
|
432
|
+
const status = rel[version]
|
|
433
|
+
if (status === 'compatible' || status === 'incompatible') {
|
|
434
|
+
return { basis: 'dshReleases', requirement: null, status }
|
|
435
|
+
}
|
|
436
|
+
return { basis: 'dshReleases', requirement: null, status: status === 'unknown' ? 'unknown' : 'undeclared' }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function checkHostCapabilities(ctx, logger) {
|
|
440
|
+
const problems = []
|
|
441
|
+
// 1) DSH version compatibility (best-effort, only when the version is detectable).
|
|
442
|
+
// The AUTHORITATIVE source is the declared range `engines.dsh` / `dsh.engines.dsh` — the same
|
|
443
|
+
// field dsh-market renders on the plugin card, so the two verdicts cannot disagree. The
|
|
444
|
+
// `dsh.compatibility.dshReleases` map is the fallback for manifests without a range.
|
|
445
|
+
let manifest = {}
|
|
446
|
+
try { manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')) } catch (e) {}
|
|
447
|
+
const dshRel = (manifest.dsh && manifest.dsh.compatibility && manifest.dsh.compatibility.dshReleases) || {}
|
|
448
|
+
const supported = Object.keys(dshRel).sort()
|
|
449
|
+
const detected = await detectDshVersion(ctx)
|
|
450
|
+
const dshVersion = detected && detected.version
|
|
451
|
+
const versionSource = detected && detected.source
|
|
452
|
+
const mechanism = detectPresetMechanism(ctx)
|
|
453
|
+
if (dshVersion) {
|
|
454
|
+
const verdict = dshVersionVerdict(dshVersion, manifest)
|
|
455
|
+
if (verdict.status === 'incompatible') {
|
|
456
|
+
problems.push(verdict.basis === 'engines'
|
|
457
|
+
? '当前 DSH 版本 v' + dshVersion + ' 不满足本包声明的宿主版本要求(engines.dsh = ' + verdict.requirement + ')。'
|
|
458
|
+
: '当前 DSH 版本 v' + dshVersion + ' 被本包声明为 incompatible;请使用 ' + supported.join(' / ') + '。')
|
|
459
|
+
} else if (verdict.status === 'undeclared' || (verdict.status === 'unknown' && verdict.basis === 'dshReleases')) {
|
|
460
|
+
problems.push('当前 DSH 版本 v' + dshVersion + ' 尚未被本包声明为兼容(dshReleases 仅声明 ' + supported.join(' / ') + ');建议使用 ' + supported.join(' / ') + ',或将该版本在 dshReleases 中标注后再自行验证。')
|
|
461
|
+
} else if (verdict.status === 'unknown' && verdict.basis === 'engines') {
|
|
462
|
+
problems.push('当前 DSH 版本 v' + dshVersion + ' 无法与 engines.dsh 的范围比对(声明值 ' + verdict.requirement + ' 不是本安装器能解析的范围);请按该范围自行确认。')
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// 2) capability self-check (the authoritative mounting gate; also covers hosts whose version
|
|
466
|
+
// could not be read). subagents / agents / tools / commands / fs shapes + v4 capabilities.
|
|
467
|
+
// `required: true` services are the mounting gate; the rest are optional services the presets
|
|
468
|
+
// read with ctx.get(). Their absence does not stop a mount but degrades SILENTLY, so report
|
|
469
|
+
// them instead of letting the field discover them: without `subprocess` no directory-creation
|
|
470
|
+
// shell runs, without `sandboxPolicy` writes carry no explicit fence, without `compaction` the
|
|
471
|
+
// v4 real /compact path is inert.
|
|
472
|
+
const checks = [
|
|
473
|
+
// subagents 服务的续做/唤醒方法是 sendMessage(sender, targetId, content, {signal});
|
|
474
|
+
// followup 不是 subagents 服务的方法(它只是 Agent 对象方法)。同时探测两者,能用一个即可。
|
|
475
|
+
{ svc: 'subagents', methods: ['startContinuable', 'interrupt'], required: true },
|
|
476
|
+
{ svc: 'agents', methods: ['roots'], required: true },
|
|
477
|
+
{ svc: 'tools', methods: ['register'], required: true },
|
|
478
|
+
{ svc: 'commands', methods: ['register'], required: true },
|
|
479
|
+
{ svc: 'fs', methods: ['resolve', 'stat', 'readText', 'writeText', 'listDir'], required: true },
|
|
480
|
+
{ svc: 'subprocess', methods: ['spawn'], required: false },
|
|
481
|
+
{ svc: 'sandboxPolicy', methods: ['resolve'], required: false },
|
|
482
|
+
{ svc: 'compaction', methods: ['compactIfNeeded'], required: false },
|
|
483
|
+
// v5 keeps its institute state in a hardened JSON file (State/<institute>.v5state.json) and does
|
|
484
|
+
// NOT use the session-projection registry or the session store any more: DSH refuses to load a
|
|
485
|
+
// session whose log carries event types outside its known set, so writing institute events into
|
|
486
|
+
// the user's session used to make that session unresumable. Neither service is checked here.
|
|
487
|
+
]
|
|
488
|
+
const degradations = []
|
|
489
|
+
for (let i = 0; i < checks.length; i++) {
|
|
490
|
+
const svc = checks[i].svc
|
|
491
|
+
const methods = checks[i].methods
|
|
492
|
+
const required = checks[i].required === true
|
|
493
|
+
const report = required ? ((m) => problems.push(m)) : ((m) => degradations.push(m))
|
|
494
|
+
let s
|
|
495
|
+
try { s = (ctx && ctx.get) ? ctx.get(svc) : undefined } catch (e) { s = undefined }
|
|
496
|
+
if (s === undefined) { report('宿主缺少服务 ' + svc); continue }
|
|
497
|
+
for (let j = 0; j < methods.length; j++) {
|
|
498
|
+
if (typeof s[methods[j]] !== 'function') report(svc + '.' + methods[j] + ' 不可用(宿主版本可能过旧)')
|
|
499
|
+
}
|
|
500
|
+
// subagents continuation (wake) API: sendMessage (modern) OR followup (legacy) must exist.
|
|
501
|
+
if (svc === 'subagents' && typeof s.sendMessage !== 'function' && typeof s.followup !== 'function') {
|
|
502
|
+
problems.push('subagents 缺少续做/唤醒方法(需 sendMessage 或 followup 至少其一)')
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// fs API shape: DSH 0.1.1 起 resolve 返回 {targetKey, displayPath} 对象(旧版返回字符串路径)
|
|
506
|
+
try {
|
|
507
|
+
const f = (ctx && ctx.get) ? ctx.get('fs') : undefined
|
|
508
|
+
if (f && typeof f.resolve === 'function') {
|
|
509
|
+
const r = await f.resolve('x', { cwd: process.cwd() })
|
|
510
|
+
if (typeof r !== 'object' || r === null || typeof r.targetKey !== 'string') {
|
|
511
|
+
problems.push('fs.resolve 返回形状不符(期望 {targetKey, displayPath},v3/v4 预设要求 DSH ≥ 0.1.1)')
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (e) { problems.push('fs.resolve 能力检测失败:' + String((e && e.message) || e)) }
|
|
515
|
+
// v4 依赖 subagents.startContinuable 的 agentOptions / toolFilter 能力(DSH 0.1.2 起由
|
|
516
|
+
// dsh-subagent 声明 SubagentCapabilities.agentOptions;spawn/fork 进程内 provider 均支持。
|
|
517
|
+
// 缺省 provider 名按 spawn 探测;探测失败不视为致命(等价于回退到再试一次、只警告)。
|
|
518
|
+
try {
|
|
519
|
+
const sa = (ctx && ctx.get) ? ctx.get('subagents') : undefined
|
|
520
|
+
if (sa && typeof sa.list === 'function') {
|
|
521
|
+
const names = (sa.list ? sa.list() : [])
|
|
522
|
+
const name = names.indexOf('spawn') !== -1 ? 'spawn' : (names[0] || '')
|
|
523
|
+
if (name && typeof sa.getProvider === 'function') {
|
|
524
|
+
const cap = (sa.getProvider(name) || {}).capabilities
|
|
525
|
+
if (cap && cap.agentOptions === false) problems.push('subagents provider "' + name + '" 不支持 agentOptions(v4 指定常驻模型/路由需要)')
|
|
526
|
+
if (cap && cap.toolFilter === false) problems.push('subagents provider "' + name + '" 不支持 toolFilter(v4 常驻工具权限需要)')
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (e) { /* 探测失败不致命 */ }
|
|
530
|
+
if (degradations.length > 0) {
|
|
531
|
+
logger?.warn?.('[dsh-vibe-math] 可选宿主服务缺失,功能会静默降级(不影响挂载):' + degradations.join(';') + '。subprocess 缺失则无法用 shell 创建目录树(仅靠 fs 自动建父目录兜底);sandboxPolicy 缺失则插件写入不带显式围栏;compaction 缺失则 v4 的真实 /compact 路径与 v5 的真实压缩不生效(v5 回退到自述浓缩)')
|
|
532
|
+
}
|
|
533
|
+
if (problems.length > 0) {
|
|
534
|
+
logger?.warn?.('[dsh-vibe-math] 宿主自检:' + problems.length + ' 项不满足(' + problems.join(';') + ')。v2/v3/v4/v5 预设依赖这些宿主服务/API,旧版或未经声明兼容的 DSH 可能无法挂载' + (dshVersion ? '(当前检测到 DSH v' + dshVersion + ',来源 ' + versionSource + ',本包适配 ' + (supported.length ? supported.join(' / ') : '(未声明)') + ')' : '') + '。')
|
|
535
|
+
} else {
|
|
536
|
+
logger?.info?.('[dsh-vibe-math] 宿主自检通过(“自检通过” ≠ “兼容”:这里只证明服务/API 的**形状**,不证明**行为**语义):subagents / agents / tools / commands / fs 服务及关键 API 均可用' + (degradations.length === 0 ? ',可选服务 subprocess / sandboxPolicy / compaction 亦齐备' : '(可选服务有缺失,见上方警告)') + (dshVersion ? '(当前 DSH v' + dshVersion + ',来源 ' + versionSource + ';本包已声明兼容 ' + supported.join(' / ') + ')' : '') + '。')
|
|
537
|
+
}
|
|
538
|
+
return { dshVersion, versionSource, mechanism }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function apply(ctx) {
|
|
542
|
+
const logger = ctx && ctx.logger
|
|
543
|
+
try {
|
|
544
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
545
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
546
|
+
const presetRoot = join(dshHome, '.agent-presets')
|
|
547
|
+
const stateFile = join(presetRoot, STATE_FILE)
|
|
548
|
+
|
|
549
|
+
// WHICH MECHANISM THIS HOST USES decides whether the directory work below means anything, so it
|
|
550
|
+
// is decided (and reported) before anything is written. The host self-check runs here too.
|
|
551
|
+
const host = await checkHostCapabilities(ctx, logger)
|
|
552
|
+
if (host && host.mechanism === 'rows') {
|
|
553
|
+
logger?.info?.('[dsh-vibe-math] preset 声明方式:本宿主以组合行声明 agent preset' +
|
|
554
|
+
(host.dshVersion ? '(DSH v' + host.dshVersion + ')' : '') + ',本包随附的 cordis.patch.yml 已声明四个 preset(dsh-vibe-math/preset-declaration);' +
|
|
555
|
+
'跳过 ' + presetRoot + ' 目录同步(该目录自 DSH 0.1.7 起不再被读取)。')
|
|
556
|
+
if (existsSync(stateFile)) {
|
|
557
|
+
logger?.info?.('[dsh-vibe-math] 提示:' + presetRoot + ' 里还留着旧版 DSH(≤ 0.1.6)读取过的 preset 副本,' +
|
|
558
|
+
'当前宿主不会再读它们,可以安全删除(要自定义 preset,请在预设选择器里复制一份,或改 profile 的 cordis.patch.yml)。')
|
|
559
|
+
}
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// current package version (the source of truth for "is this an upgrade?")
|
|
564
|
+
let pkgVersion = ''
|
|
565
|
+
try { pkgVersion = String((JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version) || '') } catch (e) { pkgVersion = '' }
|
|
566
|
+
|
|
567
|
+
const state = readState(stateFile)
|
|
568
|
+
const prevFiles = (state && state.files) || {}
|
|
569
|
+
const isUpgrade = state !== null && pkgVersion !== '' && state.version !== pkgVersion
|
|
570
|
+
const isBaseline = state === null // no recorded history → refresh everything
|
|
571
|
+
// A version change (or a first sighting) REPLACES the managed files; only a same-version boot
|
|
572
|
+
// leaves the working tree alone. See UPDATE POLICY at the top of this file.
|
|
573
|
+
const refresh = isBaseline || isUpgrade
|
|
574
|
+
const fromVersion = (state && state.version) || '(unversioned)'
|
|
575
|
+
if (pkgVersion === '') {
|
|
576
|
+
// Without a version there is nothing to compare against, so this run must NOT replace
|
|
577
|
+
// anything (a wrong guess would overwrite files for no reason); it still restores missing
|
|
578
|
+
// ones and keeps the previously recorded version, so the next readable run reports the
|
|
579
|
+
// right "from" version.
|
|
580
|
+
logger?.warn?.('[dsh-vibe-math] 读不到本包版本(package.json 缺失或损坏):本次不做版本比对,只补回缺失的 preset 文件。')
|
|
581
|
+
}
|
|
582
|
+
// A recorded version NEWER than this package means this run replaces preset bytes BACKWARDS.
|
|
583
|
+
// The policy still does it — "the preset directory equals the installed package" is the whole
|
|
584
|
+
// point — but a silent downgrade (an old bundle still installed in the profile while the presets
|
|
585
|
+
// were synced from a newer one) is exactly the surprise worth naming.
|
|
586
|
+
if (isUpgrade) {
|
|
587
|
+
const recorded = parseSemver(fromVersion)
|
|
588
|
+
const installed = parseSemver(pkgVersion)
|
|
589
|
+
if (recorded !== null && installed !== null && compareSemver(recorded, installed) > 0) {
|
|
590
|
+
logger?.warn?.('[dsh-vibe-math] 记录里的版本(' + fromVersion + ')比本包版本(' + pkgVersion + ')新,本次会把 preset 换回旧字节。' +
|
|
591
|
+
'若这不是你想要的,请先升级 profile 里的依赖:dsh plugin --profile <name> add dsh-vibe-math@latest,再重启 DSH。')
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const nextFiles = {}
|
|
596
|
+
let installed = 0, updated = 0, kept = 0
|
|
597
|
+
let userEditedKept = 0
|
|
598
|
+
const replacedEdits = []
|
|
599
|
+
const unknownPrev = []
|
|
600
|
+
const backupFailures = []
|
|
601
|
+
|
|
602
|
+
for (const p of PRESETS) {
|
|
603
|
+
const srcDir = join(here, p.src)
|
|
604
|
+
const dstDir = join(presetRoot, p.dst)
|
|
605
|
+
if (!existsSync(srcDir)) continue
|
|
606
|
+
mkdirSync(dstDir, { recursive: true })
|
|
607
|
+
for (const f of p.files) {
|
|
608
|
+
const s = join(srcDir, f)
|
|
609
|
+
const d = join(dstDir, f)
|
|
610
|
+
if (!existsSync(s)) continue
|
|
611
|
+
const key = p.src + '/' + f
|
|
612
|
+
const cur = readFileSync(s)
|
|
613
|
+
const curHash = sha256(cur)
|
|
614
|
+
if (!existsSync(d)) {
|
|
615
|
+
// missing file: always restore, whatever the version
|
|
616
|
+
writePresetFile(d, cur)
|
|
617
|
+
installed += 1
|
|
618
|
+
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
const destBuf = readFileSync(d)
|
|
622
|
+
const destHash = sha256(destBuf)
|
|
623
|
+
const prev = prevFiles[key]
|
|
624
|
+
const prevRec = (prev && typeof prev === 'object') ? prev : { hash: prev, provenance: 'package' }
|
|
625
|
+
if (destHash === curHash) {
|
|
626
|
+
// already the shipped bytes: never rewrite, so the file's mtime (which keys the preset's
|
|
627
|
+
// DSH generation) stays put
|
|
628
|
+
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
if (!refresh) {
|
|
632
|
+
// same version: nothing is being updated, so a file that differs from the package is left
|
|
633
|
+
// exactly as it is. The recorded hash stays "what this installer last wrote" (or unknown),
|
|
634
|
+
// so the drift is still recognised — and backed up — at the next version change.
|
|
635
|
+
//
|
|
636
|
+
// PROVENANCE, recorded honestly: when the on-disk bytes are NOT what this installer last
|
|
637
|
+
// wrote, something else edited this managed file, so it is marked user-owned — which is
|
|
638
|
+
// what makes the stale-preset cleanup below KEEP it instead of unlinking it. A legacy
|
|
639
|
+
// state with no hash cannot tell, so it stays 'package' (it is still backed up before any
|
|
640
|
+
// version-change replacement; the cleanup backs it up before deleting too).
|
|
641
|
+
kept += 1
|
|
642
|
+
const userEdited = typeof prevRec.hash === 'string' && destHash !== prevRec.hash
|
|
643
|
+
if (userEdited) userEditedKept += 1
|
|
644
|
+
nextFiles[key] = userEdited
|
|
645
|
+
? { hash: prevRec.hash, provenance: 'user' }
|
|
646
|
+
: typeof prevRec.hash === 'string' ? { hash: prevRec.hash, provenance: 'package' } : { provenance: 'package' }
|
|
647
|
+
continue
|
|
648
|
+
}
|
|
649
|
+
// replacing: preserve the user's bytes when they are not what this installer last wrote.
|
|
650
|
+
// The two cases are NOT the same claim and are counted apart: bytes that differ from the
|
|
651
|
+
// recorded hash are a provable edit, while a legacy state WITHOUT a hash cannot tell — that
|
|
652
|
+
// file is backed up all the same, but the log must not call it "被改过" (it cannot know).
|
|
653
|
+
if (typeof prevRec.hash !== 'string') {
|
|
654
|
+
if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
|
|
655
|
+
unknownPrev.push(key)
|
|
656
|
+
} else if (destHash !== prevRec.hash) {
|
|
657
|
+
if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
|
|
658
|
+
replacedEdits.push(key)
|
|
659
|
+
}
|
|
660
|
+
writePresetFile(d, cur)
|
|
661
|
+
updated += 1
|
|
662
|
+
nextFiles[key] = { hash: curHash, provenance: 'package' }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Clean up preset dirs that this bundle NO LONGER manages (e.g. vibe-math-v1 after it was
|
|
667
|
+
// removed at v2.0.0). The copy loop only adds/updates PRESETS; it never deletes a preset that
|
|
668
|
+
// was dropped, so an old removed preset would linger in the picker forever. Here we remove the
|
|
669
|
+
// files this installer previously recorded as package-owned under a prefix that is no longer in
|
|
670
|
+
// PRESETS, then drop the dir if it became empty. Files recorded as user-owned are KEPT.
|
|
671
|
+
//
|
|
672
|
+
// DELETION IS THE ONE PATH THAT DOES NOT REPLACE, so it obeys the same rule as the replacement
|
|
673
|
+
// path: a file whose bytes are not what this installer last wrote is copied to
|
|
674
|
+
// <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> FIRST and named in the log. A file
|
|
675
|
+
// that still matches its recorded hash IS the package's copy, so removing it destroys nothing.
|
|
676
|
+
const currentPrefixes = new Set(PRESETS.map(p => p.src + '/'))
|
|
677
|
+
let removedFiles = 0
|
|
678
|
+
let matchedPackageFiles = 0
|
|
679
|
+
let keptUserFiles = 0
|
|
680
|
+
const removedDirs = []
|
|
681
|
+
const backedUpStale = []
|
|
682
|
+
const backupFailedStale = []
|
|
683
|
+
const unreadableStale = []
|
|
684
|
+
const keptUserDirs = new Set()
|
|
685
|
+
const stale = new Map() // prefix -> [keys]
|
|
686
|
+
for (const key of Object.keys(prevFiles)) {
|
|
687
|
+
const slash = key.indexOf('/')
|
|
688
|
+
if (slash === -1) continue
|
|
689
|
+
const prefix = key.slice(0, slash + 1)
|
|
690
|
+
if (currentPrefixes.has(prefix)) continue
|
|
691
|
+
if (!stale.has(prefix)) stale.set(prefix, [])
|
|
692
|
+
stale.get(prefix).push(key)
|
|
693
|
+
}
|
|
694
|
+
for (const [prefix, keys] of stale) {
|
|
695
|
+
let dirEmpty = true
|
|
696
|
+
for (const key of keys) {
|
|
697
|
+
const rec = (prevFiles[key] && typeof prevFiles[key] === 'object') ? prevFiles[key] : { provenance: 'package' }
|
|
698
|
+
if (rec.provenance === 'user') { dirEmpty = false; keptUserFiles += 1; keptUserDirs.add(prefix.slice(0, -1)); continue } // 用户文件 → 保留
|
|
699
|
+
const f = join(presetRoot, key)
|
|
700
|
+
if (existsSync(f)) {
|
|
701
|
+
let buf = null
|
|
702
|
+
try { buf = readFileSync(f) } catch (e) { /* unreadable: treated as "cannot prove it is ours" */ }
|
|
703
|
+
// A file that still matches its recorded hash IS the copy this installer wrote, so deleting
|
|
704
|
+
// it destroys nothing. Anything else — edited bytes, or a legacy record with no hash that
|
|
705
|
+
// cannot tell — is copied to the backup root first. Deletion is allowed ONLY when nothing of
|
|
706
|
+
// the user's can be lost: a package copy, or bytes that really were preserved. An unreadable
|
|
707
|
+
// file cannot be backed up, and a FAILED backup means the bytes were not preserved either —
|
|
708
|
+
// both are KEPT and named in the log. A stale directory left behind is cosmetic; destroyed
|
|
709
|
+
// user bytes are not. (The REPLACEMENT path above keeps its own rule — a failed backup must
|
|
710
|
+
// not block a version upgrade — because there the file is overwritten, not removed.)
|
|
711
|
+
const isPackageCopy = buf !== null && typeof rec.hash === 'string' && sha256(buf) === rec.hash
|
|
712
|
+
let mayDelete = false
|
|
713
|
+
if (isPackageCopy) {
|
|
714
|
+
matchedPackageFiles += 1
|
|
715
|
+
mayDelete = true
|
|
716
|
+
} else if (buf !== null) {
|
|
717
|
+
const cut = key.lastIndexOf('/')
|
|
718
|
+
const backup = backupReplacedFile(presetRoot, fromVersion, key.slice(0, cut), key.slice(cut + 1), buf)
|
|
719
|
+
if (backup === 'failed') backupFailedStale.push(key)
|
|
720
|
+
else { backedUpStale.push(key); mayDelete = true }
|
|
721
|
+
} else {
|
|
722
|
+
unreadableStale.push(key)
|
|
723
|
+
}
|
|
724
|
+
if (mayDelete) { try { unlinkSync(f); removedFiles += 1 } catch (e) {} }
|
|
725
|
+
}
|
|
726
|
+
if (existsSync(f)) dirEmpty = false
|
|
727
|
+
}
|
|
728
|
+
const dir = join(presetRoot, prefix.slice(0, -1))
|
|
729
|
+
if (dirEmpty && existsSync(dir)) { try { rmdirSync(dir); removedDirs.push(dir) } catch (e) {} }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() }, state, logger)
|
|
733
|
+
|
|
734
|
+
const cleanupNotes = []
|
|
735
|
+
if (matchedPackageFiles > 0) cleanupNotes.push(matchedPackageFiles + ' 个仍是本安装器写入的字节(删掉的不是你的改动)')
|
|
736
|
+
if (backedUpStale.length > 0) cleanupNotes.push(backedUpStale.length + ' 个的字节与上一次安装不同(被改过),原文已备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + backedUpStale.join(', '))
|
|
737
|
+
if (backupFailedStale.length > 0) cleanupNotes.push(backupFailedStale.length + ' 个被改过的文件**备份失败**(原文未保留,已跳过删除并保留原文件):' + backupFailedStale.join(', '))
|
|
738
|
+
if (unreadableStale.length > 0) cleanupNotes.push(unreadableStale.length + ' 个文件**无法读取**(既不能证明是本安装器写入的,也无法备份),已跳过删除并保留原文件:' + unreadableStale.join(', '))
|
|
739
|
+
if (keptUserFiles > 0) cleanupNotes.push(keptUserFiles + ' 个记为用户所有的文件被保留(' + [...keptUserDirs].join(', ') + '),要清理请手动删除')
|
|
740
|
+
if (removedFiles > 0 || removedDirs.length > 0 || cleanupNotes.length > 0) {
|
|
741
741
|
// 约定:安装器面向用户的日志统一中文(英文片段混进中文句子会让用户读到半句话)。
|
|
742
|
-
logger?.info?.('[dsh-vibe-math] 预设清理:从 ' + removedDirs.length + ' 个已不再随包发布的过期预设目录(' + removedDirs.map(d => d.split(/[\\/]/).pop()).join(', ') + ') 中删除 ' + removedFiles + ' 个文件' +
|
|
743
|
-
(cleanupNotes.length > 0 ? ';' + cleanupNotes.join(';') : '') + '。')
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const backupsDir = join(presetRoot, BACKUP_DIR, String(fromVersion))
|
|
747
|
-
// Two kinds of preserved bytes, reported apart because they are not the same claim:
|
|
748
|
-
// · replacedEdits — the bytes on disk are NOT what this installer last wrote: a provable edit.
|
|
749
|
-
// · unknownPrev — a legacy state with no hash cannot tell; backed up all the same, and never
|
|
750
|
-
// called "被改过" (the old wording claimed an edit it could not see).
|
|
751
|
-
const editClause = replacedEdits.length > 0
|
|
752
|
-
? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
|
|
753
|
-
(backupFailures.length === 0
|
|
754
|
-
? ',原文备份在 ' + backupsDir + ':' + replacedEdits.join(', ')
|
|
755
|
-
: ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
|
|
756
|
-
'。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
|
|
757
|
-
: ''
|
|
758
|
-
const unknownClause = unknownPrev.length > 0
|
|
759
|
-
? '另有 ' + unknownPrev.length + ' 个文件在旧版状态里没有哈希(无法判断你是否改过),已先备份原文到 ' + backupsDir + ' 再替换' +
|
|
760
|
-
(backupFailures.length > 0 ? '(其中备份失败:' + backupFailures.join(', ') + ')' : '') + '。'
|
|
761
|
-
: ''
|
|
762
|
-
|
|
763
|
-
if (isUpgrade) {
|
|
764
|
-
logger?.info?.('[dsh-vibe-math] 预设自动更新:版本 ' + fromVersion + ' → ' + pkgVersion +
|
|
765
|
-
' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件。' +
|
|
766
|
-
editClause + unknownClause +
|
|
767
|
-
'新版本 preset 将在新会话生效。')
|
|
768
|
-
} else if (isBaseline) {
|
|
769
|
-
logger?.info?.('[dsh-vibe-math] 预设基线:已刷新 ' + (installed + updated) + ' file(s) to v' + pkgVersion +
|
|
770
|
-
(replacedEdits.length > 0
|
|
771
|
-
? ',其中 ' + replacedEdits.length + ' 个原有文件与随包版本不同' +
|
|
772
|
-
(backupFailures.length === 0 ? ',原文已备份在 ' + backupsDir
|
|
773
|
-
: ',但有 ' + backupFailures.length + ' 个备份失败(原文未保留):' + backupFailures.join(', '))
|
|
774
|
-
: '') +
|
|
775
|
-
unknownClause +
|
|
776
|
-
' — 已启用版本化自动更新(后续版本变更会直接替换被管理的 preset 文件)。')
|
|
777
|
-
} else if (installed > 0 || kept > 0) {
|
|
778
|
-
// SAME VERSION, and BOTH facts are reported: a missing file being restored must not hide the
|
|
779
|
-
// drift notice (the audit's repro: the log said "restored 1" and never mentioned the drift).
|
|
780
|
-
const parts = []
|
|
781
|
-
if (installed > 0) parts.push('还原了 ' + installed + ' 个缺失的预设文件')
|
|
782
|
-
if (kept > 0) {
|
|
783
|
-
parts.push(kept + ' 个文件与随包副本不同,已保持原样(v' + pkgVersion + ' 未变)' +
|
|
784
|
-
(userEditedKept > 0 ? ',其中 ' + userEditedKept + ' 个是安装之后的改动(记为用户所有:弃用 preset 的清理不会删除它们)' : '') +
|
|
785
|
-
';下一次版本变更会先备份原文再替换')
|
|
786
|
-
}
|
|
787
|
-
logger?.info?.('[dsh-vibe-math] 预设文件:' + parts.join(';') + '。')
|
|
788
|
-
}
|
|
789
|
-
/* the host self-check ran at the top of apply() — it also decides the preset mechanism */
|
|
790
|
-
} catch (err) {
|
|
791
|
-
logger?.warn?.('[dsh-vibe-math] 预设安装/更新失败:' + String((err && err.message) || err))
|
|
792
|
-
}
|
|
793
|
-
}
|
|
742
|
+
logger?.info?.('[dsh-vibe-math] 预设清理:从 ' + removedDirs.length + ' 个已不再随包发布的过期预设目录(' + removedDirs.map(d => d.split(/[\\/]/).pop()).join(', ') + ') 中删除 ' + removedFiles + ' 个文件' +
|
|
743
|
+
(cleanupNotes.length > 0 ? ';' + cleanupNotes.join(';') : '') + '。')
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const backupsDir = join(presetRoot, BACKUP_DIR, String(fromVersion))
|
|
747
|
+
// Two kinds of preserved bytes, reported apart because they are not the same claim:
|
|
748
|
+
// · replacedEdits — the bytes on disk are NOT what this installer last wrote: a provable edit.
|
|
749
|
+
// · unknownPrev — a legacy state with no hash cannot tell; backed up all the same, and never
|
|
750
|
+
// called "被改过" (the old wording claimed an edit it could not see).
|
|
751
|
+
const editClause = replacedEdits.length > 0
|
|
752
|
+
? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
|
|
753
|
+
(backupFailures.length === 0
|
|
754
|
+
? ',原文备份在 ' + backupsDir + ':' + replacedEdits.join(', ')
|
|
755
|
+
: ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
|
|
756
|
+
'。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
|
|
757
|
+
: ''
|
|
758
|
+
const unknownClause = unknownPrev.length > 0
|
|
759
|
+
? '另有 ' + unknownPrev.length + ' 个文件在旧版状态里没有哈希(无法判断你是否改过),已先备份原文到 ' + backupsDir + ' 再替换' +
|
|
760
|
+
(backupFailures.length > 0 ? '(其中备份失败:' + backupFailures.join(', ') + ')' : '') + '。'
|
|
761
|
+
: ''
|
|
762
|
+
|
|
763
|
+
if (isUpgrade) {
|
|
764
|
+
logger?.info?.('[dsh-vibe-math] 预设自动更新:版本 ' + fromVersion + ' → ' + pkgVersion +
|
|
765
|
+
' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件。' +
|
|
766
|
+
editClause + unknownClause +
|
|
767
|
+
'新版本 preset 将在新会话生效。')
|
|
768
|
+
} else if (isBaseline) {
|
|
769
|
+
logger?.info?.('[dsh-vibe-math] 预设基线:已刷新 ' + (installed + updated) + ' file(s) to v' + pkgVersion +
|
|
770
|
+
(replacedEdits.length > 0
|
|
771
|
+
? ',其中 ' + replacedEdits.length + ' 个原有文件与随包版本不同' +
|
|
772
|
+
(backupFailures.length === 0 ? ',原文已备份在 ' + backupsDir
|
|
773
|
+
: ',但有 ' + backupFailures.length + ' 个备份失败(原文未保留):' + backupFailures.join(', '))
|
|
774
|
+
: '') +
|
|
775
|
+
unknownClause +
|
|
776
|
+
' — 已启用版本化自动更新(后续版本变更会直接替换被管理的 preset 文件)。')
|
|
777
|
+
} else if (installed > 0 || kept > 0) {
|
|
778
|
+
// SAME VERSION, and BOTH facts are reported: a missing file being restored must not hide the
|
|
779
|
+
// drift notice (the audit's repro: the log said "restored 1" and never mentioned the drift).
|
|
780
|
+
const parts = []
|
|
781
|
+
if (installed > 0) parts.push('还原了 ' + installed + ' 个缺失的预设文件')
|
|
782
|
+
if (kept > 0) {
|
|
783
|
+
parts.push(kept + ' 个文件与随包副本不同,已保持原样(v' + pkgVersion + ' 未变)' +
|
|
784
|
+
(userEditedKept > 0 ? ',其中 ' + userEditedKept + ' 个是安装之后的改动(记为用户所有:弃用 preset 的清理不会删除它们)' : '') +
|
|
785
|
+
';下一次版本变更会先备份原文再替换')
|
|
786
|
+
}
|
|
787
|
+
logger?.info?.('[dsh-vibe-math] 预设文件:' + parts.join(';') + '。')
|
|
788
|
+
}
|
|
789
|
+
/* the host self-check ran at the top of apply() — it also decides the preset mechanism */
|
|
790
|
+
} catch (err) {
|
|
791
|
+
logger?.warn?.('[dsh-vibe-math] 预设安装/更新失败:' + String((err && err.message) || err))
|
|
792
|
+
}
|
|
793
|
+
}
|