dsh-plugin-tool-management 0.20.0 → 0.20.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 (45) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -1
  3. package/README_EN.md +1 -1
  4. package/docs/images/logo.gif +0 -0
  5. package/docs/update.md +1 -0
  6. package/lib/client.js +1594 -364
  7. package/lib/compat/preset-reach.js +5 -2
  8. package/lib/context-inject-frame.js +13 -8
  9. package/lib/hub.js +8 -5
  10. package/lib/index.js +56 -20
  11. package/lib/mcp/manager.js +21 -0
  12. package/lib/mcp/state-section.js +1 -1
  13. package/lib/memories/archive-engine.js +4 -5
  14. package/lib/memories/constants.js +2 -2
  15. package/lib/op-registry.js +7 -5
  16. package/lib/ops/memory.js +42 -4
  17. package/lib/ops/scene-records.js +43 -9
  18. package/lib/ops/sessions.js +5 -39
  19. package/lib/ops/trash.js +45 -5
  20. package/lib/request-gate.js +9 -2
  21. package/lib/scene-prompt-sync.js +48 -23
  22. package/lib/sessions/workspace.js +0 -10
  23. package/lib/skills/catalog.js +17 -5
  24. package/lib/skills/core.js +121 -5
  25. package/lib/skills/frontmatter.js +31 -0
  26. package/lib/skills/manager-state.js +41 -2
  27. package/lib/skills/service.js +46 -12
  28. package/lib/skills/skill-roots.js +54 -4
  29. package/lib/skills/skill-trash.js +118 -12
  30. package/lib/skills/upload-pipeline.js +110 -26
  31. package/lib/subagents/catalog.js +9 -5
  32. package/lib/subagents/service.js +44 -6
  33. package/lib/subagents/tools.js +45 -15
  34. package/lib/tool-table-switch.js +6 -6
  35. package/lib/tools/deps.js +43 -0
  36. package/lib/tools/mcp.js +68 -27
  37. package/lib/tools/memory.js +49 -9
  38. package/lib/tools/prompt.js +26 -9
  39. package/lib/tools/remove.js +206 -0
  40. package/lib/tools/scene.js +18 -13
  41. package/lib/tools/skills.js +162 -55
  42. package/lib/tools/subagent.js +65 -31
  43. package/lib/tools/table.js +39 -12
  44. package/lib/trash-id.js +16 -0
  45. package/package.json +115 -114
package/lib/client.js CHANGED
@@ -72,6 +72,10 @@ window.__ModuleLoader__.load({
72
72
  能拖高;权重 4 类 + 1 元素,压过上面那条 200px,不靠 !important。 */
73
73
  '.dsm-modal.dsm-modal-compact textarea.dsm-control.dsm-textarea-md{min-height:150px}' +
74
74
  '.dsm-detail-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
75
+ /* 标题行右侧的动作组:两个及以上按钮必须包一层 —— space-between 直接摊三个子元素时,
76
+ 中间那个会被顶到整行正中(用户 2026-10-15:「重启怎么在中间」)。标题在左、动作组
77
+ 在右,组内间距 8px 与行间隙一致。 */
78
+ '.dsm-detail-title-acts{display:flex;align-items:center;gap:8px}' +
75
79
  `
76
80
  .dsm-pick-list{display:flex;max-height:240px;flex-direction:column;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-m);padding:4px}.dsm-tab{padding:8px 14px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}.dsm-tab-active{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-success-primary)}.dsm-section{box-sizing:border-box;display:flex;width:100%;max-width:820px;min-width:0;margin:0 auto;padding:4px 0 24px;container-type:inline-size;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-family:inherit}.dsm-head{display:flex;flex-direction:column;align-items:stretch;gap:16px}.dsm-feedback-links{display:flex;flex-direction:column;align-items:flex-end;gap:4px}.dsm-title{margin:0;font-size:18px;line-height:26px;font-weight:600;letter-spacing:-.2px;white-space:nowrap}.dsm-feedback-link{display:inline-flex;min-height:30px;align-items:center;gap:4px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-m);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;font-weight:400;line-height:18px;text-decoration:none;white-space:nowrap}.dsm-feedback-link:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.dsm-feedback-link:focus-visible{outline:2px solid var(--dsw-alias-state-success-primary);outline-offset:2px}.dsm-feedback-link svg{flex:none}.dsm-desc{margin:12px 0 0;display:-webkit-box;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:22px;-webkit-box-orient:vertical;-webkit-line-clamp:2}.dsm-actions{display:flex;flex-wrap:wrap;gap:8px;margin-left:0;flex:none}
77
81
  /* 页头动作行:内边距收 2px。批量按钮要按「取消全选」定宽(见 .dsm-btn-bulk),
@@ -179,6 +183,10 @@ button.dsm-tag:disabled{cursor:default;opacity:.6}
179
183
  .dsm-pill{display:inline-flex;min-height:20px;align-items:center;padding:0 8px;border:1px solid var(--dsw-alias-border-l3);border-radius:99px;font-size:12px;font-weight:400;white-space:nowrap}
180
184
  .dsm-pill-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}
181
185
  .dsm-pill-warn{border-color:var(--dsm-warn);color:var(--dsm-warn)}
186
+ /* 「启动中」的胶囊此前**没有对应配色**(只有 ok / warn / muted 三档),详情页标题那颗
187
+ 「加载中」于是落在 .dsm-pill 的无色默认上 —— 与"没在跑"看着一样。与状态点同一支绿,
188
+ 压一点亮度表示还没到位。 */
189
+ .dsm-pill-busy{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);opacity:.78}
182
190
  .dsm-pill-muted{color:var(--dsw-alias-label-tertiary)}
183
191
  .dsm-tool-desc-wrap{display:flex;align-items:flex-end;gap:8px}
184
192
  .dsm-tool-desc-clamp{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}
@@ -213,6 +221,11 @@ button.dsm-tag:disabled{cursor:default;opacity:.6}
213
221
  超限转色,不再画进度条。保留 .dsm-budget-meta,它服务的是各表单的「标签 + 字数计数」行。) */.dsm-budget-meta{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.dsm-budget-meta strong{margin-right:0;font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}.dsm-char-count{color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}.dsm-char-over{color:var(--dsw-alias-state-error-primary)}.dsm-field-row{display:flex;gap:12px;align-items:flex-start}.dsm-field-row>.dsm-field{flex:1;min-width:0}
214
222
  .dsm-combo-row{display:flex;gap:8px}.dsm-combo-row .dsm-control{flex:1}
215
223
  .dsm-attach-list{display:flex;flex-direction:column;gap:4px}
224
+ /* 技能创建弹窗的附件区(2026-10-16):清单封顶滚动,行收紧一档 —— 附件多的时候
225
+ 弹窗不该被顶到比正文还高,而 .dsm-file 原档的 8px 内边距是给导入拖拽区用的。 */
226
+ .dsm-attach-actions{display:inline-flex;align-items:center;gap:6px}
227
+ .dsm-attach-cap{max-height:126px;overflow:auto;padding-right:2px}
228
+ .dsm-attach-cap .dsm-file{gap:6px;padding:5px 8px}
216
229
  .dsm-toast{position:fixed;right:22px;bottom:22px;z-index:1250;box-sizing:border-box;max-width:min(420px,calc(100vw - 44px));padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-m);background:var(--dsw-alias-bg-layer-3);box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;animation:dsm-toast-in 160ms ease-out}@keyframes dsm-toast-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}/* 动效降级:系统开了「减少动态效果」就一律关掉。新增 transition / animation 时记得同步加到这里,
217
230
  否则那一个动效就是唯一不尊重用户设置的部分。(列表换位的 FLIP 曾在 JS 侧另判,随动画一起删除。) */
218
231
  @media(prefers-reduced-motion:reduce){.dsm-toast{animation:none}.dsm-switch:after{transition:none}.dsm-dropzone{transition:none}}
@@ -548,14 +561,14 @@ html,body{scrollbar-gutter:stable}.dsm-settings-scroll-host{overflow-y:scroll!im
548
561
  .dsm-toast-warn{border-color:var(--dsm-warn);color:var(--dsm-warn)}
549
562
  .dsm-toast-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
550
563
 
551
- /* MCP JSON 导入预览(0.15.0 A1):勾选 | 主体 | 状态三列。非法行只把「问题」那一行标红,
564
+ /* MCP JSON 导入预览(0.15.0 A1):勾选 | 主体 | 状态三列;表头 label 已删
565
+ (用户 2026-10-15),勾选框随行内容垂直居中。非法行只把「问题」那一行标红,
552
566
  名字净化 / 字段忽略这类是配置事实,走中性灰 —— 黄色在这里留给不了任何东西(琥珀语义)。 */
553
- .dsm-imp-head,.dsm-imp-row{display:grid;grid-template-columns:15px minmax(0,1fr) auto;align-items:start;gap:8px}
554
- .dsm-imp-head{padding:0 4px 4px;color:var(--dsw-alias-label-tertiary);font-size:12px}
567
+ .dsm-imp-row{display:grid;grid-template-columns:15px minmax(0,1fr) auto;align-items:center;gap:8px}
555
568
  .dsm-imp-list{display:flex;flex-direction:column;gap:4px;max-height:320px;padding:4px 4px 0 0;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2) transparent}
556
569
  .dsm-imp-row{padding:8px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-m);background:var(--dsw-alias-bg-layer-1);font-size:12px}
557
570
  .dsm-imp-row.dsm-imp-bad{border-color:var(--dsw-alias-state-error-primary)}
558
- .dsm-imp-row input[type=checkbox]{width:14px;height:14px;margin:4px 0 0;cursor:pointer;accent-color:var(--dsw-alias-state-success-primary)}
571
+ .dsm-imp-row input[type=checkbox]{width:14px;height:14px;margin:0;cursor:pointer;accent-color:var(--dsw-alias-state-success-primary)}
559
572
  .dsm-imp-row input[type=checkbox]:disabled{cursor:not-allowed}
560
573
  .dsm-imp-main{display:flex;min-width:0;flex-direction:column;gap:4px}
561
574
  .dsm-imp-name{display:flex;align-items:center;flex-wrap:wrap;gap:4px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}
@@ -564,8 +577,10 @@ html,body{scrollbar-gutter:stable}.dsm-settings-scroll-host{overflow-y:scroll!im
564
577
  .dsm-imp-err{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:16px}
565
578
  .dsm-imp-meta{display:flex;flex-direction:column;align-items:flex-end;gap:4px}
566
579
  .dsm-imp-foot{display:flex;align-items:center;flex-wrap:wrap;gap:12px}
567
- .dsm-imp-radio{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}
568
- .dsm-imp-radio input{width:14px;height:14px;margin:0;accent-color:var(--dsw-alias-state-success-primary)}
580
+ /* 导入弹窗重名处理行(用户 2026-10-15 两连):从预览底部上移到预览之前,导入前
581
+ 就看得见;排他二选一用「提示词类型」同款分段控件(dsm-form-seg,aria-pressed
582
+ 表选中),「重名时」文字 label 删除(同类第三例:设置页表头 label 也删)。 */
583
+ .dsm-imp-conflict{display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px}
569
584
  .dsm-imp-count{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}
570
585
 
571
586
  /* 体检状态点(0.15.0 B1)与行内检查项。点只有 8px,靠 title 说清含义;
@@ -573,8 +588,16 @@ html,body{scrollbar-gutter:stable}.dsm-settings-scroll-host{overflow-y:scroll!im
573
588
  .dsm-dot{display:inline-block;flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-border-l3)}
574
589
  .dsm-dot-ok{background:var(--dsw-alias-state-success-primary)}
575
590
  .dsm-dot-warn{background:var(--dsm-warn)}
576
- /* (.dsm-dot-busy 与 @keyframes dsm-dot-pulse 已随 MCP 体检功能一起删除,2026-10-12:
577
- "闪动的点"只有体检的进行中态用过,状态点现在是静态的。) */
591
+ /* 启动进行中的呼吸点(2026-10-15 用户要求加回:服务启动要数秒,原来只有一个静态灰点,
592
+ "正在启动"只能靠 title 说出来。2026-10-12 曾以"闪动的点只有体检用"为由删过一次,
593
+ 这次是 MCP 启动相位自己要 —— 全页只有 phase 非 active 时用它,不会到处闪)。
594
+ 配色改绿(用户 2026-10-15):business 橙在 dsh 的语义里是"占用中/要处理",而启动中既不是
595
+ 警告也不是故障,跑起来之后它就变成同一支绿 —— 呼吸与常亮因此是同一个颜色的两种节律。 */
596
+ .dsm-dot-busy{background:var(--dsw-alias-state-success-primary);animation:dsm-dot-pulse 1.1s ease-in-out infinite}
597
+ /* 呼吸的最低亮度从 .3 提到 .65,且改成**长一圈**而不是缩一点(用户 2026-10-15:灯在闪,
598
+ 可颜色看着不绿,分不清是不是启动中)。6px 的点压到三成透明度就只剩一层灰;"变亮 + 长大"
599
+ 与跑起来那颗常亮的绿一眼可分。 */
600
+ @keyframes dsm-dot-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.65;transform:scale(1.35)}}
578
601
  /* (.dsm-inspect / -line / -warn 三条已随 MCP 体检功能一起删除,2026-10-12。) */
579
602
 
580
603
  /* 「最近改动」流水(0.15.0 B2):时间列固定窄栏,改动列吃剩余宽度,来源徽章贴在行尾。 */
@@ -640,8 +663,7 @@ html,body{scrollbar-gutter:stable}.dsm-settings-scroll-host{overflow-y:scroll!im
640
663
  detail-head / composer-chips,属于 #7)与半残的 tabs / switch 组。 */
641
664
  .dsm-pick input[type=checkbox]{flex:none;width:15px;height:15px;margin:0;cursor:pointer}
642
665
  .dsm-tooltable-ghead input[type=checkbox]{flex:none;width:15px;height:15px;margin:0;cursor:pointer}
643
- .dsm-imp-row input[type=checkbox]{width:14px;height:14px;margin:4px 0 0;cursor:pointer}
644
- .dsm-imp-radio input{width:14px;height:14px;margin:0}
666
+ .dsm-imp-row input[type=checkbox]{width:14px;height:14px;margin:0;cursor:pointer}
645
667
  .dsm-select-menu button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary); outline-offset: 1px;}
646
668
  .dsm-btn-lock:hover:not(:disabled){background: var(--dsw-alias-interactive-bg-hover);}
647
669
  .dsm-btn-quiet:hover:not(:disabled){background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary);}
@@ -680,7 +702,6 @@ button.dsm-chip:hover{background: var(--dsw-alias-interactive-bg-hover);}
680
702
  .dsm-feedback.dsm-error{padding: 8px 12px; border-radius: var(--dsm-radius-m);}
681
703
  .dsm-tab:hover{color: var(--dsw-alias-label-primary);}
682
704
  .dsm-tab[aria-selected= true ]{color: var(--dsw-alias-label-primary); border-bottom-color: var(--dsw-alias-label-primary); font-weight: 400;}
683
- .dsm-imp-radio:hover{background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary);}
684
705
  .dsm-modal-actions .dsm-btn{min-height: 30px; padding: 0 12px; font-size: 13px; line-height: 20px; border-radius: var(--dsm-radius-m);}
685
706
  .dsm-seg-filter .dsm-btn{min-height: 30px; padding: 0 12px; font-size: 13px; line-height: 20px;}
686
707
  .dsm-actions .dsm-bulk-ghost{min-height: 24px; display: inline-flex; align-items: center;}
@@ -705,6 +726,10 @@ button.dsm-chip:hover{background: var(--dsw-alias-interactive-bg-hover);}
705
726
  /* 记忆「形态」二选一:分段按钮(flat / bundle 互斥)。两个值摊开、点击即切,不依赖下拉 ——
706
727
  下拉在弹窗底部会被滚动容器裁掉(用户 2026-10-10 截图)。 */
707
728
  .dsm-form-seg{display:inline-flex;gap:2px;padding:3px;border:0;border-radius:var(--dsm-radius-m);background:var(--dsw-alias-interactive-bg-hover)}
729
+ /* 字段内的分段控件收回内容宽度(用户 2026-10-15:提示词类型 / 形态两条被
730
+ .dsm-field 的 align-items:stretch 拉满整行,过长)。flex 子项会被块状化,
731
+ inline-flex 在列向导里挡不住 stretch —— 显式 align-self + fit-content 才保险。 */
732
+ .dsm-field>.dsm-form-seg{align-self:flex-start;width:fit-content}
708
733
  .dsm-form-seg button{min-height:24px;padding:0 12px;border:0;border-radius:var(--dsm-radius-s);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;cursor:pointer}
709
734
  .dsm-form-seg button:hover{color:var(--dsw-alias-label-primary)}
710
735
  .dsm-form-seg button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:600}
@@ -755,8 +780,30 @@ button.dsm-chip:hover{background: var(--dsw-alias-interactive-bg-hover);}
755
780
  §4.2 的 720 帽对这个弹窗太紧,登记为特例;屏幕不够高时仍然自适应缩,不会顶出视口。
756
781
  2026-10-12 用户「新建场景加高加宽」:1000×960 → 1200×1080(四组那一档同步加高,
757
782
  见 .dsm-editor-grid 的 grid-auto-rows —— 只抬帽不改内容的话弹窗高度由内容决定,等于没加)。
758
- ⚠️ 必须三段类名:上面 .dsm-modal.dsm-modal-list 那条也是 720,而且带 !important。 */
759
- .dsm-modal.dsm-modal-list.dsm-modal-editor{width:min(1200px,100%)!important;max-height:min(1080px,calc(100vh - 48px))!important}
783
+ ⚠️ 必须三段类名:上面 .dsm-modal.dsm-modal-list 那条也是 720,而且带 !important。
784
+ 宽度 2026-10-15 第二轮改成 min(1200px, 88vw)(用户:小窗下宽度也要减一点):原先的 100%
785
+ 只扣掉遮罩那 24px,1000px 宽的窗口里弹窗 952 —— 左右各只剩 24px,看着就是"顶满"。
786
+ 代价:顶部五格的 flex-basis 合计 901px,容器窄过它时按既有兜底退回两行(.dsm-editor-basics
787
+ 的 flex-wrap),所以这一档让出的宽度会让五格变两行、而不是把下拉压扁。 */
788
+ .dsm-modal.dsm-modal-list.dsm-modal-editor{width:min(1200px,88vw)!important;max-height:min(1080px,calc(100vh - 48px))!important}
789
+ /* 新增 / 编辑 MCP 的表单弹窗(用户 2026-10-15:两种传输方式弹窗大小不一样)。
790
+ stdio 是「启动命令 + 参数 + 环境变量」三格,streamable-http 只有「服务 URL + 请求头」两格 ——
791
+ 高度交给内容决定时,切一下传输方式弹窗就跳一次,而 stdio 那一档自然高 782,正好顶破 720 的帽、
792
+ 默认带滚动条。这一档给确定高度(与新建记忆切「形态」同一手感:格子跟着长,弹窗不跳也不滚),
793
+ 多出来的空间由带 textarea 的那几格吸收(.dsm-field-fill,见 41 片)。
794
+ ⚠️ 这里不写 min-height:0 —— 屏幕不够高时宁可整窗滚动,也不要让格子被压到内容之下、
795
+ textarea 溢出到下一格上面(实测 760 视口下会切掉两格)。
796
+ 820 → 770(用户 2026-10-16:「MCP 弹窗各减少一点」):省下的 50 里,24 来自把这一档的字段间距
797
+ 收回 12(stdio 七格六个间距),其余是给两端各留一点余量;stdio 自然高随之降到 ~758,
798
+ 仍然不滚 —— 减高度不能减出滚动条,那是用户同一批里刚抱怨过的。 */
799
+ .dsm-modal.dsm-modal-mcp-form{height:min(770px,calc(100vh - 48px));max-height:none}
800
+ .dsm-modal-mcp-form .dsm-form{flex:1;gap:12px}
801
+ .dsm-modal-mcp-form .dsm-field-fill{flex:1 1 auto}
802
+ .dsm-modal-mcp-form .dsm-field-fill>textarea{flex:1;min-height:88px}
803
+ /* 新增 / 编辑提示词、新建 / 编辑子智能体(用户 2026-10-15:「默认就会有滑动条」):
804
+ 正文是 dsm-textarea-lg 与两块 dsm-textarea-md,内容排开比 720 的帽更高 —— 帽抬到 920,
805
+ 让这两类表单按自己的内容站满;屏幕不够高时仍按视口缩,不会顶出窗口。 */
806
+ .dsm-modal.dsm-modal-form-tall{max-height:min(920px,calc(100vh - 48px))}
760
807
  /* 列表弹窗(导出 / 回收站):高度自适应——内容矮时缩到 min-height,超高时帽在 720、
761
808
  只让 body 内滚(头/底恒定)。旧的 640/760 定高全部由此替代。 */
762
809
  .dsm-modal.dsm-modal-list{width:min(720px,100%)!important;height:auto!important;max-height:min(720px,calc(100vh - 96px))!important;min-height:480px}
@@ -880,7 +927,14 @@ button.dsm-chip:hover{background: var(--dsw-alias-interactive-bg-hover);}
880
927
  「选择」一出现就把这一格的标签行顶高 4px,下面的下拉被推下去,与其余四格错开。 */
881
928
  .dsm-editor-basics .dsm-label,.dsm-editor-basics .dsm-char-count{line-height:18px}
882
929
  .dsm-label-row .dsm-btn{min-height:18px;padding:0 6px;font-size:12px;line-height:16px}
883
- .dsm-editor-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;grid-auto-rows:300px}
930
+ /* 四组的高度:原先恒定 300px(大窗下没问题),小窗下弹窗整个顶出视口 —— 用户 2026-10-15
931
+ 「新建场景在小窗口模式下占大量面积,能不能等比例缩小,但有最小值」。
932
+ 现在按视口缩:除四组之外的固定开销实测 218px(标题行 + 顶部五格 + 动作行 + 内边距与间距),
933
+ 连同 12px 行间距一起从每半个视口里扣掉,就是每行能给到的高度;上限仍是 300(大屏一字不改)。
934
+ 扣掉的 360 比"刚好填满"(286)再多让出 74px —— 用户 2026-10-15 第二轮:小窗下填满整屏
935
+ 还是太大,弹窗该有自己的边距,不是贴满视口才算用得上空间。下限 150:再矮清单就只剩两三行。
936
+ 组内 .dsm-editor-group-body 本来就是 min-height:0 + 内滚,所以行矮下去不会把内容挤出去。 */
937
+ .dsm-editor-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;grid-auto-rows:clamp(150px,calc((100vh - 360px) / 2),300px)}
884
938
  /* 分组卡片补一圈 1px 描边(用户 2026-10-12:「浅色模式下四个功能集没有分块,全是白的」)。
885
939
  原设计是 §13.1 的"深度靠表面层次、不画边框":卡片 bg-layer-1 比弹窗 bg-layer-2 高一档。
886
940
  但这条只在深色主题成立 —— 浅色下 layer-1 与 layer-2 都是白,卡片整块融进弹窗,
@@ -1005,6 +1059,11 @@ button.dsm-chip:hover{background: var(--dsw-alias-interactive-bg-hover);}
1005
1059
  .dsm-setrow{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px;padding:4px 0}
1006
1060
  .dsm-setrow+.dsm-setrow{border-top:1px solid var(--dsw-alias-border-l1)}
1007
1061
  .dsm-setrow-label{display:flex;min-width:0;flex-direction:column;gap:1px}
1062
+ /* 名字行:名字与状态标签(tag)同一行,垂直居中(用户裁定 2026-10-15:兼容页斜杠行的
1063
+ 「已生效 / 未生效」从开关旁挪到名字右边 —— 状态跟着名字读;标签是 pill,flex 居中
1064
+ 才与 13px 的名字真正上下对齐,基线对齐会偏上一截)。 */
1065
+ .dsm-setrow-nameline{display:flex;min-width:0;align-items:center;gap:6px}
1066
+ .dsm-setrow-nameline>.dsm-setrow-name{min-width:0}
1008
1067
  .dsm-setrow-name{overflow:hidden;color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;text-overflow:ellipsis;white-space:nowrap}
1009
1068
  .dsm-setrow-desc{overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px;text-overflow:ellipsis;white-space:nowrap}
1010
1069
  .dsm-setrow-control{display:flex;flex:none;align-items:center;gap:8px}
@@ -1174,12 +1233,16 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
1174
1233
  * 行间分隔线分节(`.dsm-setrow + .dsm-setrow`)。label 可带一句 desc(12 弱色,
1175
1234
  * 跟在名字下一行 —— 行随内容长高);descTitle 给截断的 desc 悬浮全文。
1176
1235
  * 用于兼容页令牌块 / 注入设置 / 子智能体表单开关行这一类「名字 + 一个控件」的行。
1177
- * props:name / desc / descTitle / control(右侧控件,通常是 Switch)/ title。
1236
+ * props:name / desc / descTitle / control(右侧控件,通常是 Switch)/ title /
1237
+ * tag(名字右边的状态标签,可选 —— 用户裁定 2026-10-15:兼容页斜杠行的「已生效 /
1238
+ * 未生效」从开关旁挪到名字右边,状态跟着名字读,不与开关混排)。
1178
1239
  */
1179
1240
  function SettingsRow(props) {
1180
1241
  return h('div', { className: 'dsm-setrow', title: props.title || undefined },
1181
1242
  h('div', { className: 'dsm-setrow-label' },
1182
- h('span', { className: 'dsm-setrow-name' }, props.name),
1243
+ h('span', { className: 'dsm-setrow-nameline' },
1244
+ h('span', { className: 'dsm-setrow-name' }, props.name),
1245
+ props.tag || null),
1183
1246
  props.desc ? h('span', { className: 'dsm-setrow-desc', title: props.descTitle || undefined }, props.desc) : null),
1184
1247
  h('div', { className: 'dsm-setrow-control' }, props.control || null))
1185
1248
  }
@@ -2969,10 +3032,12 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
2969
3032
  // 各自就是一个源,所以能各挂各的)+ 一句"重启才生效"。
2970
3033
  const slashParts = []
2971
3034
  if (slash) {
2972
- // 行末那枚标签是"本次启动真挂上了没":开关值说的是"要不要",挂没挂上只有浏览器知道
3035
+ // 名字右边那枚标签是"本次启动真挂上了没":开关值说的是"要不要",挂没挂上只有浏览器知道
2973
3036
  // (服务端看不到源注册的结果),两件事不能合成一句 —— 合了必有一句是假的。标签只说
2974
3037
  // 已生效 / 未生效,**为什么**没生效挂在悬停那句里(重名抢占 / 宿主没这个服务 / 还没就绪,
2975
3038
  // 三种的处置完全不同)—— 见 48-slash.js 的 setSlashMount。
3039
+ // 位置(用户裁定 2026-10-15):跟在名字右边,不摆开关旁 —— 状态说的是"这一段入口
3040
+ // 挂上没",跟着名字读;与 switch 并排会被读成"开关自己的状态",而开关只说要不要。
2976
3041
  const liveTag = function (key) {
2977
3042
  const state = slashMountStateOf(key)
2978
3043
  const on = state === 'registered'
@@ -2986,17 +3051,13 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
2986
3051
  React.createElement('span', null, t('compat.slash.title')),
2987
3052
  React.createElement('span', { className: 'dsm-seg-group-line' })),
2988
3053
  // 开关行(REDESIGN §5.2 SettingsRow + Switch,切片五换装):名字+说明在左,
2989
- // 开关与「本次启动真挂上了没」的标签在右。
3054
+ // 开关在右;「本次启动真挂上了没」的标签在名字右边(tag,见上)。
2990
3055
  React.createElement(SettingsRow, { key: 'slash-tools', name: t('compat.slash.tools'), desc: t('compat.slash.tools.desc'),
2991
- control: [
2992
- React.createElement(Switch, { key: 'sw', on: slash.tools !== false, label: t('compat.slash.tools'), onClick: function () { saveSlash({ tools: slash.tools === false }) } }),
2993
- liveTag('tools'),
2994
- ] }),
3056
+ tag: liveTag('tools'),
3057
+ control: React.createElement(Switch, { key: 'sw', on: slash.tools !== false, label: t('compat.slash.tools'), onClick: function () { saveSlash({ tools: slash.tools === false }) } }) }),
2995
3058
  React.createElement(SettingsRow, { key: 'slash-quick', name: t('compat.slash.quick'), desc: t('compat.slash.quick.desc'),
2996
- control: [
2997
- React.createElement(Switch, { key: 'sw', on: slash.quickPrompts !== false, label: t('compat.slash.quick'), onClick: function () { saveSlash({ quickPrompts: slash.quickPrompts === false }) } }),
2998
- liveTag('quickPrompts'),
2999
- ] }))
3059
+ tag: liveTag('quickPrompts'),
3060
+ control: React.createElement(Switch, { key: 'sw', on: slash.quickPrompts !== false, label: t('compat.slash.quick'), onClick: function () { saveSlash({ quickPrompts: slash.quickPrompts === false }) } }) }))
3000
3061
  }
3001
3062
  const injectParts = []
3002
3063
  if (inject) {
@@ -4097,8 +4158,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4097
4158
  "empty.search": "没有匹配的技能", "empty.source": "该来源不存在或暂无技能", "loading": "正在加载技能…", "note.missing": "未提供简介", "source.toggle": "启停来源", "skill.toggle": "启停技能",
4098
4159
  "source.external.note": "只读接入,启停不改写源文件", "source.dsh.note": "可创建、导入、移到回收站;默认来源必须读取,不能移除或停用",
4099
4160
  "detail.title": "技能详情", "detail.body": "正文", "detail.frontmatter": "元数据", "detail.noFrontmatter": "该技能未提供元数据。", "detail.diagnostics": "诊断", "detail.path": "源文件", "detail.noIssues": "未发现诊断问题。",
4100
- "create.title": "创建技能", "create.target": "创建位置", "create.name": "名称", "create.name.placeholder": "例如 code-review-helper", "create.description": "简介", "create.description.placeholder": "一句话说明什么时候使用", "create.body": "正文(Markdown)", "create.body.placeholder": "写下技能要遵循的指令、步骤和边界…", "create.chat.note": "对话里 skill_manager_save 建的是用户级技能",
4101
- "import.title": "导入技能", "upload.drop.title": "点击或拖入此处", "upload.drop.copy": "支持 .zip、技能文件夹或单个 SKILL.md", "upload.req.mdzip": "支持 .md / .zip,同名自动跳过", "upload.req.skills": "zip 或文件夹,需含 SKILL.md", "upload.req.prompt": "支持 .md,同名自动跳过", "upload.req.session": "一次一个:.jsonl / .json / .md / .txt", "upload.selected.one": "{count} 个文件 · {size}", "upload.selected.other": "{count} 个文件 · {size}", "upload.remove": "移除所选内容", "upload.requirements": "文件要求", "upload.requirement.skill": "压缩包或文件夹需包含 SKILL.md", "upload.requirement.frontmatter": "SKILL.md 需包含 YAML 格式的技能名称和描述", "upload.requirement.copy": "导入时复制完整内容,不修改原始来源", "upload.requirement.persona.1": "支持 .md / .zip(可多选、可拖入)", "upload.requirement.persona.2": "一个 .md = 一个人设,文件名即人设名", "upload.requirement.persona.3": "同名自动跳过,绝不覆盖原有文件", "upload.requirement.memory.1": "支持 .md / .zip(可多选、可拖入)", "upload.requirement.memory.2": "一个 .md = 一条记忆,文件名即记忆名", "upload.requirement.memory.3": "带 SKILL.md 的目录 = bundle 记忆(附件一起进来)", "upload.requirement.memory.4": "zip 里的目录名即场景;没有目录时用下面「导入到场景」的值(留空 = 全局)", "upload.requirement.prompt.1": "支持 .md(可多选、可拖入)", "upload.requirement.prompt.2": "一个 .md = 一份预设,文件名即 id(目录名)", "upload.requirement.prompt.3": "同名已存在则跳过,绝不覆盖", "upload.requirement.session.1": "支持 .jsonl / .json / .md / .txt(一次一个)", "upload.requirement.session.2": "Claude Code / Cursor / Codex 的转录文件,或任意文本", "upload.requirement.session.3": "工作区可留空 = 归入未分组;留空不影响导入", "upload.importing": "正在导入…", "status.selected": "已选择", "select.file.invalid": "请选择 .zip 或单个 SKILL.md", "select.folder.invalid": "该文件夹里没有 SKILL.md", "error.browse.absolute": "目录路径必须是绝对路径:{path}", "error.browse.unreadable": "无法读取目录:{path}", "error.browse.notDirectory": "不是目录:{path}",
4161
+ "create.title": "创建技能", "create.target": "创建位置", "create.name": "名称", "create.name.placeholder": "例如 code-review-helper", "create.description": "简介", "create.description.placeholder": "一句话说明什么时候使用", "create.body": "正文(Markdown)", "create.body.placeholder": "写下技能要遵循的指令、步骤和边界…",
4162
+ "skill.attach.label": "附件(可选)", "skill.attach.new": "新建附件", "skill.attach.pick": "选择附件", "skill.attach.newTitle": "新建附件", "skill.attach.path": "路径(相对技能目录)", "skill.attach.path.placeholder": "例如 references/api.md", "skill.attach.content": "内容", "skill.attach.content.placeholder": "把这个文件的内容写在这里(可留空)", "skill.attach.add": "加入", "skill.attach.edit": "编辑", "skill.attach.remove": "移除附件", "skill.attach.dup": "已有同名附件:{path}", "skill.attach.badpath": "路径不合法:用相对路径,不能以 / 开头或结尾,也不能含 ..", "skill.attach.reserved": "SKILL.md 是技能正文,不能作为附件加进来:{path}", "skill.attach.tooLarge": "{name} 超过 {limit} MB", "detail.attachments": "附件",
4163
+ "import.title": "导入技能", "upload.drop.title": "点击或拖入此处", "upload.drop.copy": "支持 .zip、技能文件夹或单个 SKILL.md", "upload.drop.mzip": "支持 .md 或 .zip", "upload.drop.prompt": "支持 .md", "upload.drop.session": "支持 .jsonl / .json / .md / .txt", "upload.req.mdzip": "支持 .md / .zip,同名自动跳过", "upload.req.skills": "zip 或文件夹,需含 SKILL.md", "upload.req.prompt": "支持 .md,同名自动跳过", "upload.req.session": "一次一个:.jsonl / .json / .md / .txt", "upload.selected.one": "{count} 个文件 · {size}", "upload.selected.other": "{count} 个文件 · {size}", "upload.remove": "移除所选内容", "upload.requirements": "文件要求", "upload.requirement.skill": "压缩包或文件夹需包含 SKILL.md", "upload.requirement.frontmatter": "SKILL.md 需包含 YAML 格式的技能名称和描述", "upload.requirement.copy": "导入时复制完整内容,不修改原始来源", "upload.requirement.persona.1": "支持 .md / .zip(可多选、可拖入)", "upload.requirement.persona.2": "一个 .md = 一个人设,文件名即人设名", "upload.requirement.persona.3": "同名自动跳过,绝不覆盖原有文件", "upload.requirement.memory.1": "支持 .md / .zip(可多选、可拖入)", "upload.requirement.memory.2": "一个 .md = 一条记忆,文件名即记忆名", "upload.requirement.memory.3": "带 SKILL.md 的目录 = bundle 记忆(附件一起进来)", "upload.requirement.memory.4": "zip 里的目录名即场景;没有目录时用下面「导入到场景」的值(留空 = 全局)", "upload.requirement.prompt.1": "支持 .md(可多选、可拖入)", "upload.requirement.prompt.2": "一个 .md = 一份预设,文件名即 id(目录名)", "upload.requirement.prompt.3": "同名已存在则跳过,绝不覆盖", "upload.requirement.session.1": "支持 .jsonl / .json / .md / .txt(一次一个)", "upload.requirement.session.2": "Claude Code / Cursor / Codex 的转录文件,或任意文本", "upload.requirement.session.3": "工作区可留空 = 归入未分组;留空不影响导入", "upload.importing": "正在导入…", "status.selected": "已选择", "select.file.invalid": "请选择 .zip 或单个 SKILL.md", "select.folder.invalid": "该文件夹里没有 SKILL.md", "error.browse.absolute": "目录路径必须是绝对路径:{path}", "error.browse.unreadable": "无法读取目录:{path}", "error.browse.notDirectory": "不是目录:{path}",
4102
4164
  // F-029:服务端错误码补词条(zh 逐字复现服务端文案,命中词条前后中文显示不变;EN 不再回退中文原文)。
4103
4165
  "error.custom.absolute": "目录路径必须是绝对路径: {path}", "error.custom.notDirectory": "不是目录: {path}", "error.custom.unreadable": "无法读取目录: {path}", "error.custom.overlap": "目录与已有技能来源重叠: {path} ↔ {other}", "error.custom.invalidKey": "非法的自定义来源 key: {key}", "error.custom.notFound": "自定义来源不存在: {key}",
4104
4166
  // 与宿主 http-fence.ts 的 TOKEN_MSG **逐字相同**:同一件事在哪个页面出现都必须说同一句话,
@@ -4174,16 +4236,29 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4174
4236
  // 已并进这一组;「移入回收站 / 永久删除」与「删除」后果不同,保留各自的键。
4175
4237
  "done.enabled": "已启用:{name}", "done.disabled": "已停用:{name}",
4176
4238
  "done.created": "已创建:{name}", "done.saved": "已保存:{name}",
4177
- "done.deleted": "已删除:{name}", "done.restored": "已恢复:{name}",
4239
+ "done.deleted": "已删除:{name}", "done.restored": "已恢复:{name}", "done.restoredAs": "已恢复为:{name}",
4240
+ // 同名确认弹窗(用户 2026-10-14):四个域(记忆 / 场景 / 技能 / 子智能体)× 新建 / 回收站恢复
4241
+ // 共八个入口共用。撞名不再静默退号,先问一句再动手,所以这几个词条必须跨域通用 ——
4242
+ // 名词由调用方从 dup.kind.* 取好传进来。
4243
+ "dup.title": "已存在同名{kind}", "dup.body": "{kind}「{name}」已存在。",
4244
+ "dup.create": "继续将新建为「{name}」。", "dup.restore": "继续将恢复为「{name}」。",
4245
+ "dup.confirm.create": "继续新建", "dup.confirm.restore": "继续恢复",
4246
+ "dup.kind.memory": "记忆", "dup.kind.scene": "场景", "dup.kind.skill": "技能", "dup.kind.subagent": "子智能体", "dup.kind.prompt": "提示词",
4178
4247
  "result.imported": "已导入:{names}", "result.importPartial": "已导入:{imported};已跳过同名技能:{skipped}", "result.importSkipped": "未导入任何技能;已跳过同名技能:{names}", "result.importEmpty": "未导入任何技能。", "result.importWarnings": "{result};警告:{warnings}", "result.trashed": "已移到回收站:{name}", "result.deleted": "已永久删除:{name}", "result.updated": "状态已更新。", "result.preferred": "已改为生效:{name}({source})", "result.unpreferred": "已取消同名首选:{name}", "result.opened": "已用系统默认程序打开:{path}", "error.action": "操作失败:{error}",
4179
- "warning.scan.truncated": "技能目录较大或嵌套过深,部分技能未显示:{path}", "warning.state.invalid": "状态文件不可读,已安全停用所有技能:{path}", "warning.backupUncleaned": "旧版本备份未清理:{path}({error})", "warning.project.unavailable": "活动会话的项目目录不可访问,项目技能未显示:{path}",
4248
+ "warning.scan.truncated": "技能目录较大或嵌套过深,部分技能未显示:{path}", "warning.state.invalid": "状态文件不可读,已安全停用所有技能:{path}", "warning.backupUncleaned": "旧版本备份未清理:{path}({error})", "warning.project.unavailable": "活动会话的项目目录不可访问,项目技能未显示:{path}", "warning.retitleFailed": "声明名未随退号更新,该条目可能仍显示旧名字:{path}({error})",
4180
4249
  "error.root.readonly": "该来源不允许{action}", "error.root.disabled": "「{name}」这个来源已整体停用,请先在该来源行启用它", "error.root.unknown": "未知技能来源:{root}", "error.root.unsafe": "项目技能目录不安全,拒绝写入:{path}", "error.skill.notFound": "技能不存在: {name}", "error.skill.noFrontmatter": "缺少 frontmatter,无法{action}:{name}", "error.skill.notLoadable": "技能结构不完整,无法{action}: {name}", "error.skill.notDeletable": "该来源的技能不能删除(技能只能停用):删除只在项目级来源(<项目>/.dsh/skills)提供",
4181
4250
  "error.source.notFound": "路径不存在: {path}", "error.source.symlink": "不支持符号链接来源:{path}", "error.source.unrecognized": "无法识别的 skill 来源: {path}", "error.source.tooDeep": "skill 来源目录层级超过 {depth} 层: {path}",
4182
- "error.import.overlap": "该目录与 DSH 技能目录重叠", "error.import.emptySource": "目录下没有 skill 条目:{path}", "error.import.invalidName": "名称不合法:{name}", "error.import.duplicateName": "批量来源中存在多个同名技能: {name}", "error.import.failed": "导入失败", "error.import.rollbackFailed": "覆盖导入回滚失败,备份保留在: {path}({error})",
4183
- "error.upload.path": "上传内容包含非法路径:{path}", "error.upload.encoding": "上传内容编码无效", "error.upload.empty": "上传内容为空", "error.upload.tooMany": "上传文件过多,最多 {limit} 个", "error.upload.tooLarge": "上传内容过大,限制为 {limit} 字节", "error.upload.archiveTooLarge": "ZIP 压缩包过大,限制为 {limit} 字节", "error.upload.duplicate": "上传内容包含重复路径:{path}", "error.upload.zipInvalid": "ZIP 压缩包无法解压",
4251
+ "error.import.overlap": "该目录与 DSH 技能目录重叠", "error.import.emptySource": "目录下没有 skill 条目:{path}", "error.import.invalidName": "名称不合法:{name}", "error.import.duplicateName": "批量来源中存在多个同名技能: {name}", "error.import.failed": "导入失败", "error.import.rollbackFailed": "覆盖导入回滚失败,备份保留在: {path}({error})", "error.import.renameExhausted": "同名条目太多,无法生成退号名称: {name}",
4252
+ "error.upload.path": "上传内容包含非法路径:{path}", "error.upload.encoding": "上传内容编码无效", "error.upload.empty": "上传内容为空", "error.upload.tooMany": "上传文件过多,最多 {limit} 个", "error.upload.tooLarge": "上传内容过大,限制为 {limit} 字节", "error.upload.archiveTooLarge": "ZIP 压缩包过大,限制为 {limit} 字节", "error.upload.duplicate": "上传内容包含重复路径:{path}", "error.upload.zipInvalid": "ZIP 压缩包无法解压", "error.upload.noSkillMd": "{name} 中未找到 SKILL.md:需放在根目录,或某个子目录下",
4184
4253
  "error.trash.notFound": "回收站条目不存在: {id}", "error.trash.conflict": "无法恢复,同名技能已存在: {name}", "error.trash.invalid": "回收站条目路径非法: {id}", "error.trash.projectUnavailable": "原项目当前不在活动工作区中,无法恢复:{path}", "error.trash.rollbackFailed": "移入回收站回滚失败,未恢复内容保留在: {path}({error})", "error.trash.retention.invalid": "保留期需为非负整数(0=永久保留),收到: {value}", "error.trash.retention.writeFailed": "写入保留期失败: {reason}",
4254
+ // 四个回收站**统一**的「撞名,没能恢复」错误码(用户 2026-10-14)。界面按这个码认出
4255
+ // "这是撞名",据此弹确认窗 —— 而不是靠比文案。原先四个域各用各的码(技能是
4256
+ // error.trash.conflict、记忆是 error.rules.exists、场景借用 error.rules.invalidGroup、
4257
+ // 人设压根没有码),界面根本分不出"撞名"和"名字非法"。弹窗里那几句更具体的话在 dup.*。
4258
+ "error.dup.conflict": "已存在同名条目,未能恢复:{name}",
4185
4259
  "error.state.invalid": "技能管理器状态文件不可读,已拒绝覆盖:{path}",
4186
4260
  "error.create.descriptionRequired": "技能简介不能为空", "error.create.bodyRequired": "技能正文不能为空", "error.create.tooLarge": "技能内容过长", "error.create.conflict": "同名技能已存在: {name}",
4261
+ "error.create.reservedPath": "附件不能占用 SKILL.md(正文由本次创建写入): {path}", "error.create.filesWithFlat": "单文件技能不能带附件:附件需要目录形态",
4187
4262
  "error.proto.forbidden": "禁止的修改请求(缺少客户端标记)", "error.proto.forbiddenHost": "禁止的请求来源(非法 Host)", "error.proto.contentType": "请求体必须是 application/json", "error.proto.method": "不支持的请求方法", "error.proto.unknownAction": "未知操作", "error.proto.bodyTooLarge": "请求体过大", "error.proto.invalidJson": "请求体不是合法 JSON", "error.proto.nonJson": "服务端返回非 JSON 响应(HTTP {status})",
4188
4263
  "diagnostic.frontmatter.missing": "缺少完整 YAML frontmatter", "diagnostic.name.missing": "frontmatter 缺少 name", "diagnostic.name.invalid": "技能名需 kebab-case:{name}", "diagnostic.description.missing": "frontmatter 缺少 description", "diagnostic.invocation.invalid": "调用策略字段值无效", "diagnostic.shadowed": "被更高优先级来源 {root} 覆盖",
4189
4264
  "action.enable": "启用", "action.disable": "停用", "action.create": "创建", "action.delete": "删除", "action.restore": "恢复", "action.toggle": "启用或停用",
@@ -4192,24 +4267,24 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4192
4267
  "mcp.stat.total": "服务", "mcp.stat.tools": "工具",
4193
4268
  "mcp.search": "搜索服务", "mcp.search.placeholder": "搜索服务名称、地址或命令",
4194
4269
  "mcp.btn.new": "新增", "mcp.btn.add": "添加", "mcp.btn.save": "保存", "mcp.btn.detail": "详情", "mcp.btn.edit": "编辑", "mcp.btn.remove": "删除",
4195
- "mcp.btn.enableAll": "全部启用", "mcp.btn.disableAll": "全部停用",
4270
+ "mcp.btn.enableAll": "全部启用", "mcp.btn.restart.title": "重连服务,最长约 11 秒(启用状态不变)", "mcp.btn.restart": "重启", "mcp.btn.disableAll": "全部停用",
4196
4271
  "mcp.loading": "正在加载 MCP 服务…", "mcp.empty": "暂无 MCP,点「新增」添加", "mcp.empty.search": "没有匹配的服务。",
4197
4272
  "mcp.servers.count": "{count} 个服务", "mcp.tools.count": "{count} 个工具", "mcp.tools.countPartial": "{enabled}/{total} 个工具", "mcp.duplicate": "重复 id",
4198
4273
  "mcp.table.name": "服务名称与地址",
4199
4274
  "mcp.live.notLoaded": "未加载", "mcp.live.failed": "启动失败", "mcp.live.stopped": "未启用", "mcp.live.loading": "加载中", "mcp.live.noTools": "无可用工具", "mcp.live.running": "已启用",
4200
- "mcp.live.failedHint": "启动失败,检查配置后把开关关掉再打开重试", "mcp.live.offlineHint": "未连上:一个工具都没注册,但上次连上时它有 {count} 个工具。检查网络、命令或凭据后把开关关掉再打开重试", "mcp.live.neverHint": "从未连上过:一个工具都没注册过。检查命令、参数或凭据后把开关关掉再打开重试", "mcp.live.allOffHint": "工具全部被停用:当前没有可调用的工具(取消停用即恢复)",
4275
+ "mcp.live.failedHint": "启动失败,检查配置后重新尝试", "mcp.live.offlineHint": "启动失败,检查配置后重新尝试", "mcp.live.neverHint": "启动失败,检查配置后重新尝试", "mcp.live.allOffHint": "工具全部被停用:当前没有可调用的工具(取消停用即恢复)",
4201
4276
  "mcp.note.prefix": "备注:", "mcp.toggleServer": "启停服务", "mcp.toggleTool": "启停工具",
4202
- "mcp.msg.ok": "操作成功", "mcp.msg.failed": "操作失败", "mcp.msg.loadFailed": "加载失败", "mcp.msg.warn": "操作完成,但加载器有提示:{warning}",
4277
+ "mcp.msg.ok": "操作成功", "mcp.msg.failed": "操作失败", "mcp.msg.loadFailed": "加载失败", "mcp.msg.warn": "操作完成,但加载器有提示:{warning}", "mcp.msg.toolsRefreshed": "已取到 {count} 个工具", "mcp.msg.restartWarn": "已重启,但有告警:{warning}", "mcp.msg.restarted": "已重启:{name}",
4203
4278
  "mcp.form.addTitle": "新增 MCP", "mcp.form.editTitle": "编辑 MCP 服务:",
4204
4279
  "mcp.form.err.serverName": "服务名需为 1-32 位字母、数字、下划线或短横线(不能有空格与中文)", "mcp.form.err.url": "地址需以 http:// 或 https:// 开头", "mcp.form.err.command": "命令不能为空",
4205
4280
  "mcp.form.maskedHint": "密钥框里的打码值(如 ••••••)不是原文:直接保存不会改动它们,插件会保留补丁文件里的原值。要看原文请点「显示密钥」(本插件配了访问令牌的话,先填对令牌)。",
4206
- "mcp.field.serverName": "服务名称 serverName", "mcp.field.serverName.hint": "1-32 位 [A-Za-z0-9_-],补丁里按此名注册",
4281
+ "mcp.field.serverName": "服务名称", "mcp.field.serverName.hint": "1-32 位 [A-Za-z0-9_-],补丁里按此名注册",
4207
4282
  "mcp.field.note": "备注(可选)", "mcp.field.note.placeholder": "给模型看的决策提示,比如「这台要 sudo 免密」;留空不写",
4208
4283
  "mcp.field.transport": "传输方式", "mcp.field.level": "级别",
4209
4284
  "mcp.field.level.project": "应用级(仅当前应用:{path})", "mcp.field.level.global": "全局(跨应用:{path})",
4210
4285
  "mcp.field.url": "服务 URL", "mcp.field.url.hint": "需以 http(s):// 开头",
4211
4286
  "mcp.field.command": "启动命令", "mcp.field.args": "参数(空格或换行分隔)",
4212
- "mcp.field.env": "环境变量(每行 key=value)", "mcp.field.env.hint": "路径按系统路径写法填(Windows 用 \\,macOS/Linux 用 /)。",
4287
+ "mcp.field.env": "环境变量(每行 key=value)",
4213
4288
  "mcp.field.headers": "请求头(每行 key=value)",
4214
4289
  "mcp.detail.title": "服务详情:", "mcp.detail.config": "配置", "mcp.detail.showSecret": "显示密钥", "mcp.detail.hideSecret": "隐藏密钥",
4215
4290
  "mcp.btn.compact": "整理补丁", "mcp.btn.compact.title": "清掉补丁文件里已经不起作用的旧开关记录(每台服务的启用/停用状态不变)",
@@ -4217,23 +4292,22 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4217
4292
  "mcp.compact.confirm": "确认整理", "mcp.compact.done": "补丁文件已整理:清掉 {count} 条失效的旧开关记录,服务的启用/停用状态未变", "mcp.compact.clean": "补丁文件已经很干净,没有可清理的旧记录",
4218
4293
  "mcp.detail.entryId": "条目 ID", "mcp.detail.status": "运行状态", "mcp.detail.loader": "Loader 登记", "mcp.detail.loader.yes": "已登记", "mcp.detail.loader.no": "未登记",
4219
4294
  "mcp.tools.stale": "上次运行时", "mcp.tools.expand": "展开", "mcp.tools.collapse": "收起",
4220
- "mcp.detail.note": "备注(随上下文注入发给模型;压制型预设下不注入时,用 mcp_manager_list 仍能读到)", "mcp.detail.note.placeholder": "例如:A 不可用时改用 B 兜底", "mcp.detail.note.save": "保存备注",
4221
- "mcp.detail.tools": "工具({count})",
4295
+ "mcp.detail.note": "备注(随上下文注入发给模型)", "mcp.detail.note.placeholder": "例如:A 不可用时改用 B 兜底", "mcp.detail.note.save": "保存备注",
4296
+ "mcp.detail.tools": "工具({count})", "mcp.detail.toolsRefresh": "刷新工具",
4222
4297
  "mcp.field.headersShort": "请求头", "mcp.field.envShort": "环境变量",
4223
4298
  "mcp.tools.loading": "正在获取工具列表…", "mcp.tools.loadFailed": "加载工具失败:", "mcp.tools.none": "该服务暂无已注册工具。",
4224
4299
  "mcp.tools.note": "停用的工具对模型不可见,改动即时生效",
4225
4300
  "mcp.remove.title": "删除 MCP 服务", "mcp.remove.desc": "确定要删除「{name}」?配置将从补丁文件移除、工具立即下线,不可撤销",
4226
4301
  "mcp.err.warnings": "读取补丁告警:",
4227
- "mcp.importJson.btn": "导入", "mcp.importJson.title": "从 JSON 导入 MCP 服务器",
4228
- "mcp.importJson.hint": "认 mcpServers 映射、rows 数组与裸数组三种写法;提交前核对名字与传输方式。",
4229
- "mcp.importJson.placeholder": "粘贴 {\"mcpServers\": {…}}、{\"rows\": […]} 或数组",
4302
+ "mcp.importJson.btn": "导入", "mcp.importJson.title": "导入 MCP 服务器(补丁片段 / JSON)",
4303
+ "mcp.importJson.hint": "认 dsh 补丁片段(- insert: 块)与 mcpServers / rows / 裸数组 JSON;提交前核对名字与传输方式。",
4230
4304
  "mcp.importJson.orPaste": "或粘贴在上面的输入框里",
4231
- "mcp.importJson.secretHint": "这批配置带环境变量 / 请求头:值按原样写入补丁文件,明文密钥会被一起带出去。打码串(••••)不会入库,插件用补丁里的旧真值顶替。",
4305
+ "mcp.importJson.secretHint": "含密钥 / 请求头的条目按明文写入补丁文件;打码串(••••)不入库,沿用补丁里的旧值。",
4232
4306
  "mcp.importJson.parseError": "解析失败:{error}",
4233
- "mcp.importJson.parseError.shape": "没认出可导入的结构:需要 {\"mcpServers\": {…}}、{\"rows\": […]} 或数组",
4234
- "mcp.importJson.empty": "没有条目:这份 JSON 里一条服务器都没有",
4307
+ "mcp.importJson.parsing": "补丁片段解析中…",
4308
+ "mcp.importJson.parseError.shape": "没认出可导入的结构:需要 dsh 补丁片段(- insert: 块),或 {\"mcpServers\": {…}}、{\"rows\": […]}、数组",
4309
+ "mcp.importJson.empty": "没有条目:这段内容里没有一条 MCP 服务器",
4235
4310
  "mcp.importJson.bigBatch": "这一批 {count} 条,建议分批导入(一次发整批,中途失败不好定位)",
4236
- "mcp.importJson.preview.name": "服务名与连接参数", "mcp.importJson.preview.state": "状态",
4237
4311
  "mcp.importJson.preview.secret": "{count} 项密钥/请求头",
4238
4312
  "mcp.importJson.disabled": "导入后停用",
4239
4313
  "mcp.importJson.original": "原名:{name}",
@@ -4246,7 +4320,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4246
4320
  "mcp.importJson.argsIgnored": "参数形状不认识,已忽略",
4247
4321
  "mcp.importJson.kvIgnored": "{key} 不是对象,已忽略",
4248
4322
  "mcp.importJson.ignoredFields": "已忽略未知字段:{keys}",
4249
- "mcp.importJson.conflict.skip": "跳过重名", "mcp.importJson.conflict.overwrite": "覆盖重名",
4323
+ "mcp.importJson.conflict.label": "重名时", "mcp.importJson.conflict.skip": "跳过重名", "mcp.importJson.conflict.overwrite": "覆盖重名",
4250
4324
  "mcp.importJson.chosen": "已选 {count} 条",
4251
4325
  "mcp.importJson.submit": "导入 {count} 条", "mcp.importJson.busy": "导入中…",
4252
4326
  "mcp.importJson.none": "没有可导入的条目(非法的那几条不能勾选)",
@@ -4378,7 +4452,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4378
4452
  // 点完"应用"才知道自己存的是一段会被原样注入的字。
4379
4453
  "prompts.kind.label": "提示词类型",
4380
4454
  "prompts.kind.global": "全局提示词", "prompts.kind.quick": "快捷提示词",
4381
- "prompts.kind.global.hint": "应用后写入 AGENTS.md,下一轮对话生效;可被场景绑定。",
4455
+ "prompts.kind.global.hint": "应用于全局 AGENTS.md,启动后下一轮对话注入",
4382
4456
  "prompts.kind.quick.hint": "对话框使用:将提示词输入对话框。",
4383
4457
  "prompts.group.global": "全局提示词", "prompts.group.quick": "快捷提示词", "prompts.bulk.quick": "把这一组里当前可见的快捷提示词全部打开 / 全部关闭", "prompts.bulk.failed": "{done} 条已改,{failed} 条没改成",
4384
4458
  "quickprompt.stat": "快捷提示词",
@@ -4400,7 +4474,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4400
4474
  "compat.ov.slashCommands.onTools": "只开「工具」段", "compat.ov.slashCommands.onQuick": "只开「快捷提示词」段",
4401
4475
  "compat.ov.slashCommands.off": "两段都已关闭(本页可重新打开)",
4402
4476
  "compat.slash.title": "斜杠命令入口(重启后生效)",
4403
- "compat.slash.tools": "「工具」", "compat.slash.tools.desc": "对话框使用\"/\"控制插件功能",
4477
+ "compat.slash.tools": "「智能调度台」", "compat.slash.tools.desc": "对话框使用\"/\"控制插件功能",
4404
4478
  "compat.slash.quick": "「快捷提示词」", "compat.slash.quick.desc": "一键把预设提示词输入对话框",
4405
4479
  "compat.slash.live.on": "已生效", "compat.slash.live.off": "未生效",
4406
4480
  "compat.slash.mount.registered": "本次启动已挂上", "compat.slash.mount.pending": "还没挂上(宿主服务未就绪)", "compat.slash.mount.unregistered": "本次启动已撤下",
@@ -4519,28 +4593,37 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4519
4593
  // —— 「或」读起来像"二选一",而这些工具是真能都做的(`save` 既是新建也是修改,
4520
4594
  // `switch` 既能开也能关)。0.14.x 之前写成「保存一条记忆」这类带量词的说法,读起来
4521
4595
  // 只像"新建",与 upsert 的实际行为对不上(用户 2026-09-23 指出)。
4596
+ // 2026-10-15(用户:统一模板格式、简洁简约):行内一律收成 `<动词> + <对象>` 两个词,
4597
+ // 不带任何补充从句 —— 动词按语义固定(列出 / 读 / 进入·退出 / 启用·停用 / 新建·修改 /
4598
+ // 删除·恢复 / 派 / 应用),对象词每域固定(场景 / 记忆 / 服务器 / 技能 / 人设 / 全局提示词)。
4599
+ // 详情("还列了什么、边界在哪")一律下放到悬停那一档,行内不再抢。
4522
4600
  // 悬停补"什么时候用、会改到什么、边界在哪"。键按工具名拼(`compat.tools.about.<工具名>`),
4523
4601
  // 工具改名 = 换身份,这两组键要跟着改(取不到时界面留空、不显示键名)。
4524
- "compat.tools.about.memory_manager_list": "列出记忆与启停",
4525
- "compat.tools.about.memory_manager_read": "按 id 读记忆正文",
4602
+ "compat.tools.about.scene_manager_list": "列出场景",
4603
+ "compat.tools.about.scene_manager_switch": "进入/退出场景",
4604
+ "compat.tools.about.scene_manager_save": "新建/修改场景",
4605
+ "compat.tools.about.scene_manager_remove": "删除/恢复场景",
4606
+ "compat.tools.about.memory_manager_list": "列出记忆",
4607
+ "compat.tools.about.memory_manager_read": "读记忆正文",
4526
4608
  "compat.tools.about.memory_manager_switch": "启用/停用记忆",
4527
4609
  "compat.tools.about.memory_manager_save": "新建/修改记忆",
4528
- "compat.tools.about.scene_manager_list": "列出场景、档案与启停",
4529
- "compat.tools.about.scene_manager_switch": "进入/退出场景",
4530
- "compat.tools.about.scene_manager_save": "新建/修改场景、写档案、绑提示词",
4531
- "compat.tools.about.mcp_manager_list": "列出服务器与工具",
4532
- "compat.tools.about.mcp_manager_switch": "启停/重启服务器",
4533
- "compat.tools.about.mcp_manager_save": "新增/修改服务器",
4534
- "compat.tools.about.skill_manager_list": "列出技能、启停与路径",
4535
- "compat.tools.about.skill_manager_read": "按名字读技能正文",
4536
- "compat.tools.about.skill_manager_switch": "启用/停用技能或技能目录",
4610
+ "compat.tools.about.memory_manager_remove": "删除/恢复记忆",
4611
+ "compat.tools.about.mcp_manager_list": "列出服务器",
4612
+ "compat.tools.about.mcp_manager_switch": "启用/停用服务器",
4613
+ "compat.tools.about.mcp_manager_save": "新建/修改服务器",
4614
+ "compat.tools.about.skill_manager_list": "列出技能",
4615
+ "compat.tools.about.skill_manager_read": "读技能正文",
4616
+ "compat.tools.about.skill_manager_switch": "启用/停用技能",
4537
4617
  "compat.tools.about.skill_manager_save": "新建/修改技能",
4538
- "compat.tools.about.subagent_manager_list": "列出人设与描述",
4539
- "compat.tools.about.subagent_manager_run": "按人设派子智能体",
4618
+ "compat.tools.about.skill_manager_remove": "删除/恢复技能",
4619
+ "compat.tools.about.subagent_manager_list": "列出人设",
4620
+ "compat.tools.about.subagent_manager_run": "派子智能体",
4540
4621
  "compat.tools.about.subagent_manager_switch": "启用/停用人设",
4541
4622
  "compat.tools.about.subagent_manager_save": "新建/修改人设",
4542
- "compat.tools.about.prompt_manager_list": "列出全局提示词预设",
4543
- "compat.tools.about.prompt_manager_apply": "应用一份全局提示词预设",
4623
+ "compat.tools.about.subagent_manager_remove": "删除/恢复人设",
4624
+ "compat.tools.about.prompt_manager_list": "列出全局提示词",
4625
+ "compat.tools.about.prompt_manager_apply": "应用全局提示词",
4626
+ "compat.tools.about.prompt_manager_remove": "删除/恢复全局提示词",
4544
4627
  "compat.tools.hover.memory_manager_list": "列出记忆的 id、所属场景、启停与一句话描述,并标明注入按哪个场景走。默认只列这次真的会注入的记忆;all=true 会把停用、场景未启用、被同名覆盖的一起列出来。场景本身(档案、绑定的预设、锁没锁)在 scene_manager_list。只看不改。",
4545
4628
  "compat.tools.hover.memory_manager_read": "按 id(<场景>/<名字>)读一条记忆的完整正文。清单里只有一句话描述,要看正文就用它 —— 尤其是被停用、场景没启用、或被体积预算挤掉的记忆。",
4546
4629
  "compat.tools.hover.memory_manager_switch": "只拨索引里的开关,.md 文件一个字不动,随时能拨回来。停用的记忆不再进上下文,等于把注入预算让给别人 —— 想省 token 优先用它,而不是删。",
@@ -4553,7 +4636,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4553
4636
  "compat.tools.hover.mcp_manager_save": "新增一台服务器(streamable-http 或 stdio),或改掉同名的那台 —— 回执会说清走了哪条。改的时候**没传的字段保持原样**,所以改一个 URL 不必重述整条配置(密钥也不会因此丢);stdio 的 command+args 就是宿主真正会 spawn 的命令,改它会弹确认卡。只在用户明确要求时用。",
4554
4637
  "compat.tools.hover.skill_manager_list": "列出技能的启停、生效还是被同名覆盖、源文件路径。常驻目录只带名字和摘要,所以「我怎么关了它」「同名哪份生效」要看这个;all=true 连停用与影子副本一起列。",
4555
4638
  "compat.tools.hover.skill_manager_read": "按名字读某个技能的 SKILL.md 正文(目录里只有名字和摘要)。同名多份时取真正生效的那一份;要特意看某个被覆盖的副本就传 root。会话里已有官方 skill 工具时这条不下发。",
4556
- "compat.tools.hover.skill_manager_switch": "按技能名或按来源目录启停。停用的技能不进目录也不能被加载;同名冲突时只有生效的那份有用 —— 启错了副本等于没启。",
4639
+ "compat.tools.hover.skill_manager_switch": "按技能名或按来源目录启停。停用的技能不进目录也不能被加载;同名冲突时只有生效的那份有用 —— 启错了副本等于没启。不给 root 就按名字在所有来源里找生效的那一份(新建技能落在 hub,不传 root 也能直接启停)。给了 prefer 就改「同名首选」:true 让 root 这一份胜出,false 撤掉这次选择(与面板上那颗「启用这个」同一条路,不用再去界面点)。",
4557
4640
  "compat.tools.hover.skill_manager_save": "新建一个技能,或改掉本插件自己写的那份同名技能 —— 回执会说清走了哪条。落点是 hub 的 tool-management/skills/(与界面「创建技能」一致),新建的默认停用、要用得先启用;官方根里的技能不改。技能目录会进每一轮上下文,描述要写清「什么时候用它」。",
4558
4641
  "compat.tools.hover.subagent_manager_list": "列出可用人设(子智能体的角色定义)与各自的描述。常驻目录给的是同一份清单;这个工具拿到的永远是当前完整版,包括目录没注入到的会话。",
4559
4642
  "compat.tools.hover.subagent_manager_run": "把任务交给某个具名人设:它拿到人设当自己的系统提示词、在自己的上下文里做完、只回最终结果。开 inherit 它会看到本次会话已完成的对话。花真 token,默认要用户确认。",
@@ -4561,6 +4644,13 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4561
4644
  "compat.tools.hover.subagent_manager_save": "新建人设,或改掉已存在的那份 —— 回执会说清走了哪条。可改名字、描述、正文(正文会作为子代理的系统提示词)与输出要求,没传的字段保持原样;改名会连带改场景与档案里的绑定。provider / 模型 / 工具限制只留给面板(模型看不到自己改对了没有,所以不给)。描述决定模型什么时候会选它。",
4562
4645
  "compat.tools.hover.prompt_manager_list": "列出全局提示词预设(写进 AGENTS.md 的那一类)与摘要,标明当前生效的是哪一份 —— 同时只有一份生效。要换用哪份之前先用它确认名字。快捷提示词不在这个域里:那是用户自己在输入框里点出来的一段话,不进上下文,模型这边没有可做的动作。",
4563
4646
  "compat.tools.hover.prompt_manager_apply": "把某份预设应用到用户级 AGENTS.md,之后的对话立刻按它走。一次只有一份在生效,换一份就是替换上一份。",
4647
+ // 删除族(2026-10-15)统一悬停模板:`把<对象>移进回收站、从回收站恢复,或列出回收站。
4648
+ // 删除可恢复;永久删除在「<页>」页的回收站里做。` —— 五条同形,只换对象词与页名。
4649
+ "compat.tools.hover.scene_manager_remove": "把场景移进回收站、从回收站恢复,或列出回收站。删除可恢复;永久删除在「场景」页的回收站里做。",
4650
+ "compat.tools.hover.memory_manager_remove": "把记忆移进回收站、从回收站恢复,或列出回收站。删除可恢复 —— 只想让它别再注入就用 switch 停用,不必删;永久删除在「记忆」页的回收站里做。",
4651
+ "compat.tools.hover.skill_manager_remove": "把技能移进回收站、从回收站恢复,或列出回收站。删除可恢复 —— 只想让模型别再看到它就用 switch 停用;trash 时传 root 指定技能所在来源(不给就按名字在所有来源里找,与 save 的落点无关);永久删除在「技能」页的回收站里做。",
4652
+ "compat.tools.hover.subagent_manager_remove": "把人设移进回收站、从回收站恢复,或列出回收站。删除可恢复 —— 只想让它别进目录就用 switch 停用;永久删除在「子智能体」页的回收站里做。",
4653
+ "compat.tools.hover.prompt_manager_remove": "把全局提示词预设移进回收站、从回收站恢复,或列出回收站。删除可恢复;永久删除在「提示词」页的回收站里做。",
4564
4654
  "compat.live.title": "注入实况", "compat.live.hint": "模型实际看到的内容(只读)",
4565
4655
  "compat.live.never": "这段对话还没投过", "compat.live.delivered": "本段对话已投递 {count} 条 · 最近 {time}",
4566
4656
  "compat.live.copy": "复制全文", "compat.live.copied": "已复制", "compat.live.noAgent": "还没有会话:发一条消息后再来看",
@@ -4616,6 +4706,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4616
4706
 
4617
4707
  "scenes.mem.title": "记忆", "scenes.mem.search": "在本场景内筛选记忆(名称或描述)",
4618
4708
  "scenes.mem.noMatch": "没有匹配项", "scenes.mem.emptyScene": "该场景还没有记忆",
4709
+ "scenes.mem.createNeedScene": "先在左边填好场景名", "scenes.mem.pending": "{count} 条待创建", "scenes.mem.pendingTag": "待创建", "scenes.mem.removePending": "移除",
4710
+ "scenes.mem.pendingTagTitle": "点「创建」时按那时的场景名落盘;勾上的,建好后直接开启",
4711
+ "scenes.mem.created": "已建 {count} 条记忆", "scenes.mem.createFailed": "{count} 条记忆没能创建:{items}", "scenes.mem.incomplete": "{count} 条记忆建好了但没配全:{items}",
4619
4712
  "memory.mode.current": "当前模式", "memory.mode.exit": "退出模式",
4620
4713
  "memory.archive.edit": "档案", "memory.archive.title": "场景档案",
4621
4714
  "memory.archive.tools": "MCP 工具集", "memory.archive.skills": "技能集", "memory.archive.subagents": "子智能体集", "memory.archive.memories": "记忆集", "memory.archive.applyFailed": "运行时未能应用:{reason}",
@@ -4634,7 +4727,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4634
4727
  "memory.budget.truncated": "超出预算,段尾已列出未注入的记忆",
4635
4728
  "memory.budget.dropped": "本次未注入:{names}",
4636
4729
  "memory.scene.enable": "启用", "memory.scene.enableAll": "全部启用", "memory.scene.off": "未启用", "memory.scene.shared": "常开", "memory.scene.global.tag": "常驻",
4637
- "memory.scene.global": "全局", "memory.scene.count": "{count} 条记忆", "memory.scene.new": "新建记忆", "memory.scene.browse": "选择已有",
4730
+ "memory.scene.global": "全局", "memory.scene.count": "{count} 条记忆", "memory.scene.new": "新建记忆",
4638
4731
  "memory.scene.empty": "该场景暂无记忆",
4639
4732
  "memory.scene.orphan": "未归属场景", "memory.scene.orphan.tag": "不会注入", "memory.scene.orphan.hint": "这些记忆直接放在 memories/ 根层,没有归属场景,因此不会被注入上下文。请把它们移入某个场景目录(或放到 memories/global/ 作为「全局」记忆)。",
4640
4733
  "memory.scene.edit": "改描述", "memory.scene.editTitle": "修改场景", "memory.scene.editHelp": "名称就是它的记忆目录名;改名会连目录、档案与绑定一起改,记忆正文不动。",
@@ -4649,13 +4742,12 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4649
4742
  "memory.form.flat": "flat", "memory.form.bundle": "bundle", "memory.derived": "派生", "memory.fill.frontmatter": "补齐 frontmatter",
4650
4743
  "memory.shadowed.hint": "同名 bundle 存在,本条不加载",
4651
4744
  "memory.create.title": "新建记忆", "memory.edit.title": "编辑记忆", "memory.edit.group.lock": "编辑时场景不可修改",
4652
- "memory.field.group": "场景", "memory.field.group.placeholder": "例如 办公", "memory.field.group.hint": "留空 = 全局",
4745
+ "memory.field.group": "场景",
4653
4746
  "memory.field.name": "名称", "memory.field.name.placeholder": "例如 站会流程", "memory.name.invalid": "名称不合法(非空、≤64 字符、不含 / \\ < > : \" | ? *、不以 . 开头)",
4654
4747
  "memory.field.description": "描述", "memory.field.description.placeholder": "一句话说明用途(留空取正文首行)",
4655
4748
  "memory.field.body": "正文(Markdown)", "memory.field.body.placeholder": "写下要记住的约定、流程和边界…",
4656
4749
  "memory.field.form": "形态(flat是单文件;bundle可携带附件)", "memory.field.attach": "附件",
4657
- "memory.attach.add": "添加附件", "memory.attach.empty": "暂无附件", "memory.attach.pending": "待上传", "memory.attach.remove": "移除附件",
4658
- "memory.attach.hint": "附件随记忆存放,不会进入提示词",
4750
+ "memory.attach.add": "添加附件", "memory.attach.pending": "待上传", "memory.attach.remove": "移除附件",
4659
4751
  "memory.attach.count.one": "{count} 个附件", "memory.attach.count.other": "{count} 个附件",
4660
4752
  "memory.attach.tip": "点击管理附件:{count} 个 · 共 {size} —— {names}",
4661
4753
  "memory.attach.tip.empty": "还没有附件——点击添加",
@@ -4687,14 +4779,14 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4687
4779
  // "不影响委派",半句不解释"那影响什么",留着比不留更费解。
4688
4780
  "subagents.field.model": "模型", "subagents.field.provider": "模型来源",
4689
4781
  "subagents.field.offCatalog": "{id} · 目录里没有",
4690
- "subagents.field.catalogDepth": "目录注入", "subagents.field.catalogDepth.onlyTop": "只在顶层", "subagents.field.catalogDepth.toChild": "顶层和子会话", "subagents.field.catalogDepth.toGrand": "顶层和两层子会话", "subagents.field.catalogDepth.unlimited": "不限制嵌套",
4782
+ "subagents.field.catalogDepth": "目录注入", "subagents.field.catalogDepth.d1": "1层", "subagents.field.catalogDepth.d2": "2层", "subagents.field.catalogDepth.d3": "3层", "subagents.field.catalogDepth.unlimited": "无限",
4691
4783
  // 允许追问(0.19.0)。界面只留复选框副标题那一行 —— 机制(官方 startContinuable / 结算通知 /
4692
4784
  // 返回指引)与代价(占用可继续子代理名额、需要持久化与 session query)留在 CHANGELOG 与
4693
4785
  // README(2026-10-07 用户裁定:高级选项里的提示句太密,长说明一律不进表单)。
4694
- "subagents.field.continuable": "允许追问(后台运行)", "subagents.field.continuable.short": "回执一个 id,之后还能继续对话",
4786
+ "subagents.field.continuable": "追问", "subagents.field.continuable.on": "允许", "subagents.field.continuable.off": "禁止",
4695
4787
  // 收起态摘要用的两个「尾巴」。分隔号写在值里(而不是在代码里 join ';'):英文侧要用
4696
4788
  // "; ",中文用「;」,写进词典才不用为语言改代码。空串 = 这一项没值,整段不出现。
4697
- "subagents.adv.continuable": ";允许追问(后台运行)",
4789
+ "subagents.adv.continuable": ";可追问",
4698
4790
  "subagents.adv.legacy": ";旧格式:白名单 {allow} / 黑名单 {deny}",
4699
4791
  // 思考强度(0.14.0)。界面上只留"这一格现在能选什么"与**状态**(未选模型 / 拉取中 / 拉不到),
4700
4792
  // 不解释机制 —— 官方语义(改 provider/model 会丢掉继承值、不支持的档位直接拒)记在
@@ -4735,13 +4827,12 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4735
4827
  "memory.import": "导入", "memory.import.title": "导入记忆", "memory.import.scene": "导入到场景",
4736
4828
  "memory.result.imported": "已导入 {count} 条记忆:{names}",
4737
4829
  "memory.import.scenesCreated": "为此新建了场景:{names}",
4738
- "memory.scene.global.hint": "全局",
4739
4830
  "import.clear": "清空选择", "import.submit": "导入",
4740
4831
  "import.none": "没有导入任何文件(全部被跳过)", "import.skipped": "已跳过:{items}",
4741
4832
  "error.import.noFiles": "没有选择要导入的文件",
4742
4833
  "nav.title": "工具",
4743
- "agm.stat.total": "全局提示词", "agm.stat.active": "生效中", "agm.loading": "加载中…", "agm.empty": "暂无提示词,点「新增」创建", "agm.empty.search": "没有匹配的提示词", "agm.search.placeholder": "搜索提示词 id 或描述", "agm.btn.delete.blocked.refs": "不能删除:{refs}。先解除引用再删 —— 场景绑定的去场景页换绑提示词、或退出场景;正被应用的那份先点左边那颗开关「取消应用」。", "agm.ref.sep": ";", "agm.ref.scene": "场景「{scene}」绑定了它", "agm.ref.sceneActive": "场景「{scene}」(已启用)绑定了它", "agm.ref.file": "AGENTS.md 当前内容就是它", "agm.ref.fileApplied": "它正被应用在 AGENTS.md 里(当前生效的那一份)", "agm.ref.restore": "退出场景后要恢复的全局提示词", "agm.sceneLock.notice": "场景「{scene}」接管中:当前生效的是它绑定的「{id}」。在这里应用别的预设 = 把该场景改绑到那一份(会同步写入场景档案)。", "agm.active": "生效中", "agm.active.hint.scene": "当前生效:场景「{scene}」绑定的提示词预设", "agm.active.hint.file": "当前生效:AGENTS.md 就是这一份", "agm.inactive": "未生效", "agm.file.applied": "文件里是它", "agm.file.applied.hint": "AGENTS.md 当前内容是它;启用的场景绑了别的提示词,切换场景时已按场景把文件改成那一份", "agm.apply.hint": "应用后写入 AGENTS.md;宿主每轮重读该文件,下一轮对话生效", "agm.apply.switch": "应用提示词", "agm.apply.switch.on": "当前生效的就是这一份 · 点一下重新写回 AGENTS.md(撤销手动改动)", "agm.applyModal.title": "应用预设", "agm.edit.title": "编辑预设", "agm.remove.title": "删除预设", "agm.btn.edit": "编辑", "agm.btn.delete": "删除", "agm.btn.new": "新增", "agm.new.title": "新增提示词", "agm.btn.import": "导入", "agm.result.imported": "已导入 {count} 份预设:{names}", "agm.btn.save": "保存", "agm.btn.create": "创建", "agm.btn.confirmRemove": "确认删除", "agm.btn.confirmApply": "确认应用", "agm.field.id": "id(目录名)", "agm.field.id.hint": "允许中文、空格、点;不能含斜杠/反斜杠与 : * ? < > 等符号;≤64 字符,改 id 即目录改名。", "agm.field.id.placeholder": "例如:工作基线", "agm.field.content": "内容", "agm.field.content.placeholder": "直接写这份预设的正文(Markdown);留空 = 用空白模板", "agm.field.desc": "描述(模型在清单里看得到)", "agm.field.desc.placeholder": "这份预设是干什么的、什么时候用它", "agm.result.renamed": "已保存,id 改为「{id}」", "agm.result.rebound": "(同步了 {count} 个场景的绑定)", "agm.remove.suffix": " ?此操作不可恢复。", "agm.apply.prefix": "将把 ", "agm.apply.suffix": " 的内容写入 ~/.dsh/AGENTS.md。", "agm.apply.note": "下一轮对话生效;当前内容已备份到 __last-applied__(保留最近 5 代)", "agm.unapply.title": "取消应用", "agm.unapply.body": "将把 ~/.dsh/AGENTS.md 恢复成开始应用提示词之前的那份内容(__last-applied__ 里保存的),此后不再有任何提示词预设生效。", "agm.unapply.note": "回到的是开始应用之前的那一份,中途应用过别的预设也一并撤销 —— 想换成其中某一份,直接点它自己的开关即可(效果等价)。这一步本身也会备份,可以再应用回来。", "agm.btn.confirmUnapply": "确认取消应用", "agm.unapply.switch.on": "AGENTS.md 里就是这一份 · 点一下取消应用(把 AGENTS.md 恢复成开始应用之前的内容)", "agm.unapply.switch.blocked": "AGENTS.md 里就是这一份,但没有「应用之前的状态」可恢复 —— 它不是由「应用」写进去的,所以关不掉。要改掉文件内容请应用别的预设或直接编辑 AGENTS.md;删除这份预设不会改动 AGENTS.md", "agm.unapply.switch.onRemove": "AGENTS.md 里就是这一份 · 点一下取消应用(开始应用之前没有 AGENTS.md,这一步会把它删掉)", "agm.unapply.bodyRemove": "开始应用提示词之前 ~/.dsh/AGENTS.md 并不存在,所以取消应用会把这个文件删掉(而不是写回内容),此后不再有任何提示词预设生效。", "agm.unapply.noteRemove": "删掉的是「应用」写进去的内容;这一份已备份到 __last-applied__(保留最近 5 代),需要时可以再应用回来。", "agm.btn.confirmUnapplyRemove": "确认取消应用并删除文件", "agm.result.unapplied": "已取消应用:AGENTS.md 已恢复成开始应用之前的内容", "agm.result.unappliedRemove": "已取消应用:AGENTS.md 已删除(开始应用之前它不存在)", "agm.unapply.unavailable": "AGENTS.md 的当前内容不是由「应用」写进去的(例如首次打开时自动生成的那份预设),没有「应用之前的状态」可恢复,所以关不掉。要改掉文件里的内容:应用别的预设,或直接编辑 AGENTS.md 本身;删除这份预设不会改动 AGENTS.md。", "agm.remove.fileNote": "注意:AGENTS.md 里仍然是这份内容,全局提示词照旧生效 —— 删除预设只把它移进回收站,不会改动 AGENTS.md。要让它不再生效,请改 AGENTS.md 本身或应用别的预设。", "hist.err.load": "加载失败", "hist.err.unarchive": "恢复失败", "hist.err.delete": "删除失败", "hist.err.retention": "设置失败", "hist.err.import": "导入失败", "hist.err.sessions": "加载会话列表失败", "hist.err.export": "导出失败", "hist.err.archive": "归档失败", "hist.retention.forever": "永久保留", "hist.retention.days": "{count} 天", "hist.arch.all": "全部会话", "hist.arch.archived": "仅已归档", "hist.arch.live": "仅未归档", "hist.arch.missing": "仅目录丢失", "hist.ws.all": "全部工作区", "hist.ungrouped": "未分组", "hist.group.removed": "已移除", "hist.group.removed.hint": "该目录当前在 DSH 里没有工作区登记;分组按会话目录重建,重新登记后会自动并回同一组", "hist.group.dirMissing": "原目录不存在", "hist.group.dirMissing.hint": "工作区目录已从磁盘删除,无法重新登记;组内会话仍可恢复(归入未分组)或永久删除", "hist.btn.registerWs": "重新登记", "hist.btn.registerWs.hint": "重新登记这个目录,并把该目录下已有的会话挂回该组(只写工作区记账,不移动、不删除文件与会话)", "hist.register.title": "重新登记?", "hist.register.body": "将为「{title}」在 DSH 中重新创建一条工作区登记,并把该目录下已有的会话挂回这个工作区(恢复其分组)。只写工作区记账,不移动、不删除任何文件与会话。", "hist.register.confirm": "确认登记", "hist.register.done.title": "已重新登记", "hist.register.done.body": "工作区「{title}」已登记;该目录下的归档会话会在刷新后并入这一组。", "hist.register.done.attached": "已把该目录下 {count} 个已有会话挂回这个工作区。", "hist.register.done.skipped": "{count} 个会话未能挂回:{reason}", "hist.err.register": "登记失败", "scenes.field.prompt": "全局提示词",
4744
- "scenes.field.prompt.none": "无", "scenes.agents.note.applied": "已把「{id}」写入 AGENTS.md", "scenes.agents.note.restored": "已恢复进场景前的 AGENTS.md", "scenes.agents.note.error": "AGENTS.md 未写入:{reason}", "scenes.agents.note.baselineLost": "⚠ 退出场景时将恢复不回原来的 AGENTS.md:{reason}", "scenes.prompt.activeTag": "生效中", "scenes.prompt.noPresets": "还没有提示词预设;可先去「提示词」页新建一个。", "scenes.prompt.bound": "提示词:{id}", "scenes.prompt.live": "提示词生效中:{id}", "scenes.prompt.missing": "预设不存在:{id}", "scenes.prompt.tag.hint": "这个场景绑定的提示词预设", "scenes.prompt.mismatch.hint": "场景绑定的「{id}」与 AGENTS.md 当前内容不一致(可能被手动改过);到提示词页对它点「重新应用」写回", "scenes.enable.blocked": "已启用场景「{name}」;先关掉它才能启用别的", "scenes.result.created.collapsed": "场景「{name}」已创建,默认不启动(原先的「全部启用」已收敛为单选)", "scenes.legacyAll": "检测到 {count} 个场景同时处于启用状态(历史「全部启用」遗留);除「全局」外同时只能启用一个。", "scenes.legacyAll.fix": "收敛为单选", "scenes.preview.title": "进入场景「{name}」前,先看一眼会改什么", "scenes.preview.loading": "正在计算这次会改什么…", "scenes.preview.failed": "预览没算出来:{error}", "scenes.preview.enter": "确认进入", "scenes.preview.dismiss": "不再显示", "scenes.preview.dismiss.title": "关闭这张卡,并且以后进入场景前不再弹(想恢复用页面右上角的「开启提醒」)", "scenes.preview.toggle.off": "关闭提醒", "scenes.preview.toggle.on": "开启提醒", "scenes.preview.toggle.title": "进入场景前先弹一张「会改什么」的卡", "scenes.preview.nochange": "什么都不用改:当前运行时已经和这个场景要的一致了。", "scenes.preview.noArchive": "这个场景还没保存过档案 —— MCP / 技能 / 人设三个域都按「一个都没勾」处理,进入即全部停用。", "scenes.preview.exitFirst": "先退出「{scene}」:它改过的东西按进场景前的快照还原。", "scenes.preview.exitNoSnapshot": "当前在场景「{scene}」里,但进场景前的快照已丢失:退出只能清掉模式标记,运行时回不到原样。", "scenes.preview.row.serversOn": "启用 {count} 台 MCP 服务器:{names}", "scenes.preview.row.serversOff": "停用 {count} 台 MCP 服务器:{names}", "scenes.preview.row.toolsOn": "放回 {count} 个 MCP 工具:{names}", "scenes.preview.row.toolsOff": "停用 {count} 个 MCP 工具:{names}", "scenes.preview.row.sourcesOn": "启用 {count} 个技能目录:{names}", "scenes.preview.row.sourcesOff": "停用 {count} 个技能目录:{names}", "scenes.preview.row.skillsOn": "启用 {count} 个技能:{names}", "scenes.preview.row.skillsOff": "停用 {count} 个技能:{names}", "scenes.preview.row.personasOn": "启用 {count} 个人设:{names}", "scenes.preview.row.personasOff": "停用 {count} 个人设:{names}", "scenes.preview.enter.servers": "启用 {count} 台 MCP:{names}", "scenes.preview.enter.memories": "启用 {count} 条记忆:{names}", "scenes.preview.enter.personas": "启用 {count} 个子智能体:{names}", "scenes.preview.enter.personasNone": "子智能体一个都不启用",
4834
+ "agm.stat.total": "全局提示词", "agm.stat.active": "生效中", "agm.loading": "加载中…", "agm.empty": "暂无提示词,点「新增」创建", "agm.empty.search": "没有匹配的提示词", "agm.search.placeholder": "搜索提示词 id 或描述", "agm.btn.delete.blocked.refs": "不能删除:{refs}。先解除引用再删 —— 场景绑定的去场景页换绑提示词、或退出场景;正被应用的那份先点左边那颗开关「取消应用」。", "agm.ref.sep": ";", "agm.ref.scene": "场景「{scene}」绑定了它", "agm.ref.sceneActive": "场景「{scene}」(已启用)绑定了它", "agm.ref.file": "AGENTS.md 当前内容就是它", "agm.ref.fileApplied": "它正被应用在 AGENTS.md 里(当前生效的那一份)", "agm.ref.restore": "退出场景后要恢复的全局提示词", "agm.sceneLock.notice": "场景「{scene}」接管中:当前生效的是它绑定的「{id}」。在这里应用别的预设 = 把该场景改绑到那一份(会同步写入场景档案)。", "agm.active": "生效中", "agm.active.hint.scene": "当前生效:场景「{scene}」绑定的提示词预设", "agm.active.hint.file": "当前生效:AGENTS.md 就是这一份", "agm.inactive": "未生效", "agm.file.applied": "文件里是它", "agm.file.applied.hint": "AGENTS.md 当前内容是它;启用的场景绑了别的提示词,切换场景时已按场景把文件改成那一份", "agm.apply.hint": "应用后写入 AGENTS.md;宿主每轮重读该文件,下一轮对话生效", "agm.apply.switch": "应用提示词", "agm.apply.switch.on": "当前生效的就是这一份 · 点一下重新写回 AGENTS.md(撤销手动改动)", "agm.applyModal.title": "应用预设", "agm.edit.title": "编辑预设", "agm.remove.title": "删除预设", "agm.btn.edit": "编辑", "agm.btn.delete": "删除", "agm.btn.new": "新增", "agm.new.title": "新增提示词", "agm.btn.import": "导入", "agm.result.imported": "已导入 {count} 份预设:{names}", "agm.btn.save": "保存", "agm.btn.create": "创建", "agm.btn.confirmRemove": "确认删除", "agm.btn.confirmApply": "确认应用", "agm.field.id": "显示名称", "agm.field.id.hint": "允许中文、空格、点;不能含斜杠/反斜杠与 : * ? < > 等符号;≤64 字符,改 id 即目录改名。", "agm.field.id.placeholder": "例如:工作基线", "agm.field.content": "内容", "agm.field.content.placeholder": "直接写这份预设的正文(Markdown)", "agm.field.desc": "描述(模型在清单里看得到)", "agm.field.desc.placeholder": "这份预设是干什么的、什么时候用它", "agm.result.renamed": "已保存,id 改为「{id}」", "agm.result.rebound": "(同步了 {count} 个场景的绑定)", "agm.remove.suffix": " ?此操作不可恢复。", "agm.apply.prefix": "将把 ", "agm.apply.suffix": " 的内容写入 ~/.dsh/AGENTS.md。", "agm.apply.note": "下一轮对话生效;当前内容已备份到 __last-applied__(保留最近 5 代)", "agm.unapply.title": "取消应用", "agm.unapply.body": "将把 ~/.dsh/AGENTS.md 恢复成开始应用提示词之前的那份内容(__last-applied__ 里保存的),此后不再有任何提示词预设生效。", "agm.unapply.note": "回到的是开始应用之前的那一份,中途应用过别的预设也一并撤销 —— 想换成其中某一份,直接点它自己的开关即可(效果等价)。这一步本身也会备份,可以再应用回来。", "agm.btn.confirmUnapply": "确认取消应用", "agm.unapply.switch.on": "AGENTS.md 里就是这一份 · 点一下取消应用(把 AGENTS.md 恢复成开始应用之前的内容)", "agm.unapply.switch.blocked": "AGENTS.md 里就是这一份,但没有「应用之前的状态」可恢复 —— 它不是由「应用」写进去的,所以关不掉。要改掉文件内容请应用别的预设或直接编辑 AGENTS.md;删除这份预设不会改动 AGENTS.md", "agm.unapply.switch.onRemove": "AGENTS.md 里就是这一份 · 点一下取消应用(开始应用之前没有 AGENTS.md,这一步会把它删掉)", "agm.unapply.bodyRemove": "开始应用提示词之前 ~/.dsh/AGENTS.md 并不存在,所以取消应用会把这个文件删掉(而不是写回内容),此后不再有任何提示词预设生效。", "agm.unapply.noteRemove": "删掉的是「应用」写进去的内容;这一份已备份到 __last-applied__(保留最近 5 代),需要时可以再应用回来。", "agm.btn.confirmUnapplyRemove": "确认取消应用并删除文件", "agm.result.unapplied": "已取消应用:AGENTS.md 已恢复成开始应用之前的内容", "agm.result.unappliedRemove": "已取消应用:AGENTS.md 已删除(开始应用之前它不存在)", "agm.unapply.unavailable": "AGENTS.md 的当前内容不是由「应用」写进去的(例如首次打开时自动生成的那份预设),没有「应用之前的状态」可恢复,所以关不掉。要改掉文件里的内容:应用别的预设,或直接编辑 AGENTS.md 本身;删除这份预设不会改动 AGENTS.md。", "agm.remove.fileNote": "注意:AGENTS.md 里仍然是这份内容,全局提示词照旧生效 —— 删除预设只把它移进回收站,不会改动 AGENTS.md。要让它不再生效,请改 AGENTS.md 本身或应用别的预设。", "hist.err.load": "加载失败", "hist.err.unarchive": "恢复失败", "hist.err.delete": "删除失败", "hist.err.retention": "设置失败", "hist.err.import": "导入失败", "hist.err.sessions": "加载会话列表失败", "hist.err.export": "导出失败", "hist.retention.forever": "永久保留", "hist.retention.days": "{count} 天", "hist.arch.all": "全部会话", "hist.arch.archived": "仅已归档", "hist.arch.live": "仅未归档", "hist.arch.missing": "仅目录丢失", "hist.ws.all": "全部工作区", "hist.ungrouped": "未分组", "hist.group.removed": "已移除", "hist.group.removed.hint": "该目录当前在 DSH 里没有工作区登记;分组按会话目录重建,重新登记后会自动并回同一组", "hist.group.dirMissing": "原目录不存在", "hist.group.dirMissing.hint": "工作区目录已从磁盘删除,无法重新登记;组内会话仍可恢复(归入未分组)或永久删除", "hist.btn.registerWs": "重新登记", "hist.btn.registerWs.hint": "重新登记这个目录,并把该目录下已有的会话挂回该组(只写工作区记账,不移动、不删除文件与会话)", "hist.register.title": "重新登记?", "hist.register.body": "将为「{title}」在 DSH 中重新创建一条工作区登记,并把该目录下已有的会话挂回这个工作区(恢复其分组)。只写工作区记账,不移动、不删除任何文件与会话。", "hist.register.confirm": "确认登记", "hist.register.done.title": "已重新登记", "hist.register.done.body": "工作区「{title}」已登记;该目录下的归档会话会在刷新后并入这一组。", "hist.register.done.attached": "已把该目录下 {count} 个已有会话挂回这个工作区。", "hist.register.done.skipped": "{count} 个会话未能挂回:{reason}", "hist.err.register": "登记失败", "scenes.field.prompt": "全局提示词",
4835
+ "scenes.field.prompt.none": "无", "scenes.agents.note.applied": "已把「{id}」写入 AGENTS.md", "scenes.agents.note.restored": "已恢复进场景前的 AGENTS.md", "scenes.agents.note.error": "AGENTS.md 未写入:{reason}", "scenes.agents.note.baselineLost": "⚠ 退出场景时将恢复不回原来的 AGENTS.md:{reason}", "scenes.prompt.activeTag": "生效中", "scenes.prompt.noPresets": "还没有提示词预设;可先去「提示词」页新建一个。", "scenes.prompt.bound": "提示词:{id}", "scenes.prompt.live": "提示词生效中:{id}", "scenes.prompt.missing": "预设不存在:{id}", "scenes.prompt.tag.hint": "这个场景绑定的提示词预设", "scenes.prompt.mismatch.hint": "场景绑定的「{id}」与 AGENTS.md 当前内容不一致(可能被手动改过);到提示词页对它点「重新应用」写回", "scenes.enable.blocked": "已启用场景「{name}」;先关掉它才能启用别的", "scenes.result.created.collapsed": "场景「{name}」已创建,默认不启动(原先的「全部启用」已收敛为单选)", "scenes.legacyAll": "检测到 {count} 个场景同时处于启用状态(历史「全部启用」遗留);除「全局」外同时只能启用一个。", "scenes.legacyAll.fix": "收敛为单选", "scenes.preview.title": "进入场景「{name}」前,先看一眼会改什么", "scenes.preview.loading": "正在计算这次会改什么…", "scenes.preview.failed": "预览没算出来:{error}", "scenes.preview.enter": "确认进入", "scenes.preview.dismiss": "不再显示", "scenes.preview.dismiss.title": "关闭这张卡,并且以后进入场景前不再弹(想恢复用页面右上角的「开启提醒」)", "scenes.preview.toggle.off": "关闭提醒", "scenes.preview.toggle.on": "开启提醒", "scenes.preview.toggle.title": "进入场景前先弹一张「会改什么」的卡", "scenes.preview.nochange": "什么都不用改:当前运行时已经和这个场景要的一致了。", "scenes.preview.noArchive": "这个场景还没保存过档案 —— MCP / 技能 / 人设三个域都按「一个都没勾」处理,进入即全部停用。", "scenes.preview.exitFirst": "先退出「{scene}」:它改过的东西按进场景前的快照还原。", "scenes.preview.exitNoSnapshot": "当前在场景「{scene}」里,但进场景前的快照已丢失:退出只能清掉模式标记,运行时回不到原样。", "scenes.preview.row.serversOn": "启用 {count} 台 MCP 服务器:{names}", "scenes.preview.row.serversOff": "停用 {count} 台 MCP 服务器:{names}", "scenes.preview.row.toolsOn": "放回 {count} 个 MCP 工具:{names}", "scenes.preview.row.toolsOff": "停用 {count} 个 MCP 工具:{names}", "scenes.preview.row.skillsOn": "启用 {count} 个技能:{names}", "scenes.preview.row.skillsOff": "停用 {count} 个技能:{names}", "scenes.preview.row.personasOn": "启用 {count} 个人设:{names}", "scenes.preview.row.personasOff": "停用 {count} 个人设:{names}", "scenes.preview.enter.servers": "启用 {count} 台 MCP:{names}", "scenes.preview.enter.memories": "启用 {count} 条记忆:{names}", "scenes.preview.enter.personas": "启用 {count} 个子智能体:{names}", "scenes.preview.enter.personasNone": "子智能体一个都不启用",
4745
4836
  // 绑定三行(预览卡第一段):一律 `标签:值`,"保持现状"必须带当前那份的名字。
4746
4837
  "scenes.preview.bind.prompt": "全局提示词:切到「{name}」",
4747
4838
  "scenes.preview.bind.promptSame": "全局提示词:不变(仍是「{name}」)",
@@ -4755,7 +4846,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4755
4846
  "scenes.preview.bind.tool": "模型工具表:切到「{name}」(关 {count} 条)",
4756
4847
  "scenes.preview.bind.toolMissing": "模型工具表:「{name}」已不存在,这次不切换",
4757
4848
  "scenes.preview.bind.toolKeep": "模型工具表:保持现状(本场景未绑定)", "scenes.preview.row.notes": "用场景备注覆盖 {count} 台服务器的备注", "scenes.field.toolTable": "模型工具表方案", "scenes.field.quickPrompts": "快捷提示词", "scenes.field.quickPrompts.current": "当前设置", "scenes.field.quickPrompts.customCount": "自定义({picked}/{total})", "scenes.field.quickPrompts.none": "还没有快捷提示词,先到「提示词」页新增",
4758
- "scenes.field.quickPrompts.noneOption": "无", "scenes.field.quickPrompts.list": "本场景启用", "scenes.field.quickPrompts.pick": "选择", "scenes.quickPick.title": "选择本场景启用的快捷提示词", "scenes.quickPick.hint": "勾选的进入本场景后启用,没勾的停用;退出场景时按进入前那批开关还原。", "scenes.quickPick.confirm": "确定", "scenes.form.section.basic": "基本信息", "scenes.form.section.binding": "场景绑定", "scenes.field.toolTable.none": "当前设置", "scenes.field.toolTable.factory": "出厂默认(关 {count} 条)", "scenes.field.toolTable.count": "关 {count} 条", "scenes.field.toolTable.missing": "方案已不存在", "scenes.preview.more": " 等", "scenes.preview.truncated": "(每一类最多列前 60 项)", "scenes.preview.stale": "档案里有 {count} 项在当前环境里已经不存在,这次不会生效:{items}", "scenes.doctor.title": "有 {count} 处引用指向已经不存在的名字", "scenes.doctor.noneButSkipped": "体检没发现悬空引用", "scenes.doctor.hint": "改名或删除留下的:这一条看着还绑着,运行时按那个名字已经找不到东西了。改回正确的名字,或到档案里取消勾选。", "scenes.doctor.row": "{where}:「{name}」已不存在", "scenes.doctor.more": "另有 {count} 处未列出", "scenes.doctor.skipped": "({domains} 这次没读到,这一类没有算进体检)", "agm.restore.tag": "退出场景后恢复它", "agm.restore.tag.hint": "它是进场景前的全局提示词,退出场景时会按快照里的正文恢复;快照存的是正文,删这份预设不影响恢复", "agm.file.mismatch": "文件里不是它", "agm.file.mismatch.hint": "AGENTS.md 当前内容与这份预设不一致(可能被手动改过);点左边那颗开关把它写回", "hist.restore.note.registered": "已恢复 {count} 个会话;工作区「{title}」已重新登记,会话已挂回该组。", "hist.restore.note.attached": "已恢复 {count} 个会话,并挂回工作区「{title}」。", "hist.restore.note.skip": "{count} 个会话的工作区归属未能恢复:{reason}", "hist.purge.title": "永久删除?", "hist.purge.one": "永久删除会话「{title}」及其全部记录,不可恢复", "hist.purge.batch": "永久删除所选 {count} 个会话及其全部记录,不可恢复", "hist.btn.restore": "恢复", "hist.btn.purge": "永久删除", "hist.btn.confirmPurge": "确认删除", "hist.selectGroup": "全选组 {title}", "hist.select": "选择此会话", "hist.group.count": "{count} 个", "hist.selectAll": "全选", "hist.deselectAll": "取消全选", "hist.btn.import": "导入", "hist.btn.export": "导出", "hist.selected": "已选 {count} 项", "hist.btn.restoreSelected": "恢复所选 ({count})", "hist.btn.deleteSelected": "删除所选 ({count})", "hist.stat.archived": "归档", "hist.stat.projects": "工作区", "hist.stat.retention": "保留期", "hist.search.placeholder": "搜索标题 / 会话 ID / 项目路径", "hist.loading": "加载中…", "hist.empty.search": "无匹配的归档会话", "hist.empty.none": "暂无归档会话", "hist.import.cwd": "工作区(可留空)", "hist.import.cwd.placeholder": "选择、浏览或直接输入绝对路径;留空 = 归入未分组", "hist.import.workspace": "选工作区", "hist.import.browse": "选择", "hist.import.workspace.none": "本机还没有已登记的工作区:可以直接输入绝对路径,或用「浏览」选一个文件夹。", "hist.import.drop": "选择或拖入对话文件", "hist.import.done": "已创建会话 {id}({count} 条消息),可在 DSH 会话列表中继续对话。", "hist.import.lossy": "⚠ 整份文本没有识别出任何说话人标记,已折成一条用户消息:原对话的分轮结构可能已丢失。若源文件是 JSONL 或 Markdown 转录稿,请按原扩展名重新导入。", "hist.export.hint": "导出选中会话为转录文件,可再次导入", "hist.export.dir": "导出目录(绝对路径,自动创建)", "hist.export.browse": "浏览并选择文件夹", "hist.btn.browse": "选择", "hist.export.pickDir": "选择导出目录", "hist.export.format": "格式", "hist.export.detail": "导出内容", "hist.export.detail.simple": "仅正文", "hist.export.detail.tools": "正文 + 工具", "hist.export.detail.full": "正文 + 工具 + 思考", "hist.export.scope": "会话范围", "hist.export.workspace": "工作区", "hist.export.sessions": "会话({count} 个)", "hist.export.loading": "加载会话列表…", "hist.cwd.missing": "⚠ 工作区目录已不存在:", "hist.tag.archived": "已归档", "hist.tag.live": "未归档", "hist.export.empty": "没有符合条件的会话", "hist.result.exported": "已导出 {count} 个会话到 {dir}", "hist.result.skipped": ";跳过 {count} 个({ids})", "hist.result.archived": "已归档 {count} 个会话到 History,可到 History 页继续管理", "hist.result.archiveFailed": ";其中 {count} 个未能归档", "hist.btn.archiveSelected": "归档所选", "hist.btn.archiveSelected.title": "把选中的会话收进 History,纳入保留期管理", "hist.export.busy": "处理中…", "hist.btn.exportSelected": "导出 {count} 个会话", "hist.error.title": "操作失败", "hist.error.unknown": "未知错误",
4849
+ "scenes.field.quickPrompts.noneOption": "无", "scenes.field.quickPrompts.list": "本场景启用", "scenes.field.quickPrompts.pick": "选择", "scenes.quickPick.title": "选择本场景启用的快捷提示词", "scenes.quickPick.hint": "勾选的进入本场景后启用,没勾的停用;退出场景时按进入前那批开关还原。", "scenes.quickPick.confirm": "确定", "scenes.form.section.basic": "基本信息", "scenes.form.section.binding": "场景绑定", "scenes.field.toolTable.none": "当前设置", "scenes.field.toolTable.factory": "出厂默认(关 {count} 条)", "scenes.field.toolTable.count": "关 {count} 条", "scenes.field.toolTable.missing": "方案已不存在", "scenes.preview.more": " 等", "scenes.preview.truncated": "(每一类最多列前 60 项)", "scenes.preview.stale": "档案里有 {count} 项在当前环境里已经不存在,这次不会生效:{items}", "scenes.doctor.title": "有 {count} 处引用指向已经不存在的名字", "scenes.doctor.noneButSkipped": "体检没发现悬空引用", "scenes.doctor.hint": "改名或删除留下的:这一条看着还绑着,运行时按那个名字已经找不到东西了。改回正确的名字,或到档案里取消勾选。", "scenes.doctor.row": "{where}:「{name}」已不存在", "scenes.doctor.more": "另有 {count} 处未列出", "scenes.doctor.skipped": "({domains} 这次没读到,这一类没有算进体检)", "agm.restore.tag": "退出场景后恢复它", "agm.restore.tag.hint": "它是进场景前的全局提示词,退出场景时会按快照里的正文恢复;快照存的是正文,删这份预设不影响恢复", "agm.file.mismatch": "文件里不是它", "agm.file.mismatch.hint": "AGENTS.md 当前内容与这份预设不一致(可能被手动改过);点左边那颗开关把它写回", "hist.restore.note.registered": "已恢复 {count} 个会话;工作区「{title}」已重新登记,会话已挂回该组。", "hist.restore.note.attached": "已恢复 {count} 个会话,并挂回工作区「{title}」。", "hist.restore.note.skip": "{count} 个会话的工作区归属未能恢复:{reason}", "hist.purge.title": "永久删除?", "hist.purge.one": "永久删除会话「{title}」及其全部记录,不可恢复", "hist.purge.batch": "永久删除所选 {count} 个会话及其全部记录,不可恢复", "hist.btn.restore": "恢复", "hist.btn.purge": "永久删除", "hist.btn.confirmPurge": "确认删除", "hist.selectGroup": "全选组 {title}", "hist.select": "选择此会话", "hist.group.count": "{count} 个", "hist.selectAll": "全选", "hist.deselectAll": "取消全选", "hist.btn.import": "导入", "hist.btn.export": "导出", "hist.btn.restoreSelected": "恢复所选 ({count})", "hist.btn.deleteSelected": "删除所选 ({count})", "hist.stat.archived": "归档", "hist.stat.projects": "工作区", "hist.stat.retention": "保留期", "hist.search.placeholder": "搜索标题 / 会话 ID / 项目路径", "hist.loading": "加载中…", "hist.empty.search": "无匹配的归档会话", "hist.empty.none": "暂无归档会话", "hist.import.cwd": "工作区(可留空)", "hist.import.cwd.placeholder": "从已登记工作区选,或直接输入绝对路径;留空 = 归入未分组", "hist.import.workspace": "选工作区", "hist.import.workspace.none": "本机还没有已登记的工作区:可以直接输入绝对路径。", "hist.import.done": "已创建会话 {id}({count} 条消息),可在 DSH 会话列表中继续对话。", "hist.import.lossy": "⚠ 整份文本没有识别出任何说话人标记,已折成一条用户消息:原对话的分轮结构可能已丢失。若源文件是 JSONL 或 Markdown 转录稿,请按原扩展名重新导入。", "hist.export.hint": "导出选中会话为转录文件,可再次导入", "hist.export.dir": "导出目录(绝对路径,自动创建)", "hist.export.browse": "浏览并选择文件夹", "hist.btn.browse": "选择", "hist.export.pickDir": "选择导出目录", "hist.export.format": "格式", "hist.export.detail": "导出内容", "hist.export.detail.simple": "仅正文", "hist.export.detail.tools": "正文 + 工具", "hist.export.detail.full": "正文 + 工具 + 思考", "hist.export.scope": "会话范围", "hist.export.workspace": "工作区", "hist.export.sessions": "会话({count} 个)", "hist.export.loading": "加载会话列表…", "hist.cwd.missing": "⚠ 工作区目录已不存在:", "hist.tag.archived": "已归档", "hist.tag.live": "未归档", "hist.export.empty": "没有符合条件的会话", "hist.result.exported": "已导出 {count} 个会话到 {dir}", "hist.result.skipped": ";跳过 {count} 个({ids})", "hist.export.busy": "处理中…", "hist.btn.exportSelected": "导出 {count} 个会话", "hist.error.title": "操作失败", "hist.error.unknown": "未知错误",
4759
4850
  },
4760
4851
  en: {
4761
4852
  "title": "Skills", "desc": "Manage skills: enable, import, create, trash.", "link.project": "GitHub", "link.feedback": "Issues",
@@ -4794,8 +4885,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4794
4885
  "empty.search": "No matching skills", "empty.source": "This source is missing or has no skills", "loading": "Loading skills…", "note.missing": "No description provided", "source.toggle": "Toggle source", "skill.toggle": "Toggle skill",
4795
4886
  "source.external.note": "Read-only; toggles never rewrite source files", "source.dsh.note": "Create, import, trash; a default source, always read — it cannot be removed or toggled off",
4796
4887
  "detail.title": "Skill details", "detail.body": "Body", "detail.frontmatter": "Metadata", "detail.noFrontmatter": "This skill provides no metadata.", "detail.diagnostics": "Diagnostics", "detail.path": "Source file", "detail.noIssues": "No diagnostic issues found.",
4797
- "create.title": "Create skill", "create.target": "Create in", "create.name": "Name", "create.name.placeholder": "e.g. code-review-helper", "create.description": "Description", "create.description.placeholder": "One sentence describing when to use it", "create.body": "Body (Markdown)", "create.body.placeholder": "Write the instructions, steps, and boundaries…", "create.chat.note": "skill_manager_save in chat creates user-level skills",
4798
- "import.title": "Import skill", "upload.drop.title": "Click or drop here", "upload.drop.copy": "Supported: .zip, a skill folder, or one SKILL.md", "upload.req.mdzip": "Supports .md / .zip; same-name files are skipped", "upload.req.skills": "A zip or folder containing SKILL.md", "upload.req.prompt": "Supports .md; same-name files are skipped", "upload.req.session": "One at a time: .jsonl / .json / .md / .txt", "upload.selected.one": "{count} file · {size}", "upload.selected.other": "{count} files · {size}", "upload.remove": "Remove selection", "upload.requirements": "File requirements", "upload.requirement.skill": "Archives and folders must contain SKILL.md", "upload.requirement.frontmatter": "SKILL.md must include a YAML name and description", "upload.requirement.copy": "Import copies all content and never modifies the source", "upload.requirement.persona.1": "Accepts .md / .zip (multi-select or drag in)", "upload.requirement.persona.2": "One .md = one persona; the file name is the persona name", "upload.requirement.persona.3": "Same names are skipped, never overwritten", "upload.requirement.memory.1": "Accepts .md / .zip (multi-select or drag in)", "upload.requirement.memory.2": "One .md = one memory; the file name is the memory name", "upload.requirement.memory.3": "A folder with SKILL.md becomes a bundle memory (attachments included)", "upload.requirement.memory.4": "Folder names inside the zip are scenes; otherwise the scene field below applies (blank = global)", "upload.requirement.prompt.1": "Accepts .md (multi-select or drag in)", "upload.requirement.prompt.2": "One .md = one preset; the file name is the id", "upload.requirement.prompt.3": "A preset with the same name is skipped, never overwritten", "upload.requirement.session.1": "Accepts .jsonl / .json / .md / .txt (one file at a time)", "upload.requirement.session.2": "Transcripts from Claude Code / Cursor / Codex, or any plain text", "upload.requirement.session.3": "The workspace may be left blank (the session goes to Ungrouped)", "upload.importing": "Importing…", "status.selected": "Selected", "select.file.invalid": "Choose a .zip archive or one SKILL.md.", "select.folder.invalid": "No SKILL.md was found in the selected folder.", "error.browse.absolute": "Folder path must be absolute: {path}", "error.browse.unreadable": "Could not read folder: {path}", "error.browse.notDirectory": "Not a folder: {path}",
4888
+ "create.title": "Create skill", "create.target": "Create in", "create.name": "Name", "create.name.placeholder": "e.g. code-review-helper", "create.description": "Description", "create.description.placeholder": "One sentence describing when to use it", "create.body": "Body (Markdown)", "create.body.placeholder": "Write the instructions, steps, and boundaries…",
4889
+ "skill.attach.label": "Attachments (optional)", "skill.attach.new": "New attachment", "skill.attach.pick": "Choose attachments", "skill.attach.newTitle": "New attachment", "skill.attach.path": "Path (relative to the skill folder)", "skill.attach.path.placeholder": "e.g. references/api.md", "skill.attach.content": "Content", "skill.attach.content.placeholder": "Write this file's content here (may stay empty)", "skill.attach.add": "Add", "skill.attach.edit": "Edit", "skill.attach.remove": "Remove attachment", "skill.attach.dup": "An attachment with this path is already listed: {path}", "skill.attach.badpath": "Invalid path: use a relative path without a leading or trailing / and no ..", "skill.attach.reserved": "SKILL.md is the skill body and cannot be added as an attachment: {path}", "skill.attach.tooLarge": "{name} exceeds {limit} MB", "detail.attachments": "Attachments",
4890
+ "import.title": "Import skill", "upload.drop.title": "Click or drop here", "upload.drop.copy": "Supported: .zip, a skill folder, or one SKILL.md", "upload.drop.mzip": "Supported: .md or .zip", "upload.drop.prompt": "Supported: .md", "upload.drop.session": "Supported: .jsonl / .json / .md / .txt", "upload.req.mdzip": "Supports .md / .zip; same-name files are skipped", "upload.req.skills": "A zip or folder containing SKILL.md", "upload.req.prompt": "Supports .md; same-name files are skipped", "upload.req.session": "One at a time: .jsonl / .json / .md / .txt", "upload.selected.one": "{count} file · {size}", "upload.selected.other": "{count} files · {size}", "upload.remove": "Remove selection", "upload.requirements": "File requirements", "upload.requirement.skill": "Archives and folders must contain SKILL.md", "upload.requirement.frontmatter": "SKILL.md must include a YAML name and description", "upload.requirement.copy": "Import copies all content and never modifies the source", "upload.requirement.persona.1": "Accepts .md / .zip (multi-select or drag in)", "upload.requirement.persona.2": "One .md = one persona; the file name is the persona name", "upload.requirement.persona.3": "Same names are skipped, never overwritten", "upload.requirement.memory.1": "Accepts .md / .zip (multi-select or drag in)", "upload.requirement.memory.2": "One .md = one memory; the file name is the memory name", "upload.requirement.memory.3": "A folder with SKILL.md becomes a bundle memory (attachments included)", "upload.requirement.memory.4": "Folder names inside the zip are scenes; otherwise the scene field below applies (blank = global)", "upload.requirement.prompt.1": "Accepts .md (multi-select or drag in)", "upload.requirement.prompt.2": "One .md = one preset; the file name is the id", "upload.requirement.prompt.3": "A preset with the same name is skipped, never overwritten", "upload.requirement.session.1": "Accepts .jsonl / .json / .md / .txt (one file at a time)", "upload.requirement.session.2": "Transcripts from Claude Code / Cursor / Codex, or any plain text", "upload.requirement.session.3": "The workspace may be left blank (the session goes to Ungrouped)", "upload.importing": "Importing…", "status.selected": "Selected", "select.file.invalid": "Choose a .zip archive or one SKILL.md.", "select.folder.invalid": "No SKILL.md was found in the selected folder.", "error.browse.absolute": "Folder path must be absolute: {path}", "error.browse.unreadable": "Could not read folder: {path}", "error.browse.notDirectory": "Not a folder: {path}",
4799
4891
  "error.custom.absolute": "The folder path must be absolute: {path}", "error.custom.notDirectory": "Not a folder: {path}", "error.custom.unreadable": "Could not read the folder: {path}", "error.custom.overlap": "The folder overlaps an existing skill source: {path} ↔ {other}", "error.custom.invalidKey": "Invalid custom source key: {key}", "error.custom.notFound": "Custom source not found: {key}",
4800
4892
  // 逐字对应宿主 http-fence.ts 的 TOKEN_MSG_*(见中文侧注释)。
4801
4893
  "error.token.required": "Missing access token, or the wrong one",
@@ -4858,16 +4950,32 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4858
4950
  "confirm.trash.title": "Move to trash?", "confirm.trash.desc": "“{name}” moves to trash and can be restored", "confirm.delete.title": "Delete forever?", "confirm.delete.desc": "“{name}” is deleted forever and cannot be recovered",
4859
4951
  "done.enabled": "Enabled: {name}", "done.disabled": "Disabled: {name}",
4860
4952
  "done.created": "Created: {name}", "done.saved": "Saved: {name}",
4861
- "done.deleted": "Deleted: {name}", "done.restored": "Restored: {name}",
4953
+ "done.deleted": "Deleted: {name}", "done.restored": "Restored: {name}", "done.restoredAs": "Restored as: {name}",
4954
+ // Duplicate-name confirmation (user, 2026-10-14): shared by four domains (memories / scenes /
4955
+ // skills / subagents) across create and trash-restore. A clash is no longer silently
4956
+ // renumbered — we ask first — so these strings must stay domain-neutral; the caller passes
4957
+ // the noun in from dup.kind.*.
4958
+ "dup.title": "A {kind} with this name already exists", "dup.body": "The {kind} “{name}” already exists.",
4959
+ "dup.create": "Continuing will create “{name}”.", "dup.restore": "Continuing will restore it as “{name}”.",
4960
+ "dup.confirm.create": "Create anyway", "dup.confirm.restore": "Restore anyway",
4961
+ "dup.kind.memory": "memory", "dup.kind.scene": "scene", "dup.kind.skill": "skill", "dup.kind.subagent": "subagent", "dup.kind.prompt": "prompt",
4862
4962
  "result.imported": "Imported: {names}", "result.importPartial": "Imported: {imported}; skipped existing skills: {skipped}", "result.importSkipped": "No skills were imported; existing skills were skipped: {names}", "result.importEmpty": "No skills were imported.", "result.importWarnings": "{result}; warnings: {warnings}", "result.trashed": "Moved to trash: {name}", "result.deleted": "Permanently deleted: {name}", "result.updated": "Status updated.", "result.preferred": "Now active: {name} ({source})", "result.unpreferred": "Cleared the manual choice: {name}", "error.action": "Action failed: {error}",
4863
- "warning.scan.truncated": "Some skills were not shown because the directory is too large or deeply nested: {path}", "warning.state.invalid": "The manager state file could not be read; all skills are disabled and state writes are blocked until it is repaired: {path}", "warning.backupUncleaned": "Old version backup was not cleaned up: {path} ({error})", "warning.project.unavailable": "The project directory of an active session is not accessible, so its project skills are hidden: {path}",
4963
+ "warning.scan.truncated": "Some skills were not shown because the directory is too large or deeply nested: {path}", "warning.state.invalid": "The manager state file could not be read; all skills are disabled and state writes are blocked until it is repaired: {path}", "warning.backupUncleaned": "Old version backup was not cleaned up: {path} ({error})", "warning.project.unavailable": "The project directory of an active session is not accessible, so its project skills are hidden: {path}", "warning.retitleFailed": "The declared name was not updated alongside the renamed entry, so it may still show the old name: {path} ({error})",
4864
4964
  "error.root.readonly": "This source does not allow {action}", "error.root.disabled": "The “{name}” source is disabled as a whole — enable that source first", "error.root.unknown": "Unknown skill source: {root}", "error.root.unsafe": "The project skill directory is unsafe, so the write was refused: {path}", "error.skill.notFound": "Skill not found: {name}", "error.skill.noFrontmatter": "Skill lacks complete frontmatter, cannot {action}: {name}", "error.skill.notLoadable": "Skill structure is incomplete, cannot {action}: {name}", "error.skill.notDeletable": "Skills from this source cannot be deleted (disable them instead); deletion is available for project-level sources only (<project>/.dsh/skills)",
4865
4965
  "error.source.notFound": "Path does not exist: {path}", "error.source.symlink": "Symlinked sources are not supported: {path}", "error.source.unrecognized": "Unrecognized skill source: {path}", "error.source.tooDeep": "Skill source directory depth exceeds {depth} levels: {path}",
4866
- "error.import.overlap": "This folder overlaps the DSH skills folder", "error.import.emptySource": "No skill entries in this folder: {path}", "error.import.invalidName": "Invalid name: {name}", "error.import.duplicateName": "Batch source contains duplicate skill names: {name}", "error.import.failed": "Import failed", "error.import.rollbackFailed": "Overwrite import rollback failed; backups kept at: {path} ({error})",
4867
- "error.upload.path": "Upload contains an invalid path: {path}", "error.upload.encoding": "Upload encoding is invalid", "error.upload.empty": "Upload is empty", "error.upload.tooMany": "Too many uploaded files; maximum {limit}", "error.upload.tooLarge": "Upload is too large; limit {limit} bytes", "error.upload.archiveTooLarge": "ZIP archive is too large; limit {limit} bytes", "error.upload.duplicate": "Upload contains a duplicate path: {path}", "error.upload.zipInvalid": "ZIP archive could not be extracted",
4966
+ "error.import.overlap": "This folder overlaps the DSH skills folder", "error.import.emptySource": "No skill entries in this folder: {path}", "error.import.invalidName": "Invalid name: {name}", "error.import.duplicateName": "Batch source contains duplicate skill names: {name}", "error.import.failed": "Import failed", "error.import.rollbackFailed": "Overwrite import rollback failed; backups kept at: {path} ({error})", "error.import.renameExhausted": "Too many entries share this name; no free suffixed name available: {name}",
4967
+ "error.upload.path": "Upload contains an invalid path: {path}", "error.upload.encoding": "Upload encoding is invalid", "error.upload.empty": "Upload is empty", "error.upload.tooMany": "Too many uploaded files; maximum {limit}", "error.upload.tooLarge": "Upload is too large; limit {limit} bytes", "error.upload.archiveTooLarge": "ZIP archive is too large; limit {limit} bytes", "error.upload.duplicate": "Upload contains a duplicate path: {path}", "error.upload.zipInvalid": "ZIP archive could not be extracted", "error.upload.noSkillMd": "No SKILL.md found in {name}: place it in the root, or in a subdirectory",
4868
4968
  "error.trash.notFound": "Trash item not found: {id}", "error.trash.conflict": "Cannot restore because a skill with the same name exists: {name}", "error.trash.invalid": "Invalid trash item path: {id}", "error.trash.projectUnavailable": "The original project is not an active workspace, so this skill cannot be restored: {path}", "error.trash.rollbackFailed": "Move-to-trash rollback failed; unrecovered content was kept at: {path} ({error})", "error.trash.retention.invalid": "Retention must be a non-negative integer (0 = keep forever); got: {value}", "error.trash.retention.writeFailed": "Could not save the retention setting: {reason}",
4969
+ // One shared "name already taken, nothing was restored" code for all four trash domains
4970
+ // (user, 2026-10-14). The UI keys off this code to recognise a name clash and raise the
4971
+ // confirm dialog — instead of string-matching. Previously each domain had its own code
4972
+ // (skills: error.trash.conflict, memories: error.rules.exists, scenes borrowed
4973
+ // error.rules.invalidGroup, subagents had none), so the UI could not tell a clash from an
4974
+ // invalid name. The more specific wording lives in dup.*.
4975
+ "error.dup.conflict": "An item with this name already exists, so nothing was restored: {name}",
4869
4976
  "error.state.invalid": "The manager state file could not be read, so overwriting it was refused: {path}",
4870
4977
  "error.create.descriptionRequired": "Skill description is required", "error.create.bodyRequired": "Skill body is required", "error.create.tooLarge": "Skill content is too large", "error.create.conflict": "A skill with the same name already exists: {name}",
4978
+ "error.create.reservedPath": "An attachment cannot occupy SKILL.md (this creation writes the body): {path}", "error.create.filesWithFlat": "A single-file skill cannot carry attachments — they need the folder form",
4871
4979
  "error.proto.forbidden": "Forbidden mutation request (missing client marker)", "error.proto.forbiddenHost": "Forbidden request origin (invalid host)", "error.proto.contentType": "Content type must be application/json", "error.proto.method": "Method not allowed", "error.proto.unknownAction": "Unknown action", "error.proto.bodyTooLarge": "Request body too large", "error.proto.invalidJson": "Invalid JSON request body", "error.proto.nonJson": "Server returned a non-JSON response (HTTP {status})",
4872
4980
  "diagnostic.frontmatter.missing": "Missing complete YAML frontmatter", "diagnostic.name.missing": "Frontmatter is missing name", "diagnostic.name.invalid": "Skill name must be kebab-case: {name}", "diagnostic.description.missing": "Frontmatter is missing description", "diagnostic.invocation.invalid": "Invocation policy value is invalid", "diagnostic.shadowed": "Shadowed by higher-priority source {root}",
4873
4981
  "action.enable": "enable", "action.disable": "disable", "action.create": "create", "action.delete": "delete", "action.restore": "restore", "action.toggle": "enabling or disabling",
@@ -4876,24 +4984,24 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4876
4984
  "mcp.stat.total": "Servers", "mcp.stat.tools": "Tools",
4877
4985
  "mcp.search": "Search servers", "mcp.search.placeholder": "Search a server name, URL or command",
4878
4986
  "mcp.btn.new": "New", "mcp.btn.add": "Add", "mcp.btn.save": "Save", "mcp.btn.detail": "Details", "mcp.btn.edit": "Edit", "mcp.btn.remove": "Delete",
4879
- "mcp.btn.enableAll": "Enable all", "mcp.btn.disableAll": "Disable all",
4987
+ "mcp.btn.enableAll": "Enable all", "mcp.btn.restart.title": "Reconnect this server (up to ~11s, enabled state unchanged)", "mcp.btn.restart": "Restart", "mcp.btn.disableAll": "Disable all",
4880
4988
  "mcp.loading": "Loading MCP servers…", "mcp.empty": "No MCP servers yet — click “New”.", "mcp.empty.search": "No matching server.",
4881
4989
  "mcp.servers.count": "{count} server(s)", "mcp.tools.count": "{count} tool(s)", "mcp.tools.countPartial": "{enabled}/{total} tool(s)", "mcp.duplicate": "duplicate id",
4882
4990
  "mcp.table.name": "Server name and URL",
4883
4991
  "mcp.live.notLoaded": "not loaded", "mcp.live.failed": "start failed", "mcp.live.stopped": "disabled", "mcp.live.loading": "loading", "mcp.live.noTools": "no usable tools", "mcp.live.running": "enabled",
4884
- "mcp.live.failedHint": "Start failed — check the configuration, then toggle the switch off and on to retry", "mcp.live.offlineHint": "Not connected: no tools are registered, but it had {count} tools when it last connected. Check the network, command or credentials, then toggle the switch off and on", "mcp.live.neverHint": "Never connected: no tools have ever been registered. Check the command, arguments or credentials, then toggle the switch off and on", "mcp.live.allOffHint": "All tools are disabled: nothing is callable right now (re-enable them to restore)",
4992
+ "mcp.live.failedHint": "Start failed — check the configuration, then try again", "mcp.live.offlineHint": "Start failed — check the configuration, then try again", "mcp.live.neverHint": "Start failed — check the configuration, then try again", "mcp.live.allOffHint": "All tools are disabled: nothing is callable right now (re-enable them to restore)",
4885
4993
  "mcp.note.prefix": "Note: ", "mcp.toggleServer": "Toggle server", "mcp.toggleTool": "Toggle tool",
4886
- "mcp.msg.ok": "Done", "mcp.msg.failed": "Operation failed", "mcp.msg.loadFailed": "Load failed", "mcp.msg.warn": "Done, but the loader reported: {warning}",
4994
+ "mcp.msg.ok": "Done", "mcp.msg.failed": "Operation failed", "mcp.msg.loadFailed": "Load failed", "mcp.msg.warn": "Done, but the loader reported: {warning}", "mcp.msg.toolsRefreshed": "Fetched {count} tool(s)", "mcp.msg.restartWarn": "Restarted with warnings: {warning}", "mcp.msg.restarted": "Restarted: {name}",
4887
4995
  "mcp.form.addTitle": "Add MCP server", "mcp.form.editTitle": "Edit MCP server: ",
4888
4996
  "mcp.form.err.serverName": "Server name must be 1-32 chars of letters, digits, underscore or hyphen (no spaces, no CJK)", "mcp.form.err.url": "URL must start with http:// or https://", "mcp.form.err.command": "Command cannot be empty",
4889
4997
  "mcp.form.maskedHint": "A masked value in a secret field (e.g. ••••••) is not the real value: saving leaves those entries untouched, because the plugin writes back the value found in the patch file. To read a secret in plain text, click Reveal secrets (if this plugin has an access token configured, enter the matching token first).",
4890
- "mcp.field.serverName": "Server name (serverName)", "mcp.field.serverName.hint": "1-32 chars of [A-Za-z0-9_-]; the patch registers it under this name",
4998
+ "mcp.field.serverName": "Server name", "mcp.field.serverName.hint": "1-32 chars of [A-Za-z0-9_-]; the patch registers it under this name",
4891
4999
  "mcp.field.note": "Note (optional)", "mcp.field.note.placeholder": "A hint for the model, e.g. \“this one needs passwordless sudo\”; empty means nothing is stored",
4892
5000
  "mcp.field.transport": "Transport", "mcp.field.level": "Level",
4893
5001
  "mcp.field.level.project": "Profile level (this app: {path})", "mcp.field.level.global": "Global (across profiles: {path})",
4894
5002
  "mcp.field.url": "Server URL", "mcp.field.url.hint": "Must start with http(s)://",
4895
5003
  "mcp.field.command": "Command", "mcp.field.args": "Arguments (space or newline separated)",
4896
- "mcp.field.env": "Environment (one key=value per line)", "mcp.field.env.hint": "Use the platform's own path syntax (backslashes on Windows, / on macOS/Linux).",
5004
+ "mcp.field.env": "Environment (one key=value per line)",
4897
5005
  "mcp.field.headers": "Headers (one key=value per line)",
4898
5006
  "mcp.detail.title": "Server details: ", "mcp.detail.config": "Configuration", "mcp.detail.showSecret": "Reveal secrets", "mcp.detail.hideSecret": "Hide secrets",
4899
5007
  "mcp.btn.compact": "Tidy patch", "mcp.btn.compact.title": "Clear the stale on/off records left in the patch file (each server's enabled/disabled state is unchanged)",
@@ -4901,23 +5009,22 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4901
5009
  "mcp.compact.confirm": "Tidy now", "mcp.compact.done": "Patch file tidied: cleared {count} stale on/off record(s); no server changed state", "mcp.compact.clean": "The patch file is already clean — nothing stale to clear",
4902
5010
  "mcp.detail.entryId": "Entry id", "mcp.detail.status": "Status", "mcp.detail.loader": "Loader registration", "mcp.detail.loader.yes": "Registered", "mcp.detail.loader.no": "Not registered",
4903
5011
  "mcp.tools.stale": "from last run", "mcp.tools.expand": "Show more", "mcp.tools.collapse": "Show less",
4904
- "mcp.detail.note": "Note (sent to the model with the injected runtime snapshot; when a suppressing preset keeps it out, mcp_manager_list still reads it back)", "mcp.detail.note.placeholder": "e.g. fall back to B when A is unavailable", "mcp.detail.note.save": "Save note",
4905
- "mcp.detail.tools": "Tools ({count})",
5012
+ "mcp.detail.note": "Note (sent to the model with the injected runtime snapshot)", "mcp.detail.note.placeholder": "e.g. fall back to B when A is unavailable", "mcp.detail.note.save": "Save note",
5013
+ "mcp.detail.tools": "Tools ({count})", "mcp.detail.toolsRefresh": "Refresh tools",
4906
5014
  "mcp.field.headersShort": "Headers", "mcp.field.envShort": "Environment",
4907
5015
  "mcp.tools.loading": "Fetching the tool list…", "mcp.tools.loadFailed": "Could not load tools: ", "mcp.tools.none": "This server has no registered tools.",
4908
5016
  "mcp.tools.note": "Disabled tools are invisible to the model; changes take effect immediately",
4909
5017
  "mcp.remove.title": "Delete MCP server", "mcp.remove.desc": "Delete “{name}”? Its configuration is removed from the patch file and the tools go offline at once; this cannot be undone",
4910
5018
  "mcp.err.warnings": "Patch warnings: ",
4911
- "mcp.importJson.btn": "Import", "mcp.importJson.title": "Import MCP servers from JSON",
4912
- "mcp.importJson.hint": "Reads a {\"mcpServers\": {…}} map, {\"rows\": […]} or a bare array; check names and transport before submitting.",
4913
- "mcp.importJson.placeholder": "Paste {\"mcpServers\": {…}}, {\"rows\": […]} or an array",
5019
+ "mcp.importJson.btn": "Import", "mcp.importJson.title": "Import MCP servers (patch snippet / JSON)",
5020
+ "mcp.importJson.hint": "Reads a dsh patch snippet (- insert:) or mcpServers / rows / bare-array JSON; check names and transport before submitting.",
4914
5021
  "mcp.importJson.orPaste": "— or just paste it in the box above",
4915
- "mcp.importJson.secretHint": "These entries carry env / headers values: they are written into the patch file as-is, plaintext credentials included. Masked strings (••••) never get stored — the plugin swaps in the real values already in the patch.",
5022
+ "mcp.importJson.secretHint": "Env vars and headers are written into the patch file verbatim; masked strings (••••) are not stored — the stored value is reused.",
4916
5023
  "mcp.importJson.parseError": "Parse failed: {error}",
4917
- "mcp.importJson.parseError.shape": "No importable structure found — needs an {\"mcpServers\": {…}} map, {\"rows\": […]} or an array",
4918
- "mcp.importJson.empty": "Nothing here: this JSON contains no servers",
5024
+ "mcp.importJson.parsing": "Parsing the patch snippet…",
5025
+ "mcp.importJson.parseError.shape": "No importable structure found — needs a dsh patch snippet (- insert:), an {\"mcpServers\": {…}} map, {\"rows\": […]} or an array",
5026
+ "mcp.importJson.empty": "Nothing here: no MCP servers in this input",
4919
5027
  "mcp.importJson.bigBatch": "{count} entries in this batch — import it in smaller chunks (one request for the whole batch is hard to diagnose if it fails halfway)",
4920
- "mcp.importJson.preview.name": "Name and connection details", "mcp.importJson.preview.state": "Status",
4921
5028
  "mcp.importJson.preview.secret": "{count} secret/header field(s)",
4922
5029
  "mcp.importJson.disabled": "Import disabled",
4923
5030
  "mcp.importJson.original": "Original name: {name}",
@@ -4930,7 +5037,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
4930
5037
  "mcp.importJson.argsIgnored": "Unrecognized args shape — dropped",
4931
5038
  "mcp.importJson.kvIgnored": "{key} is not an object — dropped",
4932
5039
  "mcp.importJson.ignoredFields": "Unknown fields ignored: {keys}",
4933
- "mcp.importJson.conflict.skip": "Skip duplicates", "mcp.importJson.conflict.overwrite": "Overwrite duplicates",
5040
+ "mcp.importJson.conflict.label": "Name conflicts", "mcp.importJson.conflict.skip": "Skip duplicates", "mcp.importJson.conflict.overwrite": "Overwrite duplicates",
4934
5041
  "mcp.importJson.chosen": "{count} selected",
4935
5042
  "mcp.importJson.submit": "Import {count}", "mcp.importJson.busy": "Importing…",
4936
5043
  "mcp.importJson.none": "Nothing importable (invalid entries cannot be checked)",
@@ -5061,7 +5168,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5061
5168
  // AGENTS.md and stays in context, the other is just text pasted into the composer.
5062
5169
  "prompts.kind.label": "Prompt type",
5063
5170
  "prompts.kind.global": "Global prompt", "prompts.kind.quick": "Quick prompt",
5064
- "prompts.kind.global.hint": "Applying writes it into AGENTS.md and it takes effect next turn; scenes can bind it.",
5171
+ "prompts.kind.global.hint": "Applies to the global AGENTS.md and gets injected on the next turn after startup",
5065
5172
  "prompts.kind.quick.hint": "For use in the composer: inserts the prompt into the input box.",
5066
5173
  "prompts.group.global": "Global prompts", "prompts.group.quick": "Quick prompts", "prompts.bulk.quick": "Turn every visible quick prompt in this group on / off", "prompts.bulk.failed": "{done} changed, {failed} did not",
5067
5174
  "quickprompt.stat": "Quick prompts",
@@ -5083,7 +5190,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5083
5190
  "compat.ov.slashCommands.onTools": "Only the “Tools” section is on", "compat.ov.slashCommands.onQuick": "Only the “Quick prompts” section is on",
5084
5191
  "compat.ov.slashCommands.off": "Both sections turned off (re-enable them on this page)",
5085
5192
  "compat.slash.title": "Slash command entry (takes effect after restart)",
5086
- "compat.slash.tools": "“Tools”", "compat.slash.tools.desc": "Type \"/\" in the composer to control plugin features",
5193
+ "compat.slash.tools": "“Smart dispatch”", "compat.slash.tools.desc": "Type \"/\" in the composer to control plugin features",
5087
5194
  "compat.slash.quick": "“Quick prompts”", "compat.slash.quick.desc": "Insert a preset prompt into the composer in one click",
5088
5195
  "compat.slash.live.on": "In effect", "compat.slash.live.off": "Not in effect",
5089
5196
  "compat.slash.mount.registered": "Mounted this startup", "compat.slash.mount.pending": "Not mounted yet (host service still starting)", "compat.slash.mount.unregistered": "Unmounted this startup",
@@ -5200,26 +5307,34 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5200
5307
  // what the tool can actually do — several behaviours are joined with "/", never "or"
5201
5308
  // (these tools really do both: `save` creates *and* updates, `switch` enables *and*
5202
5309
  // disables). Keys are built from the tool name.
5203
- "compat.tools.about.memory_manager_list": "List memories and their state",
5204
- "compat.tools.about.memory_manager_read": "Read a memory body by id",
5310
+ // 2026-10-15: the inline line is now a fixed `<verb> + <object>` template — verbs fixed by
5311
+ // meaning (list / read / enter·leave / enable·disable / create·update / delete·restore /
5312
+ // run / apply), object nouns fixed per domain. Detail lives in the hover tier only.
5313
+ "compat.tools.about.scene_manager_list": "List scenes",
5314
+ "compat.tools.about.scene_manager_switch": "Enter/leave a scene",
5315
+ "compat.tools.about.scene_manager_save": "Create/update a scene",
5316
+ "compat.tools.about.scene_manager_remove": "Delete/restore a scene",
5317
+ "compat.tools.about.memory_manager_list": "List memories",
5318
+ "compat.tools.about.memory_manager_read": "Read a memory body",
5205
5319
  "compat.tools.about.memory_manager_switch": "Enable/disable a memory",
5206
5320
  "compat.tools.about.memory_manager_save": "Create/update a memory",
5207
- "compat.tools.about.scene_manager_list": "List scenes and their archives",
5208
- "compat.tools.about.scene_manager_switch": "Enter/leave a scene",
5209
- "compat.tools.about.scene_manager_save": "Create/update a scene, write its archive, bind a prompt",
5210
- "compat.tools.about.mcp_manager_list": "List servers and tools",
5211
- "compat.tools.about.mcp_manager_switch": "Enable/disable/restart a server",
5212
- "compat.tools.about.mcp_manager_save": "Add/update a server",
5213
- "compat.tools.about.skill_manager_list": "List skills with state and path",
5214
- "compat.tools.about.skill_manager_read": "Read a skill body by name",
5215
- "compat.tools.about.skill_manager_switch": "Enable/disable a skill or source",
5321
+ "compat.tools.about.memory_manager_remove": "Delete/restore a memory",
5322
+ "compat.tools.about.mcp_manager_list": "List servers",
5323
+ "compat.tools.about.mcp_manager_switch": "Enable/disable a server",
5324
+ "compat.tools.about.mcp_manager_save": "Create/update a server",
5325
+ "compat.tools.about.skill_manager_list": "List skills",
5326
+ "compat.tools.about.skill_manager_read": "Read a skill body",
5327
+ "compat.tools.about.skill_manager_switch": "Enable/disable a skill",
5216
5328
  "compat.tools.about.skill_manager_save": "Create/update a skill",
5217
- "compat.tools.about.subagent_manager_list": "List personas and descriptions",
5329
+ "compat.tools.about.skill_manager_remove": "Delete/restore a skill",
5330
+ "compat.tools.about.subagent_manager_list": "List personas",
5218
5331
  "compat.tools.about.subagent_manager_run": "Run a persona as a subagent",
5219
5332
  "compat.tools.about.subagent_manager_switch": "Enable/disable a persona",
5220
5333
  "compat.tools.about.subagent_manager_save": "Create/update a persona",
5221
- "compat.tools.about.prompt_manager_list": "List global prompt presets",
5222
- "compat.tools.about.prompt_manager_apply": "Apply a global prompt preset",
5334
+ "compat.tools.about.subagent_manager_remove": "Delete/restore a persona",
5335
+ "compat.tools.about.prompt_manager_list": "List global prompts",
5336
+ "compat.tools.about.prompt_manager_apply": "Apply a global prompt",
5337
+ "compat.tools.about.prompt_manager_remove": "Delete/restore a global prompt",
5223
5338
  "compat.tools.hover.memory_manager_list": "Lists each memory's id, scene, on/off state and one-line description, and says which scene injection follows. Only the memories that would actually be injected by default; all=true adds disabled, inactive-scene and shadowed entries. The scenes themselves (archives, bound presets, locks) are in scene_manager_list. Read-only.",
5224
5339
  "compat.tools.hover.memory_manager_read": "Reads the full body of one memory by id (<scene>/<name>). The list carries a one-line description only — use this for the body, especially for memories that are off, in an inactive scene, or dropped by the byte budget.",
5225
5340
  "compat.tools.hover.memory_manager_switch": "Flips the index switch only — the .md file is untouched and it is reversible. A disabled memory stops being injected, which frees byte budget for the others; prefer this over deleting when you want to spend fewer tokens.",
@@ -5232,7 +5347,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5232
5347
  "compat.tools.hover.mcp_manager_save": "Adds a server (streamable-http or stdio) or updates the one with that name — the receipt says which. When updating, every field you omit keeps its current value, so changing one URL does not mean restating the whole config (and the secrets survive); for stdio, command+args is the command the host actually spawns, and changing it raises a confirmation card. Only on the user's explicit request.",
5233
5348
  "compat.tools.hover.skill_manager_list": "Lists each skill's on/off state, whether it wins or is shadowed, and its source path. The always-on catalog carries names and summaries only — use this to see what you turned off and which copy of a name wins; all=true includes disabled and shadowed copies.",
5234
5349
  "compat.tools.hover.skill_manager_read": "Reads one skill's SKILL.md body by name (the catalog has names and summaries only). Name collisions resolve to the copy actually in effect; pass root to inspect a shadowed copy instead. Not sent in conversations where the official skill tool is mounted.",
5235
- "compat.tools.hover.skill_manager_switch": "Toggles by skill name or by source directory. Disabled skills leave the catalog and cannot be loaded; when a name exists in several roots only the winning copy counts, so enabling the wrong one changes nothing.",
5350
+ "compat.tools.hover.skill_manager_switch": "Toggles by skill name or by source directory. Disabled skills leave the catalog and cannot be loaded; when a name exists in several roots only the winning copy counts, so enabling the wrong one changes nothing. Omit `root` and the name is looked up across every source (new skills land in hub, so switching one right after saving works without a root). Pass `prefer` to change the same-name winner: true makes the `root` copy win, false drops the recorded choice — same path as the panel's button, no trip to the UI.",
5236
5351
  "compat.tools.hover.skill_manager_save": "Creates a skill, or updates the copy this plugin wrote itself — the receipt says which. It lands in hub (tool-management/skills/), matching the panel's \"Create skill\"; a new skill starts disabled, so enable it before use, and skills living in the official roots are never touched. The catalog is injected into every request, so make the description say when to use it.",
5237
5352
  "compat.tools.hover.subagent_manager_list": "Lists the available personas (pre-configured subagent roles) with their descriptions. The always-on catalog carries the same list; this tool always returns the current, complete version — including conversations the catalog does not reach.",
5238
5353
  "compat.tools.hover.subagent_manager_run": "Hands a task to a named persona: it gets the persona as its own system prompt, works in its own context and returns only the final result. With inherit it also sees this conversation's finished turns. It spends real tokens and asks the user by default.",
@@ -5240,6 +5355,11 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5240
5355
  "compat.tools.hover.subagent_manager_save": "Creates a persona, or updates the existing one — the receipt says which. You can set the name, description, body (the body becomes the subagent's system prompt) and output contract; omitted fields keep their current values, and a rename makes scene and archive bindings follow. Provider, model and tool limits stay in the panel (the model cannot tell whether it got them right, so it is not offered them). The description decides when the model picks it.",
5241
5356
  "compat.tools.hover.prompt_manager_list": "Lists the global prompt presets (the ones written into AGENTS.md) with their summaries and marks the one currently in effect — only one is active at a time. Use it to confirm the name before switching. Quick prompts are not this domain: the user picks those in the composer, they never enter the context, and there is nothing for the model to do with them.",
5242
5357
  "compat.tools.hover.prompt_manager_apply": "Applies a preset to the user-level AGENTS.md; later conversations follow it immediately. Only one preset is in effect, so applying another replaces it.",
5358
+ "compat.tools.hover.scene_manager_remove": "Moves a scene to the trash, restores one from the trash, or lists the trash. Deleting is reversible; emptying the trash is a panel action.",
5359
+ "compat.tools.hover.memory_manager_remove": "Moves a memory to the trash, restores one from the trash, or lists the trash. Deleting is reversible — to just stop it being injected, disable it with switch instead. Emptying the trash is a panel action.",
5360
+ "compat.tools.hover.skill_manager_remove": "Moves a skill to the trash, restores one from the trash, or lists the trash. Deleting is reversible — to just stop the model seeing it, disable it with switch; pass root to pick the source on trash (omit it and the name is looked up across every source, independently of where save put it). Emptying the trash is a panel action.",
5361
+ "compat.tools.hover.subagent_manager_remove": "Moves a persona to the trash, restores one from the trash, or lists the trash. Deleting is reversible — to just take it out of the catalog, disable it with switch. Emptying the trash is a panel action.",
5362
+ "compat.tools.hover.prompt_manager_remove": "Moves a global prompt preset to the trash, restores one from the trash, or lists the trash. Deleting is reversible; emptying the trash is a panel action.",
5243
5363
  "compat.live.title": "Live injection", "compat.live.hint": "What the model actually sees (read-only)",
5244
5364
  "compat.live.never": "nothing delivered in this conversation yet", "compat.live.delivered": "{count} delivered in this conversation · last {time}",
5245
5365
  "compat.live.copy": "Copy all", "compat.live.copied": "Copied", "compat.live.noAgent": "No session yet — send a message first",
@@ -5296,6 +5416,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5296
5416
 
5297
5417
  "scenes.mem.title": "Memories", "scenes.mem.search": "Filter memories in this scene (name or description)",
5298
5418
  "scenes.mem.noMatch": "Nothing matches", "scenes.mem.emptyScene": "This scene has no memories yet",
5419
+ "scenes.mem.createNeedScene": "Fill in the scene name first", "scenes.mem.pending": "{count} pending", "scenes.mem.pendingTag": "Pending", "scenes.mem.removePending": "Remove",
5420
+ "scenes.mem.pendingTagTitle": "Written under whatever scene name is in the box when you click “Create”; the ones you check are enabled right after creation",
5421
+ "scenes.mem.created": "Created {count} memory item(s)", "scenes.mem.createFailed": "{count} memory item(s) could not be created: {items}", "scenes.mem.incomplete": "{count} memory item(s) were created but not fully set up: {items}",
5299
5422
  "memory.mode.current": "Active mode", "memory.mode.exit": "Exit mode",
5300
5423
  "memory.archive.edit": "Profile", "memory.archive.title": "Scene profile",
5301
5424
  "memory.archive.tools": "MCP tools", "memory.archive.skills": "Skills", "memory.archive.subagents": "Subagent set", "memory.archive.memories": "Memory set", "memory.archive.applyFailed": "Could not apply at runtime: {reason}",
@@ -5314,7 +5437,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5314
5437
  "memory.budget.truncated": "Over budget — the memories left out are listed at the end",
5315
5438
  "memory.budget.dropped": "Not injected this time: {names}",
5316
5439
  "memory.scene.enable": "Enabled", "memory.scene.enableAll": "Enable all", "memory.scene.off": "Off", "memory.scene.shared": "always on", "memory.scene.global.tag": "always injected",
5317
- "memory.scene.global": "Global", "memory.scene.count": "{count} memories", "memory.scene.new": "New memory", "memory.scene.browse": "Pick existing",
5440
+ "memory.scene.global": "Global", "memory.scene.count": "{count} memories", "memory.scene.new": "New memory",
5318
5441
  "memory.scene.empty": "No memories in this scene yet",
5319
5442
  "memory.scene.orphan": "No scene", "memory.scene.orphan.tag": "never injected", "memory.scene.orphan.hint": "These memories sit directly in the memories/ root and belong to no scene, so they are never injected into the context. Move them into a scene directory (or into memories/global/ to make them “Global”).",
5320
5443
  "memory.scene.edit": "Edit", "memory.scene.editTitle": "Edit scene", "memory.scene.editHelp": "The name is its memory folder name; renaming moves the folder, profile and bindings along. Memory files stay untouched.",
@@ -5329,13 +5452,12 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5329
5452
  "memory.form.flat": "flat", "memory.form.bundle": "bundle", "memory.derived": "derived", "memory.fill.frontmatter": "Fill frontmatter",
5330
5453
  "memory.shadowed.hint": "A bundle with the same name exists, so this one is not loaded",
5331
5454
  "memory.create.title": "New memory", "memory.edit.title": "Edit memory", "memory.edit.group.lock": "The scene cannot be changed while editing",
5332
- "memory.field.group": "Scene", "memory.field.group.placeholder": "e.g. office", "memory.field.group.hint": "Blank = global",
5455
+ "memory.field.group": "Scene",
5333
5456
  "memory.field.name": "Name", "memory.field.name.placeholder": "e.g. 站会流程", "memory.name.invalid": "Invalid name (non-empty, ≤64 chars, no / \\ < > : \" | ? *, must not start with a dot)",
5334
5457
  "memory.field.description": "Description", "memory.field.description.placeholder": "One line on what it is for (blank = first body line)",
5335
5458
  "memory.field.body": "Body (Markdown)", "memory.field.body.placeholder": "Write down the conventions, steps, and boundaries to remember…",
5336
5459
  "memory.field.form": "Form", "memory.field.attach": "Attachments",
5337
- "memory.attach.add": "Add files", "memory.attach.empty": "No attachments yet", "memory.attach.pending": "pending", "memory.attach.remove": "Remove attachment",
5338
- "memory.attach.hint": "Stored with the memory; never injected into the prompt",
5460
+ "memory.attach.add": "Add files", "memory.attach.pending": "pending", "memory.attach.remove": "Remove attachment",
5339
5461
  "memory.attach.count.one": "{count} attachment", "memory.attach.count.other": "{count} attachments",
5340
5462
  "memory.attach.tip": "Click to manage attachments: {count} · {size} total — {names}",
5341
5463
  "memory.attach.tip.empty": "No files yet — click to add",
@@ -5365,16 +5487,16 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5365
5487
  // without saying what it does affect, which is more confusing than saying nothing.
5366
5488
  "subagents.field.model": "Model", "subagents.field.provider": "Model provider",
5367
5489
  "subagents.field.offCatalog": "{id} · not in the catalogue",
5368
- "subagents.field.catalogDepth": "Catalog injection", "subagents.field.catalogDepth.onlyTop": "Top level only", "subagents.field.catalogDepth.toChild": "Top level and subagents", "subagents.field.catalogDepth.toGrand": "Top level and two levels down", "subagents.field.catalogDepth.unlimited": "No nesting limit",
5490
+ "subagents.field.catalogDepth": "Catalog injection", "subagents.field.catalogDepth.d1": "1 level", "subagents.field.catalogDepth.d2": "2 levels", "subagents.field.catalogDepth.d3": "3 levels", "subagents.field.catalogDepth.unlimited": "Unlimited",
5369
5491
  // Continuable (0.19.0). The form keeps only the checkbox subtitle; the mechanism (the
5370
5492
  // official startContinuable, the settlement notice, the return guidance) and the cost
5371
5493
  // (capacity slot, persistence + session query) live in CHANGELOG and README, not here
5372
5494
  // (user ruling 2026-10-07: the advanced section carried far too many help sentences).
5373
- "subagents.field.continuable": "Continuable (runs in the background)", "subagents.field.continuable.short": "Returns an id you can keep talking to",
5495
+ "subagents.field.continuable": "Continuable", "subagents.field.continuable.on": "Allow", "subagents.field.continuable.off": "Deny",
5374
5496
  // The two "tails" of the collapsed summary. The separator lives inside the value (not a
5375
5497
  // hard-coded join in code): English needs "; " where Chinese needs ";". Empty = no value,
5376
5498
  // so the whole clause disappears.
5377
- "subagents.adv.continuable": "; continuable (runs in the background)",
5499
+ "subagents.adv.continuable": "; continuable",
5378
5500
  "subagents.adv.legacy": "; legacy allow: {allow} / deny: {deny}",
5379
5501
  // Reasoning effort (0.14.0). The form keeps only what this field offers right now plus the
5380
5502
  // transient states (no model picked / loading / none available); the official semantics (the
@@ -5418,13 +5540,12 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5418
5540
  "memory.import": "Import", "memory.import.title": "Import memories", "memory.import.scene": "Import into scene",
5419
5541
  "memory.result.imported": "Imported {count} memory/memories: {names}",
5420
5542
  "memory.import.scenesCreated": "Scenes created for them: {names}",
5421
- "memory.scene.global.hint": "Global",
5422
5543
  "import.clear": "Clear", "import.submit": "Import",
5423
5544
  "import.none": "Nothing was imported (all skipped)", "import.skipped": "Skipped: {items}",
5424
5545
  "error.import.noFiles": "No files selected",
5425
5546
  "nav.title": "Tools",
5426
- "agm.stat.total": "Global prompts", "agm.stat.active": "In effect", "agm.loading": "Loading…", "agm.empty": "No prompts yet — click “New” to create one", "agm.empty.search": "No matching prompts", "agm.search.placeholder": "Search prompt ids or descriptions", "agm.btn.delete.blocked.refs": "Cannot delete: {refs}. Remove that reference first — for a scene binding, rebind the prompt on the scene page or leave the scene; for the one currently applied, flip the switch on the left to un-apply.", "agm.ref.sep": "; ", "agm.ref.scene": "bound by scene “{scene}”", "agm.ref.sceneActive": "bound by scene “{scene}” (enabled)", "agm.ref.file": "this is what AGENTS.md currently holds", "agm.ref.fileApplied": "it is applied in AGENTS.md (the one currently in effect)", "agm.ref.restore": "the global prompt to restore after leaving the scene", "agm.sceneLock.notice": "Scene “{scene}” is driving the baseline: the prompt in effect is its bound “{id}”. Applying another preset here rebinds that scene to it (written into the scene profile as well).", "agm.active": "Active", "agm.active.hint.scene": "Currently active: the prompt preset bound to scene “{scene}”", "agm.active.hint.file": "Currently active: AGENTS.md is exactly this preset", "agm.inactive": "Not applied", "agm.file.applied": "In AGENTS.md", "agm.file.applied.hint": "AGENTS.md currently holds this one; the enabled scene binds a different preset, which now takes effect", "agm.apply.hint": "Writes AGENTS.md; the host re-reads that file every turn, so it takes effect on the next turn", "agm.apply.switch": "Apply prompt", "agm.apply.switch.on": "This one is in effect · click to write AGENTS.md again (undoing hand edits)", "agm.applyModal.title": "Apply preset", "agm.edit.title": "Edit preset", "agm.remove.title": "Delete preset", "agm.btn.edit": "Edit", "agm.btn.delete": "Delete", "agm.btn.new": "New", "agm.new.title": "New prompt", "agm.btn.import": "Import", "agm.result.imported": "Imported {count} preset(s): {names}", "agm.btn.save": "Save", "agm.btn.create": "Create", "agm.btn.confirmRemove": "Delete", "agm.btn.confirmApply": "Apply", "agm.field.id": "id (directory name)", "agm.field.id.hint": "Letters, spaces and dots are fine; no slashes or : * ? < >; up to 64 chars. Changing it renames the directory.", "agm.field.id.placeholder": "e.g. work-baseline", "agm.field.content": "Content", "agm.field.content.placeholder": "Write the preset body (Markdown) here; leave blank to start from the empty template", "agm.field.desc": "Description (the model sees this in the list)", "agm.field.desc.placeholder": "What this preset is for and when to use it", "agm.result.renamed": "Saved; id is now “{id}”", "agm.result.rebound": " ({count} scene binding(s) updated)", "agm.remove.suffix": "? This cannot be undone.", "agm.apply.prefix": "Write the contents of ", "agm.apply.suffix": " into ~/.dsh/AGENTS.md.", "agm.apply.note": "Takes effect on the next turn; the current content is backed up to __last-applied__ (last 5 kept)", "agm.unapply.title": "Un-apply", "agm.unapply.body": "This restores ~/.dsh/AGENTS.md to the content from before you started applying presets (kept in __last-applied__); no preset takes effect afterwards.", "agm.unapply.note": "What comes back is the content from before you started applying: presets you applied in between are rolled back too — to switch to one of them instead, just flip its own switch (same result). This step is backed up too, so you can apply it again.", "agm.btn.confirmUnapply": "Un-apply", "agm.unapply.switch.on": "This one is in AGENTS.md · click to un-apply (restore AGENTS.md to the content from before you started applying)", "agm.unapply.switch.blocked": "This one is in AGENTS.md, but there is no “state from before the apply” to restore — it was not written by “Apply”, so it cannot be turned off. To change the file, apply another preset or edit AGENTS.md directly; deleting this preset does not touch AGENTS.md", "agm.unapply.switch.onRemove": "This one is in AGENTS.md · click to un-apply (there was no AGENTS.md before you started applying, so this deletes the file)", "agm.unapply.bodyRemove": "There was no ~/.dsh/AGENTS.md before you started applying presets, so un-applying deletes that file instead of restoring content; no preset takes effect afterwards.", "agm.unapply.noteRemove": "What gets deleted is the content “Apply” wrote; that content is backed up to __last-applied__ (last 5 kept), so you can apply it back if needed.", "agm.btn.confirmUnapplyRemove": "Un-apply and delete the file", "agm.result.unapplied": "Un-applied: AGENTS.md is back to the content from before you started applying", "agm.result.unappliedRemove": "Un-applied: AGENTS.md was deleted (it did not exist before you started applying)", "agm.unapply.unavailable": "The current AGENTS.md was not written by “Apply” (the preset created automatically the first time you open this page is one such case), so there is no “state from before the apply” to restore and it cannot be turned off. To change what AGENTS.md holds: apply another preset, or edit AGENTS.md itself; deleting this preset does not touch AGENTS.md.", "agm.remove.fileNote": "Note: AGENTS.md still holds this content and your global prompt stays in effect — deleting the preset only moves it to the trash and does not touch AGENTS.md. To stop it taking effect, edit AGENTS.md itself or apply another preset.", "hist.err.load": "Load failed", "hist.err.unarchive": "Restore failed", "hist.err.delete": "Delete failed", "hist.err.retention": "Update failed", "hist.err.import": "Import failed", "hist.err.sessions": "Failed to load sessions", "hist.err.export": "Export failed", "hist.err.archive": "Archive failed", "hist.retention.forever": "Keep forever", "hist.retention.days": "{count} days", "hist.arch.all": "All sessions", "hist.arch.archived": "Archived only", "hist.arch.live": "Not archived only", "hist.arch.missing": "Missing directory only", "hist.ws.all": "All workspaces", "hist.ungrouped": "Ungrouped", "hist.group.removed": "Removed", "hist.group.removed.hint": "This directory has no DSH workspace registration right now; the group is rebuilt from session directories and rejoins automatically once registered again", "hist.group.dirMissing": "Original folder missing", "hist.group.dirMissing.hint": "The workspace directory was deleted from disk, so it cannot be registered again; sessions in this group can still be restored (they join Ungrouped) or deleted forever", "hist.btn.registerWs": "Register", "hist.btn.registerWs.hint": "Register this directory again and attach its existing sessions back to that group (workspace accounting only — no file or session is moved or deleted)", "hist.register.title": "Register again?", "hist.register.body": "A workspace registration will be created in DSH for “{title}”, and the sessions under this directory will be attached back to it (restoring their grouping). This writes workspace accounting only — no file or session is moved or deleted.", "hist.register.confirm": "Register", "hist.register.done.title": "Registered", "hist.register.done.body": "Workspace “{title}” is registered; archived sessions under this directory join that group after a refresh.", "hist.register.done.attached": "Attached {count} existing session(s) under this directory back to the workspace.", "hist.register.done.skipped": "{count} session(s) could not be attached: {reason}", "hist.err.register": "Registration failed", "scenes.field.prompt": "Global prompt",
5427
- "scenes.field.prompt.none": "None", "scenes.agents.note.applied": "wrote “{id}” into AGENTS.md", "scenes.agents.note.restored": "restored the AGENTS.md baseline from before the scene", "scenes.agents.note.error": "AGENTS.md was not written: {reason}", "scenes.agents.note.baselineLost": "⚠ Leaving the scene will not restore the previous AGENTS.md: {reason}", "scenes.prompt.activeTag": "in effect", "scenes.prompt.noPresets": "No prompt presets yet — create one on the Prompts page.", "scenes.prompt.bound": "Prompt: {id}", "scenes.prompt.live": "Prompt active: {id}", "scenes.prompt.missing": "Preset missing: {id}", "scenes.prompt.tag.hint": "The prompt preset bound to this scene", "scenes.prompt.mismatch.hint": "The scene-bound “{id}” does not match the current contents of AGENTS.md (it may have been edited by hand); click “Apply again” for it on the Prompts page to write it back", "scenes.enable.blocked": "Scene “{name}” is already enabled; turn it off first", "scenes.result.created.collapsed": "Scene “{name}” created and left off (the legacy “all enabled” default collapsed to a single choice)", "scenes.legacyAll": "{count} scenes are enabled at once (a leftover of the legacy “all enabled” default); only one scene besides “Global” may be enabled.", "scenes.legacyAll.fix": "Collapse to one", "scenes.preview.title": "Before entering scene “{name}” — what this will change", "scenes.preview.loading": "Working out what changes…", "scenes.preview.failed": "Preview failed: {error}", "scenes.preview.enter": "Enter the scene", "scenes.preview.dismiss": "Don't show again", "scenes.preview.dismiss.title": "Close this card and stop showing it before entering scenes (bring it back with “Turn the reminder on” at the top of this page)", "scenes.preview.toggle.off": "Turn the reminder off", "scenes.preview.toggle.on": "Turn the reminder on", "scenes.preview.toggle.title": "Show a “what will change” card before entering a scene", "scenes.preview.nochange": "Nothing to change: the runtime already matches what this scene asks for.", "scenes.preview.noArchive": "This scene has no saved profile — MCP, skills and personas are all read as “none checked”, so entering disables every one of them.", "scenes.preview.exitFirst": "Leaves “{scene}” first: what it changed is restored from the before-entering snapshot.", "scenes.preview.exitNoSnapshot": "“{scene}” is active but its before-entering snapshot is gone: leaving only clears the mode flag, the runtime will not go back.", "scenes.preview.row.serversOn": "Enables {count} MCP server(s): {names}", "scenes.preview.row.serversOff": "Disables {count} MCP server(s): {names}", "scenes.preview.row.toolsOn": "Puts back {count} MCP tool(s): {names}", "scenes.preview.row.toolsOff": "Disables {count} MCP tool(s): {names}", "scenes.preview.row.sourcesOn": "Enables {count} skill director(ies): {names}", "scenes.preview.row.sourcesOff": "Disables {count} skill director(ies): {names}", "scenes.preview.row.skillsOn": "Enables {count} skill(s): {names}", "scenes.preview.row.skillsOff": "Disables {count} skill(s): {names}", "scenes.preview.row.personasOn": "Enables {count} persona(s): {names}", "scenes.preview.row.personasOff": "Disables {count} persona(s): {names}", "scenes.preview.enter.servers": "{count} MCP server(s) stay on: {names}", "scenes.preview.enter.memories": "{count} memory(ies) enabled: {names}", "scenes.preview.enter.personas": "{count} subagent(s) enabled: {names}", "scenes.preview.enter.personasNone": "No subagent stays enabled",
5547
+ "agm.stat.total": "Global prompts", "agm.stat.active": "In effect", "agm.loading": "Loading…", "agm.empty": "No prompts yet — click “New” to create one", "agm.empty.search": "No matching prompts", "agm.search.placeholder": "Search prompt ids or descriptions", "agm.btn.delete.blocked.refs": "Cannot delete: {refs}. Remove that reference first — for a scene binding, rebind the prompt on the scene page or leave the scene; for the one currently applied, flip the switch on the left to un-apply.", "agm.ref.sep": "; ", "agm.ref.scene": "bound by scene “{scene}”", "agm.ref.sceneActive": "bound by scene “{scene}” (enabled)", "agm.ref.file": "this is what AGENTS.md currently holds", "agm.ref.fileApplied": "it is applied in AGENTS.md (the one currently in effect)", "agm.ref.restore": "the global prompt to restore after leaving the scene", "agm.sceneLock.notice": "Scene “{scene}” is driving the baseline: the prompt in effect is its bound “{id}”. Applying another preset here rebinds that scene to it (written into the scene profile as well).", "agm.active": "Active", "agm.active.hint.scene": "Currently active: the prompt preset bound to scene “{scene}”", "agm.active.hint.file": "Currently active: AGENTS.md is exactly this preset", "agm.inactive": "Not applied", "agm.file.applied": "In AGENTS.md", "agm.file.applied.hint": "AGENTS.md currently holds this one; the enabled scene binds a different preset, which now takes effect", "agm.apply.hint": "Writes AGENTS.md; the host re-reads that file every turn, so it takes effect on the next turn", "agm.apply.switch": "Apply prompt", "agm.apply.switch.on": "This one is in effect · click to write AGENTS.md again (undoing hand edits)", "agm.applyModal.title": "Apply preset", "agm.edit.title": "Edit preset", "agm.remove.title": "Delete preset", "agm.btn.edit": "Edit", "agm.btn.delete": "Delete", "agm.btn.new": "New", "agm.new.title": "New prompt", "agm.btn.import": "Import", "agm.result.imported": "Imported {count} preset(s): {names}", "agm.btn.save": "Save", "agm.btn.create": "Create", "agm.btn.confirmRemove": "Delete", "agm.btn.confirmApply": "Apply", "agm.field.id": "Display name", "agm.field.id.hint": "Letters, spaces and dots are fine; no slashes or : * ? < >; up to 64 chars. Changing it renames the directory.", "agm.field.id.placeholder": "e.g. work-baseline", "agm.field.content": "Content", "agm.field.content.placeholder": "Write the preset body (Markdown) here", "agm.field.desc": "Description (the model sees this in the list)", "agm.field.desc.placeholder": "What this preset is for and when to use it", "agm.result.renamed": "Saved; id is now “{id}”", "agm.result.rebound": " ({count} scene binding(s) updated)", "agm.remove.suffix": "? This cannot be undone.", "agm.apply.prefix": "Write the contents of ", "agm.apply.suffix": " into ~/.dsh/AGENTS.md.", "agm.apply.note": "Takes effect on the next turn; the current content is backed up to __last-applied__ (last 5 kept)", "agm.unapply.title": "Un-apply", "agm.unapply.body": "This restores ~/.dsh/AGENTS.md to the content from before you started applying presets (kept in __last-applied__); no preset takes effect afterwards.", "agm.unapply.note": "What comes back is the content from before you started applying: presets you applied in between are rolled back too — to switch to one of them instead, just flip its own switch (same result). This step is backed up too, so you can apply it again.", "agm.btn.confirmUnapply": "Un-apply", "agm.unapply.switch.on": "This one is in AGENTS.md · click to un-apply (restore AGENTS.md to the content from before you started applying)", "agm.unapply.switch.blocked": "This one is in AGENTS.md, but there is no “state from before the apply” to restore — it was not written by “Apply”, so it cannot be turned off. To change the file, apply another preset or edit AGENTS.md directly; deleting this preset does not touch AGENTS.md", "agm.unapply.switch.onRemove": "This one is in AGENTS.md · click to un-apply (there was no AGENTS.md before you started applying, so this deletes the file)", "agm.unapply.bodyRemove": "There was no ~/.dsh/AGENTS.md before you started applying presets, so un-applying deletes that file instead of restoring content; no preset takes effect afterwards.", "agm.unapply.noteRemove": "What gets deleted is the content “Apply” wrote; that content is backed up to __last-applied__ (last 5 kept), so you can apply it back if needed.", "agm.btn.confirmUnapplyRemove": "Un-apply and delete the file", "agm.result.unapplied": "Un-applied: AGENTS.md is back to the content from before you started applying", "agm.result.unappliedRemove": "Un-applied: AGENTS.md was deleted (it did not exist before you started applying)", "agm.unapply.unavailable": "The current AGENTS.md was not written by “Apply” (the preset created automatically the first time you open this page is one such case), so there is no “state from before the apply” to restore and it cannot be turned off. To change what AGENTS.md holds: apply another preset, or edit AGENTS.md itself; deleting this preset does not touch AGENTS.md.", "agm.remove.fileNote": "Note: AGENTS.md still holds this content and your global prompt stays in effect — deleting the preset only moves it to the trash and does not touch AGENTS.md. To stop it taking effect, edit AGENTS.md itself or apply another preset.", "hist.err.load": "Load failed", "hist.err.unarchive": "Restore failed", "hist.err.delete": "Delete failed", "hist.err.retention": "Update failed", "hist.err.import": "Import failed", "hist.err.sessions": "Failed to load sessions", "hist.err.export": "Export failed", "hist.retention.forever": "Keep forever", "hist.retention.days": "{count} days", "hist.arch.all": "All sessions", "hist.arch.archived": "Archived only", "hist.arch.live": "Not archived only", "hist.arch.missing": "Missing directory only", "hist.ws.all": "All workspaces", "hist.ungrouped": "Ungrouped", "hist.group.removed": "Removed", "hist.group.removed.hint": "This directory has no DSH workspace registration right now; the group is rebuilt from session directories and rejoins automatically once registered again", "hist.group.dirMissing": "Original folder missing", "hist.group.dirMissing.hint": "The workspace directory was deleted from disk, so it cannot be registered again; sessions in this group can still be restored (they join Ungrouped) or deleted forever", "hist.btn.registerWs": "Register", "hist.btn.registerWs.hint": "Register this directory again and attach its existing sessions back to that group (workspace accounting only — no file or session is moved or deleted)", "hist.register.title": "Register again?", "hist.register.body": "A workspace registration will be created in DSH for “{title}”, and the sessions under this directory will be attached back to it (restoring their grouping). This writes workspace accounting only — no file or session is moved or deleted.", "hist.register.confirm": "Register", "hist.register.done.title": "Registered", "hist.register.done.body": "Workspace “{title}” is registered; archived sessions under this directory join that group after a refresh.", "hist.register.done.attached": "Attached {count} existing session(s) under this directory back to the workspace.", "hist.register.done.skipped": "{count} session(s) could not be attached: {reason}", "hist.err.register": "Registration failed", "scenes.field.prompt": "Global prompt",
5548
+ "scenes.field.prompt.none": "None", "scenes.agents.note.applied": "wrote “{id}” into AGENTS.md", "scenes.agents.note.restored": "restored the AGENTS.md baseline from before the scene", "scenes.agents.note.error": "AGENTS.md was not written: {reason}", "scenes.agents.note.baselineLost": "⚠ Leaving the scene will not restore the previous AGENTS.md: {reason}", "scenes.prompt.activeTag": "in effect", "scenes.prompt.noPresets": "No prompt presets yet — create one on the Prompts page.", "scenes.prompt.bound": "Prompt: {id}", "scenes.prompt.live": "Prompt active: {id}", "scenes.prompt.missing": "Preset missing: {id}", "scenes.prompt.tag.hint": "The prompt preset bound to this scene", "scenes.prompt.mismatch.hint": "The scene-bound “{id}” does not match the current contents of AGENTS.md (it may have been edited by hand); click “Apply again” for it on the Prompts page to write it back", "scenes.enable.blocked": "Scene “{name}” is already enabled; turn it off first", "scenes.result.created.collapsed": "Scene “{name}” created and left off (the legacy “all enabled” default collapsed to a single choice)", "scenes.legacyAll": "{count} scenes are enabled at once (a leftover of the legacy “all enabled” default); only one scene besides “Global” may be enabled.", "scenes.legacyAll.fix": "Collapse to one", "scenes.preview.title": "Before entering scene “{name}” — what this will change", "scenes.preview.loading": "Working out what changes…", "scenes.preview.failed": "Preview failed: {error}", "scenes.preview.enter": "Enter the scene", "scenes.preview.dismiss": "Don't show again", "scenes.preview.dismiss.title": "Close this card and stop showing it before entering scenes (bring it back with “Turn the reminder on” at the top of this page)", "scenes.preview.toggle.off": "Turn the reminder off", "scenes.preview.toggle.on": "Turn the reminder on", "scenes.preview.toggle.title": "Show a “what will change” card before entering a scene", "scenes.preview.nochange": "Nothing to change: the runtime already matches what this scene asks for.", "scenes.preview.noArchive": "This scene has no saved profile — MCP, skills and personas are all read as “none checked”, so entering disables every one of them.", "scenes.preview.exitFirst": "Leaves “{scene}” first: what it changed is restored from the before-entering snapshot.", "scenes.preview.exitNoSnapshot": "“{scene}” is active but its before-entering snapshot is gone: leaving only clears the mode flag, the runtime will not go back.", "scenes.preview.row.serversOn": "Enables {count} MCP server(s): {names}", "scenes.preview.row.serversOff": "Disables {count} MCP server(s): {names}", "scenes.preview.row.toolsOn": "Puts back {count} MCP tool(s): {names}", "scenes.preview.row.toolsOff": "Disables {count} MCP tool(s): {names}", "scenes.preview.row.skillsOn": "Enables {count} skill(s): {names}", "scenes.preview.row.skillsOff": "Disables {count} skill(s): {names}", "scenes.preview.row.personasOn": "Enables {count} persona(s): {names}", "scenes.preview.row.personasOff": "Disables {count} persona(s): {names}", "scenes.preview.enter.servers": "{count} MCP server(s) stay on: {names}", "scenes.preview.enter.memories": "{count} memory(ies) enabled: {names}", "scenes.preview.enter.personas": "{count} subagent(s) enabled: {names}", "scenes.preview.enter.personasNone": "No subagent stays enabled",
5428
5549
  "scenes.preview.bind.prompt": "Global prompt: switch to “{name}”",
5429
5550
  "scenes.preview.bind.promptSame": "Global prompt: unchanged (still “{name}”)",
5430
5551
  "scenes.preview.bind.promptKeep": "Global prompt: keeps “{name}” (this scene binds none)",
@@ -5437,7 +5558,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5437
5558
  "scenes.preview.bind.tool": "Tool table: switch to scheme “{name}” (hides {count})",
5438
5559
  "scenes.preview.bind.toolMissing": "Tool table: scheme “{name}” no longer exists — nothing switches this time",
5439
5560
  "scenes.preview.bind.toolKeep": "Tool table: stays as it is (this scene binds none)", "scenes.preview.row.notes": "Overrides the notes of {count} server(s) with scene notes", "scenes.field.toolTable": "Model tool-table scheme", "scenes.field.quickPrompts": "Quick prompts", "scenes.field.quickPrompts.current": "Current settings", "scenes.field.quickPrompts.customCount": "Custom ({picked}/{total})", "scenes.field.quickPrompts.none": "No quick prompts yet — add some on the Prompts page",
5440
- "scenes.field.quickPrompts.noneOption": "None", "scenes.field.quickPrompts.list": "Enabled in this scene", "scenes.field.quickPrompts.pick": "Choose", "scenes.quickPick.title": "Choose the quick prompts enabled in this scene", "scenes.quickPick.hint": "Checked ones are enabled when you enter this scene, unchecked ones are disabled; leaving the scene restores the switches from before you entered.", "scenes.quickPick.confirm": "Done", "scenes.form.section.basic": "Basics", "scenes.form.section.binding": "Scene bindings", "scenes.field.toolTable.none": "Current settings", "scenes.field.toolTable.factory": "Factory default (hides {count})", "scenes.field.toolTable.count": "hides {count}", "scenes.field.toolTable.missing": "scheme no longer exists", "scenes.preview.more": " etc.", "scenes.preview.truncated": "(each kind lists its first 60 entries only)", "scenes.preview.stale": "{count} profile entry(ies) no longer exist in this environment and will not take effect: {items}", "scenes.doctor.title": "{count} reference(s) point at a name that no longer exists", "scenes.doctor.noneButSkipped": "The check found no dangling references", "scenes.doctor.hint": "Left behind by a rename or a delete: it still looks bound here, but nothing matches that name at runtime. Point it at the right name, or untick it in the profile.", "scenes.doctor.row": "{where}: “{name}” no longer exists", "scenes.doctor.more": "{count} more not listed", "scenes.doctor.skipped": "({domains} could not be read, so that kind was not checked)", "agm.restore.tag": "Restored after the scene", "agm.restore.tag.hint": "The global prompt from before the scene; leaving the scene restores it from the snapshot's text, so deleting this preset does not break the restore", "agm.file.mismatch": "Not in AGENTS.md", "agm.file.mismatch.hint": "AGENTS.md does not currently match this preset (it may have been edited by hand); flip the switch on the left to write it back", "hist.restore.note.registered": "Restored {count} session(s); workspace “{title}” was registered again and the sessions are back in that group.", "hist.restore.note.attached": "Restored {count} session(s) and attached them to workspace “{title}”.", "hist.restore.note.skip": "Workspace ownership could not be restored for {count} session(s): {reason}", "hist.purge.title": "Delete forever?", "hist.purge.one": "Delete “{title}” and all of its records forever — this cannot be undone", "hist.purge.batch": "Delete the {count} selected sessions and all of their records forever — this cannot be undone", "hist.btn.restore": "Restore", "hist.btn.purge": "Delete forever", "hist.btn.confirmPurge": "Delete", "hist.selectGroup": "Select group {title}", "hist.select": "Select this session", "hist.group.count": "{count}", "hist.selectAll": "Select all", "hist.deselectAll": "Deselect all", "hist.btn.import": "Import", "hist.btn.export": "Export", "hist.selected": "{count} selected", "hist.btn.restoreSelected": "Restore selected ({count})", "hist.btn.deleteSelected": "Delete selected ({count})", "hist.stat.archived": "Archived", "hist.stat.projects": "Workspaces", "hist.stat.retention": "Retention", "hist.search.placeholder": "Search title / session id / project path", "hist.loading": "Loading…", "hist.empty.search": "No matching archived sessions", "hist.empty.none": "No archived sessions yet", "hist.import.cwd": "Workspace (optional)", "hist.import.cwd.placeholder": "Pick, browse, or type an absolute path; leave blank to go to Ungrouped", "hist.import.workspace": "Workspace", "hist.import.browse": "Browse", "hist.import.workspace.none": "No registered workspace on this machine yet — type an absolute path, or use Browse to pick a folder.", "hist.import.drop": "Choose or drop a conversation file", "hist.import.done": "Created session {id} ({count} messages); continue it from the DSH session list.", "hist.import.lossy": "⚠ No speaker markers were found in the text, so it was folded into a single user message: the original turn structure may have been lost. If the source is a JSONL or Markdown transcript, import it with its original extension.", "hist.export.hint": "Export the selected sessions as transcript files that can be imported again", "hist.export.dir": "Export directory (absolute path, created automatically)", "hist.export.browse": "Browse and choose a folder", "hist.btn.browse": "Choose", "hist.export.pickDir": "Choose export directory", "hist.export.format": "Format", "hist.export.detail": "Export detail", "hist.export.detail.simple": "Text only", "hist.export.detail.tools": "Text + tools", "hist.export.detail.full": "Text + tools + thinking", "hist.export.scope": "Session scope", "hist.export.workspace": "Workspace", "hist.export.sessions": "Sessions ({count})", "hist.export.loading": "Loading sessions…", "hist.cwd.missing": "⚠ The workspace directory no longer exists: ", "hist.tag.archived": "Archived", "hist.tag.live": "Not archived", "hist.export.empty": "No matching sessions", "hist.result.exported": "Exported {count} session(s) to {dir}", "hist.result.skipped": "; skipped {count} ({ids})", "hist.result.archived": "Archived {count} session(s) to History; manage them on the History page", "hist.result.archiveFailed": "; {count} could not be archived", "hist.btn.archiveSelected": "Archive selected", "hist.btn.archiveSelected.title": "Move the selected sessions into History so retention applies to them", "hist.export.busy": "Working…", "hist.btn.exportSelected": "Export {count} session(s)", "hist.error.title": "Operation failed", "hist.error.unknown": "Unknown error",
5561
+ "scenes.field.quickPrompts.noneOption": "None", "scenes.field.quickPrompts.list": "Enabled in this scene", "scenes.field.quickPrompts.pick": "Choose", "scenes.quickPick.title": "Choose the quick prompts enabled in this scene", "scenes.quickPick.hint": "Checked ones are enabled when you enter this scene, unchecked ones are disabled; leaving the scene restores the switches from before you entered.", "scenes.quickPick.confirm": "Done", "scenes.form.section.basic": "Basics", "scenes.form.section.binding": "Scene bindings", "scenes.field.toolTable.none": "Current settings", "scenes.field.toolTable.factory": "Factory default (hides {count})", "scenes.field.toolTable.count": "hides {count}", "scenes.field.toolTable.missing": "scheme no longer exists", "scenes.preview.more": " etc.", "scenes.preview.truncated": "(each kind lists its first 60 entries only)", "scenes.preview.stale": "{count} profile entry(ies) no longer exist in this environment and will not take effect: {items}", "scenes.doctor.title": "{count} reference(s) point at a name that no longer exists", "scenes.doctor.noneButSkipped": "The check found no dangling references", "scenes.doctor.hint": "Left behind by a rename or a delete: it still looks bound here, but nothing matches that name at runtime. Point it at the right name, or untick it in the profile.", "scenes.doctor.row": "{where}: “{name}” no longer exists", "scenes.doctor.more": "{count} more not listed", "scenes.doctor.skipped": "({domains} could not be read, so that kind was not checked)", "agm.restore.tag": "Restored after the scene", "agm.restore.tag.hint": "The global prompt from before the scene; leaving the scene restores it from the snapshot's text, so deleting this preset does not break the restore", "agm.file.mismatch": "Not in AGENTS.md", "agm.file.mismatch.hint": "AGENTS.md does not currently match this preset (it may have been edited by hand); flip the switch on the left to write it back", "hist.restore.note.registered": "Restored {count} session(s); workspace “{title}” was registered again and the sessions are back in that group.", "hist.restore.note.attached": "Restored {count} session(s) and attached them to workspace “{title}”.", "hist.restore.note.skip": "Workspace ownership could not be restored for {count} session(s): {reason}", "hist.purge.title": "Delete forever?", "hist.purge.one": "Delete “{title}” and all of its records forever — this cannot be undone", "hist.purge.batch": "Delete the {count} selected sessions and all of their records forever — this cannot be undone", "hist.btn.restore": "Restore", "hist.btn.purge": "Delete forever", "hist.btn.confirmPurge": "Delete", "hist.selectGroup": "Select group {title}", "hist.select": "Select this session", "hist.group.count": "{count}", "hist.selectAll": "Select all", "hist.deselectAll": "Deselect all", "hist.btn.import": "Import", "hist.btn.export": "Export", "hist.btn.restoreSelected": "Restore selected ({count})", "hist.btn.deleteSelected": "Delete selected ({count})", "hist.stat.archived": "Archived", "hist.stat.projects": "Workspaces", "hist.stat.retention": "Retention", "hist.search.placeholder": "Search title / session id / project path", "hist.loading": "Loading…", "hist.empty.search": "No matching archived sessions", "hist.empty.none": "No archived sessions yet", "hist.import.cwd": "Workspace (optional)", "hist.import.cwd.placeholder": "Pick a registered workspace or type an absolute path; leave blank to go to Ungrouped", "hist.import.workspace": "Workspace", "hist.import.workspace.none": "No registered workspace on this machine yet — type an absolute path.", "hist.import.done": "Created session {id} ({count} messages); continue it from the DSH session list.", "hist.import.lossy": "⚠ No speaker markers were found in the text, so it was folded into a single user message: the original turn structure may have been lost. If the source is a JSONL or Markdown transcript, import it with its original extension.", "hist.export.hint": "Export the selected sessions as transcript files that can be imported again", "hist.export.dir": "Export directory (absolute path, created automatically)", "hist.export.browse": "Browse and choose a folder", "hist.btn.browse": "Choose", "hist.export.pickDir": "Choose export directory", "hist.export.format": "Format", "hist.export.detail": "Export detail", "hist.export.detail.simple": "Text only", "hist.export.detail.tools": "Text + tools", "hist.export.detail.full": "Text + tools + thinking", "hist.export.scope": "Session scope", "hist.export.workspace": "Workspace", "hist.export.sessions": "Sessions ({count})", "hist.export.loading": "Loading sessions…", "hist.cwd.missing": "⚠ The workspace directory no longer exists: ", "hist.tag.archived": "Archived", "hist.tag.live": "Not archived", "hist.export.empty": "No matching sessions", "hist.result.exported": "Exported {count} session(s) to {dir}", "hist.result.skipped": "; skipped {count} ({ids})", "hist.export.busy": "Working…", "hist.btn.exportSelected": "Export {count} session(s)", "hist.error.title": "Operation failed", "hist.error.unknown": "Unknown error",
5441
5562
  }
5442
5563
  };
5443
5564
 
@@ -5912,6 +6033,8 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
5912
6033
  const [levelFilter, setLevelFilter] = React.useState('')
5913
6034
  const [collapsed, setCollapsed] = React.useState({})
5914
6035
  const [formModal, setFormModal] = React.useState(null)
6036
+ // 切走这一页 → 这一页的关窗暂存作废(见 42-shared-ui.js 的 useFormDraftScope)。
6037
+ useFormDraftScope('mcp:')
5915
6038
  // 表单的本地校验结果(不塞进 formModal:那对象是发给服务端的 payload 形状)。
5916
6039
  const [formError, setFormError] = React.useState(null)
5917
6040
  const [detail, setDetail] = React.useState(null)
@@ -6070,9 +6193,16 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6070
6193
  })
6071
6194
  }
6072
6195
 
6073
- const openAdd = () => { setFormError(null); setFormModal(Object.assign({ mode: 'add', id: null }, emptyForm())) }
6196
+ // 新增与编辑各存一份(编辑按条目 id 分键:改 A 关窗不会串到 B)。
6197
+ const formDraftKey = (modal) => (modal && modal.mode === 'edit' ? 'mcp:edit:' + modal.id : 'mcp:create')
6198
+ const openAdd = () => {
6199
+ setFormError(null)
6200
+ setFormModal(takeFormDraft('mcp:create') || Object.assign({ mode: 'add', id: null }, emptyForm()))
6201
+ }
6074
6202
  const openEdit = (row) => {
6075
6203
  setFormError(null)
6204
+ const draft = takeFormDraft('mcp:edit:' + row.id)
6205
+ if (draft) { setFormModal(draft); return }
6076
6206
  setFormModal({
6077
6207
  mode: 'edit',
6078
6208
  id: row.id,
@@ -6087,6 +6217,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6087
6217
  })
6088
6218
  }
6089
6219
  const closeForm = () => { setFormError(null); setFormModal(null) }
6220
+ // 只有"关掉"这条路存草稿(提交成功走的是 closeForm 本身,不进这里)——
6221
+ // 所以不需要"成功后清草稿"那一步。
6222
+ const dismissForm = () => { const cur = formModal; closeForm(); if (cur) stashFormDraft(formDraftKey(cur), cur) }
6090
6223
  const setFormField = (key) => (ev) => { setFormError(null); setFormModal(Object.assign({}, formModal, { [key]: ev.target.value })) }
6091
6224
  const setFormValue = (key, value) => { setFormError(null); setFormModal(Object.assign({}, formModal, { [key]: value })) }
6092
6225
  /**
@@ -6127,11 +6260,36 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6127
6260
  run(payload.mode === 'edit' ? 'mcpm-edit' : 'mcpm-add', args, 'form', closeForm)
6128
6261
  }
6129
6262
 
6263
+ // 「启动中」的乐观记账(用户 2026-10-15:呼吸灯要过一会才出现):翻开开关那一刻宿主
6264
+ // 还没把条目起起来,轮询到的仍是停用前的那份状态,于是灯要等下一次轮询(5 秒)才呼吸。
6265
+ // 点下时先记一笔,相位报回 active 即不再算数;30 秒后自动作废 —— 真起不来的条目不该永远呼吸。
6266
+ const STARTING_TTL_MS = 30000
6267
+ const [startingRows, setStartingRows] = React.useState({})
6268
+ const startingKey = (row) => row.id + '@' + row.level
6269
+ const markStarting = (row) => {
6270
+ setStartingRows((prev) => Object.assign({}, prev, { [startingKey(row)]: Date.now() }))
6271
+ }
6272
+ const clearStarting = (row) => {
6273
+ setStartingRows((prev) => {
6274
+ if (prev[startingKey(row)] === undefined) return prev
6275
+ const next = Object.assign({}, prev)
6276
+ delete next[startingKey(row)]
6277
+ return next
6278
+ })
6279
+ }
6280
+ const isStarting = (row) => {
6281
+ const ts = startingRows[startingKey(row)]
6282
+ return ts !== undefined && Date.now() - ts < STARTING_TTL_MS
6283
+ }
6284
+
6130
6285
  // Toggling updates only the clicked row (scroll position and expanded
6131
6286
  // groups survive); a failed write re-reads the server state instead.
6132
6287
  const toggleRow = (row) => {
6133
6288
  const nextDisabled = !row.disabled
6134
6289
  setMsg(null)
6290
+ // 翻开(nextDisabled=false)= 立刻按「启动中」显示;关掉 = 销账,否则刚停用的那行
6291
+ // 还在呼吸。(2026-10-15 这一条写反过一次:结果是"开了不绿、关了反而绿 30 秒"。)
6292
+ if (nextDisabled) clearStarting(row); else markStarting(row)
6135
6293
  setState((prev) => Object.assign({}, prev, {
6136
6294
  rows: (prev.rows || []).map((item) => (
6137
6295
  item.id === row.id && item.level === row.level
@@ -6142,16 +6300,38 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6142
6300
  setBusy(row.id + ':toggle')
6143
6301
  apiCall('mcpm-set-enabled', { id: row.id, level: row.level, enabled: row.disabled }).then((res) => {
6144
6302
  if (res && !res.ok) {
6303
+ clearStarting(row)
6145
6304
  setMsg({ kind: 'err', text: (res && res.error) || mt('mcp.msg.failed') })
6146
6305
  refresh()
6147
6306
  } else if (res && res.warning) {
6148
6307
  setMsg({ kind: 'warn', text: mt('mcp.msg.warn', { warning: res.warning }) })
6149
6308
  }
6150
6309
  }).catch((e) => {
6310
+ clearStarting(row)
6151
6311
  setMsg({ kind: 'err', text: errMsg(e) })
6152
6312
  refresh()
6153
6313
  }).then(() => setBusy(null))
6154
6314
  }
6315
+ /**
6316
+ * 重连一台服务(`mcpm-restart`):服务端先把它临时停用、等 loader 摘掉、再按重启前的
6317
+ * 状态恢复 —— 最长约 11 秒,所以**不走 `run()`**(那条路只服务"一按就回"的写操作,
6318
+ * 也没有地方放 warnings)。启用状态不变,这正是"重启"与"关掉再打开"的区别。
6319
+ * 只有驻留在补丁文件里的条目可重连:loader 行没有可写的 id/level,调用点据此置灰。
6320
+ */
6321
+ const restartRow = (row) => {
6322
+ setMsg(null)
6323
+ setBusy(row.id + ':restart')
6324
+ apiCall('mcpm-restart', { id: row.id, level: row.level }).then((res) => {
6325
+ if (res && res.ok === false) {
6326
+ setMsg({ kind: 'err', text: (res && res.error) || mt('mcp.msg.failed') })
6327
+ } else if (res && res.warnings && res.warnings.length) {
6328
+ setMsg({ kind: 'warn', text: mt('mcp.msg.restartWarn', { warning: res.warnings.join(';') }) })
6329
+ } else {
6330
+ setMsg({ kind: 'ok', text: mt('mcp.msg.restarted', { name: row.serverName }) })
6331
+ }
6332
+ }).catch((e) => setMsg({ kind: 'err', text: errMsg(e) })).then(() => { setBusy(null); refresh() })
6333
+ }
6334
+
6155
6335
  const toggleReveal = () => {
6156
6336
  const next = !reveal
6157
6337
  setReveal(next)
@@ -6250,6 +6430,31 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6250
6430
  }).catch((e) => setMsg({ kind: 'err', text: errMsg(e) })).then(() => setBusy(null))
6251
6431
  }
6252
6432
 
6433
+ /**
6434
+ * 详情页里"现在去问一次工具清单"(`mcpm-tools-refresh`):对还没有缓存的服务器,
6435
+ * 服务端是真的**临时启动一次**去抓工具名,拿到后恢复原启用状态 —— 所以它慢、也可能失败。
6436
+ * 详情那节的「工具:N」平时只读缓存,这是唯一的主动刷新入口;在途响应按打开详情时的
6437
+ * 序号作废(换了一台服务器就不该把旧结果写进去)。
6438
+ */
6439
+ const refreshTools = () => {
6440
+ const row = detail && detail.row
6441
+ if (!row) return
6442
+ const seq = detailSeqRef.current
6443
+ setMsg(null)
6444
+ setBusy('tools:refresh')
6445
+ apiCall('mcpm-tools-refresh', { serverName: row.serverName }).then((res) => {
6446
+ if (seq !== detailSeqRef.current) return
6447
+ if (res && res.ok === false) {
6448
+ setMsg({ kind: 'err', text: (res && res.error) || mt('mcp.msg.failed') })
6449
+ return
6450
+ }
6451
+ const tools = (res && res.tools) || []
6452
+ setDetail((prev) => (prev === null ? prev : Object.assign({}, prev, { loading: false, error: null, tools: tools })))
6453
+ if (res && res.warning) setMsg({ kind: 'warn', text: mt('mcp.msg.warn', { warning: res.warning }) })
6454
+ else setMsg({ kind: 'ok', text: mt('mcp.msg.toolsRefreshed', { count: tools.length }) })
6455
+ }).catch((e) => { if (seq === detailSeqRef.current) setMsg({ kind: 'err', text: errMsg(e) }) }).then(() => setBusy(null))
6456
+ }
6457
+
6253
6458
  const rows = state.rows || []
6254
6459
  // 工具数的三个口径(与 src/mcp/state-section.ts 同源,理由见那里的文件头):
6255
6460
  // liveToolCount 当前真实注册了几个工具(**不含**「已知工具」缓存)
@@ -6315,10 +6520,14 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6315
6520
 
6316
6521
  // 状态口径:tone 只决定胶囊配色(ok=能用、bad=坏了、warn=当前没工具、muted=没在跑)。
6317
6522
  const liveStatus = (row) => {
6523
+ // 「刚翻开开关」这一条排在最前(但仍在 failed 之后):新写进补丁的条目在宿主读到
6524
+ // 之前 `row.live` 干脆是空的,若让它先落到「未加载 / 已停止」,界面就是"点了开关
6525
+ // 先灰几秒才绿" —— 用户 2026-10-15 报的"看不出是不是启动中"正是这几秒。
6526
+ if (row.live && row.live.phase === 'failed') return { text: mt('mcp.live.failed'), tone: 'bad' }
6527
+ if (isStarting(row) && !(row.live && row.live.phase === 'active')) return { text: mt('mcp.live.loading'), tone: 'busy' }
6318
6528
  if (!row.live) return { text: mt('mcp.live.notLoaded'), tone: 'muted' }
6319
- if (row.live.phase === 'failed') return { text: mt('mcp.live.failed'), tone: 'bad' }
6320
6529
  if (!row.live.enabled) return { text: mt('mcp.live.stopped'), tone: 'muted' }
6321
- if (row.live.phase && row.live.phase !== 'active') return { text: mt('mcp.live.loading'), tone: 'muted' }
6530
+ if (row.live.phase && row.live.phase !== 'active') return { text: mt('mcp.live.loading'), tone: 'busy' }
6322
6531
  // 判"有没有可用工具"只看**真实注册**的那个数(缓存不算)。
6323
6532
  if (liveEnabledOf(row) === 0) return { text: mt('mcp.live.noTools'), tone: 'warn' }
6324
6533
  return { text: mt('mcp.live.running'), tone: 'ok' }
@@ -6460,9 +6669,10 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6460
6669
  const maskedInForm = !!formModal && /•{3,}/.test(String(formModal.env || '') + String(formModal.headers || ''))
6461
6670
  const formModalNode = formModal && React.createElement(Modal, {
6462
6671
  key: 'mcp-form',
6672
+ className: 'dsm-modal-mcp-form',
6463
6673
  title: formModal.mode === 'edit' ? mt('mcp.form.editTitle') + formModal.id : mt('mcp.form.addTitle'),
6464
6674
  closeLabel: mt('btn.close'),
6465
- onClose: closeForm,
6675
+ onClose: dismissForm,
6466
6676
  },
6467
6677
  React.createElement('div', { className: 'dsm-form' },
6468
6678
  maskedInForm ? React.createElement(Notice, { kind: 'warn', text: mt('mcp.form.maskedHint') }) : null,
@@ -6471,9 +6681,19 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6471
6681
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.serverName')),
6472
6682
  React.createElement('input', { className: 'dsm-control', value: formModal.serverName, placeholder: 'e.g. github', onChange: setFormField('serverName') }),
6473
6683
  React.createElement('span', { className: 'dsm-help' }, mt('mcp.field.serverName.hint'))),
6474
- React.createElement('label', { className: 'dsm-field' },
6684
+ // 传输方式用分段控件而不是下拉(用户 2026-10-15):只有 stdio /
6685
+ // streamable-http 两个互斥值,下拉要多点一下才能看见全部选项,
6686
+ // 且选中态只占一行里的一小条。与「重名处理」「提示词类型」同款。
6687
+ React.createElement('div', { className: 'dsm-field' },
6475
6688
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.transport')),
6476
- React.createElement(SourceSelect, { value: formModal.transport, options: MCP_TRANSPORT_OPTIONS, onChange: (value) => setFormValue('transport', value) })),
6689
+ React.createElement('div', { className: 'dsm-form-seg', role: 'group', 'aria-label': mt('mcp.field.transport') },
6690
+ MCP_TRANSPORT_OPTIONS.map(function (o) {
6691
+ return React.createElement('button', {
6692
+ key: o.value, type: 'button',
6693
+ 'aria-pressed': formModal.transport === o.value ? 'true' : 'false',
6694
+ onClick: function () { setFormValue('transport', o.value) },
6695
+ }, o.label);
6696
+ }))),
6477
6697
  React.createElement('label', { className: 'dsm-field' },
6478
6698
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.level')),
6479
6699
  React.createElement(SourceSelect, {
@@ -6493,15 +6713,16 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6493
6713
  React.createElement('label', { className: 'dsm-field' },
6494
6714
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.command')),
6495
6715
  React.createElement('input', { className: 'dsm-control', value: formModal.command, placeholder: 'npx -y @modelcontextprotocol/server-github', onChange: setFormField('command') })),
6496
- React.createElement('label', { className: 'dsm-field' },
6716
+ React.createElement('label', { className: 'dsm-field dsm-field-fill' },
6497
6717
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.args')),
6498
6718
  React.createElement('textarea', { className: 'dsm-control dsm-textarea-sm', value: formModal.args, placeholder: '-y\n@modelcontextprotocol/server-github', onChange: setFormField('args') })),
6499
- React.createElement('label', { className: 'dsm-field' },
6719
+ // 环境变量的「路径按系统写法填」那句提示删除(用户 2026-10-15):它讲的是
6720
+ // 少数人才会碰的细节,却把这一格顶高一行、也让 stdio 与 http 两档更不齐。
6721
+ React.createElement('label', { className: 'dsm-field dsm-field-fill' },
6500
6722
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.env')),
6501
- React.createElement('span', { className: 'dsm-help' }, mt('mcp.field.env.hint')),
6502
6723
  React.createElement('textarea', { className: 'dsm-control dsm-textarea-sm', value: formModal.env, placeholder: 'GITHUB_TOKEN=xxx', onChange: setFormField('env') }))),
6503
6724
  formModal.transport === 'streamable-http'
6504
- ? React.createElement('label', { className: 'dsm-field' },
6725
+ ? React.createElement('label', { className: 'dsm-field dsm-field-fill' },
6505
6726
  React.createElement('span', { className: 'dsm-label' }, mt('mcp.field.headers')),
6506
6727
  React.createElement('textarea', { className: 'dsm-control dsm-textarea-sm', value: formModal.headers, placeholder: 'Authorization=Bearer xxx', onChange: setFormField('headers') }))
6507
6728
  : null,
@@ -6538,7 +6759,11 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6538
6759
  React.createElement('div', { className: 'dsm-detail-section' },
6539
6760
  React.createElement('div', { className: 'dsm-detail-title dsm-detail-title-row' },
6540
6761
  mt('mcp.detail.config'),
6541
- React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy !== null, onClick: toggleReveal }, reveal ? mt('mcp.detail.hideSecret') : mt('mcp.detail.showSecret'))),
6762
+ // 两颗动作包成一组(用户 2026-10-15:「重启怎么在中间」)—— space-between
6763
+ // 摊三个子元素时中间那个会被顶到整行正中,标题左、动作组右才对。
6764
+ React.createElement('span', { className: 'dsm-detail-title-acts' },
6765
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', title: mt('mcp.btn.restart.title'), disabled: busy !== null || detail.row.level === 'loader', onClick: () => restartRow(detail.row) }, mt('mcp.btn.restart')),
6766
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy !== null, onClick: toggleReveal }, reveal ? mt('mcp.detail.hideSecret') : mt('mcp.detail.showSecret')))),
6542
6767
  React.createElement('div', { className: 'dsm-fm' },
6543
6768
  configRow(mt('mcp.detail.entryId'), detail.row.id),
6544
6769
  configRow(mt('mcp.field.level'), mt('mcp.level.' + detail.row.level)),
@@ -6554,22 +6779,30 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6554
6779
  React.createElement('div', { className: 'dsm-detail-title' }, mt('mcp.detail.status')),
6555
6780
  React.createElement('div', { className: 'dsm-feedback' + (detailStatus.tone === 'bad' ? ' dsm-error' : ' dsm-warning') }, detailHint)) : null,
6556
6781
  React.createElement('div', { className: 'dsm-detail-section' },
6557
- // 字数计数摆在小标题同一行右侧:备注的上限就是注入段的截断长度(`MCP_NOTE_MAX`)。
6782
+ // 标题行只留标题 + 字数计数:备注的上限就是注入段的截断长度(`MCP_NOTE_MAX`)。
6558
6783
  // 超限标红沿用记忆页描述那套(`dsm-char-over`)—— 服务端不校验备注长度,
6559
6784
  // 这条之前存的长备注会整条存着、注入时被截掉,得让人看得见超了多少。
6560
- React.createElement('div', { className: 'dsm-budget-meta' },
6561
- React.createElement('div', { className: 'dsm-detail-title' }, mt('mcp.detail.note')),
6785
+ React.createElement('div', { className: 'dsm-detail-title dsm-detail-title-row' },
6786
+ mt('mcp.detail.note'),
6562
6787
  React.createElement('span', { className: 'dsm-char-count' + (String(noteDraft || '').length > MCP_NOTE_MAX ? ' dsm-char-over' : '') }, String(noteDraft || '').length + ' / ' + MCP_NOTE_MAX)),
6563
6788
  React.createElement('textarea', { className: 'dsm-control dsm-textarea-sm', value: noteDraft, maxLength: MCP_NOTE_MAX, placeholder: mt('mcp.detail.note.placeholder'), onChange: (ev) => setNoteDraft(ev.target.value) }),
6789
+ // 「保存备注」贴着自己要提交的输入框下方右对齐,且是全弹窗唯一的实心主按钮
6790
+ // (用户 2026-10-15:上一版把它与「刷新工具」都摆成标题行里的 quiet 键,
6791
+ // 两颗一模一样、还落在相邻两节的同一高度 —— 备注输完下意识就点到「刷新
6792
+ // 工具」。两个信号把两颗分开:位置贴着 textarea,提交动作跟着被提交的
6793
+ // 字段走;权重用实心主色,它才是备注区的提交,「刷新工具」保持 quiet
6794
+ // 的工具区动作)。
6795
+ React.createElement('div', { className: 'dsm-modal-actions' },
6796
+ React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: busy !== null || noteDraft.trim() === String(detail.row.notes || ''), onClick: saveNote }, mt('mcp.detail.note.save'))),
6564
6797
  // 备注保存的回执就地渲染(见 noteNotice 的说明)。文案原样来自服务端:
6565
6798
  // 成功走 Notice 的 ok 档(右下角浮出的 toast,与全站成功提示同一副长相);
6566
6799
  // 被冻结闸拒掉是弹窗内的一条红色 feedback —— 这正是用户要看到的那句话,
6567
6800
  // 只是位置从页面挪进了弹窗。
6568
- React.createElement(Notice, { kind: noteNotice && noteNotice.kind, text: noteNotice && noteNotice.text }),
6569
- React.createElement('div', { className: 'dsm-modal-actions' },
6570
- React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy !== null || noteDraft.trim() === String(detail.row.notes || ''), onClick: saveNote }, mt('mcp.detail.note.save')))),
6801
+ React.createElement(Notice, { kind: noteNotice && noteNotice.kind, text: noteNotice && noteNotice.text })),
6571
6802
  React.createElement('div', { className: 'dsm-detail-section' },
6572
- React.createElement('div', { className: 'dsm-detail-title' }, mt('mcp.detail.tools', { count: detail.loading ? '…' : (detail.tools || []).length })),
6803
+ React.createElement('div', { className: 'dsm-detail-title dsm-detail-title-row' },
6804
+ mt('mcp.detail.tools', { count: detail.loading ? '…' : (detail.tools || []).length }),
6805
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy !== null || detail.loading === true, onClick: refreshTools }, mt('mcp.detail.toolsRefresh'))),
6573
6806
  toolListNode))
6574
6807
 
6575
6808
 
@@ -6669,6 +6902,10 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6669
6902
  var busyState = React.useState(null), busy = busyState[0], setBusy = busyState[1]
6670
6903
  var em = React.useState(null), editModal = em[0], setEditModal = em[1]
6671
6904
  var cm = React.useState(null), createModal = cm[0], setCreateModal = cm[1]
6905
+ // 新建撞名确认窗(42-shared-ui.js 的 DupNameDialog):`{ name, finalName, kind }`。
6906
+ // 提示词是**第五个**用它的域(记忆 / 场景 / 技能 / 子智能体之外)—— 前四个在 2026-10-14
6907
+ // 一起改成了"先问一句",提示词当时漏在外面,撞名照旧是一句「id 已存在」把人挡回去。
6908
+ var ca = React.useState(null), dupAsk = ca[0], setDupAsk = ca[1]
6672
6909
  var ac = React.useState(null), applyConfirm = ac[0], setApplyConfirm = ac[1]
6673
6910
  // 回收站:true = 打开本页那一个回收站(42-shared-ui.js 的 TrashModal,domain = prompts)。
6674
6911
  // 提示词域(预设 + 快捷)的清单加载与恢复/永久删除都在组件内部完成,
@@ -6683,6 +6920,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6683
6920
  var quick = qs[0], setQuick = qs[1]
6684
6921
  var qk = React.useState(null), quickEdit = qk[0], setQuickEdit = qk[1]
6685
6922
  var qf = React.useState(null), quickConfirm = qf[0], setQuickConfirm = qf[1]
6923
+ // 三个弹窗(新增预设 / 编辑预设 / 编辑快捷提示词)各自一份关窗暂存(见 42-shared-ui.js:
6924
+ // 关窗存、打开取,成功提交不走这条路,所以没有"成功后要记得清"那一步)。
6925
+ useFormDraftScope('prompt:')
6686
6926
  function refreshQuick() {
6687
6927
  apiCall('quickprompt-list', {})
6688
6928
  .then(function (res) {
@@ -6841,6 +7081,9 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6841
7081
  }
6842
7082
  function openEdit(p) {
6843
7083
  beginAction()
7084
+ // 30 秒内重开同一份:把刚才关掉的那份编辑内容填回去,不重读磁盘。
7085
+ var draft = takeFormDraft('prompt:edit:' + p.id)
7086
+ if (draft) { setEditModal(draft); return }
6844
7087
  // id 可改:改名 = 目录改名,宿主会把场景绑定一起改名(见 rules-rebind-prompt)。
6845
7088
  apiCall('agentsmd-read', { id: p.id }).then(function (res) {
6846
7089
  if (res && res.ok) setEditModal({ id: p.id, nextId: p.id, content: res.content, description: p.description || '' })
@@ -6901,6 +7144,32 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6901
7144
  else refresh()
6902
7145
  }).catch(function (e) { setBusy(null); setApplyConfirm(null); actionFailed(e) })
6903
7146
  }
7147
+ /**
7148
+ * 新建提示词的**入口**:先按目标那一档的现有 id 查重(用户 2026-10-14)。
7149
+ *
7150
+ * 撞名原先只在服务端被拒(`agentsmd-create` / `quickprompt-create` 各回一句
7151
+ * 「id 已存在」),用户看到的是"这条 id 不能用",而不是"要不要叫 `1(1)`" ——
7152
+ * 与其余四个域(记忆 / 场景 / 技能 / 子智能体)的口径不一致。这里补上同一套:
7153
+ * 撞名先弹 `DupNameDialog`,确认了才带着退号后的 id 落盘。
7154
+ *
7155
+ * 查重只在**同一档内部**做:全局提示词与快捷提示词各占一个目录
7156
+ * (`prompts/<id>/` 与 `quick-prompts/<id>/`),两边的 id 互不影响。
7157
+ *
7158
+ * 本地查重不替代服务端那道闸:期间别人(或模型工具)建了同一条,提交仍会被拒,
7159
+ * 那时照旧如实报错 —— 这里只是把常见情形做顺手,不是替服务端下判断。
7160
+ */
7161
+ function submitCreate(id, content, description) {
7162
+ var typed = String(id || '').trim()
7163
+ var isQuick = createModal && createModal.kind === 'quick'
7164
+ var taken = {}
7165
+ ;(isQuick ? quick.prompts : state.presets).forEach(function (p) { taken[String(p.id)] = true })
7166
+ var finalId = uniqueName(typed, taken)
7167
+ // 正文与描述一并带进确认窗:确认时直接用它们,不再回头读 `createModal` ——
7168
+ // 那样确认动作就依赖"那个弹窗还开着",而这两件事没有必然关系。
7169
+ if (finalId !== typed) { setDupAsk({ name: typed, finalId: finalId, content: content, description: description }); return }
7170
+ doCreate(typed, content, description)
7171
+ }
7172
+ /** 真正落盘那一步(id 已定)。`id` 来自 `submitCreate` 或确认弹窗。 */
6904
7173
  function doCreate(id, content, description) {
6905
7174
  beginAction()
6906
7175
  setBusy('create')
@@ -6952,6 +7221,8 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
6952
7221
  */
6953
7222
  function openQuickEdit(p) {
6954
7223
  beginAction()
7224
+ var draft = takeFormDraft('prompt:quick:' + p.id)
7225
+ if (draft) { setQuickEdit(draft); return }
6955
7226
  setQuickEdit({ id: p.id, nextId: p.id, content: p.content || '', description: p.description || '' })
6956
7227
  }
6957
7228
  function doQuickUpdate() {
@@ -7217,7 +7488,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7217
7488
  [quickEnabledCount + '/' + quick.prompts.length, t('quickprompt.stat')],
7218
7489
  ],
7219
7490
  actions: [
7220
- React.createElement('button', { key: 'new', type: 'button', className: 'dsm-btn', disabled: state.anyLocked === true, onClick: function () { setCreateModal({ kind: 'global', id: '', content: '', description: '', error: null }) } }, t('agm.btn.new')),
7491
+ React.createElement('button', { key: 'new', type: 'button', className: 'dsm-btn', disabled: state.anyLocked === true, onClick: function () { setCreateModal(takeFormDraft('prompt:create') || { kind: 'global', id: '', content: '', description: '', error: null }) } }, t('agm.btn.new')),
7221
7492
  React.createElement('button', { key: 'import', type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy !== null || state.anyLocked === true, onClick: function () { setImportResult(null); setImportOpen(true) } }, t('agm.btn.import')),
7222
7493
  React.createElement('button', { key: 'export', type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy !== null || !(state.presets.length || quick.prompts.length), onClick: function () { setExportState({ busy: false, result: null }) } }, t('export.presets')),
7223
7494
  React.createElement('button', { key: 'trash', type: 'button', className: 'dsm-btn dsm-btn-secondary', onClick: function () { setTrash(true) } }, t('trash.btn.open')),
@@ -7285,7 +7556,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7285
7556
  onReload: function () { refresh(true) },
7286
7557
  }) : null,
7287
7558
  // 编辑:id 与内容都可改(改 id = 目录改名,场景绑定由宿主一起改名)。
7288
- editModal ? React.createElement(Modal, { title: t('agm.edit.title') + ' · ' + editModal.id, closeLabel: t('btn.close'), onClose: function () { setEditModal(null) } },
7559
+ editModal ? React.createElement(Modal, { className: 'dsm-modal-form-tall', title: t('agm.edit.title') + ' · ' + editModal.id, closeLabel: t('btn.close'), onClose: function () { stashFormDraft('prompt:edit:' + editModal.id, editModal); setEditModal(null) } },
7289
7560
  React.createElement('div', { className: 'dsm-field' },
7290
7561
  React.createElement('label', { className: 'dsm-label' }, t('agm.field.id')),
7291
7562
  React.createElement('input', { className: 'dsm-control', value: editModal.nextId || '', onChange: function (e) { setEditModal(Object.assign({}, editModal, { nextId: e.target.value, error: null })) } }),
@@ -7316,7 +7587,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7316
7587
  // 自己滚,正文框静止在 dsm-textarea-lg 的下限上、只能往下拖(与新建人设同款手感)。
7317
7588
  // 标题用 agm.new.title(「新增提示词」)而不是按钮那颗 agm.btn.new(「新增」):
7318
7589
  // 弹窗里已经看不到那颗按钮了,标题光写"新增"不知道在新增什么(用户 2026-10-12)。
7319
- createModal ? React.createElement(Modal, { title: t('agm.new.title'), closeLabel: t('btn.close'), onClose: function () { setCreateModal(null) } },
7590
+ createModal ? React.createElement(Modal, { className: 'dsm-modal-form-tall', title: t('agm.new.title'), closeLabel: t('btn.close'), onClose: function () { stashFormDraft('prompt:create', createModal); setCreateModal(null) } },
7320
7591
  kindPicker(createModal.kind, function (v) { setCreateModal(Object.assign({}, createModal, { kind: v, error: null })) }),
7321
7592
  React.createElement('div', { className: 'dsm-field' },
7322
7593
  React.createElement('label', { className: 'dsm-label' }, t('agm.field.id')),
@@ -7334,7 +7605,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7334
7605
  React.createElement('textarea', { className: 'dsm-control dsm-textarea-lg', placeholder: t(createModal.kind === 'quick' ? 'quickprompt.field.content.placeholder' : 'agm.field.content.placeholder'), value: createModal.content || '', onChange: function (e) { setCreateModal(Object.assign({}, createModal, { content: e.target.value })) } })),
7335
7606
  createModal.error ? React.createElement('div', { className: 'dsm-feedback dsm-error' }, createModal.error) : null,
7336
7607
  React.createElement('div', { className: 'dsm-modal-actions' },
7337
- React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: busy !== null || !String(createModal.id || '').trim(), onClick: function () { doCreate(createModal.id, createModal.content, createModal.description) } }, t('agm.btn.create')))) : null,
7608
+ React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: busy !== null || !String(createModal.id || '').trim(), onClick: function () { submitCreate(createModal.id, createModal.content, createModal.description) } }, t('agm.btn.create')))) : null,
7338
7609
  // 三种确认共用一个弹窗:删除 / 取消应用 / 应用(`applyConfirm` 上的标记位区分)。
7339
7610
  applyConfirm ? React.createElement(Modal, { className: 'dsm-modal-sm', title: applyConfirm.remove ? (t('agm.remove.title') + ' · ' + applyConfirm.id) : ((applyConfirm.unapply ? t('agm.unapply.title') : t('agm.applyModal.title')) + ' · ' + applyConfirm.id), closeLabel: t('btn.close'), onClose: function () { setApplyConfirm(null) } },
7340
7611
  applyConfirm.remove
@@ -7356,7 +7627,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7356
7627
  React.createElement('div', { className: 'dsm-modal-actions' },
7357
7628
  React.createElement('button', { type: 'button', className: 'dsm-btn ' + (applyConfirm.remove || (applyConfirm.unapply && unapplyRemoves) ? 'dsm-btn-danger' : ''), disabled: busy !== null, onClick: function () { if (applyConfirm.remove) doRemove({ id: applyConfirm.id }); else if (applyConfirm.unapply) doUnapply(); else doApply(applyConfirm.id) } }, applyConfirm.remove ? t('agm.btn.confirmRemove') : (applyConfirm.unapply ? (unapplyRemoves ? t('agm.btn.confirmUnapplyRemove') : t('agm.btn.confirmUnapply')) : t('agm.btn.confirmApply'))))) : null,
7358
7629
  importOpen ? React.createElement(ImportModal, {
7359
- key: 'imp', t: t, title: t('agm.btn.import'), accept: '.md',
7630
+ key: 'imp', t: t, title: t('agm.btn.import'), accept: '.md', hint: t('upload.drop.prompt'),
7360
7631
  busy: importBusy, result: importResult,
7361
7632
  // 类型选择放在弹窗顶部(`extra` 槽):一次导入只进一类,选完再拖文件。
7362
7633
  extra: kindPicker(importKind, setImportKind),
@@ -7367,7 +7638,7 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7367
7638
  }) : null,
7368
7639
  // 快捷提示词的编辑:正文直接来自列表(`quickprompt-list` 连正文一起回),
7369
7640
  // 不像预设那样还要再发一次 read。
7370
- quickEdit ? React.createElement(Modal, { title: t('quickprompt.edit.title') + ' · ' + quickEdit.id, closeLabel: t('btn.close'), onClose: function () { setQuickEdit(null) } },
7641
+ quickEdit ? React.createElement(Modal, { title: t('quickprompt.edit.title') + ' · ' + quickEdit.id, closeLabel: t('btn.close'), onClose: function () { stashFormDraft('prompt:quick:' + quickEdit.id, quickEdit); setQuickEdit(null) } },
7371
7642
  React.createElement('div', { className: 'dsm-field' },
7372
7643
  React.createElement('label', { className: 'dsm-label' }, t('agm.field.id')),
7373
7644
  React.createElement('input', { className: 'dsm-control', value: quickEdit.nextId || '', onChange: function (e) { setQuickEdit(Object.assign({}, quickEdit, { nextId: e.target.value, error: null })) } }),
@@ -7389,7 +7660,16 @@ button.dsm-rowselect-box:disabled{cursor:default;opacity:.45}
7389
7660
  React.createElement('p', { className: 'dsm-help' }, t('quickprompt.remove.hint')),
7390
7661
  React.createElement('div', { className: 'dsm-modal-actions' },
7391
7662
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy !== null, onClick: function () { setQuickConfirm(null) } }, t('btn.cancel')),
7392
- React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-danger', disabled: busy !== null, onClick: function () { doQuickRemove(quickConfirm.id) } }, t('agm.btn.confirmRemove')))) : null)
7663
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-danger', disabled: busy !== null, onClick: function () { doQuickRemove(quickConfirm.id) } }, t('agm.btn.confirmRemove')))) : null,
7664
+ // 新建撞名确认窗。放在最后渲染 = 盖在新建弹窗之上(两个弹窗都开着,用户确认/取消
7665
+ // 之后回到那张还填着内容的表单,不用重新输入)。
7666
+ dupAsk ? React.createElement(DupNameDialog, {
7667
+ key: 'dup-name', t: t, kind: t('dup.kind.prompt'),
7668
+ name: dupAsk.name, finalName: dupAsk.finalId, action: 'create',
7669
+ busy: busy !== null,
7670
+ onConfirm: function () { setDupAsk(null); doCreate(dupAsk.finalId, dupAsk.content, dupAsk.description) },
7671
+ onCancel: function () { setDupAsk(null) },
7672
+ }) : null)
7393
7673
  }
7394
7674
 
7395
7675
  // 「工具」设置页:一个侧栏项,内部 tab 切换 场景 / MCP / 技能 / 子智能体 / 提示词 / 记忆 / 会话。
@@ -8111,6 +8391,90 @@ function enabledMemoryCount(rules) {
8111
8391
  if (props.state === 'empty') return h(EmptyState, { text: props.text, action: props.action })
8112
8392
  return h(react.Fragment, null, props.children)
8113
8393
  }
8394
+ /**
8395
+ * 同名退号:`测` → `测(1)` → `测(2)`…(全角括号,用户 2026-10-14 定的写法)。
8396
+ *
8397
+ * `taken` 是「已经被占用的名字」集合 —— 数组按值判、对象按键判,两种写法在各调用点
8398
+ * 手头的数据形状不同(一处是 `list.map(name)`,一处是现拼的字典),这里一并收下。
8399
+ *
8400
+ * `style`:`'paren'`(缺省)= 全角括号,用在**名字即文件名**的三个域(记忆 / 场景 /
8401
+ * 子智能体);`'dash'` = 半角连字符,只有技能域用 —— 技能名必须过 `KEBAB_RE`
8402
+ * (`^[a-z0-9]+(?:-[a-z0-9]+)*$`),全角括号会被 `toKebab` 抹成 `-`,写出来是
8403
+ * `foo-1` 而不是 `foo(1)`,不如一开始就按 `-1` 退号,省得"看着叫甲、落盘叫乙"。
8404
+ *
8405
+ * 上限 999 只是兜底:真到那里说明有一千条同名,再往后试没有意义。
8406
+ */
8407
+ function uniqueName(raw, taken, style) {
8408
+ var isArray = Array.isArray(taken)
8409
+ // `hasOwnProperty` 而不是 `!!taken[n]`:占用表是各调用点现拼的普通对象,直接取属性会
8410
+ // 把原型上的东西(`constructor` / `toString` / `__proto__`)算成"已被占用" —— 用户给
8411
+ // 记忆起名叫 `constructor` 就会被莫名退号。
8412
+ var has = isArray
8413
+ ? function (n) { return taken.indexOf(n) >= 0 }
8414
+ : function (n) { return !!taken && Object.prototype.hasOwnProperty.call(taken, n) }
8415
+ var name = String(raw == null ? '' : raw).trim()
8416
+ if (!has(name)) return name
8417
+ var dash = style === 'dash'
8418
+ for (var i = 1; i <= 999; i += 1) {
8419
+ var candidate = dash ? name + '-' + i : name + '(' + i + ')'
8420
+ if (!has(candidate)) return candidate
8421
+ }
8422
+ return name
8423
+ }
8424
+ /**
8425
+ * 「已存在同名 X」确认弹窗(用户 2026-10-14)。
8426
+ *
8427
+ * 起因:撞名原先在**提交那一刻静默退号**(`测` → `测(1)`)—— 名字是被自动改掉的,
8428
+ * 用户看着输入框里写着「测」、落盘的却叫「测(1)」。改成先问一句:把撞上的名字与
8429
+ * 将要用的名字都摆出来,确认了才建 / 才恢复。
8430
+ *
8431
+ * 刻意做成**最小的一档**(`dsm-modal-sm`):这里只有「继续 / 取消」两个答案,没有别的
8432
+ * 字段可填 —— 用大弹窗会让人以为还要再改点什么。四个域 × 新建 / 恢复共八个入口共用它,
8433
+ * 所以文案走 `dup.*` 一组跨域词条,只有名词(记忆 / 场景 / 技能 / 子智能体)由调用方传。
8434
+ *
8435
+ * props:`t` / `kind`(已取过词的名词)/ `name`(撞上的名字)/ `finalName`(确认后实际
8436
+ * 用的名字)/ `action`(`'create'` | `'restore'`)/ `busy` / `onConfirm` / `onCancel`。
8437
+ */
8438
+ function DupNameDialog(props) {
8439
+ var t = props.t
8440
+ var restore = props.action === 'restore'
8441
+ return h(Modal, {
8442
+ key: 'dup-name', className: 'dsm-modal-sm',
8443
+ title: t('dup.title', { kind: props.kind }),
8444
+ closeLabel: t('btn.close'), onClose: props.onCancel,
8445
+ },
8446
+ h('p', { className: 'dsm-desc' }, t('dup.body', { kind: props.kind, name: props.name })),
8447
+ h('p', { className: 'dsm-help' }, t(restore ? 'dup.restore' : 'dup.create', { name: props.finalName })),
8448
+ h('div', { className: 'dsm-modal-actions' },
8449
+ h('button', { type: 'button', className: 'dsm-btn', disabled: props.busy === true, onClick: props.onConfirm }, t(restore ? 'dup.confirm.restore' : 'dup.confirm.create')),
8450
+ h('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: props.busy === true, onClick: props.onCancel }, t('btn.cancel'))))
8451
+ }
8452
+ /**
8453
+ * 创建 / 编辑弹窗的「关掉 ≠ 丢掉」(用户 2026-10-16:误点背板就得重头填一遍)。
8454
+ *
8455
+ * 只兜"手滑关掉"这一件事:关窗那一下把表单暂存,30 秒内重开就填回去。三条作废的路都
8456
+ * 是"已经不需要它了"—— 提交成功(成功那条路不走 onClose,所以根本不进这里)、过点时间、
8457
+ * 切走这一页(页面组件卸载)。不落盘、不跨重启,也就不需要「草稿」标记与清空键:
8458
+ * 一分钟内回来就是要它,超过 30 秒它自己没了。
8459
+ */
8460
+ var FORM_DRAFT_TTL_MS = 30000
8461
+ var formDrafts = {}
8462
+ function stashFormDraft(key, value) { formDrafts[key] = { at: Date.now(), value: value } }
8463
+ /** 取回即销账(一次性):没有或已过期都回 null,调用方照常按空表打开。 */
8464
+ function takeFormDraft(key) {
8465
+ var hit = formDrafts[key]
8466
+ if (!hit) return null
8467
+ delete formDrafts[key]
8468
+ return Date.now() - hit.at < FORM_DRAFT_TTL_MS ? hit.value : null
8469
+ }
8470
+ /** 页面组件挂一次:卸载(= 切到别的功能页)时把这一页的暂存全清掉。 */
8471
+ function useFormDraftScope(prefix) {
8472
+ react.useEffect(function () {
8473
+ return function () {
8474
+ Object.keys(formDrafts).forEach(function (key) { if (key.indexOf(prefix) === 0) delete formDrafts[key] })
8475
+ }
8476
+ }, [])
8477
+ }
8114
8478
  function Modal(props) {
8115
8479
  var ref = react.useRef(null)
8116
8480
  // 弹窗存在期间,令牌提示交给**弹窗自己**渲染:页面级那份会被遮罩压暗、盖在弹窗背后,
@@ -8289,21 +8653,44 @@ function enabledMemoryCount(rules) {
8289
8653
  */
8290
8654
  function runSerial(items, one) {
8291
8655
  return new Promise(function (resolve) {
8292
- var out = { done: 0, failed: 0, firstErr: null }
8656
+ // `results` 与 `items` **逐位对齐**(失败位是 null):回收站恢复要用成功那一条回传的
8657
+ // 最终名字来回执(退号恢复时它与回收站里的原名不同),只数成功个数是不够的。
8658
+ var out = { done: 0, failed: 0, firstErr: null, firstFail: null, results: [] }
8293
8659
  var step = function (i) {
8294
8660
  if (i >= items.length) { resolve(out); return }
8295
8661
  Promise.resolve().then(function () { return one(items[i]) }).then(function (res) {
8296
- if (res && res.ok === true) out.done += 1
8297
- else { out.failed += 1; if (!out.firstErr) out.firstErr = (res && res.error) || res || 'error' }
8662
+ if (res && res.ok === true) { out.done += 1; out.results.push(res) }
8663
+ else {
8664
+ out.failed += 1
8665
+ // `firstErr` 仍旧只存**错误文案**(部分失败的回执一直用它,换成整份结果会把
8666
+ // 措辞一起换掉 —— 词典里的模板句比 op 原文笼统);`firstFail` 另存整份失败结果,
8667
+ // 因为只有它带着 `code` / `params`(`error.dup.conflict` 的候选名字就在
8668
+ // `params.next` 里),回收站那条"撞名先问一句"要靠它。
8669
+ if (!out.firstErr) out.firstErr = (res && res.error) || res || 'error'
8670
+ if (!out.firstFail) out.firstFail = res || null
8671
+ out.results.push(null)
8672
+ }
8298
8673
  step(i + 1)
8299
8674
  }, function (e) {
8300
8675
  out.failed += 1; if (!out.firstErr) out.firstErr = errMsg(e)
8676
+ if (!out.firstFail) out.firstFail = null
8677
+ out.results.push(null)
8301
8678
  step(i + 1)
8302
8679
  })
8303
8680
  }
8304
8681
  step(0)
8305
8682
  })
8306
8683
  }
8684
+ /**
8685
+ * 这条失败结果是不是「撞名」(四个回收站统一的 `error.dup.conflict`)?
8686
+ *
8687
+ * 判据是**错误码**,不是文案 —— 文案会随语言和措辞变,码不会。`params.next` 必须有:
8688
+ * 弹窗要把它当作"确认后会叫什么"如实写出来,没有候选名字就退回普通报错,别弹一个
8689
+ * 说不出结果的确认窗。
8690
+ */
8691
+ function isDupConflict(res) {
8692
+ return !!(res && res.code === 'error.dup.conflict' && res.params && res.params.next)
8693
+ }
8307
8694
  /**
8308
8695
  * 每一页的回收站包含哪几组(`TrashModal` 的 `domain`)。
8309
8696
  * 两组的那两页 —— 技能页(技能 + 已移除的目录)、提示词页(提示词预设 + 快捷提示词)——
@@ -8362,6 +8749,10 @@ function enabledMemoryCount(rules) {
8362
8749
  // 行内二次确认。存「哪一组的哪一行在确认」,批量条的 key 为 null。
8363
8750
  const cfx = React.useState(null)
8364
8751
  const confirm = cfx[0], setConfirm = cfx[1]
8752
+ // 「已存在同名 X,是否恢复为 X(1)」确认窗(用户 2026-10-14)。null = 没开;开了就是
8753
+ // 一条 `{ group, items, kind, name, finalName }` —— 点「继续恢复」才带 `dedupe` 重发。
8754
+ const dfx = React.useState(null)
8755
+ const dupAsk = dfx[0], setDupAsk = dfx[1]
8365
8756
  // 「详情」只读预览(用户 2026-10-12:「回收站的每条信息应该增加一个详情按键,点开就能看
8366
8757
  // 这个信息是什么样子,不能更改内容,没有保存按键」)。null = 没开;开了就是一条
8367
8758
  // `{ group, id, name, meta, loading, error, files }`:
@@ -8417,6 +8808,18 @@ function enabledMemoryCount(rules) {
8417
8808
  // ── 各组清单(`TRASH_DOMAINS` 按页取用)。每组的 restore / purge 都是**全局单条 op**
8418
8809
  // (apiCall 直呼,信封原样返回)。──
8419
8810
  const noteDefault = function (item) { return t('trash.deletedAt', { time: item.deletedAt ? new Date(item.deletedAt).toLocaleString() : '—' }) }
8811
+ /**
8812
+ * 「已恢复」回执,四个可退号的域共用。
8813
+ *
8814
+ * 回执必须写**最终落盘的名字**:退号恢复(撞名 → `测(1)`)时它与回收站里显示的原名
8815
+ * 不同,写原名就是谎报 —— 用户回到列表里找不到「测」,因为磁盘上那份叫「测(1)」。
8816
+ * 判据用服务端回的 `renamed` 而不是自己比对 `res.name !== it.name`:服务端说没改名就是
8817
+ * 没改名,界面不猜。
8818
+ */
8819
+ const restoredText = function (it, res) {
8820
+ var name = (res && res.name) || it.name
8821
+ return t(res && res.renamed === true ? 'done.restoredAs' : 'done.restored', { name: name })
8822
+ }
8420
8823
  const memoryNote = function (item) {
8421
8824
  return sceneLabel(sceneList, item.group) + ' · '
8422
8825
  + t('memory.form.' + (item.form === 'bundle' ? 'bundle' : 'flat')) + ' · '
@@ -8449,9 +8852,10 @@ function enabledMemoryCount(rules) {
8449
8852
  //(用户 2026-10-12:「技能回收站应该和其他回收站一样灰色的不能选择」)。
8450
8853
  id: 'skills', title: t('trash.section.skills'), note: t('trash.section.skills.sub'), frozen: true,
8451
8854
  nameOf: function (it) { return it.name }, noteOf: noteDefault,
8452
- restore: function (it) { return apiCall('skill-trash-restore', { id: it.id }) },
8855
+ dupKind: 'dup.kind.skill',
8856
+ restore: function (it, dedupe) { return apiCall('skill-trash-restore', { id: it.id, dedupe: dedupe === true }) },
8453
8857
  purge: function (it) { return apiCall('skill-trash-delete', { id: it.id }) },
8454
- resultRestore: function (it) { return t('done.restored', { name: it.name }) },
8858
+ resultRestore: restoredText,
8455
8859
  resultPurge: function (it) { return t('trash.purged', { name: it.name }) },
8456
8860
  },
8457
8861
  dirs: {
@@ -8496,20 +8900,22 @@ function enabledMemoryCount(rules) {
8496
8900
  id: 'agents', title: null, note: t('trash.section.agents.sub'), frozen: true,
8497
8901
  op: 'subagent-trash-list', pick: function (res) { return res.trash },
8498
8902
  nameOf: function (it) { return it.name }, noteOf: noteDefault,
8499
- restore: function (it) { return apiCall('subagent-trash-restore', { id: it.id }) },
8903
+ dupKind: 'dup.kind.subagent',
8904
+ restore: function (it, dedupe) { return apiCall('subagent-trash-restore', { id: it.id, dedupe: dedupe === true }) },
8500
8905
  purge: function (it) { return apiCall('subagent-trash-delete', { id: it.id }) },
8501
8906
  // 启停状态没能写回(服务端 stateSyncError)必须说出来,不能只报「已恢复」。
8502
8907
  restoreWarn: true,
8503
- resultRestore: function (it) { return t('done.restored', { name: it.name }) },
8908
+ resultRestore: restoredText,
8504
8909
  resultPurge: function (it) { return t('trash.purged', { name: it.name }) },
8505
8910
  },
8506
8911
  scenes: {
8507
8912
  id: 'scenes', title: null, note: t('trash.section.scenes.sub'), frozen: false,
8508
8913
  op: 'scene-trash-list', pick: function (res) { return res.trash },
8509
8914
  nameOf: function (it) { return it.name }, noteOf: noteDefault,
8510
- restore: function (it) { return apiCall('scene-trash-restore', { id: it.id }) },
8915
+ dupKind: 'dup.kind.scene',
8916
+ restore: function (it, dedupe) { return apiCall('scene-trash-restore', { id: it.id, dedupe: dedupe === true }) },
8511
8917
  purge: function (it) { return apiCall('scene-trash-delete', { id: it.id }) },
8512
- resultRestore: function (it) { return t('done.restored', { name: it.name }) },
8918
+ resultRestore: restoredText,
8513
8919
  resultPurge: function (it) { return t('trash.purged', { name: it.name }) },
8514
8920
  },
8515
8921
  memories: {
@@ -8517,9 +8923,10 @@ function enabledMemoryCount(rules) {
8517
8923
  op: 'rules-trash-list', pick: function (res) { return res.entries },
8518
8924
  idOf: function (it) { return it.trashId },
8519
8925
  nameOf: function (it) { return it.name }, noteOf: memoryNote,
8520
- restore: function (it) { return apiCall('rules-restore', { trashId: it.trashId }) },
8926
+ dupKind: 'dup.kind.memory',
8927
+ restore: function (it, dedupe) { return apiCall('rules-restore', { trashId: it.trashId, dedupe: dedupe === true }) },
8521
8928
  purge: function (it) { return apiCall('rules-trash-remove', { trashId: it.trashId }) },
8522
- resultRestore: function (it) { return t('done.restored', { name: it.name }) },
8929
+ resultRestore: restoredText,
8523
8930
  resultPurge: function (it) { return t('memory.result.trashRemoved', { name: it.name }) },
8524
8931
  },
8525
8932
  }
@@ -8608,18 +9015,34 @@ function enabledMemoryCount(rules) {
8608
9015
  setConfirm(null)
8609
9016
  if (props.onReload) props.onReload()
8610
9017
  }
8611
- function runRestore(g, items) {
9018
+ /**
9019
+ * 恢复。`dedupe === true` = 用户在"已存在同名 X"确认窗里点过继续,这一次按退号的名字落盘。
9020
+ *
9021
+ * 撞名(用户 2026-10-14):服务端**先拒**并回 `error.dup.conflict` + `params.next`,
9022
+ * 这里认出它、把确认窗弹出来;用户点继续才带 `dedupe` 重来。批量恢复**不弹** ——
9023
+ * 一次勾了八条、其中三条撞名时弹三个窗没人受得了,那种情形照旧走"部分失败"的回执,
9024
+ * 用户看完自己挑单条重试。
9025
+ */
9026
+ function runRestore(g, items, dedupe) {
8612
9027
  setBusy(true)
8613
9028
  var warns = []
8614
9029
  runSerial(items, function (item) {
8615
- return g.restore(item).then(function (res) {
9030
+ return g.restore(item, dedupe).then(function (res) {
8616
9031
  if (g.restoreWarn && res) { var w = stateSyncWarn(t, res); if (w) warns.push(w) }
8617
9032
  return res
8618
9033
  })
8619
9034
  }).then(function (out) {
8620
9035
  setBusy(false)
9036
+ if (out.failed === 1 && items.length === 1 && dedupe !== true && isDupConflict(out.firstFail)) {
9037
+ setDupAsk({
9038
+ group: g.id, items: items, kind: g.dupKind,
9039
+ name: String(out.firstFail.params.name || items[0].name || ''),
9040
+ finalName: String(out.firstFail.params.next),
9041
+ })
9042
+ return
9043
+ }
8621
9044
  if (out.failed === 0) {
8622
- var head = items.length === 1 ? g.resultRestore(items[0]) : t('trash.restoreMany.result', { count: out.done })
9045
+ var head = items.length === 1 ? g.resultRestore(items[0], out.results[0]) : t('trash.restoreMany.result', { count: out.done })
8623
9046
  afterWrite(g, warns.length === 0, warns.length ? head + ' ' + warns.join(' ') : head)
8624
9047
  } else {
8625
9048
  setBusy(false)
@@ -8823,10 +9246,19 @@ function enabledMemoryCount(rules) {
8823
9246
  groupIds.map(function (gid) { return groupNode(GROUPS[gid], true) }))
8824
9247
  : React.createElement('div', { className: 'dsm-trash-pane-scroll' },
8825
9248
  groupIds.map(function (gid) { return groupNode(GROUPS[gid], false) }))))
8826
- if (!preview) return trashNode
9249
+ // 「已存在同名 X」确认窗:叠在回收站之上(`Modal` 自带 gateModalDepth 计数,与「详情」
9250
+ // 同一种叠法)。取消 → 回到回收站,勾选与滚动位置都不动,什么都没发生。
9251
+ const dupNode = dupAsk ? React.createElement(DupNameDialog, {
9252
+ key: 'dup-name', t: t, kind: t(dupAsk.kind),
9253
+ name: dupAsk.name, finalName: dupAsk.finalName, action: 'restore',
9254
+ busy: busy,
9255
+ onCancel: function () { setDupAsk(null) },
9256
+ onConfirm: function () { setDupAsk(null); runRestore(GROUPS[dupAsk.group], dupAsk.items, true) },
9257
+ }) : null
9258
+ if (!preview) return dupNode ? React.createElement(React.Fragment, null, trashNode, dupNode) : trashNode
8827
9259
  // 「详情」弹窗**叠在回收站上面**(`Modal` 自带 gateModalDepth 计数,叠弹窗是既有形态:
8828
9260
  // 导入套导出、自定义目录套选目录都是这样)。关掉它回到回收站,勾选与滚动位置都不动。
8829
- return React.createElement(React.Fragment, null, trashNode, previewModalNode())
9261
+ return React.createElement(React.Fragment, null, trashNode, previewModalNode(), dupNode)
8830
9262
  }
8831
9263
  /**
8832
9264
  * 勾选行(2026-10-11 切片五收编):本质就是行选择 RowSelect —— 导出列表、导入
@@ -8949,6 +9381,9 @@ function enabledMemoryCount(rules) {
8949
9381
  onDrop: function (e) { e.preventDefault(); if (e.currentTarget.classList) e.currentTarget.classList.remove("dsm-dropzone-active"); droppedFiles(e.dataTransfer).then(add) },
8950
9382
  },
8951
9383
  h("span", { className: "dsm-dropzone-title" }, t("upload.drop.title")),
9384
+ // 默认文案是**技能页**的(zip / 技能文件夹 / SKILL.md)—— 别的页必须显式传
9385
+ // hint(用户 2026-10-15:子智能体 / 提示词 / 记忆 / 会话四页都漏传过,
9386
+ // 拖拽区挂着 SKILL.md,而它们根本不收技能)。
8952
9387
  h("span", { className: "dsm-dropzone-copy" }, props.hint || t("upload.drop.copy"))),
8953
9388
  h("input", { ref: ref, type: "file", multiple: true, accept: props.accept || ".md,.zip", className: "dsm-hidden-input", onChange: function (e) { add(e.target.files); e.target.value = "" } }),
8954
9389
  // 已选文件:一行一个(名字 + 大小 + 移除),与技能页同一形态。
@@ -9088,12 +9523,20 @@ function enabledMemoryCount(rules) {
9088
9523
  return h('span', { className: 'dsm-tag' + tone, title: props.title }, props.text)
9089
9524
  }
9090
9525
 
9091
- // ---------- MCP JSON 导入 / 导出(0.15.0 A1)------------------------------------
9092
- // 服务端 `mcpm-import` / `mcpm-export` 早就实现完整,缺的只是界面与**格式转换**:
9093
- // 社区流通的块是 `{"mcpServers": {...}}`,而服务端只认数组或 `{rows:[...]}`,
9526
+ // ---------- MCP 导入 / 导出(0.15.0 A1;0.20.x 起收 dsh 补丁片段)------------------
9527
+ // 服务端 `mcpm-import` / `mcpm-export` 早就实现完整,缺的只是界面与**格式转换**。
9528
+ // 导入面(三条路):
9529
+ // · 社区流通的 `{"mcpServers": {...}}`
9530
+ // · 服务端自己的 `{rows: [...]}` / 裸数组
9531
+ // · **dsh 补丁片段**(用户 2026-10-15 指出这才是真实模板):`- insert:` 块 +
9532
+ // `name: '@deepseek-ai/dsh-mcp-client'` + `config:`,也就是 `cordis.patch.yml` 里
9533
+ // 本插件自己写出来的形状(生成侧见 src/mcp/patch-yaml.ts 的 buildInsertBlock)。
9094
9534
  // 且 `transport` 只要不是字面 `'stdio'` 就一律落 streamable-http、`serverName` 不合规矩
9095
9535
  // 就整条丢弃。这些判定必须在提交前做完并让用户看见,否则导入完只剩一串跳过原因。
9096
9536
 
9537
+ /** 本插件写入补丁时用的 loader 名(单一真相源在 src/host-names.ts 的 MCP_CLIENT_MODULE)。 */
9538
+ var MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client'
9539
+
9097
9540
  function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) }
9098
9541
 
9099
9542
  /** 名字净化:非 `[A-Za-z0-9_-]` → `-`,去空壳横线,截到 32(与服务端那条正则同口径)。 */
@@ -9126,6 +9569,31 @@ function enabledMemoryCount(rules) {
9126
9569
  return null
9127
9570
  }
9128
9571
 
9572
+ // ---------- dsh 补丁片段(YAML):解析在宿主侧 ------------------------------------
9573
+ // 客户端**不解析 YAML**:这一半跑在浏览器里(`window.__ModuleLoader__`),那里的
9574
+ // `require` 只认宿主注册过的浏览器模块(react / react-dom/client),`js-yaml` 取不到 ——
9575
+ // 把它当"本机大概装了就能用"是 0.20.1 那次改错的根因。补丁片段一律发给
9576
+ // `mcpm-parse-patch`(宿主侧 `parseRows`,纯字符串运算,与本插件读真身补丁同一口径)。
9577
+ /** 文本看着像不像 dsh 补丁片段(只用来在"读通了但没有条目"与"结构不认识"之间挑一句对症的报错)。 */
9578
+ function looksLikePatchText(raw) {
9579
+ return String(raw).indexOf('- insert:') >= 0 || String(raw).indexOf(MCP_CLIENT_MODULE) >= 0
9580
+ }
9581
+ /**
9582
+ * 宿主解析回来的条目 → `[名字, 配置]` 对。`config` 里的 serverName 优先,
9583
+ * 没有就退回 insert 子条目的 id(与宿主写补丁时的取名口径一致)。
9584
+ * 启停在子条目自己身上、或由后面的覆盖块改写,`parseRows` 已按文件顺序合好。
9585
+ */
9586
+ function patchEntriesToPairs(entries) {
9587
+ var list = Array.isArray(entries) ? entries : []
9588
+ return list.map(function (e) {
9589
+ var cfg = isPlainObject(e && e.config) ? Object.assign({}, e.config) : {}
9590
+ if (e && e.id !== undefined && cfg.id === undefined) cfg.id = String(e.id)
9591
+ if (e && e.disabled !== undefined && cfg.disabled === undefined) cfg.disabled = e.disabled
9592
+ var name = cfg.serverName !== undefined && String(cfg.serverName).trim() ? String(cfg.serverName) : String(e && e.id == null ? '' : e.id)
9593
+ return [name, cfg]
9594
+ })
9595
+ }
9596
+
9129
9597
  var MCP_CFG_KEYS = ['command', 'args', 'url', 'env', 'headers', 'disabled', 'type', 'transport', 'serverName', 'id', 'level']
9130
9598
 
9131
9599
  /** 一条配置 → 预览行(含服务端会拒绝的形状问题)。不取词:问题以 code 回给界面翻译。 */
@@ -9163,25 +9631,12 @@ function enabledMemoryCount(rules) {
9163
9631
  }
9164
9632
 
9165
9633
  /**
9166
- * 粘贴的任意 MCP JSON → `{ok, rows}` 或 `{ok:false, code}`。
9167
- * 识别顺序:`{mcpServers:{…}}` → `{rows:[…]}` → 裸数组 → 裸映射。
9634
+ * `[名字, 配置]` 对 → 预览行。JSON 那条路与宿主补丁解析那条路共用这一份判定
9635
+ * (重名退号 / 传输方式 / 致命项因此不会有两副口径)。
9168
9636
  */
9169
- function convertMcpJsonToRows(text) {
9170
- var parsed
9171
- try { parsed = JSON.parse(String(text == null ? '' : text).trim()) } catch (e) { return { ok: false, code: 'parse', error: errMsg(e) } }
9172
- if (!isPlainObject(parsed) && !Array.isArray(parsed)) return { ok: false, code: 'shape' }
9173
- var pairs = []
9174
- var map = isPlainObject(parsed) ? mcpServerMapOf(parsed) : null
9175
- if (map) {
9176
- Object.keys(map).forEach(function (k) { pairs.push([k, map[k]]) })
9177
- } else if (Array.isArray(parsed) || Array.isArray(parsed.rows)) {
9178
- var list = Array.isArray(parsed) ? parsed : parsed.rows
9179
- list.forEach(function (it, i) { pairs.push([it && it.serverName != null ? String(it.serverName) : String(i + 1), it]) })
9180
- } else if (looksLikeServerMap(parsed)) {
9181
- Object.keys(parsed).forEach(function (k) { pairs.push([k, parsed[k]]) })
9182
- } else return { ok: false, code: 'shape' }
9637
+ function mcpRowsFromPairs(pairs) {
9183
9638
  var used = {}
9184
- var rows = pairs.map(function (pair, i) {
9639
+ return pairs.map(function (pair, i) {
9185
9640
  var row = mcpRowFromConfig(pair[0], pair[1], i)
9186
9641
  // 批次内重名:serverName 全局唯一,第二条起服务端必然「serverName 已存在」。
9187
9642
  // 与其让用户导入一半才发现,不如现在就改名并在预览里标出来。
@@ -9202,11 +9657,72 @@ function enabledMemoryCount(rules) {
9202
9657
  row.id = row.payload.id || mcpImportIdOf(row.serverName)
9203
9658
  return row
9204
9659
  })
9205
- return { ok: true, rows: rows }
9206
9660
  }
9207
9661
 
9662
+ /**
9663
+ * 粘贴的文本按 **JSON** 试:`{mcpServers:{…}}` → `{rows:[…]}` → 裸数组 → 裸映射。
9664
+ * 返回 `{ok, rows}` / `{ok:false, code}`;**JSON 根本不成立时返回 null** —— 那份文本
9665
+ * 该走补丁片段那条路(宿主侧 `mcpm-parse-patch`,见上)。
9666
+ */
9667
+ function convertMcpJsonToRows(text) {
9668
+ var raw = String(text == null ? '' : text).trim()
9669
+ var parsed = null
9670
+ try { parsed = JSON.parse(raw) } catch (e) { return null }
9671
+ if (!isPlainObject(parsed) && !Array.isArray(parsed)) return { ok: false, code: 'shape' }
9672
+ var pairs = []
9673
+ var map = isPlainObject(parsed) ? mcpServerMapOf(parsed) : null
9674
+ if (map) {
9675
+ Object.keys(map).forEach(function (k) { pairs.push([k, map[k]]) })
9676
+ } else if (Array.isArray(parsed) || Array.isArray(parsed.rows)) {
9677
+ var list = Array.isArray(parsed) ? parsed : parsed.rows
9678
+ list.forEach(function (it, i) { pairs.push([it && it.serverName != null ? String(it.serverName) : String(i + 1), it]) })
9679
+ } else if (looksLikeServerMap(parsed)) {
9680
+ Object.keys(parsed).forEach(function (k) { pairs.push([k, parsed[k]]) })
9681
+ } else return { ok: false, code: 'shape' }
9682
+ return { ok: true, rows: mcpRowsFromPairs(pairs) }
9683
+ }
9684
+
9685
+ /**
9686
+ * 导入框的「dsh 标准模板」。打开弹窗即预填这份,用户直接改;删空后 placeholder
9687
+ * 显示同一份(useState 初值与 placeholder 同源,两条路径永远是这一份)。
9688
+ *
9689
+ * 用**真实**的补丁格式而不是社区的 `{"mcpServers": …}`(用户 2026-10-15:贴出
9690
+ * `cordis.patch.yml` 里的真身,"默认模板不对"):这正是 `buildInsertBlock`
9691
+ * (src/mcp/patch-yaml.ts)写进补丁的形状,也是宿主真正读的那份 —— 缩进、引号、
9692
+ * `config:` 的层级都按生成器逐字取。两条示例覆盖两种传输方式(stdio 看 command、
9693
+ * http 看 url;判传输不看 `transport` 字段,见 mcpRowFromConfig 注释),
9694
+ * serverName 带 example- 前缀就是让人一眼看出"这是模板,改我"。
9695
+ *
9696
+ * 顶层的 `# dsh-plugin-tool-management:server:<id>` 标记**不写进模板**:那是本插件
9697
+ * 自己落盘时加的,用户手写的片段不需要它(解析时按注释忽略)。
9698
+ */
9699
+ var MCP_IMPORT_TEMPLATE = [
9700
+ '- insert:',
9701
+ ' - id: mcp-example',
9702
+ " name: '" + MCP_CLIENT_MODULE + "'",
9703
+ ' config:',
9704
+ ' serverName: "example"',
9705
+ ' transport: "stdio"',
9706
+ ' command: "npx"',
9707
+ ' args:',
9708
+ ' - "-y"',
9709
+ ' - "your-package"',
9710
+ ' env:',
9711
+ ' "API_KEY": ""',
9712
+ '- insert:',
9713
+ ' - id: mcp-example-remote',
9714
+ " name: '" + MCP_CLIENT_MODULE + "'",
9715
+ ' config:',
9716
+ ' serverName: "example-remote"',
9717
+ ' transport: "streamable-http"',
9718
+ ' url: "https://your-server.com/mcp"',
9719
+ ].join('\n')
9720
+
9208
9721
  function McpJsonImportModal(props) {
9209
9722
  var t = props.t
9723
+ // 打开时框子是空的(用户 2026-10-15 回退上一版的"预填模板":预填让人以为已经填好了,
9724
+ // 而且大多数人是要粘贴自己那份,不是改这份示例)。模板只留在 placeholder 里 ——
9725
+ // 空框显示它,一敲字就没了,两处内容同源(MCP_IMPORT_TEMPLATE)。
9210
9726
  var ts = react.useState(''); var text = ts[0], setText = ts[1]
9211
9727
  // 解析走防抖值:整份 JSON 在每次按键上都重解析没有意义。
9212
9728
  var dq = useDebouncedValue(text)
@@ -9215,8 +9731,26 @@ function enabledMemoryCount(rules) {
9215
9731
  var cs = react.useState('skip'); var conflict = cs[0], setConflict = cs[1]
9216
9732
  var os = react.useState({}); var off = os[0], setOff = os[1]
9217
9733
  var fileRef = react.useRef(null)
9218
- var converted = react.useMemo(function () {
9219
- return String(dq || '').trim() ? convertMcpJsonToRows(dq) : null
9734
+ // 预览结果。JSON 那条路在浏览器里同步算完;补丁片段(YAML)得问宿主,所以这份是
9735
+ // effect 的产物 —— 换文本时先落一个 parsing 占位,回来再覆盖(alive 挡旧请求的迟到响应)。
9736
+ var vs = react.useState(null); var converted = vs[0], setConverted = vs[1]
9737
+ react.useEffect(function () {
9738
+ var raw = String(dq || '').trim()
9739
+ if (!raw) { setConverted(null); return }
9740
+ var fromJson = convertMcpJsonToRows(raw)
9741
+ if (fromJson) { setConverted(fromJson); return }
9742
+ var alive = true
9743
+ setConverted({ ok: false, code: 'parsing' })
9744
+ apiCall('mcpm-parse-patch', { text: raw }).then(function (res) {
9745
+ if (!alive) return
9746
+ if (!res || !res.ok) { setConverted({ ok: false, code: 'parse', error: (res && res.error) || '' }); return }
9747
+ var patchRows = mcpRowsFromPairs(patchEntriesToPairs(res.rows))
9748
+ // 读通了却一条 MCP 也没有(整份文件只有别的 loader)→ 交给「没有条目」那一句。
9749
+ if (patchRows.length) setConverted({ ok: true, rows: patchRows })
9750
+ else if (looksLikePatchText(raw)) setConverted({ ok: true, rows: [] })
9751
+ else setConverted({ ok: false, code: 'shape' })
9752
+ }).catch(function (e) { if (alive) setConverted({ ok: false, code: 'parse', error: errMsg(e) }) })
9753
+ return function () { alive = false }
9220
9754
  }, [dq])
9221
9755
  var rows = converted && converted.ok ? converted.rows : []
9222
9756
  var chosen = rows.filter(function (r) { return !r.fatal && off[r.key] !== true })
@@ -9256,15 +9790,15 @@ function enabledMemoryCount(rules) {
9256
9790
  }
9257
9791
 
9258
9792
  var preview = !converted ? null
9793
+ : converted.code === 'parsing' ? h('div', { className: 'dsm-help' }, t('mcp.importJson.parsing'))
9259
9794
  : !converted.ok ? h('div', { className: 'dsm-feedback dsm-error', role: 'alert' },
9260
9795
  converted.code === 'parse' ? t('mcp.importJson.parseError', { error: converted.error }) : t('mcp.importJson.parseError.shape'))
9261
9796
  : !rows.length ? h('div', { className: 'dsm-empty' }, t('mcp.importJson.empty'))
9262
9797
  : h(react.Fragment, null,
9263
9798
  rows.length > 20 ? h('div', { className: 'dsm-help' }, t('mcp.importJson.bigBatch', { count: rows.length })) : null,
9264
- h('div', { className: 'dsm-imp-head' },
9265
- h('span', null, ''),
9266
- h('span', null, t('mcp.importJson.preview.name')),
9267
- h('span', null, t('mcp.importJson.preview.state'))),
9799
+ // 预览表头整行删除(用户 2026-10-15):「服务名与连接参数」「状态」两个
9800
+ // 表头 label 是给三列网格看的,删掉后每行自带 transport 标签与端点,
9801
+ // 信息不丢;勾选框随之改为随行内容垂直居中(.dsm-imp-row align-items:center)。
9268
9802
  h('div', { className: 'dsm-imp-list' }, rows.map(function (r) {
9269
9803
  var cf = conflictOf(r)
9270
9804
  var endpoint = r.transport === 'stdio' ? [r.command].concat(r.args).join(' ') : r.url
@@ -9283,26 +9817,30 @@ function enabledMemoryCount(rules) {
9283
9817
  cf ? h('span', { className: 'dsm-tag' + (cf === 'skip' ? ' dsm-tag-off' : '') }, t('mcp.importJson.will.' + cf)) : null,
9284
9818
  r.secretCount ? h('span', { className: 'dsm-tag' }, t('mcp.importJson.preview.secret', { count: r.secretCount })) : null))
9285
9819
  })),
9820
+ // 冲突处理上移到预览之前(见 dsm-imp-conflict),页脚只剩计数。
9286
9821
  h('div', { className: 'dsm-imp-foot' },
9287
- h('label', { className: 'dsm-imp-radio' },
9288
- h('input', { type: 'radio', name: 'dsm-imp-conflict', checked: conflict === 'skip', onChange: function () { setConflict('skip') } }),
9289
- t('mcp.importJson.conflict.skip')),
9290
- h('label', { className: 'dsm-imp-radio' },
9291
- h('input', { type: 'radio', name: 'dsm-imp-conflict', checked: conflict === 'overwrite', onChange: function () { setConflict('overwrite') } }),
9292
- t('mcp.importJson.conflict.overwrite')),
9293
9822
  h('span', { className: 'dsm-imp-count' }, t('mcp.importJson.chosen', { count: chosen.length }))))
9294
9823
 
9295
9824
  return h(Modal, { className: 'dsm-modal-lg', title: t('mcp.importJson.title'), closeLabel: t('btn.close'), onClose: props.onClose },
9296
9825
  h('div', { className: 'dsm-form' },
9297
- h('p', { className: 'dsm-desc' }, t('mcp.importJson.hint')),
9826
+ h('p', { className: 'dsm-help' }, t('mcp.importJson.hint')),
9298
9827
  h('textarea', {
9299
- className: 'dsm-control dsm-textarea-md', rows: 8, value: text, spellCheck: false,
9300
- placeholder: t('mcp.importJson.placeholder'), onChange: function (e) { setText(e.target.value) },
9828
+ className: 'dsm-control dsm-textarea-md', rows: 14, value: text, spellCheck: false,
9829
+ placeholder: MCP_IMPORT_TEMPLATE, onChange: function (e) { setText(e.target.value) },
9301
9830
  }),
9302
9831
  h('div', { className: 'dsm-upload-choices' },
9303
9832
  h('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () { if (fileRef.current) fileRef.current.click() } }, t('btn.file.pick')),
9304
9833
  h('span', { className: 'dsm-upload-divider' }, t('mcp.importJson.orPaste')),
9305
- h('input', { ref: fileRef, type: 'file', accept: '.json', className: 'dsm-hidden-input', onChange: function (e) { onPickFile(e.target.files); e.target.value = '' } })),
9834
+ h('input', { ref: fileRef, type: 'file', accept: '.json,.yml,.yaml,.txt', className: 'dsm-hidden-input', onChange: function (e) { onPickFile(e.target.files); e.target.value = '' } })),
9835
+ // 重名处理(用户 2026-10-15 两连):从预览底部上移到预览之前 —— 埋在列表
9836
+ // 脚下的二选一,导入前根本看不见;radio 换成「提示词类型」同款分段控件 ——
9837
+ // 排他二选一的正解控件,恒有一颗按下,没有「都不选」这一态。
9838
+ rows.length ? h('div', { className: 'dsm-imp-conflict' },
9839
+ // 「重名时」文字 label 删除(用户 2026-10-15):两颗互斥按钮自己说清
9840
+ // 「跳过 / 覆盖」,前置标签是废话;可访问性由 aria-label 承担。
9841
+ h('div', { className: 'dsm-form-seg', role: 'group', 'aria-label': t('mcp.importJson.conflict.label') },
9842
+ h('button', { key: 'skip', type: 'button', 'aria-pressed': conflict === 'skip' ? 'true' : 'false', onClick: function () { setConflict('skip') } }, t('mcp.importJson.conflict.skip')),
9843
+ h('button', { key: 'ov', type: 'button', 'aria-pressed': conflict === 'overwrite' ? 'true' : 'false', onClick: function () { setConflict('overwrite') } }, t('mcp.importJson.conflict.overwrite')))) : null,
9306
9844
  rows.some(function (r) { return r.secretCount > 0 }) ? h(Notice, { kind: 'warn', text: t('mcp.importJson.secretHint') }) : null,
9307
9845
  preview,
9308
9846
  result ? h('div', { className: 'dsm-feedback' + (result.ok ? (result.warning ? ' dsm-warning' : '') : ' dsm-error'), role: 'status' },
@@ -9393,10 +9931,22 @@ function enabledMemoryCount(rules) {
9393
9931
  var es = react.useState({}), expanded = es[0], setExpanded = es[1];
9394
9932
  var ms = react.useState(null), modal = ms[0], setModal = ms[1];
9395
9933
  var rs = react.useState(null), result = rs[0], setResult = rs[1];
9396
- var fms = react.useState({ root: "dsh", name: "", description: "", body: "" }), form = fms[0], setForm = fms[1];
9934
+ var fms = react.useState({ root: "dsh", name: "", description: "", body: "", files: [] }), form = fms[0], setForm = fms[1];
9935
+ // 「已存在同名技能」确认弹窗(用户 2026-10-14):`{ name, finalName }` 或 null。
9936
+ // 撞名不再静默退号(`foo` → `foo-1`),先问一句再建。
9937
+ var dups = react.useState(null), dupAsk = dups[0], setDupAsk = dups[1];
9397
9938
  var us = react.useState(null), upload = us[0], setUpload = us[1];
9398
9939
  var ds = react.useState(null), detail = ds[0], setDetail = ds[1];
9399
9940
  var cs = react.useState({ path: "", label: "" }), customForm = cs[0], setCustomForm = cs[1];
9941
+ // 技能附件(用户 2026-10-16 裁定 A:技能不止名字 + 简介 + 正文,`references/`、`lib/`
9942
+ // 这类参考资料也要能在界面里直接给,而且**能在界面里写**,不只是从本机挑文件):
9943
+ // 条目挂在 `form.files` 上随创建一起提交,形状 `{ path, size, data(base64), text? }`。
9944
+ // `attachError` 是附件区自己的一行报错(超限 / 撞路径),`draftFile` 是「新建文件」小窗的载荷。
9945
+ var afs = react.useState(null), attachError = afs[0], setAttachError = afs[1];
9946
+ var dfs = react.useState(null), draftFile = dfs[0], setDraftFile = dfs[1];
9947
+ var attachInputRef = react.useRef(null);
9948
+ // 切走这一页 → 「创建技能」关窗时那份暂存作废(见 42-shared-ui.js 的 useFormDraftScope)。
9949
+ useFormDraftScope('skill:');
9400
9950
  // 组内「启用的排前面」的定序表(见 42-shared-ui.js 的 rankedFirst):整页一张,
9401
9951
  // 各来源卡片共用 —— 技能 key 带着来源前缀,跨卡片不会撞。
9402
9952
  var skillRank = useRank();
@@ -9457,7 +10007,52 @@ function enabledMemoryCount(rules) {
9457
10007
  setResult({ ok: false, text: (res && res.error) || t("error.action", { error: "unknown" }) });
9458
10008
  }).catch(function (error) { setResult({ ok: false, text: t("error.action", { error: translateError(t, error) }) }); }).finally(function () { setBusy(false); });
9459
10009
  }
9460
- function submitCreate() { post("/create", form, null).then(function (data) { setModal(null); setForm({ root: "dsh", name: "", description: "", body: "" }); setResult(function (prev) { return Object.assign({ ok: true, warning: false }, prev || {}, { text: t("done.created", { name: data.name }) }); }); }).catch(swallowPostError); }
10010
+ /**
10011
+ * 客户端侧的 `toKebab` 镜像(宿主实现在 `src/skills/frontmatter.ts`)。
10012
+ *
10013
+ * 为什么界面里要再写一份:技能名落盘前会被宿主归一化(`Foo Bar` → `foo-bar`),而
10014
+ * 「新建撞名先问一句」必须在**提交之前**就知道归一化后的名字 —— 否则问出来的
10015
+ * 「`Foo Bar-1`」和真正写下去的「`foo-bar-1`」对不上,等于换了个地方骗人。镜像宿主
10016
+ * 谓词在本项目有先例(隔壁 `isValidSceneSegment`)。宿主改口径这里要一起改;不一致
10017
+ * 的后果只是"提示里的名字和实际不符",不是写坏数据 —— 服务端那道同名闸仍在。
10018
+ */
10019
+ function kebabName(raw) {
10020
+ var s = String(raw == null ? "" : raw).trim();
10021
+ if (s === "") return "";
10022
+ return s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
10023
+ .replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "-")
10024
+ .replace(/-+/g, "-").replace(/^-+|-+$/g, "");
10025
+ }
10026
+ /**
10027
+ * 技能页「创建技能」的提交(用户 2026-10-14 第二轮):**先问一句,再退号**。
10028
+ *
10029
+ * 判据只看**落点那一个来源**里的条目名 —— `skill-create` 的同名检查也是按目标根做的
10030
+ * (`safeExistingEntryPaths(root, name)`),别的来源里有同名是另一回事(那个由"同名
10031
+ * 首选"管,不是冲突)。比的是条目名(文件 / 目录名)而不是 frontmatter 里的展示名,
10032
+ * 与服务端同口径。
10033
+ *
10034
+ * 退号用 `-1`(不是 `(1)`):技能名必须过 KEBAB_RE,全角括号会被 `toKebab` 抹成 `-`,
10035
+ * 写出来本来就是 `foo-1`,不如一开始就按这个写。
10036
+ */
10037
+ function submitCreate() {
10038
+ var typed = String(form.name || "").trim();
10039
+ var normalized = kebabName(typed);
10040
+ var target = allRoots.filter(function (r) { return String(r.key) === String(form.root); })[0];
10041
+ var taken = {};
10042
+ ((target && target.skills) || []).forEach(function (sk) { taken[String(sk.name)] = true; });
10043
+ var finalName = uniqueName(normalized, taken, "dash");
10044
+ if (finalName !== normalized) { setDupAsk({ name: normalized, finalName: finalName }); return; }
10045
+ createSkill(finalName);
10046
+ }
10047
+ /** 真正落盘那一步(名字已定)。`name` 来自 `submitCreate` 或确认弹窗。 */
10048
+ function createSkill(name) {
10049
+ // 附件只带 path + data:`text` 是界面里再编辑用的缓冲,一起发等于把同一份内容传两遍。
10050
+ var payload = Object.assign({}, form, {
10051
+ name: name,
10052
+ files: (form.files || []).map(function (f) { return { path: f.path, data: f.data } }),
10053
+ });
10054
+ post("/create", payload, null).then(function (data) { setModal(null); setForm({ root: "dsh", name: "", description: "", body: "", files: [] }); setAttachError(null); setResult(function (prev) { return Object.assign({ ok: true, warning: false }, prev || {}, { text: t("done.created", { name: data.name }) }); }); }).catch(swallowPostError);
10055
+ }
9461
10056
  function selectUploadFiles(files) { pickerOpenRef.current = false; var selected = inspectUploadSelection(files); if (!selected) return; if (selected.error) { setUpload(null); setResult({ ok: false, text: translateError(t, selected.error) }); return; } setUpload(selected); setResult(null); }
9462
10057
  /** D14:导出条目 = 按**来源文件夹**分组,组内列出该文件夹里的全部技能(含同名被遮蔽的)。 */
9463
10058
  function exportItems() {
@@ -9523,15 +10118,122 @@ function enabledMemoryCount(rules) {
9523
10118
  targets[""] = all;
9524
10119
  return { filtered: filtered, targets: targets };
9525
10120
  }, [roots, dq, activeSource, t]);
9526
- var createRoots = allRoots.filter(function (root) { return root.mutable === true; }), createOptions = createRoots.map(function (root) { return { value: root.key, label: rootDisplayName(t, root) }; }); if (!createOptions.length) createOptions.push({ value: "hub", label: t("root.hub") });
9527
- // 新建技能的默认落点:hub(`tool-management/skills/`)。
9528
- // 用户在技能页筛选了某个来源时,优先用那个来源;两者都不可用时退回 dsh。
10121
+ // 创建位置候选:只要**用户级**可写根(DSH 技能 / 导入技能)。项目级来源
10122
+ // (`project-dsh:<hash>`,标签「项目 DSH · <仓库名>」)排除在外 —— 用户裁定
10123
+ // 2026-10-15「新增技能默认 DSH 技能;导入技能功能才是导入技能目录」,新建落点
10124
+ // 只在用户级目录里选。更硬的理由是闭环:`skill_manager_save` 的改分支
10125
+ // (`skill-update` op)只认 hub + dsh 两个可写根,建在项目目录里的技能模型侧
10126
+ // 「再存一次」会被 error.update.notOwnRoot 拒;模型工具没有 root 参数、永远落
10127
+ // dsh,这个下拉是唯一能把技能建进项目根的入口。项目来源本身不动 —— 来源卡片
10128
+ // 照旧列它(发现 / 启停 / 删除),只是不再作为「创建位置」候选。
10129
+ var createRoots = allRoots.filter(function (root) { return root.mutable === true && root.scope !== "project"; }), createOptions = createRoots.map(function (root) { return { value: root.key, label: rootDisplayName(t, root) }; }); if (!createOptions.length) createOptions.push({ value: "hub", label: t("root.hub") });
10130
+ // 新建技能的默认落点:DSH 技能(`~/.dsh/skills/`,用户裁定 2026-10-15)。
10131
+ // v0.4 起这里默认 hub,现在改回 dsh —— hub(`tool-management/skills/`)只留给「导入技能」
10132
+ // 功能(import / upload 走 `skillCreateRootPath()`,不经这一处)。与 `skill-create` 的
10133
+ // op 默认值、core.js 的 createSkill 默认值同口径:三处必须一致,否则人点界面和模型调用
10134
+ // 会把同一个「新建技能」落到两个不同的根。
10135
+ // 用户在技能页筛选了某个来源时,优先用那个来源;筛选来源与 dsh 都不可用时退回 hub。
10136
+ // 筛到**项目来源**时不算数(项目来源已不在 createRoots 里,见上方):那时仍落 dsh ——
10137
+ // 新建技能的落点按裁定只在用户级目录里选,不跟着页面筛选拐进项目仓库。
9529
10138
  function defaultCreateRoot() {
9530
10139
  if (createRoots.some(function (root) { return root.key === activeSource; })) return activeSource;
9531
- if (createRoots.some(function (root) { return root.key === "hub"; })) return "hub";
9532
- return "dsh";
10140
+ if (createRoots.some(function (root) { return root.key === "dsh"; })) return "dsh";
10141
+ return "hub";
10142
+ }
10143
+ function openCreate() {
10144
+ // 30 秒内重开 = 把刚才关掉的那份填回来(含附件);落点仍按当前筛选重算。
10145
+ var draft = takeFormDraft('skill:create')
10146
+ if (draft) { setForm(Object.assign({}, draft, { root: defaultCreateRoot() })); setAttachError(null); setModal("create"); return }
10147
+ setForm(Object.assign({}, form, { root: defaultCreateRoot(), files: [] })); setAttachError(null); setModal("create");
10148
+ }
10149
+ /** 关掉 ≠ 丢掉:只有 onClose 这条路存草稿(提交成功是就地 setModal(null),不进这里)。 */
10150
+ function dismissCreate() { stashFormDraft('skill:create', form); setModal(null); }
10151
+ /** 并入清单:SKILL.md 与超限的当场拦下(服务端还会再拦一次),同路径的后者不盖前者。 */
10152
+ function addAttachments(list) {
10153
+ var accepted = [], skippedDup = ""
10154
+ var lower = {}
10155
+ ;(form.files || []).forEach(function (f) { lower[String(f.path).toLowerCase()] = true })
10156
+ list.forEach(function (item) {
10157
+ if (/(^|\/)skill\.md$/i.test(item.path)) { setAttachError(t('skill.attach.reserved', { path: item.path })); return }
10158
+ if (item.size > MAX_UPLOAD_ENTRY_BYTES) { setAttachError(t('skill.attach.tooLarge', { name: item.path, limit: 32 })); return }
10159
+ var key = String(item.path).toLowerCase()
10160
+ if (lower[key]) { skippedDup = item.path; return }
10161
+ lower[key] = true
10162
+ accepted.push(item)
10163
+ })
10164
+ if (skippedDup) setAttachError(t('skill.attach.dup', { path: skippedDup }))
10165
+ if (!accepted.length) return
10166
+ setForm(function (prev) { return Object.assign({}, prev, { files: (prev.files || []).concat(accepted) }) })
10167
+ }
10168
+ function pickAttachments() { if (attachInputRef.current) attachInputRef.current.click() }
10169
+ function onAttachmentsPicked(ev) {
10170
+ var picked = Array.prototype.slice.call((ev.target && ev.target.files) || [])
10171
+ if (ev.target) ev.target.value = '' // 允许连续选中同一个文件
10172
+ if (!picked.length) return
10173
+ setAttachError(null)
10174
+ Promise.all(picked.map(function (file) {
10175
+ return file.arrayBuffer().then(function (buf) {
10176
+ return { path: uploadFilePath(file), size: buf.byteLength, data: bytesToBase64(buf) }
10177
+ })
10178
+ })).then(addAttachments).catch(function (error) { setAttachError(translateError(t, error)) })
10179
+ }
10180
+ function dropAttachment(index) {
10181
+ setForm(function (prev) {
10182
+ var next = (prev.files || []).slice()
10183
+ next.splice(index, 1)
10184
+ return Object.assign({}, prev, { files: next })
10185
+ })
10186
+ setAttachError(null)
10187
+ }
10188
+ /** 打开「新建附件」小窗:`index` 为数字 = 改已有那一条(只改界面里写的那份),null = 新增。 */
10189
+ function openNewFile(index) {
10190
+ var cur = typeof index === "number" ? (form.files || [])[index] : null
10191
+ if (cur && cur.text === undefined) return
10192
+ setDraftFile({ index: typeof index === "number" ? index : null, path: cur ? cur.path : "", content: cur ? String(cur.text || "") : "", error: null })
10193
+ }
10194
+ function submitNewFile() {
10195
+ var path = String(draftFile.path || '').trim().replace(/\\/g, '/')
10196
+ var content = String(draftFile.content || '')
10197
+ if (!path || path.charAt(0) === '/' || path.slice(-1) === '/' || /(^|\/)(\.\.?)($|\/)/.test(path)) {
10198
+ setDraftFile(Object.assign({}, draftFile, { error: t('skill.attach.badpath') })); return
10199
+ }
10200
+ var clash = (form.files || []).some(function (f, i) {
10201
+ return i !== draftFile.index && String(f.path).toLowerCase() === path.toLowerCase()
10202
+ })
10203
+ if (clash) { setDraftFile(Object.assign({}, draftFile, { error: t('skill.attach.dup', { path: path }) })); return }
10204
+ var bytes = new TextEncoder().encode(content)
10205
+ var entry = { path: path, size: bytes.length, data: bytesToBase64(bytes.buffer), text: content }
10206
+ setForm(function (prev) {
10207
+ var list = (prev.files || []).slice()
10208
+ if (typeof draftFile.index === "number") list.splice(draftFile.index, 1, entry)
10209
+ else list.push(entry)
10210
+ return Object.assign({}, prev, { files: list })
10211
+ })
10212
+ setDraftFile(null)
10213
+ setAttachError(null)
10214
+ }
10215
+ /** 附件区:一行「标签 + 两颗动作键」,下面只在有条目时才占高度(清单封顶滚动,空着不占)。 */
10216
+ function renderAttachField() {
10217
+ var list = form.files || []
10218
+ return h("div", { className: "dsm-field" },
10219
+ h("div", { className: "dsm-budget-meta" },
10220
+ h("span", { className: "dsm-label" }, list.length ? t("skill.attach.label") + " · " + list.length : t("skill.attach.label")),
10221
+ h("span", { className: "dsm-attach-actions" },
10222
+ h("button", { type: "button", className: "dsm-btn dsm-btn-quiet", onClick: function () { openNewFile(null) } }, t("skill.attach.new")),
10223
+ h("button", { type: "button", className: "dsm-btn dsm-btn-quiet", onClick: pickAttachments }, t("skill.attach.pick")))),
10224
+ h("input", { ref: attachInputRef, type: "file", multiple: true, className: "dsm-hidden-input", onChange: onAttachmentsPicked }),
10225
+ list.length
10226
+ ? h("div", { className: "dsm-attach-list dsm-attach-cap" }, list.map(function (f, i) {
10227
+ return h("div", { key: i + ":" + f.path, className: "dsm-file", title: f.path },
10228
+ h("span", { className: "dsm-file-kind", "aria-hidden": "true" }, f.text === undefined ? "FILE" : "NEW"),
10229
+ h("span", { className: "dsm-file-name" }, f.path),
10230
+ h("span", { className: "dsm-file-meta" }, fmtBytes(f.size)),
10231
+ f.text === undefined ? null : h("button", { type: "button", className: "dsm-btn dsm-btn-quiet", onClick: function () { openNewFile(i) } }, t("skill.attach.edit")),
10232
+ h("button", { type: "button", className: "dsm-file-remove", "aria-label": t("skill.attach.remove") + " " + f.path, onClick: function () { dropAttachment(i) } }, "×"))
10233
+ }))
10234
+ : null,
10235
+ attachError ? h("div", { className: "dsm-feedback dsm-error", role: "alert" }, attachError) : null)
9533
10236
  }
9534
- function openCreate() { setForm(Object.assign({}, form, { root: defaultCreateRoot() })); setModal("create"); }
9535
10237
  // 项目作用域的来源不一定带 projectName / projectRoot(hub「导入技能」就没有),
9536
10238
  // 旧实现直接拼字符串会把字面量 "undefined" 显示到回收站里。
9537
10239
  function trashRootLabel(item) {
@@ -9591,7 +10293,18 @@ function enabledMemoryCount(rules) {
9591
10293
  // 锁定期间**只有「详情」还活着**:它是只读动作;其余三颗都会改配置,一律置灰
9592
10294
  //(用户 2026-10-12:「锁定情况下删除键应该变灰且不能点击」—— 与子智能体页锁定态同长相)。
9593
10295
  var actions = [];
9594
- if (canToggle && skill.shadowedBy) actions.push(h("button", { key: "activate", type: "button", className: "dsm-btn dsm-btn-quiet", disabled: busy || anyLocked, title: t("btn.activate.title"), onClick: function () { activateShadowed(root, skill); } }, t("btn.activate")));
10296
+ /**
10297
+ * 「启用这个」写的是「同名技能该用哪个**来源**」的首选表(`/prefer` →
10298
+ * `preferredSkills[名] = 来源 key`,`shadowedBy.root === 本行来源` 即表示撞名发生在
10299
+ * **同一个来源目录内部**(两个文件夹都写着 `name: X`)—— 那种情况下首选表怎么指都
10300
+ * 指不出区别(值就是来源 key,两份在同一个来源里),按下去只会回一句"已改为生效"、
10301
+ * 实际什么都没变(用户 2026-10-14 实测到的那颗假按钮)。
10302
+ *
10303
+ * 「被覆盖」这个标记照旧保留(同来源里的落选那份确实不会被加载,如实说比不说好),
10304
+ * 收掉的只是那颗**做不到它承诺的事**的按钮。判据是来源 key,不是文案。
10305
+ */
10306
+ var canPrefer = !!skill.shadowedBy && skill.shadowedBy.root !== root.key;
10307
+ if (canToggle && canPrefer) actions.push(h("button", { key: "activate", type: "button", className: "dsm-btn dsm-btn-quiet", disabled: busy || anyLocked, title: t("btn.activate.title"), onClick: function () { activateShadowed(root, skill); } }, t("btn.activate")));
9595
10308
  if (skill.preferred === true) actions.push(h("button", { key: "unprefer", type: "button", className: "dsm-btn dsm-btn-quiet", disabled: busy || anyLocked, title: t("btn.unprefer.title"), onClick: function () { clearPreferred(root, skill); } }, t("btn.unprefer")));
9596
10309
  actions.push(h("button", { key: "detail", type: "button", className: "dsm-btn dsm-btn-quiet", onClick: function () { openDetail(root, skill); } }, t("btn.detail")));
9597
10310
  if (root.deletable === true) actions.push(h("button", { key: "trash", type: "button", className: "dsm-btn dsm-btn-quiet dsm-btn-danger", disabled: busy || anyLocked, onClick: function () { setModal({ type: "trash-confirm", root: root.key, name: skill.name }); } }, t("btn.trash")));
@@ -9627,16 +10340,16 @@ function enabledMemoryCount(rules) {
9627
10340
  // 只展开、不收起 —— 两个方向(全选 / 取消全选)都要能看见结果,收起是点卡片头的事。
9628
10341
  if (bulk.targets.length) acts.push(h("button", { key: "all", type: "button", className: "dsm-btn dsm-btn-quiet dsm-btn-bulk", disabled: busy || anyLocked, title: t("source.bulk.title"), onClick: function () { setExpanded(Object.assign({}, expanded, { [root.key]: true })); submitBulk(bulk.targets, !bulk.on, root.key); } }, bulkPair(bulk.on ? t("bulk.unselectAll") : t("bulk.selectAll"), bulk.on ? t("bulk.selectAll") : t("bulk.unselectAll"))));
9629
10342
  if (showSwitch) {
9630
- // 「移除来源」只留图标(用户裁定 2026-09-17):它是低频的破坏性操作,却和「全选」
9631
- // 并列、还带红框描边 —— 于是整行的视觉重心落在它身上,四字宽度也占地方。
9632
- // 图标按钮必须自带 title + aria-label(没有可见文字);点击照旧弹确认弹窗,
9633
- // 那里仍是带文字的红色「移除来源」,破坏性操作的说明没有丢。
9634
- if (root.removable === true) acts.push(h("button", { key: "rm", type: "button", className: "dsm-btn dsm-btn-quiet dsm-btn-danger dsm-btn-icon", disabled: busy || anyLocked, title: t("btn.source.remove.title"), "aria-label": t("btn.source.remove.title"), onClick: function () { setModal({ type: "source-remove-confirm", key: root.key, name: displayName }); } }, h(TrashIcon, { size: 14 })));
9635
10343
  // 开关来源时**顺带把卡片展开**(用户裁定 2026-09-17),与旁边那颗「全选」同口径:
9636
10344
  // 启停的结果就是下面这些技能行(开启 = 整目录可用了,关闭 = 逐行变停用),卡片收着
9637
10345
  // 的话点完只剩标签从「已停用」变「已启用」,目录里到底有什么、哪些真的可用了看不见。
9638
10346
  // 只展开、不收起 —— 两个方向都要能看见结果,收起仍然是点卡片头那一行的事。
9639
10347
  acts.push(h(Switch, { key: "sw", on: root.enabled, disabled: busy || anyLocked, label: t("source.toggle") + " " + displayName, onClick: function () { setExpanded(Object.assign({}, expanded, { [root.key]: true })); (root.enabled ? post("/source-disable", { root: root.key }, "done.disabled", { name: displayName }) : post("/source-enable", { root: root.key }, "done.enabled", { name: displayName })).then(function () { dropUndoForRoot(root.key, displayName); }); } }));
10348
+ // 「移除来源」只要文字,放在开关**右侧**(用户裁定 2026-10-15,连续推翻两次:
10349
+ // 2026-09-17 的「只留图标」与当天早些时候的「图标 + 文字」—— 图标在这颗 quiet
10350
+ // 键里只是装饰,「移除来源」四个字已把语义说全;破坏性操作的郑重感由确认弹窗
10351
+ // 承担,点击照旧弹红色「移除来源」确认框,说明没有丢)。
10352
+ if (root.removable === true) acts.push(h("button", { key: "rm", type: "button", className: "dsm-btn dsm-btn-quiet dsm-btn-danger", disabled: busy || anyLocked, title: t("btn.source.remove.title"), "aria-label": t("btn.source.remove"), onClick: function () { setModal({ type: "source-remove-confirm", key: root.key, name: displayName }); } }, t("btn.source.remove")));
9640
10353
  }
9641
10354
  return acts.length ? h("span", { className: "dsm-source-actions" }, acts) : null;
9642
10355
  })()), open ? h("div", { className: "dsm-source-body" }, filtered.length ? rankedFirst(skillRank, function (skill) { return skill.key || (root.key + "/" + skill.name) }, filtered, function (skill) { return isSkillEnabled(skill); }).map(function (skill) { return renderSkill(root, skill); }) : h("div", { className: "dsm-empty" }, dq ? t("empty.search") : t("empty.source"))) : null);
@@ -9787,7 +10500,27 @@ function enabledMemoryCount(rules) {
9787
10500
  : snapshot.error && !snapshot.data ? h(ListStates, { key: "errstate", t: t, state: "error", text: snapshot.error, onRetry: function () { refresh(); } })
9788
10501
  : h("div", { key: "sources", className: "dsm-sources" }, roots.map(renderRoot))]);
9789
10502
 
9790
- if (modal === "create") content.push(h(Modal, { key: "create", title: t("create.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("div", { className: "dsm-form" }, h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("create.target")), h(SourceSelect, { value: form.root, options: createOptions, onChange: function (value) { updateForm("root", value); } })), [["name", "create.name", "create.name.placeholder"], ["description", "create.description", "create.description.placeholder"]].map(function (field) { return h("label", { key: field[0], className: "dsm-field" }, h("div", { className: "dsm-budget-meta" }, h("span", { className: "dsm-label" }, t(field[1])), field[0] === "description" ? h("span", { className: "dsm-char-count" }, String(form.description || "").length + " / " + CATALOG_DESC_MAX) : null), h("input", { className: "dsm-control", value: form[field[0]], placeholder: t(field[2]), maxLength: field[0] === "description" ? CATALOG_DESC_MAX : undefined, onChange: function (e) { updateForm(field[0], e.target.value); } })); }), h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("create.body")), h("textarea", { className: "dsm-control", value: form.body, placeholder: t("create.body.placeholder"), onChange: function (e) { updateForm("body", e.target.value); } })), h("p", { className: "dsm-help" }, t("create.chat.note"))), h("div", { className: "dsm-modal-actions" }, h("button", { className: "dsm-btn", disabled: busy || !form.name.trim() || !form.description.trim() || !form.body.trim(), onClick: submitCreate }, t("btn.create.now")))));
10503
+ if (modal === "create") content.push(h(Modal, { key: "create", title: t("create.title"), closeLabel: t("btn.close"), onClose: dismissCreate }, h("div", { className: "dsm-form" },
10504
+ // 创建位置用分段控件而不是下拉(用户 2026-10-15):候选就是全部可写来源,
10505
+ // 互斥单选,与「重名处理」「提示词类型」同款;只有一项时也如实显示"只有这一个去处"。
10506
+ h("div", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("create.target")),
10507
+ h("div", { className: "dsm-form-seg", role: "group", "aria-label": t("create.target") },
10508
+ createOptions.map(function (o) { return h("button", { key: o.value, type: "button", "aria-pressed": form.root === o.value ? "true" : "false", onClick: function () { updateForm("root", o.value); } }, o.label); }))),
10509
+ [["name", "create.name", "create.name.placeholder"], ["description", "create.description", "create.description.placeholder"]].map(function (field) { return h("label", { key: field[0], className: "dsm-field" }, h("div", { className: "dsm-budget-meta" }, h("span", { className: "dsm-label" }, t(field[1])), field[0] === "description" ? h("span", { className: "dsm-char-count" }, String(form.description || "").length + " / " + CATALOG_DESC_MAX) : null), h("input", { className: "dsm-control", value: form[field[0]], placeholder: t(field[2]), maxLength: field[0] === "description" ? CATALOG_DESC_MAX : undefined, onChange: function (e) { updateForm(field[0], e.target.value); } })); }), h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("create.body")), h("textarea", { className: "dsm-control", value: form.body, placeholder: t("create.body.placeholder"), onChange: function (e) { updateForm("body", e.target.value); } })), renderAttachField()), h("div", { className: "dsm-modal-actions" }, h("button", { className: "dsm-btn", disabled: busy || !form.name.trim() || !form.description.trim() || !form.body.trim(), onClick: submitCreate }, t("btn.create.now")))));
10510
+ // 「新建附件」小窗叠在创建弹窗之上:路径 + 内容两个框,确定即并入清单(可再点开改)。
10511
+ // 目录不用单独给 —— 路径里写 `references/api.md`,服务端按那段建目录。
10512
+ if (draftFile) content.push(h(Modal, {
10513
+ key: "attach-new", title: t("skill.attach.newTitle"), closeLabel: t("btn.close"), className: "dsm-modal-compact",
10514
+ onClose: function () { setDraftFile(null); },
10515
+ }, h("div", { className: "dsm-form" },
10516
+ h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("skill.attach.path")),
10517
+ h("input", { className: "dsm-control", value: draftFile.path, placeholder: t("skill.attach.path.placeholder"), onChange: function (e) { setDraftFile(Object.assign({}, draftFile, { path: e.target.value })) } })),
10518
+ h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("skill.attach.content")),
10519
+ h("textarea", { className: "dsm-control dsm-textarea-md", value: draftFile.content, placeholder: t("skill.attach.content.placeholder"), onChange: function (e) { setDraftFile(Object.assign({}, draftFile, { content: e.target.value })) } })),
10520
+ draftFile.error ? h("div", { className: "dsm-feedback dsm-error", role: "alert" }, draftFile.error) : null),
10521
+ h("div", { className: "dsm-modal-actions" },
10522
+ h("button", { type: "button", className: "dsm-btn dsm-btn-secondary", onClick: function () { setDraftFile(null); } }, t("btn.cancel")),
10523
+ h("button", { type: "button", className: "dsm-btn", disabled: !String(draftFile.path || "").trim(), onClick: submitNewFile }, t("skill.attach.add")))));
9791
10524
  // D13:导入弹窗统一用共享 ImportModal(能力取三者并集:拖拽区 + 已选文件行 +
9792
10525
  // 文件要求清单 + 结果反馈)。技能页只保留自己的校验与提交逻辑。
9793
10526
  if (modal === "import") content.push(h(ImportModal, {
@@ -9809,7 +10542,17 @@ function enabledMemoryCount(rules) {
9809
10542
  // 原来是路径一行 + .dsm-modal-actions(右对齐)又一行,两行之间一大片空白。
9810
10543
  // 路径条 flex:1 吃掉剩余宽度、按钮固定不动,窄了路径自己折行(word-break:break-all)。
9811
10544
  h("div", { className: "dsm-detail-path-row" }, h("div", { className: "dsm-detail-path" }, detail.path), h("button", { type: "button", className: "dsm-btn dsm-btn-secondary", disabled: busy, onClick: openSource }, t("btn.open.editor"))),
9812
- h("div", { className: "dsm-detail-section" }, h("div", { className: "dsm-detail-title" }, t("detail.diagnostics")), detail.diagnostics.length ? detail.diagnostics.map(function (item, index) { return h("div", { key: index, className: "dsm-diag" }, t(item.code, item.params || {})); }) : h("div", { className: "dsm-note" }, t("detail.noIssues"))), h("div", { className: "dsm-detail-section" }, h("div", { className: "dsm-detail-title" }, t("detail.frontmatter")), renderFrontmatter(t, detail.frontmatter)), h("div", { className: "dsm-detail-section" }, h("div", { className: "dsm-detail-title" }, t("detail.body")), h("pre", { className: "dsm-code" }, detail.body || ""))) : h("div", { className: "dsm-empty" }, t("loading"))));
10545
+ h("div", { className: "dsm-detail-section" }, h("div", { className: "dsm-detail-title" }, t("detail.diagnostics")), detail.diagnostics.length ? detail.diagnostics.map(function (item, index) { return h("div", { key: index, className: "dsm-diag" }, t(item.code, item.params || {})); }) : h("div", { className: "dsm-note" }, t("detail.noIssues"))), h("div", { className: "dsm-detail-section" }, h("div", { className: "dsm-detail-title" }, t("detail.frontmatter")), renderFrontmatter(t, detail.frontmatter)),
10546
+ // 附件清单(服务端 skill-detail 现在会列 bundle 目录里 SKILL.md 之外的文件):
10547
+ // 建完之后要能在界面里看到它们真的落进去了,否则「加了附件」只有创建那一次是可信的。
10548
+ detail.attachments && detail.attachments.length ? h("div", { className: "dsm-detail-section" },
10549
+ h("div", { className: "dsm-detail-title" }, t("detail.attachments") + " · " + detail.attachments.length + (detail.attachmentsTruncated ? " +" : "")),
10550
+ h("div", { className: "dsm-attach-list dsm-attach-cap" }, detail.attachments.map(function (a) {
10551
+ return h("div", { key: a.path, className: "dsm-file", title: a.path },
10552
+ h("span", { className: "dsm-file-name" }, a.path),
10553
+ h("span", { className: "dsm-file-meta" }, fmtBytes(a.size)))
10554
+ }))) : null,
10555
+ h("div", { className: "dsm-detail-section" }, h("div", { className: "dsm-detail-title" }, t("detail.body")), h("pre", { className: "dsm-code" }, detail.body || ""))) : h("div", { className: "dsm-empty" }, t("loading"))));
9813
10556
  if (modal === "export") content.push(h(ExportModal, {
9814
10557
  key: "export", t: t, title: t("export.skills"),
9815
10558
  items: exportItems(), busy: busy, result: result,
@@ -9825,6 +10568,15 @@ function enabledMemoryCount(rules) {
9825
10568
  if (modal === "custom-add") content.push(h(Modal, { key: "custom-add", title: t("custom.add.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("div", { className: "dsm-form" }, h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("custom.add.path")), h("div", { className: "dsm-dir-row" }, h("input", { className: "dsm-control", value: customForm.path, placeholder: defaultIoDir(), onChange: function (e) { setCustomForm(Object.assign({}, customForm, { path: e.target.value })); } }), h("button", { type: "button", className: "dsm-btn dsm-btn-secondary", disabled: busy, onClick: openCustomDir, title: t("custom.add.openDir.title") }, t("btn.openDir")))), h("label", { className: "dsm-field" }, h("span", { className: "dsm-label" }, t("custom.add.label")), h("input", { className: "dsm-control", value: customForm.label, placeholder: t("custom.add.label.placeholder"), onChange: function (e) { setCustomForm(Object.assign({}, customForm, { label: e.target.value })); } })), h("p", { className: "dsm-help" }, t("custom.add.help")), result ? h("div", { className: "dsm-feedback" + (result.warning ? " dsm-warning" : result.ok ? "" : " dsm-error"), role: "alert" }, result.text) : null), customForm.picking ? h(DirPickerModal, { key: "custom-dir-picker", title: t("custom.add.picker.title"), initial: String(customForm.path || ""), closeLabel: t("btn.close"), onClose: function () { setCustomForm(Object.assign({}, customForm, { picking: false })); }, onPick: function (path) { setCustomForm(Object.assign({}, customForm, { path: path, picking: false })); } }) : null, h("div", { className: "dsm-modal-actions" }, h("button", { type: "button", className: "dsm-btn", disabled: busy || !String(customForm.path || "").trim(), onClick: submitCustomAdd }, t("custom.add.submit")))));
9826
10569
  if (modal && modal.type === "source-remove-confirm") content.push(h(Modal, { key: "source-remove-confirm", title: t("confirm.source.remove.title"), closeLabel: t("btn.close"), className: "dsm-modal-sm", onClose: function () { setModal(null); } }, h("p", { className: "dsm-desc" }, t("confirm.source.remove.desc", { name: modal.name })), h("p", { className: "dsm-help" }, t("confirm.source.remove.hint")), h("div", { className: "dsm-modal-actions" }, h("button", { className: "dsm-btn dsm-btn-danger", disabled: busy, onClick: function () { post("/source-remove", { root: modal.key }, "result.sourceRemoved", { name: modal.name }).then(function () { setModal(null); }).catch(swallowPostError); } }, t("btn.source.remove")))));
9827
10570
  if (modal && modal.type === "custom-remove-confirm") content.push(h(Modal, { key: "custom-remove-confirm", title: t("confirm.custom.remove.title"), closeLabel: t("btn.close"), className: "dsm-modal-sm", onClose: function () { setModal(null); } }, h("p", { className: "dsm-desc" }, t("confirm.custom.remove.desc", { name: modal.name })), h("div", { className: "dsm-modal-actions" }, h("button", { className: "dsm-btn dsm-btn-danger", disabled: busy, onClick: function () { submitCustomRemove(modal.key, modal.name); } }, t("btn.custom.remove")))));
10571
+ // 「已存在同名技能」小窗:叠在「创建技能」弹窗之上(`Modal` 自带 gateModalDepth 计数)。
10572
+ // 取消 → 回到创建弹窗改名字,简介与正文一个字都不动。
10573
+ if (dupAsk) content.push(h(DupNameDialog, {
10574
+ key: "dup-name", t: t, kind: t("dup.kind.skill"),
10575
+ name: dupAsk.name, finalName: dupAsk.finalName, action: "create",
10576
+ busy: busy,
10577
+ onCancel: function () { setDupAsk(null); },
10578
+ onConfirm: function () { setDupAsk(null); createSkill(dupAsk.finalName); },
10579
+ }));
9828
10580
  return h("section", { className: "dsm-section" }, content);
9829
10581
  }
9830
10582
 
@@ -9927,7 +10679,7 @@ function enabledMemoryCount(rules) {
9927
10679
  // 多选集合:勾选的 sessionId。最小 React 支持函数式初值(Set 惰性创建)。
9928
10680
  var selState = React.useState(function () { return new Set() })
9929
10681
  var selected = selState[0], setSelected = selState[1]
9930
- // 导入对话弹窗:null = 关闭;{ busy, error, result, cwd, pickingDir } = 打开。
10682
+ // 导入对话弹窗:null = 关闭;{ busy, error, result, cwd } = 打开。
9931
10683
  var impState = React.useState(null)
9932
10684
  var imp = impState[0], setImp = impState[1]
9933
10685
  // 导出对话弹窗:null = 关闭;{ busy, error, result, outDir, format, detail, wsFilter, selectedIds } = 打开。
@@ -10102,7 +10854,7 @@ function enabledMemoryCount(rules) {
10102
10854
  reader.readAsText(file)
10103
10855
  }
10104
10856
  function openImportPicker() {
10105
- setImp({ busy: false, error: null, result: null, cwd: '', pickingDir: false })
10857
+ setImp({ busy: false, error: null, result: null, cwd: '' })
10106
10858
  }
10107
10859
  // ---------- 导出对话:弹窗内选范围/工作区 → 勾选会话 → 写入指定目录 ----------
10108
10860
  function openExportPicker() {
@@ -10147,31 +10899,6 @@ function enabledMemoryCount(rules) {
10147
10899
  setExp(Object.assign({}, exp, { busy: false, error: t('hist.err.export') }))
10148
10900
  })
10149
10901
  }
10150
- // 把选中的会话批量归档到 History(纳入保留期管理),随后刷新列表与页面。
10151
- function doArchiveSelected() {
10152
- var ids = Array.from(exp.selectedIds)
10153
- if (!ids.length || exp.busy) return
10154
- setExp(Object.assign({}, exp, { busy: true, error: null, result: null }))
10155
- apiCall('history-archive-batch', { sessionIds: ids }).then(function (res) {
10156
- if (res && res.ok) {
10157
- refresh()
10158
- return apiCall('history-sessions', {}).then(function (r2) {
10159
- setExp(function (prev) {
10160
- if (!prev) return prev
10161
- return Object.assign({}, prev, {
10162
- busy: false,
10163
- selectedIds: new Set(),
10164
- result: { archived: (res.archived || []).length, failed: (res.failed || []).length },
10165
- items: (r2 && r2.ok && Array.isArray(r2.items)) ? r2.items : prev.items,
10166
- })
10167
- })
10168
- })
10169
- }
10170
- setExp(function (prev) { return prev ? Object.assign({}, prev, { busy: false, error: (res && res.error) || t('hist.err.archive') }) : prev })
10171
- }).catch(function () {
10172
- setExp(function (prev) { return prev ? Object.assign({}, prev, { busy: false, error: t('hist.err.archive') }) : prev })
10173
- })
10174
- }
10175
10902
 
10176
10903
  var RETENTION_OPTS = [
10177
10904
  { value: 0, label: t('hist.retention.forever') },
@@ -10248,6 +10975,13 @@ function enabledMemoryCount(rules) {
10248
10975
  return it.workspaceId === wid && !!data.workspaces[wid]
10249
10976
  })
10250
10977
  }
10978
+ // 「全选」只看**当前筛出来的**那些(折叠的组也算在里面:折叠不是排除)。
10979
+ var allExportPicked = !!(exp && exportItems.length) && exportItems.every(function (it) { return exp.selectedIds.has(it.sessionId) })
10980
+ function toggleAllExportPicked() {
10981
+ setExp(Object.assign({}, exp, {
10982
+ selectedIds: allExportPicked ? new Set() : new Set(exportItems.map(function (it) { return it.sessionId })),
10983
+ }))
10984
+ }
10251
10985
 
10252
10986
  function renderRow(it) {
10253
10987
  // 列表行两行制(REDESIGN §3.4,切片四):行 1 标题,行 2 目录(截断 + title);
@@ -10390,6 +11124,7 @@ function enabledMemoryCount(rules) {
10390
11124
  t: t,
10391
11125
  title: t('hist.btn.import'),
10392
11126
  accept: '.jsonl,.json,.md,.markdown,.txt',
11127
+ hint: t('upload.drop.session'),
10393
11128
  busy: imp.busy === true,
10394
11129
  requirement: t('upload.req.session'),
10395
11130
  requirementTitle: [t('upload.requirement.session.1'), t('upload.requirement.session.2'), t('upload.requirement.session.3')].join('\n'),
@@ -10397,18 +11132,17 @@ function enabledMemoryCount(rules) {
10397
11132
  extra: React.createElement('div', null,
10398
11133
  React.createElement('div', { className: 'dsm-field' },
10399
11134
  React.createElement('label', { className: 'dsm-label' }, t('hist.import.cwd')),
10400
- // 三条路并存:从已登记的工作区挑、浏览一个文件夹、直接粘路径。
11135
+ // 两条路:从已登记的工作区挑、直接粘路径(用户 2026-10-15:删掉
11136
+ // 「浏览文件夹」那颗 —— 它和底下的文件拖拽区在 import 语境里都叫
11137
+ // "选择",功能重叠;要选没登记过的目录,粘绝对路径即可)。
10401
11138
  // 标签是 combo 的**兄弟**而不是祖先 —— 嵌套 label 会把点击转给清单里第一项。
10402
11139
  React.createElement(SceneCombo, {
10403
11140
  options: importWsOptions, value: imp.cwd || '',
10404
11141
  placeholder: t('hist.import.cwd.placeholder'), label: t('hist.import.cwd'),
10405
11142
  browseLabel: t('hist.import.workspace'),
10406
- pickFolderLabel: t('hist.import.browse'), pickFolderTitle: t('hist.export.browse'),
10407
11143
  disabled: imp.busy === true,
10408
11144
  onChange: function (v) { setImp(Object.assign({}, imp, { cwd: v })) },
10409
- onPickFolder: function () { requestHostDirectory(function (path) { setImp(Object.assign({}, imp, { cwd: path })) }, function () { setImp(Object.assign({}, imp, { pickingDir: true })) }) },
10410
11145
  }),
10411
- imp.pickingDir ? React.createElement(DirPickerModal, { key: 'imp-dir-picker', title: t('dir.title'), initial: String(imp.cwd || ''), closeLabel: t('btn.close'), onClose: function () { setImp(Object.assign({}, imp, { pickingDir: false })) }, onPick: function (path) { setImp(Object.assign({}, imp, { cwd: path, pickingDir: false })) } }) : null,
10412
11146
  importWsOptions.length ? null : React.createElement('p', { className: 'dsm-help' }, t('hist.import.workspace.none')))),
10413
11147
  onClose: function () { setImp(null) },
10414
11148
  onSubmit: function (entries, files) { doImportFile(files[0]) },
@@ -10477,14 +11211,12 @@ function enabledMemoryCount(rules) {
10477
11211
  // ↑ 最后那个括号收掉 .dsm-export-form:结果条与动作行留在表单外面,才能常驻不动。
10478
11212
  exp.error ? React.createElement('div', { className: 'dsm-feedback dsm-error' }, String(exp.error)) : null,
10479
11213
  exp.result ? React.createElement('div', { className: 'dsm-feedback' },
10480
- exp.result.exported
10481
- ? (t('hist.result.exported', { count: (exp.result.exported || []).length, dir: String(exp.outDir || '').trim() })
10482
- + ((exp.result.skipped && exp.result.skipped.length) ? t('hist.result.skipped', { count: exp.result.skipped.length, ids: exp.result.skipped.map(function (s) { return s.sessionId }).join('、') }) : ''))
10483
- : (t('hist.result.archived', { count: exp.result.archived })
10484
- + (exp.result.failed ? t('hist.result.archiveFailed', { count: exp.result.failed }) : ''))) : null,
11214
+ t('hist.result.exported', { count: (exp.result.exported || []).length, dir: String(exp.outDir || '').trim() })
11215
+ + ((exp.result.skipped && exp.result.skipped.length) ? t('hist.result.skipped', { count: exp.result.skipped.length, ids: exp.result.skipped.map(function (s) { return s.sessionId }).join('、') }) : '')) : null,
10485
11216
  React.createElement('div', { className: 'dsm-modal-actions' },
10486
- React.createElement('span', { className: 'dsm-hist-batch-count' }, t('hist.selected', { count: exp.selectedIds.size })),
10487
- React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: exp.busy || !exp.selectedIds.size, onClick: doArchiveSelected, title: t('hist.btn.archiveSelected.title') }, t('hist.btn.archiveSelected')),
11217
+ // 「已选 N 项」不占一栏:右边那颗导出键本身就带着份数(导出 N 个会话),
11218
+ // 与本页回收站那条批量条同一口径。「归档所选」换成「全选」(用户 2026-10-16)。
11219
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary dsm-btn-bulk', disabled: exp.busy || !exportItems.length, onClick: toggleAllExportPicked }, bulkPair(allExportPicked ? t('hist.deselectAll') : t('hist.selectAll'), allExportPicked ? t('hist.selectAll') : t('hist.deselectAll'))),
10488
11220
  React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: exp.busy || !exp.selectedIds.size || !String(exp.outDir || '').trim(), onClick: doExport }, exp.busy ? t('hist.export.busy') : t('hist.btn.exportSelected', { count: exp.selectedIds.size })))) : null,
10489
11221
  modal && modal.type === 'error' ? React.createElement(Modal, { key: 'err', className: 'dsm-modal-sm', title: t('hist.error.title'), closeLabel: t('btn.close'), onClose: function () { setModal(null) } },
10490
11222
  React.createElement('p', { className: 'dsm-help' }, String(modal.message || t('hist.error.unknown')))) : null)
@@ -10609,6 +11341,8 @@ function enabledMemoryCount(rules) {
10609
11341
  var modal = ms[0], setModal = ms[1]
10610
11342
  var sfs = React.useState({ name: '', error: null })
10611
11343
  var sceneForm = sfs[0], setSceneForm = sfs[1]
11344
+ // 切走这一页 → 场景编辑器关窗时那份暂存作废(见 42-shared-ui.js 的 useFormDraftScope)。
11345
+ useFormDraftScope('scene:')
10612
11346
  var pos = React.useState([])
10613
11347
  var presetOptions = pos[0], setPresetOptions = pos[1]
10614
11348
  // 当前**生效中**的提示词预设 id(`agentsmd-list` 里 active 的那份)。预览卡要用它把
@@ -10630,6 +11364,24 @@ function enabledMemoryCount(rules) {
10630
11364
  var quickPick = qks[0], setQuickPick = qks[1]
10631
11365
  var dts = React.useState(null)
10632
11366
  var drillTools = dts[0], setDrillTools = dts[1]
11367
+ // 记忆集组头的「创建」(用户 2026-10-14):套用记忆页的「新建记忆」表单,但**不带场景
11368
+ // 选择器** —— 记忆归属由当前编辑的场景定死,于是这里建出来的条目立刻出现在本组清单里。
11369
+ // null = 关闭;{ name, description, body, form, files, error } = 草稿。
11370
+ var mns = React.useState(null)
11371
+ var memNew = mns[0], setMemNew = mns[1]
11372
+ // 「已存在同名记忆」确认弹窗(用户 2026-10-14 第二轮):`{ name, finalName }` 或 null。
11373
+ // 撞名不再在提交那一刻静默退号,先问一句;确认后才按 `finalName` 攒成草稿。
11374
+ var mds = React.useState(null)
11375
+ var memDupAsk = mds[0], setMemDupAsk = mds[1]
11376
+ // 场景本身撞名的确认弹窗(同一天同一套设计):`{ name, finalName }` 或 null。
11377
+ // 场景与记忆共用一颗弹窗组件,但状态分开 —— 两个入口能同时存在(编辑器里开着
11378
+ // 记忆新建窗时再点保存场景),合成一个状态会让其中一个把另一个顶掉。
11379
+ var sds = React.useState(null)
11380
+ var sceneDupAsk = sds[0], setSceneDupAsk = sds[1]
11381
+ var memAttachRef = React.useRef(null)
11382
+ // 附件上限与服务端 MAX_ATTACH_ENTRY_BYTES 对齐(记忆页同一口径)。
11383
+ var MEM_ATTACH_MAX_MB = 8
11384
+ var MEM_ATTACH_MAX_BYTES = MEM_ATTACH_MAX_MB << 20
10633
11385
  // 场景回收站:true = 打开本页那一个回收站(42-shared-ui.js 的 TrashModal)。
10634
11386
  // 清单加载 / 恢复 / 永久删除 / 批量都在组件内部完成,本页只在写操作后重读自己的数据。
10635
11387
  var sts = React.useState(false)
@@ -10797,17 +11549,24 @@ function enabledMemoryCount(rules) {
10797
11549
  function openEditor(scene) {
10798
11550
  var name = scene ? scene.name : null
10799
11551
  var sceneArchive = scene ? ((data.archives || {})[name] || {}) : {}
10800
- setSceneForm({
11552
+ // 子弹窗(新建记忆)随编辑器一起重开:留着上一轮的草稿会让人以为"我上次填的还在"。
11553
+ setMemNew(null)
11554
+ var draftKey = name ? 'scene:edit:' + name : 'scene:create'
11555
+ var draft = takeFormDraft(draftKey)
11556
+ setSceneForm(Object.assign({
10801
11557
  name: name || '', description: scene ? scene.description || '' : '', prompt: scene ? scene.prompt || '' : '',
10802
11558
  toolTablePreset: typeof sceneArchive.toolTablePreset === 'string' ? sceneArchive.toolTablePreset : '',
10803
11559
  quickMode: !Array.isArray(sceneArchive.quickPrompts)
10804
11560
  ? 'current' : (sceneArchive.quickPrompts.length ? 'custom' : 'none'),
10805
11561
  quickPrompts: Array.isArray(sceneArchive.quickPrompts) ? sceneArchive.quickPrompts.slice() : null,
10806
11562
  error: null,
10807
- })
11563
+ }, draft || {}, { error: null }))
10808
11564
  setModal({
10809
- type: 'scene-editor', name: name, drill: null, drillError: null, error: null, inventory: null,
11565
+ type: 'scene-editor', name: name, draftKey: draftKey, drill: null, drillError: null, error: null, inventory: null,
10810
11566
  mcpQuery: '', skillQuery: '', subQuery: '', memQuery: '',
11567
+ // 创建态里攒下的记忆(见 flushPendingMemories):`rules-create` 要求目标场景已存在,
11568
+ // 所以场景还没落盘时只能先记在这里,等场景建出来再补。编辑态恒为空数组。
11569
+ pendingMems: [],
10811
11570
  sections: {
10812
11571
  mcp: sceneArchive.mcp ? Object.assign({}, sceneArchive.mcp) : null,
10813
11572
  mcpNotes: sceneArchive.mcpNotes && Object.keys(sceneArchive.mcpNotes).length ? Object.assign({}, sceneArchive.mcpNotes) : null,
@@ -10874,6 +11633,27 @@ function enabledMemoryCount(rules) {
10874
11633
  var isEdit = modal.name != null
10875
11634
  var name = String(sceneForm.name || '').trim()
10876
11635
  if (!isValidSceneSegment(name)) { setSceneForm(Object.assign({}, sceneForm, { error: t('error.rules.invalidGroup') })); return }
11636
+ /*
11637
+ * 新建撞名:**先问一句再退号**(用户 2026-10-14 第二轮)。
11638
+ *
11639
+ * 这一处不能像记忆页那样"交给服务端报错再问" —— `rules-create-scene` 是**幂等
11640
+ * upsert**(已存在则更新描述/标签,不报错),而且模型工具依赖这条语义(见
11641
+ * `scene-records.ts` 的注释)。所以撞名只能由界面自己认出来:场景名就是记录键、
11642
+ * 不经过任何归一化,`data.scenes` 里查得到就是真的撞了。
11643
+ *
11644
+ * 编辑态不做这一步:改的是自己,不存在撞名。
11645
+ */
11646
+ if (!isEdit) {
11647
+ var taken = {}
11648
+ ;(data.scenes || []).forEach(function (s) { taken[String(s.name)] = true })
11649
+ var finalName = uniqueName(name, taken)
11650
+ if (finalName !== name) { setSceneDupAsk({ name: name, finalName: finalName }); return }
11651
+ }
11652
+ saveScene(name, isEdit)
11653
+ }
11654
+ /** 真正落盘那一步(名字已定)。`name` 来自 `submitSceneEditor` 或确认弹窗。 */
11655
+ function saveScene(name, isEdit) {
11656
+ if (!modal || modal.type !== 'scene-editor') return
10877
11657
  var description = String(sceneForm.description || '').trim()
10878
11658
  var prompt = String(sceneForm.prompt || '').trim()
10879
11659
  setBusy(true)
@@ -10900,9 +11680,10 @@ function enabledMemoryCount(rules) {
10900
11680
  // 编辑态沿用旧档案弹窗的口径:保存 = 整份档案落盘(四段全删 = 存空档案);
10901
11681
  // 创建态只在真的配了组时才写档案,不给新场景凭空落一份空档案。
10902
11682
  if (isEdit) hasArchive = true
10903
- if (!hasArchive) { finishSceneEditor(name, isEdit, res, null); return }
11683
+ // 场景已落盘 → 才能补建创建态里攒下的记忆(`rules-create` 要求场景已存在)。
11684
+ if (!hasArchive) { flushPendingMemories(name, isEdit, res, null); return }
10904
11685
  apiCall('scene-archive-save', { scene: name, archive: archivePayload }).then(function (ares) {
10905
- if (ares && ares.ok) finishSceneEditor(name, isEdit, res, ares)
11686
+ if (ares && ares.ok) flushPendingMemories(name, isEdit, res, ares)
10906
11687
  else {
10907
11688
  // 场景已建/已改、档案没存上:留在编辑器里如实报错,主按钮变「保存到场景」可重试。
10908
11689
  setBusy(false)
@@ -10914,7 +11695,7 @@ function enabledMemoryCount(rules) {
10914
11695
  })
10915
11696
  }).catch(function (e) { setBusy(false); setSceneForm(Object.assign({}, sceneForm, { error: errMsg(e) })) })
10916
11697
  }
10917
- function finishSceneEditor(name, isEdit, sceneRes, archiveRes) {
11698
+ function finishSceneEditor(name, isEdit, sceneRes, archiveRes, memRes) {
10918
11699
  setBusy(false)
10919
11700
  setModal(null)
10920
11701
  // 新建时若把历史默认(全部启用)收敛成单选,如实说一句,别让用户以为场景"自己开了"。
@@ -10927,6 +11708,13 @@ function enabledMemoryCount(rules) {
10927
11708
  if (archiveRes.stale && archiveRes.stale.length) text += ' · ' + t('memory.archive.stale', { items: archiveRes.stale.join('、') })
10928
11709
  if (archiveRes.applyError) { ok = false; text += ' · ' + t('memory.archive.applyFailed', { reason: archiveRes.applyError }) }
10929
11710
  }
11711
+ // 攒下的记忆草稿的落盘结果(flushPendingMemories)。三个数分开报:「建了几条」
11712
+ // 与两种失败(压根没建上 / 建好了但没配全)—— 后者记忆是存在的,混进前者就是谎报。
11713
+ if (memRes) {
11714
+ if (memRes.created) text += ' · ' + t('scenes.mem.created', { count: memRes.created })
11715
+ if (memRes.incomplete.length) { ok = false; text += ' · ' + t('scenes.mem.incomplete', { count: memRes.incomplete.length, items: memRes.incomplete.join(';') }) }
11716
+ if (memRes.failed.length) { ok = false; text += ' · ' + t('scenes.mem.createFailed', { count: memRes.failed.length, items: memRes.failed.join(';') }) }
11717
+ }
10930
11718
  setResult({ ok: ok, text: text })
10931
11719
  refresh(true)
10932
11720
  }
@@ -11043,7 +11831,9 @@ function enabledMemoryCount(rules) {
11043
11831
  // 与记忆页同一个写入口(`rules-toggle`)。于是「记忆页开了 → 档案页显示开启」
11044
11832
  // 天然成立,不存在两套状态、也不需要双向同步代码。
11045
11833
  //
11046
- // 勾选**立即提交**(不等「保存到场景」):它是全局状态,不属于场景档案。
11834
+ // 已存在记忆的勾选**立即提交**(不等「保存到场景」):它是全局状态,不属于场景档案。
11835
+ // 本次新配的记忆是**草稿**,走另一条路 —— 存在 `modal.pendingMems` 里,点「创建 /
11836
+ // 保存到场景」时才落盘(见 flushPendingMemories),所以两种时机的行为没有分叉。
11047
11837
  //
11048
11838
  // **只列被编辑场景自己的记忆**:保留场景 `global` 恒定注入(任何对话都生效),
11049
11839
  // 不需要在这里配置,列出来只会让人以为「不勾就不注入」。
@@ -11124,7 +11914,11 @@ function enabledMemoryCount(rules) {
11124
11914
  opts.hideFilter ? null : segFilter(opts.query, opts.onQuery, opts.queryPlaceholder),
11125
11915
  opts.body))
11126
11916
  }
11127
- /** 组 4:记忆集(P5:单一真相源 = `rules[*].enabled`,勾选立即提交、不随「保存」落盘)。 */
11917
+ /**
11918
+ * 组 4:记忆集。两段内容拼在一张清单里:
11919
+ * · 已存在的记忆 —— 勾选**立即提交**(P5:单一真相源 = `rules[*].enabled`,不随「保存」落盘);
11920
+ * · 本次新配的草稿 —— 只存在于编辑器里,随「创建 / 保存到场景」一起落盘(`pendingMems`)。
11921
+ */
11128
11922
  function memoriesGroup() {
11129
11923
  var inv = modal.inventory || { memories: [] }
11130
11924
  var own = sceneMemoriesOf(inv.memories, modal.name || '')
@@ -11134,35 +11928,327 @@ function enabledMemoryCount(rules) {
11134
11928
  || String(m.description || '').toLowerCase().indexOf(q) >= 0
11135
11929
  }) : own, function (m) { return String(m.id) }, function (m) { return memoryEnabled(String(m.id)) })
11136
11930
  var enabledCount = own.filter(function (m) { return memoryEnabled(String(m.id)) }).length
11931
+ // 草稿**另算一栏**(`· N 条待创建`),不并进 `x/y 已勾选`:那一栏是「全选 / 全不选」
11932
+ // 的作用面(它只认已存在的记忆),并进去就会出现"写着 5/5、全选却只勾了 3 条"。
11933
+ // 两栏各说各的:前者 = 这个场景现在有几条、开着几条;后者 = 我这次还配了几条。
11934
+ var pending = modal.pendingMems || []
11935
+ // 本次编辑里攒下的记忆草稿(还没落盘,两种时机都有)。它们:
11936
+ // · 勾选框照常可用 —— 勾 = 落盘时直接开启,与下面那些真记忆同一语义(新草稿默认勾上);
11937
+ // · 「编辑」回填草稿,「移除」撤掉草稿;
11938
+ // · 行首标「待创建」,免得跟真的记忆混成一片(标签的 title 补两句非显然的话:
11939
+ // 落点取点「创建」那一刻的场景名、勾选的含义)。
11940
+ // 草稿行**不受筛选框影响**:它是"我这次配了什么"的台账,被搜索词筛掉只会让人以为它没了。
11941
+ var rows = pending.map(function (m, i) {
11942
+ return RowSelect({
11943
+ disabled: busy,
11944
+ key: 'pending:' + i,
11945
+ checked: m.enabled === true,
11946
+ name: m.name,
11947
+ desc: m.description ? clipText(m.description, MEM_DESC_MAX) : null,
11948
+ descTitle: m.description || null,
11949
+ meta: React.createElement('span', { className: 'dsm-tag', title: t('scenes.mem.pendingTagTitle') }, t('scenes.mem.pendingTag')),
11950
+ actions: [
11951
+ React.createElement('button', { key: 'edit', type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () { openMemoryEdit(i) } }, t('memory.edit')),
11952
+ React.createElement('button', { key: 'rm', type: 'button', className: 'dsm-btn dsm-btn-quiet dsm-btn-danger', disabled: busy, onClick: function () { dropPendingMemory(i) } }, t('scenes.mem.removePending')),
11953
+ ],
11954
+ onChange: function () { togglePendingMemory(i) },
11955
+ })
11956
+ }).concat(items.map(function (m) {
11957
+ return RowSelect({
11958
+ disabled: busy,
11959
+ key: m.id,
11960
+ checked: memoryEnabled(String(m.id)),
11961
+ name: m.name,
11962
+ // 记忆描述是自由正文的首行,可能很长:截断到 MEM_DESC_MAX,全文放 title。
11963
+ desc: m.description ? clipText(m.description, MEM_DESC_MAX) : null,
11964
+ descTitle: m.description || null,
11965
+ onChange: function () { toggleMemory(String(m.id)) },
11966
+ })
11967
+ }))
11137
11968
  return editorGroupNode({
11138
11969
  title: t('memory.archive.memories'),
11139
- count: t('scenes.seg.checked', { checked: enabledCount, total: own.length }),
11140
- actions: segActions({
11970
+ count: t('scenes.seg.checked', { checked: enabledCount, total: own.length })
11971
+ + (pending.length ? ' · ' + t('scenes.mem.pending', { count: pending.length }) : ''),
11972
+ // 「创建」排在最前(用户 2026-10-14:在全选左边),点开的是记忆页那个「新建记忆」
11973
+ // 弹窗(少了场景选择器,见 memoryCreateNode)。其余三个组没有这颗 —— 它们的条目
11974
+ // 来自磁盘扫描(服务器 / 技能 / 人设),不在这里现造。
11975
+ actions: [memoryCreateButton()].concat(segActions({
11141
11976
  defined: true, busy: busy,
11142
11977
  selectAllLabel: t('bulk.selectAll'), clearLabel: t('bulk.unselectAll'),
11143
11978
  onAll: function () { setAllMemories(true) },
11144
11979
  onClear: function () { setAllMemories(false) },
11145
11980
  allChecked: own.length > 0 && enabledCount === own.length,
11146
- }),
11981
+ })),
11147
11982
  query: modal.memQuery,
11148
11983
  onQuery: function (v) { setModal(Object.assign({}, modal, { memQuery: v })) },
11149
11984
  queryPlaceholder: t('scenes.mem.search'),
11150
- body: items.length
11151
- ? React.createElement('div', null, items.map(function (m) {
11152
- return RowSelect({
11153
- disabled: busy,
11154
- key: m.id,
11155
- checked: memoryEnabled(String(m.id)),
11156
- name: m.name,
11157
- // 记忆描述是自由正文的首行,可能很长:截断到 MEM_DESC_MAX,全文放 title。
11158
- desc: m.description ? clipText(m.description, MEM_DESC_MAX) : null,
11159
- descTitle: m.description || null,
11160
- onChange: function () { toggleMemory(String(m.id)) },
11161
- })
11162
- }))
11985
+ body: rows.length
11986
+ ? React.createElement('div', null, rows)
11163
11987
  : React.createElement('div', { className: 'dsm-pick-empty' }, q ? t('scenes.mem.noMatch') : t('scenes.mem.emptyScene')),
11164
11988
  })
11165
11989
  }
11990
+ /*
11991
+ * 记忆集组头的「创建」(用户 2026-10-14):套用记忆页那个「新建记忆」弹窗,**只少了
11992
+ * 场景选择器** —— 场景由编辑器上下文定死,以只读一行显示。
11993
+ *
11994
+ * 与其余三组同一条模型:**弹窗里建出来的都只是草稿**,存在 `modal.pendingMems` 里,
11995
+ * 点「创建 / 保存到场景」那一刻才真正落盘(`flushPendingMemories`)。创建态如此,
11996
+ * 编辑态也一样(用户 2026-10-14:「已经存在的场景,本次创建记忆应该也能编辑、移除,
11997
+ * 也是未创建的记录」)—— 于是两种时机只有**落点**不同,行为不再分叉:草稿行一律
11998
+ * 可勾选、可编辑、可移除。
11999
+ *
12000
+ * 落盘仍走记忆页那两个宿主 op(`rules-create` + `rules-attach`)。一处刻意的偏离:
12001
+ * 服务端新建记忆默认 `enabled: false`(`ops/memory.ts` 的 P5 口径,记忆页照此),
12002
+ * 而这里的草稿**默认勾选**(用户 2026-10-14 裁定)—— 在这个场景里新建记忆本就是为了
12003
+ * 让它生效,落盘时按勾选补一次 `rules-toggle`;记忆页那条路径不受影响。
12004
+ *
12005
+ * 落点:编辑态 = 场景名;创建态 = 左边**正在填**的场景名(没填或非法就没有落点,
12006
+ * 入口置灰)。草稿**不记场景名** —— 落点统一取点「创建」那一刻的名字,所以中途改名
12007
+ * 不会把早先那条留在旧名字下,也不会各落一半。场景名此刻还只是表单里的一个字串,
12008
+ * 把它抄进草稿只会抄出一个马上过期的副本;代价是草稿行上不能写场景名(写了改名后
12009
+ * 就是假话),这件事由「待创建」标签的 title 说清。
12010
+ */
12011
+ /** 这次新建的记忆落到哪个场景:编辑态是场景名,创建态是表单里正在填的那个名字。 */
12012
+ function memoryCreateTarget() {
12013
+ if (!modal || modal.type !== 'scene-editor') return ''
12014
+ return modal.name != null ? String(modal.name) : String(sceneForm.name || '').trim()
12015
+ }
12016
+ function memoryCreateReady() {
12017
+ if (!modal || modal.type !== 'scene-editor') return false
12018
+ if (modal.name != null) return true
12019
+ return isValidSceneSegment(String(sceneForm.name || '').trim())
12020
+ }
12021
+ function openMemoryCreate() {
12022
+ if (!memoryCreateReady()) return
12023
+ // `editing` = 这一窗改的是第几条草稿;null = 新建一条。
12024
+ setMemNew({ editing: null, name: '', description: '', body: '', form: 'flat', files: [], error: null })
12025
+ }
12026
+ /** 改一条待创建的记忆:草稿回填进弹窗,保存时**替换**原来那条,而不是又添一条。 */
12027
+ function openMemoryEdit(index) {
12028
+ if (!modal || modal.type !== 'scene-editor') return
12029
+ var m = (modal.pendingMems || [])[index]
12030
+ if (!m) return
12031
+ setMemNew({
12032
+ editing: index, name: m.name, description: m.description || '', body: m.body || '',
12033
+ form: m.form || 'flat', files: (m.files || []).slice(), error: null,
12034
+ })
12035
+ }
12036
+ /** 撤掉一条还没落盘的记忆草稿。 */
12037
+ function dropPendingMemory(index) {
12038
+ if (!modal || modal.type !== 'scene-editor') return
12039
+ var next = (modal.pendingMems || []).slice()
12040
+ next.splice(index, 1)
12041
+ setModal(Object.assign({}, modal, { pendingMems: next }))
12042
+ }
12043
+ /**
12044
+ * 勾 / 取消勾一条待创建的记忆。语义与记忆页、本组其余行**完全一致**(勾 = 启用),
12045
+ * 只是生效时刻晚一步:服务端新建记忆默认不开启(`ops/memory.ts`),所以落盘时
12046
+ * create 之后要显式补一次 `rules-toggle`(见 flushPendingMemories)。
12047
+ */
12048
+ function togglePendingMemory(index) {
12049
+ if (!modal || modal.type !== 'scene-editor') return
12050
+ var next = (modal.pendingMems || []).slice()
12051
+ if (!next[index]) return
12052
+ next[index] = Object.assign({}, next[index], { enabled: next[index].enabled !== true })
12053
+ setModal(Object.assign({}, modal, { pendingMems: next }))
12054
+ }
12055
+ function pickMemAttach() { if (memAttachRef.current) memAttachRef.current.click() }
12056
+ function onMemAttachPicked(ev) {
12057
+ var picked = Array.prototype.slice.call((ev.target && ev.target.files) || [])
12058
+ if (ev.target) ev.target.value = '' // 允许连续选中同一个文件
12059
+ if (!picked.length) return
12060
+ Promise.all(picked.map(function (f) {
12061
+ return f.arrayBuffer().then(function (buf) {
12062
+ return { name: String(f.name || ''), size: buf.byteLength, data: bytesToBase64(buf) }
12063
+ })
12064
+ })).then(function (entries) {
12065
+ var tooBig = entries.filter(function (e) { return e.size > MEM_ATTACH_MAX_BYTES })
12066
+ var keep = entries.filter(function (e) { return e.size <= MEM_ATTACH_MAX_BYTES })
12067
+ setMemNew(function (prev) {
12068
+ if (!prev) return prev
12069
+ var next = Object.assign({}, prev, { files: (prev.files || []).concat(keep) })
12070
+ // 超限的当场拦下(服务端还会再拦一次),其余照常加入。
12071
+ if (tooBig.length) next.error = t('memory.attach.tooLarge', { name: tooBig[0].name, limit: MEM_ATTACH_MAX_MB })
12072
+ return next
12073
+ })
12074
+ }).catch(function (e) { setMemNew(function (prev) { return prev ? Object.assign({}, prev, { error: errMsg(e) }) : prev }) })
12075
+ }
12076
+ function dropMemPending(index) {
12077
+ setMemNew(function (prev) {
12078
+ if (!prev) return prev
12079
+ var next = (prev.files || []).slice()
12080
+ next.splice(index, 1)
12081
+ return Object.assign({}, prev, { files: next })
12082
+ })
12083
+ }
12084
+ /**
12085
+ * 名称去重:`测` 已被占用就退成 `测(1)`,再撞就 `测(2)`……
12086
+ *
12087
+ * 判据是**会落盘的那一套名字** = 本场景已有的记忆 + 本次已攒下的其它草稿。
12088
+ * 服务端 `rules-create` 撞名是直接拒绝的(`error.rules.exists`),所以这一步的作用是
12089
+ * 让「配两条同名」能正常落盘,而不是报个错让用户自己回去改名(用户 2026-10-14 要求)。
12090
+ *
12091
+ * `skipIndex` 跳过正在编辑的那条草稿:不跳的话改一次名就会把自己也算成占用者,
12092
+ * 于是每保存一次名字就往后挪一位。
12093
+ *
12094
+ * 退号本身走共享的 `uniqueName`(42-shared-ui.js,八个入口同一套);这里只负责
12095
+ * 把"占用者"凑出来 —— 各域的判据不同,凑法也就留在各域。
12096
+ */
12097
+ function uniqueMemoryName(raw, skipIndex) {
12098
+ var taken = {}
12099
+ sceneMemories(modal.name).forEach(function (m) { taken[String(m.name)] = true })
12100
+ ;(modal.pendingMems || []).forEach(function (m, i) { if (i !== skipIndex) taken[String(m.name)] = true })
12101
+ return uniqueName(raw, taken)
12102
+ }
12103
+ /**
12104
+ * 弹窗的提交动作:**先问一句再退号**(用户 2026-10-14 第二轮)。
12105
+ *
12106
+ * 原来这里静默退号 —— 输入框里写着「测」,草稿行上却是「测(1)」。现在撞名先弹一个
12107
+ * 小窗说明,确认后才按 `测(1)` 攒草稿。落盘仍然是点「创建 / 保存到场景」那一刻
12108
+ * (见 flushPendingMemories):这里一个请求都不发。
12109
+ */
12110
+ function submitMemoryCreate() {
12111
+ if (!memNew || !modal || modal.type !== 'scene-editor') return
12112
+ var typed = String(memNew.name || '').trim()
12113
+ var finalName = uniqueMemoryName(typed, memNew.editing != null ? memNew.editing : null)
12114
+ if (finalName !== typed) { setMemDupAsk({ name: typed, finalName: finalName }); return }
12115
+ stageMemoryDraft(typed)
12116
+ }
12117
+ /**
12118
+ * 把草稿写回 `modal.pendingMems`(新增一条,或**替换**被编辑的那条)。`name` 已定。
12119
+ *
12120
+ * 写的是草稿的**拷贝**:弹窗一关 `memNew` 就归 null,不能再引用它。
12121
+ */
12122
+ function stageMemoryDraft(name) {
12123
+ if (!memNew || !modal || modal.type !== 'scene-editor') return
12124
+ var list = (modal.pendingMems || []).slice()
12125
+ var editing = memNew.editing != null && list[memNew.editing] ? memNew.editing : null
12126
+ var draft = {
12127
+ name: name,
12128
+ description: String(memNew.description || '').trim(),
12129
+ body: memNew.body || '',
12130
+ form: memNew.form || 'flat',
12131
+ files: (memNew.files || []).slice(),
12132
+ // 新草稿默认勾选(用户 2026-10-14 裁定:这个场景里新建的记忆就是要它生效);
12133
+ // 勾选本身住在**行上**(编辑弹窗里没有这一栏,与记忆页的新建弹窗一致),
12134
+ // 所以改草稿时原样保留它,别让一次改名顺手抹掉「建好后开启」的意图。
12135
+ enabled: editing != null ? list[editing].enabled === true : true,
12136
+ }
12137
+ if (editing != null) list[editing] = draft
12138
+ else list.push(draft)
12139
+ setModal(Object.assign({}, modal, { pendingMems: list }))
12140
+ setMemNew(null)
12141
+ }
12142
+ /** 组头那颗「创建」:创建态且场景名还没填/非法时置灰,title 说清差什么。 */
12143
+ function memoryCreateButton() {
12144
+ var ready = memoryCreateReady()
12145
+ return React.createElement('button', {
12146
+ key: 'memnew', type: 'button', className: 'dsm-btn dsm-btn-quiet',
12147
+ disabled: busy || !ready,
12148
+ title: ready ? '' : t('scenes.mem.createNeedScene'),
12149
+ onClick: openMemoryCreate,
12150
+ }, t('memory.btn.create'))
12151
+ }
12152
+ /**
12153
+ * 把攒下的记忆草稿真正落盘 —— 排在场景本体(与档案)之后,因为 `rules-create` 要求
12154
+ * 目标场景**已存在**。两种时机共用这一条:创建态是"场景刚建出来",编辑态是"场景已改完"。
12155
+ * 某一条失败不回滚场景、也不影响同批其余条目,逐条如实报出名字与原因。
12156
+ */
12157
+ function flushPendingMemories(name, isEdit, sceneRes, archiveRes) {
12158
+ var pending = (modal && modal.pendingMems) || []
12159
+ if (!pending.length) { finishSceneEditor(name, isEdit, sceneRes, archiveRes, null); return }
12160
+ setBusy(true)
12161
+ var created = 0
12162
+ // 两个失败桶分开:`failed` = 这条记忆压根没建出来;`incomplete` = 建出来了,但
12163
+ // 后续两步(开启 / 传附件)有一处没做成。混成一个桶就会谎报 —— 后者文件是存在的。
12164
+ var failed = []
12165
+ var incomplete = []
12166
+ var chain = Promise.resolve()
12167
+ pending.forEach(function (m) {
12168
+ chain = chain.then(function () {
12169
+ return apiCall('rules-create', { group: name, name: m.name, description: m.description, body: m.body, form: m.form }).then(function (res) {
12170
+ if (!res || !res.ok) { failed.push(m.name + '(' + translateError(t, res) + ')'); return null }
12171
+ created += 1
12172
+ var id = (res.rule && res.rule.id) || (name + '/' + m.name)
12173
+ var post = []
12174
+ // 勾选 = 建好后直接开启(服务端新建记忆默认不开启)。
12175
+ if (m.enabled === true) post.push(apiCall('rules-toggle', { id: id, enabled: true }).then(function (tg) {
12176
+ if (!tg || !tg.ok) incomplete.push(m.name + '(' + translateError(t, tg) + ')')
12177
+ }, function (e) { incomplete.push(m.name + '(' + errMsg(e) + ')') }))
12178
+ if ((m.files || []).length) post.push(apiCall('rules-attach', { id: id, files: m.files.map(function (f) { return { path: f.name, data: f.data } }) }).then(function (att) {
12179
+ if (att && att.ok === false) incomplete.push(m.name + '(' + translateError(t, att) + ')')
12180
+ }, function (e) { incomplete.push(m.name + '(' + errMsg(e) + ')') }))
12181
+ // 两个 onRejected 都就地收下错误:漏出去会被外层 catch 记成"没建出来",
12182
+ // 而那时记忆其实已经在磁盘上了。
12183
+ return post.length ? Promise.all(post) : null
12184
+ }).catch(function (e) { failed.push(m.name + '(' + errMsg(e) + ')') })
12185
+ })
12186
+ })
12187
+ chain.then(function () {
12188
+ setBusy(false)
12189
+ finishSceneEditor(name, isEdit, sceneRes, archiveRes, { created: created, failed: failed, incomplete: incomplete })
12190
+ })
12191
+ }
12192
+ /**
12193
+ * 「新建记忆」弹窗本体。字段与记忆页逐项一致(名称 / 描述 / 正文 / 形态 + bundle 附件),
12194
+ * 场景一栏改成只读展示 —— 这正是「套用记忆页的新建,但不需要选择场景」。
12195
+ */
12196
+ function memoryCreateNode() {
12197
+ if (!memNew || !modal || modal.type !== 'scene-editor') return null
12198
+ var nameInvalid = String(memNew.name || '').trim() !== '' && !isValidSceneSegment(String(memNew.name || '').trim())
12199
+ var descLen = String(memNew.description || '').length
12200
+ var target = memoryCreateTarget()
12201
+ // 撞名改由提交时的确认窗处理(`DupNameDialog`):输入框下面那行"保存会记为「X」"
12202
+ // 是它的**预告版**,用户 2026-10-14 裁定啰嗦,删掉 —— 真撞上了才弹窗问一句。
12203
+ // 改草稿时标题与主按钮换成「编辑 / 保存」那一套(与记忆页同词条)——否则同一个弹窗
12204
+ // 在"新建"和"改一条已存在的东西"两种语义下长得一模一样,用户分不清点下去会加一条还是改一条。
12205
+ var editing = memNew.editing != null
12206
+ return React.createElement(Modal, { key: 'memnew', className: 'dsm-modal-compact', title: editing ? t('memory.edit.title') : t('memory.create.title'), closeLabel: t('btn.close'), onClose: function () { setMemNew(null) } },
12207
+ React.createElement('div', { className: 'dsm-form' },
12208
+ React.createElement('label', { className: 'dsm-field' },
12209
+ React.createElement('span', { className: 'dsm-label' }, t('memory.field.group')),
12210
+ // 只读一行:场景由上下文决定,不给选择器,也不加说明(用户 2026-10-14 裁定
12211
+ // 撤掉那句「记忆归属于当前编辑的场景」—— 灰掉的框里写着场景名,本身就是答案)。
12212
+ React.createElement('input', { className: 'dsm-control', value: sceneLabel(data.scenes, target), disabled: true, onChange: function () {} })),
12213
+ React.createElement('label', { className: 'dsm-field' },
12214
+ React.createElement('span', { className: 'dsm-label' }, t('memory.field.name')),
12215
+ React.createElement('input', { className: 'dsm-control' + (nameInvalid ? ' dsm-rule-invalid' : ''), value: memNew.name || '', placeholder: t('memory.field.name.placeholder'), onChange: function (e) { setMemNew(Object.assign({}, memNew, { name: e.target.value })) } }),
12216
+ nameInvalid ? React.createElement('p', { className: 'dsm-rule-hint' }, t('memory.name.invalid')) : null),
12217
+ React.createElement('label', { className: 'dsm-field' },
12218
+ React.createElement('div', { className: 'dsm-budget-meta' },
12219
+ React.createElement('span', { className: 'dsm-label' }, t('memory.field.description')),
12220
+ React.createElement('span', { className: 'dsm-char-count' + (descLen > 500 ? ' dsm-char-over' : '') }, descLen + ' / 500')),
12221
+ React.createElement('input', { className: 'dsm-control', value: memNew.description || '', placeholder: t('memory.field.description.placeholder'), onChange: function (e) { setMemNew(Object.assign({}, memNew, { description: e.target.value })) } })),
12222
+ React.createElement('label', { className: 'dsm-field' },
12223
+ React.createElement('span', { className: 'dsm-label' }, t('memory.field.body')),
12224
+ React.createElement('textarea', { className: 'dsm-control dsm-textarea-md', value: memNew.body || '', placeholder: t('memory.field.body.placeholder'), onChange: function (e) { setMemNew(Object.assign({}, memNew, { body: e.target.value })) } })),
12225
+ React.createElement('div', { className: 'dsm-field' },
12226
+ React.createElement('span', { className: 'dsm-label' }, t('memory.field.form')),
12227
+ // flat / bundle 就两个值:分段按钮摊开、点击即切(与记忆页同一控件)。
12228
+ React.createElement('div', { className: 'dsm-form-seg', role: 'group' },
12229
+ RULE_FORM_OPTIONS.map(function (o) {
12230
+ return React.createElement('button', { key: o.value, type: 'button', 'aria-pressed': (memNew.form || 'flat') === o.value, onClick: function () { setMemNew(Object.assign({}, memNew, { form: o.value })) } }, o.label);
12231
+ })),
12232
+ memNew.form === 'bundle'
12233
+ ? React.createElement('div', { className: 'dsm-field' },
12234
+ React.createElement('div', { className: 'dsm-budget-meta' },
12235
+ React.createElement('span', { className: 'dsm-label' }, t('memory.field.attach')),
12236
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy, onClick: pickMemAttach }, t('memory.attach.add'))),
12237
+ React.createElement('input', { ref: memAttachRef, type: 'file', multiple: true, className: 'dsm-hidden-input', onChange: onMemAttachPicked }),
12238
+ (memNew.files || []).length
12239
+ ? React.createElement('div', { className: 'dsm-attach-list' }, (memNew.files || []).map(function (f, i) {
12240
+ return React.createElement('div', { key: 'p:' + i + ':' + f.name, className: 'dsm-file', title: f.name },
12241
+ React.createElement('span', { className: 'dsm-file-kind', 'aria-hidden': 'true' }, 'NEW'),
12242
+ React.createElement('span', { className: 'dsm-file-name' }, f.name),
12243
+ React.createElement('span', { className: 'dsm-file-meta' }, fmtBytes(f.size) + ' · ' + t('memory.attach.pending')),
12244
+ React.createElement('button', { type: 'button', className: 'dsm-file-remove', disabled: busy, 'aria-label': t('memory.attach.remove') + ' ' + f.name, onClick: function () { dropMemPending(i) } }, '×'))
12245
+ }))
12246
+ : null)
12247
+ : null),
12248
+ memNew.error ? React.createElement('div', { className: 'dsm-feedback dsm-error', role: 'alert' }, String(memNew.error)) : null),
12249
+ React.createElement('div', { className: 'dsm-modal-actions' },
12250
+ React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: busy || nameInvalid || descLen > 500 || !String(memNew.name || '').trim() || !String(memNew.body || '').trim(), onClick: submitMemoryCreate }, editing ? t('memory.btn.save') : t('memory.btn.create'))))
12251
+ }
11166
12252
  /** 组 1:MCP 工具集(服务器级行选择 → 行内「编辑」打开次级弹窗:备注 + 工具行选择)。 */
11167
12253
  function mcpGroup() {
11168
12254
  var sections = modalSections()
@@ -11463,7 +12549,8 @@ function enabledMemoryCount(rules) {
11463
12549
  mcp: modal.sections.mcp ? Object.keys(modal.sections.mcp).length : 0,
11464
12550
  skills: modal.sections.skills ? modal.sections.skills.length : 0,
11465
12551
  subagents: modal.sections.subagents ? modal.sections.subagents.length : 0,
11466
- memories: own.length,
12552
+ // 创建态还没落盘的那批也算进去:它们同样会在「创建」那一刻变成这个场景的记忆。
12553
+ memories: own.length + ((modal.pendingMems || []).length),
11467
12554
  })
11468
12555
  var descLen = String(sceneForm.description || '').length
11469
12556
  // 字段级错误只留**真正关于本字段**的(用户 2026-10-12:「场景名下面的删除」)。
@@ -11489,7 +12576,7 @@ function enabledMemoryCount(rules) {
11489
12576
  title: React.createElement('span', { className: 'dsm-modal-title-row' },
11490
12577
  isEdit ? t('scenes.editor.title', { name: label }) : t('memory.scene.createTitle'),
11491
12578
  React.createElement('span', { className: 'dsm-editor-summary' }, summary)),
11492
- closeLabel: t('btn.close'), onClose: function () { setModal(null) } },
12579
+ closeLabel: t('btn.close'), onClose: function () { if (modal.draftKey) stashFormDraft(modal.draftKey, sceneForm); setModal(null) } },
11493
12580
  modal.error ? React.createElement(Notice, { kind: 'err', text: modal.error }) : null,
11494
12581
  React.createElement('div', { className: 'dsm-form' },
11495
12582
  React.createElement('div', { className: 'dsm-field-row dsm-editor-basics' },
@@ -11645,7 +12732,10 @@ function enabledMemoryCount(rules) {
11645
12732
  *
11646
12733
  * `nameOnly`:技能键是 `<来源>/<名字>`,而自定义来源的键是一串机器名
11647
12734
  * (`custom-d38da02b873bdb5c`)—— 铺在卡上没人认得出那是哪个技能。技能只取名字
11648
- * (最后一段),来源与人设本来就是短名(用户 2026-09-23 裁定)。
12735
+ * (最后一段),人设本来就是短名。**技能目录(来源)不上卡**:来源级切换是技能
12736
+ * 启停的实现细节(勾了技能的来源才启用、一条都没勾就停用),项目来源的 key 还是
12737
+ * 一串路径哈希(`project-dsh:47d48f408c5edc5e`),而那个目录对用户可能根本不存在、
12738
+ * 也不是新建技能的落点 —— 报出来只会被当成故障(用户 2026-10-15 裁定)。
11649
12739
  */
11650
12740
  function previewNames(list, nameOnly) {
11651
12741
  var names = (list || []).map(function (item) {
@@ -11749,8 +12839,6 @@ function enabledMemoryCount(rules) {
11749
12839
  pushRow(rest, 'scenes.preview.row.serversOff', mcp.serversOff)
11750
12840
  pushRow(rest, 'scenes.preview.row.toolsOn', mcp.toolsOn)
11751
12841
  pushRow(rest, 'scenes.preview.row.toolsOff', mcp.toolsOff)
11752
- pushRow(rest, 'scenes.preview.row.sourcesOn', sk.sourcesOn)
11753
- pushRow(rest, 'scenes.preview.row.sourcesOff', sk.sourcesOff)
11754
12842
  pushRow(rest, 'scenes.preview.row.skillsOn', sk.on, true)
11755
12843
  pushRow(rest, 'scenes.preview.row.skillsOff', sk.off, true)
11756
12844
  pushRow(rest, 'scenes.preview.row.personasOn', sub.on)
@@ -12063,6 +13151,26 @@ function enabledMemoryCount(rules) {
12063
13151
  React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: busy || preview.loading, onClick: confirmEnter }, t('scenes.preview.enter')))) : null,
12064
13152
  // 场景编辑器(新建 / 编辑 / 档案三弹窗融合,见 editorNode)。
12065
13153
  editorNode(),
13154
+ // 「新建记忆」弹窗(记忆集组头那颗「创建」):同样排在编辑器之后 —— 它是编辑器里
13155
+ // 开出来的子弹窗,后渲染的盖在上面。
13156
+ memoryCreateNode(),
13157
+ // 「已存在同名记忆」小窗:叠在「新建记忆」弹窗之上(三个入口的层级从下往上依次是
13158
+ // 场景编辑器 → 新建记忆 → 同名确认)。取消 → 回到新建记忆弹窗改名字,正文与附件不动。
13159
+ memDupAsk ? React.createElement(DupNameDialog, {
13160
+ key: 'memdup', t: t, kind: t('dup.kind.memory'),
13161
+ name: memDupAsk.name, finalName: memDupAsk.finalName, action: 'create',
13162
+ busy: busy,
13163
+ onCancel: function () { setMemDupAsk(null) },
13164
+ onConfirm: function () { setMemDupAsk(null); stageMemoryDraft(memDupAsk.finalName) },
13165
+ }) : null,
13166
+ // 「已存在同名场景」小窗:叠在场景编辑器之上。取消 → 回到编辑器改名,档案四组不动。
13167
+ sceneDupAsk ? React.createElement(DupNameDialog, {
13168
+ key: 'scenedup', t: t, kind: t('dup.kind.scene'),
13169
+ name: sceneDupAsk.name, finalName: sceneDupAsk.finalName, action: 'create',
13170
+ busy: busy,
13171
+ onCancel: function () { setSceneDupAsk(null) },
13172
+ onConfirm: function () { setSceneDupAsk(null); saveScene(sceneDupAsk.finalName, false) },
13173
+ }) : null,
12066
13174
  // 快捷提示词勾选弹窗(2026-10-12):**排在编辑器之后**,两层遮罩同 z-index,
12067
13175
  // 后渲染的在上面 —— 它是编辑器里那颗按钮开出来的子弹窗。
12068
13176
  quickPick ? React.createElement(Modal, {
@@ -12130,6 +13238,12 @@ function enabledMemoryCount(rules) {
12130
13238
  var result = rs[0], setResult = rs[1]
12131
13239
  var ms = React.useState(null)
12132
13240
  var modal = ms[0], setModal = ms[1]
13241
+ // 切走这一页 → 编辑弹窗关窗时那份暂存作废(见 42-shared-ui.js 的 useFormDraftScope)。
13242
+ useFormDraftScope('sub:')
13243
+ // 「已存在同名人设」确认弹窗(用户 2026-10-14):`{ name, finalName }` 或 null。
13244
+ // 撞名不再静默退号(`写作助手` → `写作助手(1)`),先问一句再建。
13245
+ var dups = React.useState(null)
13246
+ var dupAsk = dups[0], setDupAsk = dups[1]
12133
13247
  /**
12134
13248
  * 「思考强度」的加载状态。key = 已拉取/正在拉取的 `provider\0model`(空串 = 还没选模型)。
12135
13249
  * 与 `cands` 分开两个 state:档位是**按模型**懒加载的,而候选目录是一次性拉的,
@@ -12284,12 +13398,12 @@ function enabledMemoryCount(rules) {
12284
13398
  /** 高级选项:默认收起;已经在用模型/工具限制的人设自动展开(否则用户看不见自己配了什么)。 */
12285
13399
  /** 「不限制嵌套」的取值(与 src/subagents/service.ts 的 UNLIMITED_PERSONA_CATALOG_DEPTH 同值)。 */
12286
13400
  var CATALOG_DEPTH_UNLIMITED = 99
12287
- /** 目录注入深度的选项文案:下拉与折叠摘要共用一处,免得两处说法分叉。 */
13401
+ /** 目录注入深度的四档文案(分段按钮的键面);手写过 4-98 的旧值一律报「3层」。 */
12288
13402
  function catalogDepthLabel(n) {
12289
- if (n === 1) return t('subagents.field.catalogDepth.onlyTop')
12290
- if (n === 2) return t('subagents.field.catalogDepth.toChild')
13403
+ if (n === 1) return t('subagents.field.catalogDepth.d1')
13404
+ if (n === 2) return t('subagents.field.catalogDepth.d2')
12291
13405
  if (n >= CATALOG_DEPTH_UNLIMITED) return t('subagents.field.catalogDepth.unlimited')
12292
- return t('subagents.field.catalogDepth.toGrand')
13406
+ return t('subagents.field.catalogDepth.d3')
12293
13407
  }
12294
13408
  function initialAdvanced(p) {
12295
13409
  var modes = p && p.toolsByPreset ? Object.keys(p.toolsByPreset).length : 0
@@ -12312,9 +13426,23 @@ function enabledMemoryCount(rules) {
12312
13426
  })
12313
13427
  return out
12314
13428
  }
13429
+ /**
13430
+ * 关窗 ≠ 丢掉:打开时按这个键取回刚才那份(30 秒内),关窗时按同一个键存回去。
13431
+ * 键带在 modal 上而不是从 `form.name` 推 —— 名字框是用户正在改的那个字段,拿它当键
13432
+ * 会出现"存进 A 键、取回时读 B 键"。
13433
+ */
13434
+ function applyDraft(base, draft) {
13435
+ if (!draft) return base
13436
+ return Object.assign({}, base, {
13437
+ advanced: draft.advanced === true,
13438
+ form: Object.assign({}, base.form, draft.form, { error: null }),
13439
+ })
13440
+ }
12315
13441
  function openEditor(name) {
13442
+ var draftKey = name ? 'sub:edit:' + name : 'sub:create'
13443
+ var draft = takeFormDraft(draftKey)
12316
13444
  if (!name) {
12317
- setModal({ type: 'editor', mode: 'create', advanced: false, openMode: null, stoppedRules: {}, modeQuery: '', legacyTarget: '', form: { name: '', description: '', provider: '', model: '', reasoningEffort: '', catalogDepth: 1, continuable: false, tools: [], toolsDeny: [], toolsByPreset: {}, body: '', output: '', error: null } })
13445
+ setModal(applyDraft({ type: 'editor', mode: 'create', draftKey: draftKey, advanced: false, openMode: null, stoppedRules: {}, modeQuery: '', legacyTarget: '', form: { name: '', description: '', provider: '', model: '', reasoningEffort: '', catalogDepth: 1, continuable: false, tools: [], toolsDeny: [], toolsByPreset: {}, body: '', output: '', error: null } }, draft))
12318
13446
  return
12319
13447
  }
12320
13448
  setBusy(true)
@@ -12323,7 +13451,7 @@ function enabledMemoryCount(rules) {
12323
13451
  if (res && res.ok) {
12324
13452
  var p = res.persona || {}
12325
13453
  var advanced = initialAdvanced(p)
12326
- setModal({ type: 'editor', mode: 'edit', originalName: String(p.name || name), advanced: advanced, openMode: null, stoppedRules: {}, modeQuery: '', legacyTarget: '', form: {
13454
+ setModal(applyDraft({ type: 'editor', mode: 'edit', draftKey: draftKey, originalName: String(p.name || name), advanced: advanced, openMode: null, stoppedRules: {}, modeQuery: '', legacyTarget: '', form: {
12327
13455
  name: p.name || name, description: p.description || '', provider: p.provider || '', model: p.model || '',
12328
13456
  // 思考强度:服务端回空串 = 这份人设没指定(不是"没有档位")。空值不落盘,见 serializePersona。
12329
13457
  reasoningEffort: p.reasoningEffort || '',
@@ -12333,14 +13461,19 @@ function enabledMemoryCount(rules) {
12333
13461
  continuable: p.continuable === true,
12334
13462
  tools: (p.tools || []).slice(), toolsDeny: (p.toolsDeny || []).slice(),
12335
13463
  toolsByPreset: cloneRules(p.toolsByPreset), body: p.body || '', output: p.output || '', error: null,
12336
- } })
13464
+ } }, draft))
12337
13465
  // 已配过限制的人设**一打开就是展开的**(initialAdvanced)→ 候选数据必须在这里也拉,
12338
13466
  // 否则四行模式先亮"宿主没有回传预设名单",非得点两次「高级选项」才补上(用户实测)。
12339
13467
  // 思考强度同理:展开着就要有档位清单(它按模型懒加载,所以这里显式带上 model)。
12340
- if (advanced) { loadCandidates(); loadEfforts(p.provider, p.model) }
13468
+ if (advanced || (draft && draft.advanced === true)) { loadCandidates(); loadEfforts(p.provider, p.model) }
12341
13469
  } else setResult({ ok: false, text: translateError(t, res) })
12342
13470
  }).catch(function (e) { setBusy(false); setResult({ ok: false, text: errMsg(e) }) })
12343
13471
  }
13472
+ /** 关掉编辑弹窗:存下这一份再关(提交成功那条路不走这里,所以不必事后清)。 */
13473
+ function dismissEditor() {
13474
+ if (modal && modal.type === 'editor' && modal.draftKey) stashFormDraft(modal.draftKey, { advanced: modal.advanced, form: modal.form })
13475
+ setModal(null)
13476
+ }
12344
13477
  function setForm(patch) { if (modal && modal.type === 'editor') setModal(Object.assign({}, modal, { form: Object.assign({}, modal.form, patch) })) }
12345
13478
  /** 列表里增删一项(勾选语义:存在 = 勾上)。 */
12346
13479
  function toggled(list, name) {
@@ -12686,15 +13819,34 @@ function enabledMemoryCount(rules) {
12686
13819
  loadEfforts(modal.form.provider, modal.form.model)
12687
13820
  }
12688
13821
  }
13822
+ /**
13823
+ * 编辑器唯一的保存动作。**新建撞名先问一句**(用户 2026-10-14 第二轮)。
13824
+ *
13825
+ * 判据是当前人设清单(`data.subagents` 的 `name`)—— 人设名就是文件名、
13826
+ * `validPersonaName` 不做任何归一化,所以本地查得到就是真的撞了。编辑态不做这一步:
13827
+ * 改的是自己,而且改名撞上别人由服务端 `subagent-update` 自己拒(`人设已存在`)。
13828
+ */
12689
13829
  function submitEditor() {
13830
+ if (!modal || modal.type !== 'editor') return
13831
+ var typed = String(modal.form.name || '').trim()
13832
+ if (modal.mode === 'create') {
13833
+ var taken = {}
13834
+ ;(data.subagents || []).forEach(function (p) { taken[String(p.name)] = true })
13835
+ var finalName = uniqueName(typed, taken)
13836
+ if (finalName !== typed) { setDupAsk({ name: typed, finalName: finalName }); return }
13837
+ }
13838
+ saveEditor(typed)
13839
+ }
13840
+ /** 真正落盘那一步(名字已定)。`name` 来自 `submitEditor` 或确认弹窗。 */
13841
+ function saveEditor(name) {
12690
13842
  if (!modal || modal.type !== 'editor') return
12691
13843
  setBusy(true)
12692
13844
  var op = modal.mode === 'create' ? 'subagent-create' : 'subagent-update'
12693
13845
  // 编辑时名字可改:nextName 只在真的改过时才传(服务端按原名定位文件)。
12694
- var renamed = modal.mode === 'edit' && modal.originalName && modal.originalName !== modal.form.name
13846
+ var renamed = modal.mode === 'edit' && modal.originalName && modal.originalName !== name
12695
13847
  apiCall(op, {
12696
- name: renamed ? String(modal.originalName) : modal.form.name,
12697
- ...(renamed ? { nextName: String(modal.form.name || '').trim() } : {}),
13848
+ name: renamed ? String(modal.originalName) : name,
13849
+ ...(renamed ? { nextName: name } : {}),
12698
13850
  description: modal.form.description,
12699
13851
  provider: modal.form.provider, model: modal.form.model,
12700
13852
  catalogDepth: modal.form.catalogDepth,
@@ -12706,7 +13858,7 @@ function enabledMemoryCount(rules) {
12706
13858
  output: modal.form.output || '',
12707
13859
  }).then(function (res) {
12708
13860
  setBusy(false)
12709
- if (res && res.ok) { var sw = stateSyncWarn(t, res); setModal(null); setResult({ ok: !sw, warning: !!sw, text: t('done.saved', { name: modal.form.name }) + (sw ? ' ' + sw : '') }); refresh(true) }
13861
+ if (res && res.ok) { var sw = stateSyncWarn(t, res); setModal(null); setResult({ ok: !sw, warning: !!sw, text: t('done.saved', { name: name }) + (sw ? ' ' + sw : '') }); refresh(true) }
12710
13862
  else setModal(Object.assign({}, modal, { form: Object.assign({}, modal.form, { error: translateError(t, res) }) }))
12711
13863
  }).catch(function (e) { setBusy(false); setModal(Object.assign({}, modal, { form: Object.assign({}, modal.form, { error: errMsg(e) }) })) })
12712
13864
  }
@@ -12825,13 +13977,13 @@ function enabledMemoryCount(rules) {
12825
13977
  ],
12826
13978
  })
12827
13979
  })),
12828
- modal && modal.type === 'editor' ? React.createElement(Modal, { key: 'sedit', wide: true, className: 'dsm-modal-compact', title: modal.mode === 'create' ? t('subagents.create') : t('subagents.edit') + ' · ' + modal.form.name, closeLabel: t('btn.close'), onClose: function () { setModal(null) } },
13980
+ modal && modal.type === 'editor' ? React.createElement(Modal, { key: 'sedit', wide: true, className: 'dsm-modal-compact dsm-modal-form-tall', title: modal.mode === 'create' ? t('subagents.create') : t('subagents.edit') + ' · ' + modal.form.name, closeLabel: t('btn.close'), onClose: dismissEditor },
12829
13981
  React.createElement('div', { className: 'dsm-form' },
12830
13982
  React.createElement('label', { className: 'dsm-field' },
12831
13983
  React.createElement('span', { className: 'dsm-label' }, t('subagents.field.name')),
12832
13984
  React.createElement('input', { className: 'dsm-control', value: modal.form.name || '', placeholder: 'code-review', onChange: function (e) { setForm({ name: e.target.value }) } })),
12833
13985
  React.createElement('label', { className: 'dsm-field' },
12834
- // 描述会整段进「可委派的子智能体」目录,上限就是注入侧的截断长度(`CATALOG_DESC_MAX`);
13986
+ // 描述会整段进「当前子智能体」目录,上限就是注入侧的截断长度(`CATALOG_DESC_MAX`);
12835
13987
  // 存量人设的超长描述标红(服务端不校验这个长度,只有注入时截)。
12836
13988
  React.createElement('div', { className: 'dsm-budget-meta' },
12837
13989
  React.createElement('span', { className: 'dsm-label' }, t('subagents.field.description')),
@@ -12955,40 +14107,41 @@ function enabledMemoryCount(rules) {
12955
14107
  // ── 运行方式:这份人设"什么时候跑、跑在哪些会话里" ──
12956
14108
  React.createElement('div', { className: 'dsm-adv-group' },
12957
14109
  React.createElement('div', { className: 'dsm-adv-group-head' }, t('subagents.adv.group.runtime')),
12958
- // 目录注入深度:人设目录注入到哪些会话(默认 1 = 只在顶层)。用下拉而不是数字
12959
- // 输入:只有 1/2/3 三个有意义的档,手写数字写错要到注入时才暴露。
14110
+ // 目录注入与启动追问并排一行(用户 2026-10-16):两项都是这一组的一次性
14111
+ // 选择,各占一行会把「运行方式」拉成三条长行;控件统一用分段按钮(与
14112
+ // 「创建位置」「提示词类型」同款)—— 档数就 4 个 / 2 个,下拉要点开才看得到全部。
12960
14113
  //
14114
+ // 目录注入深度:人设目录注入到哪些会话(默认 1 = 只在顶层)。
12961
14115
  // ⚠️ 它**不是**递归上限。2026-09-17 用户实测后改名(原名 maxDepth /「委派预算」):
12962
14116
  // 官方 `dsh-tool-subagent` 默认 `maxDepth: 3`,子代理本来就能继续嵌套,本插件
12963
14117
  // 也不再向官方传 maxDepth。这个字段只决定常驻目录出现在哪些深度的会话里。
12964
- React.createElement('label', { className: 'dsm-field dsm-adv-half' },
12965
- React.createElement('span', { className: 'dsm-label' }, t('subagents.field.catalogDepth')),
12966
- React.createElement(SourceSelect, {
12967
- options: [1, 2, 3, CATALOG_DEPTH_UNLIMITED].map(function (n) {
12968
- // 选项文字不带序号:前面再加一个「2 ·」是同一件事说两遍(用户 2026-09-17
12969
- // 指出)。下拉的 value 仍是数字,存的还是 catalogDepth 本身。
12970
- return { value: String(n), label: catalogDepthLabel(n) }
12971
- }),
12972
- value: String(typeof modal.form.catalogDepth === 'number' ? modal.form.catalogDepth : 1),
12973
- disabled: busy,
12974
- onChange: function (v) { setForm({ catalogDepth: Number(v) }) },
12975
- })),
12976
- // 允许追问(frontmatter `continuable: true`):这条通道的**产出是异步的** ——
12977
- // 委派只回执一个 agent id,子代理在后台跑、完成后自己把结果发回来,
12978
- // 之后可以用宿主自带的 `send_message` 追问、`interrupt_agent` 中止。
12979
- // 默认关:它占用宿主"可继续子代理"的名额(`maxActiveSubagents`,默认 8),
12980
- // 且要求宿主加载了持久化与 session query 两个服务。
12981
- // 机制与代价的完整说明记在 CHANGELOG / README,不进表单(0.19.0 重排)。
12982
- // 切片五:开关行换 SettingsRow + Switch(REDESIGN §5.2),原生勾选框退场。
12983
- React.createElement(SettingsRow, {
12984
- name: t('subagents.field.continuable'),
12985
- desc: t('subagents.field.continuable.short'),
12986
- control: React.createElement(Switch, {
12987
- on: modal.form.continuable === true, disabled: busy,
12988
- label: t('subagents.field.continuable'),
12989
- onClick: function () { setForm({ continuable: !(modal.form.continuable === true) }) },
12990
- }),
12991
- })),
14118
+ React.createElement('div', { className: 'dsm-field-row' },
14119
+ React.createElement('div', { className: 'dsm-field' },
14120
+ React.createElement('span', { className: 'dsm-label' }, t('subagents.field.catalogDepth')),
14121
+ React.createElement('div', { className: 'dsm-form-seg', role: 'group', 'aria-label': t('subagents.field.catalogDepth') },
14122
+ [1, 2, 3, CATALOG_DEPTH_UNLIMITED].map(function (n) {
14123
+ return React.createElement('button', {
14124
+ key: n, type: 'button',
14125
+ 'aria-pressed': String(typeof modal.form.catalogDepth === 'number' ? modal.form.catalogDepth : 1) === String(n) ? 'true' : 'false',
14126
+ onClick: function () { setForm({ catalogDepth: n }) },
14127
+ }, catalogDepthLabel(n))
14128
+ }))),
14129
+ // 启动追问(frontmatter `continuable: true`):这条通道的**产出是异步的** ——
14130
+ // 委派只回执一个 agent id,子代理在后台跑、完成后自己把结果发回来,
14131
+ // 之后可以用宿主自带的 `send_message` 追问、`interrupt_agent` 中止。
14132
+ // 默认禁止:它占用宿主"可继续子代理"的名额(`maxActiveSubagents`,默认 8),
14133
+ // 且要求宿主加载了持久化与 session query 两个服务。
14134
+ // 机制与代价的完整说明记在 CHANGELOG / README,不进表单(0.19.0 重排)。
14135
+ React.createElement('div', { className: 'dsm-field' },
14136
+ React.createElement('span', { className: 'dsm-label' }, t('subagents.field.continuable')),
14137
+ React.createElement('div', { className: 'dsm-form-seg', role: 'group', 'aria-label': t('subagents.field.continuable') },
14138
+ [{ key: 'on', value: true }, { key: 'off', value: false }].map(function (choice) {
14139
+ return React.createElement('button', {
14140
+ key: choice.key, type: 'button',
14141
+ 'aria-pressed': (modal.form.continuable === true) === choice.value ? 'true' : 'false',
14142
+ onClick: function () { setForm({ continuable: choice.value }) },
14143
+ }, t('subagents.field.continuable.' + choice.key))
14144
+ }))))),
12992
14145
  // ── 工具限制:按 Agent 预设一行一个模式(默认全折叠、全部未启动,白/黑互斥)。
12993
14146
  // 旧格式的全局名单(老文件 / 别处导入)在这里只读呈现,点「转换」才搬进某个模式。
12994
14147
  React.createElement('div', { className: 'dsm-adv-group' },
@@ -13011,7 +14164,7 @@ function enabledMemoryCount(rules) {
13011
14164
  React.createElement('p', { className: 'dsm-help' }, t('subagents.delete.desc', { name: modal.name })),
13012
14165
  React.createElement('div', { className: 'dsm-modal-actions' },
13013
14166
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-danger', disabled: busy, onClick: function () { submitDelete(modal.name) } }, t('memory.btn.delete.confirm')))) : null,
13014
- modal && modal.type === 'import' ? React.createElement(ImportModal, { key: 'simp', t: t, title: t('subagents.import.title'), busy: busy, requirement: t('upload.req.mdzip'), requirementTitle: [t('upload.requirement.persona.1'), t('upload.requirement.persona.2'), t('upload.requirement.persona.3')].join('\n'), onClose: function () { setModal(null) }, onSubmit: submitImport }) : null,
14167
+ modal && modal.type === 'import' ? React.createElement(ImportModal, { key: 'simp', t: t, title: t('subagents.import.title'), busy: busy, hint: t('upload.drop.mzip'), requirement: t('upload.req.mdzip'), requirementTitle: [t('upload.requirement.persona.1'), t('upload.requirement.persona.2'), t('upload.requirement.persona.3')].join('\n'), onClose: function () { setModal(null) }, onSubmit: submitImport }) : null,
13015
14168
  modal && modal.type === 'export' ? React.createElement(ExportModal, {
13016
14169
  key: 'export', t: t, title: t('export.subagents'),
13017
14170
  items: data.subagents.map(function (p) { return { key: p.name, name: p.name, desc: p.description || '' } }),
@@ -13032,6 +14185,15 @@ function enabledMemoryCount(rules) {
13032
14185
  domain: 'agents',
13033
14186
  onClose: function () { setTrash(false) },
13034
14187
  onReload: function () { refresh(true) },
14188
+ }) : null,
14189
+ // 「已存在同名人设」小窗:叠在编辑器之上(`Modal` 自带 gateModalDepth 计数)。
14190
+ // 取消 → 回到编辑器改名字,简介与正文一个字都不动。
14191
+ dupAsk ? React.createElement(DupNameDialog, {
14192
+ key: 'dup-name', t: t, kind: t('dup.kind.subagent'),
14193
+ name: dupAsk.name, finalName: dupAsk.finalName, action: 'create',
14194
+ busy: busy,
14195
+ onCancel: function () { setDupAsk(null) },
14196
+ onConfirm: function () { setDupAsk(null); saveEditor(dupAsk.finalName) },
13035
14197
  }) : null)
13036
14198
  }
13037
14199
 
@@ -13106,6 +14268,8 @@ function enabledMemoryCount(rules) {
13106
14268
  warnings: [],
13107
14269
  })
13108
14270
  var data = state[0], setData = state[1]
14271
+ // 切走这一页 → 记忆编辑弹窗关窗时那份暂存作废(见 42-shared-ui.js 的 useFormDraftScope)。
14272
+ useFormDraftScope('mem:')
13109
14273
  var qs = React.useState('')
13110
14274
  // 场景锁定(v0.8):任一场景锁定 = 五个域整体冻结;本页全部写控件禁用。
13111
14275
  var anyLocked = data.anyLocked === true
@@ -13130,7 +14294,7 @@ function enabledMemoryCount(rules) {
13130
14294
  var editor = es[0], setEditor = es[1]
13131
14295
  var ups = useUndoState()
13132
14296
  var undo = ups[0], setUndo = ups[1], dismissUndo = ups[2]
13133
- var imps = React.useState('')
14297
+ var imps = React.useState('global')
13134
14298
  var importScene = imps[0], setImportScene = imps[1]
13135
14299
  // 新建场景弹窗与场景建/删已移至「场景」页(ScenesPage)。
13136
14300
  // 记忆回收站:true = 打开本页那一个回收站(42-shared-ui.js 的 TrashModal)。
@@ -13140,6 +14304,10 @@ function enabledMemoryCount(rules) {
13140
14304
  // 那个是页面级回执(会自动消失),导出结果只该出现在弹窗里。
13141
14305
  var exs = React.useState(null)
13142
14306
  var exportState = exs[0], setExportState = exs[1]
14307
+ // 「已存在同名记忆」确认弹窗的状态(用户 2026-10-14):`{ name, finalName }` 或 null。
14308
+ // 撞名不再静默退号,先问一句;确认后才按 `finalName` 提交(见 submitCreate)。
14309
+ var dqs = React.useState(null)
14310
+ var dupAsk = dqs[0], setDupAsk = dqs[1]
13143
14311
  // bundle 附件的隐藏文件选择器;上限与服务端 MAX_ATTACH_ENTRY_BYTES 对齐。
13144
14312
  var attachRef = React.useRef(null)
13145
14313
  var ATTACH_MAX_MB = 8
@@ -13211,22 +14379,41 @@ function enabledMemoryCount(rules) {
13211
14379
 
13212
14380
  // ── 记忆 CRUD ──
13213
14381
  function openCreate(sceneName) {
13214
- setEditor({ mode: 'create', id: '', group: sceneName || '', name: '', description: '', body: '', form: 'flat', path: '', error: null, files: [], attachments: [] })
14382
+ /**
14383
+ * 默认落点 = 保留场景「全局」(`GLOBAL_SCENE`,与 48-slash.js 那份同名常量同值,
14384
+ * 服务端出处 `src/memories/constants.ts`)。
14385
+ *
14386
+ * 这里必须把"空"落成一个**具体名字**,不能留着 `''`:
14387
+ * · 界面上 `''` 会被 `SourceSelect` 兜到第一项,看着是「全局」,但那是巧合;
14388
+ * · `submitCreate` 的本地查重是按 `r.group` **精确比对**的,而全局桶里那些记忆
14389
+ * 的 `group` 是 `'global'` 而不是 `''` —— 留着空值就查不到,撞名要等到服务端
14390
+ * 才被拒(`error.rules.exists`),用户拿到的是报错而不是那颗确认弹窗。
14391
+ * 宿主侧两种写法等价(`rules-create` 自己把空 group 映射到 `global`),所以这里
14392
+ * 只是把它提前落定,不改变任何落盘结果。
14393
+ */
14394
+ setEditor(takeFormDraft('mem:create') || { mode: 'create', id: '', group: sceneName || GLOBAL_SCENE, name: '', description: '', body: '', form: 'flat', path: '', error: null, files: [], attachments: [] })
13215
14395
  setModal({ type: 'editor' })
13216
14396
  }
13217
14397
  function openEditor(rule, focusAttach) {
13218
14398
  if (busy) return
13219
14399
  setBusy(true); setResult(null)
14400
+ var draftKey = 'mem:edit:' + rule.id
13220
14401
  apiCall('rules-read', { id: rule.id }).then(function (res) {
13221
14402
  setBusy(false)
13222
14403
  if (res && res.ok && res.rule) {
13223
14404
  var rd = res.rule
13224
- setEditor({ mode: 'edit', id: rd.id, group: rd.group, name: rd.name, description: rd.description || '', body: rd.body || '', form: rd.form === 'bundle' ? 'bundle' : 'flat', path: rd.path || '', error: null, files: [], attachments: rd.attachments || [], focusAttach: focusAttach === true })
14405
+ // 30 秒内重开同一条:把刚才关掉的那份填回来(磁盘那份的内容已经读出来了,
14406
+ // 但用户改过的字段以草稿为准,未改的字段两种路径读到的是一样的)。
14407
+ var draft = takeFormDraft(draftKey)
14408
+ // focusAttach 是"打开就替你把文件框点开"的一次性指令,重放它会凭空弹一次文件选择框。
14409
+ if (draft) draft = Object.assign({}, draft, { focusAttach: false, error: null })
14410
+ setEditor(draft || { mode: 'edit', id: rd.id, group: rd.group, name: rd.name, description: rd.description || '', body: rd.body || '', form: rd.form === 'bundle' ? 'bundle' : 'flat', path: rd.path || '', error: null, files: [], attachments: rd.attachments || [], focusAttach: focusAttach === true })
13225
14411
  setModal({ type: 'editor' })
13226
14412
  } else setResult({ ok: false, text: translateError(t, res) })
13227
14413
  }).catch(function (e) { setBusy(false); setResult({ ok: false, text: errMsg(e) }) })
13228
14414
  }
13229
- function closeEditor() { setModal(null) }
14415
+ // 关掉 ≠ 丢掉:只有这一条路存草稿(提交成功走 finishEditor,就地 setModal(null))。
14416
+ function closeEditor() { if (editor) stashFormDraft(editor.mode === 'edit' ? 'mem:edit:' + editor.id : 'mem:create', editor); setModal(null) }
13230
14417
  // ── 导入记忆(.md / .zip;场景留空 = 保留场景「全局」)──
13231
14418
  function submitImport(files) {
13232
14419
  if (!files || !files.length) return
@@ -13401,10 +14588,32 @@ function enabledMemoryCount(rules) {
13401
14588
  refresh(true)
13402
14589
  })
13403
14590
  }
14591
+ /**
14592
+ * 记忆页「新增」的提交(用户 2026-10-14 第二轮):**先问一句,再退号**。
14593
+ *
14594
+ * 服务端 `rules-create` 撞名是直接拒绝的(`error.rules.exists`),原先界面把这句话
14595
+ * 原样摆出来、让用户自己回去改名。现在改成:本地先查一遍**同一组**里的记忆(记忆 id
14596
+ * 是 `<场景>/<名>`,所以判据只看同组,跨场景同名是合法的、不该拦),撞上了就弹一个
14597
+ * 小窗说明"已有同名",确认后用 `测(1)` 建 —— 名字在这一刻定死,落盘的就是界面上
14598
+ * 写着的那个,不再有"看着叫甲、落盘叫乙"。
14599
+ *
14600
+ * 本地查重不替代服务端那道闸:期间别人(或模型工具)建了一条同名,提交仍会被拒,
14601
+ * 那时照旧如实报错 —— 这里只是把常见情形做顺手,不是替服务端下判断。
14602
+ */
13404
14603
  function submitCreate() {
14604
+ var typed = String(editor.name || '').trim()
14605
+ var group = String(editor.group || '').trim()
14606
+ var taken = {}
14607
+ ;(data.rules || []).forEach(function (r) { if (String(r.group || '') === group) taken[String(r.name)] = true })
14608
+ var finalName = uniqueName(typed, taken)
14609
+ if (finalName !== typed) { setDupAsk({ name: typed, finalName: finalName }); return }
14610
+ createMemory(typed)
14611
+ }
14612
+ /** 真正落盘那一步(名字已定)。`name` 来自 `submitCreate` 或确认弹窗。 */
14613
+ function createMemory(name) {
13405
14614
  var payload = {
13406
14615
  group: String(editor.group || '').trim(),
13407
- name: String(editor.name || '').trim(),
14616
+ name: name,
13408
14617
  description: String(editor.description || '').trim(),
13409
14618
  body: editor.body || '',
13410
14619
  form: editor.form || 'flat',
@@ -13418,8 +14627,8 @@ function enabledMemoryCount(rules) {
13418
14627
  return null
13419
14628
  }
13420
14629
  var id = (res.rule && res.rule.id) || (payload.group ? payload.group + '/' + payload.name : payload.name)
13421
- var name = (res.rule && (res.rule.id || res.rule.name)) || payload.name
13422
- return finishEditor(id, name, pending, 'done.created', 'memory.result.createdAttached')
14630
+ var doneName = (res.rule && (res.rule.id || res.rule.name)) || payload.name
14631
+ return finishEditor(id, doneName, pending, 'done.created', 'memory.result.createdAttached')
13423
14632
  }).catch(function (e) { setBusy(false); setEditor(Object.assign({}, editor, { error: errMsg(e) })) })
13424
14633
  }
13425
14634
  function submitUpdate() {
@@ -13540,9 +14749,12 @@ function enabledMemoryCount(rules) {
13540
14749
  var statusKey = shadowed ? 'status.shadowed' : !enabled ? 'memory.status.off' : sceneActive === false ? 'memory.status.sceneOff' : 'status.enabled'
13541
14750
  // 灰=不进上下文但不是故障(被遮蔽 / 本条停用 / 场景没启用),绿=这条真的会注入。
13542
14751
  var statusTone = shadowed || !enabled || sceneActive === false ? 'muted' : 'ok'
13543
- // 「需要处理」的两种(被覆盖 / 场景未启用)走行 1 的 tag,启停复述走行首状态点
13544
- //(REDESIGN §3.4;切片三移交的「状态点前移」)。
13545
- var attention = statusKey !== 'status.enabled' && statusKey !== 'memory.status.off'
14752
+ // 「需要处理」只剩「被覆盖」一种:它看着是开的、实际不注入,必须在行上说出来。
14753
+ // 「场景未启用」不再占行内标签(用户 2026-10-15 两条一起提的):分组卡头已经写着
14754
+ // 「未启用」,逐行复述是噪音;更要紧的是它原先会把**行首那颗状态点整颗吃掉** ——
14755
+ // 在同一场景里启用一条记忆,点当场消失,看着像这一行坏了。现在点照常画
14756
+ //(灰 = 不进上下文),原因留在 title 上。
14757
+ var attention = shadowed
13546
14758
  // 行 2 元信息(切片三移交的「tags 列清理」):描述 / 多级分组路径 / 附件 / 派生。
13547
14759
  // 附件计数保持**可点**(进编辑弹窗并滚到附件区),其余降为弱色文字。
13548
14760
  //
@@ -13674,7 +14886,7 @@ function enabledMemoryCount(rules) {
13674
14886
  ],
13675
14887
  actions: [
13676
14888
  React.createElement('button', { key: 'new', type: 'button', className: 'dsm-btn', disabled: busy || anyLocked, onClick: function () { openCreate(sceneFilter || '') } }, t('memory.btn.new')),
13677
- React.createElement('button', { key: 'import', type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy || anyLocked, onClick: function () { setResult(null); setImportScene(''); setModal({ type: 'import' }) } }, t('memory.import')),
14889
+ React.createElement('button', { key: 'import', type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy || anyLocked, onClick: function () { setResult(null); setImportScene('global'); setModal({ type: 'import' }) } }, t('memory.import')),
13678
14890
  React.createElement('button', { key: 'export', type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy || !exportableRules.length, onClick: function () { setResult(null); setExportState({ busy: false, result: null }) } }, t('export.memories')),
13679
14891
  // 「全选」从工具条搬到页头、紧挨回收站左侧(用户 2026-10-12:「全选移到回收站
13680
14892
  // 旁边」)。它管的是列表,但和「回收站」一样是**整页范围**的动作,跟其余页头键
@@ -13720,21 +14932,22 @@ function enabledMemoryCount(rules) {
13720
14932
  React.createElement('div', { className: 'dsm-modal-actions' },
13721
14933
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-danger', disabled: busy, onClick: function () { submitDelete(modal.rule) } }, t('memory.btn.delete.confirm')))) : null,
13722
14934
  modal && modal.type === 'import' ? React.createElement(ImportModal, {
13723
- key: 'mimp', t: t, title: t('memory.import.title'), busy: busy,
14935
+ key: 'mimp', t: t, title: t('memory.import.title'), busy: busy, hint: t('upload.drop.mzip'),
13724
14936
  requirement: t('upload.req.mdzip'),
13725
14937
  requirementTitle: [t('upload.requirement.memory.1'), t('upload.requirement.memory.2'), t('upload.requirement.memory.3'), t('upload.requirement.memory.4')].join('\n'),
13726
- // 「导入到场景」与记忆编辑器的「场景」用**同一个**控件(用户 2026-10-12:
13727
- // 「应该是选择场景的样子,现在只能手动填写」)。SceneCombo 既能从已有场景里挑,
13728
- // 也保留手输新场景名的能力(宿主 rules-create 用 mkdir(recursive) 落盘,
13729
- // 自定义场景不必先建目录)—— 换过去是"多一个下拉",不是"少一种填法"。
13730
- extra: React.createElement('label', { className: 'dsm-field' },
14938
+ // 「导入到场景」**只能选**(用户 2026-10-15:手输入口去掉;此前用的是可手输的
14939
+ // SceneCombo)。代价说清楚:`rules-import` 本来会为引用的目录名补出场景记录
14940
+ // (回传 `scenes`,界面用 `memory.import.scenesCreated` 如实告知),去掉手输之后
14941
+ // 要导入到一个全新场景,得先去「场景」页把它建出来。
14942
+ // 默认「全局」:`sceneLabel` 已把保留场景 global 映成「全局」,选项里排最前 ——
14943
+ // 也就是原来"留空 = 全局"那条语义,只是现在看得见、点得着。选项剔掉 `''`
14944
+ // (「未归属场景」那个游离桶):那不是"新记忆该去的地方"(与编辑器同一口径)。
14945
+ extra: React.createElement('div', { className: 'dsm-field' },
13731
14946
  React.createElement('span', { className: 'dsm-label' }, t('memory.import.scene')),
13732
- React.createElement(SceneCombo, {
13733
- options: sceneChoices,
14947
+ React.createElement(SourceSelect, {
14948
+ options: sceneChoices.filter(function (o) { return o.value !== '' }),
13734
14949
  value: importScene,
13735
- placeholder: t('memory.scene.global.hint'),
13736
- browseLabel: t('memory.scene.browse'),
13737
- label: t('memory.import.scene'),
14950
+ ariaLabel: t('memory.import.scene'),
13738
14951
  disabled: busy,
13739
14952
  onChange: setImportScene,
13740
14953
  })),
@@ -13752,21 +14965,30 @@ function enabledMemoryCount(rules) {
13752
14965
  React.createElement('div', { className: 'dsm-form' },
13753
14966
  React.createElement('label', { className: 'dsm-field' },
13754
14967
  React.createElement('span', { className: 'dsm-label' }, t('memory.field.group')),
14968
+ /**
14969
+ * 场景**只能选**,不能手输(用户 2026-10-14)。
14970
+ *
14971
+ * 除了"手输容易打错",更硬的理由在宿主那一侧:`rules-create` 对非全局场景
14972
+ * **要求它已经存在**(`error.rules.sceneNotFound`:「请先在「场景」页创建该场景」),
14973
+ * 所以手输一个不存在的场景名除了换来一条报错之外没有别的结果。要新场景就去
14974
+ * 场景页建 —— 那条路径本来就在,而且它还会把提示词绑定、启用集合一起配好。
14975
+ *
14976
+ * 「全局」是**选项里的一项**,不是"留空":宿主把空 group 映射到保留场景
14977
+ * `global`(`GLOBAL_SCENE`,恒常注入的真实场景)。所以默认值直接就是它,
14978
+ * 底下那句「留空 = 全局」也一并删掉 —— 字段自己写着「全局」,那句话不再说明什么。
14979
+ *
14980
+ * 选项里剔掉 `''`(「未归属场景」那个游离桶):那是给历史数据做清理用的分组,
14981
+ * 不是"新记忆该去的地方"(那里的记忆不会被注入上下文)。
14982
+ */
13755
14983
  editor.mode === 'create'
13756
- ? React.createElement(SceneCombo, {
13757
- options: sceneChoices,
14984
+ ? React.createElement(SourceSelect, {
14985
+ options: sceneChoices.filter(function (o) { return o.value !== '' }),
13758
14986
  value: editor.group || '',
13759
- placeholder: t('memory.field.group.placeholder'),
13760
- browseLabel: t('memory.scene.browse'),
13761
- label: t('memory.field.group'),
14987
+ ariaLabel: t('memory.field.group'),
13762
14988
  disabled: busy,
13763
- invalid: editorGroupInvalid,
13764
14989
  onChange: function (v) { setEditor(Object.assign({}, editor, { group: v })) },
13765
14990
  })
13766
14991
  : React.createElement('input', { className: 'dsm-control', value: editor.group || '', disabled: true, onChange: function () {} }),
13767
- editor.mode === 'create'
13768
- ? React.createElement('p', { className: 'dsm-help' }, t('memory.field.group.hint'))
13769
- : null,
13770
14992
  editorGroupInvalid ? React.createElement('p', { className: 'dsm-rule-hint' }, t('error.rules.invalidGroup')) : null),
13771
14993
  React.createElement('label', { className: 'dsm-field' },
13772
14994
  React.createElement('span', { className: 'dsm-label' }, t('memory.field.name')),
@@ -13811,8 +15033,7 @@ function enabledMemoryCount(rules) {
13811
15033
  React.createElement('span', { className: 'dsm-file-meta' }, fmtBytes(f.size) + ' · ' + t('memory.attach.pending')),
13812
15034
  React.createElement('button', { type: 'button', className: 'dsm-file-remove', disabled: busy, 'aria-label': t('memory.attach.remove') + ' ' + f.name, onClick: function () { dropPending(i) } }, '×'))
13813
15035
  }))
13814
- : React.createElement('p', { className: 'dsm-help' }, t('memory.attach.empty')),
13815
- React.createElement('span', { className: 'dsm-help' }, t('memory.attach.hint')))
15036
+ : null)
13816
15037
  : null),
13817
15038
  editor.error ? React.createElement('div', { className: 'dsm-feedback dsm-error', role: 'alert' }, String(editor.error)) : null),
13818
15039
  // 锁定态把「为什么保存键是灰的」写在弹窗里:编辑页能打开(看内容是只读的),
@@ -13829,6 +15050,15 @@ function enabledMemoryCount(rules) {
13829
15050
  domain: 'memories',
13830
15051
  onClose: function () { setModal(null) },
13831
15052
  onReload: function () { refresh(true) },
15053
+ }) : null,
15054
+ // 「已存在同名记忆」小窗叠在编辑弹窗之上(`Modal` 自带 gateModalDepth 计数,
15055
+ // 叠弹窗是既有形态)。取消 → 回到编辑弹窗改名字,编辑器里的内容一个字都不动。
15056
+ dupAsk ? React.createElement(DupNameDialog, {
15057
+ key: 'dup-name', t: t, kind: t('dup.kind.memory'),
15058
+ name: dupAsk.name, finalName: dupAsk.finalName, action: 'create',
15059
+ busy: busy,
15060
+ onCancel: function () { setDupAsk(null) },
15061
+ onConfirm: function () { setDupAsk(null); createMemory(dupAsk.finalName) },
13832
15062
  }) : null)
13833
15063
  }
13834
15064
  // ── 斜杠命令「工具」段(对话输入框里直接管六个域)────────────────────────────