dsh-plugin-tool-management 0.19.0 → 0.19.1
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 +31 -0
- package/README.md +254 -223
- package/README_EN.md +223 -223
- package/docs/images/logo.png +0 -0
- package/docs/images/promo-cover-EN.png +0 -0
- package/docs/images/promo-cover.png +0 -0
- package/docs/update.md +28 -518
- package/lib/client.js +805 -84
- package/lib/index.js +40 -0
- package/lib/op-registry.js +16 -1
- package/lib/ops/prompts.js +64 -7
- package/lib/ops/trash-retention.js +55 -0
- package/lib/prompts/service.js +191 -25
- package/lib/scene-prompt-sync.js +1 -1
- package/lib/tools/scene.js +4 -1
- package/lib/trash-retention.js +256 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -56,6 +56,9 @@ import { buildCompatOps } from './ops/compat.js';
|
|
|
56
56
|
import { buildPromptOps } from './ops/prompts.js';
|
|
57
57
|
import { buildQuickPromptOps } from './ops/quick-prompts.js';
|
|
58
58
|
import { buildSessionOps } from './ops/sessions.js';
|
|
59
|
+
import { buildTrashRetentionOps } from './ops/trash-retention.js';
|
|
60
|
+
// 回收站保留期(0.19.1):侧车 `hub/trash-retention.json` + 六类回收站的到期清扫。
|
|
61
|
+
import { createTrashRetentionDomain } from './trash-retention.js';
|
|
59
62
|
// HTTP 请求准入与 handlers 后处理(2026-09-19 从本文件 apply 闭包抽出,依赖显式传参)。
|
|
60
63
|
import { createAccessToken, createFrozenGate, createOpWhitelist, createSceneLock, guardModelOp, guardModelOps, installHandlerGuards } from './request-gate.js';
|
|
61
64
|
// `isReadCall` 随 HTTP 路由一起搬走了(写门禁与审计流水都读同一份登记表判据)。
|
|
@@ -874,6 +877,33 @@ export default {
|
|
|
874
877
|
// 保留期设置与到期清扫、批量操作目标校验、归档列表分组视图。构造实例、首次清扫、
|
|
875
878
|
// 挂周期定时器都在工厂内完成 —— 它们原本就写在 apply() 的同一位置。
|
|
876
879
|
const history = createHistoryDomain({ ctx, config, pluginRoot: PLUGIN_ROOT, message });
|
|
880
|
+
// 回收站保留期域(0.19.1):与上面的 history 同形 —— 构造、首次清扫、周期定时器都在
|
|
881
|
+
// 工厂内完成。默认保留期是 `0`(永久保留),所以对没打开过这个开关的宿主来说,
|
|
882
|
+
// 启动那次清扫读完侧车就返回了,一次磁盘遍历都不做。
|
|
883
|
+
//
|
|
884
|
+
// 记忆回收站那两条**走 memories 域的 op**,不在这里重抄路径与 manifest 形状:它在
|
|
885
|
+
// `hub/memories-trash/`(hub 根下的独立目录,不在 `trash/` 里),形状由 memories 域
|
|
886
|
+
// 自己定 —— 重抄一遍只会得到第二份会漂移的真相(hub.ts 顶部那张目录图专门写过这件事)。
|
|
887
|
+
const trashRetention = createTrashRetentionDomain({
|
|
888
|
+
ctx,
|
|
889
|
+
config,
|
|
890
|
+
memories: {
|
|
891
|
+
list: async () => {
|
|
892
|
+
const res = await memoriesService.ops['rules-trash-list']({});
|
|
893
|
+
const entries = res && Array.isArray(res.entries) ? res.entries : [];
|
|
894
|
+
return entries
|
|
895
|
+
.map((e) => ({
|
|
896
|
+
id: String((e && e.trashId) || ''),
|
|
897
|
+
name: String((e && e.name) || ''),
|
|
898
|
+
deletedAt: String((e && e.deletedAt) || ''),
|
|
899
|
+
}))
|
|
900
|
+
// 没有 id 的条目删不掉,也不能记流水 —— 在这里挡掉,免得把一条坏数据一路带到
|
|
901
|
+
// 「永久删除」那一步才失败。
|
|
902
|
+
.filter((e) => e.id !== '');
|
|
903
|
+
},
|
|
904
|
+
purge: (id) => memoriesService.ops['rules-trash-remove']({ trashId: id }),
|
|
905
|
+
},
|
|
906
|
+
});
|
|
877
907
|
// ── 场景提示词 → 全局基线(AGENTS.md)同步 ────────────────────────────────
|
|
878
908
|
//
|
|
879
909
|
// 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
|
|
@@ -933,6 +963,8 @@ export default {
|
|
|
933
963
|
return `场景「${ref.label || ''}」${ref.active ? '(已启用)' : ''}绑定了它`;
|
|
934
964
|
if (ref && ref.kind === 'restore')
|
|
935
965
|
return '它就是退出场景后要恢复的全局提示词';
|
|
966
|
+
if (ref && ref.applied === true)
|
|
967
|
+
return '它正被「应用」在 ~/.dsh/AGENTS.md 里';
|
|
936
968
|
return '~/.dsh/AGENTS.md 当前内容就是它';
|
|
937
969
|
};
|
|
938
970
|
// ---------- path discovery ----------
|
|
@@ -1546,6 +1578,14 @@ export default {
|
|
|
1546
1578
|
extractTurnsFromEvents: extractExportTurns,
|
|
1547
1579
|
serializeTurns: serializeTranscript,
|
|
1548
1580
|
}),
|
|
1581
|
+
// 回收站保留期(ops/trash-retention.ts):trash-retention-get(只读)/
|
|
1582
|
+
// trash-retention-set(写 + 冻结;成功后立即扫一次,见该文件文件头)。
|
|
1583
|
+
// 清扫本身**不在这里**——它是引擎的后台动作,由 trashRetention 域自己挂启动与周期。
|
|
1584
|
+
...buildTrashRetentionOps({
|
|
1585
|
+
read: trashRetention.read,
|
|
1586
|
+
write: trashRetention.write,
|
|
1587
|
+
sweep: trashRetention.sweep,
|
|
1588
|
+
}),
|
|
1549
1589
|
// rules 域里要走「场景 ↔ 全局基线同步」包装的四个写 op(ops/scene-sync.ts 提供)。
|
|
1550
1590
|
// 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
|
|
1551
1591
|
...buildSceneSyncOps({
|
package/lib/op-registry.js
CHANGED
|
@@ -140,7 +140,7 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
140
140
|
'subagent-toggle': { serviceWrite: true, frozen: true, syncsArchive: true },
|
|
141
141
|
'subagent-trash-restore': { serviceWrite: true, frozen: true },
|
|
142
142
|
'subagent-trash-delete': { serviceWrite: true, frozen: true },
|
|
143
|
-
// ── 提示词域(
|
|
143
|
+
// ── 提示词域(12)──────────────────────────────────────────────────────────
|
|
144
144
|
'agentsmd-list': { annotatesLock: true },
|
|
145
145
|
'agentsmd-read': { readonly: true },
|
|
146
146
|
'agentsmd-get-current': { readonly: true },
|
|
@@ -149,6 +149,8 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
149
149
|
'agentsmd-update': { write: true, frozen: true },
|
|
150
150
|
// apply 写全局 AGENTS.md = 换掉生效基线。
|
|
151
151
|
'agentsmd-apply': { write: true, frozen: true },
|
|
152
|
+
// unapply 同样写全局 AGENTS.md(把它恢复成链起点,即开始应用之前的那份内容)→ 与 apply 同档。
|
|
153
|
+
'agentsmd-unapply': { write: true, frozen: true },
|
|
152
154
|
'agentsmd-remove': { write: true, frozen: true },
|
|
153
155
|
'agentsmd-import': { write: true, frozen: true },
|
|
154
156
|
'agentsmd-trash-restore': { write: true, frozen: true },
|
|
@@ -188,6 +190,19 @@ export const OP_REGISTRY = Object.freeze({
|
|
|
188
190
|
'bundle-export': { write: true },
|
|
189
191
|
// 新增一条宿主工作区登记 —— 改的是宿主侧的登记,不是插件自己的侧车。
|
|
190
192
|
'history-workspace-register': { write: true },
|
|
193
|
+
// ── 回收站保留期(2)──────────────────────────────────────────────────────
|
|
194
|
+
// 与 History 页的保留期(上面那两条)是同一个功能出现在两个页面:同一份侧车形状
|
|
195
|
+
// (`{retentionDays, updatedAt}`)、同一条到期判据、同一个「改设置即重置倒计时」语义。
|
|
196
|
+
// 读侧是纯读(客户端 boot 时必问一次拿当前值),与 `history-retention-get` 同档。
|
|
197
|
+
'trash-retention-get': { readonly: true },
|
|
198
|
+
// 写侧**比 `history-retention-set` 多一层冻结**,这是一处刻意的不同:
|
|
199
|
+
// · 历史页那条只影响「已归档会话」—— 归档是用户主动收进去的,删了就是删了;
|
|
200
|
+
// · 这一条成功后立即扫一次,**能永久删掉六类回收站条目里的任意一条**,而六条手工
|
|
201
|
+
// 删除 op(`skill-trash-delete` / `agentsmd-trash-delete` / `subagent-trash-delete` /
|
|
202
|
+
// `quickprompt-trash-delete` / `rules-trash-remove`)全都是冻结的。
|
|
203
|
+
// 一个能删掉所有回收站条目的 op 却比单条删除更宽松,等于给冻结开了后门。
|
|
204
|
+
// (`scene-trash-delete` 是唯一没标冻结的那条,属既有不一致,不在本次范围。)
|
|
205
|
+
'trash-retention-set': { write: true, frozen: true },
|
|
191
206
|
// ── 兼容 / 注入 / 备份 / 流水 / 迁移快照(11)────────────────────────────────
|
|
192
207
|
'compat-status': { readonly: true },
|
|
193
208
|
// 「最近改动」流水的读侧:只读 hub 里的 `audit.jsonl`(内容只有对象名,见 audit-log.ts 第 ① 条)。
|
package/lib/ops/prompts.js
CHANGED
|
@@ -71,10 +71,44 @@ export function buildPromptOps(deps) {
|
|
|
71
71
|
},
|
|
72
72
|
// 应用 = 写全局基线文件;场景接管期间只放行「场景绑定的那一份」(见 applyPresetGuarded)。
|
|
73
73
|
'agentsmd-apply': (args) => deps.applyPresetGuarded(String((args && args.id) || '')),
|
|
74
|
+
// 取消应用 = **回到链起点**(这一串应用/取消往复开始之前的状态):起点有内容就把
|
|
75
|
+
// `__last-applied__/AGENTS.md` 写回去,起点文件不存在就把文件删掉(`removed: true`)。
|
|
76
|
+
// 连续应用过 A、B 时是**一步回到起点**,不是只退一步 —— 与场景基线同一条口径
|
|
77
|
+
// (见 `service.ts` 的 `backupGlobal`)。
|
|
78
|
+
// 它是「生效中」那颗开关的**关**方向 —— 此前只有「应用」没有反向动作,于是「只有一份预设
|
|
79
|
+
// 且它生效中」时开关关不掉、删除又被引用拦着(2026-10-10 实测反馈)。
|
|
80
|
+
//
|
|
81
|
+
// 用户 2026-10-10 第二次反馈「取消应用,AGENTS.md 还是存在」暴露了另一半:`backupGlobal()`
|
|
82
|
+
// 原先在文件不存在时**什么都不写**,"起点不存在"因此不可恢复,取消应用恒判 noBackup,
|
|
83
|
+
// 而这里给出的出路「删除这份预设」不成立(remove 不碰 AGENTS.md)。现在那个事实由
|
|
84
|
+
// `__last-applied__/absent.json` 记着,删文件这一支才走得通。
|
|
85
|
+
// 第三次反馈(同一天)「新建 a、b 两份提示词,启动 a,再启动 b,不启动 a 也不启动 b,
|
|
86
|
+
// AGENTS.md 没有消失」则是这个标记**被后续写入抹掉**:`backupGlobal` 原先每笔都覆盖链
|
|
87
|
+
// 起点槽位,第 ③ 步「应用 b」就把"起点不存在"刷成了 a 的内容。改成链起点只写一次。
|
|
88
|
+
//
|
|
89
|
+
// 场景驱动时**拒绝**:那时基线归场景管(生效中的是场景绑定的那一份),在这里撤销只会被
|
|
90
|
+
// 下一次场景同步写回去,而且「取消应用」对场景绑定也不成立 —— 要解除得去场景页改绑定。
|
|
91
|
+
'agentsmd-unapply': async () => {
|
|
92
|
+
const driver = await deps.scenePromptSync.driver();
|
|
93
|
+
if (driver) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
code: 'error.agentsMd.unapplyWhileScene',
|
|
97
|
+
error: `场景「${driver.label}」正在驱动全局基线,不能在这里取消应用:先退出场景,或到场景页改掉它的提示词绑定。`,
|
|
98
|
+
params: { scene: driver.label },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const res = await deps.promptsService.unapply();
|
|
102
|
+
if (res && res.ok === false) {
|
|
103
|
+
// 「没有链起点」是一种**确定的**状态(不是故障):AGENTS.md 的当前内容不是由
|
|
104
|
+
// 「应用」写进去的(首次打开时播种的 default 就是这样)。单给一个 code,界面才能
|
|
105
|
+
// 把出路说清楚(应用别的预设 / 直接编辑 AGENTS.md),而不是丢一句写盘失败。
|
|
106
|
+
return res.noBackup ? { ok: false, code: 'error.agentsMd.noUnapplyBackup', error: res.error } : res;
|
|
107
|
+
}
|
|
108
|
+
return res;
|
|
109
|
+
},
|
|
74
110
|
'agentsmd-get-current': () => deps.promptsService.getCurrent(),
|
|
75
|
-
//
|
|
76
|
-
// 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
|
|
77
|
-
// 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
|
|
111
|
+
// 删除:**场景绑定**与**当前应用的那份**拦得住(判据见下方注释)。拒绝时逐条说明「谁在用」,
|
|
78
112
|
// 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
|
|
79
113
|
//
|
|
80
114
|
// 探测**失败**时改为拒绝(2026-09-30 审查 F13)。原来的取舍是「放行并记 warn:删预设不动
|
|
@@ -96,13 +130,36 @@ export function buildPromptOps(deps) {
|
|
|
96
130
|
params: { id },
|
|
97
131
|
};
|
|
98
132
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
133
|
+
// 删除保护认两类**真依赖**(判据是「删掉它会不会让别处静默坏掉」或「会不会留下一个
|
|
134
|
+
// 用户无法理解的中间态」):
|
|
135
|
+
// ① 场景绑定 → 会静默坏掉:场景驱动靠 `presetId` 去读预设正文,读不到 `driver()`
|
|
136
|
+
// 就返回 null,场景静默失效(AGENTS.md 停在场景态,用户看到的是"什么都没做")。
|
|
137
|
+
// ② **当前应用的**那份(`file` + `applied`,见下)→ 会留下中间态:删掉之后
|
|
138
|
+
// `~/.dsh/AGENTS.md` 内容一字不变、**照旧生效**,但界面上再没有任何预设对应它。
|
|
139
|
+
// 用户看到的是「我把它删了,怎么全局提示词还在?」(2026-10-10 用户反馈:
|
|
140
|
+
// 「当前应用的提示词如果直接删除,AGENTS.md 还存在,当前应用的提示词应该不能删除」)。
|
|
141
|
+
// 拦住并引导先「取消应用」,这一步会把文件恢复成链起点(开始应用之前的状态),
|
|
142
|
+
// 之后就能删了。
|
|
143
|
+
//
|
|
144
|
+
// **不拦**的两类(各自都有明确的理由,别顺手加回来):
|
|
145
|
+
// - `file` 但没有 `applied`(首次打开提示词页时自动播种出来的 default):它内容与
|
|
146
|
+
// AGENTS.md 相同、界面标「文件里是它」,却从未发生过写入。删掉它完全合理(那只是
|
|
147
|
+
// 一份副本),而**拦它就是死路** —— 那份没有链起点可回,取消应用关不掉。
|
|
148
|
+
// - `restore`(退出场景要恢复的那一份):恢复走的是 `scene-baseline.json` 里存下的
|
|
149
|
+
// **正文**(scene-prompt-sync 的 `restore(saved.content)`),不读预设目录。
|
|
150
|
+
// 2026-09-16 的裁定是三类一律拦,那条规则在「只有一份预设且它生效中」时构成**死锁**:
|
|
151
|
+
// 开关关不掉(基线没有"全部不应用"这一态)、引用又解不开。现在开关能关了(取消应用),
|
|
152
|
+
// 拦 ② 才有出路 —— 取消应用会把 `__applied__.json` 清成 null,`applied` 随之变 false。
|
|
153
|
+
const blocking = (refs.get(id) || []).filter((r) => r.kind === 'scene' || (r.kind === 'file' && r.applied === true));
|
|
154
|
+
if (blocking.length) {
|
|
155
|
+
const refsText = blocking.map(deps.promptRefReason).join(';');
|
|
156
|
+
const hasScene = blocking.some((r) => r.kind === 'scene');
|
|
157
|
+
const hasFile = blocking.some((r) => r.kind === 'file');
|
|
158
|
+
const escape = [hasScene ? '到场景页换绑提示词、或退出场景' : '', hasFile ? '先点左边那颗开关「取消应用」' : ''].filter(Boolean).join(';');
|
|
102
159
|
return {
|
|
103
160
|
ok: false,
|
|
104
161
|
code: 'error.agentsMd.referenced',
|
|
105
|
-
error: `「${id}
|
|
162
|
+
error: `「${id}」不能删除:${refsText}。先解除引用再删 —— ${escape}。`,
|
|
106
163
|
params: { id, refs: refsText },
|
|
107
164
|
};
|
|
108
165
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 回收站保留期的两条 op(0.19.1)。
|
|
2
|
+
//
|
|
3
|
+
// trash-retention-get 只读:当前保留期天数(`0` = 永久保留)
|
|
4
|
+
// trash-retention-set 写:改保留期,成功后**立即扫一次**(与 `history-retention-set` 同一条)
|
|
5
|
+
//
|
|
6
|
+
// 与 `history-retention-get/set` 逐字同形(ops/sessions.ts:599-618):同一个
|
|
7
|
+
// `retentionDays` 入参、同一条「非负整数」校验、同一条「改完立刻扫,让界面反映新策略」。
|
|
8
|
+
// 两处保留期是同一个功能出现在两个页面,回执形状分家的话,客户端就得写两套解析。
|
|
9
|
+
//
|
|
10
|
+
// 读侧为什么是纯读:客户端 boot 时必问一次,用来渲染下拉框的当前值。那一次不能要令牌,
|
|
11
|
+
// 否则没配令牌的宿主上这一格永远是空的(与 `slash-settings` 同一条理由)。
|
|
12
|
+
export function buildTrashRetentionOps(deps) {
|
|
13
|
+
return {
|
|
14
|
+
'trash-retention-get': async () => {
|
|
15
|
+
const { retentionDays } = await deps.read();
|
|
16
|
+
return { ok: true, retentionDays };
|
|
17
|
+
},
|
|
18
|
+
'trash-retention-set': async (args) => {
|
|
19
|
+
const days = Number((args && args.retentionDays) ?? -1);
|
|
20
|
+
// 错误文案承诺的是「非负整数」,就按整数校验:1.5 这类小数以前在 History 页会被
|
|
21
|
+
// 静默取整成 1,用户看到的回执与输入不符。这里用同一道判据。
|
|
22
|
+
if (!Number.isSafeInteger(days) || days < 0) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
code: 'error.trash.retention.invalid',
|
|
26
|
+
error: `retentionDays 需为非负整数(0=永久保留),收到: ${String((args && args.retentionDays))}`,
|
|
27
|
+
params: { value: String((args && args.retentionDays)) },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await deps.write(days);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
code: 'error.trash.retention.writeFailed',
|
|
37
|
+
error: `写入保留期失败: ${String(e?.message ?? e)}`,
|
|
38
|
+
params: { reason: String(e?.message ?? e) },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// 设置变更后立即扫一次:界面上的条目数要与新策略一致,不能等到 6 小时后的下一轮。
|
|
42
|
+
// 清扫自己吞异常(见 createTrashRetentionDomain),所以这里不会因为删除失败而回滚设置
|
|
43
|
+
// —— 设置已经写进侧车了,如实回执「已保存,清理时 N 项失败」比报一个假失败诚实。
|
|
44
|
+
const res = await deps.sweep();
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
retentionDays: days,
|
|
48
|
+
// 回执带上这次实际清掉的数量:用户改完设置马上想知道「清掉了什么」,
|
|
49
|
+
// 而 `failed > 0` 说明有条目删不掉(IO 错误),要能说出来而不是假装干净。
|
|
50
|
+
swept: res.swept.length,
|
|
51
|
+
failed: res.failed,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/lib/prompts/service.js
CHANGED
|
@@ -52,6 +52,28 @@ const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
|
|
|
52
52
|
* `isDirectory()` 过滤天然把它排除在预设之外。
|
|
53
53
|
*/
|
|
54
54
|
const APPLIED_FILE = '__applied__.json';
|
|
55
|
+
/**
|
|
56
|
+
* 「应用前 AGENTS.md 根本不存在」这个**事实**的标记文件,与备份内容同放在 `__last-applied__/`。
|
|
57
|
+
*
|
|
58
|
+
* 为什么需要它(0.19.1 修,用户 2026-10-10 反馈「全局提示词取消应用,AGENTS.md 还是存在」):
|
|
59
|
+
* `backupGlobal()` 原先在 `current === null` 时直接 `return false` —— **一个字节都不写**。
|
|
60
|
+
* 于是"应用前没有这个文件"在磁盘上没留下任何痕迹,`unapply()` 便永远读不到备份、恒判
|
|
61
|
+
* `noBackup`,取消应用**永久关不掉**;而界面给出的出路「删除这份预设」根本不成立
|
|
62
|
+
* (`remove()` 只把预设目录移进回收站,不碰 AGENTS.md)。出路不成立 = 死路。
|
|
63
|
+
*
|
|
64
|
+
* 有了它,「取消应用」才是「应用」的**真逆操作**:
|
|
65
|
+
* 应用前有文件 → 写回那份内容;应用前没文件 → 把文件删掉。
|
|
66
|
+
* 与内容槽 `AGENTS.md` **同时只应有一个**:两个都在时 `unapplyState()` 优先判 `'restore'`,
|
|
67
|
+
* 就会把一份不相干的内容写进用户的文件。
|
|
68
|
+
*
|
|
69
|
+
* **但"只能有一个"不等于"每次写都刷掉另一个"**(0.19.1 再修,见 `backupGlobal` 的注释):
|
|
70
|
+
* 这两个槽位合起来记的是**链起点**,链走到中途(应用 a 之后又应用 b)不许动它 ——
|
|
71
|
+
* 原先 `backupGlobal` 每写一笔都顺手 `rm` 掉另一个槽,于是「应用 a → 应用 b」这一步就把
|
|
72
|
+
* "起点不存在"永久抹掉了,取消应用再也回不到"没有文件"。
|
|
73
|
+
* 放在 `__last-applied__/` 里是安全的 —— 该目录已被 `list()` 按 `LAST_APPLIED_ID` 排除,
|
|
74
|
+
* 放什么进去都不会被当成预设。
|
|
75
|
+
*/
|
|
76
|
+
const ABSENT_FILE = 'absent.json';
|
|
55
77
|
/** 新建预设的初始正文(空模板);用户也可以直接粘贴自己的内容。 */
|
|
56
78
|
const BLANK_TEMPLATE = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
|
|
57
79
|
/** 全局 AGENTS.md 的备份代际上限:与 mcpm patch 的 KEEP_PATCH_BACKUPS 同纪律。 */
|
|
@@ -106,29 +128,34 @@ export function createPromptsService(_ctx, deps) {
|
|
|
106
128
|
}
|
|
107
129
|
catch { /* 描述写不进去不该让保存失败 */ }
|
|
108
130
|
}
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
131
|
+
// 幂等初始化:**只在库目录本身不存在时**把全局 AGENTS.md 拷成 default
|
|
132
|
+
// 预设(首次运行引导)。
|
|
133
|
+
//
|
|
134
|
+
// 判据为什么是「目录在不在」而不是「目录里有没有预设」(0.19.1 修):后者把
|
|
135
|
+
// 「用户把预设删光了」误判成「首次运行的空库」,于是 list() / getCurrent() 每次都重新
|
|
136
|
+
// 播种 —— 用户删掉最后一个预设、刷新列表,同名 default 又回来了(内容 = 当前
|
|
137
|
+
// AGENTS.md,所以 md5 相同),删除看起来「不持久」。库目录一旦存在就说明这个库已经
|
|
138
|
+
// 被初始化过;空目录是用户的决定,不是我们的空库。
|
|
139
|
+
//
|
|
140
|
+
// 只在**目录不存在**时播种还有一层必要:库目录由 create / apply / 备份 / 应用记录
|
|
141
|
+
// 各自按需创建,所以「已存在」这个信号在任何一次真实使用之后都成立;反过来,
|
|
142
|
+
// 全新安装时没有任何一处会先建出这个目录(hub 布局迁移只搬已存在的东西),
|
|
143
|
+
// 首次运行引导因此仍然会发生。
|
|
112
144
|
async function ensureInit() {
|
|
113
|
-
const [presetsDir, globalPath] = await Promise.all([deps.presetsDir, deps.getGlobalAgentsMdPath()]);
|
|
114
|
-
let existing = [];
|
|
115
145
|
try {
|
|
116
|
-
|
|
146
|
+
await stat(deps.presetsDir);
|
|
147
|
+
return; // 库目录已存在(哪怕空)→ 不插手
|
|
117
148
|
}
|
|
118
|
-
catch { /*
|
|
119
|
-
const hasPreset = existing.some((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID);
|
|
120
|
-
if (hasPreset)
|
|
121
|
-
return;
|
|
149
|
+
catch { /* 库目录不存在 → 真·首次运行,继续播种 */ }
|
|
122
150
|
let globalContent;
|
|
123
151
|
try {
|
|
124
|
-
globalContent = await readFile(
|
|
152
|
+
globalContent = await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
|
|
125
153
|
}
|
|
126
154
|
catch {
|
|
127
155
|
return;
|
|
128
156
|
}
|
|
129
157
|
try {
|
|
130
|
-
|
|
131
|
-
const defaultDir = join(presetsDir, 'default');
|
|
158
|
+
const defaultDir = join(deps.presetsDir, 'default');
|
|
132
159
|
await mkdir(defaultDir, { recursive: true });
|
|
133
160
|
await writeFile(join(defaultDir, FILENAME), globalContent, 'utf8');
|
|
134
161
|
}
|
|
@@ -263,19 +290,52 @@ export function createPromptsService(_ctx, deps) {
|
|
|
263
290
|
return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
|
|
264
291
|
}
|
|
265
292
|
/**
|
|
266
|
-
* 覆盖写全局 `~/.dsh/AGENTS.md`
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
293
|
+
* 覆盖写全局 `~/.dsh/AGENTS.md` 前的备份。
|
|
294
|
+
*
|
|
295
|
+
* **两个槽位(内容槽 `AGENTS.md` / `ABSENT_FILE`)合起来记的是「链起点」** —— 即
|
|
296
|
+
* "连续应用/取消往复"这一整串动作**开始之前**的文件状态。所以**只在链起点写一次**:
|
|
297
|
+
* 一旦记下,后面每一笔写入都原样保留它,直到链结束(取消应用 / 写回基线)才清掉。
|
|
298
|
+
*
|
|
299
|
+
* 为什么不是"每笔覆盖"(0.19.1 修,用户 2026-10-10 报:「原本没有 AGENTS.md,新建 a、b
|
|
300
|
+
* 两份提示词,启动 a,再启动 b;不启动 a 也不启动 b,`~/.dsh/AGENTS.md` 没有消失」):
|
|
301
|
+
* ① 起点 文件不存在 槽位=空
|
|
302
|
+
* ② 应用 a 文件="# a" 槽位=absent("应用前不存在")
|
|
303
|
+
* ③ 应用 b 文件="# b" 槽位=**被刷成 "# a"** ← 每笔覆盖把"起点不存在"永久抹掉了
|
|
304
|
+
* ④ 取消应用 文件="# a" 槽位="# a" ← 回不到"没有文件",两颗开关还互相点亮
|
|
305
|
+
* ⑤ 此后无论点多少次,文件只在 "# a" / "# b" 之间来回,**永远回不到起点**。
|
|
306
|
+
*
|
|
307
|
+
* 与场景基线同一条口径:`scene-prompt-sync` 的 `writeBaseline` 也是「已有快照就不覆盖」
|
|
308
|
+
* (连续切场景时基线始终是"进场景之前"那一份)。
|
|
309
|
+
*
|
|
310
|
+
* **多代 `.bak-<stamp>` 快照照旧每笔都写**:它记的不是链起点,而是"每一笔被覆盖前的内容"
|
|
311
|
+
* (审计 / 抢救用),两者互不干扰 —— 链起点保证"退得回起点",多代快照保证"中途每一份都还
|
|
312
|
+
* 在磁盘上"。**必须留多代**:只有单个槽位时,连续写两次就会把用户原始手写内容永久覆盖掉,
|
|
313
|
+
* 而「应用」只是点两下按钮。
|
|
314
|
+
*
|
|
315
|
+
* @returns 是否真的备份了内容(`current` 非空才为 `true`)。文件不存在时返回 `false`,但
|
|
316
|
+
* **不再是"什么都不做"** —— 见 `ABSENT_FILE` 的注释:那个"不存在"本身就是链起点状态。
|
|
270
317
|
*/
|
|
271
318
|
async function backupGlobal(current) {
|
|
272
|
-
if (current === null)
|
|
273
|
-
return false;
|
|
274
319
|
const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
275
320
|
await mkdir(backupDir, { recursive: true });
|
|
321
|
+
// 还没有链起点 → 现在记下它;记过就绝不再动(这正是 bug 的修法)。
|
|
322
|
+
if (await unapplyState() === null) {
|
|
323
|
+
if (current === null) {
|
|
324
|
+
// 链起点 = "文件不存在"。写不进去不该让「应用」失败 —— 代价只是取消应用退回"关不掉"。
|
|
325
|
+
try {
|
|
326
|
+
await writeFile(join(backupDir, ABSENT_FILE), JSON.stringify({ at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
|
|
327
|
+
}
|
|
328
|
+
catch { /* 见上 */ }
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
await writeFile(join(backupDir, FILENAME), current, 'utf8');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (current === null)
|
|
335
|
+
return false;
|
|
336
|
+
// 审计快照:每一笔被覆盖前的内容都留一代(链起点不受它影响)。
|
|
276
337
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
277
338
|
await writeFile(join(backupDir, `${FILENAME}.bak-${stamp}`), current, 'utf8');
|
|
278
|
-
await writeFile(join(backupDir, FILENAME), current, 'utf8');
|
|
279
339
|
try {
|
|
280
340
|
const gens = (await readdir(backupDir)).filter((n) => n.startsWith(FILENAME + '.bak-')).sort();
|
|
281
341
|
for (const stale of gens.slice(0, Math.max(0, gens.length - KEEP_GLOBAL_BACKUPS))) {
|
|
@@ -285,6 +345,15 @@ export function createPromptsService(_ctx, deps) {
|
|
|
285
345
|
catch { /* 轮转失败不影响本次写入 */ }
|
|
286
346
|
return true;
|
|
287
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* 链结束(取消应用 / 写回基线)时清掉链起点 —— 下一笔「应用」重新开一条链。
|
|
350
|
+
* 与场景退出后 `writeBaseline({presetId: null, content: null})` 把快照清空是同一个口径。
|
|
351
|
+
*/
|
|
352
|
+
async function clearChainStart() {
|
|
353
|
+
const dir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
354
|
+
await rm(join(dir, FILENAME), { force: true });
|
|
355
|
+
await rm(join(dir, ABSENT_FILE), { force: true });
|
|
356
|
+
}
|
|
288
357
|
async function readGlobal() {
|
|
289
358
|
try {
|
|
290
359
|
return await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
|
|
@@ -332,8 +401,9 @@ export function createPromptsService(_ctx, deps) {
|
|
|
332
401
|
}
|
|
333
402
|
/**
|
|
334
403
|
* 按**原文**写回全局基线(不经过任何预设)。
|
|
335
|
-
*
|
|
336
|
-
*
|
|
404
|
+
* 用途:① 场景切换的「关掉场景 → 恢复进场景之前的基线」——那份内容可能是用户手写的,
|
|
405
|
+
* 不一定对应任何预设;② 「取消应用」的 `'restore'` 分支。
|
|
406
|
+
* 备份纪律与 `apply` 完全相同;写回即**链结束**,链起点一并清掉(见 `clearChainStart`)。
|
|
337
407
|
*/
|
|
338
408
|
async function restore(content) {
|
|
339
409
|
const text = String(content ?? '');
|
|
@@ -347,18 +417,109 @@ export function createPromptsService(_ctx, deps) {
|
|
|
347
417
|
}
|
|
348
418
|
// 基线内容可能来自用户手写(场景退出恢复)→ 不绑定任何预设。
|
|
349
419
|
await writeAppliedId(null);
|
|
420
|
+
await clearChainStart();
|
|
350
421
|
return { ok: true, backedUp };
|
|
351
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* 「链起点是什么状态」—— 界面据此决定「取消应用」可不可点、点下去会发生什么。
|
|
425
|
+
* `'restore'` → 起点有一份内容,取消应用 = 把它写回去;
|
|
426
|
+
* `'remove'` → 起点 AGENTS.md 根本不存在,取消应用 = 删掉文件;
|
|
427
|
+
* `null` → 没有链起点(从没「应用」过,或链已结束),关不掉。
|
|
428
|
+
*
|
|
429
|
+
* 两个槽位同时存在只可能是手改磁盘造成的(`backupGlobal` 记链起点时只会写其中一个),
|
|
430
|
+
* 那种情况优先 `'restore'`:**写回内容比删文件保守**,猜错时用户丢的是"文件里的旧内容"
|
|
431
|
+
* 而不是整个文件。
|
|
432
|
+
*/
|
|
433
|
+
async function unapplyState() {
|
|
434
|
+
const dir = join(deps.presetsDir, LAST_APPLIED_ID);
|
|
435
|
+
try {
|
|
436
|
+
if ((await stat(join(dir, FILENAME))).isFile())
|
|
437
|
+
return 'restore';
|
|
438
|
+
}
|
|
439
|
+
catch { /* 没有内容槽 */ }
|
|
440
|
+
try {
|
|
441
|
+
if ((await stat(join(dir, ABSENT_FILE))).isFile())
|
|
442
|
+
return 'remove';
|
|
443
|
+
}
|
|
444
|
+
catch { /* 没有"不存在"标记 */ }
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* 「取消应用」= **回到链起点**:把这一串应用/取消往复开始之前的文件状态恢复出来。
|
|
449
|
+
*
|
|
450
|
+
* 语义边界(写清楚是因为它决定了开关什么时候可关):
|
|
451
|
+
* - 恢复的是**链起点**,不是"上一步"。连续应用过 A、B 时,取消应用直接回到"应用 A 之前"
|
|
452
|
+
* —— 文件不存在就删掉文件、有一份手写内容就写回那份内容(0.19.1 修,见 `backupGlobal`)。
|
|
453
|
+
* 想从 B 切回 A 就点 A 的开关,效果等价,不必先退回 A 再取消。
|
|
454
|
+
* - **没有链起点就拒绝**(`noBackup`)。没有链起点只有一种成因:AGENTS.md 的当前内容不是
|
|
455
|
+
* 由「应用」写进去的(首次打开提示词页时播种出来的 `default` 就是这样,它内容与 AGENTS.md
|
|
456
|
+
* 相同、显示「生效中」,却从未发生过写入)。此时"起点"无从谈起,写空文件等于抹掉用户的
|
|
457
|
+
* 全局指令 —— 所以宁可拒绝,让界面去说清「要让它不再生效,请删除这份预设或应用别的预设」。
|
|
458
|
+
*
|
|
459
|
+
* `'restore'` 分支复用 `restore()`:备份纪律与「应用」完全一致。`'remove'` 分支(起点文件
|
|
460
|
+
* 不存在)先 `backupGlobal(prev)` 把当前内容存进多代快照,所以删掉的内容仍然拿得回来
|
|
461
|
+
* (`__last-applied__/AGENTS.md.bak-*`),可撤销性与另一支对称。
|
|
462
|
+
* 两支结束都清链起点(`'restore'` 在 `restore()` 里清)—— 链到此为止。
|
|
463
|
+
*/
|
|
464
|
+
async function unapply() {
|
|
465
|
+
const mode = await unapplyState();
|
|
466
|
+
if (mode === null) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
noBackup: true,
|
|
470
|
+
error: '没有可恢复的「链起点」:__last-applied__ 里既没有备份内容,也没有「起点不存在」的标记(AGENTS.md 当前内容不是由「应用」写进去的)。',
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (mode === 'remove') {
|
|
474
|
+
const prev = await readGlobal();
|
|
475
|
+
const backedUp = await backupGlobal(prev);
|
|
476
|
+
try {
|
|
477
|
+
await rm(await deps.getGlobalAgentsMdPath(), { force: true });
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
return { ok: false, error: '删除全局 AGENTS.md 失败:' + message(e) };
|
|
481
|
+
}
|
|
482
|
+
await writeAppliedId(null);
|
|
483
|
+
await clearChainStart();
|
|
484
|
+
return { ok: true, backedUp, removed: true };
|
|
485
|
+
}
|
|
486
|
+
let content;
|
|
487
|
+
try {
|
|
488
|
+
content = await readFile(join(deps.presetsDir, LAST_APPLIED_ID, FILENAME), 'utf8');
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// `unapplyState()` 刚判过它存在,这里读不到只能是并发下被清掉(或权限问题)。
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
noBackup: true,
|
|
495
|
+
error: '没有可恢复的「链起点内容」:__last-applied__ 里没有备份(AGENTS.md 当前内容不是由「应用」写进去的)。',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return restore(content);
|
|
499
|
+
}
|
|
352
500
|
async function getCurrent() {
|
|
353
501
|
try {
|
|
354
502
|
await ensureInit();
|
|
355
503
|
const [globalPath, presetsDir] = await Promise.all([deps.getGlobalAgentsMdPath(), deps.presetsDir]);
|
|
504
|
+
// `lastApplied` 与 `presetId` 是**两个不同的事实**,删除保护要的是前者:
|
|
505
|
+
// `presetId` = 内容逐字节相同(谁的内容此刻躺在文件里)
|
|
506
|
+
// `lastApplied`= 最近一次「应用」写进去的是谁(用户**主动做过这个选择**)
|
|
507
|
+
// 两者在"首次打开提示词页时自动播种出来的 default"上分道扬镳:它内容与 AGENTS.md
|
|
508
|
+
// 相同(presetId 命中),却从未发生过写入(lastApplied 为 null)。那份副本删掉完全
|
|
509
|
+
// 合理 —— 拦它反而会变成死路(取消应用也关不掉)。见 `ops/prompts.ts` 的删除保护。
|
|
510
|
+
const lastApplied = await readAppliedId();
|
|
511
|
+
// 与内容一起报「链起点是什么状态」—— 界面用它决定那颗「生效中」的开关点下去是
|
|
512
|
+
// 「取消应用」还是「说明为什么关不掉」,以及取消应用的确认弹窗要说"写回内容"还是
|
|
513
|
+
// **"删掉文件"**(后者是不可逆感很强的动作,必须提前说清,不能点下去才知道)。
|
|
514
|
+
const unapplyMode = await unapplyState();
|
|
515
|
+
const unapplyAvailable = unapplyMode !== null;
|
|
516
|
+
const unapplyRemoves = unapplyMode === 'remove';
|
|
356
517
|
let content;
|
|
357
518
|
try {
|
|
358
519
|
content = await readFile(globalPath, 'utf8');
|
|
359
520
|
}
|
|
360
521
|
catch {
|
|
361
|
-
return { ok: true, content: '', presetId: null, exists: false };
|
|
522
|
+
return { ok: true, content: '', presetId: null, lastApplied, exists: false, unapplyAvailable, unapplyRemoves };
|
|
362
523
|
}
|
|
363
524
|
const globalHash = createHash('sha256').update(content).digest('hex');
|
|
364
525
|
let presetId = null;
|
|
@@ -382,7 +543,7 @@ export function createPromptsService(_ctx, deps) {
|
|
|
382
543
|
break;
|
|
383
544
|
}
|
|
384
545
|
}
|
|
385
|
-
return { ok: true, content, presetId, exists: true };
|
|
546
|
+
return { ok: true, content, presetId, lastApplied, exists: true, unapplyAvailable, unapplyRemoves };
|
|
386
547
|
}
|
|
387
548
|
catch (e) {
|
|
388
549
|
return { ok: false, error: message(e) };
|
|
@@ -409,6 +570,11 @@ export function createPromptsService(_ctx, deps) {
|
|
|
409
570
|
const moved = await moveToTrash('prompts', safeId, [{ from: dir, dest: 'preset' }]);
|
|
410
571
|
if (moved.ok === false)
|
|
411
572
|
return { ok: false, error: '移入回收站失败:' + moved.error };
|
|
573
|
+
// 删掉的正是「最近一次应用」的那一份 → 记录跟着清掉。与 `update()` 改名时跟着改记录对称。
|
|
574
|
+
// 不清的后果:之后再新建一个同 id 的预设,界面会凭这条陈旧记录错误地标上「最近一次应用」
|
|
575
|
+
// (`list()` 只做 `appliedId === id` 的比对,认不出「这是另一个预设」)。
|
|
576
|
+
if (await readAppliedId() === safeId)
|
|
577
|
+
await writeAppliedId(null);
|
|
412
578
|
return { ok: true, id: safeId };
|
|
413
579
|
}
|
|
414
580
|
async function trashList() {
|
|
@@ -462,5 +628,5 @@ export function createPromptsService(_ctx, deps) {
|
|
|
462
628
|
await writeDescription(safeId, description);
|
|
463
629
|
return { ok: true, id: safeId };
|
|
464
630
|
}
|
|
465
|
-
return { list, read, create, update, apply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
|
|
631
|
+
return { list, read, create, update, apply, unapply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
|
|
466
632
|
}
|
package/lib/scene-prompt-sync.js
CHANGED
|
@@ -156,7 +156,7 @@ export function createScenePromptSync(deps) {
|
|
|
156
156
|
if (!cur || cur.ok !== true)
|
|
157
157
|
complete = false;
|
|
158
158
|
else if (cur.presetId)
|
|
159
|
-
add(String(cur.presetId), { kind: 'file' });
|
|
159
|
+
add(String(cur.presetId), { kind: 'file', applied: cur.lastApplied === cur.presetId });
|
|
160
160
|
}
|
|
161
161
|
catch (e) {
|
|
162
162
|
complete = false;
|
package/lib/tools/scene.js
CHANGED
|
@@ -276,7 +276,10 @@ export function buildSceneTools(deps) {
|
|
|
276
276
|
scene: { type: 'string', required: true, description: 'Scene name (one path segment, no slashes). Created if absent.' },
|
|
277
277
|
label: { type: 'string', description: 'Display name shown in the UI. Omit = keep.' },
|
|
278
278
|
description: { type: 'string', description: 'What this scene is for; shown beside the scene name in the「本机当前的场景」reminder while the scene is on — write an instruction, not a note. Omit = keep.' },
|
|
279
|
-
|
|
279
|
+
// 与下面 `mcp` / `skills` / `subagents` 同一句式("Omit = keep; X = 一个都不"):
|
|
280
|
+
// 界面 2026-10-10 补了「无」这个选项,工具侧的空串语义要跟着说清,否则模型只知道
|
|
281
|
+
// 「省略 = 保持」,不知道还有一条「解绑」的路。
|
|
282
|
+
prompt: { type: 'string', description: 'Prompt preset id to bind; while this scene is on it is the global baseline. Omit = keep; \'\' = bind none (this scene leaves AGENTS.md alone).' },
|
|
280
283
|
mcp: {
|
|
281
284
|
type: 'object',
|
|
282
285
|
// 官方要求对象类型**显式声明开放性**(`additionalProperties` 是必需字段,且只能是
|