dsh-plugin-tool-management 0.10.0 → 0.12.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/CHANGELOG.md +100 -1
- package/README.md +67 -50
- package/README_EN.md +61 -38
- package/cordis.patch.yml +10 -1
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +132 -12
- package/lib/client.js +2826 -547
- package/lib/compat/patch-dialect.js +173 -0
- package/lib/compat/preset-reach.js +1 -10
- package/lib/compat/probe.js +205 -24
- package/lib/compat/runtime-notes.js +25 -0
- package/lib/context-inject.js +83 -9
- package/lib/host-names.js +12 -0
- package/lib/http-fence.js +35 -15
- package/lib/hub.js +28 -2
- package/lib/imports/parsers.js +15 -9
- package/lib/imports/upload.js +43 -4
- package/lib/index.js +927 -3897
- package/lib/mcp/loader-token.js +238 -0
- package/lib/mcp/manager.js +1769 -0
- package/lib/mcp/override-blocks.js +10 -3
- package/lib/mcp/patch-yaml.js +351 -0
- package/lib/mcp/secret-guard.js +145 -0
- package/lib/{rules → memories}/archive-engine.js +1 -1
- package/lib/{rules → memories}/archive.js +1 -1
- package/lib/memories/constants.js +128 -0
- package/lib/memories/index-io.js +330 -0
- package/lib/memories/projection.js +280 -0
- package/lib/memories/service.js +686 -0
- package/lib/memories/snapshot.js +672 -0
- package/lib/ops/candidates.js +64 -0
- package/lib/ops/compat.js +226 -0
- package/lib/ops/ctx.js +9 -0
- package/lib/ops/memory.js +678 -0
- package/lib/ops/prompts.js +107 -0
- package/lib/ops/scene-records.js +460 -0
- package/lib/ops/scene-sync.js +17 -0
- package/lib/ops/sessions.js +603 -0
- package/lib/ops/trash.js +140 -0
- package/lib/paths.js +103 -0
- package/lib/prompts/preset-id.js +49 -0
- package/lib/{agents-md → prompts}/service.js +1 -1
- package/lib/request-gate.js +320 -0
- package/lib/scene-prompt-sync.js +4 -4
- package/lib/scenes/candidates.js +344 -0
- package/lib/{history → sessions}/bridge.js +124 -36
- package/lib/sessions/history.js +323 -0
- package/lib/{history → sessions}/tombstone.js +1 -1
- package/lib/{history → sessions}/workspace.js +151 -50
- package/lib/skills/core.js +74 -39
- package/lib/skills/readonly-discovery.js +4 -1
- package/lib/skills/service.js +98 -13
- package/lib/subagents/service.js +199 -55
- package/lib/tools/deps.js +8 -0
- package/lib/tools/mcp.js +110 -0
- package/lib/tools/memory.js +87 -0
- package/lib/tools/prompt.js +70 -0
- package/lib/tools/skills.js +139 -0
- package/lib/tools/subagent.js +40 -0
- package/package.json +13 -10
- package/lib/agents-md/preset-id.js +0 -49
- package/lib/rules/service.js +0 -3078
package/lib/scene-prompt-sync.js
CHANGED
|
@@ -127,7 +127,7 @@ export function createScenePromptSync(deps) {
|
|
|
127
127
|
warn(`scene-prompt: refs probe (scenes) failed: ${String(e)}`);
|
|
128
128
|
}
|
|
129
129
|
try {
|
|
130
|
-
const cur = await deps.
|
|
130
|
+
const cur = await deps.prompts.getCurrent();
|
|
131
131
|
if (!cur || cur.ok !== true)
|
|
132
132
|
complete = false;
|
|
133
133
|
else if (cur.presetId)
|
|
@@ -159,7 +159,7 @@ export function createScenePromptSync(deps) {
|
|
|
159
159
|
// 基线始终是"进场景之前"那一份)。
|
|
160
160
|
const saved = await readBaseline();
|
|
161
161
|
if (!saved || saved.content === null) {
|
|
162
|
-
const cur = await deps.
|
|
162
|
+
const cur = await deps.prompts.getCurrent();
|
|
163
163
|
await writeBaseline({
|
|
164
164
|
v: 1,
|
|
165
165
|
at: Date.now(),
|
|
@@ -167,7 +167,7 @@ export function createScenePromptSync(deps) {
|
|
|
167
167
|
content: cur && cur.ok && cur.exists ? String(cur.content) : null,
|
|
168
168
|
});
|
|
169
169
|
}
|
|
170
|
-
const res = await deps.
|
|
170
|
+
const res = await deps.prompts.apply(current.presetId);
|
|
171
171
|
if (res && res.ok === false)
|
|
172
172
|
return { error: String(res.error || '写入 AGENTS.md 失败') };
|
|
173
173
|
return { applied: current.presetId };
|
|
@@ -176,7 +176,7 @@ export function createScenePromptSync(deps) {
|
|
|
176
176
|
const saved = await readBaseline();
|
|
177
177
|
if (!saved || saved.content === null)
|
|
178
178
|
return { unchanged: true };
|
|
179
|
-
const res = await deps.
|
|
179
|
+
const res = await deps.prompts.restore(saved.content);
|
|
180
180
|
if (res && res.ok === false)
|
|
181
181
|
return { error: String(res.error || '恢复 AGENTS.md 失败') };
|
|
182
182
|
await writeBaseline({ v: 1, at: Date.now(), presetId: null, content: null });
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// 场景 / 表单的**候选源** —— 界面要"选一个"的地方,数据从哪来。
|
|
2
|
+
//
|
|
3
|
+
// 2026-09-19 从 index.ts 的 apply 闭包原样抽出(10 段,约 260 行)。为什么单独一个文件:
|
|
4
|
+
// 这些函数是**只读的取数**,彼此只有缓存关系,与"写补丁 / 改场景"完全无关;此前散在
|
|
5
|
+
// apply 闭包的四个角落(286 / 425 / 532 / 833 / 1924-2133),改一处要在 3000 行里找。
|
|
6
|
+
//
|
|
7
|
+
// 三组候选,各自的失效模式不同:
|
|
8
|
+
// * **技能 / 记忆 / 场景**(skillRows / memoryCandidates / memorySceneCandidates)——
|
|
9
|
+
// 直接读各自 service 的 op,无缓存;
|
|
10
|
+
// * **预设工具**(presetToolCandidates / presetToolNames)—— 枚举会为预设建立 standing
|
|
11
|
+
// mount(官方语义:每进程一次),所以按 PRESET_ENUM_CACHE_MS 缓存,不能每次重枚举;
|
|
12
|
+
// * **模型**(modelCandidates)—— 只读宿主 LLM 目录,**不发网络请求**。
|
|
13
|
+
//
|
|
14
|
+
// 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
|
|
15
|
+
// 放进本文件会让两个域互相引用。
|
|
16
|
+
import { presetRosterOf } from '../compat/preset-reach.js';
|
|
17
|
+
import { injectionFactsOf, readCompositionFacts } from '../compat/preset-reach.js';
|
|
18
|
+
export function createCandidates(deps) {
|
|
19
|
+
// 外部能力一次解构成局部名:块内代码逐字搬来,保持原样最不容易出错。
|
|
20
|
+
const { get, tools, mcp, skillsService, memoriesService, toolKeyParts } = deps;
|
|
21
|
+
const ctx = { get };
|
|
22
|
+
// Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
|
|
23
|
+
// 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
|
|
24
|
+
const presetRoster = () => presetRosterOf(ctx);
|
|
25
|
+
async function toolStates() {
|
|
26
|
+
const disabled = await mcp.readDisabledTools();
|
|
27
|
+
const states = {};
|
|
28
|
+
let schemas = [];
|
|
29
|
+
try {
|
|
30
|
+
schemas = await tools.schemas();
|
|
31
|
+
}
|
|
32
|
+
catch { /* 无 live 工具 → 仅启停表 */ }
|
|
33
|
+
const liveByServer = {};
|
|
34
|
+
for (const s of schemas) {
|
|
35
|
+
const p = toolKeyParts(String((s && s.name) || ''));
|
|
36
|
+
if (!p)
|
|
37
|
+
continue;
|
|
38
|
+
const list = liveByServer[p.server] || (liveByServer[p.server] = []);
|
|
39
|
+
if (list.indexOf(p.tool) < 0)
|
|
40
|
+
list.push(p.tool);
|
|
41
|
+
}
|
|
42
|
+
for (const [server, list] of Object.entries(disabled)) {
|
|
43
|
+
// 整台停用(['*']):在 live 工具上展开成具体键,不产出 `server/*` 伪键(那会污染勾选器)。
|
|
44
|
+
if (list.indexOf('*') >= 0) {
|
|
45
|
+
for (const t of (liveByServer[server] || []))
|
|
46
|
+
states[server + '/' + t] = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const t of list)
|
|
50
|
+
states[server + '/' + t] = false;
|
|
51
|
+
}
|
|
52
|
+
for (const [server, list] of Object.entries(liveByServer)) {
|
|
53
|
+
for (const t of list)
|
|
54
|
+
if (!(server + '/' + t in states))
|
|
55
|
+
states[server + '/' + t] = true;
|
|
56
|
+
}
|
|
57
|
+
return states;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 场景档案「技能集」的技能行 —— 技能名与来源名**分开**给出。
|
|
61
|
+
*
|
|
62
|
+
* 界面早先直接渲染 `key`(形如 `<来源 key>/<技能名>`),而自定义目录的来源 key 是
|
|
63
|
+
* `custom-<sha256-16>`,于是导入的技能在场景档案里显示成一串哈希(用户 2026-09-15 报的)。
|
|
64
|
+
* 这里把来源的 label / localeKey 一并带上,界面就能显示「技能名 · 来源名」。
|
|
65
|
+
*
|
|
66
|
+
* 含被覆盖的副本(`shadowed`):场景允许勾选任意一条技能,用生效集校验会把合法勾选判成 stale。
|
|
67
|
+
*/
|
|
68
|
+
async function skillRows() {
|
|
69
|
+
const rows = [];
|
|
70
|
+
try {
|
|
71
|
+
const r = await skillsService.ops['skill-state']({});
|
|
72
|
+
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
73
|
+
const rootKey = String((root && root.key) || '');
|
|
74
|
+
if (!rootKey)
|
|
75
|
+
continue;
|
|
76
|
+
for (const sk of ((root && root.skills) || [])) {
|
|
77
|
+
const name = String((sk && (sk.declaredName || sk.name)) || '');
|
|
78
|
+
if (!name)
|
|
79
|
+
continue;
|
|
80
|
+
rows.push({
|
|
81
|
+
key: rootKey + '/' + name,
|
|
82
|
+
name,
|
|
83
|
+
enabled: sk.enabled === true && !sk.shadowedBy,
|
|
84
|
+
shadowed: !!sk.shadowedBy,
|
|
85
|
+
loadable: sk.loadable !== false,
|
|
86
|
+
rootKey,
|
|
87
|
+
rootLabel: String((root && root.label) || rootKey),
|
|
88
|
+
...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
|
|
89
|
+
...(root && root.kind ? { rootKind: String(root.kind) } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch { /* 技能状态读不到 → 空列表(界面如实显示"没有可选项") */ }
|
|
95
|
+
// 平铺列表按技能名排(同名再按来源),比按来源聚在一起更好找。
|
|
96
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name) || a.rootKey.localeCompare(b.rootKey));
|
|
97
|
+
}
|
|
98
|
+
// 注入判定要的两条预设事实(压制型?挂得到 AGENTS.md 通道?)。读取走 TTL 缓存:
|
|
99
|
+
// 组合文件可以随时被编辑(patchReload: live),但每个 step 读一次盘没有必要。
|
|
100
|
+
const PRESET_FACTS_TTL_MS = 15_000;
|
|
101
|
+
const presetFactsCache = new Map();
|
|
102
|
+
async function presetFactsForAgent(agent) {
|
|
103
|
+
const roster = presetRoster();
|
|
104
|
+
if (!roster || typeof roster.composedPreset !== 'function' || typeof roster.read !== 'function')
|
|
105
|
+
return undefined;
|
|
106
|
+
let presetId = '';
|
|
107
|
+
try {
|
|
108
|
+
presetId = String(roster.composedPreset(agent?.ctx) ?? '');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
if (presetId === '')
|
|
114
|
+
return undefined;
|
|
115
|
+
const hit = presetFactsCache.get(presetId);
|
|
116
|
+
if (hit && Date.now() - hit.at < PRESET_FACTS_TTL_MS)
|
|
117
|
+
return hit.value;
|
|
118
|
+
try {
|
|
119
|
+
const text = String((await roster.read(presetId)) ?? '');
|
|
120
|
+
const value = injectionFactsOf(readCompositionFacts(text));
|
|
121
|
+
presetFactsCache.set(presetId, { at: Date.now(), value });
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 记忆候选(档案编辑器第 4 段「记忆」):`[{ id, scene, name, description }]`。
|
|
130
|
+
* 只读、不读正文(勾选集只存 id);任何异常降级为空列表,不让档案弹窗崩掉。
|
|
131
|
+
*/
|
|
132
|
+
async function memoryCandidates() {
|
|
133
|
+
try {
|
|
134
|
+
const r = await memoriesService.ops['rules-list']({});
|
|
135
|
+
if (!r || r.ok === false)
|
|
136
|
+
return [];
|
|
137
|
+
return (r.rules || [])
|
|
138
|
+
.filter((x) => !x.shadowed)
|
|
139
|
+
.map((x) => ({
|
|
140
|
+
id: String(x.id),
|
|
141
|
+
scene: String(x.group || ''),
|
|
142
|
+
name: String(x.name || ''),
|
|
143
|
+
description: String(x.description || ''),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 场景候选(档案编辑器第 4 段的分组维度):`[{ name, label, description, count, global }]`。 */
|
|
151
|
+
async function memorySceneCandidates() {
|
|
152
|
+
try {
|
|
153
|
+
const r = await memoriesService.ops['rules-list']({});
|
|
154
|
+
if (!r || r.ok === false)
|
|
155
|
+
return [];
|
|
156
|
+
return (r.scenes || []).map((s) => ({
|
|
157
|
+
name: String(s.name),
|
|
158
|
+
label: String(s.label || s.name),
|
|
159
|
+
description: String(s.description || ''),
|
|
160
|
+
count: Number(s.count || 0),
|
|
161
|
+
global: s.global === true,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 工具候选(人设的「工具白名单 / 黑名单」选择器):
|
|
170
|
+
* 取**全体 Agent 预设工具名的并集**——人设可能在任意预设下被子代理复用,
|
|
171
|
+
* 只列当前会话的工具会让换预设后的子代理启动失败(官方 `toolFilter` 对未知名直接拒绝)。
|
|
172
|
+
* 同时标注 `current`:当前会话可见的工具(其余只是「本预设可用,当前会话看不到」)。
|
|
173
|
+
*
|
|
174
|
+
* 每次调用都会为尚未挂载的预设建立 standing mount(官方语义:一个预设在本进程内只挂一次,
|
|
175
|
+
* 正常创建会话时同样会挂),因此结果会按需缓存 PRESET_ENUM_CACHE_MS,避免频繁枚举。
|
|
176
|
+
*/
|
|
177
|
+
// 两个预设枚举缓存(全体候选 / 单个预设)共用这个 TTL:枚举的代价是"给预设建立
|
|
178
|
+
// standing mount",与内容变化无关,所以只要别刷得太勤就行。
|
|
179
|
+
const PRESET_ENUM_CACHE_MS = 60_000;
|
|
180
|
+
let presetToolsCache = null;
|
|
181
|
+
async function presetToolCandidates() {
|
|
182
|
+
if (presetToolsCache && Date.now() - presetToolsCache.at < PRESET_ENUM_CACHE_MS)
|
|
183
|
+
return presetToolsCache.value;
|
|
184
|
+
const byName = new Map();
|
|
185
|
+
const presets = [];
|
|
186
|
+
// 当前会话的可见工具(用于标 current);拿不到就全部按「非当前」处理。
|
|
187
|
+
const currentNames = new Set();
|
|
188
|
+
try {
|
|
189
|
+
const schemas = await tools.schemas();
|
|
190
|
+
for (const s of schemas || [])
|
|
191
|
+
currentNames.add(String(s.name));
|
|
192
|
+
}
|
|
193
|
+
catch { /* 无 live 工具 → current 全 false */ }
|
|
194
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
195
|
+
if (agentPresets && typeof agentPresets.list === 'function') {
|
|
196
|
+
try {
|
|
197
|
+
// roster 顺序 = 官方的展示顺序(preset.yml 的 order:标准 1 / PTC 2 / 极简 3 / 创造 4,
|
|
198
|
+
// 自建预设排在其后)。人设编辑器的四行照抄这个顺序,**不要**再按 id 排序。
|
|
199
|
+
const roster = await agentPresets.list();
|
|
200
|
+
for (const p of roster || []) {
|
|
201
|
+
const id = String((p && p.id) || '');
|
|
202
|
+
if (!id)
|
|
203
|
+
continue;
|
|
204
|
+
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
205
|
+
// 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
|
|
206
|
+
const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
|
|
207
|
+
presets.push({
|
|
208
|
+
id,
|
|
209
|
+
name: String((p && (p.name || p.id)) || id),
|
|
210
|
+
trust: String((p && p.trust) || 'user'),
|
|
211
|
+
broken: typeof (p && p.broken) === 'string',
|
|
212
|
+
tools: names,
|
|
213
|
+
});
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
216
|
+
rec.presets.add(id);
|
|
217
|
+
byName.set(name, rec);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch { /* 预设服务不可用 → 退回「仅当前会话工具」 */ }
|
|
222
|
+
}
|
|
223
|
+
// 当前会话的工具即便没有任何预设可枚举,也要出现在候选里(否则选择器是空的)。
|
|
224
|
+
for (const name of currentNames) {
|
|
225
|
+
if (name.startsWith('mcp__'))
|
|
226
|
+
continue;
|
|
227
|
+
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
228
|
+
byName.set(name, rec);
|
|
229
|
+
}
|
|
230
|
+
for (const rec of byName.values())
|
|
231
|
+
rec.current = currentNames.has(rec.name);
|
|
232
|
+
const value = {
|
|
233
|
+
tools: [...byName.values()]
|
|
234
|
+
.map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
|
|
235
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
236
|
+
// 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
|
|
237
|
+
presets,
|
|
238
|
+
};
|
|
239
|
+
presetToolsCache = { at: Date.now(), value };
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
244
|
+
* "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
|
|
245
|
+
* 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 PRESET_ENUM_CACHE_MS;
|
|
246
|
+
* `subagent_manager_run` 每次委派都要用它校验名单,不能每次都重新枚举。
|
|
247
|
+
*/
|
|
248
|
+
const presetNamesCache = new Map();
|
|
249
|
+
async function presetToolNames(id) {
|
|
250
|
+
const hit = presetNamesCache.get(id);
|
|
251
|
+
if (hit && Date.now() - hit.at < PRESET_ENUM_CACHE_MS)
|
|
252
|
+
return hit.names;
|
|
253
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
254
|
+
let scopeKey;
|
|
255
|
+
try {
|
|
256
|
+
scopeKey = agentPresets && typeof agentPresets.standingKeyFor === 'function'
|
|
257
|
+
? await agentPresets.standingKeyFor(id)
|
|
258
|
+
: undefined;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
scopeKey = undefined;
|
|
262
|
+
}
|
|
263
|
+
let names = [];
|
|
264
|
+
try {
|
|
265
|
+
names = ((await tools.schemas(scopeKey)) || []).map((s) => String(s.name)).filter(Boolean);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
names = [];
|
|
269
|
+
}
|
|
270
|
+
presetNamesCache.set(id, { at: Date.now(), names });
|
|
271
|
+
return names;
|
|
272
|
+
}
|
|
273
|
+
/** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
|
|
274
|
+
async function presetNames() {
|
|
275
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
276
|
+
if (!agentPresets || typeof agentPresets.list !== 'function')
|
|
277
|
+
return [];
|
|
278
|
+
try {
|
|
279
|
+
const roster = await agentPresets.list();
|
|
280
|
+
return (roster || [])
|
|
281
|
+
.map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || 'user') }))
|
|
282
|
+
.filter((p) => p.id !== '');
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* 模型候选(人设的「模型」下拉):宿主已注册的 provider + 各 provider 能宣告的模型。
|
|
290
|
+
* 只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
|
|
291
|
+
* 拿不到就返回空列表,UI 退回手填(跨来源模型如 sensenova 的手工条目仍需手填兜底)。
|
|
292
|
+
* 返回扁平列表:DSH 的模型路由是 (provider, model) 一对,两个键必须同时给。
|
|
293
|
+
*/
|
|
294
|
+
async function modelCandidates() {
|
|
295
|
+
const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : undefined);
|
|
296
|
+
if (!llm || typeof llm.listProviders !== 'function')
|
|
297
|
+
return { models: [] };
|
|
298
|
+
let list = [];
|
|
299
|
+
try {
|
|
300
|
+
list = llm.listProviders() || [];
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return { models: [] };
|
|
304
|
+
}
|
|
305
|
+
const models = [];
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const p of list) {
|
|
308
|
+
const provider = String((p && (p.id || p.provider)) || '');
|
|
309
|
+
if (!provider)
|
|
310
|
+
continue;
|
|
311
|
+
const providerName = String((p && (p.name || p.displayName)) || provider);
|
|
312
|
+
let discovered = [];
|
|
313
|
+
try {
|
|
314
|
+
// 适配器可选提供 listModels(官方 LlmAdapter 契约);未提供时该 provider 只报名字。
|
|
315
|
+
if (typeof llm.listModels === 'function')
|
|
316
|
+
discovered = (await llm.listModels(provider)) || [];
|
|
317
|
+
}
|
|
318
|
+
catch { /* 该 provider 的目录读失败 → 只报 provider 本身 */ }
|
|
319
|
+
for (const m of discovered) {
|
|
320
|
+
const id = String((m && (m.id || m.model)) || '');
|
|
321
|
+
if (!id)
|
|
322
|
+
continue;
|
|
323
|
+
const key = provider + '\u0000' + id;
|
|
324
|
+
if (seen.has(key))
|
|
325
|
+
continue;
|
|
326
|
+
seen.add(key);
|
|
327
|
+
models.push({ provider, providerName, id, name: String((m && m.name) || id) });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { models };
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
presetRoster,
|
|
334
|
+
presetFactsForAgent,
|
|
335
|
+
toolStates,
|
|
336
|
+
skillRows,
|
|
337
|
+
presetToolCandidates,
|
|
338
|
+
presetToolNames,
|
|
339
|
+
presetNames,
|
|
340
|
+
modelCandidates,
|
|
341
|
+
memoryCandidates,
|
|
342
|
+
memorySceneCandidates,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { symbols } from "@deepseek-ai/cordis";
|
|
2
2
|
import { workspaceDomainSpec } from "@deepseek-ai/dsh-workspace";
|
|
3
3
|
import { assessHost, routeFor, refusalsFor, CapabilityRefusalError } from "../compat/probe.js";
|
|
4
|
+
import { noteRuntime } from "../compat/runtime-notes.js";
|
|
4
5
|
export { CapabilityRefusalError };
|
|
5
6
|
// No Service subclass, no ctx.provide(), no second storage-domain owner.
|
|
6
7
|
// The official API lacks unarchive and a cache deletion barrier, so this file
|
|
@@ -17,7 +18,11 @@ export { CapabilityRefusalError };
|
|
|
17
18
|
// 禁令针对的对象(包文件与宿主机制的永久改写)不同层。若用户裁定该解释不成立,
|
|
18
19
|
// 撤掉 acquire/releaseCacheGuard 即可整体退回「无删除屏障」的降级形态。
|
|
19
20
|
const cacheGuards = new WeakMap();
|
|
20
|
-
const raw = (value) =>
|
|
21
|
+
const raw = (value) => {
|
|
22
|
+
// cordis 的 traceable 代理在 [symbols.original] 上挂原始对象;无代理时原样返回
|
|
23
|
+
const holder = value;
|
|
24
|
+
return holder?.[symbols.original] ?? value;
|
|
25
|
+
};
|
|
21
26
|
const sameLifecycle = (a, b) => a && b && a.createdAt === b.createdAt && (a.cwd ?? null) === (b.cwd ?? null);
|
|
22
27
|
/**
|
|
23
28
|
* Install the write barrier around the host's projection cache when (and only
|
|
@@ -32,16 +37,39 @@ const sameLifecycle = (a, b) => a && b && a.createdAt === b.createdAt && (a.cwd
|
|
|
32
37
|
* @throws {CapabilityRefusalError} when the cache cannot support a safe delete.
|
|
33
38
|
*/
|
|
34
39
|
function acquireCacheGuard(cache) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const existing = cacheGuards.get(cache);
|
|
41
|
+
// 复用前先验「装着的确实是我们的包装器」:release 时被第三方接管过的那条路会留下一个
|
|
42
|
+
// 「在册但没装」的 guard,复用它等于墓碑拦截静默失效(缓存行可能复活)。
|
|
43
|
+
if (existing && existing.users > 0 && existing.installed()) {
|
|
44
|
+
existing.users += 1;
|
|
45
|
+
return existing;
|
|
39
46
|
}
|
|
47
|
+
if (existing)
|
|
48
|
+
cacheGuards.delete(cache);
|
|
40
49
|
const missing = ["put", "write", "requireTable"].filter((name) => typeof cache[name] !== "function");
|
|
41
50
|
if (missing.length > 0) {
|
|
42
51
|
throw new CapabilityRefusalError("delete", refusalsFor({ findings: [{ id: "projection.write", label: "投影缓存写入路径", kind: "write", owner: "projectionCache", fallback: "disable-destructive", state: "missing-member", detail: `宿主投影缓存缺少 ${missing.join(", ")}`, missing }] }, ["projection.write"]));
|
|
43
52
|
}
|
|
44
|
-
|
|
53
|
+
let table;
|
|
54
|
+
try {
|
|
55
|
+
table = cache.requireTable();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// 安装期就抛:此刻还没动 put/write(不可能有半状态),但必须上报 —— 删除屏障没在岗。
|
|
59
|
+
noteRuntime({
|
|
60
|
+
id: "projection-cache-adapter",
|
|
61
|
+
label: "投影缓存删除屏障",
|
|
62
|
+
kind: "delete",
|
|
63
|
+
fallback: "inform-only",
|
|
64
|
+
detail: `投影缓存适配未完成(requireTable 抛错):${String(error?.message ?? error)};宿主对象未被改动。`,
|
|
65
|
+
});
|
|
66
|
+
throw new CapabilityRefusalError("delete", [{
|
|
67
|
+
id: "projection.table-delete",
|
|
68
|
+
label: "投影缓存行删除",
|
|
69
|
+
detail: `宿主投影缓存的 requireTable() 抛错:${String(error?.message ?? error)}`,
|
|
70
|
+
recovery: "请更新本插件到与本机 DSH 匹配的版本(先运行 node scripts/doctor.mjs 查看差异)。",
|
|
71
|
+
}]);
|
|
72
|
+
}
|
|
45
73
|
if (typeof table?.delete !== "function") {
|
|
46
74
|
throw new CapabilityRefusalError("delete", [{
|
|
47
75
|
id: "projection.table-delete",
|
|
@@ -86,29 +114,46 @@ function acquireCacheGuard(cache) {
|
|
|
86
114
|
return serial(id, async () => {
|
|
87
115
|
if (isBlocked(id, expected))
|
|
88
116
|
return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
117
|
+
try {
|
|
118
|
+
await originals.put.call(cache, id, expected, detached);
|
|
119
|
+
if (isBlocked(id, expected)) {
|
|
120
|
+
const stored = table.get(id);
|
|
121
|
+
if (stored && sameLifecycle(stored.identity, expected))
|
|
122
|
+
await table.delete(id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// 形状变了才会是 TypeError(与本仓库探针同一条口径:受控 Error 不算适配失败)。
|
|
127
|
+
// 撤掉包装、绝不让宿主留在"半个包装器"状态下,并上报 —— 下一次删除会重装。
|
|
128
|
+
if (error instanceof TypeError)
|
|
129
|
+
retire("failed");
|
|
130
|
+
throw error;
|
|
94
131
|
}
|
|
95
132
|
});
|
|
96
133
|
},
|
|
97
134
|
write(session) {
|
|
98
135
|
// Track the flush await before put as well as put itself. Otherwise a
|
|
99
136
|
// delayed write could outlive disposal and bypass the restored method.
|
|
137
|
+
let task;
|
|
100
138
|
try {
|
|
101
|
-
|
|
139
|
+
task = track(Promise.resolve(originals.write.call(cache, session)));
|
|
102
140
|
}
|
|
103
141
|
catch (error) {
|
|
142
|
+
if (error instanceof TypeError)
|
|
143
|
+
retire("failed");
|
|
104
144
|
return Promise.reject(error);
|
|
105
145
|
}
|
|
146
|
+
return task.catch((error) => { if (error instanceof TypeError)
|
|
147
|
+
retire("failed"); throw error; });
|
|
106
148
|
},
|
|
107
149
|
};
|
|
108
150
|
cache.put = wrappers.put;
|
|
109
151
|
cache.write = wrappers.write;
|
|
110
|
-
|
|
152
|
+
const self = {
|
|
111
153
|
users: 1,
|
|
154
|
+
// 两个都得是我们的才算"在岗":只装着一个就等于另一半的写入不受墓碑约束(缓存行会复活)。
|
|
155
|
+
// 半个的情形由 acquire 的重建路径处理(覆盖掉那半个外来包装),并在退役时已上报过。
|
|
156
|
+
installed: () => cache.put === wrappers.put && cache.write === wrappers.write,
|
|
112
157
|
begin(id, header) { blocked.set(id, header ?? null); },
|
|
113
158
|
clearTombstone(id) { blocked.delete(id); },
|
|
114
159
|
async whenIdle() { while (pending.size)
|
|
@@ -128,26 +173,55 @@ function acquireCacheGuard(cache) {
|
|
|
128
173
|
await this.whenIdle();
|
|
129
174
|
if (this.users > 0)
|
|
130
175
|
return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Never overwrite a later third-party wrapper.
|
|
134
|
-
if (cache[name] !== wrappers[name]) {
|
|
135
|
-
restored = false;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (descriptors[name])
|
|
139
|
-
Object.defineProperty(cache, name, descriptors[name]);
|
|
140
|
-
else
|
|
141
|
-
delete cache[name];
|
|
142
|
-
}
|
|
143
|
-
if (restored) {
|
|
144
|
-
blocked.clear();
|
|
145
|
-
cacheGuards.delete(cache);
|
|
146
|
-
}
|
|
176
|
+
const clean = restoreWrappers();
|
|
177
|
+
retire(clean ? "idle" : "taken-over");
|
|
147
178
|
},
|
|
148
179
|
};
|
|
149
|
-
|
|
150
|
-
|
|
180
|
+
let retired = false;
|
|
181
|
+
/** 撤包装:第三方装了的话**不动它**(返回 false 表示没撤干净)。 */
|
|
182
|
+
function restoreWrappers() {
|
|
183
|
+
let clean = true;
|
|
184
|
+
for (const name of ["put", "write"]) {
|
|
185
|
+
// Never overwrite a later third-party wrapper.
|
|
186
|
+
if (cache[name] !== wrappers[name]) {
|
|
187
|
+
clean = false;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (descriptors[name])
|
|
191
|
+
Object.defineProperty(cache, name, descriptors[name]);
|
|
192
|
+
else
|
|
193
|
+
delete cache[name];
|
|
194
|
+
}
|
|
195
|
+
return clean;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 退役这个 guard:摘出 WeakMap(之后 acquire 会重建)、撤包装、清墓碑;非正常退役要上报。
|
|
199
|
+
*
|
|
200
|
+
* 三种退役理由对应三条真实路径:`idle` = 引用归零的正常释放(不上报);
|
|
201
|
+
* `taken-over` = 第三方在我们的包装器之上又包了一层(release 撤不干净,下次删除重建);
|
|
202
|
+
* `failed` = 首次使用抛 TypeError(宿主形状变了,已恢复原方法)。
|
|
203
|
+
*/
|
|
204
|
+
function retire(reason) {
|
|
205
|
+
if (retired)
|
|
206
|
+
return;
|
|
207
|
+
retired = true;
|
|
208
|
+
if (cacheGuards.get(cache) === self)
|
|
209
|
+
cacheGuards.delete(cache);
|
|
210
|
+
blocked.clear();
|
|
211
|
+
if (reason === "idle")
|
|
212
|
+
return;
|
|
213
|
+
noteRuntime({
|
|
214
|
+
id: "projection-cache-adapter",
|
|
215
|
+
label: "投影缓存删除屏障",
|
|
216
|
+
kind: "delete",
|
|
217
|
+
fallback: "inform-only",
|
|
218
|
+
detail: reason === "taken-over"
|
|
219
|
+
? "宿主缓存的 put/write 已被第三方包装接管:本插件的删除屏障这一轮已退役(缓存行删除少了墓碑拦截),下一次删除会重新安装。"
|
|
220
|
+
: "投影缓存适配在首次使用时抛错(宿主形状可能变了):已恢复宿主原方法,下一次删除会重新安装并再探一次。",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
cacheGuards.set(cache, self);
|
|
224
|
+
return self;
|
|
151
225
|
}
|
|
152
226
|
/**
|
|
153
227
|
* Live-session members the delete sequence needs, split by branch.
|
|
@@ -168,10 +242,12 @@ export function requiredSessionCapabilities(hasLiveSession) {
|
|
|
168
242
|
? ["sessions.detach-live"]
|
|
169
243
|
: ["sessions.cold-announce"];
|
|
170
244
|
}
|
|
171
|
-
export function
|
|
245
|
+
export function createSessionsBridge(ctx, suppliedRegistry, onArchive) {
|
|
172
246
|
// Cordis returns a fresh traceable method proxy on access. Compare and adapt
|
|
173
247
|
// the original object, not proxies; calls retain the service owner's context.
|
|
174
248
|
const registry = raw(suppliedRegistry);
|
|
249
|
+
// 官方声明把 put/requireTable 标为 private(运行时存在,见文件头「纪律边界」),
|
|
250
|
+
// 因此按本文件实际调用面收窄,不改动宿主对象本身。
|
|
175
251
|
const cache = raw(ctx.get("sessionProjectionCache"));
|
|
176
252
|
let guard;
|
|
177
253
|
let stopObserving;
|
|
@@ -216,7 +292,9 @@ export function createHistoryBridge(ctx, suppliedRegistry, onArchive) {
|
|
|
216
292
|
const method = (name, ...args) => {
|
|
217
293
|
if (typeof registry[name] !== "function")
|
|
218
294
|
throw new Error(`宿主缺少归档兼容接口 ${name},操作已停止`);
|
|
219
|
-
|
|
295
|
+
// 动态成员名:先收窄为可调用类型,再以 registry 为 receiver 调用,
|
|
296
|
+
// 与 registry[name](...args) 的 this 绑定完全一致。
|
|
297
|
+
return registry[name].call(registry, ...args);
|
|
220
298
|
};
|
|
221
299
|
/**
|
|
222
300
|
* The adapter route needs the registry's read/write/delegate members. This is
|
|
@@ -256,9 +334,11 @@ export function createHistoryBridge(ctx, suppliedRegistry, onArchive) {
|
|
|
256
334
|
// from the official sidebar or an external archive manager. No monkey
|
|
257
335
|
// patch of archiveSession and no competing writer of its ledger.
|
|
258
336
|
stopObserving = ctx.on("domain/changed", (change) => {
|
|
259
|
-
|
|
337
|
+
// 官方事件类型把 value 声明为 unknown(载荷形状由 workspace 域规范决定)。
|
|
338
|
+
const payload = change.value;
|
|
339
|
+
if (change?.domain !== workspaceDomainSpec.name || change.table !== "" || !Array.isArray(payload?.archivedSessionIds))
|
|
260
340
|
return;
|
|
261
|
-
const next = new Set(
|
|
341
|
+
const next = new Set(payload.archivedSessionIds);
|
|
262
342
|
const at = Date.now();
|
|
263
343
|
for (const id of next)
|
|
264
344
|
if (!previous.has(id))
|
|
@@ -291,7 +371,15 @@ export function createHistoryBridge(ctx, suppliedRegistry, onArchive) {
|
|
|
291
371
|
}]);
|
|
292
372
|
if (typeof cache.delete === "function" && typeof cache.whenIdle === "function")
|
|
293
373
|
return;
|
|
294
|
-
guard
|
|
374
|
+
// 本地这份 guard 可能已经退役(第三方接管 / 首次使用抛错):退役时已从 WeakMap 摘掉自己,
|
|
375
|
+
// 这里也得换掉它 —— 拿一个不在岗的屏障去挡墓碑,正是"缓存行静默复活"那条路。
|
|
376
|
+
const next = acquireCacheGuard(cache);
|
|
377
|
+
if (next !== guard) {
|
|
378
|
+
const previous = guard;
|
|
379
|
+
guard = next;
|
|
380
|
+
if (previous)
|
|
381
|
+
await previous.release().catch(() => undefined);
|
|
382
|
+
}
|
|
295
383
|
guard.begin(id, header);
|
|
296
384
|
},
|
|
297
385
|
cache() { return guard ?? cache; },
|