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.
@@ -0,0 +1,256 @@
1
+ // 回收站保留期(0.19.1)—— 超过保留期的回收站条目由清扫**自动永久删除**。
2
+ //
3
+ // 为什么要有它:回收站此前只有「进来」没有「出去」—— 除了用户一条条点「永久删除」,
4
+ // 没有任何自动清空的开关。删掉一个场景、一批技能、一堆记忆之后,条目会一直躺在磁盘上。
5
+ // History(归档会话)页早就有这套(`hub/history-retention.json` + `sweepHistory`),
6
+ // 这里**刻意同形**:同一个 `{retentionDays, updatedAt}` 侧车形状、同一条到期判据、同一个
7
+ // 「改设置即重置倒计时」语义。两处口径一旦分家,用户在两个页面看到的就是两套话术。
8
+ //
9
+ // 覆盖六类:hub 四类(`trash/<域>-trash/<id>/manifest.json`)+ 技能(`trash/skills-trash/`,
10
+ // 元数据文件叫 `metadata.json`,是 core 层自己写的那套)+ 记忆(`memories-trash/`,
11
+ // **hub 根下的独立目录**,不在 `trash/` 里 —— 见 hub.ts 顶部那张目录图)。
12
+ //
13
+ // 三条口径(改这个文件前先读):
14
+ // ① `retentionDays <= 0` = **永久保留**,清扫是纯 no-op。这是默认值,也是本文件存在的
15
+ // 前提:`skills/manager-state.ts` 里有一条更早的产品裁定 ——「回收站没有自动清理:
16
+ // 超龄条目通过 warnings 提示用户到 Skills 管理页处理,**避免静默删除用户可能还想
17
+ // 恢复的内容**」。那条裁定针对的是「没有开关的自动删除」,所以这里默认关闭、
18
+ // 只有用户显式打开才删。默认值改了就等于推翻那条裁定,别顺手改。
19
+ // ② 到期基线 = `max(deletedAt, updatedAt)`,`updatedAt` 是**最近一次改保留期的时刻**。
20
+ // 于是「把保留期从 30 天改成 7 天」不会当场删掉一批 8 天前的条目 —— 倒计时从改设置
21
+ // 那一刻按新天数重算。与 History 页同一条(那边原话:「改保留期即重置倒计时」)。
22
+ // ③ `deletedAt` 缺失 / 解析失败**不清扫**。判据宁可漏删也不能误删:条目元数据被手改过、
23
+ // 或来自更早的版本时,我们并不知道它躺了多久 —— 按「刚删的」处理,让它等下一轮。
24
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
+ import { dirname } from 'node:path';
26
+ import { recordAudit } from './audit-log.js';
27
+ import { hubPath, listTrashEntries, purgeTrashEntry, } from './hub.js';
28
+ import { listTrash as listSkillTrash, permanentlyDeleteTrash } from './skills/skill-trash.js';
29
+ /** 一天的毫秒数(保留期按「天」配置,比较按毫秒)。与 sessions/history.ts 同一个常量。 */
30
+ export const MS_PER_DAY = 24 * 60 * 60 * 1000;
31
+ /** 保留期侧车文件名(放 hub 根,与 `history-retention.json` 并排)。 */
32
+ export const TRASH_RETENTION_FILE = 'trash-retention.json';
33
+ /** 走 hub 通用回收站的四类(技能与记忆各自另有实现,不在这里)。 */
34
+ export const HUB_TRASH_KINDS = ['subagents', 'scenes', 'prompts', 'quick-prompts'];
35
+ /** 永久删除时记流水用的 op 名 —— **复用各域已有的 `*-trash-delete`**,不另造一套。
36
+ * 这样兼容页「最近改动」里自动清理与手工删除长得一样,`compat.audit.op.trashDelete`
37
+ * 那条既有翻译也直接适用(否则会在流水里露出一串没人认识的 op 名)。 */
38
+ const AUDIT_OP_BY_KIND = {
39
+ prompts: 'agentsmd-trash-delete',
40
+ 'quick-prompts': 'quickprompt-trash-delete',
41
+ subagents: 'subagent-trash-delete',
42
+ scenes: 'scene-trash-delete',
43
+ };
44
+ const AUDIT_OP_SKILLS = 'skill-trash-delete';
45
+ const AUDIT_OP_MEMORIES = 'rules-trash-remove';
46
+ /** 侧车绝对路径。 */
47
+ export function trashRetentionPath() {
48
+ return hubPath(TRASH_RETENTION_FILE);
49
+ }
50
+ /**
51
+ * 解析侧车内容(纯函数,坏输入不抛)。
52
+ *
53
+ * 与 `readHistoryRetention` 同判据:`retentionDays` 只认**有限且非负**的数,其余一律 0
54
+ * (= 永久保留)。宁可退化成「什么都不删」,也不能因为一份被手改坏的 JSON 变成
55
+ * 「拿一个负数当保留期」—— 那会让 `now - 负数 * 一天` 落到未来,把全部条目判成到期。
56
+ */
57
+ export function parseTrashRetention(raw) {
58
+ const obj = raw && typeof raw === 'object' ? raw : null;
59
+ const days = Number((obj && obj.retentionDays) ?? 0);
60
+ const updatedAt = Number((obj && obj.updatedAt) ?? 0);
61
+ return {
62
+ retentionDays: Number.isFinite(days) && days >= 0 ? days : 0,
63
+ updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
64
+ };
65
+ }
66
+ /** 读侧车。文件不存在 / 读坏 / JSON 坏 → `{0, 0}`(= 永久保留)。 */
67
+ export async function readTrashRetention() {
68
+ try {
69
+ return parseTrashRetention(JSON.parse(await readFile(trashRetentionPath(), 'utf8')));
70
+ }
71
+ catch {
72
+ return { retentionDays: 0, updatedAt: 0 };
73
+ }
74
+ }
75
+ /** 写侧车。`updatedAt` = 本次修改时刻(到期基线随之重置,见文件头第 ② 条)。 */
76
+ export async function writeTrashRetention(retentionDays) {
77
+ const abs = trashRetentionPath();
78
+ await mkdir(dirname(abs), { recursive: true });
79
+ await writeFile(abs, JSON.stringify({ retentionDays, updatedAt: Date.now() }), 'utf8');
80
+ }
81
+ /**
82
+ * 算出到期应永久删除的条目 id(纯函数,便于测试)。
83
+ *
84
+ * 参数顺序、返回形状与 `sessions/history.ts` 的 `expiredArchivedIds` 逐字对应 ——
85
+ * 两处保留期必须是同一套判据,改一边就要改另一边。
86
+ *
87
+ * @param entries 至少要有 `id` 与 `deletedAt`(ISO 字符串)
88
+ * @param retentionDays `<= 0` = 永久保留,返回空集
89
+ * @param now 当前时刻(毫秒)
90
+ * @param updatedAt 最近一次改保留期的时刻(毫秒);`0` = 从没改过,退回到 deletedAt 语义
91
+ */
92
+ export function expiredTrashIds(entries, retentionDays, now, updatedAt = 0) {
93
+ if (!(retentionDays > 0))
94
+ return [];
95
+ const cutoff = now - retentionDays * MS_PER_DAY;
96
+ const out = [];
97
+ for (const entry of entries) {
98
+ const deleted = Date.parse(String(entry.deletedAt ?? ''));
99
+ // 缺失 / 解析失败 → 不知道躺了多久 → 不清扫(文件头第 ③ 条)。
100
+ if (!Number.isFinite(deleted))
101
+ continue;
102
+ const baseline = updatedAt > 0 ? Math.max(deleted, updatedAt) : deleted;
103
+ if (baseline <= cutoff)
104
+ out.push(entry.id);
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * 收集六类回收站条目(best-effort:某一类读不出来只跳过那一类,不拖垮整轮清扫)。
110
+ *
111
+ * 为什么每类各自 try/catch:清扫是启动期的后台动作,一个坏掉的 manifest 不该让另外五类
112
+ * 永远清不掉 —— 那正是「保留期看起来没生效」这类难查故障的温床。
113
+ */
114
+ export async function collectTrashCandidates(memories) {
115
+ const out = [];
116
+ for (const kind of HUB_TRASH_KINDS) {
117
+ let entries = [];
118
+ try {
119
+ entries = await listTrashEntries(kind);
120
+ }
121
+ catch {
122
+ entries = [];
123
+ }
124
+ for (const entry of entries) {
125
+ out.push({
126
+ key: `${kind}/${entry.id}`,
127
+ domain: kind,
128
+ name: entry.name,
129
+ deletedAt: String(entry.deletedAt ?? ''),
130
+ purge: () => purgeTrashEntry(kind, entry.id),
131
+ });
132
+ }
133
+ }
134
+ try {
135
+ for (const item of await listSkillTrash()) {
136
+ out.push({
137
+ key: `skills/${item.id}`,
138
+ domain: 'skills',
139
+ name: item.name,
140
+ // `deletedAt` 在技能元数据里是可选的:更早的版本可能没写 → 空串 → 不清扫(第 ③ 条)。
141
+ deletedAt: String(item.deletedAt ?? ''),
142
+ purge: () => permanentlyDeleteTrash(item.id, undefined),
143
+ });
144
+ }
145
+ }
146
+ catch { /* 技能回收站读不出来 → 本轮跳过 */ }
147
+ try {
148
+ for (const item of await memories.list()) {
149
+ out.push({
150
+ key: `memories/${item.id}`,
151
+ domain: 'memories',
152
+ name: item.name,
153
+ deletedAt: String(item.deletedAt ?? ''),
154
+ purge: () => memories.purge(item.id),
155
+ });
156
+ }
157
+ }
158
+ catch { /* 记忆回收站读不出来 → 本轮跳过 */ }
159
+ return out;
160
+ }
161
+ /** 删除成功与否:`{ok:false}` 与抛错都算失败(与 `recordOpAudit` 同口径)。 */
162
+ async function purgeSucceeded(run) {
163
+ try {
164
+ const res = await run();
165
+ if (res && typeof res === 'object' && res.ok === false)
166
+ return false;
167
+ return true;
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
173
+ /**
174
+ * 扫一轮:按当前保留期把到期条目永久删除。
175
+ *
176
+ * **刻意不经 op 表**(与 `sweepHistory` 同一条):这是引擎自己的后台动作,不是「谁发起的
177
+ * 一次改动」。走 op 表会同时套上令牌门禁与场景冻结 —— 于是「锁着一个场景」就等于
178
+ * 「保留期静默失效」,而用户根本看不出原因。手工删除仍走各域的 `*-trash-delete`(那几条
179
+ * 是冻结的),两条路的差别只在**发起方**:用户点的是写操作,清扫不是。
180
+ *
181
+ * 流水照记(`source: 'engine'`):自动删掉的东西必须留痕,否则用户只会看到条目"自己消失了"。
182
+ */
183
+ export async function sweepTrash(memories, now = Date.now()) {
184
+ const { retentionDays, updatedAt } = await readTrashRetention();
185
+ if (!(retentionDays > 0))
186
+ return { swept: [], failed: 0, retentionDays };
187
+ const candidates = await collectTrashCandidates(memories);
188
+ const expired = new Set(expiredTrashIds(candidates.map((c) => ({ id: c.key, deletedAt: c.deletedAt })), retentionDays, now, updatedAt));
189
+ const swept = [];
190
+ let failed = 0;
191
+ for (const candidate of candidates) {
192
+ if (!expired.has(candidate.key))
193
+ continue;
194
+ if (await purgeSucceeded(candidate.purge)) {
195
+ swept.push(`${candidate.domain}/${candidate.name || candidate.key}`);
196
+ recordAudit({
197
+ ts: now,
198
+ op: candidate.domain === 'skills' ? AUDIT_OP_SKILLS
199
+ : candidate.domain === 'memories' ? AUDIT_OP_MEMORIES
200
+ : (AUDIT_OP_BY_KIND[candidate.domain] || 'trash-sweep'),
201
+ target: candidate.name || candidate.key,
202
+ source: 'engine',
203
+ });
204
+ }
205
+ else {
206
+ failed += 1;
207
+ }
208
+ }
209
+ return { swept, failed, retentionDays };
210
+ }
211
+ /**
212
+ * 回收站保留期域:读 / 写侧车 + 清扫,并在**启动时扫一次、之后按 `sweepIntervalMs` 周期复跑**。
213
+ *
214
+ * 与 `createHistoryDomain` 同形(那边是归档会话的保留期):默认 6 小时一次,`sweepIntervalMs`
215
+ * 由插件配置覆盖。间隔读同一个配置键,是为了让"清扫频率"只有一个说法 —— 两个域各读各的键,
216
+ * 用户调了其中一个却只影响一半,是查不出来的那种不一致。
217
+ *
218
+ * 清扫**不阻断启动**:`void sweep()` 失败只 warn。回收站清理是附属能力,任何时候都不该
219
+ * 让插件起不来(与 audit-log.ts 第 ② 条同一条纪律)。
220
+ */
221
+ export function createTrashRetentionDomain(deps) {
222
+ const { ctx, config, memories } = deps;
223
+ const configured = Number(config?.sweepIntervalMs || 0);
224
+ const sweepIntervalMs = configured > 0 ? configured : 6 * 60 * 60 * 1000;
225
+ async function sweep() {
226
+ try {
227
+ return await sweepTrash(memories);
228
+ }
229
+ catch (e) {
230
+ ctx.logger?.warn?.(`trash retention sweep failed: ${String(e?.message ?? e)}`);
231
+ return { swept: [], failed: 0, retentionDays: 0 };
232
+ }
233
+ }
234
+ // 启动时扫一次:上次退出到现在可能已经过了好几个保留期。
235
+ try {
236
+ void sweep();
237
+ }
238
+ catch { /* 非致命 */ }
239
+ try {
240
+ ctx.effect(() => {
241
+ // interval 缺失时退化为不挂钟(fake-ctx 测试里正是如此,不阻塞测试)。
242
+ const timer = ctx;
243
+ const fn = typeof timer.interval === 'function' ? timer.interval
244
+ : (typeof timer.setInterval === 'function' ? timer.setInterval : undefined);
245
+ if (!fn)
246
+ return () => { };
247
+ return fn(() => { void sweep(); }, sweepIntervalMs);
248
+ }, 'dsh-plugin-tool-management: trash retention sweep');
249
+ }
250
+ catch { /* timer 缺失时静默 */ }
251
+ return {
252
+ read: readTrashRetention,
253
+ write: writeTrashRetention,
254
+ sweep,
255
+ };
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, skills, scenes, memories, subagent personas and AGENTS.md presets — a scene switches the whole environment, and the model can drive it from chat too.",
5
5
  "repository": {
6
6
  "type": "git",