dsh-plugin-tool-management 0.5.1 → 0.7.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 +131 -0
- package/README.md +185 -238
- package/README_EN.md +185 -304
- package/cordis.patch.yml +9 -55
- 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 +55 -0
- package/lib/agents-md/preset-id.js +49 -0
- package/lib/agents-md/service.js +180 -57
- package/lib/client.js +4710 -3560
- package/lib/compat/preset-reach.js +425 -0
- 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 +94 -0
- package/lib/hub.js +160 -2
- package/lib/index.js +759 -157
- package/lib/mcp/override-blocks.js +195 -0
- package/lib/rules/provider.js +3 -3
- package/lib/rules/service.js +342 -31
- package/lib/scene-prompt-sync.js +112 -0
- package/lib/skills/core.js +123 -35
- package/lib/skills/service.js +9 -1
- package/lib/subagents/service.js +197 -23
- package/lib/subagents/tools.js +8 -2
- package/package.json +6 -3
- package/screenshots.json +10 -9
- package/docs/Changelog.md +0 -517
- 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
package/lib/index.js
CHANGED
|
@@ -12,13 +12,20 @@
|
|
|
12
12
|
// artifact — same convention as DSH's own packages, which ship compiled JS).
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { ArchiveWorkspaceRegistry as HistoryService, workspaceBaseName, workspacePathKey } from './history/workspace.js';
|
|
15
17
|
import { createSkillsService, pluginLog } from './skills/service.js';
|
|
16
18
|
import { createAgentsMdService } from './agents-md/service.js';
|
|
17
19
|
import { createRulesService } from './rules/service.js';
|
|
18
20
|
import { createArchiveEngine } from './rules/archive-engine.js';
|
|
19
|
-
import { createSubagentService } from './subagents/service.js';
|
|
21
|
+
import { createSubagentService, decideToolFilter } from './subagents/service.js';
|
|
20
22
|
import { isApprovalNever } from './approval-policy.js';
|
|
21
|
-
import {
|
|
23
|
+
import { EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
|
|
24
|
+
import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
|
|
25
|
+
import { fenceRejection, secretOpRejection } from './http-fence.js';
|
|
26
|
+
import { planOverrideCompaction } from './mcp/override-blocks.js';
|
|
27
|
+
import { hubPath, hubRoot, relocateEntries } from './hub.js';
|
|
28
|
+
import { createScenePromptSync } from './scene-prompt-sync.js';
|
|
22
29
|
import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
|
|
23
30
|
import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
|
|
24
31
|
import { homedir } from 'node:os';
|
|
@@ -58,15 +65,19 @@ function serializeTurns(turns, format) {
|
|
|
58
65
|
}
|
|
59
66
|
export default {
|
|
60
67
|
name: 'dsh-plugin-tool-management-host',
|
|
61
|
-
inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions'],
|
|
68
|
+
inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions', 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence'],
|
|
62
69
|
apply(ctx, config) {
|
|
63
70
|
const fs = ctx.fs;
|
|
64
71
|
const settings = ctx.settings;
|
|
65
72
|
const sandboxPolicy = ctx.sandboxPolicy;
|
|
66
73
|
const webServer = ctx.webServer;
|
|
67
74
|
const tools = ctx.tools;
|
|
75
|
+
const agents = ctx.get('agents');
|
|
68
76
|
// pluginInventory is optional: probe at use time, degrade to no live info.
|
|
69
77
|
const pluginInventory = ctx.get('pluginInventory');
|
|
78
|
+
// Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
|
|
79
|
+
// 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
|
|
80
|
+
const presetRoster = () => presetRosterOf(ctx);
|
|
70
81
|
// Package version, surfaced in the Settings pages and the HTTP API. Read
|
|
71
82
|
// from the installed package.json so it always matches the release tag.
|
|
72
83
|
let PKG_VERSION = 'unknown';
|
|
@@ -74,12 +85,17 @@ export default {
|
|
|
74
85
|
PKG_VERSION = createRequire(import.meta.url)('../package.json').version || 'unknown';
|
|
75
86
|
}
|
|
76
87
|
catch (e) { /* keep unknown */ }
|
|
77
|
-
// Optional access token
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
88
|
+
// Optional access token. Enabled by setting `config.token` on this plugin's
|
|
89
|
+
// loader row (profile cordis.patch.yml override) or the
|
|
90
|
+
// DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN env var. Two roles now:
|
|
91
|
+
// 1. escape hatch — a correct token is accepted *in place of* the host's
|
|
92
|
+
// browser authentication, so curl/scripts and LAN tooling keep working;
|
|
93
|
+
// 2. defense in depth — when set, every state-changing op additionally
|
|
94
|
+
// requires `x-dsh-token: <token>`.
|
|
95
|
+
// It is NOT the primary gate anymore: the route calls the host's
|
|
96
|
+
// connection.requestRejection fence first (Host must be loopback/LAN
|
|
97
|
+
// IP-literal → defeats DNS rebinding, plus browser-session cookie auth),
|
|
98
|
+
// so an unset token no longer means "anyone may call writes".
|
|
83
99
|
// NOTE: the entry config arrives as the SECOND apply argument (Cordis
|
|
84
100
|
// calls `callback(ctx, config)`) — never read it off `ctx.config`, which
|
|
85
101
|
// is not an injected service and throws "cannot get property without
|
|
@@ -204,17 +220,82 @@ export default {
|
|
|
204
220
|
}
|
|
205
221
|
return states;
|
|
206
222
|
}
|
|
207
|
-
|
|
223
|
+
/**
|
|
224
|
+
* 一次扫描同时给出三样东西(避免为了三份视图各扫一遍磁盘):
|
|
225
|
+
* - `states`:**真实生效**的技能 `<rootKey>/<name>` → boolean;
|
|
226
|
+
* - `shadowed`:被同名技能覆盖的键集合(`sk.shadowedBy` 存在);
|
|
227
|
+
* - `all`:全部键 → boolean(被覆盖的副本记 `false`,但键保留)。
|
|
228
|
+
*
|
|
229
|
+
* 为什么要区分:`sk.enabled` 只有**同名竞争的胜者**才有值,被覆盖的副本恒为 `undefined`。
|
|
230
|
+
* 旧实现用 `sk.enabled !== false` 取值,把副本误判成「已启用」,于是场景模式退出回放快照时
|
|
231
|
+
* 会凭空往 `enabledSkills` 写条目;反过来把副本当成「已停用」又会在进入模式时把用户
|
|
232
|
+
* 显式启用的记录改写成显式停用。两种都会在「同名竞争消失 / 来源重新打开」后改变实际行为。
|
|
233
|
+
*/
|
|
234
|
+
async function skillScan() {
|
|
208
235
|
const r = await skillsService.ops['skill-state']({});
|
|
209
236
|
const states = {};
|
|
237
|
+
const all = {};
|
|
238
|
+
const shadowed = new Set();
|
|
210
239
|
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
211
240
|
for (const sk of (root.skills || [])) {
|
|
212
241
|
const name = String(sk.declaredName || sk.name || '');
|
|
213
|
-
if (name)
|
|
214
|
-
|
|
242
|
+
if (!name)
|
|
243
|
+
continue;
|
|
244
|
+
const key = String(root.key || '') + '/' + name;
|
|
245
|
+
if (sk.shadowedBy) {
|
|
246
|
+
shadowed.add(key);
|
|
247
|
+
all[key] = false;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const on = sk.enabled === true;
|
|
251
|
+
states[key] = on;
|
|
252
|
+
all[key] = on;
|
|
215
253
|
}
|
|
216
254
|
}
|
|
217
|
-
return states;
|
|
255
|
+
return { states, shadowed, all };
|
|
256
|
+
}
|
|
257
|
+
async function skillStates() {
|
|
258
|
+
// knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
|
|
259
|
+
// 用生效集去校验会把合法勾选判成 stale 丢掉。
|
|
260
|
+
return (await skillScan()).all;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 场景档案「技能集」的技能行 —— 技能名与来源名**分开**给出。
|
|
264
|
+
*
|
|
265
|
+
* 界面早先直接渲染 `key`(形如 `<来源 key>/<技能名>`),而自定义目录的来源 key 是
|
|
266
|
+
* `custom-<sha256-16>`,于是导入的技能在场景档案里显示成一串哈希(用户 2026-09-15 报的)。
|
|
267
|
+
* 这里把来源的 label / localeKey 一并带上,界面就能显示「技能名 · 来源名」。
|
|
268
|
+
*
|
|
269
|
+
* 含被覆盖的副本(`shadowed`):场景允许勾选任意一条技能,用生效集校验会把合法勾选判成 stale。
|
|
270
|
+
*/
|
|
271
|
+
async function skillRows() {
|
|
272
|
+
const rows = [];
|
|
273
|
+
try {
|
|
274
|
+
const r = await skillsService.ops['skill-state']({});
|
|
275
|
+
for (const root of ((r && r.data && r.data.roots) || [])) {
|
|
276
|
+
const rootKey = String((root && root.key) || '');
|
|
277
|
+
if (!rootKey)
|
|
278
|
+
continue;
|
|
279
|
+
for (const sk of ((root && root.skills) || [])) {
|
|
280
|
+
const name = String((sk && (sk.declaredName || sk.name)) || '');
|
|
281
|
+
if (!name)
|
|
282
|
+
continue;
|
|
283
|
+
rows.push({
|
|
284
|
+
key: rootKey + '/' + name,
|
|
285
|
+
name,
|
|
286
|
+
enabled: sk.enabled === true && !sk.shadowedBy,
|
|
287
|
+
shadowed: !!sk.shadowedBy,
|
|
288
|
+
rootKey,
|
|
289
|
+
rootLabel: String((root && root.label) || rootKey),
|
|
290
|
+
...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
|
|
291
|
+
...(root && root.kind ? { rootKind: String(root.kind) } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch { /* 技能状态读不到 → 空列表(界面如实显示"没有可选项") */ }
|
|
297
|
+
// 平铺列表按技能名排(同名再按来源),比按来源聚在一起更好找。
|
|
298
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name) || a.rootKey.localeCompare(b.rootKey));
|
|
218
299
|
}
|
|
219
300
|
const archiveService = createArchiveEngine({
|
|
220
301
|
loadSlice: async () => ({ ...(await rulesService.readArchiveSlice()) }),
|
|
@@ -259,12 +340,24 @@ export default {
|
|
|
259
340
|
scheduleToolRestrictions();
|
|
260
341
|
},
|
|
261
342
|
knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
|
|
262
|
-
|
|
343
|
+
// 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
|
|
344
|
+
// 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
|
|
345
|
+
currentSkills: async () => (await skillScan()).states,
|
|
263
346
|
applySkills: async (target) => {
|
|
347
|
+
// 两道过滤,缺一不可:
|
|
348
|
+
// 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
|
|
349
|
+
// 2. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
|
|
350
|
+
// 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
|
|
351
|
+
// 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
|
|
352
|
+
const { states, shadowed } = await skillScan();
|
|
264
353
|
for (const [key, on] of Object.entries(target)) {
|
|
265
354
|
const i = key.indexOf('/');
|
|
266
355
|
if (i <= 0)
|
|
267
356
|
throw new Error(`技能 key 不合法: ${key}`);
|
|
357
|
+
if (shadowed.has(key))
|
|
358
|
+
continue;
|
|
359
|
+
if (states[key] === on)
|
|
360
|
+
continue;
|
|
268
361
|
const root = key.slice(0, i);
|
|
269
362
|
const name = key.slice(i + 1);
|
|
270
363
|
const r = await skillsService.ops[on ? 'skill-enable' : 'skill-disable']({ root, name });
|
|
@@ -307,6 +400,7 @@ export default {
|
|
|
307
400
|
...archiveService.writeOps,
|
|
308
401
|
...subagentService.writeOps,
|
|
309
402
|
'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
|
|
403
|
+
'mcpm-compact',
|
|
310
404
|
'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
|
|
311
405
|
// mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
|
|
312
406
|
// token-gated like a write — on a LAN-exposed port the token must be the
|
|
@@ -315,29 +409,116 @@ export default {
|
|
|
315
409
|
'skill-open',
|
|
316
410
|
// agents-md 写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
|
|
317
411
|
'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
|
|
318
|
-
//
|
|
412
|
+
// 提示词预设的回收站(恢复 / 永久删除都是写)
|
|
413
|
+
'agentsmd-trash-restore', 'agentsmd-trash-delete',
|
|
414
|
+
// history 写操作(archive/unarchive 改归档集合;delete 永久删除;retention-set 写保留期;
|
|
415
|
+
// workspace-register 会新增一条宿主工作区登记,同样是写)
|
|
319
416
|
'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
|
|
320
417
|
'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
|
|
321
|
-
'history-archive-batch',
|
|
418
|
+
'history-archive-batch', 'history-workspace-register',
|
|
322
419
|
]);
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
420
|
+
// 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
|
|
421
|
+
// (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
|
|
422
|
+
const SENSITIVE_OPS = new Set(['mcpm-reveal', 'mcpm-export']);
|
|
423
|
+
// 兼容体检的日志去重:同一宿主版本只写一条 compat/probe,避免轮询刷屏。
|
|
424
|
+
let compatLoggedFor;
|
|
425
|
+
// ---------- independent history facade ----------
|
|
426
|
+
// Keep the active workspaceRegistry/sessionProjectionCache instances intact.
|
|
427
|
+
// Native archive extensions are delegated by capability, not package name.
|
|
428
|
+
// Official gaps use a checked adapter (history/bridge.js); no service or
|
|
429
|
+
// storage-domain replacement. Retention uses the existing sidecar ledger.
|
|
430
|
+
// 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
|
|
431
|
+
// (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
|
|
432
|
+
// `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
|
|
433
|
+
// 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把 agents-md 预设
|
|
434
|
+
// 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
|
|
435
|
+
// 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
|
|
436
|
+
const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
|
|
437
|
+
const historyWorkspaceSnapshotFile = String(config?.workspaceSnapshotFile || hubPath('history-workspaces.json'));
|
|
438
|
+
const historyRetentionPath = String(config?.historyRetentionPath || hubPath('history-retention.json'));
|
|
439
|
+
const historySidecarFiles = new Set(['history-archived-at.json', 'history-retention.json', 'history-workspaces.json']);
|
|
440
|
+
const historyStandby = relocateEntries(join(PLUGIN_ROOT, 'data'), hubRoot(), (name) => historySidecarFiles.has(name))
|
|
441
|
+
.catch(() => 0);
|
|
334
442
|
const sweepIntervalMs = Number(config?.sweepIntervalMs || 0) || 6 * 60 * 60 * 1000;
|
|
443
|
+
let history;
|
|
444
|
+
ctx.effect(() => {
|
|
445
|
+
const registry = ctx.get('workspaceRegistry');
|
|
446
|
+
if (registry && typeof registry.archiveSession === 'function') {
|
|
447
|
+
history = new HistoryService(ctx, registry, {
|
|
448
|
+
archivedAtFile: historyArchivedAtFile,
|
|
449
|
+
workspaceSnapshotFile: historyWorkspaceSnapshotFile,
|
|
450
|
+
ready: historyStandby,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return async () => { const current = history; history = undefined; await current?.dispose(); };
|
|
454
|
+
}, 'dsh-plugin-tool-management: independent history');
|
|
335
455
|
function getHistoryRegistry() {
|
|
336
|
-
|
|
337
|
-
|
|
456
|
+
return history;
|
|
457
|
+
}
|
|
458
|
+
// ── 场景提示词 → 全局基线(AGENTS.md)同步 ────────────────────────────────
|
|
459
|
+
//
|
|
460
|
+
// 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
|
|
461
|
+
// 实现收在 ./scene-prompt-sync.ts(可在临时目录上端到端验证):启用/切换场景、
|
|
462
|
+
// 关掉场景、改绑定、编辑"驱动基线的那份预设"四个动作都会同步;关掉场景时按
|
|
463
|
+
// 进场景前的基线快照(`scene-baseline.json`,hub 内)原文写回。
|
|
464
|
+
const scenePromptSync = createScenePromptSync({
|
|
465
|
+
agentsMd: agentsMdService,
|
|
466
|
+
rules: rulesService,
|
|
467
|
+
baselineFile: hubPath('scene-baseline.json'),
|
|
468
|
+
logger: ctx.logger,
|
|
469
|
+
});
|
|
470
|
+
/**
|
|
471
|
+
* 把同步结果并进 op 响应:只在原操作成功时同步,失败原样透传。
|
|
472
|
+
* 同步失败**不改**原操作的成功结论(场景确实切了),而是把原因放进 `agentsMd.error`,
|
|
473
|
+
* 由界面如实显示成警告 —— 绝不假装文件已经改好。
|
|
474
|
+
*/
|
|
475
|
+
const withAgentsMdSync = (res) => scenePromptSync.withSync(res);
|
|
476
|
+
/**
|
|
477
|
+
* 恢复归档后的**归属恢复**(best-effort,绝不阻断恢复本身):按会话 cwd 找到或重建
|
|
478
|
+
* 工作区登记,并把会话挂回该登记的 `sessionIds`。恢复成功但归属恢复失败时返回 undefined,
|
|
479
|
+
* 由客户端按"会话已恢复、工作区归属未恢复"如实呈现。
|
|
480
|
+
*/
|
|
481
|
+
async function restoreWorkspaceAccounting(sessionIds) {
|
|
482
|
+
const registry = getHistoryRegistry();
|
|
483
|
+
if (!registry || typeof registry.ensureWorkspaceAccounting !== 'function' || !sessionIds.length)
|
|
484
|
+
return undefined;
|
|
485
|
+
try {
|
|
486
|
+
return await registry.ensureWorkspaceAccounting(sessionIds);
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
ctx.logger?.warn?.(`history: workspace accounting after restore failed: ${message(e)}`);
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* **当前生效的提示词预设 id**(界面「生效中」的那一份),供写操作做门禁:
|
|
495
|
+
* ① 启用的场景绑定了它 → 就是它(场景优先级最高,与 agentsmd-list 的标记同一口径);
|
|
496
|
+
* ② 否则回退到文件比对(`~/.dsh/AGENTS.md` 内容 == 某份预设)。
|
|
497
|
+
* 探测失败返回 `null`(调用方决定放行还是拒绝;当前只在删除上使用,放行 + warn)。
|
|
498
|
+
*/
|
|
499
|
+
async function effectivePresetId() {
|
|
500
|
+
try {
|
|
501
|
+
const r = await rulesService.ops['rules-list']({});
|
|
502
|
+
const sp = r && r.ok ? r.scenePrompt : null;
|
|
503
|
+
if (sp && sp.scene && !sp.missing && sp.presetId)
|
|
504
|
+
return String(sp.presetId);
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
ctx.logger?.warn?.(`agents-md: scene prompt probe failed: ${message(e)}`);
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
const cur = await agentsMdService.getCurrent();
|
|
511
|
+
if (cur && cur.ok && cur.presetId)
|
|
512
|
+
return String(cur.presetId);
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
ctx.logger?.warn?.(`agents-md: global baseline probe failed: ${message(e)}`);
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
338
518
|
}
|
|
339
519
|
async function readHistoryRetention() {
|
|
340
520
|
try {
|
|
521
|
+
await historyStandby;
|
|
341
522
|
const raw = await readFile(historyRetentionPath, 'utf8');
|
|
342
523
|
const obj = JSON.parse(raw);
|
|
343
524
|
const days = Number((obj && obj.retentionDays) ?? 0);
|
|
@@ -352,6 +533,7 @@ export default {
|
|
|
352
533
|
}
|
|
353
534
|
}
|
|
354
535
|
async function writeHistoryRetention(retentionDays) {
|
|
536
|
+
await historyStandby;
|
|
355
537
|
const dir = dirname(historyRetentionPath);
|
|
356
538
|
await mkdir(dir, { recursive: true });
|
|
357
539
|
// updatedAt = 修改时刻:每次改保留期,已归档会话的到期基线重置为此时刻。
|
|
@@ -1669,7 +1851,13 @@ export default {
|
|
|
1669
1851
|
}
|
|
1670
1852
|
else {
|
|
1671
1853
|
c = removeMarked(c, id, 'enable');
|
|
1672
|
-
|
|
1854
|
+
// 幂等:已经生效为「停用」时不再追加 disable 块。无条件 append 会让每次点
|
|
1855
|
+
// 「停用」都往 patch 里塞一条重复条目(历史上 18 条互相矛盾的条目就是这么来的,
|
|
1856
|
+
// 最终生效值只能靠 last-wins 合并顺序猜),且文件会随每次启停线性膨胀。
|
|
1857
|
+
const { rows } = parseRows(c);
|
|
1858
|
+
const row = rows.find((r) => r.id === id);
|
|
1859
|
+
if (!row || !row.disabled)
|
|
1860
|
+
c = appendBlock(c, buildDisableBlock(id, true));
|
|
1673
1861
|
}
|
|
1674
1862
|
await writePatch(abs, c);
|
|
1675
1863
|
return { ok: true };
|
|
@@ -1800,6 +1988,55 @@ export default {
|
|
|
1800
1988
|
return { ok: true };
|
|
1801
1989
|
});
|
|
1802
1990
|
}
|
|
1991
|
+
/**
|
|
1992
|
+
* 收敛补丁文件里的启停覆盖块(用户显式点「整理补丁」才走这里)。
|
|
1993
|
+
*
|
|
1994
|
+
* 只删"删掉也不改变生效状态"的块(判定见 planOverrideCompaction):insert 行、
|
|
1995
|
+
* 带 config 的覆盖块、以及决定当前生效值的那一条都原样保留。删除前 writePatch
|
|
1996
|
+
* 会自动备份上一版(保留最近 5 份),所以这一步是可回退的。
|
|
1997
|
+
*
|
|
1998
|
+
* 基准状态由 planOverrideCompaction 自己从 insert 行读(另一份文件的内容只用来补
|
|
1999
|
+
* "insert 行不在本文件里"的情况)。这里**不要**喂 parseRows() 的 rows —— 那是合并
|
|
2000
|
+
* 覆盖块之后的生效值,会让每个 decider 都被判成多余:第一版就是这么把 6 台 MCP
|
|
2001
|
+
* 全部启用的。同理,界面不显示任何"多余块数"(那个数字也来自同一条错误口径)。
|
|
2002
|
+
*/
|
|
2003
|
+
async function mcpmCompact() {
|
|
2004
|
+
const p = await ensurePaths();
|
|
2005
|
+
return withWriteLock(async () => {
|
|
2006
|
+
const levels = ['project', 'global'];
|
|
2007
|
+
const absOf = (level) => (level === 'project' ? p.projectPatch : p.globalPatch);
|
|
2008
|
+
const contents = {};
|
|
2009
|
+
for (const level of levels) {
|
|
2010
|
+
try {
|
|
2011
|
+
contents[level] = await readPatch(absOf(level));
|
|
2012
|
+
}
|
|
2013
|
+
catch (e) {
|
|
2014
|
+
contents[level] = '';
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
const removed = {};
|
|
2018
|
+
const files = [];
|
|
2019
|
+
let total = 0;
|
|
2020
|
+
for (const level of levels) {
|
|
2021
|
+
const content = contents[level];
|
|
2022
|
+
if (!content)
|
|
2023
|
+
continue;
|
|
2024
|
+
const others = levels.filter((other) => other !== level).map((other) => contents[other]).filter(Boolean);
|
|
2025
|
+
const plan = planOverrideCompaction(content, others);
|
|
2026
|
+
if (!plan.ranges.length)
|
|
2027
|
+
continue;
|
|
2028
|
+
await writePatch(absOf(level), spliceRanges(splitLines(content), plan.ranges));
|
|
2029
|
+
files.push(absOf(level));
|
|
2030
|
+
for (const [id, stat] of Object.entries(plan.ids)) {
|
|
2031
|
+
if (!stat.dropped)
|
|
2032
|
+
continue;
|
|
2033
|
+
removed[id] = (removed[id] || 0) + stat.dropped;
|
|
2034
|
+
total += stat.dropped;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return { ok: true, removed: total, ids: removed, files };
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
1803
2040
|
async function mcpmExport() {
|
|
1804
2041
|
const p = await ensurePaths();
|
|
1805
2042
|
const list = await mcpmList();
|
|
@@ -1979,6 +2216,109 @@ export default {
|
|
|
1979
2216
|
},
|
|
1980
2217
|
};
|
|
1981
2218
|
}
|
|
2219
|
+
/**
|
|
2220
|
+
* 归档会话的归属解析(三级,宿主侧算好再给客户端):
|
|
2221
|
+
* 1. 活登记的 sessionIds 命中 → 组 = 该登记;
|
|
2222
|
+
* 2. 未命中但规范路径命中某活登记 → 组 = 该登记(会话不在其记账里,属正常);
|
|
2223
|
+
* 3. 没有任何活登记 → 按会话目录重建分组(`path:<归一化路径>`),
|
|
2224
|
+
* 目录仍在则可一键重新登记(写回宿主),目录已不在则退到 header 里的原始 cwd。
|
|
2225
|
+
* 宿主删除工作区是硬删、不留墓碑,登记一没,`workspaceId` 就再也查不到;但会话目录
|
|
2226
|
+
* (`registry.sessionPaths` / header.cwd)还在,所以归属可以按目录重建、并在目录回来时
|
|
2227
|
+
* 自动并回同一组。除插件自己 `data/` 下的「见过的登记」快照外,本函数不写任何宿主状态。
|
|
2228
|
+
* 任何一步失败都降级为「不分组」,绝不阻断列表。
|
|
2229
|
+
*/
|
|
2230
|
+
async function buildHistoryGroups(registry, items) {
|
|
2231
|
+
const groups = [];
|
|
2232
|
+
const workspaces = {};
|
|
2233
|
+
try {
|
|
2234
|
+
const groupsById = new Map();
|
|
2235
|
+
const liveByPath = new Map();
|
|
2236
|
+
const owned = new Map();
|
|
2237
|
+
const liveRecords = [];
|
|
2238
|
+
const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
|
|
2239
|
+
const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
|
|
2240
|
+
if (table) {
|
|
2241
|
+
const ids = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
|
|
2242
|
+
? state.workspaceIds
|
|
2243
|
+
: [...table.entries()].map(([id]) => id);
|
|
2244
|
+
for (const wid of ids) {
|
|
2245
|
+
const rec = table.get(wid);
|
|
2246
|
+
if (!rec || !Array.isArray(rec.sessionIds))
|
|
2247
|
+
continue;
|
|
2248
|
+
const path = typeof rec.path === 'string' && rec.path ? rec.path : undefined;
|
|
2249
|
+
const title = (rec.title && String(rec.title)) || (path ? workspaceBaseName(path) : String(wid));
|
|
2250
|
+
for (const sid of rec.sessionIds)
|
|
2251
|
+
if (!owned.has(sid))
|
|
2252
|
+
owned.set(sid, wid);
|
|
2253
|
+
if (path) {
|
|
2254
|
+
liveByPath.set(workspacePathKey(path), wid);
|
|
2255
|
+
liveRecords.push({ id: String(wid), path, title });
|
|
2256
|
+
}
|
|
2257
|
+
const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
|
|
2258
|
+
groupsById.set(view.id, view);
|
|
2259
|
+
groups.push(view);
|
|
2260
|
+
workspaces[wid] = { title, ...(path ? { path } : {}) };
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
// 「见过的登记」快照:登记被删后仍能给出原命名,并区分「已移除」与「从未登记」。
|
|
2264
|
+
const snapshot = typeof registry.workspaceSnapshot === 'function' ? await registry.workspaceSnapshot() : undefined;
|
|
2265
|
+
if (liveRecords.length && typeof registry.rememberWorkspaces === 'function') {
|
|
2266
|
+
try {
|
|
2267
|
+
await registry.rememberWorkspaces(liveRecords);
|
|
2268
|
+
}
|
|
2269
|
+
catch { /* 快照写失败不影响分组 */ }
|
|
2270
|
+
}
|
|
2271
|
+
const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
|
|
2272
|
+
const headers = registry.headers instanceof Map ? registry.headers : undefined;
|
|
2273
|
+
for (const it of items) {
|
|
2274
|
+
const sid = it.sessionId;
|
|
2275
|
+
let wid = owned.get(sid);
|
|
2276
|
+
const canonical = sessionPaths ? sessionPaths.get(sid) : undefined;
|
|
2277
|
+
if (wid === undefined && typeof canonical === 'string' && canonical) {
|
|
2278
|
+
const byPath = liveByPath.get(workspacePathKey(canonical));
|
|
2279
|
+
if (byPath !== undefined)
|
|
2280
|
+
wid = byPath;
|
|
2281
|
+
}
|
|
2282
|
+
if (wid !== undefined) {
|
|
2283
|
+
it.workspaceId = wid;
|
|
2284
|
+
it.groupId = 'ws:' + wid;
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
let path = typeof canonical === 'string' && canonical ? canonical : undefined;
|
|
2288
|
+
if (path === undefined && it.cwd)
|
|
2289
|
+
path = it.cwd;
|
|
2290
|
+
if (path === undefined && headers) {
|
|
2291
|
+
const cwd = headers.get(sid)?.cwd;
|
|
2292
|
+
if (typeof cwd === 'string' && cwd)
|
|
2293
|
+
path = cwd;
|
|
2294
|
+
}
|
|
2295
|
+
if (!path)
|
|
2296
|
+
continue;
|
|
2297
|
+
const key = 'path:' + workspacePathKey(path);
|
|
2298
|
+
it.groupId = key;
|
|
2299
|
+
const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
|
|
2300
|
+
const existing = groupsById.get(key);
|
|
2301
|
+
if (existing) {
|
|
2302
|
+
// 同目录的多个会话:目录存在(sessionPaths 命中)即可重新登记。
|
|
2303
|
+
if (canonical !== undefined)
|
|
2304
|
+
existing.canRegister = true;
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
const view = {
|
|
2308
|
+
id: key,
|
|
2309
|
+
title: known?.title || workspaceBaseName(path) || path,
|
|
2310
|
+
kind: 'detached',
|
|
2311
|
+
registered: known !== undefined,
|
|
2312
|
+
canRegister: canonical !== undefined,
|
|
2313
|
+
path: known?.path || path,
|
|
2314
|
+
};
|
|
2315
|
+
groupsById.set(key, view);
|
|
2316
|
+
groups.push(view);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
catch { /* 分组是增强:任何异常都退回扁平列表 */ }
|
|
2320
|
+
return { groups, workspaces };
|
|
2321
|
+
}
|
|
1982
2322
|
/**
|
|
1983
2323
|
* 记忆候选(档案编辑器第 4 段「记忆」):`[{ id, scene, name, description }]`。
|
|
1984
2324
|
* 只读、不读正文(勾选集只存 id);任何异常降级为空列表,不让档案弹窗崩掉。
|
|
@@ -2045,32 +2385,24 @@ export default {
|
|
|
2045
2385
|
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2046
2386
|
if (agentPresets && typeof agentPresets.list === 'function') {
|
|
2047
2387
|
try {
|
|
2388
|
+
// roster 顺序 = 官方的展示顺序(preset.yml 的 order:标准 1 / PTC 2 / 极简 3 / 创造 4,
|
|
2389
|
+
// 自建预设排在其后)。人设编辑器的四行照抄这个顺序,**不要**再按 id 排序。
|
|
2048
2390
|
const roster = await agentPresets.list();
|
|
2049
2391
|
for (const p of roster || []) {
|
|
2050
2392
|
const id = String((p && p.id) || '');
|
|
2051
2393
|
if (!id)
|
|
2052
2394
|
continue;
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
}
|
|
2063
|
-
let names = [];
|
|
2064
|
-
try {
|
|
2065
|
-
names = (await tools.schemas(scopeKey) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2066
|
-
}
|
|
2067
|
-
catch {
|
|
2068
|
-
names = [];
|
|
2069
|
-
}
|
|
2395
|
+
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
2396
|
+
// 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
|
|
2397
|
+
const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
|
|
2398
|
+
presets.push({
|
|
2399
|
+
id,
|
|
2400
|
+
name: String((p && (p.name || p.id)) || id),
|
|
2401
|
+
trust: String((p && p.trust) || 'user'),
|
|
2402
|
+
broken: typeof (p && p.broken) === 'string',
|
|
2403
|
+
tools: names,
|
|
2404
|
+
});
|
|
2070
2405
|
for (const name of names) {
|
|
2071
|
-
// MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
|
|
2072
|
-
if (name.startsWith('mcp__'))
|
|
2073
|
-
continue;
|
|
2074
2406
|
const rec = byName.get(name) || { name, presets: new Set(), current: false };
|
|
2075
2407
|
rec.presets.add(id);
|
|
2076
2408
|
byName.set(name, rec);
|
|
@@ -2092,11 +2424,94 @@ export default {
|
|
|
2092
2424
|
tools: [...byName.values()]
|
|
2093
2425
|
.map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
|
|
2094
2426
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
2095
|
-
|
|
2427
|
+
// 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
|
|
2428
|
+
presets,
|
|
2096
2429
|
};
|
|
2097
2430
|
presetToolsCache = { at: Date.now(), value };
|
|
2098
2431
|
return value;
|
|
2099
2432
|
}
|
|
2433
|
+
/**
|
|
2434
|
+
* 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
|
|
2435
|
+
* "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
|
|
2436
|
+
* 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
|
|
2437
|
+
* `subagent_run` 每次委派都要用它校验名单,不能每次都重新枚举。
|
|
2438
|
+
*/
|
|
2439
|
+
const presetNamesCache = new Map();
|
|
2440
|
+
async function presetToolNames(id) {
|
|
2441
|
+
const hit = presetNamesCache.get(id);
|
|
2442
|
+
if (hit && Date.now() - hit.at < 60000)
|
|
2443
|
+
return hit.names;
|
|
2444
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2445
|
+
let scopeKey;
|
|
2446
|
+
try {
|
|
2447
|
+
scopeKey = agentPresets && typeof agentPresets.standingKeyFor === 'function'
|
|
2448
|
+
? await agentPresets.standingKeyFor(id)
|
|
2449
|
+
: undefined;
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
scopeKey = undefined;
|
|
2453
|
+
}
|
|
2454
|
+
let names = [];
|
|
2455
|
+
try {
|
|
2456
|
+
names = ((await tools.schemas(scopeKey)) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2457
|
+
}
|
|
2458
|
+
catch {
|
|
2459
|
+
names = [];
|
|
2460
|
+
}
|
|
2461
|
+
presetNamesCache.set(id, { at: Date.now(), names });
|
|
2462
|
+
return names;
|
|
2463
|
+
}
|
|
2464
|
+
/** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
|
|
2465
|
+
async function presetNames() {
|
|
2466
|
+
const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
|
|
2467
|
+
if (!agentPresets || typeof agentPresets.list !== 'function')
|
|
2468
|
+
return [];
|
|
2469
|
+
try {
|
|
2470
|
+
const roster = await agentPresets.list();
|
|
2471
|
+
return (roster || [])
|
|
2472
|
+
.map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || 'user') }))
|
|
2473
|
+
.filter((p) => p.id !== '');
|
|
2474
|
+
}
|
|
2475
|
+
catch {
|
|
2476
|
+
return [];
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
/** 调用方 agent 跑在哪个 Agent 预设上;判断不了返回 null(绝不猜)。 */
|
|
2480
|
+
function currentPresetId(agentCtx) {
|
|
2481
|
+
if (agentCtx === undefined || agentCtx === null)
|
|
2482
|
+
return null;
|
|
2483
|
+
try {
|
|
2484
|
+
const roster = presetRoster();
|
|
2485
|
+
if (roster === undefined || typeof roster.composedPreset !== 'function')
|
|
2486
|
+
return null;
|
|
2487
|
+
const id = String(roster.composedPreset(agentCtx) ?? '');
|
|
2488
|
+
return id === '' ? null : id;
|
|
2489
|
+
}
|
|
2490
|
+
catch {
|
|
2491
|
+
return null;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* 人设的工具限制:按**当前会话的预设**决定这次委派下发什么(规则见 decideToolFilter)。
|
|
2496
|
+
* 已知工具名 = 宿主平面(含 MCP)∪ 当前预设的工具;枚举失败时退化成宿主平面,
|
|
2497
|
+
* 于是模式名单里的预设侧工具会被如实记为"当前不存在",而不是造成官方 restrict 抛错。
|
|
2498
|
+
*/
|
|
2499
|
+
async function subagentToolFilterFor(persona, agentCtx) {
|
|
2500
|
+
let hostNames = [];
|
|
2501
|
+
try {
|
|
2502
|
+
hostNames = ((await tools.schemas()) || []).map((s) => String(s.name)).filter(Boolean);
|
|
2503
|
+
}
|
|
2504
|
+
catch {
|
|
2505
|
+
hostNames = [];
|
|
2506
|
+
}
|
|
2507
|
+
const mcp = hostNames.filter((name) => name.startsWith('mcp__'));
|
|
2508
|
+
const known = new Set(hostNames);
|
|
2509
|
+
const presetId = currentPresetId(agentCtx);
|
|
2510
|
+
if (presetId !== null)
|
|
2511
|
+
for (const name of await presetToolNames(presetId))
|
|
2512
|
+
known.add(name);
|
|
2513
|
+
return decideToolFilter(persona, presetId, { names: known, mcp });
|
|
2514
|
+
}
|
|
2100
2515
|
/**
|
|
2101
2516
|
* 模型候选(人设的「模型」下拉):宿主已注册的 provider + 各 provider 能宣告的模型。
|
|
2102
2517
|
* 只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
|
|
@@ -2156,6 +2571,7 @@ export default {
|
|
|
2156
2571
|
'mcpm-set-all': mcpmSetAll,
|
|
2157
2572
|
'mcpm-restart': mcpmRestart,
|
|
2158
2573
|
'mcpm-remove': mcpmRemove,
|
|
2574
|
+
'mcpm-compact': mcpmCompact,
|
|
2159
2575
|
'mcpm-export': mcpmExport,
|
|
2160
2576
|
'mcpm-import': mcpmImport,
|
|
2161
2577
|
// 技能管理 ops(由 ./skills/service.js 提供):skill-state / skill-detail /
|
|
@@ -2171,6 +2587,15 @@ export default {
|
|
|
2171
2587
|
// rules-remove-scene。
|
|
2172
2588
|
// 成功返回扁平 {ok:true, ...}(不套 data),失败 {ok:false, error, code?}。
|
|
2173
2589
|
...rulesService.ops,
|
|
2590
|
+
// 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
|
|
2591
|
+
// 直接修改」,所以这四个 rules 写 op 走包装 —— 先执行原逻辑,成功后把当前场景绑定的
|
|
2592
|
+
// 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
|
|
2593
|
+
// 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
|
|
2594
|
+
// 放在 spread 之后,显式覆盖同名 op。
|
|
2595
|
+
'rules-set-active': async (args) => withAgentsMdSync(await rulesService.ops['rules-set-active'](args)),
|
|
2596
|
+
'rules-update-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-update-scene'](args)),
|
|
2597
|
+
'rules-create-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-create-scene'](args)),
|
|
2598
|
+
'rules-remove-scene': async (args) => withAgentsMdSync(await rulesService.ops['rules-remove-scene'](args)),
|
|
2174
2599
|
// 场景档案 ops(由 ./rules/archive-engine.ts 提供):scene-mode-get /
|
|
2175
2600
|
// scene-archive-save / scene-mode-set。成功返回扁平 {ok:true, ...},
|
|
2176
2601
|
// 失败 {ok:false, error};写 op 已含 archiveService.writeOps 门禁派生。
|
|
@@ -2185,7 +2610,7 @@ export default {
|
|
|
2185
2610
|
'model-candidates': async () => ({ ok: true, ...(await modelCandidates()) }),
|
|
2186
2611
|
// 场景档案勾选器数据源 v2:全部 MCP 服务器(含未运行)+ 技能全集 + 人设清单。
|
|
2187
2612
|
'scene-inventory': async () => {
|
|
2188
|
-
const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(),
|
|
2613
|
+
const [rowsR, tools, skills, subs] = await Promise.all([mcpmListView(), toolStates(), skillRows(), subagentService.list()]);
|
|
2189
2614
|
const disabledRaw = await readDisabledTools();
|
|
2190
2615
|
const rows = (rowsR && rowsR.rows) || [];
|
|
2191
2616
|
const mcpServers = [];
|
|
@@ -2209,8 +2634,13 @@ export default {
|
|
|
2209
2634
|
ok: true,
|
|
2210
2635
|
mcpServers,
|
|
2211
2636
|
tools: Object.entries(tools).map(([key, enabled]) => ({ key, enabled })),
|
|
2212
|
-
|
|
2213
|
-
|
|
2637
|
+
// 技能行:技能名与来源名分开给(早先只给 `<来源 key>/<技能名>`,自定义目录的
|
|
2638
|
+
// 来源 key 是 `custom-<hash>`,于是导入的技能在场景里显示成一串哈希)。
|
|
2639
|
+
skills,
|
|
2640
|
+
// 人设行要显示"按模式配了什么限制",所以把按模式名单一起带上;
|
|
2641
|
+
// presets 只给名字与来源(不枚举工具,避免打开档案弹窗就挂载每个预设)。
|
|
2642
|
+
subagents: subs.map((p) => ({ name: p.name, description: p.description, toolsByPreset: p.toolsByPreset ?? null })),
|
|
2643
|
+
presets: await presetNames(),
|
|
2214
2644
|
// 档案编辑器第 4 段「记忆」的候选:场景 + 每个场景里的记忆条数。
|
|
2215
2645
|
// 记忆正文不在这里返回(勾选集只存 id,渲染时才读盘)。
|
|
2216
2646
|
scenes: await memorySceneCandidates(),
|
|
@@ -2220,59 +2650,94 @@ export default {
|
|
|
2220
2650
|
// AGENTS.md 预设库 ops(由 ./agents-md/service.js 提供):agentsmd-list /
|
|
2221
2651
|
// agentsmd-read / agentsmd-create / agentsmd-update / agentsmd-remove /
|
|
2222
2652
|
// agentsmd-apply / agentsmd-get-current
|
|
2223
|
-
|
|
2653
|
+
// 提示词预设库(由 ./agents-md/service.ts 提供)。这里把两件事对齐成用户看到的一份状态:
|
|
2654
|
+
// - `active`(界面「生效中」)= **当前真正在起作用的基线**:启用的场景绑了预设就是它,
|
|
2655
|
+
// 否则才看文件比对(预设正文 == ~/.dsh/AGENTS.md)。用户裁定:「场景启动了,
|
|
2656
|
+
// 提示词页生效中的应该是场景选择的那个」。
|
|
2657
|
+
// - `fileApplied` = 文件比对结果(AGENTS.md 里确实是它)。两者同时为真说明
|
|
2658
|
+
// 场景绑的就是已应用的那份,正文一致时宿主不会再注入第二遍(去重)。
|
|
2659
|
+
'agentsmd-list': async () => {
|
|
2660
|
+
const base = await agentsMdService.list();
|
|
2661
|
+
if (!base || base.ok === false)
|
|
2662
|
+
return base;
|
|
2663
|
+
let scenePrompt;
|
|
2664
|
+
try {
|
|
2665
|
+
const r = await rulesService.ops['rules-list']({});
|
|
2666
|
+
if (r && r.ok && r.scenePrompt)
|
|
2667
|
+
scenePrompt = r.scenePrompt;
|
|
2668
|
+
}
|
|
2669
|
+
catch { /* 场景服务不可用 → 只回预设库 */ }
|
|
2670
|
+
const sceneId = scenePrompt && !scenePrompt.missing && scenePrompt.scene ? scenePrompt.presetId : null;
|
|
2671
|
+
// 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
|
|
2672
|
+
// 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
|
|
2673
|
+
const sceneDrives = sceneId !== null;
|
|
2674
|
+
const presets = base.presets.map((p) => {
|
|
2675
|
+
const fileApplied = p.active === true;
|
|
2676
|
+
const byScene = sceneDrives && sceneId === p.id;
|
|
2677
|
+
const active = sceneDrives ? byScene : fileApplied;
|
|
2678
|
+
return { ...p, active, fileApplied, activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null) };
|
|
2679
|
+
});
|
|
2680
|
+
return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
|
|
2681
|
+
},
|
|
2224
2682
|
'agentsmd-read': (args) => agentsMdService.read(String((args && args.id) || '')),
|
|
2225
|
-
|
|
2226
|
-
'agentsmd-
|
|
2683
|
+
// content 与 from 都可选:新建弹窗里直接写正文(content),或从现有预设复制(from)。
|
|
2684
|
+
'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), {
|
|
2685
|
+
...(args && args.from ? { from: String(args.from) } : {}),
|
|
2686
|
+
...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
|
|
2687
|
+
}),
|
|
2688
|
+
// 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
|
|
2689
|
+
// 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
|
|
2690
|
+
// 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
|
|
2691
|
+
// `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
|
|
2692
|
+
'agentsmd-update': async (args) => {
|
|
2693
|
+
const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined);
|
|
2694
|
+
if (!res || res.ok === false)
|
|
2695
|
+
return res;
|
|
2696
|
+
let renamed = {};
|
|
2697
|
+
if (res.renamedFrom) {
|
|
2698
|
+
try {
|
|
2699
|
+
const r = await rulesService.ops['rules-rebind-prompt']({ from: res.renamedFrom, to: res.id });
|
|
2700
|
+
renamed = r && r.ok ? { reboundScenes: r.changed } : {};
|
|
2701
|
+
}
|
|
2702
|
+
catch { /* 改名同步失败不影响保存本身 */ }
|
|
2703
|
+
}
|
|
2704
|
+
return withAgentsMdSync({ ...res, ...renamed });
|
|
2705
|
+
},
|
|
2227
2706
|
'agentsmd-apply': (args) => agentsMdService.apply(String((args && args.id) || '')),
|
|
2228
2707
|
'agentsmd-get-current': () => agentsMdService.getCurrent(),
|
|
2229
|
-
|
|
2708
|
+
// 删除:**正在生效的那份拒绝删除**(用户裁定)。生效 = 启用的场景绑定了它,
|
|
2709
|
+
// 否则 = 文件内容就是它。禁用的按钮只是提示,真正的门在这里(模型与旧页面也会走这条路)。
|
|
2710
|
+
// 探测失败时放行并记一条 warn:删预设不动 ~/.dsh/AGENTS.md,最坏是少一份副本,可重建。
|
|
2711
|
+
'agentsmd-remove': async (args) => {
|
|
2712
|
+
const id = String((args && args.id) || '');
|
|
2713
|
+
const effective = await effectivePresetId();
|
|
2714
|
+
if (effective !== null && effective === id) {
|
|
2715
|
+
return { ok: false, error: `「${id}」正在生效,不能删除:先启用别的场景或换绑提示词,或先把别的预设应用上去。` };
|
|
2716
|
+
}
|
|
2717
|
+
return agentsMdService.remove(id);
|
|
2718
|
+
},
|
|
2230
2719
|
'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
|
|
2720
|
+
// 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
|
|
2721
|
+
// 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
|
|
2722
|
+
'agentsmd-trash-list': () => agentsMdService.trashList(),
|
|
2723
|
+
'agentsmd-trash-restore': (args) => agentsMdService.trashRestore(String((args && args.id) || '')),
|
|
2724
|
+
'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
|
|
2231
2725
|
// History(归档会话管理)ops。只读:history-list / history-retention-get;
|
|
2232
2726
|
// 写:history-archive / history-unarchive / history-delete / history-retention-set。
|
|
2233
2727
|
// workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
|
|
2234
2728
|
'history-list': async () => {
|
|
2235
2729
|
const registry = getHistoryRegistry();
|
|
2236
2730
|
if (!registry)
|
|
2237
|
-
return { ok: false, error: '
|
|
2731
|
+
return { ok: false, error: '归档服务未就绪:请检查宿主 workspaceRegistry 服务。' };
|
|
2238
2732
|
try {
|
|
2239
2733
|
const items = (typeof registry.archivedSessionDetails === 'function')
|
|
2240
2734
|
? (await registry.archivedSessionDetails()).items
|
|
2241
2735
|
: (await registry.archivedSessionMetadata()).items.map((i) => ({ sessionId: i.sessionId, createdAt: i.createdAt, archivedAt: registry.archivedAt?.(i.sessionId) }));
|
|
2242
2736
|
const { retentionDays } = await readHistoryRetention();
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
const table = registry.requireTable();
|
|
2248
|
-
const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
|
|
2249
|
-
const wsIds = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
|
|
2250
|
-
? state.workspaceIds
|
|
2251
|
-
: [...table.entries()].map(([id]) => id);
|
|
2252
|
-
const owned = new Map();
|
|
2253
|
-
const workspaces = {};
|
|
2254
|
-
for (const wid of wsIds) {
|
|
2255
|
-
const rec = table.get(wid);
|
|
2256
|
-
if (!rec || !Array.isArray(rec.sessionIds))
|
|
2257
|
-
continue;
|
|
2258
|
-
for (const sid of rec.sessionIds)
|
|
2259
|
-
if (!owned.has(sid))
|
|
2260
|
-
owned.set(sid, wid);
|
|
2261
|
-
workspaces[wid] = {
|
|
2262
|
-
title: (rec.title && String(rec.title)) || String(wid),
|
|
2263
|
-
...(typeof rec.path === 'string' && rec.path ? { path: rec.path } : {}),
|
|
2264
|
-
};
|
|
2265
|
-
}
|
|
2266
|
-
for (const it of items) {
|
|
2267
|
-
const wid = owned.get(it.sessionId);
|
|
2268
|
-
if (wid !== undefined)
|
|
2269
|
-
it.workspaceId = wid;
|
|
2270
|
-
}
|
|
2271
|
-
return { ok: true, items, workspaces, retentionDays };
|
|
2272
|
-
}
|
|
2273
|
-
catch (e) { /* 降级为下方扁平返回 */ }
|
|
2274
|
-
}
|
|
2275
|
-
return { ok: true, items, retentionDays };
|
|
2737
|
+
// 归属分组(best-effort):活登记 → 路径命中 → 按会话目录重建。
|
|
2738
|
+
// 宿主删除工作区后登记消失,靠会话目录把分组补回来(详见 buildHistoryGroups)。
|
|
2739
|
+
const { groups, workspaces } = await buildHistoryGroups(registry, items);
|
|
2740
|
+
return { ok: true, items, groups, workspaces, retentionDays };
|
|
2276
2741
|
}
|
|
2277
2742
|
catch (e) {
|
|
2278
2743
|
return { ok: false, error: message(e) };
|
|
@@ -2291,23 +2756,6 @@ export default {
|
|
|
2291
2756
|
? (await registry.archivedSessionDetails()).items
|
|
2292
2757
|
: (await registry.archivedSessionMetadata()).items;
|
|
2293
2758
|
const archived = new Set(archivedItems.map((i) => i.sessionId));
|
|
2294
|
-
// 工作区归属反查(与 history-list 一致;best-effort)。
|
|
2295
|
-
const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
|
|
2296
|
-
const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
|
|
2297
|
-
const wsIds = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
|
|
2298
|
-
? state.workspaceIds
|
|
2299
|
-
: (table ? [...table.entries()].map(([id]) => id) : []);
|
|
2300
|
-
const owned = new Map();
|
|
2301
|
-
if (table) {
|
|
2302
|
-
for (const wid of wsIds) {
|
|
2303
|
-
const rec = table.get(wid);
|
|
2304
|
-
if (!rec || !Array.isArray(rec.sessionIds))
|
|
2305
|
-
continue;
|
|
2306
|
-
for (const sid of rec.sessionIds)
|
|
2307
|
-
if (!owned.has(sid))
|
|
2308
|
-
owned.set(sid, wid);
|
|
2309
|
-
}
|
|
2310
|
-
}
|
|
2311
2759
|
const cache = ctx.get('sessionProjectionCache');
|
|
2312
2760
|
const items = [];
|
|
2313
2761
|
for (const h of headers) {
|
|
@@ -2325,9 +2773,6 @@ export default {
|
|
|
2325
2773
|
item.cwd = h.cwd;
|
|
2326
2774
|
if (typeof h.createdAt === 'number' && Number.isFinite(h.createdAt))
|
|
2327
2775
|
item.createdAt = h.createdAt;
|
|
2328
|
-
const wid = owned.get(sessionId);
|
|
2329
|
-
if (wid !== undefined)
|
|
2330
|
-
item.workspaceId = wid;
|
|
2331
2776
|
if (cache && typeof cache.cachedSnapshot === 'function') {
|
|
2332
2777
|
try {
|
|
2333
2778
|
const snap = cache.cachedSnapshot(h, 0, ['title']);
|
|
@@ -2348,7 +2793,9 @@ export default {
|
|
|
2348
2793
|
}
|
|
2349
2794
|
items.push(item);
|
|
2350
2795
|
}
|
|
2351
|
-
|
|
2796
|
+
// 归属分组(与 history-list 同源):导出弹窗的工作区筛选按同一套组 id。
|
|
2797
|
+
const { groups } = await buildHistoryGroups(registry, items);
|
|
2798
|
+
return { ok: true, items, groups };
|
|
2352
2799
|
}
|
|
2353
2800
|
catch (e) {
|
|
2354
2801
|
return { ok: false, error: message(e) };
|
|
@@ -2414,8 +2861,7 @@ export default {
|
|
|
2414
2861
|
catch (e) {
|
|
2415
2862
|
return { ok: false, error: message(e) };
|
|
2416
2863
|
}
|
|
2417
|
-
},
|
|
2418
|
-
// 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
|
|
2864
|
+
}, // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
|
|
2419
2865
|
'history-archive-batch': async (args) => {
|
|
2420
2866
|
const registry = getHistoryRegistry();
|
|
2421
2867
|
if (!registry)
|
|
@@ -2437,6 +2883,79 @@ export default {
|
|
|
2437
2883
|
}
|
|
2438
2884
|
return { ok: true, archived, failed };
|
|
2439
2885
|
},
|
|
2886
|
+
// 兼容体检:把"宿主到底支持哪些操作、哪些降级了、为什么"变成可读状态,
|
|
2887
|
+
// 而不是等用户点删除时才看见报错。只读,不改任何数据。
|
|
2888
|
+
'compat-status': async (args) => {
|
|
2889
|
+
const force = Boolean(args && args.refresh);
|
|
2890
|
+
try {
|
|
2891
|
+
const registry = getHistoryRegistry();
|
|
2892
|
+
const assessment = registry && typeof registry.capabilities === 'function'
|
|
2893
|
+
? registry.capabilities(force)
|
|
2894
|
+
: undefined;
|
|
2895
|
+
if (assessment === undefined) {
|
|
2896
|
+
return {
|
|
2897
|
+
ok: true,
|
|
2898
|
+
host: { version: 'unknown' },
|
|
2899
|
+
findings: [],
|
|
2900
|
+
degraded: [],
|
|
2901
|
+
blockers: ['归档服务未挂载:无法探测宿主能力'],
|
|
2902
|
+
summary: '宿主能力探测不可用(归档服务未挂载)',
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
const summary = summarize(assessment);
|
|
2906
|
+
// 每次刷新都留一条结构化日志:升级当天就能在日志里看到降级发生。
|
|
2907
|
+
if (force || !compatLoggedFor || compatLoggedFor !== assessment.identity.version) {
|
|
2908
|
+
compatLoggedFor = assessment.identity.version;
|
|
2909
|
+
void pluginLog()('compat/probe', {
|
|
2910
|
+
hostVersion: assessment.identity.version,
|
|
2911
|
+
summary,
|
|
2912
|
+
degraded: assessment.degraded.map((item) => ({ id: item.id, state: item.state, detail: item.detail })),
|
|
2913
|
+
blockers: assessment.identity.blockers,
|
|
2914
|
+
sameAsHost: assessment.identity.sameAsHost,
|
|
2915
|
+
}).catch(() => { });
|
|
2916
|
+
}
|
|
2917
|
+
return {
|
|
2918
|
+
ok: true,
|
|
2919
|
+
host: { version: assessment.identity.version, modules: assessment.identity.modules },
|
|
2920
|
+
sameAsHost: assessment.identity.sameAsHost,
|
|
2921
|
+
findings: assessment.findings,
|
|
2922
|
+
degraded: assessment.degraded,
|
|
2923
|
+
blockers: assessment.identity.blockers,
|
|
2924
|
+
mayDelete: assessment.mayDelete,
|
|
2925
|
+
verifiedVersion: VERIFIED_HOST_VERSION,
|
|
2926
|
+
expectedPeerRange: EXPECTED_PEER_RANGE,
|
|
2927
|
+
generatedAt: assessment.generatedAt,
|
|
2928
|
+
summary,
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
catch (e) {
|
|
2932
|
+
return { ok: false, error: message(e) };
|
|
2933
|
+
}
|
|
2934
|
+
},
|
|
2935
|
+
// 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
|
|
2936
|
+
// 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
|
|
2937
|
+
// 模型真的看得到吗" —— minimal 这类 persona complete 的预设会压制全部提示词段。
|
|
2938
|
+
'preset-reach': async () => {
|
|
2939
|
+
try {
|
|
2940
|
+
// 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
|
|
2941
|
+
// $DSH_HOME/cordis.patch.yml 这一层挂载,所以它对所有预设一视同仁;
|
|
2942
|
+
// 这一列要回答的只是"现在有没有 MCP 工具可用"。探测失败 → 不传该字段,
|
|
2943
|
+
// 矩阵那一格显示"判断不了"而不是猜。
|
|
2944
|
+
let mcpTools;
|
|
2945
|
+
try {
|
|
2946
|
+
const schemas = await tools.schemas();
|
|
2947
|
+
mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith('mcp__')).length;
|
|
2948
|
+
}
|
|
2949
|
+
catch {
|
|
2950
|
+
mcpTools = undefined;
|
|
2951
|
+
}
|
|
2952
|
+
const report = await assessPresetReach(presetRoster(), { mcpTools });
|
|
2953
|
+
return { ok: true, ...report };
|
|
2954
|
+
}
|
|
2955
|
+
catch (e) {
|
|
2956
|
+
return { ok: false, error: message(e) };
|
|
2957
|
+
}
|
|
2958
|
+
},
|
|
2440
2959
|
'history-unarchive': async (args) => {
|
|
2441
2960
|
const registry = getHistoryRegistry();
|
|
2442
2961
|
if (!registry)
|
|
@@ -2446,7 +2965,8 @@ export default {
|
|
|
2446
2965
|
return { ok: false, error: '缺少 sessionId' };
|
|
2447
2966
|
try {
|
|
2448
2967
|
const r = await registry.unarchiveSession(sessionId);
|
|
2449
|
-
|
|
2968
|
+
const workspace = await restoreWorkspaceAccounting([sessionId]);
|
|
2969
|
+
return { ok: true, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
|
|
2450
2970
|
}
|
|
2451
2971
|
catch (e) {
|
|
2452
2972
|
return { ok: false, error: message(e) };
|
|
@@ -2478,7 +2998,8 @@ export default {
|
|
|
2478
2998
|
return parsed;
|
|
2479
2999
|
try {
|
|
2480
3000
|
const r = await registry.unarchiveSessions(parsed.target);
|
|
2481
|
-
|
|
3001
|
+
const workspace = await restoreWorkspaceAccounting(r.unarchivedSessionIds || []);
|
|
3002
|
+
return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
|
|
2482
3003
|
}
|
|
2483
3004
|
catch (e) {
|
|
2484
3005
|
return { ok: false, error: message(e) };
|
|
@@ -2499,6 +3020,28 @@ export default {
|
|
|
2499
3020
|
return { ok: false, error: message(e) };
|
|
2500
3021
|
}
|
|
2501
3022
|
},
|
|
3023
|
+
// 重新登记工作区(写宿主状态):为「工作区已被删除、目录仍在」的分组补回一条登记。
|
|
3024
|
+
// 只在用户显式确认后调用;宿主 realpath 会拒绝不存在的目录,同路径已登记则幂等返回。
|
|
3025
|
+
// 不移动、不删除任何文件或会话,也不改写 sessionIds 记账。
|
|
3026
|
+
'history-workspace-register': async (args) => {
|
|
3027
|
+
const registry = getHistoryRegistry();
|
|
3028
|
+
if (!registry)
|
|
3029
|
+
return { ok: false, error: '归档服务未挂载' };
|
|
3030
|
+
const path = String((args && args.path) || '').trim();
|
|
3031
|
+
if (!path)
|
|
3032
|
+
return { ok: false, error: '缺少工作区目录 path' };
|
|
3033
|
+
if (typeof registry.registerWorkspace !== 'function') {
|
|
3034
|
+
return { ok: false, error: '当前环境不支持重新登记工作区(registry 未实现 registerWorkspace)' };
|
|
3035
|
+
}
|
|
3036
|
+
const title = args && args.title ? String(args.title) : undefined;
|
|
3037
|
+
try {
|
|
3038
|
+
const workspace = await registry.registerWorkspace(path, title);
|
|
3039
|
+
return { ok: true, workspace };
|
|
3040
|
+
}
|
|
3041
|
+
catch (e) {
|
|
3042
|
+
return { ok: false, error: message(e) };
|
|
3043
|
+
}
|
|
3044
|
+
},
|
|
2502
3045
|
// 从其他 Agent(Claude Code / Cursor JSONL、Codex Markdown、通用文本)导入对话,
|
|
2503
3046
|
// 通过 sessions.create 的 seed 机制生成一个可继续对话的全新会话。
|
|
2504
3047
|
'history-import': async (args) => {
|
|
@@ -2518,9 +3061,8 @@ export default {
|
|
|
2518
3061
|
}
|
|
2519
3062
|
if (!turns.length)
|
|
2520
3063
|
return { ok: false, error: '未能从该文件中识别出对话内容' };
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
return { ok: false, error: '当前环境不支持创建会话(sessions.create 不可用)' };
|
|
3064
|
+
if (!agents || typeof agents.create !== 'function')
|
|
3065
|
+
return { ok: false, error: '当前环境不支持创建会话(agents.create 不可用)' };
|
|
2524
3066
|
// 仅接受绝对路径的 cwd;非法或缺失时会话不带目录(归入未分组)。
|
|
2525
3067
|
const cwdArg = String((args && args.cwd) || '').trim();
|
|
2526
3068
|
const cwd = /^([A-Za-z]:[\\/]|\\\\|\/)/.test(cwdArg) ? cwdArg : undefined;
|
|
@@ -2536,8 +3078,19 @@ export default {
|
|
|
2536
3078
|
surfaceOp: 'append',
|
|
2537
3079
|
}));
|
|
2538
3080
|
try {
|
|
2539
|
-
|
|
2540
|
-
|
|
3081
|
+
// `sessions.create()` 只创建裸 Session;它不经过 DSH agent factory / workspace
|
|
3082
|
+
// / session-log,所以返回的内存 id 不会进入 UI 的会话列表。必须走官方的
|
|
3083
|
+
// `ctx.agents.create()`,它会在同一事务里创建 Agent、发布 Session,并触发宿主
|
|
3084
|
+
// 的持久化与 projection 链路。这里使用 UUID,避免 SessionStore 的裸自增 id 与
|
|
3085
|
+
// 宿主 API 会话 id 空间混用。
|
|
3086
|
+
const id = 'session-' + randomUUID();
|
|
3087
|
+
await agents.create({ sessionId: id, seed, ...(cwd ? { meta: { cwd } } : {}) });
|
|
3088
|
+
const sessions = ctx.get('sessions');
|
|
3089
|
+
const live = sessions && typeof sessions.get === 'function' ? sessions.get(id) : undefined;
|
|
3090
|
+
if (live === undefined || live === null) {
|
|
3091
|
+
return { ok: false, error: `创建会话失败:宿主未登记该会话(${id}),未导入任何内容` };
|
|
3092
|
+
}
|
|
3093
|
+
return { ok: true, sessionId: id, count: turns.length };
|
|
2541
3094
|
}
|
|
2542
3095
|
catch (e) {
|
|
2543
3096
|
return { ok: false, error: '创建会话失败: ' + message(e) };
|
|
@@ -2624,17 +3177,20 @@ export default {
|
|
|
2624
3177
|
},
|
|
2625
3178
|
'history-retention-set': async (args) => {
|
|
2626
3179
|
const days = Number((args && args.retentionDays) ?? -1);
|
|
2627
|
-
|
|
2628
|
-
|
|
3180
|
+
// 错误文案承诺的是「非负整数」,就按整数校验:1.5 这类小数以前会被静默取整成 1,
|
|
3181
|
+
// 用户看到的回执与输入不符。
|
|
3182
|
+
if (!Number.isSafeInteger(days) || days < 0) {
|
|
3183
|
+
return { ok: false, error: 'retentionDays 需为非负整数(0=永久不删除),收到: ' + String((args && args.retentionDays)) };
|
|
3184
|
+
}
|
|
2629
3185
|
try {
|
|
2630
|
-
await writeHistoryRetention(
|
|
3186
|
+
await writeHistoryRetention(days);
|
|
2631
3187
|
}
|
|
2632
3188
|
catch (e) {
|
|
2633
3189
|
return { ok: false, error: '写入保留期失败: ' + message(e) };
|
|
2634
3190
|
}
|
|
2635
3191
|
// 设置变更后立即扫一次,UI 反映新策略。
|
|
2636
3192
|
await sweepHistory().catch(() => { });
|
|
2637
|
-
return { ok: true, retentionDays:
|
|
3193
|
+
return { ok: true, retentionDays: days };
|
|
2638
3194
|
},
|
|
2639
3195
|
};
|
|
2640
3196
|
// ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
|
|
@@ -2746,7 +3302,10 @@ export default {
|
|
|
2746
3302
|
}));
|
|
2747
3303
|
tools.register(defineTool({
|
|
2748
3304
|
name: 'skill_manager_create',
|
|
2749
|
-
|
|
3305
|
+
// 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
|
|
3306
|
+
// tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
|
|
3307
|
+
// root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
|
|
3308
|
+
description: 'Create a new local DSH skill under the tool-management skills root (DSH_HOME/tool-management/skills). Use only when the user explicitly asks to create or save a reusable skill.',
|
|
2750
3309
|
parameters: {
|
|
2751
3310
|
name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
|
|
2752
3311
|
description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
|
|
@@ -2754,7 +3313,7 @@ export default {
|
|
|
2754
3313
|
},
|
|
2755
3314
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
2756
3315
|
async execute(args) {
|
|
2757
|
-
const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body
|
|
3316
|
+
const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body });
|
|
2758
3317
|
if (!r || r.ok === false)
|
|
2759
3318
|
throw new Error((r && r.error) || 'skill create failed');
|
|
2760
3319
|
const data = r.data || {};
|
|
@@ -2767,12 +3326,15 @@ export default {
|
|
|
2767
3326
|
description: 'List AGENTS.md presets in the plugin preset library (id, active state).',
|
|
2768
3327
|
parameters: {},
|
|
2769
3328
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
2770
|
-
async execute() {
|
|
3329
|
+
async execute(_args, exec) {
|
|
2771
3330
|
const r = await agentsMdService.list();
|
|
2772
3331
|
if (!r.ok)
|
|
2773
3332
|
throw new Error(r.error);
|
|
2774
3333
|
const summary = r.presets.map((p) => p.id + (p.active ? ' [active]' : ''));
|
|
2775
|
-
|
|
3334
|
+
// 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
|
|
3335
|
+
// 是 complete)时文件内容不会进提示词。
|
|
3336
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
3337
|
+
return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(Applying takes effect on the next session created; the current session is unchanged.)' + notice;
|
|
2776
3338
|
},
|
|
2777
3339
|
}));
|
|
2778
3340
|
tools.register(defineTool({
|
|
@@ -2800,15 +3362,18 @@ export default {
|
|
|
2800
3362
|
group: { type: 'string', description: 'Optional scene filter.' },
|
|
2801
3363
|
},
|
|
2802
3364
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
2803
|
-
async execute(args) {
|
|
3365
|
+
async execute(args, exec) {
|
|
2804
3366
|
const r = await rulesService.ops['rules-list'](args);
|
|
2805
3367
|
if (!r || r.ok === false)
|
|
2806
3368
|
throw new Error((r && r.error) || '读取规则失败');
|
|
2807
3369
|
const lines = (r.rules || []).map((x) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + (x.enabled ? '已启用' : '已停用') +
|
|
2808
3370
|
(x.description ? ' — ' + x.description : '')));
|
|
2809
3371
|
const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
|
|
3372
|
+
// 注入边界:预设可能压制提示词段(complete persona,或未挂 agent-instructions),
|
|
3373
|
+
// 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
|
|
3374
|
+
const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
|
|
2810
3375
|
return '记忆(' + (r.rules || []).length + '):\n' + (lines.join('\n') || '(无记忆)') +
|
|
2811
|
-
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)');
|
|
3376
|
+
'\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
|
|
2812
3377
|
},
|
|
2813
3378
|
}));
|
|
2814
3379
|
tools.register(defineTool({
|
|
@@ -2848,7 +3413,9 @@ export default {
|
|
|
2848
3413
|
// 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
|
|
2849
3414
|
// 裸 register 会把未编译的参数声明直接发给模型 API。
|
|
2850
3415
|
tools.register(defineTool(defineSubagentListTool({ list: () => subagentService.list(), sceneLists: subagentSceneLists })));
|
|
2851
|
-
|
|
3416
|
+
// 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
|
|
3417
|
+
// 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
|
|
3418
|
+
tools.register(defineTool(defineSubagentRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
|
|
2852
3419
|
}
|
|
2853
3420
|
catch (e) {
|
|
2854
3421
|
console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
|
|
@@ -2872,13 +3439,31 @@ export default {
|
|
|
2872
3439
|
pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
|
|
2873
3440
|
return true;
|
|
2874
3441
|
};
|
|
2875
|
-
|
|
3442
|
+
// subagent_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
|
|
3443
|
+
// 本来就做不成(人设不存在),弹卡 / 写 bypass 日志只会产生一次无效审批:
|
|
3444
|
+
// 用户批准之后模型收到的是「人设不可用」。所以先校验,再决定要不要问。
|
|
3445
|
+
// 校验本身不可用时返回 true(保持原行为,绝不因为探测失败而少问一次)。
|
|
3446
|
+
const subagentRunTargetExists = async (exec) => {
|
|
3447
|
+
try {
|
|
3448
|
+
const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
|
|
3449
|
+
if (!name)
|
|
3450
|
+
return false;
|
|
3451
|
+
const list = await subagentService.list();
|
|
3452
|
+
return list.some((p) => p.name === name);
|
|
3453
|
+
}
|
|
3454
|
+
catch (e) {
|
|
3455
|
+
return true;
|
|
3456
|
+
}
|
|
3457
|
+
};
|
|
3458
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
2876
3459
|
if (!exec || !CONFIRM_LABELS[String(exec.name)])
|
|
2877
3460
|
return next();
|
|
3461
|
+
if (exec.name === 'subagent_run' && !(await subagentRunTargetExists(exec)))
|
|
3462
|
+
return next();
|
|
2878
3463
|
if (bypassedByFullAccess(exec))
|
|
2879
3464
|
return next();
|
|
2880
3465
|
if (exec.name === 'skill_manager_create') {
|
|
2881
|
-
return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under
|
|
3466
|
+
return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
|
|
2882
3467
|
}
|
|
2883
3468
|
if (exec.name === 'rule_manager_write') {
|
|
2884
3469
|
// D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
|
|
@@ -2942,9 +3527,10 @@ export default {
|
|
|
2942
3527
|
// ---------- HTTP API route (UI half), registered defensively ----------
|
|
2943
3528
|
if (webServer) {
|
|
2944
3529
|
// Cap request bodies (88 MiB): skill-upload carries Base64 folder/ZIP
|
|
2945
|
-
// payloads (64 MiB of raw content).
|
|
2946
|
-
//
|
|
2947
|
-
// `config.maxBodyBytes` overrides the cap
|
|
3530
|
+
// payloads (64 MiB of raw content). Reachability is decided by
|
|
3531
|
+
// fenceRejection() below — the host's Host/Origin + browser-session fence
|
|
3532
|
+
// (or a correct explicit token). `config.maxBodyBytes` overrides the cap
|
|
3533
|
+
// (tests use a small value).
|
|
2948
3534
|
const configuredMaxBody = Number(config?.maxBodyBytes);
|
|
2949
3535
|
const MAX_BODY = Number.isFinite(configuredMaxBody) && configuredMaxBody > 0 ? configuredMaxBody : 88 * 1024 * 1024;
|
|
2950
3536
|
const readBody = (req) => new Promise((resolve, reject) => {
|
|
@@ -2965,6 +3551,20 @@ export default {
|
|
|
2965
3551
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
2966
3552
|
req.on('error', reject);
|
|
2967
3553
|
});
|
|
3554
|
+
// 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
|
|
3555
|
+
// 没有全局中间件,既定约定是每个注册方自己调 connection.requestRejection(req):
|
|
3556
|
+
// 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
|
|
3557
|
+
// 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权。
|
|
3558
|
+
// 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
|
|
3559
|
+
// 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
|
|
3560
|
+
const connection = (() => {
|
|
3561
|
+
try {
|
|
3562
|
+
return ctx.get('connection');
|
|
3563
|
+
}
|
|
3564
|
+
catch {
|
|
3565
|
+
return undefined;
|
|
3566
|
+
}
|
|
3567
|
+
})();
|
|
2968
3568
|
try {
|
|
2969
3569
|
// ctx.effect wires the route's disposer into this plugin's scope, so an
|
|
2970
3570
|
// unload (HMR removal, disable, update) unregisters the route — the
|
|
@@ -2973,12 +3573,10 @@ export default {
|
|
|
2973
3573
|
kind: 'exact',
|
|
2974
3574
|
path: '/dsh-plugin-tool-management/api',
|
|
2975
3575
|
handler: async (req, res) => {
|
|
2976
|
-
//
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2980
|
-
// answers preflights — requiring `x-dsh-plugin` is the primary
|
|
2981
|
-
// gate; POST-only and the Origin check are defense in depth.
|
|
3576
|
+
// 鉴权顺序:POST-only → 插件门禁头(防误触,不是凭证)→ 宿主栅栏
|
|
3577
|
+
// (Host/Origin + browser-session cookie)→ 可选访问令牌。令牌是本地工具与
|
|
3578
|
+
// LAN 部署的逃生门:**令牌正确即视为已授权**,可越过浏览器鉴权;
|
|
3579
|
+
// 未配置令牌时不再有"谁都放行"的缺口。
|
|
2982
3580
|
const hdr = (name) => {
|
|
2983
3581
|
const v = req.headers?.[name];
|
|
2984
3582
|
return Array.isArray(v) ? v[0] ?? '' : v ?? '';
|
|
@@ -2993,19 +3591,12 @@ export default {
|
|
|
2993
3591
|
res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
|
|
2994
3592
|
return;
|
|
2995
3593
|
}
|
|
2996
|
-
const
|
|
2997
|
-
if (
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
sameOrigin =
|
|
3003
|
-
/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(u.hostname) || u.host === hostHdr;
|
|
3004
|
-
}
|
|
3005
|
-
catch (e) { /* unparseable origin → rejected below */ }
|
|
3006
|
-
if (!sameOrigin) {
|
|
3007
|
-
res.writeHead(403, { 'content-type': 'application/json' });
|
|
3008
|
-
res.end(JSON.stringify({ ok: false, error: 'cross-origin request rejected' }));
|
|
3594
|
+
const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token') === TOKEN;
|
|
3595
|
+
if (!tokenAccepted) {
|
|
3596
|
+
const fence = fenceRejection(req, connection);
|
|
3597
|
+
if (fence) {
|
|
3598
|
+
res.writeHead(fence.status, { 'content-type': 'application/json' });
|
|
3599
|
+
res.end(JSON.stringify({ ok: false, error: fence.error }));
|
|
3009
3600
|
return;
|
|
3010
3601
|
}
|
|
3011
3602
|
}
|
|
@@ -3023,6 +3614,17 @@ export default {
|
|
|
3023
3614
|
/* otherwise fall through with {} */
|
|
3024
3615
|
}
|
|
3025
3616
|
const op = String(payload.op || '');
|
|
3617
|
+
// 敏感 op(会吐明文密钥与完整配置):**必须**带对的访问令牌,没配令牌
|
|
3618
|
+
// 就没有明文(判定与理由见 http-fence.ts 的 secretOpRejection)。
|
|
3619
|
+
// 之前这里只查"有没有 Origin 头",而 token 为空时它形同虚设 —— 任何能
|
|
3620
|
+
// 打开 GUI 的浏览器都拿得到全部密钥原文。
|
|
3621
|
+
if (SENSITIVE_OPS.has(op)) {
|
|
3622
|
+
const gate = secretOpRejection({ tokenConfigured: TOKEN !== '', tokenAccepted });
|
|
3623
|
+
if (gate) {
|
|
3624
|
+
res.end(JSON.stringify({ ok: false, error: gate.error, code: gate.code }));
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3026
3628
|
if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
|
|
3027
3629
|
res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
|
|
3028
3630
|
return;
|