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.
- package/CHANGELOG.md +39 -0
- package/README.md +18 -3
- package/README_EN.md +18 -3
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +16 -0
- package/lib/client.js +1573 -133
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +9 -720
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +10 -478
- package/lib/http-api.js +229 -0
- package/lib/hub.js +2 -0
- package/lib/index.js +134 -836
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/manager.js +30 -548
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +142 -5
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +4 -38
- package/lib/memories/snapshot.js +10 -252
- package/lib/op-registry.js +21 -0
- package/lib/ops/compat.js +11 -0
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +40 -0
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +13 -9
- package/lib/ops/snapshot.js +18 -12
- package/lib/prompts/quick-service.js +211 -0
- package/lib/scene-linkage.js +184 -0
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +5 -163
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +56 -2326
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +5 -240
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +14 -638
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/ops/snapshot.js
CHANGED
|
@@ -26,10 +26,11 @@ const F_SCENES = 'scenes.json';
|
|
|
26
26
|
const D_MEMORIES = 'memories';
|
|
27
27
|
const D_SUBAGENTS = 'subagents';
|
|
28
28
|
const D_PROMPTS = 'prompts';
|
|
29
|
+
const D_QUICK = 'quick-prompts';
|
|
29
30
|
const D_SKILLS = 'skills';
|
|
30
|
-
/**
|
|
31
|
-
const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS];
|
|
32
|
-
const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_SKILLS, 'settings', 'scenes'];
|
|
31
|
+
/** 四个"文件域":整个目录打进一份 zip,键是相对路径。 */
|
|
32
|
+
const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_QUICK];
|
|
33
|
+
const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_QUICK, D_SKILLS, 'settings', 'scenes'];
|
|
33
34
|
function stamp() {
|
|
34
35
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
35
36
|
}
|
|
@@ -86,7 +87,7 @@ function namesOf(res, prefer) {
|
|
|
86
87
|
return [];
|
|
87
88
|
const bags = [res.data, res].filter(Boolean);
|
|
88
89
|
for (const bag of bags) {
|
|
89
|
-
for (const key of ['rows', 'personas', 'presets', 'scenes', 'skills', 'list', 'entries', 'items']) {
|
|
90
|
+
for (const key of ['rows', 'personas', 'presets', 'prompts', 'scenes', 'skills', 'list', 'entries', 'items']) {
|
|
90
91
|
const arr = bag[key];
|
|
91
92
|
if (!Array.isArray(arr))
|
|
92
93
|
continue;
|
|
@@ -236,11 +237,12 @@ export function createSnapshotOps(deps) {
|
|
|
236
237
|
return { ok: false, error: '写 mcp.json 失败: ' + deps.message(e) };
|
|
237
238
|
}
|
|
238
239
|
domains.mcp = mcpOut.length;
|
|
239
|
-
// ── 文件域:记忆 / 子智能体 /
|
|
240
|
+
// ── 文件域:记忆 / 子智能体 / 提示词 / 快捷提示词,各打进一份 zip(键 = 相对路径)──
|
|
240
241
|
const roots = {
|
|
241
242
|
[D_MEMORIES]: deps.hubPath(D_MEMORIES),
|
|
242
243
|
[D_SUBAGENTS]: deps.hubPath(D_SUBAGENTS),
|
|
243
244
|
[D_PROMPTS]: deps.promptsDir(),
|
|
245
|
+
[D_QUICK]: deps.quickPromptsDir(),
|
|
244
246
|
};
|
|
245
247
|
for (const kind of FILE_DOMAINS) {
|
|
246
248
|
const files = await walk(roots[kind]);
|
|
@@ -352,6 +354,7 @@ export function createSnapshotOps(deps) {
|
|
|
352
354
|
mcp: namesOf(await deps.invokeOp('mcpm-list', {}), ['serverName']),
|
|
353
355
|
[D_SUBAGENTS]: namesOf(await deps.invokeOp('subagent-list', {}), ['name']),
|
|
354
356
|
[D_PROMPTS]: namesOf(await deps.invokeOp('agentsmd-list', {}), ['id']),
|
|
357
|
+
[D_QUICK]: namesOf(await deps.invokeOp('quickprompt-list', {}), ['id']),
|
|
355
358
|
[D_MEMORIES]: namesOf(await deps.invokeOp('rules-list', {}), ['id']),
|
|
356
359
|
scenes: sceneRecordsOf(await deps.invokeOp('rules-list', {})).map((r) => r.name),
|
|
357
360
|
};
|
|
@@ -493,12 +496,13 @@ export function createSnapshotOps(deps) {
|
|
|
493
496
|
throw new Error(String((res && res.error) || 'subagent-import 失败'));
|
|
494
497
|
return { imported: (res.imported || []).length, skipped: skipped + (res.skipped || []).length };
|
|
495
498
|
});
|
|
496
|
-
|
|
497
|
-
|
|
499
|
+
// 两个提示词域同形:`<id>/<正文>` + 可选 `<id>/meta.json`,一次一份地导入。差别只在
|
|
500
|
+
// 正文文件名与 op 名(快捷域没有独立的 import op,`create` 就是那个形状),所以共用一段。
|
|
501
|
+
const importPromptLike = (domain, docName, importOp, updateOp) => run(domain, async () => {
|
|
502
|
+
const zip = files[domain + '.zip'];
|
|
498
503
|
if (!zip)
|
|
499
504
|
return { imported: 0, skipped: 0 };
|
|
500
505
|
const entries = unzipSync(new Uint8Array(await readFile(zip)));
|
|
501
|
-
// 提示词的落点是 `<id>/AGENTS.md`(+ 可选 `<id>/meta.json`),而 `agentsmd-import` 一次一份。
|
|
502
506
|
const bodies = {};
|
|
503
507
|
const metas = {};
|
|
504
508
|
let skipped = 0;
|
|
@@ -509,7 +513,7 @@ export function createSnapshotOps(deps) {
|
|
|
509
513
|
continue;
|
|
510
514
|
}
|
|
511
515
|
const text = Buffer.from(bytes).toString('utf8');
|
|
512
|
-
if (parts[1] ===
|
|
516
|
+
if (parts[1] === docName)
|
|
513
517
|
bodies[parts[0]] = text;
|
|
514
518
|
else if (parts[1] === 'meta.json') {
|
|
515
519
|
try {
|
|
@@ -521,17 +525,17 @@ export function createSnapshotOps(deps) {
|
|
|
521
525
|
let imported = 0;
|
|
522
526
|
for (const id of Object.keys(bodies)) {
|
|
523
527
|
const desc = String((metas[id] && metas[id].description) || '');
|
|
524
|
-
const res = await deps.invokeOp(
|
|
528
|
+
const res = await deps.invokeOp(importOp, { id, content: bodies[id], description: desc });
|
|
525
529
|
if (res && res.ok) {
|
|
526
530
|
imported += 1;
|
|
527
531
|
continue;
|
|
528
532
|
}
|
|
529
|
-
//
|
|
533
|
+
// 同名一律拒(两个 import 口都不覆盖)。覆盖模式下改走 upsert 那条 update 路径。
|
|
530
534
|
if (!overwrite) {
|
|
531
535
|
skipped += 1;
|
|
532
536
|
continue;
|
|
533
537
|
}
|
|
534
|
-
const upd = await deps.invokeOp(
|
|
538
|
+
const upd = await deps.invokeOp(updateOp, { id, nextId: id, content: bodies[id], description: desc });
|
|
535
539
|
if (upd && upd.ok)
|
|
536
540
|
imported += 1;
|
|
537
541
|
else
|
|
@@ -539,6 +543,8 @@ export function createSnapshotOps(deps) {
|
|
|
539
543
|
}
|
|
540
544
|
return { imported, skipped };
|
|
541
545
|
});
|
|
546
|
+
await importPromptLike(D_PROMPTS, 'AGENTS.md', 'agentsmd-import', 'agentsmd-update');
|
|
547
|
+
await importPromptLike(D_QUICK, 'PROMPT.md', 'quickprompt-create', 'quickprompt-update');
|
|
542
548
|
await run(D_MEMORIES, async () => {
|
|
543
549
|
const zip = files[D_MEMORIES + '.zip'];
|
|
544
550
|
if (!zip)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 快捷提示词库(hub/quick-prompts/)。
|
|
2
|
+
//
|
|
3
|
+
// 与「全局提示词预设」(`./service.ts`)是**两件事**,别混着看:
|
|
4
|
+
// 预设 = 一份会覆盖写 `~/.dsh/AGENTS.md` 的指令基线,进上下文、被场景绑定、模型能在之间挑;
|
|
5
|
+
// 快捷 = 用户存好的一段现成文字,只由**用户自己**在对话框 `/` 菜单里点出来贴进草稿。
|
|
6
|
+
//
|
|
7
|
+
// 所以它**不注入上下文、不进场景**,实现上靠"另起一份存储"来保证:预设服务的
|
|
8
|
+
// `list()` 只扫 `hub/prompts/` 下的合法目录,注入取的是场景绑定的那份正文,模型工具
|
|
9
|
+
// `prompt_manager_list` 复用的又是 `agentsmd-list` —— 四条路径各自只认 `hub/prompts/`,
|
|
10
|
+
// 快捷词放在隔壁目录里就天然进不去。**不要**为了少一个目录把它塞进 prompts/ 加个标记位:
|
|
11
|
+
// 那等于让四条路径各自学会过滤,漏一条就是"快捷词被当成基线注入"。
|
|
12
|
+
// 条目那颗开关(`enabled`)只管"在不在对话框 `/` 菜单里出现",同样不碰上面四条路径。
|
|
13
|
+
//
|
|
14
|
+
// `list()` 连**正文一起给**,不是偷懒省一次请求:宿主的 `settle()` 拿到 onPick 的返回值后
|
|
15
|
+
// **同步**执行插入(input-trigger client.js),异步读正文来不及 —— 点的时候手里就得有那段字。
|
|
16
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { isValidPresetId, normalizePresetId } from './preset-id.js';
|
|
19
|
+
import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
|
|
20
|
+
/** 快捷提示词正文的文件名。与预设的 `AGENTS.md` 刻意不同名:这两个库不该被互换过。 */
|
|
21
|
+
const FILENAME = 'PROMPT.md';
|
|
22
|
+
/** 侧车(说明 + 启停),与正文同目录:正文本身一个字都不该被这些元信息污染。 */
|
|
23
|
+
const META_FILE = 'meta.json';
|
|
24
|
+
export function createQuickPromptsService(deps) {
|
|
25
|
+
const message = (e) => String((e && e.message) || e);
|
|
26
|
+
const idError = (raw) => {
|
|
27
|
+
const result = normalizePresetId(raw);
|
|
28
|
+
return result.ok ? null : result.error;
|
|
29
|
+
};
|
|
30
|
+
/** 侧车读回来的形状:缺字段一律按"没动过"解释(说明为空、开关为开)。 */
|
|
31
|
+
async function readMeta(id) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(await readFile(join(deps.dir, id, META_FILE), 'utf8'));
|
|
34
|
+
return {
|
|
35
|
+
description: typeof parsed.description === 'string' ? parsed.description : '',
|
|
36
|
+
// 只有明确写着 false 才算关:这颗开关之前的老库根本没有这个字段,默认必须是"照常在 / 里出现"。
|
|
37
|
+
enabled: parsed.enabled !== false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { description: '', enabled: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 侧车写:`undefined` = 那一项不动(空串 / `true` 才是明确回到默认)。
|
|
46
|
+
* 两项都回到默认时连文件一起删 —— 不留空壳(原来"说明清空就删侧车"那条纪律照旧)。
|
|
47
|
+
*/
|
|
48
|
+
async function writeMeta(id, patch) {
|
|
49
|
+
const cur = await readMeta(id);
|
|
50
|
+
const description = patch.description === undefined ? cur.description : String(patch.description).trim();
|
|
51
|
+
const enabled = patch.enabled === undefined ? cur.enabled : patch.enabled === true;
|
|
52
|
+
const body = {};
|
|
53
|
+
if (description !== '')
|
|
54
|
+
body.description = description;
|
|
55
|
+
if (enabled !== true)
|
|
56
|
+
body.enabled = false;
|
|
57
|
+
const file = join(deps.dir, id, META_FILE);
|
|
58
|
+
try {
|
|
59
|
+
if (!Object.keys(body).length) {
|
|
60
|
+
await rm(file, { force: true });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await writeFile(file, JSON.stringify(body, null, 2) + '\n', 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch { /* 元信息写不进去不该让保存失败:正文才是本体 */ }
|
|
66
|
+
}
|
|
67
|
+
async function list() {
|
|
68
|
+
try {
|
|
69
|
+
let entries = [];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(deps.dir, { withFileTypes: true });
|
|
72
|
+
}
|
|
73
|
+
catch { /* 库还没建过 = 空 */ }
|
|
74
|
+
const ids = entries.filter((e) => e.isDirectory() && isValidPresetId(e.name)).map((e) => e.name);
|
|
75
|
+
const prompts = await Promise.all(ids.map(async (id) => {
|
|
76
|
+
const meta = await readMeta(id);
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
description: meta.description,
|
|
80
|
+
enabled: meta.enabled,
|
|
81
|
+
// 读不到的那条(正文被外部删了)给空串:界面上仍然列出来,用户能看见"它在但没内容",
|
|
82
|
+
// 而不是凭空少一条却查不出为什么。
|
|
83
|
+
content: await readFile(join(deps.dir, id, FILENAME), 'utf8').catch(() => ''),
|
|
84
|
+
};
|
|
85
|
+
}));
|
|
86
|
+
prompts.sort((a, b) => a.id.localeCompare(b.id));
|
|
87
|
+
return { ok: true, prompts };
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return { ok: false, error: message(e) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function create(id, content, description) {
|
|
94
|
+
const bad = idError(id);
|
|
95
|
+
if (bad)
|
|
96
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
97
|
+
const safeId = String(id ?? '').trim();
|
|
98
|
+
const dir = join(deps.dir, safeId);
|
|
99
|
+
try {
|
|
100
|
+
await stat(dir);
|
|
101
|
+
return { ok: false, error: 'id 已存在:' + safeId };
|
|
102
|
+
}
|
|
103
|
+
catch { /* 不存在,继续 */ }
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
await writeFile(join(dir, FILENAME), String(content ?? ''), 'utf8');
|
|
106
|
+
await writeMeta(safeId, { description });
|
|
107
|
+
return { ok: true, id: safeId };
|
|
108
|
+
}
|
|
109
|
+
async function update(id, content, nextId, description) {
|
|
110
|
+
const bad = idError(id);
|
|
111
|
+
if (bad)
|
|
112
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
113
|
+
const safeId = String(id ?? '').trim();
|
|
114
|
+
try {
|
|
115
|
+
await stat(join(deps.dir, safeId));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { ok: false, error: '快捷提示词不存在:' + safeId };
|
|
119
|
+
}
|
|
120
|
+
let targetId = safeId;
|
|
121
|
+
if (nextId !== undefined && String(nextId).trim() !== safeId) {
|
|
122
|
+
const badNext = idError(nextId);
|
|
123
|
+
if (badNext)
|
|
124
|
+
return { ok: false, error: '非法新 id:' + badNext };
|
|
125
|
+
targetId = String(nextId).trim();
|
|
126
|
+
try {
|
|
127
|
+
await stat(join(deps.dir, targetId));
|
|
128
|
+
return { ok: false, error: '新 id 已存在:' + targetId };
|
|
129
|
+
}
|
|
130
|
+
catch { /* 可用 */ }
|
|
131
|
+
try {
|
|
132
|
+
await rename(join(deps.dir, safeId), join(deps.dir, targetId));
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return { ok: false, error: '改名失败:' + message(e) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await writeFile(join(deps.dir, targetId, FILENAME), String(content ?? ''), 'utf8');
|
|
139
|
+
await writeMeta(targetId, { description });
|
|
140
|
+
return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 启停:只写侧车那一个字段,正文与说明一律不动。
|
|
144
|
+
* 目录不存在时照实拒绝 —— 静默"成功"会在界面上留下一颗凭空出现的开关。
|
|
145
|
+
*/
|
|
146
|
+
async function setEnabled(id, enabled) {
|
|
147
|
+
const safeId = String(id ?? '').trim();
|
|
148
|
+
const bad = idError(safeId);
|
|
149
|
+
if (bad)
|
|
150
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
151
|
+
try {
|
|
152
|
+
await stat(join(deps.dir, safeId));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return { ok: false, error: '快捷提示词不存在:' + safeId };
|
|
156
|
+
}
|
|
157
|
+
await writeMeta(safeId, { enabled });
|
|
158
|
+
return { ok: true, id: safeId, enabled };
|
|
159
|
+
}
|
|
160
|
+
/** 删除 = 整个目录进回收站(与预设同一条纪律:不静默删用户写的东西)。 */
|
|
161
|
+
async function remove(id) {
|
|
162
|
+
const safeId = String(id ?? '').trim();
|
|
163
|
+
const bad = idError(safeId);
|
|
164
|
+
if (bad)
|
|
165
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
166
|
+
const dir = join(deps.dir, safeId);
|
|
167
|
+
try {
|
|
168
|
+
await stat(dir);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { ok: false, error: '快捷提示词不存在:' + safeId };
|
|
172
|
+
}
|
|
173
|
+
const moved = await moveToTrash('quick-prompts', safeId, [{ from: dir, dest: 'prompt' }]);
|
|
174
|
+
if (moved.ok === false)
|
|
175
|
+
return { ok: false, error: '移入回收站失败:' + moved.error };
|
|
176
|
+
return { ok: true, id: safeId };
|
|
177
|
+
}
|
|
178
|
+
async function trashList() {
|
|
179
|
+
return { ok: true, trash: await listTrashEntries('quick-prompts') };
|
|
180
|
+
}
|
|
181
|
+
async function trashRestore(id) {
|
|
182
|
+
const entry = await readTrashEntry('quick-prompts', String(id ?? '').trim());
|
|
183
|
+
if (!entry)
|
|
184
|
+
return { ok: false, error: '回收站条目不存在:' + String(id ?? '').trim() };
|
|
185
|
+
const bad = idError(entry.name);
|
|
186
|
+
if (bad)
|
|
187
|
+
return { ok: false, error: '回收站里的 id 不合法:' + bad };
|
|
188
|
+
const target = join(deps.dir, entry.name);
|
|
189
|
+
try {
|
|
190
|
+
await stat(target);
|
|
191
|
+
return { ok: false, error: '无法恢复,同 id 已存在:' + entry.name };
|
|
192
|
+
}
|
|
193
|
+
catch { /* 可用 */ }
|
|
194
|
+
try {
|
|
195
|
+
await mkdir(deps.dir, { recursive: true });
|
|
196
|
+
await moveOutOfTrash('quick-prompts', entry.id, 'prompt', target);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
return { ok: false, error: '恢复失败:' + message(e) };
|
|
200
|
+
}
|
|
201
|
+
await purgeTrashEntry('quick-prompts', entry.id);
|
|
202
|
+
return { ok: true, id: entry.name };
|
|
203
|
+
}
|
|
204
|
+
async function trashDelete(id) {
|
|
205
|
+
const clean = String(id ?? '').trim();
|
|
206
|
+
if (!(await purgeTrashEntry('quick-prompts', clean)))
|
|
207
|
+
return { ok: false, error: '回收站条目不存在:' + clean };
|
|
208
|
+
return { ok: true, id: clean };
|
|
209
|
+
}
|
|
210
|
+
return { list, create, update, setEnabled, remove, trashList, trashRestore, trashDelete };
|
|
211
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
export function installSceneLinkage(deps) {
|
|
2
|
+
/**
|
|
3
|
+
* 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
|
|
4
|
+
* 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
|
|
5
|
+
* 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
|
|
6
|
+
*/
|
|
7
|
+
async function rebindSubagentInArchives(from, to) {
|
|
8
|
+
try {
|
|
9
|
+
const slice = await deps.memoriesService.readArchiveSlice();
|
|
10
|
+
const touched = {};
|
|
11
|
+
for (const [scene, archive] of Object.entries(slice.archives || {})) {
|
|
12
|
+
const list = archive.subagents;
|
|
13
|
+
if (!Array.isArray(list) || !list.includes(from))
|
|
14
|
+
continue;
|
|
15
|
+
touched[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
|
|
16
|
+
}
|
|
17
|
+
const names = Object.keys(touched);
|
|
18
|
+
// 逐场景增量(审查 P2-3):此前整表回写,会把窗口期里别人保存的**别的场景**档案抹掉。
|
|
19
|
+
for (const scene of names)
|
|
20
|
+
await deps.memoriesService.saveArchive(scene, touched[scene]);
|
|
21
|
+
return names.length;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 人设改名后同步运行时快照(F-025):退出还原按 `subagentsAll` 逐名走,快照里
|
|
29
|
+
* 还留着旧名的话,退出会「旧名静默跳过、新名不还原」—— 被改名的人设停留在
|
|
30
|
+
* 场景期间状态。快照在进入场景时拍、改名发生在进入后,只能在改名时跟着改。
|
|
31
|
+
*/
|
|
32
|
+
async function renameSubagentInSnapshot(from, to) {
|
|
33
|
+
try {
|
|
34
|
+
const slice = await deps.memoriesService.readArchiveSlice();
|
|
35
|
+
const mode = slice && slice.mode;
|
|
36
|
+
const snapshot = mode && mode.snapshot;
|
|
37
|
+
if (!snapshot)
|
|
38
|
+
return;
|
|
39
|
+
let changed = false;
|
|
40
|
+
const next = { ...snapshot };
|
|
41
|
+
if (snapshot.subagentsAll && Object.prototype.hasOwnProperty.call(snapshot.subagentsAll, from)) {
|
|
42
|
+
const all = {};
|
|
43
|
+
for (const [k, v] of Object.entries(snapshot.subagentsAll)) {
|
|
44
|
+
all[k === from ? to : k] = v;
|
|
45
|
+
if (k === from)
|
|
46
|
+
changed = true;
|
|
47
|
+
}
|
|
48
|
+
next.subagentsAll = all;
|
|
49
|
+
}
|
|
50
|
+
for (const field of ['subagents', 'subagentsOn']) {
|
|
51
|
+
const list = snapshot[field];
|
|
52
|
+
if (Array.isArray(list) && list.includes(from)) {
|
|
53
|
+
next[field] = list.map((n) => (n === from ? to : n));
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (changed)
|
|
58
|
+
await deps.memoriesService.patchIndex({ mode: { ...mode, snapshot: next } });
|
|
59
|
+
}
|
|
60
|
+
catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
|
|
61
|
+
}
|
|
62
|
+
const baseSubagentUpdate = deps.subagentService.ops['subagent-update'];
|
|
63
|
+
if (typeof baseSubagentUpdate === 'function') {
|
|
64
|
+
deps.subagentService.ops['subagent-update'] = async (args) => {
|
|
65
|
+
const res = await baseSubagentUpdate(args);
|
|
66
|
+
if (res && res.ok !== false && res.renamedFrom) {
|
|
67
|
+
const from = String(res.renamedFrom);
|
|
68
|
+
const to = String(res.name);
|
|
69
|
+
void rebindSubagentInArchives(from, to);
|
|
70
|
+
void renameSubagentInSnapshot(from, to);
|
|
71
|
+
}
|
|
72
|
+
return res;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 快捷提示词改名后同步场景档案(与人设改名那两条同一件事):档案里存的还是旧 id 的话,
|
|
77
|
+
* 下一次进场景它会被当成 stale 丢掉 —— 用户看到的就成了"改了个名字,场景里的勾选悄悄没了",
|
|
78
|
+
* 而且那条快捷词还会在进场景时被停用(它不在勾选集里了)。
|
|
79
|
+
*/
|
|
80
|
+
async function rebindQuickPromptInArchives(from, to) {
|
|
81
|
+
try {
|
|
82
|
+
const slice = await deps.memoriesService.readArchiveSlice();
|
|
83
|
+
const touched = [];
|
|
84
|
+
for (const [scene, archive] of Object.entries(slice.archives || {})) {
|
|
85
|
+
const list = archive.quickPrompts;
|
|
86
|
+
if (!Array.isArray(list) || !list.includes(from))
|
|
87
|
+
continue;
|
|
88
|
+
touched.push([scene, { ...archive, quickPrompts: list.map((n) => (n === from ? to : n)) }]);
|
|
89
|
+
}
|
|
90
|
+
// 逐场景增量写(与审查 P2-3 同一条口径):整表回写会抹掉窗口期里别人存的别的场景。
|
|
91
|
+
for (const [scene, next] of touched)
|
|
92
|
+
await deps.memoriesService.saveArchive(scene, next);
|
|
93
|
+
}
|
|
94
|
+
catch { /* 同步失败不阻断改名本身;残留由档案保存时的 stale 清理兜住 */ }
|
|
95
|
+
}
|
|
96
|
+
const baseQuickPromptUpdate = deps.quickPromptOps['quickprompt-update'];
|
|
97
|
+
if (typeof baseQuickPromptUpdate === 'function') {
|
|
98
|
+
deps.quickPromptOps['quickprompt-update'] = async (args) => {
|
|
99
|
+
const res = await baseQuickPromptUpdate(args);
|
|
100
|
+
if (res && res.ok !== false && res.renamedFrom)
|
|
101
|
+
void rebindQuickPromptInArchives(String(res.renamedFrom), String(res.id));
|
|
102
|
+
return res;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
|
|
106
|
+
// 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
|
|
107
|
+
const baseSceneModeSet = deps.archiveService.ops['scene-mode-set'];
|
|
108
|
+
if (typeof baseSceneModeSet === 'function') {
|
|
109
|
+
deps.archiveService.ops['scene-mode-set'] = async (args) => {
|
|
110
|
+
// 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
|
|
111
|
+
// 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
|
|
112
|
+
// 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
|
|
113
|
+
try {
|
|
114
|
+
const slice = await deps.memoriesService.readArchiveSlice();
|
|
115
|
+
const current = slice.mode && slice.mode.scene;
|
|
116
|
+
if (current && args && 'scene' in (args || {})) {
|
|
117
|
+
const target = args.scene == null ? null : String(args.scene).trim() || null;
|
|
118
|
+
if (target !== current) {
|
|
119
|
+
const locked = await deps.lockedSceneNames();
|
|
120
|
+
if (locked.includes(current)) {
|
|
121
|
+
return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
|
|
127
|
+
const res = await baseSceneModeSet(args);
|
|
128
|
+
if (res && res.ok !== false) {
|
|
129
|
+
// 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
|
|
130
|
+
// 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
|
|
131
|
+
void deps.subagentCatalog.refresh().catch(() => { });
|
|
132
|
+
void deps.mcp.stateCatalog.refresh().catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
return res;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
|
|
139
|
+
* (与进入场景时同一口径,见 archive-engine)——
|
|
140
|
+
* 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
|
|
141
|
+
* 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
|
|
142
|
+
* (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
|
|
143
|
+
*/
|
|
144
|
+
const baseSceneArchiveSave = deps.archiveService.ops['scene-archive-save'];
|
|
145
|
+
if (typeof baseSceneArchiveSave === 'function') {
|
|
146
|
+
deps.archiveService.ops['scene-archive-save'] = async (args) => {
|
|
147
|
+
const res = await baseSceneArchiveSave(args);
|
|
148
|
+
try {
|
|
149
|
+
const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
|
|
150
|
+
const slice = await deps.memoriesService.readArchiveSlice();
|
|
151
|
+
const mode = slice.mode;
|
|
152
|
+
if (mode && mode.scene === res.scene) {
|
|
153
|
+
const every = (await deps.subagentService.list()).map((p) => p.name);
|
|
154
|
+
const unbound = every.filter((n) => bound.indexOf(n) < 0);
|
|
155
|
+
const toOn = await deps.subagentService.enabledStore.disabledAmong(bound);
|
|
156
|
+
const toOff = await deps.subagentService.enabledStore.enabledAmong(unbound);
|
|
157
|
+
if (toOn.length)
|
|
158
|
+
await deps.subagentService.enabledStore.setEnabled(toOn, true);
|
|
159
|
+
if (toOff.length)
|
|
160
|
+
await deps.subagentService.enabledStore.setEnabled(toOff, false);
|
|
161
|
+
if (toOn.length || toOff.length)
|
|
162
|
+
void deps.subagentCatalog.refresh();
|
|
163
|
+
const snapshot = mode.snapshot;
|
|
164
|
+
// 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
|
|
165
|
+
// 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
|
|
166
|
+
// archive-engine 的 restoreSnapshot 兼容分支)。
|
|
167
|
+
if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
|
|
168
|
+
const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
|
|
169
|
+
const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
|
|
170
|
+
for (const n of toOn)
|
|
171
|
+
if (backOn.indexOf(n) < 0)
|
|
172
|
+
backOn.push(n);
|
|
173
|
+
for (const n of toOff)
|
|
174
|
+
if (backOn.indexOf(n) < 0 && offList.indexOf(n) < 0)
|
|
175
|
+
offList.push(n);
|
|
176
|
+
await deps.memoriesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: backOn, subagentsOn: offList } } });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
|
|
181
|
+
return res;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export const sessionIdSchema = {
|
|
2
|
+
parse(value) {
|
|
3
|
+
if (typeof value !== "string" || value.length === 0)
|
|
4
|
+
throw new TypeError(`sessionId must be a non-empty string, got ${String(value)}`);
|
|
5
|
+
return value;
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
export const workspaceIdSchema = {
|
|
9
|
+
parse(value) {
|
|
10
|
+
if (typeof value !== "string" || value.length === 0)
|
|
11
|
+
throw new TypeError(`workspaceId must be a non-empty string, got ${String(value)}`);
|
|
12
|
+
return value;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export const archivedSetSchema = {
|
|
16
|
+
parse(value) {
|
|
17
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
18
|
+
throw new TypeError("result must be an object");
|
|
19
|
+
const ids = value.archivedSessionIds;
|
|
20
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string"))
|
|
21
|
+
throw new TypeError("archivedSessionIds must be a string array");
|
|
22
|
+
return value;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const deletedSchema = {
|
|
26
|
+
parse(value) {
|
|
27
|
+
if (typeof value !== "object" ||
|
|
28
|
+
value === null ||
|
|
29
|
+
Array.isArray(value) ||
|
|
30
|
+
value.deleted !== true)
|
|
31
|
+
throw new TypeError("deleted must be true");
|
|
32
|
+
return value;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
export const archivedBatchTargetSchema = {
|
|
36
|
+
// 校验通过后原样返回记录,调用方按 scope 判别式收窄(宿主返回值校验器,故返回 any)。
|
|
37
|
+
parse(value) {
|
|
38
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
39
|
+
throw new TypeError("target must be an object");
|
|
40
|
+
if (value.scope === "all" || value.scope === "ungrouped")
|
|
41
|
+
return value;
|
|
42
|
+
if (value.scope === "workspace" &&
|
|
43
|
+
typeof value.workspaceId === "string" &&
|
|
44
|
+
value.workspaceId.length > 0)
|
|
45
|
+
return value;
|
|
46
|
+
if (value.scope === "sessions" &&
|
|
47
|
+
Array.isArray(value.sessionIds) &&
|
|
48
|
+
value.sessionIds.length > 0 &&
|
|
49
|
+
value.sessionIds.every((id) => typeof id === "string" && id.length > 0))
|
|
50
|
+
return value;
|
|
51
|
+
throw new TypeError("target.scope must be all, ungrouped, workspace with a non-empty workspaceId, or sessions with non-empty sessionIds");
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
export const unarchivedBatchSchema = {
|
|
55
|
+
parse(value) {
|
|
56
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
57
|
+
throw new TypeError("result must be an object");
|
|
58
|
+
if (!Array.isArray(value.archivedSessionIds) ||
|
|
59
|
+
value.archivedSessionIds.some((id) => typeof id !== "string"))
|
|
60
|
+
throw new TypeError("archivedSessionIds must be a string array");
|
|
61
|
+
if (!Array.isArray(value.unarchivedSessionIds) ||
|
|
62
|
+
value.unarchivedSessionIds.some((id) => typeof id !== "string"))
|
|
63
|
+
throw new TypeError("unarchivedSessionIds must be a string array");
|
|
64
|
+
return value;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
export const archivedWorkspaceBatchSchema = {
|
|
68
|
+
parse(value) {
|
|
69
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
70
|
+
throw new TypeError("result must be an object");
|
|
71
|
+
if (!Array.isArray(value.archivedSessionIds) ||
|
|
72
|
+
value.archivedSessionIds.some((id) => typeof id !== "string"))
|
|
73
|
+
throw new TypeError("archivedSessionIds must be a string array");
|
|
74
|
+
if (!Array.isArray(value.archivedSessionIdsAdded) ||
|
|
75
|
+
value.archivedSessionIdsAdded.some((id) => typeof id !== "string"))
|
|
76
|
+
throw new TypeError("archivedSessionIdsAdded must be a string array");
|
|
77
|
+
return value;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export const deletedBatchSchema = {
|
|
81
|
+
parse(value) {
|
|
82
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
83
|
+
throw new TypeError("result must be an object");
|
|
84
|
+
for (const key of [
|
|
85
|
+
"requestedSessionIds",
|
|
86
|
+
"deletedSessionIds",
|
|
87
|
+
"skippedSessionIds",
|
|
88
|
+
]) {
|
|
89
|
+
if (!Array.isArray(value[key]) ||
|
|
90
|
+
value[key].some((id) => typeof id !== "string"))
|
|
91
|
+
throw new TypeError(`${key} must be a string array`);
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(value.failures) ||
|
|
94
|
+
value.failures.some((failure) => typeof failure !== "object" ||
|
|
95
|
+
failure === null ||
|
|
96
|
+
typeof failure.sessionId !== "string" ||
|
|
97
|
+
typeof failure.message !== "string"))
|
|
98
|
+
throw new TypeError("failures must contain sessionId/message objects");
|
|
99
|
+
return value;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
export const archivedSessionMetadataSchema = {
|
|
103
|
+
parse(value) {
|
|
104
|
+
if (typeof value !== "object" ||
|
|
105
|
+
value === null ||
|
|
106
|
+
Array.isArray(value) ||
|
|
107
|
+
!Array.isArray(value.items))
|
|
108
|
+
throw new TypeError("result.items must be an array");
|
|
109
|
+
if (value.items.some((item) => typeof item !== "object" ||
|
|
110
|
+
item === null ||
|
|
111
|
+
typeof item.sessionId !== "string" ||
|
|
112
|
+
typeof item.createdAt !== "number" ||
|
|
113
|
+
!Number.isFinite(item.createdAt)))
|
|
114
|
+
throw new TypeError("items must contain sessionId/createdAt objects");
|
|
115
|
+
if (value.repairedSessionIds !== void 0 &&
|
|
116
|
+
(!Array.isArray(value.repairedSessionIds) ||
|
|
117
|
+
value.repairedSessionIds.some((id) => typeof id !== "string")))
|
|
118
|
+
throw new TypeError("repairedSessionIds must be a string array");
|
|
119
|
+
return value;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 工作区路径的归一化比较键与展示名。
|
|
2
|
+
//
|
|
3
|
+
// 从 sessions/workspace.ts 整段搬来,一行未改。workspacePathKey 的口径注释里写着它不是
|
|
4
|
+
// 「只用于分组」而是决定会话挂回哪条登记,也写着它比宿主 realpathNormalize 更宽 ——
|
|
5
|
+
// 那两条是本插件的已知假设,改这个文件前先读它们。
|
|
6
|
+
/**
|
|
7
|
+
* 路径比较键:工作区的唯一性在宿主侧是 realpath 字符串相等(dsh-workspace
|
|
8
|
+
* `realpathNormalize`),Windows 盘符大小写与分隔符拼写却可能不同,因此比较前
|
|
9
|
+
* 先归一化。
|
|
10
|
+
*
|
|
11
|
+
* ⚠ 它不是"只用于分组":它决定**哪些会话被挂回哪条登记** —— `registerWorkspace` 会调
|
|
12
|
+
* `attachKnownSessions`,后者按这个键挑选会话并写宿主记账(`sessionIds`);
|
|
13
|
+
* `ensureWorkspaceAccounting` 也拿它当去重键后 `host.create` + 挂回。
|
|
14
|
+
* 而且它比宿主更宽:宿主 `realpathNormalize` 只是 `fs.realpath`(**不做大小写归一**),
|
|
15
|
+
* 本函数在 Windows 上 `toLowerCase()` ⇒ `C:\Proj` 与 `c:\proj` 在插件里同组、在宿主里
|
|
16
|
+
* 可以是两条登记。挂回前应以宿主 `resolveByPath` 实际返回的 id 复核,而不是按归一字符串匹配。
|
|
17
|
+
* @param {string} path - 原始路径。
|
|
18
|
+
* @returns {string} 归一化后的比较键(空路径返回空串)。
|
|
19
|
+
*/
|
|
20
|
+
export function workspacePathKey(path) {
|
|
21
|
+
const raw = String(path ?? "").trim();
|
|
22
|
+
if (!raw)
|
|
23
|
+
return "";
|
|
24
|
+
const windows = /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith("\\\\");
|
|
25
|
+
let out = windows ? raw.replace(/\//g, "\\") : raw;
|
|
26
|
+
while (out.length > 1 && (out.endsWith("\\") || out.endsWith("/")))
|
|
27
|
+
out = out.slice(0, -1);
|
|
28
|
+
return windows ? out.toLowerCase() : out;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 展示用目录名(与宿主 `defaultWorkspaceTitle` 同义:末段,无末段则用根拼写)。
|
|
32
|
+
* @param {string} path - 原始路径。
|
|
33
|
+
* @returns {string} 非空展示名。
|
|
34
|
+
*/
|
|
35
|
+
export function workspaceBaseName(path) {
|
|
36
|
+
const raw = String(path ?? "").trim();
|
|
37
|
+
if (!raw)
|
|
38
|
+
return "";
|
|
39
|
+
const trimmed = raw.replace(/[\\/]+$/, "");
|
|
40
|
+
const index = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
|
|
41
|
+
const segment = index >= 0 ? trimmed.slice(index + 1) : trimmed;
|
|
42
|
+
if (segment)
|
|
43
|
+
return segment;
|
|
44
|
+
const root = trimmed.match(/^(?:[A-Za-z]:[\\/]|[\\/]{1,2})/);
|
|
45
|
+
return root ? root[0] : trimmed || raw;
|
|
46
|
+
}
|