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.
Files changed (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,323 @@
1
+ // 归档会话域(原 index.ts apply() 闭包内六段,2026-09-19 抽出)。
2
+ //
3
+ // 这一域管的是「归档会话的外围」:侧车账本的落点与搬迁、保留期设置、到期清扫、
4
+ // 批量操作目标的校验、归档列表的分组视图。会话本身的归档 / 恢复 / 删除走宿主
5
+ // 注册表(./workspace.ts 的 ArchiveWorkspaceRegistry),这里只做围绕它的记账与投影。
6
+ //
7
+ // 为什么抽出来:这些内容此前散在 apply() 闭包的六个不相邻段落里(1041 / 1093 /
8
+ // 1152 / 1172 / 1683 / 1711),彼此靠闭包隐式共享 `sessions` 实例与侧车路径。抽成
9
+ // 工厂后私有状态(实例、目录存在性缓存)收在内部,只经返回对象的七个出口暴露;
10
+ // 对外依赖(config / pluginRoot / message / ctx)改为显式入参。
11
+ //
12
+ // 一处必须守住的语义:侧车三件套放 hub 根(`$DSH_HOME/tool-management/`)而**不是**
13
+ // 插件目录 —— npm 安装下 `dsh plugin update` 会整体替换插件目录,账本一丢,保留期
14
+ // 基线就从 archivedAt 退回 createdAt,归档会话会被提前清掉。旧位置启动时一次性搬入。
15
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
16
+ import { dirname, join } from 'node:path';
17
+ import { hubPath, hubRoot, relocateEntries } from '../hub.js';
18
+ import { ArchiveWorkspaceRegistry as SessionsService, workspaceBaseName, workspacePathKey } from './workspace.js';
19
+ /**
20
+ * 建归档会话域。副作用(构造注册表实例、首次清扫、挂周期定时器)都在工厂内完成 ——
21
+ * 它们原本就写在 apply() 的同一位置,语义是「这个服务自己的启动逻辑」,不是组装层的接线。
22
+ */
23
+ export function createHistoryDomain(deps) {
24
+ const { ctx, config, pluginRoot, message } = deps;
25
+ // ---------- independent history facade ----------
26
+ // Keep the active workspaceRegistry/sessionProjectionCache instances intact.
27
+ // Native archive extensions are delegated by capability, not package name.
28
+ // Official gaps use a checked adapter (sessions/bridge.ts); no service or
29
+ // storage-domain replacement. Retention uses the existing sidecar ledger.
30
+ // 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
31
+ // (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
32
+ // `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
33
+ // 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把提示词预设
34
+ // 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
35
+ // 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
36
+ const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
37
+ const historyWorkspaceSnapshotFile = String(config?.workspaceSnapshotFile || hubPath('history-workspaces.json'));
38
+ const historyRetentionPath = String(config?.historyRetentionPath || hubPath('history-retention.json'));
39
+ const historySidecarFiles = new Set(['history-archived-at.json', 'history-retention.json', 'history-workspaces.json']);
40
+ const historyStandby = relocateEntries(join(pluginRoot, 'data'), hubRoot(), (name) => historySidecarFiles.has(name))
41
+ .catch(() => 0);
42
+ const sweepIntervalMs = Number(config?.sweepIntervalMs || 0) || 6 * 60 * 60 * 1000;
43
+ let sessions;
44
+ ctx.effect(() => {
45
+ const registry = ctx.get('workspaceRegistry');
46
+ if (registry && typeof registry.archiveSession === 'function') {
47
+ sessions = new SessionsService(ctx, registry, {
48
+ archivedAtFile: historyArchivedAtFile,
49
+ workspaceSnapshotFile: historyWorkspaceSnapshotFile,
50
+ ready: historyStandby,
51
+ });
52
+ }
53
+ return async () => { const current = sessions; sessions = undefined; await current?.dispose(); };
54
+ }, 'dsh-plugin-tool-management: independent history');
55
+ function getSessionsRegistry() {
56
+ return sessions;
57
+ }
58
+ /**
59
+ * 恢复归档后的**归属恢复**(best-effort,绝不阻断恢复本身):按会话 cwd 找到或重建
60
+ * 工作区登记,并把会话挂回该登记的 `sessionIds`。恢复成功但归属恢复失败时返回 undefined,
61
+ * 由客户端按"会话已恢复、工作区归属未恢复"如实呈现。
62
+ */
63
+ async function restoreWorkspaceAccounting(sessionIds) {
64
+ const registry = getSessionsRegistry();
65
+ if (!registry || typeof registry.ensureWorkspaceAccounting !== 'function' || !sessionIds.length)
66
+ return undefined;
67
+ try {
68
+ return await registry.ensureWorkspaceAccounting(sessionIds);
69
+ }
70
+ catch (e) {
71
+ ctx.logger?.warn?.(`history: workspace accounting after restore failed: ${message(e)}`);
72
+ return undefined;
73
+ }
74
+ }
75
+ async function readHistoryRetention() {
76
+ try {
77
+ await historyStandby;
78
+ const raw = await readFile(historyRetentionPath, 'utf8');
79
+ const obj = JSON.parse(raw);
80
+ const days = Number((obj && obj.retentionDays) ?? 0);
81
+ const updatedAt = Number((obj && obj.updatedAt) ?? 0);
82
+ return {
83
+ retentionDays: Number.isFinite(days) && days >= 0 ? days : 0,
84
+ updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
85
+ };
86
+ }
87
+ catch {
88
+ return { retentionDays: 0, updatedAt: 0 };
89
+ }
90
+ }
91
+ async function writeHistoryRetention(retentionDays) {
92
+ await historyStandby;
93
+ const dir = dirname(historyRetentionPath);
94
+ await mkdir(dir, { recursive: true });
95
+ // updatedAt = 修改时刻:每次改保留期,已归档会话的到期基线重置为此时刻。
96
+ await writeFile(historyRetentionPath, JSON.stringify({ retentionDays, updatedAt: Date.now() }), 'utf8');
97
+ }
98
+ /**
99
+ * 计算到期应删的归档会话。基线 = max(archivedAt(账本)?? createdAt(元数据),
100
+ * updatedAt(最近一次修改保留期的时刻))。改保留期即重置倒计时:到期时刻从
101
+ * 修改时刻起按新天数重新计算;updatedAt 缺失(旧配置)时退回归档时刻语义。
102
+ * retentionDays <= 0 表示永久不删除,返回空集。纯函数:便于测试。
103
+ */
104
+ /** 一天的毫秒数(保留期按"天"配置,比较按毫秒)。 */
105
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
106
+ function expiredArchivedIds(items, retentionDays, now, updatedAt = 0) {
107
+ if (!(retentionDays > 0))
108
+ return [];
109
+ const cutoff = now - retentionDays * MS_PER_DAY;
110
+ const out = [];
111
+ for (const it of items) {
112
+ const archived = (typeof it.archivedAt === 'number' && Number.isFinite(it.archivedAt))
113
+ ? it.archivedAt
114
+ : (typeof it.createdAt === 'number' && Number.isFinite(it.createdAt) ? it.createdAt : undefined);
115
+ if (archived === undefined)
116
+ continue;
117
+ const baseline = updatedAt > 0 ? Math.max(archived, updatedAt) : archived;
118
+ if (baseline <= cutoff)
119
+ out.push(it.sessionId);
120
+ }
121
+ return out;
122
+ }
123
+ async function sweepHistory() {
124
+ const registry = getSessionsRegistry();
125
+ if (!registry)
126
+ return { swept: [] };
127
+ const { retentionDays, updatedAt } = await readHistoryRetention();
128
+ if (!(retentionDays > 0))
129
+ return { swept: [] };
130
+ try {
131
+ const details = (typeof registry.archivedSessionDetails === 'function')
132
+ ? (await registry.archivedSessionDetails()).items
133
+ : (await registry.archivedSessionMetadata()).items.map((i) => ({ sessionId: i.sessionId, createdAt: i.createdAt, archivedAt: registry.archivedAt?.(i.sessionId) }));
134
+ const expired = expiredArchivedIds(details, retentionDays, Date.now(), updatedAt);
135
+ if (expired.length === 0)
136
+ return { swept: [] };
137
+ const res = await registry.deleteArchivedSessions({ scope: 'sessions', sessionIds: expired });
138
+ return { swept: res.deletedSessionIds || [] };
139
+ }
140
+ catch (e) {
141
+ console.error('[dsh-plugin-tool-management] history sweep failed:', message(e));
142
+ return { swept: [] };
143
+ }
144
+ }
145
+ // 启动时扫一次,再周期复跑。fake-ctx 测试里 ctx.effect 立即调用并 dispose,
146
+ // interval 未提供时退化为不挂钟(不阻塞测试)。
147
+ try {
148
+ void sweepHistory();
149
+ }
150
+ catch { /* 非致命 */ }
151
+ try {
152
+ ctx.effect(() => {
153
+ const timer = ctx;
154
+ const fn = typeof timer.interval === 'function' ? timer.interval : (typeof timer.setInterval === 'function' ? timer.setInterval : undefined);
155
+ if (!fn)
156
+ return () => { };
157
+ return fn(() => { void sweepHistory(); }, sweepIntervalMs);
158
+ }, 'dsh-plugin-tool-management: history sweep');
159
+ }
160
+ catch { /* timer 缺失时静默 */ }
161
+ // 批量操作目标校验:scope 白名单;sessions 需非空 sessionIds;workspace 需
162
+ // workspaceId。与 ArchiveWorkspaceRegistry.archivedBatchTargetSchema 语义一致。
163
+ function parseHistoryBatchTarget(raw) {
164
+ const t = raw && typeof raw === 'object' ? raw : null;
165
+ if (!t)
166
+ return { ok: false, error: '缺少 target' };
167
+ const scope = t.scope;
168
+ if (scope !== 'all' && scope !== 'ungrouped' && scope !== 'sessions' && scope !== 'workspace') {
169
+ return { ok: false, error: 'target.scope 不合法' };
170
+ }
171
+ if (scope === 'sessions') {
172
+ if (!Array.isArray(t.sessionIds) || t.sessionIds.length === 0 ||
173
+ t.sessionIds.some((id) => typeof id !== 'string' || !String(id).trim())) {
174
+ return { ok: false, error: 'scope=sessions 需要非空的 sessionIds 数组' };
175
+ }
176
+ }
177
+ if (scope === 'workspace' && !(typeof t.workspaceId === 'string' && String(t.workspaceId).trim())) {
178
+ return { ok: false, error: 'scope=workspace 需要 workspaceId' };
179
+ }
180
+ return {
181
+ ok: true,
182
+ target: {
183
+ scope,
184
+ ...(scope === 'sessions' ? { sessionIds: t.sessionIds.map((s) => String(s).trim()) } : {}),
185
+ ...(scope === 'workspace' ? { workspaceId: String(t.workspaceId).trim() } : {}),
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * 归档会话的归属解析(三级,宿主侧算好再给客户端):
191
+ * 1. 活登记的 sessionIds 命中 → 组 = 该登记;
192
+ * 2. 未命中但规范路径命中某活登记 → 组 = 该登记(会话不在其记账里,属正常);
193
+ * 3. 没有任何活登记 → 按会话目录重建分组(`path:<归一化路径>`),
194
+ * 目录仍在则可一键重新登记(写回宿主),目录已不在则退到 header 里的原始 cwd。
195
+ * 宿主删除工作区是硬删、不留墓碑,登记一没,`workspaceId` 就再也查不到;但会话目录
196
+ * (`registry.sessionPaths` / header.cwd)还在,所以归属可以按目录重建、并在目录回来时
197
+ * 自动并回同一组。除插件自己 `data/` 下的「见过的登记」快照外,本函数不写任何宿主状态。
198
+ * 任何一步失败都降级为「不分组」,绝不阻断列表。
199
+ */
200
+ async function buildHistoryGroups(registry, items) {
201
+ const groups = [];
202
+ const workspaces = {};
203
+ try {
204
+ const groupsById = new Map();
205
+ const liveByPath = new Map();
206
+ const owned = new Map();
207
+ const liveRecords = [];
208
+ const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
209
+ const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
210
+ if (table) {
211
+ const ids = state && Array.isArray(state.workspaceIds) && state.workspaceIds.length
212
+ ? state.workspaceIds
213
+ : [...table.entries()].map(([id]) => id);
214
+ for (const wid of ids) {
215
+ const rec = table.get(wid);
216
+ if (!rec || !Array.isArray(rec.sessionIds))
217
+ continue;
218
+ const path = typeof rec.path === 'string' && rec.path ? rec.path : undefined;
219
+ const title = (rec.title && String(rec.title)) || (path ? workspaceBaseName(path) : String(wid));
220
+ for (const sid of rec.sessionIds)
221
+ if (!owned.has(sid))
222
+ owned.set(sid, wid);
223
+ if (path) {
224
+ liveByPath.set(workspacePathKey(path), wid);
225
+ liveRecords.push({ id: String(wid), path, title });
226
+ }
227
+ const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
228
+ groupsById.set(view.id, view);
229
+ groups.push(view);
230
+ workspaces[wid] = { title, ...(path ? { path } : {}) };
231
+ }
232
+ }
233
+ // 「见过的登记」快照:登记被删后仍能给出原命名,并区分「已移除」与「从未登记」。
234
+ const snapshot = typeof registry.workspaceSnapshot === 'function' ? await registry.workspaceSnapshot() : undefined;
235
+ if (liveRecords.length && typeof registry.rememberWorkspaces === 'function') {
236
+ try {
237
+ await registry.rememberWorkspaces(liveRecords);
238
+ }
239
+ catch { /* 快照写失败不影响分组 */ }
240
+ }
241
+ const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
242
+ const headers = registry.headers instanceof Map ? registry.headers : undefined;
243
+ // 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
244
+ // 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
245
+ const dirExistsCache = new Map();
246
+ async function directoryExists(p) {
247
+ const k = workspacePathKey(p);
248
+ const hit = dirExistsCache.get(k);
249
+ if (hit !== undefined)
250
+ return hit;
251
+ let ok = false;
252
+ try {
253
+ ok = (await stat(p)).isDirectory();
254
+ }
255
+ catch {
256
+ ok = false;
257
+ }
258
+ dirExistsCache.set(k, ok);
259
+ return ok;
260
+ }
261
+ for (const it of items) {
262
+ const sid = it.sessionId;
263
+ let wid = owned.get(sid);
264
+ const canonical = sessionPaths ? sessionPaths.get(sid) : undefined;
265
+ if (wid === undefined && typeof canonical === 'string' && canonical) {
266
+ const byPath = liveByPath.get(workspacePathKey(canonical));
267
+ if (byPath !== undefined)
268
+ wid = byPath;
269
+ }
270
+ if (wid !== undefined) {
271
+ it.workspaceId = wid;
272
+ it.groupId = 'ws:' + wid;
273
+ continue;
274
+ }
275
+ let path = typeof canonical === 'string' && canonical ? canonical : undefined;
276
+ if (path === undefined && it.cwd)
277
+ path = it.cwd;
278
+ if (path === undefined && headers) {
279
+ const cwd = headers.get(sid)?.cwd;
280
+ if (typeof cwd === 'string' && cwd)
281
+ path = cwd;
282
+ }
283
+ if (!path)
284
+ continue;
285
+ const key = 'path:' + workspacePathKey(path);
286
+ it.groupId = key;
287
+ const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
288
+ const existing = groupsById.get(key);
289
+ if (existing) {
290
+ // 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
291
+ if (canonical !== undefined || (await directoryExists(path))) {
292
+ existing.canRegister = true;
293
+ existing.dirMissing = false;
294
+ }
295
+ continue;
296
+ }
297
+ const canRegister = canonical !== undefined || (await directoryExists(path));
298
+ const view = {
299
+ id: key,
300
+ title: known?.title || workspaceBaseName(path) || path,
301
+ kind: 'detached',
302
+ registered: known !== undefined,
303
+ canRegister,
304
+ dirMissing: !canRegister,
305
+ path: known?.path || path,
306
+ };
307
+ groupsById.set(key, view);
308
+ groups.push(view);
309
+ }
310
+ }
311
+ catch { /* 分组是增强:任何异常都退回扁平列表 */ }
312
+ return { groups, workspaces };
313
+ }
314
+ return {
315
+ getSessionsRegistry,
316
+ restoreWorkspaceAccounting,
317
+ readHistoryRetention,
318
+ writeHistoryRetention,
319
+ sweepHistory,
320
+ parseHistoryBatchTarget,
321
+ buildHistoryGroups,
322
+ };
323
+ }
@@ -1,7 +1,7 @@
1
1
  //#region lib/types/tombstone.js
2
2
  /**
3
3
  * 通用墓碑簿记:登记一个已删除 id,并按 FIFO 在上限处淘汰最旧项。
4
- * 工作区注册表(lib/history/workspace.js)在用;淘汰策略集中在这里,避免各处拷贝分叉。
4
+ * 工作区注册表(lib/sessions/workspace.js)在用;淘汰策略集中在这里,避免各处拷贝分叉。
5
5
  */
6
6
  /**
7
7
  * 登记已删除 id 并执行上限淘汰。