dsh-plugin-tool-management 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +300 -238
- package/README_EN.md +300 -304
- package/cordis.patch.yml +9 -55
- package/docs/Changelog.md +108 -481
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/docs/update.md +54 -0
- package/lib/agents-md/preset-id.js +49 -0
- package/lib/agents-md/service.js +138 -56
- package/lib/client.js +4142 -3560
- package/lib/compat/probe.js +665 -0
- package/lib/history/bridge.js +293 -0
- package/lib/history/workspace.js +498 -52
- package/lib/http-fence.js +73 -0
- package/lib/index.js +511 -126
- package/lib/rules/provider.js +3 -3
- package/lib/rules/service.js +264 -30
- package/lib/scene-prompt-sync.js +112 -0
- package/lib/skills/core.js +112 -35
- package/lib/skills/service.js +6 -1
- package/package.json +5 -3
- package/screenshots.json +8 -7
- package/docs/images/MCP.png +0 -0
- package/docs/images//344/274/232/350/257/235.png +0 -0
- package/docs/images//345/234/272/346/231/257.png +0 -0
- package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images//346/212/200/350/203/275.png +0 -0
- package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images//350/256/260/345/277/206.png +0 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host capability probe — the single place that decides what this plugin may
|
|
3
|
+
* do to the running host's data, and why not when it may not.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS REPLACES THE TEXT-COMPARISON GATE
|
|
6
|
+
* ------------------------------------------
|
|
7
|
+
* The previous gate in `history/bridge.js` compared each host method against
|
|
8
|
+
* this plugin's own copy of the official prototype with
|
|
9
|
+
* `Function.prototype.toString()`. That only ever answered one question —
|
|
10
|
+
* "is the host running the same release I was written against?" — and it
|
|
11
|
+
* answered it with a hard throw, so a harmless upstream refactor disabled
|
|
12
|
+
* whole features. It also quietly assumed the plugin and the host load two
|
|
13
|
+
* *different* copies of the same package.
|
|
14
|
+
*
|
|
15
|
+
* The durable questions are different:
|
|
16
|
+
*
|
|
17
|
+
* - IDENTITY: do the plugin and the host share the same physical module?
|
|
18
|
+
* (If yes, `===`, `instanceof` and symbol lookups cross the boundary and
|
|
19
|
+
* every "same implementation" concern disappears. Missing identity is a
|
|
20
|
+
* deployment defect, reported as such, not something to guess around.)
|
|
21
|
+
* - PRESENCE: does the live host object expose the members the adapter
|
|
22
|
+
* calls? Members are checked as live values with their shape, because a
|
|
23
|
+
* host may have been upgraded, proxied, or replaced wholesale.
|
|
24
|
+
* - BEHAVIOUR: for anything that can be observed without mutating host
|
|
25
|
+
* state, call it and look at the result — a getter that no longer returns
|
|
26
|
+
* a Map is a fact, a version string is a hint.
|
|
27
|
+
*
|
|
28
|
+
* Every answer is reported per capability instead of collapsing into one
|
|
29
|
+
* boolean, so a caller can degrade exactly the feature that lost support and
|
|
30
|
+
* leave the rest working.
|
|
31
|
+
*/
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
/** Packages whose physical module identity matters to this plugin. */
|
|
36
|
+
export const IDENTITY_PACKAGES = [
|
|
37
|
+
'@deepseek-ai/cordis',
|
|
38
|
+
'@deepseek-ai/dsh-tools',
|
|
39
|
+
'@deepseek-ai/dsh-workspace',
|
|
40
|
+
'@deepseek-ai/dsh-session-projection-cache',
|
|
41
|
+
'@deepseek-ai/dsh-storage-domain',
|
|
42
|
+
'@deepseek-ai/dsh-spill-local',
|
|
43
|
+
];
|
|
44
|
+
/** Peers this plugin was written and verified against. */
|
|
45
|
+
export const EXPECTED_PEER_RANGE = '>=0.1.5-rc.2 <0.2.0-0';
|
|
46
|
+
/** The release this plugin's adapters were last verified against. */
|
|
47
|
+
export const VERIFIED_HOST_VERSION = '0.1.5-rc.2';
|
|
48
|
+
const isFn = (value) => typeof value === 'function';
|
|
49
|
+
/** Resolve a package the way this plugin resolves it, without throwing. */
|
|
50
|
+
function safeResolve(specifier) {
|
|
51
|
+
try {
|
|
52
|
+
return import.meta.resolve(specifier).replace(/^file:\/\/\//, '').replace(/\//g, process.platform === 'win32' ? '\\' : '/');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
try {
|
|
56
|
+
return createRequire(import.meta.url).resolve(specifier);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a path through every junction/symlink to its physical file.
|
|
65
|
+
* Node loads modules by real path, so two different-looking paths that
|
|
66
|
+
* realpath to one file ARE the same module — which is exactly the property
|
|
67
|
+
* this plugin depends on, and the reason `@deepseek-ai/*` is junctioned into
|
|
68
|
+
* the host installation instead of being copied.
|
|
69
|
+
*/
|
|
70
|
+
function realPathOf(value) {
|
|
71
|
+
if (value === null)
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
return realpathSync.native !== undefined ? realpathSync.native(value) : realpathSync(value);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Read a package version from a resolvable manifest. */
|
|
81
|
+
function versionOf(specifier) {
|
|
82
|
+
const entry = safeResolve(specifier);
|
|
83
|
+
if (entry === null)
|
|
84
|
+
return undefined;
|
|
85
|
+
let dir = dirname(entry);
|
|
86
|
+
for (let i = 0; i < 4; i += 1) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
89
|
+
if (parsed.name === specifier && typeof parsed.version === 'string')
|
|
90
|
+
return parsed.version;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* keep walking up */
|
|
94
|
+
}
|
|
95
|
+
const parent = dirname(dir);
|
|
96
|
+
if (parent === dir)
|
|
97
|
+
break;
|
|
98
|
+
dir = parent;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Compare a live member with this plugin's copy of the implementation.
|
|
104
|
+
* `undefined` means "not comparable" — the member is absent on the live object
|
|
105
|
+
* or the reference copy does not have it either.
|
|
106
|
+
*/
|
|
107
|
+
function textMatchOf(live, reference) {
|
|
108
|
+
if (!isFn(live) || !isFn(reference))
|
|
109
|
+
return undefined;
|
|
110
|
+
if (live === reference)
|
|
111
|
+
return true;
|
|
112
|
+
try {
|
|
113
|
+
return Function.prototype.toString.call(live) === Function.prototype.toString.call(reference);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The prototype that defines a live instance's members, following the prototype
|
|
121
|
+
* chain so proxies, subclass instances and cross-realm objects all answer
|
|
122
|
+
* honestly.
|
|
123
|
+
*/
|
|
124
|
+
function prototypeOf(instance) {
|
|
125
|
+
if (instance === null || typeof instance !== 'object')
|
|
126
|
+
return undefined;
|
|
127
|
+
if (typeof instance === 'function')
|
|
128
|
+
return instance;
|
|
129
|
+
const proto = Object.getPrototypeOf(instance);
|
|
130
|
+
return proto !== null && typeof proto === 'object' ? proto : undefined;
|
|
131
|
+
}
|
|
132
|
+
/** Official prototypes this plugin's adapters mirror, when importable. */
|
|
133
|
+
function referencePrototypes() {
|
|
134
|
+
const out = {};
|
|
135
|
+
try {
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
137
|
+
const ws = createRequire(import.meta.url)('@deepseek-ai/dsh-workspace');
|
|
138
|
+
if (ws.WorkspaceRegistry?.prototype !== undefined)
|
|
139
|
+
out.workspace = ws.WorkspaceRegistry.prototype;
|
|
140
|
+
}
|
|
141
|
+
catch { /* optional in tests and in trimmed deployments */ }
|
|
142
|
+
try {
|
|
143
|
+
const pc = createRequire(import.meta.url)('@deepseek-ai/dsh-session-projection-cache');
|
|
144
|
+
if (pc.SessionProjectionCache?.prototype !== undefined)
|
|
145
|
+
out.cache = pc.SessionProjectionCache.prototype;
|
|
146
|
+
}
|
|
147
|
+
catch { /* optional in tests */ }
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The capability table. This IS the contract with the host: everything the
|
|
152
|
+
* adapters reach for is listed here with what happens when it is absent, so a
|
|
153
|
+
* missing member produces a named, visible degradation instead of a throw from
|
|
154
|
+
* somewhere in the middle of a delete sequence.
|
|
155
|
+
*/
|
|
156
|
+
const CAPABILITY_SPECS = [
|
|
157
|
+
// ---- workspace registry: read side -------------------------------------
|
|
158
|
+
{
|
|
159
|
+
id: 'workspace.read-state',
|
|
160
|
+
label: '读取工作区状态',
|
|
161
|
+
kind: 'read',
|
|
162
|
+
owner: 'workspace',
|
|
163
|
+
fallback: 'degrade-read',
|
|
164
|
+
methods: ['requireState'],
|
|
165
|
+
probe: (t) => {
|
|
166
|
+
try {
|
|
167
|
+
const state = t.requireState();
|
|
168
|
+
if (state === null || typeof state !== 'object')
|
|
169
|
+
return 'requireState() 未返回对象';
|
|
170
|
+
const value = state;
|
|
171
|
+
if (!Array.isArray(value.archivedSessionIds))
|
|
172
|
+
return 'requireState().archivedSessionIds 不是数组';
|
|
173
|
+
if (!Array.isArray(value.workspaceIds))
|
|
174
|
+
return 'requireState().workspaceIds 不是数组';
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
return `requireState() 抛错:${String(error?.message ?? error)}`;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'workspace.read-table',
|
|
184
|
+
label: '读取工作区表',
|
|
185
|
+
kind: 'read',
|
|
186
|
+
owner: 'workspace',
|
|
187
|
+
fallback: 'degrade-read',
|
|
188
|
+
methods: ['requireTable'],
|
|
189
|
+
probe: (t) => {
|
|
190
|
+
try {
|
|
191
|
+
const table = t.requireTable();
|
|
192
|
+
if (table === null || typeof table !== 'object' || !isFn(table.get))
|
|
193
|
+
return 'requireTable() 未返回可 get 的表';
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return `requireTable() 抛错:${String(error?.message ?? error)}`;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'workspace.index-shape',
|
|
203
|
+
label: '工作区索引结构',
|
|
204
|
+
kind: 'read',
|
|
205
|
+
owner: 'workspace',
|
|
206
|
+
fallback: 'degrade-read',
|
|
207
|
+
fields: [
|
|
208
|
+
{ name: 'headers', instanceOf: 'Map' },
|
|
209
|
+
{ name: 'sessionPaths', instanceOf: 'Map' },
|
|
210
|
+
{ name: 'invalidSessionPaths', instanceOf: 'Map' },
|
|
211
|
+
{ name: 'entities', instanceOf: 'Map' },
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'workspace.read-header',
|
|
216
|
+
label: '读取会话头部',
|
|
217
|
+
kind: 'read',
|
|
218
|
+
owner: 'workspace',
|
|
219
|
+
fallback: 'degrade-read',
|
|
220
|
+
methods: ['readSessionHeader'],
|
|
221
|
+
},
|
|
222
|
+
// ---- workspace registry: write side ------------------------------------
|
|
223
|
+
{
|
|
224
|
+
id: 'workspace.enqueue',
|
|
225
|
+
label: '串行写事务',
|
|
226
|
+
kind: 'write',
|
|
227
|
+
owner: 'workspace',
|
|
228
|
+
fallback: 'disable-destructive',
|
|
229
|
+
methods: ['enqueueOperation'],
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'workspace.set-state',
|
|
233
|
+
label: '写工作区状态',
|
|
234
|
+
kind: 'write',
|
|
235
|
+
owner: 'workspace',
|
|
236
|
+
fallback: 'disable-destructive',
|
|
237
|
+
methods: ['setState'],
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'workspace.index-header',
|
|
241
|
+
label: '索引会话头部',
|
|
242
|
+
kind: 'write',
|
|
243
|
+
owner: 'workspace',
|
|
244
|
+
fallback: 'disable-destructive',
|
|
245
|
+
methods: ['indexHeader'],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: 'workspace.archive-native',
|
|
249
|
+
label: '宿主原生归档入口',
|
|
250
|
+
kind: 'write',
|
|
251
|
+
owner: 'workspace',
|
|
252
|
+
fallback: 'native-entry',
|
|
253
|
+
methods: ['archiveSession'],
|
|
254
|
+
optional: true,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'workspace.unarchive-native',
|
|
258
|
+
label: '宿主原生恢复入口',
|
|
259
|
+
kind: 'write',
|
|
260
|
+
owner: 'workspace',
|
|
261
|
+
fallback: 'native-entry',
|
|
262
|
+
methods: ['unarchiveSession'],
|
|
263
|
+
optional: true,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: 'workspace.batch-native',
|
|
267
|
+
label: '宿主原生批量入口',
|
|
268
|
+
kind: 'write',
|
|
269
|
+
owner: 'workspace',
|
|
270
|
+
fallback: 'native-entry',
|
|
271
|
+
methods: ['archiveWorkspaceSessions'],
|
|
272
|
+
optional: true,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'workspace.delete-native',
|
|
276
|
+
label: '宿主原生删除入口',
|
|
277
|
+
kind: 'delete',
|
|
278
|
+
owner: 'workspace',
|
|
279
|
+
// NOT `disable-destructive`: when this slot is empty the plugin performs the
|
|
280
|
+
// whole delete itself, so nothing is disabled. `native-entry` describes what
|
|
281
|
+
// actually happens (the adapter takes over).
|
|
282
|
+
fallback: 'native-entry',
|
|
283
|
+
methods: ['deleteSession'],
|
|
284
|
+
optional: true,
|
|
285
|
+
},
|
|
286
|
+
// ---- sessions runtime (private members, used only on the live branch) ---
|
|
287
|
+
{
|
|
288
|
+
id: 'sessions.detach-live',
|
|
289
|
+
label: '实时会话落盘与分离',
|
|
290
|
+
kind: 'delete',
|
|
291
|
+
owner: 'sessions',
|
|
292
|
+
fallback: 'disable-destructive',
|
|
293
|
+
methods: ['flush', 'liveEntryFor', 'detachEntered'],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'sessions.cold-announce',
|
|
297
|
+
label: '冷会话移除广播',
|
|
298
|
+
kind: 'delete',
|
|
299
|
+
owner: 'sessions',
|
|
300
|
+
fallback: 'disable-destructive',
|
|
301
|
+
methods: ['enter', 'announce'],
|
|
302
|
+
},
|
|
303
|
+
// ---- projection cache ---------------------------------------------------
|
|
304
|
+
{
|
|
305
|
+
id: 'projection.write',
|
|
306
|
+
label: '投影缓存写入路径',
|
|
307
|
+
kind: 'write',
|
|
308
|
+
owner: 'projectionCache',
|
|
309
|
+
fallback: 'disable-destructive',
|
|
310
|
+
methods: ['write', 'put', 'requireTable'],
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'projection.delete-native',
|
|
314
|
+
label: '投影缓存删除屏障',
|
|
315
|
+
kind: 'delete',
|
|
316
|
+
owner: 'projectionCache',
|
|
317
|
+
// Absent on rc.2: `history/bridge.js` installs a checked write barrier
|
|
318
|
+
// instead, so absence is a routing fact, not a failure. Only a cache whose
|
|
319
|
+
// write path cannot be wrapped at all is a real problem.
|
|
320
|
+
fallback: 'native-entry',
|
|
321
|
+
optional: true,
|
|
322
|
+
probe: (t) => {
|
|
323
|
+
if (isFn(t?.delete) && isFn(t?.whenIdle))
|
|
324
|
+
return undefined;
|
|
325
|
+
const wrappable = ['write', 'put'].filter((name) => !isFn(t?.[name]));
|
|
326
|
+
if (wrappable.length > 0)
|
|
327
|
+
return `宿主缓存无法安全包裹(缺少 ${wrappable.join(', ')})`;
|
|
328
|
+
let table;
|
|
329
|
+
try {
|
|
330
|
+
table = t.requireTable();
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
return `requireTable() 抛错:${String(error?.message ?? error)}`;
|
|
334
|
+
}
|
|
335
|
+
if (!isFn(table?.delete))
|
|
336
|
+
return '宿主缓存存储不支持行删除(table.delete 缺失)';
|
|
337
|
+
return undefined;
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
/**
|
|
342
|
+
* The capability sets each operation depends on, grouped by how the plugin
|
|
343
|
+
* reaches the same outcome when a member is missing. Order matters: the first
|
|
344
|
+
* viable route wins.
|
|
345
|
+
*
|
|
346
|
+
* - `native`: host entry points that already implement the whole operation.
|
|
347
|
+
* - `adapter`: the checked compatibility adapter (the only route that touches
|
|
348
|
+
* private members), used when the host has no native entry.
|
|
349
|
+
*/
|
|
350
|
+
export const OPERATION_ROUTES = {
|
|
351
|
+
archive: { native: ['workspace.archive-native'], adapter: ['workspace.enqueue', 'workspace.set-state'] },
|
|
352
|
+
unarchive: { native: ['workspace.unarchive-native'], adapter: ['workspace.enqueue', 'workspace.set-state'] },
|
|
353
|
+
batch: { native: ['workspace.batch-native'], adapter: ['workspace.enqueue', 'workspace.set-state'] },
|
|
354
|
+
delete: {
|
|
355
|
+
native: ['workspace.delete-native'],
|
|
356
|
+
// NOTE: `projection.delete-native` is deliberately NOT a route requirement.
|
|
357
|
+
// A host cache without its own delete barrier is expected on rc.2; the
|
|
358
|
+
// bridge wraps it (`workspace.js` / `history/bridge.js`) and reports a
|
|
359
|
+
// refusal itself when even that is impossible. Requiring the native barrier
|
|
360
|
+
// here would disable deletion on exactly the host this plugin was verified
|
|
361
|
+
// against.
|
|
362
|
+
adapter: [
|
|
363
|
+
'workspace.enqueue', 'workspace.set-state', 'workspace.index-header',
|
|
364
|
+
'sessions.detach-live', 'sessions.cold-announce', 'projection.write',
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
list: { native: [], adapter: ['workspace.read-state', 'workspace.read-table', 'workspace.index-shape'] },
|
|
368
|
+
};
|
|
369
|
+
/** Texts a user can act on, per capability, when it is the reason for a refusal. */
|
|
370
|
+
function recoveryFor(_finding) {
|
|
371
|
+
return `宿主 ${VERIFIED_HOST_VERSION} 的该项能力未通过探测;请更新本插件到与本机 DSH 匹配的版本,或改用宿主原生入口(先运行 node scripts/doctor.mjs 查看差异)。`;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* One capability's result. `target` is the live host object; `reference` is
|
|
375
|
+
* this plugin's copy of the same implementation when the package is importable.
|
|
376
|
+
*/
|
|
377
|
+
function inspectCapability(spec, target, reference) {
|
|
378
|
+
const base = {
|
|
379
|
+
id: spec.id,
|
|
380
|
+
label: spec.label,
|
|
381
|
+
kind: spec.kind,
|
|
382
|
+
owner: spec.owner,
|
|
383
|
+
fallback: spec.fallback,
|
|
384
|
+
...(spec.optional === true ? { optional: true } : {}),
|
|
385
|
+
};
|
|
386
|
+
if (target === undefined || target === null) {
|
|
387
|
+
return { ...base, state: 'not-available', detail: '宿主未提供该服务(ctx.get 返回 undefined)', missing: [] };
|
|
388
|
+
}
|
|
389
|
+
if (spec.when !== undefined && !spec.when(target)) {
|
|
390
|
+
return { ...base, state: 'ok', detail: '当前分支不需要(按实时/冷会话路径判定)', missing: [] };
|
|
391
|
+
}
|
|
392
|
+
const missing = [];
|
|
393
|
+
let textMatch;
|
|
394
|
+
for (const name of spec.methods ?? []) {
|
|
395
|
+
const live = target[name];
|
|
396
|
+
if (!isFn(live)) {
|
|
397
|
+
missing.push(name);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const match = textMatchOf(live, reference?.[name]);
|
|
401
|
+
if (match === false && textMatch !== false)
|
|
402
|
+
textMatch = false;
|
|
403
|
+
else if (match === true && textMatch === undefined)
|
|
404
|
+
textMatch = true;
|
|
405
|
+
}
|
|
406
|
+
for (const field of spec.fields ?? []) {
|
|
407
|
+
const value = target[field.name];
|
|
408
|
+
if (field.instanceOf === 'Map' && !(value instanceof Map))
|
|
409
|
+
missing.push(`${field.name}(非 Map)`);
|
|
410
|
+
else if (field.instanceOf === 'Array' && !Array.isArray(value))
|
|
411
|
+
missing.push(`${field.name}(非数组)`);
|
|
412
|
+
else if (field.instanceOf === 'Set' && !(value instanceof Set))
|
|
413
|
+
missing.push(`${field.name}(非 Set)`);
|
|
414
|
+
}
|
|
415
|
+
if (missing.length > 0) {
|
|
416
|
+
return {
|
|
417
|
+
...base,
|
|
418
|
+
state: 'missing-member',
|
|
419
|
+
detail: `宿主实现缺少 ${missing.join(', ')}`,
|
|
420
|
+
missing,
|
|
421
|
+
...(textMatch === undefined ? {} : { textMatch }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (spec.probe !== undefined) {
|
|
425
|
+
const failure = spec.probe(target);
|
|
426
|
+
if (failure !== undefined) {
|
|
427
|
+
return { ...base, state: 'shape-mismatch', detail: failure, missing: [], ...(textMatch === undefined ? {} : { textMatch }) };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
...base,
|
|
432
|
+
state: 'ok',
|
|
433
|
+
detail: textMatch === false ? '成员齐备(实现文本与本插件适配的版本不同,按能力使用)' : '成员齐备',
|
|
434
|
+
missing: [],
|
|
435
|
+
...(textMatch === undefined ? {} : { textMatch }),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Capability ids whose absence is a routing fact rather than a failure: the
|
|
440
|
+
* plugin substitutes its own implementation. They never appear as degraded and
|
|
441
|
+
* never block a route.
|
|
442
|
+
*
|
|
443
|
+
* Superseded by {@link CapabilityFinding.optional}, which marks the same fact on
|
|
444
|
+
* the finding itself (the UI and the doctor both read that flag). Kept as the
|
|
445
|
+
* exported id list so callers can ask "which slots does the plugin itself back?"
|
|
446
|
+
* without duplicating the table.
|
|
447
|
+
*/
|
|
448
|
+
export const SUBSTITUTED_CAPABILITIES = CAPABILITY_SPECS
|
|
449
|
+
.filter((spec) => spec.optional === true)
|
|
450
|
+
.map((spec) => spec.id);
|
|
451
|
+
/**
|
|
452
|
+
* Inspect the live host behind one plugin context.
|
|
453
|
+
*
|
|
454
|
+
* Everything is read-only: the only host code paths touched are getters and
|
|
455
|
+
* `requireState()`/`requireTable()`, both of which the plugin already calls on
|
|
456
|
+
* every list request. A failure inside a probe is a finding, never a throw.
|
|
457
|
+
*
|
|
458
|
+
* @param ctx - the plugin's cordis context.
|
|
459
|
+
* @returns the assessment snapshot surfaced to the UI, the tools and the log.
|
|
460
|
+
*/
|
|
461
|
+
export function assessHost(ctx) {
|
|
462
|
+
const get = (name) => {
|
|
463
|
+
try {
|
|
464
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
// Cordis exposes traceable proxies; compare and inspect the original objects.
|
|
471
|
+
const unwrap = (value) => {
|
|
472
|
+
if (value === null || typeof value !== 'object')
|
|
473
|
+
return value;
|
|
474
|
+
try {
|
|
475
|
+
const original = value[Symbol.for('cordis.original')];
|
|
476
|
+
if (original !== undefined)
|
|
477
|
+
return original;
|
|
478
|
+
}
|
|
479
|
+
catch { /* ignore */ }
|
|
480
|
+
return value;
|
|
481
|
+
};
|
|
482
|
+
const registry = unwrap(get('workspaceRegistry'));
|
|
483
|
+
const cache = unwrap(get('sessionProjectionCache'));
|
|
484
|
+
const sessions = unwrap(ctx.sessions !== undefined ? ctx.sessions : get('sessions'));
|
|
485
|
+
const references = referencePrototypes();
|
|
486
|
+
const targets = {
|
|
487
|
+
workspace: { target: registry, reference: references.workspace },
|
|
488
|
+
projectionCache: { target: cache, reference: references.cache },
|
|
489
|
+
sessions: { target: sessions, reference: undefined },
|
|
490
|
+
persistence: { target: unwrap(get('sessionPersistence')), reference: undefined },
|
|
491
|
+
};
|
|
492
|
+
const findings = CAPABILITY_SPECS.map((spec) => {
|
|
493
|
+
const slot = targets[spec.owner];
|
|
494
|
+
return inspectCapability(spec, slot.target, slot.reference);
|
|
495
|
+
});
|
|
496
|
+
// Identity: is the plugin loading the same physical modules the host runs?
|
|
497
|
+
const modules = {};
|
|
498
|
+
const sameAsHost = {};
|
|
499
|
+
const blockers = [];
|
|
500
|
+
const hostRoot = hostPackageRoot();
|
|
501
|
+
for (const name of IDENTITY_PACKAGES) {
|
|
502
|
+
const resolved = safeResolve(name);
|
|
503
|
+
modules[name] = resolved;
|
|
504
|
+
if (resolved === null) {
|
|
505
|
+
sameAsHost[name] = null;
|
|
506
|
+
blockers.push(`${name}:无法解析`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (hostRoot === null) {
|
|
510
|
+
sameAsHost[name] = null;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
// Resolve the same name from the host installation's own anchor, then
|
|
514
|
+
// compare PHYSICAL files: a junction is the same module, not a copy.
|
|
515
|
+
let hostResolved = null;
|
|
516
|
+
try {
|
|
517
|
+
const hostRequire = createRequire(join(dirname(hostRoot), 'package.json'));
|
|
518
|
+
hostResolved = hostRequire.resolve(name);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
hostResolved = null;
|
|
522
|
+
}
|
|
523
|
+
const same = hostResolved === null ? null : realPathOf(hostResolved) === realPathOf(resolved);
|
|
524
|
+
sameAsHost[name] = same;
|
|
525
|
+
if (same === false)
|
|
526
|
+
blockers.push(`${name}:插件与宿主加载的是两份不同拷贝(运行 node scripts/host-deps.mjs --fix)`);
|
|
527
|
+
}
|
|
528
|
+
// Degraded = something is genuinely unavailable. Optional slots are excluded:
|
|
529
|
+
// their absence selects the adapter route and leaves the feature fully
|
|
530
|
+
// working, so listing them here would tell the user a working feature is
|
|
531
|
+
// broken (and, with the destructive wording, that its buttons are disabled).
|
|
532
|
+
const degraded = findings.filter((finding) => finding.state !== 'ok' && finding.optional !== true);
|
|
533
|
+
// Deletion is possible when SOME route reaches it. The optional native
|
|
534
|
+
// delegate slots (`workspace.delete-native`, `workspace.unarchive-native`,
|
|
535
|
+
// `workspace.batch-native`) are expected to be ABSENT on hosts whose official
|
|
536
|
+
// API lacks those entry points — that absence selects the adapter route, it
|
|
537
|
+
// does not mean deletion is impossible.
|
|
538
|
+
const delta = routeFor({ findings }, 'delete').via !== 'none';
|
|
539
|
+
return {
|
|
540
|
+
identity: {
|
|
541
|
+
version: versionOf('@deepseek-ai/dsh-workspace') ?? 'unknown',
|
|
542
|
+
modules,
|
|
543
|
+
sameAsHost,
|
|
544
|
+
blockers,
|
|
545
|
+
},
|
|
546
|
+
findings,
|
|
547
|
+
degraded,
|
|
548
|
+
mayDelete: delta,
|
|
549
|
+
generatedAt: Date.now(),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/** True when two paths denote the same file; falls back to string equality. */
|
|
553
|
+
function samePath(a, b) {
|
|
554
|
+
const normalize = (value) => value.replace(/\\/g, '/').toLowerCase();
|
|
555
|
+
return normalize(realPathOf(a) ?? a) === normalize(realPathOf(b) ?? b);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The `@deepseek-ai` directory of the DSH installation this plugin is attached
|
|
559
|
+
* to, derived from where a shared package physically lives.
|
|
560
|
+
*
|
|
561
|
+
* Anchoring on the resolved entry rather than on `require.resolve('@deepseek-ai/dsh')`
|
|
562
|
+
* matters: the plugin never imports the `dsh` app package, so it need not be
|
|
563
|
+
* resolvable from the plugin at all — only the shared libraries are.
|
|
564
|
+
*/
|
|
565
|
+
function hostPackageRoot() {
|
|
566
|
+
for (const anchor of IDENTITY_PACKAGES) {
|
|
567
|
+
const resolved = realPathOf(safeResolve(anchor));
|
|
568
|
+
if (resolved === null)
|
|
569
|
+
continue;
|
|
570
|
+
let dir = dirname(resolved);
|
|
571
|
+
for (let i = 0; i < 3; i += 1) {
|
|
572
|
+
if (dir.endsWith(join('node_modules', '@deepseek-ai')))
|
|
573
|
+
return dir;
|
|
574
|
+
const parent = dirname(dir);
|
|
575
|
+
if (parent === dir)
|
|
576
|
+
break;
|
|
577
|
+
dir = parent;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
/** Human-readable summary line for logs and the settings page header. */
|
|
583
|
+
export function summarize(assessment) {
|
|
584
|
+
const total = assessment.findings.length;
|
|
585
|
+
const ok = total - assessment.degraded.length;
|
|
586
|
+
const state = assessment.degraded.length === 0 ? '全部可用' : `降级 ${assessment.degraded.length} 项`;
|
|
587
|
+
return `宿主 ${assessment.identity.version} · 能力 ${ok}/${total} · ${state}`;
|
|
588
|
+
}
|
|
589
|
+
/** Findings by id, for route decisions. */
|
|
590
|
+
export function findingsById(assessment) {
|
|
591
|
+
return new Map(assessment.findings.map((finding) => [finding.id, finding]));
|
|
592
|
+
}
|
|
593
|
+
/** Refusals for every degraded capability in the list, in list order. */
|
|
594
|
+
export function refusalsFor(assessment, ids) {
|
|
595
|
+
const index = findingsById(assessment);
|
|
596
|
+
const out = [];
|
|
597
|
+
for (const id of ids) {
|
|
598
|
+
const finding = index.get(id);
|
|
599
|
+
if (finding === undefined || finding.state === 'ok')
|
|
600
|
+
continue;
|
|
601
|
+
out.push({ id: finding.id, label: finding.label, detail: finding.detail, recovery: recoveryFor(finding) });
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Choose how one operation should reach the host.
|
|
607
|
+
*
|
|
608
|
+
* Never silently falls back from a partially available route into a destructive
|
|
609
|
+
* one: a route is chosen only when every capability it needs is `ok`.
|
|
610
|
+
*
|
|
611
|
+
* @param assessment - latest host assessment.
|
|
612
|
+
* @param operation - operation name from {@link OPERATION_ROUTES}.
|
|
613
|
+
* @returns the route to take plus, when `none`, what is missing and why.
|
|
614
|
+
*/
|
|
615
|
+
export function routeFor(assessment, operation) {
|
|
616
|
+
const routes = OPERATION_ROUTES[operation];
|
|
617
|
+
const index = findingsById(assessment);
|
|
618
|
+
const allOk = (ids) => ids.every((id) => {
|
|
619
|
+
const finding = index.get(id);
|
|
620
|
+
// A capability that the host does not expose at all cannot block a route
|
|
621
|
+
// it is not part of; absent ids are treated as satisfied.
|
|
622
|
+
return finding === undefined || finding.state === 'ok';
|
|
623
|
+
});
|
|
624
|
+
if (routes.native.length > 0 && allOk(routes.native))
|
|
625
|
+
return { via: 'native', refusals: [] };
|
|
626
|
+
if (allOk(routes.adapter))
|
|
627
|
+
return { via: 'adapter', refusals: [] };
|
|
628
|
+
const needed = routes.native.length > 0 ? [...new Set([...routes.native, ...routes.adapter])] : routes.adapter;
|
|
629
|
+
const refusals = refusalsFor(assessment, needed);
|
|
630
|
+
if (refusals.length > 0)
|
|
631
|
+
return { via: 'none', refusals };
|
|
632
|
+
return {
|
|
633
|
+
via: 'none',
|
|
634
|
+
refusals: [{
|
|
635
|
+
id: operation,
|
|
636
|
+
label: operation,
|
|
637
|
+
detail: '宿主未提供该操作的任何可用路径',
|
|
638
|
+
recovery: recoveryFor({ id: operation }),
|
|
639
|
+
}],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* The error a refused operation throws. Kept as a distinct class so callers can
|
|
644
|
+
* tell "the host cannot do this safely" from "the operation failed".
|
|
645
|
+
*/
|
|
646
|
+
export class CapabilityRefusalError extends Error {
|
|
647
|
+
operation;
|
|
648
|
+
refusals;
|
|
649
|
+
constructor(operation, refusals) {
|
|
650
|
+
super(`宿主不支持该操作(${operation}):`
|
|
651
|
+
+ refusals.map((item) => `${item.label} — ${item.detail}`).join(';')
|
|
652
|
+
+ '。为避免用未知实现改动数据,操作在任何写入之前停止。'
|
|
653
|
+
+ (refusals[0]?.recovery !== undefined ? ` ${refusals[0].recovery}` : ''));
|
|
654
|
+
this.name = 'CapabilityRefusalError';
|
|
655
|
+
this.operation = operation;
|
|
656
|
+
this.refusals = refusals;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/** Throw unless the assessment allows the operation through some route. */
|
|
660
|
+
export function requireRoute(assessment, operation) {
|
|
661
|
+
const decision = routeFor(assessment, operation);
|
|
662
|
+
if (decision.via === 'none')
|
|
663
|
+
throw new CapabilityRefusalError(operation, decision.refusals);
|
|
664
|
+
return decision;
|
|
665
|
+
}
|