dsh-plugin-tool-management 0.5.1 → 0.6.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/README.md +300 -238
- package/README_EN.md +300 -304
- package/cordis.patch.yml +9 -55
- package/docs/Changelog.md +108 -481
- 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 +54 -0
- package/lib/agents-md/preset-id.js +49 -0
- package/lib/agents-md/service.js +138 -56
- package/lib/client.js +4142 -3560
- 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 +73 -0
- package/lib/index.js +511 -126
- package/lib/rules/provider.js +3 -3
- package/lib/rules/service.js +264 -30
- package/lib/scene-prompt-sync.js +112 -0
- package/lib/skills/core.js +112 -35
- package/lib/skills/service.js +6 -1
- package/package.json +5 -3
- package/screenshots.json +8 -7
- 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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/update.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 版本更新概要
|
|
2
|
+
|
|
3
|
+
每个版本**大致改了什么**——想知道「升上去会变什么」看这里就够了。
|
|
4
|
+
逐条细节、边界与已知限制见 [Changelog](./Changelog.md);功能说明见 [README](../README.md) / [README_EN](../README_EN.md)。
|
|
5
|
+
|
|
6
|
+
> 版本号 = npm 上实际发布的发布号(`npm view dsh-plugin-tool-management versions`)。
|
|
7
|
+
> 日期 = npm 发布时刻(**UTC**);本地时间(UTC+8)比它晚 8 小时。
|
|
8
|
+
> 开发期的内部编号 `v0.3` / `v0.4` 没有单独发布,内容随 v0.5.0 一起出去。
|
|
9
|
+
|
|
10
|
+
| 版本 | 发布 | 主题 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| [v0.6.0](#v0602026-09-14) | 2026-09-14 | 宿主兼容体检 + 「兼容」页 |
|
|
13
|
+
| [v0.5.1](#v0512026-09-13) | 2026-09-13 | 场景档案默认不勾选 + 场景页排版重做 |
|
|
14
|
+
| [v0.5.0](#v0502026-09-13) | 2026-09-13 | 数据目录统一 + 场景显式化 + 界面重做 |
|
|
15
|
+
| [v0.2.0 及更早](#v020-及更早2026-09-12) | 2026-09-12 | 五域合并进一个「工具」面板 |
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## v0.6.0(2026-09-14)
|
|
20
|
+
|
|
21
|
+
- **新增「工具 → 兼容」页**(第 8 个页签):一屏看清宿主版本与要求范围、插件适配版本、能力可用数(如 15/15)、每个宿主动作走**原生入口 / 插件适配层 / 不可用**、以及哪些能力已降级与原因。只读体检,不改任何数据。
|
|
22
|
+
- **兼容判定换底座**:不再拿**源码文本**比对官方实现(上游改一行注释就整体拒绝服务),改为「模块实体是否同一份 + 能力探测」。缺能力时按能力路由(原生 → 适配层 → 拒绝),拒绝信息写明**缺什么、后果、怎么修**,并且永远发生在**任何写入之前**。配套新增 `node scripts/doctor.mjs`(只读体检)与 `node scripts/host-deps.mjs`(把插件依赖 junction 到宿主安装,`--fix` / `--restore`)。
|
|
23
|
+
- **归档会话不再丢工作区归属**:工作区登记被删除后,分组按会话目录自动重建(标「工作区已移除」/「未登记的工作区」),目录仍在时可一键**重新登记**;恢复归档会话时会顺带把它挂回工作区记账(此前恢复出来只会掉进「未分组」)。归档账本 / 保留期 / 登记快照搬出插件目录,避免 `dsh plugin update` 覆盖整个目录时丢账本。
|
|
24
|
+
- **技能来源权限语义修正**:默认来源(`DSH 技能` / `导入技能`)**必须读取**——不可移除、不可停用,但**里面的技能可以删**(进回收站、可从回收站原样放回);外部 Agent 目录与自定义目录可以停用 / 移除(不再读取),但里面的技能**只读、不能删**。顺带修好「导入技能里删掉的技能无法从回收站恢复」。
|
|
25
|
+
- **切换场景 = 直接换 AGENTS.md**:启用/切换场景、改绑提示词、编辑"正在生效的那份预设"的正文,都会把那份预设**真写进 `~/.dsh/AGENTS.md`**(覆盖前多代备份,最近 5 代保留在 `agents-md/__last-applied__/`);**关掉场景时按原文恢复进场景之前的基线**(手写内容也能原样回来)。除保留场景「全局」外**同时只能启用一个场景**(启用后其余开关置灰),**新建场景默认不启动**;场景表单里的提示词默认选中**当前生效的那份**(没有「不绑定」选项)。「提示词」页把这一份标成**「生效中」**并写明来源(`当前:life(场景「A」)`),**正在生效的那份不能删除**(按钮禁用 + 宿主拒绝),换绑或应用别的预设后即可删。「进入模式」会连同档案、记忆、提示词一起生效。
|
|
26
|
+
- **提示词预设**:新建弹窗改成「id + 正文文本框」(可直接把内容粘进去,也可从现有预设复制一份再改);编辑时 **id 与正文都能改**——改 id 就是目录改名,**场景绑定会自动跟着改**,不留悬空绑定。id 不再限定小写字母/数字/连字符,**中文、空格、点、下划线都行**(只挡路径分隔符这类文件系统非法字符与 `__last-applied__` 备份槽)。
|
|
27
|
+
- **安全与界面**:插件 HTTP 路由接上宿主鉴权栅栏(Host/Origin 判定 + browser-session cookie;`token` 作为脚本与局域网部署的逃生门);AGENTS.md 预设「应用」改为保留最近 **5 代**备份;全站 24 处弹窗的「取消 / 关闭」统一只在右上角;英文界面不再露中文(面板跟随宿主语言,会话页与 AGENTS.md 页接入词典)。
|
|
28
|
+
|
|
29
|
+
## v0.5.1(2026-09-13)
|
|
30
|
+
|
|
31
|
+
- **场景档案弹窗:新加的段默认一项都不勾**——MCP 工具集 / 技能集 / 子智能体绑定是**空集**,记忆段只预勾**本场景里已启用**的那几条;「全选」= 本场景全部。代价写进界面:空段 = 该域全部停用(子智能体段例外:空段 = 不限制)。
|
|
32
|
+
- **档案弹窗「记忆」段只列被编辑场景自己的记忆**:列别的场景(含「全局」)勾了不起作用,属于陷阱,已删除;记忆描述按 80 字截断,长描述不再横向溢出段边框。
|
|
33
|
+
- **场景页排版重做**:卡片只留「名字 + 标签 + 开关 / 一行描述 / 动作行」,数量收进页首「当前模式」条(只在真的进入模式后出现);描述上限 60 字。
|
|
34
|
+
- **保留场景「全局」不再出现在场景页与档案弹窗**:它恒定注入、不是可切换的预设,MCP / 技能 / 人设各有专页;标签「任何对话都注入」压成「常驻」。
|
|
35
|
+
- 客户端契约测试补上「档案弹窗默认勾选」「记忆段只认本场景的记忆」「场景页不列全局」等用例;两份 README 的测试组清单同步校正。
|
|
36
|
+
|
|
37
|
+
## v0.5.0(2026-09-13)
|
|
38
|
+
|
|
39
|
+
- **破坏性:数据全部收进 `~/.dsh/tool-management/`**(记忆 / 人设 / AGENTS.md 预设 / 新建导入的技能 / 回收站 / 索引 / 配置)。旧位置首次启动**自动搬入**:只搬不删、绝不覆盖、每进程一次、失败不阻断(`~/.dsh/skills/` 不搬,仍作为可切换来源)。
|
|
40
|
+
- **场景从「恰好有这个名字的目录」升级为显式记录**(带描述与顺序):空场景合法;保留场景「全局」恒在不可删;`memories/` 根层的裸 `.md` 不再注入并在体检里报 `noScene`。
|
|
41
|
+
- **技能来源**:新增插件自有来源 **「导入技能」**(新建 / 导入的落点,优先级高于 `~/.dsh/skills/`);新增 **「移除来源」** = 连目录都不再扫描(源文件一个字节都不动,可一键恢复读取)。
|
|
42
|
+
- **界面**:工具页七栏;场景档案编辑器新增第 4 段「记忆」(只影响注入,不动记忆文件);人设的模型与工具限制收进「高级选项」;四个表单的辅助提示去冗余。
|
|
43
|
+
- **修掉三个真故障**:工具页整页白屏、导出静默丢失(导出写在 `apply` 方法体内,`apply` 提前返回就没落地)、技能状态文件被判非法导致 Skills 页锁死。
|
|
44
|
+
- 斜杠命令 `/mcp`、`/skills`、`/agents-md`、`/scene-memory` 全部下线(面板里每一项都有等价入口)。
|
|
45
|
+
|
|
46
|
+
## v0.2.0 及更早(2026-09-12)
|
|
47
|
+
|
|
48
|
+
- 五个域合并进一个「工具」设置面板:**MCP / Skills / AGENTS.md 预设 / History(归档会话)**。
|
|
49
|
+
- **MCP**:工具级独立启停(模型看不见也调不到)、重启只重连**不改变启停状态**、密钥默认打码、改写补丁前自动 `.bak`(保留 5 份)、重复 loader id 写前拦截、跨级迁移失败自动回滚。
|
|
50
|
+
- **Skills**:接入 `~/.agents` / `~/.codex` / `~/.claude` 与自定义目录(只读接入、重叠拒绝);创建 / ZIP 与文件夹导入 / 回收站;技能目录后台监听,编辑器改完自动刷新。
|
|
51
|
+
- **AGENTS.md 预设库**:多套全局基线,一键「应用」写入 `~/.dsh/AGENTS.md`(新会话生效,当前会话不变)。
|
|
52
|
+
- **History**:归档会话按项目分组、搜索、全选、批量恢复 / 永久删除、保留期自动清理;支持从 Claude Code / Cursor(JSONL)、Codex(Markdown)、任意文本导入;导出 Markdown / JSONL。
|
|
53
|
+
|
|
54
|
+
> 文档对 **0.1.1 / 0.1.2 / 0.1.3** 没有独立记录(只有 git tag),因此上表不拆分它们的差异,也不补写没有证据的条目。
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// dsh-plugin-tool-management —— 提示词预设 id 的唯一口径。
|
|
2
|
+
//
|
|
3
|
+
// 用户裁定(2026-09-15):「id 应该什么都能写」——不再限定小写字母/数字/连字符,
|
|
4
|
+
// 中文、空格、下划线、点都可以。id 同时也是**磁盘目录名**,所以只保留两类硬约束:
|
|
5
|
+
// ① 文件系统安全:不含路径分隔符与 Windows 非法字符、不以点开头/结尾、
|
|
6
|
+
// 不是 `.` / `..`、不是 Windows 保留设备名;
|
|
7
|
+
// ② 一个保留字:`__last-applied__`(apply 的备份槽,不是用户预设)。
|
|
8
|
+
// 这套口径由 agents-md 服务与场景绑定(`scenes[].prompt`)共用,避免两处漂移。
|
|
9
|
+
// 约定镜像 @deepseek-ai/dsh-agent-presets 的「id 即目录名」,但放宽了字符集。
|
|
10
|
+
/** id 长度上限(字符数;目录名过长在 Windows 上还受 MAX_PATH 约束)。 */
|
|
11
|
+
export const PRESET_ID_MAX = 64;
|
|
12
|
+
/** apply 的备份槽:不是用户预设,任何入口都不得当作预设 id。 */
|
|
13
|
+
export const LAST_APPLIED_PRESET_ID = '__last-applied__';
|
|
14
|
+
/** Windows 保留设备名(不区分大小写,带扩展名同样非法)。 */
|
|
15
|
+
const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
16
|
+
/**
|
|
17
|
+
* 校验并归一化一个预设 id。
|
|
18
|
+
* @param raw - 用户输入或来自索引的原始值。
|
|
19
|
+
* @returns `{ ok: true, id }`(已 trim)或 `{ ok: false, error }`(给人看的中文原因)。
|
|
20
|
+
*/
|
|
21
|
+
export function normalizePresetId(raw) {
|
|
22
|
+
const id = String(raw ?? '').trim();
|
|
23
|
+
if (id === '')
|
|
24
|
+
return { ok: false, error: 'id 不能为空' };
|
|
25
|
+
if (id.length > PRESET_ID_MAX)
|
|
26
|
+
return { ok: false, error: `id 过长(≤${PRESET_ID_MAX} 字符,当前 ${id.length})` };
|
|
27
|
+
if (id === LAST_APPLIED_PRESET_ID)
|
|
28
|
+
return { ok: false, error: `「${LAST_APPLIED_PRESET_ID}」是「应用」的备份槽,不能用作预设 id` };
|
|
29
|
+
if (id === '.' || id === '..')
|
|
30
|
+
return { ok: false, error: 'id 不能是「.」或「..」' };
|
|
31
|
+
if (id.startsWith('.'))
|
|
32
|
+
return { ok: false, error: 'id 不能以「.」开头' };
|
|
33
|
+
if (/[\\/]/.test(id))
|
|
34
|
+
return { ok: false, error: 'id 不能含路径分隔符(/ 或 \\)' };
|
|
35
|
+
if (/[<>:"|?*]/.test(id))
|
|
36
|
+
return { ok: false, error: 'id 不能含 < > : " | ? * 这些字符' };
|
|
37
|
+
if (/[.\s]$/.test(id))
|
|
38
|
+
return { ok: false, error: 'id 不能以点或空格结尾(Windows 会静默去掉)' };
|
|
39
|
+
if (WINDOWS_RESERVED.test(id))
|
|
40
|
+
return { ok: false, error: `「${id}」是 Windows 保留设备名` };
|
|
41
|
+
// 控制字符(含换行/制表)会让目录名与日志不可读,直接拒绝。
|
|
42
|
+
if (/[\u0000-\u001f\u007f]/.test(id))
|
|
43
|
+
return { ok: false, error: 'id 不能含控制字符' };
|
|
44
|
+
return { ok: true, id };
|
|
45
|
+
}
|
|
46
|
+
/** 便捷判定(索引解析等只关心"能不能用"的地方)。 */
|
|
47
|
+
export function isValidPresetId(raw) {
|
|
48
|
+
return normalizePresetId(raw).ok;
|
|
49
|
+
}
|
package/lib/agents-md/service.js
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
// dsh-plugin-tool-management —— AGENTS.md 预设库 + 切换 服务层。
|
|
2
2
|
//
|
|
3
3
|
// DSH 全局指令基线只有一个文件 ~/.dsh/AGENTS.md(USER_GLOBAL_FILE 固定),
|
|
4
|
-
// 没有内置的「多份全局 AGENTS.md
|
|
4
|
+
// 没有内置的「多份全局 AGENTS.md 切换」机制。本服务在 hub 内维护一个
|
|
5
5
|
// 预设库(每套一个子目录 + AGENTS.md),「应用」= 把选中预设内容写入
|
|
6
6
|
// ~/.dsh/AGENTS.md,新会话生效(当前会话不变,DSH 本身如此)。
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// id 即目录名:字符集口径见 `./preset-id.ts`(用户裁定「什么都能写」,只留
|
|
9
|
+
// 文件系统安全约束);`__last-applied__` 是备份槽,不算用户预设。
|
|
10
10
|
// 「当前生效」靠比对 ~/.dsh/AGENTS.md 的 sha256 与各预设 sha256 推断,
|
|
11
11
|
// 无状态文件——用户手改全局文件也能如实反映。
|
|
12
|
-
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
-
|
|
15
|
+
import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
|
|
16
16
|
const FILENAME = 'AGENTS.md';
|
|
17
|
-
const LAST_APPLIED_ID =
|
|
17
|
+
const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
|
|
18
|
+
/** 新建预设的初始正文(空模板);用户也可以直接粘贴自己的内容。 */
|
|
19
|
+
const BLANK_TEMPLATE = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
|
|
20
|
+
/** 全局 AGENTS.md 的备份代际上限:与 mcpm patch 的 KEEP_PATCH_BACKUPS 同纪律。 */
|
|
21
|
+
const KEEP_GLOBAL_BACKUPS = 5;
|
|
18
22
|
export function createAgentsMdService(_ctx, deps) {
|
|
19
23
|
const message = (e) => String((e && e.message) || e);
|
|
24
|
+
/** 校验 id 并返回错误文本(合法时返回 `null`)。 */
|
|
25
|
+
const idError = (raw) => {
|
|
26
|
+
const result = normalizePresetId(raw);
|
|
27
|
+
return result.ok ? null : result.error;
|
|
28
|
+
};
|
|
20
29
|
async function sha256OfFile(abs) {
|
|
21
30
|
try {
|
|
22
31
|
const buf = await readFile(abs, 'utf8');
|
|
@@ -36,7 +45,7 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
36
45
|
existing = await readdir(presetsDir, { withFileTypes: true });
|
|
37
46
|
}
|
|
38
47
|
catch { /* 库不存在 */ }
|
|
39
|
-
const hasPreset = existing.some((e) => e.isDirectory() &&
|
|
48
|
+
const hasPreset = existing.some((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID);
|
|
40
49
|
if (hasPreset)
|
|
41
50
|
return;
|
|
42
51
|
let globalContent;
|
|
@@ -67,7 +76,7 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
67
76
|
}
|
|
68
77
|
const globalHash = await sha256OfFile(globalPath);
|
|
69
78
|
const ids = entries
|
|
70
|
-
.filter((e) => e.isDirectory() &&
|
|
79
|
+
.filter((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID)
|
|
71
80
|
.map((e) => e.name);
|
|
72
81
|
const presets = await Promise.all(ids.map(async (id) => {
|
|
73
82
|
const contentHash = await sha256OfFile(join(presetsDir, id, FILENAME));
|
|
@@ -84,11 +93,10 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
95
|
async function read(id) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
|
|
96
|
+
const bad = idError(id);
|
|
97
|
+
if (bad)
|
|
98
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
99
|
+
const safeId = String(id ?? '').trim();
|
|
92
100
|
try {
|
|
93
101
|
const content = await readFile(join(deps.presetsDir, safeId, FILENAME), 'utf8');
|
|
94
102
|
return { ok: true, content };
|
|
@@ -97,39 +105,53 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
97
105
|
return { ok: false, error: '预设不存在:' + safeId };
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
/** 新建预设:`content` 优先(用户在新建弹窗里直接写的内容),否则用 `from` 复制,否则空模板。 */
|
|
109
|
+
async function create(id, options) {
|
|
110
|
+
const bad = idError(id);
|
|
111
|
+
if (bad)
|
|
112
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
113
|
+
const safeId = String(id ?? '').trim();
|
|
104
114
|
const dir = join(deps.presetsDir, safeId);
|
|
105
115
|
try {
|
|
106
116
|
await stat(dir);
|
|
107
117
|
return { ok: false, error: 'id 已存在:' + safeId };
|
|
108
118
|
}
|
|
109
119
|
catch { /* 不存在,继续 */ }
|
|
120
|
+
const explicit = options && typeof options.content === 'string' ? options.content : undefined;
|
|
110
121
|
let content;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!PRESET_ID_RE.test(srcId))
|
|
114
|
-
return { ok: false, error: '非法 from id:' + srcId };
|
|
115
|
-
try {
|
|
116
|
-
content = await readFile(join(deps.presetsDir, srcId, FILENAME), 'utf8');
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return { ok: false, error: '来源预设不存在:' + srcId };
|
|
120
|
-
}
|
|
122
|
+
if (explicit !== undefined) {
|
|
123
|
+
content = explicit;
|
|
121
124
|
}
|
|
122
125
|
else {
|
|
123
|
-
|
|
126
|
+
const srcId = String(options && options.from ? options.from : '').trim();
|
|
127
|
+
if (srcId) {
|
|
128
|
+
const badFrom = idError(srcId);
|
|
129
|
+
if (badFrom)
|
|
130
|
+
return { ok: false, error: '非法来源 id:' + badFrom };
|
|
131
|
+
try {
|
|
132
|
+
content = await readFile(join(deps.presetsDir, srcId, FILENAME), 'utf8');
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { ok: false, error: '来源预设不存在:' + srcId };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
content = BLANK_TEMPLATE;
|
|
140
|
+
}
|
|
124
141
|
}
|
|
125
142
|
await mkdir(dir, { recursive: true });
|
|
126
143
|
await writeFile(join(dir, FILENAME), content, 'utf8');
|
|
127
144
|
return { ok: true, id: safeId };
|
|
128
145
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
/**
|
|
147
|
+
* 保存预设:正文写入 + 可选**改名**(目录改名,内容随目录一起走)。
|
|
148
|
+
* 改名冲突(目标已存在)直接拒绝,不合并、不覆盖。
|
|
149
|
+
*/
|
|
150
|
+
async function update(id, content, nextId) {
|
|
151
|
+
const bad = idError(id);
|
|
152
|
+
if (bad)
|
|
153
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
154
|
+
const safeId = String(id ?? '').trim();
|
|
133
155
|
const dir = join(deps.presetsDir, safeId);
|
|
134
156
|
try {
|
|
135
157
|
await stat(dir);
|
|
@@ -137,13 +159,64 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
137
159
|
catch {
|
|
138
160
|
return { ok: false, error: '预设不存在:' + safeId };
|
|
139
161
|
}
|
|
140
|
-
|
|
141
|
-
|
|
162
|
+
let targetId = safeId;
|
|
163
|
+
if (nextId !== undefined && String(nextId).trim() !== safeId) {
|
|
164
|
+
const badNext = idError(nextId);
|
|
165
|
+
if (badNext)
|
|
166
|
+
return { ok: false, error: '非法新 id:' + badNext };
|
|
167
|
+
targetId = String(nextId).trim();
|
|
168
|
+
const nextDir = join(deps.presetsDir, targetId);
|
|
169
|
+
try {
|
|
170
|
+
await stat(nextDir);
|
|
171
|
+
return { ok: false, error: '新 id 已存在:' + targetId };
|
|
172
|
+
}
|
|
173
|
+
catch { /* 可用 */ }
|
|
174
|
+
try {
|
|
175
|
+
await rename(dir, nextDir);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
return { ok: false, error: '改名失败:' + message(e) };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await writeFile(join(deps.presetsDir, targetId, FILENAME), String(content ?? ''), 'utf8');
|
|
182
|
+
return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 覆盖写全局 `~/.dsh/AGENTS.md` 前的备份(多代 + `__last-applied__` 维护)。
|
|
186
|
+
* **必须留多代**:只有单个 `__last-applied__` 槽位时,连续写两次就会把用户原始手写内容
|
|
187
|
+
* 永久覆盖掉(第一次备份原始内容 → 第二次备份第一个预设),而「应用」只是点两下按钮。
|
|
188
|
+
* @returns 是否真的备份了(当前文件存在才备份)。
|
|
189
|
+
*/
|
|
190
|
+
async function backupGlobal(current) {
|
|
191
|
+
if (current === null)
|
|
192
|
+
return false;
|
|
193
|
+
const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
194
|
+
await mkdir(backupDir, { recursive: true });
|
|
195
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
196
|
+
await writeFile(join(backupDir, `${FILENAME}.bak-${stamp}`), current, 'utf8');
|
|
197
|
+
await writeFile(join(backupDir, FILENAME), current, 'utf8');
|
|
198
|
+
try {
|
|
199
|
+
const gens = (await readdir(backupDir)).filter((n) => n.startsWith(FILENAME + '.bak-')).sort();
|
|
200
|
+
for (const stale of gens.slice(0, Math.max(0, gens.length - KEEP_GLOBAL_BACKUPS))) {
|
|
201
|
+
await rm(join(backupDir, stale), { force: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch { /* 轮转失败不影响本次写入 */ }
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
async function readGlobal() {
|
|
208
|
+
try {
|
|
209
|
+
return await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
142
214
|
}
|
|
143
215
|
async function apply(id) {
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
return { ok: false, error: '非法 id
|
|
216
|
+
const bad = idError(id);
|
|
217
|
+
if (bad)
|
|
218
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
219
|
+
const safeId = String(id ?? '').trim();
|
|
147
220
|
let content;
|
|
148
221
|
try {
|
|
149
222
|
content = await readFile(join(deps.presetsDir, safeId, FILENAME), 'utf8');
|
|
@@ -151,20 +224,27 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
151
224
|
catch {
|
|
152
225
|
return { ok: false, error: '预设不存在:' + safeId };
|
|
153
226
|
}
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
227
|
+
const prev = await readGlobal();
|
|
228
|
+
const backedUp = await backupGlobal(prev);
|
|
229
|
+
await writeFile(await deps.getGlobalAgentsMdPath(), content, 'utf8');
|
|
230
|
+
return { ok: true, id: safeId, backedUp };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 按**原文**写回全局基线(不经过任何预设)。
|
|
234
|
+
* 用途:场景切换的「关掉场景 → 恢复进场景之前的基线」——那份内容可能是用户手写的,
|
|
235
|
+
* 不一定对应任何预设。备份纪律与 `apply` 完全相同。
|
|
236
|
+
*/
|
|
237
|
+
async function restore(content) {
|
|
238
|
+
const text = String(content ?? '');
|
|
239
|
+
const prev = await readGlobal();
|
|
240
|
+
const backedUp = await backupGlobal(prev);
|
|
157
241
|
try {
|
|
158
|
-
|
|
242
|
+
await writeFile(await deps.getGlobalAgentsMdPath(), text, 'utf8');
|
|
159
243
|
}
|
|
160
|
-
catch {
|
|
161
|
-
|
|
162
|
-
const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
163
|
-
await mkdir(backupDir, { recursive: true });
|
|
164
|
-
await writeFile(join(backupDir, FILENAME), prev, 'utf8');
|
|
244
|
+
catch (e) {
|
|
245
|
+
return { ok: false, error: '写入全局 AGENTS.md 失败:' + message(e) };
|
|
165
246
|
}
|
|
166
|
-
|
|
167
|
-
return { ok: true, id: safeId, backedUp: prev !== null };
|
|
247
|
+
return { ok: true, backedUp };
|
|
168
248
|
}
|
|
169
249
|
async function getCurrent() {
|
|
170
250
|
try {
|
|
@@ -185,7 +265,7 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
185
265
|
}
|
|
186
266
|
catch { /* 库不存在 */ }
|
|
187
267
|
for (const e of entries) {
|
|
188
|
-
if (!e.isDirectory() || !
|
|
268
|
+
if (!e.isDirectory() || !isValidPresetId(e.name) || e.name === LAST_APPLIED_ID)
|
|
189
269
|
continue;
|
|
190
270
|
let c;
|
|
191
271
|
try {
|
|
@@ -206,11 +286,12 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
206
286
|
}
|
|
207
287
|
}
|
|
208
288
|
async function remove(id) {
|
|
209
|
-
const safeId = String(id
|
|
289
|
+
const safeId = String(id ?? '').trim();
|
|
210
290
|
if (safeId === LAST_APPLIED_ID)
|
|
211
291
|
return { ok: false, error: '备份槽不可删除' };
|
|
212
|
-
|
|
213
|
-
|
|
292
|
+
const bad = idError(safeId);
|
|
293
|
+
if (bad)
|
|
294
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
214
295
|
const dir = join(deps.presetsDir, safeId);
|
|
215
296
|
try {
|
|
216
297
|
await stat(dir);
|
|
@@ -223,9 +304,10 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
223
304
|
}
|
|
224
305
|
// 从外部文本内容(如导入的 .md 文件)建预设:id 校验 + 重复检查 + 写 AGENTS.md。
|
|
225
306
|
async function importPreset(id, content) {
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
return { ok: false, error: '非法 id
|
|
307
|
+
const bad = idError(id);
|
|
308
|
+
if (bad)
|
|
309
|
+
return { ok: false, error: '非法 id:' + bad };
|
|
310
|
+
const safeId = String(id ?? '').trim();
|
|
229
311
|
const dir = join(deps.presetsDir, safeId);
|
|
230
312
|
try {
|
|
231
313
|
await stat(dir);
|
|
@@ -236,5 +318,5 @@ export function createAgentsMdService(_ctx, deps) {
|
|
|
236
318
|
await writeFile(join(dir, FILENAME), String(content ?? ''), 'utf8');
|
|
237
319
|
return { ok: true, id: safeId };
|
|
238
320
|
}
|
|
239
|
-
return { list, read, create, update, apply, getCurrent, remove, importPreset };
|
|
321
|
+
return { list, read, create, update, apply, restore, getCurrent, remove, importPreset };
|
|
240
322
|
}
|