dsh-plugin-tool-management 0.18.5 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +254 -221
  3. package/README_EN.md +223 -224
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/images/logo.png +0 -0
  21. package/docs/images/promo-cover-EN.png +0 -0
  22. package/docs/images/promo-cover.png +0 -0
  23. package/docs/update.md +28 -500
  24. package/lib/client.js +1857 -913
  25. package/lib/context-inject-frame.js +13 -0
  26. package/lib/context-inject-select.js +4 -3
  27. package/lib/context-inject.js +4 -3
  28. package/lib/index.js +100 -19
  29. package/lib/op-registry.js +16 -1
  30. package/lib/ops/candidates.js +1 -1
  31. package/lib/ops/prompts.js +64 -7
  32. package/lib/ops/trash-retention.js +55 -0
  33. package/lib/prompts/service.js +191 -25
  34. package/lib/scene-prompt-sync.js +1 -1
  35. package/lib/scenes/candidates.js +162 -69
  36. package/lib/settings-sidecars.js +2 -1
  37. package/lib/subagents/catalog.js +2 -2
  38. package/lib/subagents/persona-parse.js +36 -1
  39. package/lib/subagents/persona-write.js +7 -1
  40. package/lib/subagents/run-provider.js +55 -21
  41. package/lib/subagents/service.js +3 -3
  42. package/lib/subagents/tools.js +17 -2
  43. package/lib/tools/scene.js +4 -1
  44. package/lib/tools/subagent.js +13 -1
  45. package/lib/trash-retention.js +256 -0
  46. package/package.json +1 -1
@@ -67,6 +67,19 @@ export const DOMAIN_FRAME = {
67
67
  supersede: SUPERSEDE_NOTE,
68
68
  },
69
69
  };
70
+ /**
71
+ * `how` 行里**点名过的工具**(六个域里只有 skills / subagents 两个会点名)。
72
+ *
73
+ * 为什么要有这份名单:一条 `how` 行是否成立,取决于它点名的工具在**这个 agent 手里**还在不在。
74
+ * 名单放在这里而不是调用方 —— 与上面那两条 `how` 相邻,改一处不会漏另一处。
75
+ *
76
+ * "不在手里"有两条路,都要算:
77
+ * ① 兼容页「模型工具表」把它关掉(用户全局开关,名单在 index.ts 的 `hiddenTools` 里);
78
+ * ② **人设的工具限制**把它砍掉(`decideToolFilter` → 官方 `tools.restrict`,只作用于那份
79
+ * 人设启动的子代理)。②此前没人管:目录照注、`how` 行照样点名一个模型手里没有的工具,
80
+ * 等于诱导它去调一个不存在的名字(2026-10-07 修)。
81
+ */
82
+ export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run'];
70
83
  /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
71
84
  export const fallbackFrame = (label) => ({
72
85
  title: `本机的${label}`,
@@ -20,9 +20,10 @@ export function normalizeInjectSettings(raw) {
20
20
  * 规则(设计 2026-09-16;目录注入深度 2026-09-17):
21
21
  * - 域开关关掉的、文本为空的,都不进;
22
22
  * - **压制型预设**且没开「仍然注入」→ 一个都不进(跟随预设);
23
- * - 域声明 `applicableTo` 说不成立的域不进(当前只有人设目录:会话深度超出了人设的
24
- * `catalogDepth`,默认 1 = 只在顶层注入。**这不是"子会话不能委派"** —— 委派由官方
25
- * 决定,见 service.ts 里 `catalogDepth` 的注释);
23
+ * - 域声明 `applicableTo` 说不成立的域不进(**深度判据逐域写**:场景与记忆只在顶层
24
+ * —— 它们说的是父会话的处境;人设目录则看会话深度有没有超出人设的 `catalogDepth`,
25
+ * 默认 1 = 只在顶层注入。**这都不是"子会话不能委派"** —— 委派由官方决定,
26
+ * 见 service.ts 里 `catalogDepth` 的注释);
26
27
  * - 有官方载体的两个域(提示词 / 技能目录):预设挂得到官方那条行时不进
27
28
  * —— 官方自己会送,再注入一遍只会重复;预设事实读不到(`undefined`)时按"已承载"处理
28
29
  * (宁可与现状一致,也不制造重复);
@@ -80,7 +80,7 @@ import { kindOfMessage, LIVE_KINDS, newestDomainTexts, officialKindsToSuppress,
80
80
  // subagents/{catalog,service}.ts 与契约测试都按这个路径引)。
81
81
  export { DEFAULT_INJECT_SETTINGS, DOMAIN_TOOL_PREFIX, INJECT_DOMAIN_KEYS, INJECT_KIND_OF, domainOfTool, isSubagentSession, subagentDepthOf, } from './context-inject-contract.js';
82
82
  export { explainInjections, normalizeInjectSettings, selectInjections, } from './context-inject-select.js';
83
- export { clearedDomainText, escapeFrameBody, renderDomainText } from './context-inject-frame.js';
83
+ export { clearedDomainText, escapeFrameBody, HOW_NAMED_TOOLS, renderDomainText } from './context-inject-frame.js';
84
84
  export { officialKindsToSuppress } from './context-inject-surface.js';
85
85
  // B4:关域拦截的**顺序自检**。官方那两条注入行与本插件同挂在这条瀑布上,拦截成立的剩余前提是
86
86
  // "本插件排在它们上游"(本插件用 `{ prepend: true }` 把自己钉在钩子表最前,所以真正还依赖的
@@ -370,8 +370,9 @@ export function createContextInjector(deps) {
370
370
  const published = new Set();
371
371
  // 域声明按 key 索引:来源文件(`files`)只在真要发消息时取,所以要能从这里回查声明。
372
372
  const domainOf = new Map(domains.map((domain) => [domain.key, domain]));
373
- // 工具表开关本步读一次(同步快照):`how` 行里点名工具的那几句据此换话术。
374
- const hiddenNow = deps.hiddenTools !== undefined ? deps.hiddenTools() : null;
373
+ // `how` 行点名工具的那几句据本步的快照换话术。按 agent 取:全局开关之外,
374
+ // 人设的工具限制也会让工具不在手里,而那份名单逐 agent 不同。
375
+ const hiddenNow = deps.hiddenTools !== undefined ? deps.hiddenTools(agent) : null;
375
376
  const toolHidden = hiddenNow === null ? () => false : (name) => hiddenNow.has(name);
376
377
  for (const section of sections) {
377
378
  published.add(section.key);
package/lib/index.js CHANGED
@@ -30,7 +30,7 @@ import { runStateDoctor } from './ops/state-doctor.js';
30
30
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
31
31
  import { createSubagentCatalog } from './subagents/catalog.js';
32
32
  import { createSkillCatalog } from './skills/catalog.js';
33
- import { createContextInjector, subagentDepthOf, } from './context-inject.js';
33
+ import { createContextInjector, HOW_NAMED_TOOLS, subagentDepthOf, } from './context-inject.js';
34
34
  import { isApprovalNever } from './approval-policy.js';
35
35
  import { DEFAULT_HIDDEN_TOOLS, FACTORY_TOOL_TABLE_PRESET, normalizeToolTableSettings, } from './tools/table.js';
36
36
  import { createToolTableSwitch } from './tool-table-switch.js';
@@ -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 路由一起搬走了(写门禁与审计流水都读同一份登记表判据)。
@@ -624,6 +627,35 @@ export default {
624
627
  }
625
628
  return out;
626
629
  };
630
+ /**
631
+ * `how` 行点名过的工具里,**这个 agent 手里没有**的那些(注入器每步同步问一次)。
632
+ *
633
+ * 两条路并起来,缺一条框架文本就会说谎:
634
+ * ① `toolTableHidden()` —— 兼容页「模型工具表」的全局开关。**必须留着**:它读的是设置
635
+ * 本身,与"限制层装没装上"无关(可见性的重放有 300ms 去抖,只问可见性会有一个窗口漏报)。
636
+ * ② `tools.get(name, agent)` —— 拿 agent 当 scope 问"它解析得到这个名字吗",与
637
+ * `carrierHiddenToolsFor` 同一套官方写法。这一条覆盖**人设的工具限制**:
638
+ * `decideToolFilter` 算出的 allow/deny 由官方在子代理启动时 `tools.restrict` 装到那个
639
+ * scope 上(`dsh-subagent/lib/index.js:522`),全局开关表里看不到它 —— 于是人设砍掉
640
+ * `skill_manager_read` 之后,技能目录照注、`how` 行照样点名一个模型手里没有的工具。
641
+ * 拿不到 agent 时跳过:`get(name, undefined)` 问的是**全局视图**,会得出相反结论。
642
+ */
643
+ const howHiddenToolsFor = (agent) => {
644
+ const hidden = new Set(toolTableHidden());
645
+ if (agent === null || (typeof agent !== 'object' && typeof agent !== 'function'))
646
+ return hidden;
647
+ const scope = agent;
648
+ for (const name of HOW_NAMED_TOOLS) {
649
+ if (hidden.has(name))
650
+ continue;
651
+ try {
652
+ if (tools.get(name, scope) === undefined)
653
+ hidden.add(name);
654
+ }
655
+ catch { /* 读不到 = 按还在手里算 */ }
656
+ }
657
+ return hidden;
658
+ };
627
659
  // ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
628
660
  // 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
629
661
  // 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
@@ -673,23 +705,23 @@ export default {
673
705
  // 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
674
706
  // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
675
707
  //
676
- // `applicableTo`(2026-09-17 方案 A):子智能体域按"**目录该不该注入到这么深的
677
- // 会话**"(人设的 `catalogDepth`,默认 1 = 只在顶层注入)判断,**不是**"能不能
678
- // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider
679
- // 只在传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫
680
- // "还有没有委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里
681
- // `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
682
- // 所以不会分叉。
708
+ // 深度判据(`applicableTo`)**逐域写在域声明上**,三个域各一条,语义都**不是**"能不能
709
+ // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider 只在
710
+ // 传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫"还有没有
711
+ // 委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里 `catalogDepth` 的注释。
712
+ // · 场景与记忆:**恒为"只在顶层"**。这两段说的是**父会话的处境**(此刻在哪个场景、
713
+ // 这个场景下攒了什么),子代理要什么由父代理写进委派说明;记忆本体又是几 KB 的
714
+ // 常驻文本,子代理真有需要可以自己用 `memory_manager_*` 取。放它们进子会话,
715
+ // 子代理的上下文就不再收敛为「角色 + 任务」。(0.19.0 中途有一版把这两条放开到
716
+ // 所有深度,已回退。)
717
+ // · 子智能体目录:人设的 `catalogDepth`(默认 1 = 只在顶层)—— 它问的是"**目录该不该
718
+ // 注入到这么深的会话**",与"这个域要不要注入"是两件事。目录正文也按同一判据过滤
719
+ // (`text(agent)`),两处同源所以不会分叉。
720
+ // · MCP / 技能 / 提示词:不分深度。
683
721
  //
684
- // 场景与记忆(2026-09-17 用户裁定;2026-09-23 拆成两个域):**都只在顶层注入**。
685
- // 记忆是"父会话的现场",不是子代理完成任务所需的事实 —— 塞进一次性子会话只会与
686
- // 角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与整份记忆)。
687
- // 场景段同理:它说的是"父会话现在处在哪个模式",而子代理根本没有"模式"可言。
688
- // 子代理手里有 `memory_manager_list/read` 与 `scene_manager_save`,需要什么自己取;
689
- // 父代理上下文里也有,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
690
- // 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
691
- // 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
692
- // 工具,不知道清单就只能瞎调)。
722
+ // 同一张 `domains` 表管着六个域的开关,没有按会话分流的第二张表 —— 那张
723
+ // `subagentDomains` 已删:人设身份在官方机制里取不到(descriptor 是字段白名单、
724
+ // 冷恢复只还原 provider/model/effort)。
693
725
  domains: () => [
694
726
  // 场景(**框架**):启用的场景 + 场景说明(用户的约定)。排第一 —— 先让模型知道
695
727
  // "现在在哪个场景、这个场景的约定是什么",再读下面的记忆条目。
@@ -710,7 +742,7 @@ export default {
710
742
  ],
711
743
  settings: () => injectSettingsSync(),
712
744
  // 工具表开关:`how` 行里点名工具的那几句要跟着它换话术(关掉的不点名)。
713
- hiddenTools: toolTableHidden,
745
+ hiddenTools: howHiddenToolsFor,
714
746
  factsFor: (agent) => candidates.presetFactsForAgent(agent),
715
747
  // 令牌门禁(2026-09-19,用户裁定「宿主侧硬拦截」):令牌**在生效**(`TOKEN !== ''`,
716
748
  // 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
@@ -845,6 +877,33 @@ export default {
845
877
  // 保留期设置与到期清扫、批量操作目标校验、归档列表分组视图。构造实例、首次清扫、
846
878
  // 挂周期定时器都在工厂内完成 —— 它们原本就写在 apply() 的同一位置。
847
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
+ });
848
907
  // ── 场景提示词 → 全局基线(AGENTS.md)同步 ────────────────────────────────
849
908
  //
850
909
  // 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
@@ -904,6 +963,8 @@ export default {
904
963
  return `场景「${ref.label || ''}」${ref.active ? '(已启用)' : ''}绑定了它`;
905
964
  if (ref && ref.kind === 'restore')
906
965
  return '它就是退出场景后要恢复的全局提示词';
966
+ if (ref && ref.applied === true)
967
+ return '它正被「应用」在 ~/.dsh/AGENTS.md 里';
907
968
  return '~/.dsh/AGENTS.md 当前内容就是它';
908
969
  };
909
970
  // ---------- path discovery ----------
@@ -1399,6 +1460,11 @@ export default {
1399
1460
  // 实现在 ./scenes/candidates.ts(2026-09-19 从本闭包 10 段抽出,约 260 行)。
1400
1461
  // 放在这里而不是更早:它要 mcp(读停用表)与 toolKeyParts(416 行的 const),
1401
1462
  // 而唯一在更早处引用它的地方(注入 deps 的 factsFor)是箭头函数,调用发生在之后。
1463
+ // 本插件注册过的工具名。**唯一**可靠的"插件工具"判据:官方没有工具级归属接口
1464
+ // (`ToolSchema` 无归属字段、`ToolRuntime` 公开面无来源、`tools/change` 无载荷),
1465
+ // 但自己注册了什么自己当然知道。登记点在下面 `toolDeps.register`(2115 行那个漏斗,
1466
+ // 六个域的构建器全部经它),所以不可能漂移 —— 新增工具自动进集合,不用维护前缀表。
1467
+ const selfToolNames = new Set();
1402
1468
  const candidates = createCandidates({
1403
1469
  get: (name) => ctx.get(name),
1404
1470
  tools,
@@ -1406,6 +1472,9 @@ export default {
1406
1472
  skillsService,
1407
1473
  memoriesService,
1408
1474
  toolKeyParts,
1475
+ // 传 getter 而不是快照:登记发生在 apply 过程中(本行之后 600 余行),
1476
+ // 而这个工厂现在就要构造完。
1477
+ selfToolNames: () => selfToolNames,
1409
1478
  });
1410
1479
  // 提示词预设域的 op 表。单独提出来是为了让**模型工具**能复用同一份判定:
1411
1480
  // `prompt_manager_list` 以前直调 `promptsService.list()`,于是它报的「生效中」是
@@ -1509,6 +1578,14 @@ export default {
1509
1578
  extractTurnsFromEvents: extractExportTurns,
1510
1579
  serializeTurns: serializeTranscript,
1511
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
+ }),
1512
1589
  // rules 域里要走「场景 ↔ 全局基线同步」包装的四个写 op(ops/scene-sync.ts 提供)。
1513
1590
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
1514
1591
  ...buildSceneSyncOps({
@@ -2089,7 +2166,11 @@ export default {
2089
2166
  defineTool,
2090
2167
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
2091
2168
  // 兼容页「模型工具表」块的分组与 ≈token 都取自这里,那是这份设置唯一的数字来源。
2092
- register: (def) => { recordToolSize(def); tools.register(def); },
2169
+ //
2170
+ // 同一行里登记 `selfToolNames`:六个域的构建器全部经这个漏斗,所以它就是"本插件注册了
2171
+ // 哪些工具"的权威清单。`tools.register` 抛(重名 / 保留名)时不登记 —— 那份不在表里,
2172
+ // 也不该出现在「工具限制」的候选里。
2173
+ register: (def) => { recordToolSize(def); tools.register(def); selfToolNames.add(String(def.name)); },
2093
2174
  lockedSceneGuard,
2094
2175
  syncSwitchToScene,
2095
2176
  // 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
@@ -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
- // ── 提示词域(11)──────────────────────────────────────────────────────────
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 第 ① 条)。
@@ -6,7 +6,7 @@ export function buildCandidateOps(deps) {
6
6
  return {
7
7
  // 人设表单的两个候选源之一(只读,均为「按需拉取」——不进 scene-inventory,避免每次开
8
8
  // 档案弹窗都枚举预设)。
9
- // preset-tools:全体 Agent 预设工具名并集 + 各工具所属预设 + 当前会话是否可见。
9
+ // preset-tools:一行一个 Agent 预设,各带自己的工具清单与归属组(0.19.0 起不再返回全体并集)。
10
10
  // `unavailable` = 本插件自己没注册上的工具:工具列表接口是唯一按名字枚举工具的地方,
11
11
  // 注册失败只能在这里被如实回答。
12
12
  'preset-tools': async () => ({
@@ -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
- // 删除:**被引用的那份一律拒绝**(用户裁定,2026-09-16 扩到引用清单)。三类引用:
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
- const why = refs.get(id) || [];
100
- if (why.length) {
101
- const refsText = why.map(deps.promptRefReason).join(';');
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}」仍被引用,不能删除:${refsText}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
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
+ }