dsh-plugin-tool-management 0.17.0 → 0.18.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 (83) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -3
  3. package/README_EN.md +18 -3
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +16 -0
  21. package/lib/client.js +1573 -133
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/http-api.js +229 -0
  33. package/lib/hub.js +2 -0
  34. package/lib/index.js +134 -836
  35. package/lib/mcp/inspect-probe.js +199 -0
  36. package/lib/mcp/manager.js +30 -548
  37. package/lib/mcp/tool-visibility.js +388 -0
  38. package/lib/memories/archive-engine.js +142 -5
  39. package/lib/memories/archive.js +10 -1
  40. package/lib/memories/discovery.js +261 -0
  41. package/lib/memories/export-plan.js +42 -0
  42. package/lib/memories/memories-types.js +1 -0
  43. package/lib/memories/service.js +4 -38
  44. package/lib/memories/snapshot.js +10 -252
  45. package/lib/op-registry.js +21 -0
  46. package/lib/ops/compat.js +11 -0
  47. package/lib/ops/quick-prompts.js +26 -0
  48. package/lib/ops/scene-records.js +40 -0
  49. package/lib/ops/scene-sync.js +71 -1
  50. package/lib/ops/sessions.js +13 -9
  51. package/lib/ops/snapshot.js +18 -12
  52. package/lib/prompts/quick-service.js +211 -0
  53. package/lib/scene-linkage.js +184 -0
  54. package/lib/sessions/archive-schemas.js +121 -0
  55. package/lib/sessions/path-key.js +46 -0
  56. package/lib/sessions/workspace.js +5 -163
  57. package/lib/settings-sidecars.js +143 -0
  58. package/lib/skills/core-types.js +8 -0
  59. package/lib/skills/core-util.js +81 -0
  60. package/lib/skills/core.js +56 -2326
  61. package/lib/skills/dir-watch.js +71 -0
  62. package/lib/skills/frontmatter.js +96 -0
  63. package/lib/skills/homes.js +14 -0
  64. package/lib/skills/import-pipeline.js +231 -0
  65. package/lib/skills/manager-state.js +800 -0
  66. package/lib/skills/official.js +269 -0
  67. package/lib/skills/provider-registration.js +89 -0
  68. package/lib/skills/service.js +5 -240
  69. package/lib/skills/skill-roots.js +287 -0
  70. package/lib/skills/skill-trash.js +265 -0
  71. package/lib/skills/system-trash.js +97 -0
  72. package/lib/skills/upload-pipeline.js +374 -0
  73. package/lib/slash-settings.js +25 -0
  74. package/lib/subagents/persona-parse.js +312 -0
  75. package/lib/subagents/persona-types.js +5 -0
  76. package/lib/subagents/persona-write.js +93 -0
  77. package/lib/subagents/run-provider.js +159 -0
  78. package/lib/subagents/service.js +14 -638
  79. package/lib/subagents/tool-filter.js +98 -0
  80. package/lib/token-config.js +240 -0
  81. package/lib/tool-table-switch.js +163 -0
  82. package/lib/tools/scene.js +40 -9
  83. package/package.json +3 -2
@@ -8,13 +8,17 @@
8
8
  //
9
9
  // 对 service.ts 只做 type-only 引用(`import type`),避免与它形成运行时循环依赖。
10
10
  import { readdirSync, statSync } from 'node:fs';
11
- import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
12
- import { dirname, join, resolve } from 'node:path';
13
- import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
14
- import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { parseSkillDoc } from '../skills/core.js';
14
+ import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
15
15
  import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeading, sceneLine, compareSceneBuckets, memoryBlock } from './projection.js';
16
- import { isIndexQuarantined, readIndex, writeIndex, pathExists, readFileOrThrowSync, isAbsentError } from './index-io.js';
17
- export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
16
+ import { isIndexQuarantined, readIndex, writeIndex, readFileOrThrowSync, isAbsentError } from './index-io.js';
17
+ // 来源发现与投影(扫目录读成条目 / frontmatter 投成规则 / 老布局搬家)2026-10-01 整段搬到
18
+ // ./discovery.js,正文一字未改(moved-verify 逐字对拍)。snapshot.js 的公开面照旧:搬走的
19
+ // 九件全部从这里再出口 —— memories/service.ts 按 ./snapshot.js 引它们。
20
+ export { deriveDescription, deriveFromDoc, discover, identity, projectRule, relocateLegacyLayout, } from './discovery.js';
21
+ import { deriveFromDoc, discover, projectRule, } from './discovery.js';
18
22
  // ── 文件工具 ───────────────────────────────────────────────────────────────
19
23
  /**
20
24
  * BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
@@ -27,213 +31,10 @@ export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() :
27
31
  * 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
28
32
  * 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
29
33
  */
30
- export async function discover(memoriesRoot) {
31
- const entries = new Map();
32
- const shadowed = [];
33
- const groups = new Set();
34
- const scenes = [];
35
- const warnings = [];
36
- const rootPath = resolve(memoriesRoot);
37
- try {
38
- const st = await lstat(rootPath);
39
- if (!st.isDirectory() || st.isSymbolicLink())
40
- return { entries, shadowed, groups, scenes, warnings, truncated: false };
41
- }
42
- catch {
43
- return { entries, shadowed, groups, scenes, warnings, truncated: false };
44
- }
45
- const queue = [
46
- { path: rootPath, group: '', depth: 0, ancestors: new Set() },
47
- ];
48
- let directories = 0;
49
- let itemCount = 0;
50
- let truncated = false;
51
- const addEntry = (entry) => {
52
- const existing = entries.get(entry.id);
53
- if (!existing) {
54
- entries.set(entry.id, entry);
55
- return;
56
- }
57
- if (entry.kind === 'bundle' && existing.kind === 'flat') {
58
- shadowed.push({ ...existing, shadowed: true });
59
- entries.set(entry.id, entry);
60
- return;
61
- }
62
- if (entry.kind === 'flat' && existing.kind === 'bundle') {
63
- shadowed.push({ ...entry, shadowed: true });
64
- }
65
- };
66
- for (let cursor = 0; cursor < queue.length; cursor++) {
67
- if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
68
- truncated = true;
69
- break;
70
- }
71
- const current = queue[cursor];
72
- let realDirectory;
73
- let dirents = [];
74
- try {
75
- realDirectory = await realpath(current.path);
76
- if (current.ancestors.has(identity(realDirectory)))
77
- continue;
78
- const dir = await readdir(current.path, { withFileTypes: true });
79
- directories++;
80
- for (const item of dir) {
81
- if (itemCount >= MAX_ENTRIES) {
82
- truncated = true;
83
- break;
84
- }
85
- itemCount++;
86
- dirents.push(item);
87
- }
88
- }
89
- catch {
90
- continue; // 失效链接或不可读目录不阻断其他来源
91
- }
92
- // bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
93
- //
94
- // **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
95
- // `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
96
- // 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
97
- // 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
98
- // 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
99
- if (current.group !== '') {
100
- const sepIdx = current.group.lastIndexOf('/');
101
- const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
102
- const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
103
- let docPath = '';
104
- if (parentGroup !== '') {
105
- for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
106
- const p = join(current.path, candidate);
107
- try {
108
- const st = await lstat(p);
109
- if (st.isFile() && !st.isSymbolicLink()) {
110
- docPath = p;
111
- break;
112
- }
113
- }
114
- catch { /* 该候选名不存在 → 试下一个 */ }
115
- }
116
- }
117
- if (docPath !== '') {
118
- const id = parentGroup ? `${parentGroup}/${name}` : name;
119
- addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
120
- continue;
121
- }
122
- }
123
- if (current.group !== '') {
124
- groups.add(current.group);
125
- // 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
126
- if (current.depth === 1)
127
- scenes.push(current.group);
128
- }
129
- dirents.sort((a, b) => a.name.localeCompare(b.name));
130
- const ancestors = new Set(current.ancestors).add(identity(realDirectory));
131
- for (const item of dirents) {
132
- if (item.name.startsWith('.'))
133
- continue; // 隐藏目录/文件跳过
134
- const path = join(current.path, item.name);
135
- try {
136
- const st = await lstat(path);
137
- // 符号链接一律不跟随:注入路径(`probeSceneFilesSync`)本来就不跟随,两端必须同口径 ——
138
- // 否则列表里会出现**永远注入不进去**的记忆(用户打开开关却什么也没发生)。顺带也关掉了
139
- // 「链接指向根外、写操作顺着链接穿透出去」这条路(写入侧另有 realpath 断言兜底)。
140
- if (st.isSymbolicLink())
141
- continue;
142
- if (st.isDirectory()) {
143
- if (!isValidGroupSegment(item.name)) {
144
- warnings.push(`跳过非法目录名(${SEGMENT_RULE_HINT}):${current.group ? current.group + '/' : ''}${item.name}`);
145
- continue;
146
- }
147
- if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
148
- truncated = true;
149
- continue;
150
- }
151
- queue.push({
152
- path,
153
- group: current.group ? `${current.group}/${item.name}` : item.name,
154
- depth: current.depth + 1,
155
- ancestors,
156
- });
157
- continue;
158
- }
159
- if (!st.isFile())
160
- continue;
161
- if (!item.name.toLowerCase().endsWith('.md'))
162
- continue; // 只认 .md
163
- const name = item.name.slice(0, -3);
164
- const id = current.group ? `${current.group}/${name}` : name;
165
- addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
166
- }
167
- catch {
168
- /* 来源在扫描期间失效时跳过 */
169
- }
170
- }
171
- }
172
- return { entries, shadowed, groups, scenes, warnings, truncated };
173
- }
174
34
  // ── 规则解析 + 派生 ────────────────────────────────────────────────────────
175
35
  /** 派生 description:首个 `#` 标题 → 首个非空行;截断 500 字符。 */
176
- export function deriveDescription(body) {
177
- const text = String(body ?? '').trim();
178
- if (!text)
179
- return '';
180
- const heading = /^#\s+(.+)$/m.exec(text);
181
- const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
182
- return raw.slice(0, MAX_DESCRIPTION_LENGTH);
183
- }
184
36
  /** 从 frontmatter/正文派生字段(派生只存在于内存投影,不写回文件)。 */
185
- export function deriveFromDoc(entry, doc) {
186
- const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
187
- const name = declaredName || entry.name;
188
- let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
189
- let descriptionDerived = false;
190
- if (description === '') {
191
- description = deriveDescription(doc.body);
192
- descriptionDerived = true;
193
- }
194
- const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
195
- let whenToUse;
196
- if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
197
- whenToUse = String(doc.map.whenToUse).trim();
198
- }
199
- else if (globs) {
200
- whenToUse = `适用路径:${globs}`;
201
- }
202
- const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
203
- return { name, description, descriptionDerived, whenToUse, globs, metadata };
204
- }
205
37
  /** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
206
- export function projectRule(entry, derived, idxEntry) {
207
- // 体积按**正文文件**算(bundle 的附件不计:附件不进注入正文)。一次 stat 一条记忆,
208
- // 结果随快照的 1s TTL 缓存一起复用;读不到就不带这个字段,别把"不知道"报成 0 B。
209
- let size;
210
- try {
211
- size = statSync(entry.docPath).size;
212
- }
213
- catch {
214
- size = undefined;
215
- }
216
- const rule = {
217
- id: entry.id,
218
- group: entry.group,
219
- name: derived.name,
220
- form: entry.kind,
221
- path: entry.docPath,
222
- ...(size !== undefined ? { bytes: size } : {}),
223
- description: derived.description,
224
- ...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
225
- ...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
226
- ...(derived.globs !== undefined ? { globs: derived.globs } : {}),
227
- ...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
228
- enabled: idxEntry?.enabled ?? true,
229
- order: idxEntry?.order ?? DEFAULT_ORDER,
230
- tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
231
- pinned: idxEntry?.pinned === true,
232
- note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
233
- ...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
234
- };
235
- return rule;
236
- }
237
38
  // ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
238
39
  /** 把旧布局搬进 `tool-management/`(每个进程只尝试一次,幂等)。
239
40
  *
@@ -246,49 +47,6 @@ export function projectRule(entry, derived, idxEntry) {
246
47
  * 源目录**保留**(内容已搬走,留空壳不影响正确性,也方便用户核对)。
247
48
  * 目标已存在同名项 → 保留目标、跳过该项(绝不覆盖新数据)。
248
49
  */
249
- export async function relocateLegacyLayout(memoriesRoot) {
250
- const home = resolveDshHome();
251
- const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
252
- for (const legacyRoot of legacyRoots) {
253
- if (!(await pathExists(legacyRoot)))
254
- continue;
255
- if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
256
- continue; // 自定义 roots 落在旧路径 → 不自我搬移
257
- let items;
258
- try {
259
- items = await readdir(legacyRoot, { withFileTypes: true });
260
- }
261
- catch {
262
- continue;
263
- }
264
- for (const item of items) {
265
- if (item.name.startsWith('.'))
266
- continue;
267
- const src = join(legacyRoot, item.name);
268
- // 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
269
- // 其余条目(场景目录)整体搬进 memories/,目录名保留。
270
- const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
271
- const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
272
- const dest = join(destDir, item.name);
273
- try {
274
- if (await pathExists(dest))
275
- continue;
276
- await mkdir(destDir, { recursive: true });
277
- try {
278
- await rename(src, dest);
279
- }
280
- catch {
281
- // 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
282
- await cp(src, dest, { recursive: true });
283
- await rm(src, { recursive: true, force: true });
284
- }
285
- }
286
- catch {
287
- /* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
288
- }
289
- }
290
- }
291
- }
292
50
  // ── 快照(发现 + 索引合并 + 索引清理)──────────────────────────────────────
293
51
  export async function buildSnapshot(memoriesRoot, stateDir) {
294
52
  const discovery = await discover(memoriesRoot);
@@ -153,6 +153,22 @@ export const OP_REGISTRY = Object.freeze({
153
153
  'agentsmd-import': { write: true, frozen: true },
154
154
  'agentsmd-trash-restore': { write: true, frozen: true },
155
155
  'agentsmd-trash-delete': { write: true, frozen: true },
156
+ // ── 快捷提示词域(8)───────────────────────────────────────────────────────
157
+ // 只改插件自己的侧车(`hub/quick-prompts/`),不写 AGENTS.md、不进场景、不进注入;
158
+ // 但"改用户存好的东西"按本文件口径就是写操作(判据不是"会不会写盘",见文件头)。
159
+ // frozen 与五个管理域同一条:**锁定期间整体只读**(用户裁定 2026-09-30,不给它开例外,
160
+ // 免得"锁定"这件事要分两套话术解释)。
161
+ 'quickprompt-list': { readonly: true },
162
+ 'quickprompt-trash-list': { readonly: true },
163
+ 'quickprompt-create': { write: true, frozen: true },
164
+ 'quickprompt-update': { write: true, frozen: true },
165
+ // 那颗开关改的是"这条在不在对话框 `/` 菜单里出现",动的仍是用户自己的东西 ——
166
+ // 与 create/update 同档,不因为它不碰正文就免了令牌。`syncsArchive`:场景绑了快捷词时,
167
+ // 在场景里手动开关一条要跟着改进那份勾选集(未绑定的场景不跟,见 index.ts 那一支的注释)。
168
+ 'quickprompt-toggle': { write: true, frozen: true, syncsArchive: true },
169
+ 'quickprompt-remove': { write: true, frozen: true },
170
+ 'quickprompt-trash-restore': { write: true, frozen: true },
171
+ 'quickprompt-trash-delete': { write: true, frozen: true },
156
172
  // ── 历史会话域(16)────────────────────────────────────────────────────────
157
173
  'history-list': { readonly: true },
158
174
  'history-sessions': { readonly: true },
@@ -205,6 +221,11 @@ export const OP_REGISTRY = Object.freeze({
205
221
  // **刻意不冻结**:它只是界面提示,锁着场景的人在场景页照样该能关掉提醒 —— 冻结的是五个
206
222
  // 管理域的改动,不是这个页面的显示偏好(判据见文件头 write/frozen 两段的边界)。
207
223
  'scene-settings': { write: true, writeWhen: (a) => a.set === true },
224
+ // 斜杠命令入口的开关(侧车 `slash-settings.json`):与 `scene-settings` 同规格 —— 写侧车所以
225
+ // 按写门禁,但**刻意不冻结**(关掉一个界面入口不属于五个管理域的改动)。读侧不带 `set` 是纯读:
226
+ // 客户端 boot 时必问一次决定挂不挂 `/` 菜单,那一次不能要令牌,否则没配令牌的宿主上这个功能
227
+ // 永远出不来。
228
+ 'slash-settings': { write: true, writeWhen: (a) => a.set === true },
208
229
  // 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
209
230
  'backups-clean': { write: true },
210
231
  // ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
package/lib/ops/compat.js CHANGED
@@ -259,6 +259,17 @@ export function buildCompatOps(deps) {
259
259
  key: 'tool-table', label: '模型工具表', tab: 'compat', state: 'ok', detail: offPart,
260
260
  hidden: table.hiddenCount, total: table.totalCount, defaults: table.defaultHiddenCount,
261
261
  });
262
+ // 斜杠命令入口(`/` 菜单里的「工具」与「快捷提示词」两段,各一颗开关)。服务端只知道
263
+ // **开关开没开**:真正挂没挂上是浏览器那半的事(宿主有没有 `inputTriggers` 服务),那一句
264
+ // 由兼容页就地报,不冒充服务端探测(见 client 48-slash.js 的挂载状态)。两个布尔一起带上,
265
+ // "开着哪一段"由界面按语言说。
266
+ const slash = await deps.readSlashSettings();
267
+ push({
268
+ key: 'slash-commands', label: '斜杠命令入口', tab: 'compat',
269
+ state: slash.tools || slash.quickPrompts ? 'ok' : 'disabled',
270
+ detail: slash.tools || slash.quickPrompts ? '开关已开(在对话输入框打 / 唤出已打开的那几段)' : '两段都已关闭(本页可重新打开)',
271
+ slashTools: slash.tools, slashQuickPrompts: slash.quickPrompts,
272
+ });
262
273
  const nativeNote = notes.get('workspace.delete-native');
263
274
  push({
264
275
  key: 'native-delete', label: '宿主原生删除入口', tab: 'compat',
@@ -0,0 +1,26 @@
1
+ // 快捷提示词域的 HTTP ops(实现见 ../prompts/quick-service.ts)。
2
+ //
3
+ // 与 `ops/prompts.ts` 的差别不是"少写了几条",而是**这个域没有宿主状态**:不写
4
+ // `~/.dsh/AGENTS.md`、不参与场景同步、没有"生效中"。所以这里既不需要 `withAgentsMdSync`
5
+ // 也不需要引用探测 —— 反过来,正因为它是纯侧车读写,才更要老老实实进 `op-registry`
6
+ // 的写门禁清单(配了令牌的宿主上,免令牌请求不该能改用户存好的文字)。
7
+ export function buildQuickPromptOps(quick) {
8
+ const text = (args, key) => args && typeof args[key] === 'string' ? String(args[key]) : undefined;
9
+ return {
10
+ // 列表连正文一起回(客户端点用时手里就得有那段字,见 quick-service.ts 头注)。
11
+ 'quickprompt-list': async () => quick.list(),
12
+ 'quickprompt-create': (args) => quick.create(String((args && args.id) || ''), text(args, 'content') ?? '', text(args, 'description')),
13
+ // 改名不牵连任何别处:快捷提示词不被场景绑定、不被引用,改完就是改完了。
14
+ 'quickprompt-update': (args) => quick.update(String((args && args.id) || ''), text(args, 'content') ?? '', args && args.nextId !== undefined ? String(args.nextId) : undefined, text(args, 'description')),
15
+ 'quickprompt-remove': (args) => quick.remove(String((args && args.id) || '')),
16
+ // 启停只改侧车的 `enabled`(在不在对话框 `/` 菜单里出现),正文一个字不动。
17
+ // `enabled` 必须是真布尔:省略它不叫"打开",而是调用方写错了,静默按某一种处理会
18
+ // 让界面与落盘各说各话。
19
+ 'quickprompt-toggle': (args) => (args && typeof args.enabled === 'boolean')
20
+ ? quick.setEnabled(String((args && args.id) || ''), args.enabled === true)
21
+ : Promise.resolve({ ok: false, error: '缺少 enabled(true / false)' }),
22
+ 'quickprompt-trash-list': () => quick.trashList(),
23
+ 'quickprompt-trash-restore': (args) => quick.trashRestore(String((args && args.id) || '')),
24
+ 'quickprompt-trash-delete': (args) => quick.trashDelete(String((args && args.id) || '')),
25
+ };
26
+ }
@@ -8,6 +8,21 @@ import { listTrashEntries, moveIntoTrash, moveOutOfTrash, moveToTrash, purgeTras
8
8
  import { MAX_DESCRIPTION_LENGTH, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, SHARED_GROUP, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
9
9
  import { normalizeActive, collapseActiveForNewScene } from '../memories/projection.js';
10
10
  import { readIndex, writeIndex, sceneRecordOf, normalizeScenePromptId, pathExists } from '../memories/index-io.js';
11
+ /** `quickPrompts` 参数的三种形态:不传 = 完全不碰这一域;null / 非数组 = 解绑(删字段);
12
+ * 数组 = 勾选集(去重去空)。**空数组是合法值**(= 进入后全部停用),不能当成"没绑"。 */
13
+ function quickPromptsArg(args) {
14
+ if (!args || args.quickPrompts === undefined)
15
+ return undefined;
16
+ if (!Array.isArray(args.quickPrompts))
17
+ return null;
18
+ const out = [];
19
+ for (const item of args.quickPrompts) {
20
+ const id = String(item == null ? '' : item).trim();
21
+ if (id && out.indexOf(id) < 0)
22
+ out.push(id);
23
+ }
24
+ return out;
25
+ }
11
26
  /** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
12
27
  export function buildSceneRecordOps(rc) {
13
28
  const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, refuseOutsideRoot, sceneRows, snapshot, stateDir } = rc;
@@ -132,6 +147,14 @@ export function buildSceneRecordOps(rc) {
132
147
  index.archives = {};
133
148
  index.archives[name] = { ...(index.archives[name] || {}), toolTablePreset };
134
149
  }
150
+ // 快捷提示词勾选集(0.18.0):同样存进**档案**。空数组也要落 —— 它说的是"进入后全部停用",
151
+ // 与"没绑"(不碰这一域)是相反的意思。
152
+ const quickCreate = quickPromptsArg(args);
153
+ if (quickCreate) {
154
+ if (!index.archives)
155
+ index.archives = {};
156
+ index.archives[name] = { ...(index.archives[name] || {}), quickPrompts: quickCreate };
157
+ }
135
158
  await writeIndex(stateDir, index);
136
159
  invalidateSnapshot();
137
160
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
@@ -204,6 +227,23 @@ export function buildSceneRecordOps(rc) {
204
227
  else
205
228
  delete index.archives[name];
206
229
  }
230
+ if (args && args.quickPrompts !== undefined) {
231
+ // 快捷提示词勾选集(0.18.0,用户裁定入口在「修改场景」表单):与工具表方案同一族 ——
232
+ // 绑定存**档案**,这一次读-改-写顺带落掉。`null` = 解绑(这一域回到"保持现状");
233
+ // 空数组 = 绑定了但一条都没勾(进入后全部停用),**不是**解绑。不传这个参数 = 完全不碰。
234
+ const quick = quickPromptsArg(args);
235
+ if (!index.archives)
236
+ index.archives = {};
237
+ const archive = { ...((index.archives && index.archives[name]) || {}) };
238
+ if (quick === null)
239
+ delete archive.quickPrompts;
240
+ else if (quick !== undefined)
241
+ archive.quickPrompts = quick;
242
+ if (Object.keys(archive).length > 0)
243
+ index.archives[name] = archive;
244
+ else
245
+ delete index.archives[name];
246
+ }
207
247
  if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
208
248
  const label = String(args.label).trim();
209
249
  if (label === '')
@@ -2,6 +2,56 @@
2
2
  //
3
3
  // rules 域的**其余 op 由 ./memories/service.ts 直接 spread 进表**,不经过本文件 —— 这里只放
4
4
  // 四个要走「场景 ↔ 全局基线同步」包装的写 op。
5
+ import { GLOBAL_SCENE, SHARED_GROUP } from '../memories/constants.js';
6
+ /** 参数形态不归本层管(多场景 / 非法名 / `all`):交给原 op 校验并报错,运行时一律不动。 */
7
+ const SKIP = Symbol('skip');
8
+ /**
9
+ * 「启用集合」与「当前模式」是同一个意图的两片状态:`active` 决定记忆与绑定的提示词按哪个
10
+ * 场景注入,`mode.scene` 决定运行时(MCP / 技能 / 人设 / 工具表 / 快捷词)停在哪份档案上。
11
+ * 两者必须恒等 —— 历史上每次只有一个动,就出现过一个方向各坏一次:拨开关不应用档案(面板
12
+ * 0.9.x 的旧账,见 45-scenes.js 的 ⚠️ 注释),以及 `/` 菜单进 / 出场景只写 `active`
13
+ * (用户 2026-10-01 报,图见 CHANGELOG)。
14
+ *
15
+ * 所以这里**在服务端兜住这条不变量**,而不是要求每个调用方各开两枪:`rules-set-active` 是
16
+ * "我要让哪个场景生效"的那个 op,四个调用方(面板开关、`/` 菜单、模型工具、以后新增的入口)
17
+ * 只要调它,运行时就会跟着对齐。
18
+ *
19
+ * 顺序是**先切运行时、后写启用集合**:引擎会校验场景存在、没被锁、绑的工具表方案还在,
20
+ * 它拒绝时启用集合必须一个字都不动。反过来(先写 active)留下的正是"开关过去了、运行时没切"
21
+ * 那个半截状态 —— 也就是这一版要消灭的东西。
22
+ */
23
+ async function syncModeToActive(args, deps) {
24
+ const want = targetSceneOf(args);
25
+ if (want === SKIP)
26
+ return null;
27
+ if (want === (await deps.currentModeScene()))
28
+ return null;
29
+ return deps.sceneModeSet({ scene: want });
30
+ }
31
+ /**
32
+ * 从 `scenes:[...]` 里取出"这一次要让哪个场景生效"(`null` = 一个都不让 = 退出模式)。
33
+ *
34
+ * 保留名的过滤与去重和 `rulesSetActive` **同源**(同一份常量,不是抄一份):`_shared` 与
35
+ * `global` 恒常生效,不进显式集合 —— 所以 `['global','_shared']` 与原 op 一样读成"清空"。
36
+ * 收敛后仍多于一个 = 单选模型下的非法请求,原 op 会拒,这里回 SKIP 让运行时一动不动
37
+ * (宁可少切一次,也不要按一个原 op 马上要拒绝的参数去改运行时)。
38
+ */
39
+ function targetSceneOf(args) {
40
+ if (args && args.all === true)
41
+ return SKIP;
42
+ const raw = args && args.scenes;
43
+ if (!Array.isArray(raw))
44
+ return SKIP;
45
+ const names = [];
46
+ for (const item of raw) {
47
+ const name = String(item == null ? '' : item).trim();
48
+ if (!name || name === SHARED_GROUP || name === GLOBAL_SCENE)
49
+ continue;
50
+ if (names.indexOf(name) < 0)
51
+ names.push(name);
52
+ }
53
+ return names.length > 1 ? SKIP : (names[0] ?? null);
54
+ }
5
55
  export function buildSceneSyncOps(deps) {
6
56
  return {
7
57
  // 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
@@ -9,7 +59,20 @@ export function buildSceneSyncOps(deps) {
9
59
  // 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
10
60
  // 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
11
61
  // 组装时放在 memoriesService.ops 的 spread **之后**,显式覆盖同名 op。
12
- 'rules-set-active': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-set-active'](args)),
62
+ //
63
+ // `rules-set-active` 额外承担「两轴恒等」(见文件头):先把运行时切到位,再写启用集合。
64
+ 'rules-set-active': async (args) => {
65
+ const mode = await syncModeToActive(args, deps);
66
+ if (mode && mode.ok === false)
67
+ return mode;
68
+ const res = await deps.rulesOps['rules-set-active'](args);
69
+ if (!res || res.ok === false)
70
+ return res;
71
+ // 引擎跳过的档案键(本机已不存在的服务器 / 技能键)并进来给调用方 —— 面板与 `/` 都要
72
+ // 那句"进了,但没全进"。
73
+ const stale = mode && Array.isArray(mode.stale) && mode.stale.length ? mode.stale : null;
74
+ return deps.withAgentsMdSync(stale ? Object.assign({}, res, { modeStale: stale }) : res);
75
+ },
13
76
  'rules-update-scene': async (args) => {
14
77
  const res = await deps.rulesOps['rules-update-scene'](args);
15
78
  // 工具表方案绑定改动(0.15.0):改的是**当前启用场景**且这次真的带了这一参数时即时生效;
@@ -20,6 +83,13 @@ export function buildSceneSyncOps(deps) {
20
83
  }
21
84
  catch { /* 即时生效失败不拦保存;下次进/退场景会对齐 */ }
22
85
  }
86
+ // 快捷提示词勾选集同理:只有这次真的带了这一参数才动(不传 = 不碰这一域)。
87
+ if (res && res.ok !== false && deps.onQuickPromptsChanged && args && args.quickPrompts !== undefined) {
88
+ try {
89
+ await deps.onQuickPromptsChanged(String(args.name || ''), Array.isArray(args.quickPrompts) ? args.quickPrompts.map((x) => String(x)) : null);
90
+ }
91
+ catch { /* 同上 */ }
92
+ }
23
93
  return deps.withAgentsMdSync(res);
24
94
  },
25
95
  'rules-create-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-create-scene'](args)),
@@ -392,15 +392,15 @@ export function buildSessionOps(deps) {
392
392
  // 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
393
393
  'bundle-export': async (args) => {
394
394
  const kind = String((args && args.kind) || '');
395
- const KINDS = ['skills', 'subagents', 'presets', 'memories'];
395
+ const KINDS = ['skills', 'subagents', 'presets', 'quick-prompts', 'memories'];
396
396
  if (KINDS.indexOf(kind) < 0)
397
397
  return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
398
398
  const rawNames = (args && args.names) || [];
399
399
  const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
400
400
  if (!names.length)
401
401
  return { ok: false, error: '请至少选择一项' };
402
- // 名字逐项校验:子智能体与提示词这两个域的名字会被拼成绝对路径直接进 readFile,
403
- // `..` 或路径分隔符能读到根外(此前只做 trim().filter(Boolean))。
402
+ // 名字逐项校验:子智能体、提示词预设与快捷提示词这三个域的名字会被拼成绝对路径直接进
403
+ // readFile,`..` 或路径分隔符能读到根外(此前只做 trim().filter(Boolean))。
404
404
  // 另外两个域**不套单段谓词**,原因不同:
405
405
  // - 记忆:名字是 `<场景>/<名>` 的 id,只用来查索引拿路径,本身不参与拼接;
406
406
  // - 技能:名字可以是多段相对路径(只读来源的 bundle 用 relative() 拼出名字),
@@ -408,7 +408,7 @@ export function buildSessionOps(deps) {
408
408
  // 匹配不上就进 missing,套单段谓词反而会把合法的多段技能名误杀。
409
409
  const rejected = [];
410
410
  const accepted = names.filter((name) => {
411
- const ok = kind === 'presets' ? isValidPresetId(name)
411
+ const ok = kind === 'presets' || kind === 'quick-prompts' ? isValidPresetId(name)
412
412
  : kind === 'subagents' ? validPersonaName(name)
413
413
  : true;
414
414
  if (!ok)
@@ -441,16 +441,20 @@ export function buildSessionOps(deps) {
441
441
  entries.push({ zip: `${name}.md`, abs });
442
442
  }
443
443
  }
444
- else if (kind === 'presets') {
444
+ else if (kind === 'presets' || kind === 'quick-prompts') {
445
+ // 两个提示词域同形(一 id 一目录、正文 + `meta.json` 描述侧车),差别只在根目录与
446
+ // 正文文件名 —— 走同一段代码,别让"导出快捷词"变成第二份会漏改的拷贝。
447
+ const root = kind === 'quick-prompts' ? deps.quickPromptsDir : deps.promptsDir;
448
+ const doc = kind === 'quick-prompts' ? 'PROMPT.md' : 'AGENTS.md';
445
449
  for (const id of accepted) {
446
- const docAbs = join(deps.promptsDir, id, 'AGENTS.md');
447
- if (!isInsideRoot(deps.promptsDir, docAbs)) {
450
+ const docAbs = join(root, id, doc);
451
+ if (!isInsideRoot(root, docAbs)) {
448
452
  missing.push(id);
449
453
  continue;
450
454
  }
451
- entries.push({ zip: `${id}/AGENTS.md`, abs: docAbs });
455
+ entries.push({ zip: `${id}/${doc}`, abs: docAbs });
452
456
  // 描述侧车(预设那句说明)随预设一起走:导出再导入不该把它丢掉。
453
- const metaAbs = join(deps.promptsDir, id, 'meta.json');
457
+ const metaAbs = join(root, id, 'meta.json');
454
458
  try {
455
459
  if ((await stat(metaAbs)).isFile())
456
460
  entries.push({ zip: `${id}/meta.json`, abs: metaAbs });