dsh-plugin-tool-management 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,16 +133,17 @@ dsh plugin --profile web add dsh-plugin-tool-management@latest
133
133
  - **场景是显式记录**:`memories/<场景>/` 是它的记忆目录,场景本身带**描述**与顺序(存在 `rules-index.json` 的 `scenes` 切片)。`~/.dsh/tool-management/memories/办公/流程.md` 即"办公"场景下的一条记忆。场景名支持中文等任意 Unicode(≤64 字符,不含 `/ \ < > : " | ? *`,不以 `.` 开头,**单个路径段**);`global` 是保留场景(界面显示「全局」),`_shared/` 是历史保留的公共场景。
134
134
  - **新建场景**:点「新建场景」填名字与一句描述(也可以自己在 `memories/` 下 `mkdir`,效果一样,下次读取会补上记录)。**空场景也合法**——可以先把场景建好、之后再往里放记忆。卡片上还有「改描述」随时补说明。
135
135
  - **每个 `.md` 就是一条记忆**:一句话或一段话都行,不用写 frontmatter,整篇正文都会注入。往场景文件夹里丢文件就生效,也可以点卡片上的「新建记忆」在页面里写——**文件名支持中文**(如 `站会流程.md`)。
136
- - **开关场景**:场景卡片右侧的开关就是启用/停用(与 Skills 页同一套组件和布局)。其目录树内**所有 `.md` 的正文会自动进入系统提示词**——模型不需要做任何动作,也不用每次重复解释。切换**下一个请求即生效**,无需重开会话或重载插件。
137
- - **默认全部启用**:没有任何配置时所有场景都生效("丢进去就有用");场景多了再在页面上收窄。`global`(「全局」)与 `_shared/` 恒常生效,卡片上没有开关。
138
- - **全局记忆**:保留场景 `global` 的记忆**任何对话都注入**(不吃场景开关),适合"放之四海皆准"的偏好与约定。新建记忆时场景留空即落到这里。
136
+ - **开关场景**:场景卡片右上角的开关就是启用/停用(与 Skills 页同一套组件和布局)。其目录树内**所有 `.md` 的正文会自动进入系统提示词**——模型不需要做任何动作,也不用每次重复解释。切换**下一个请求即生效**,无需重开会话或重载插件。
137
+ - **默认全部启用**:没有任何配置时所有场景都生效("丢进去就有用");场景多了再在页面上收窄。`global`(「全局」)与 `_shared/` 恒常生效——**场景页只列可切换的预设场景**,所以保留场景 `global` 不在这一页出现(历史保留名 `_shared/` 若存在则显示为「常开」卡片、同样没有开关)。
138
+ - **全局记忆**:保留场景 `global` 的记忆**任何对话都注入**(不吃场景开关;「记忆」页的全局分组上标**「常驻」**),适合"放之四海皆准"的偏好与约定。新建记忆时场景留空即落到这里。
139
139
  - **导入记忆**:页头「导入记忆」支持 `.md` 与 `.zip`(可多选、可拖入)。zip 内带目录时**目录名即场景**(`工作/standup.md` → 场景 `工作`);**直接拖入文件夹同理**(按文件夹层级落场景,多级目录保留为 `A/B`);裸 `.md` 落到弹窗里选的场景——**留空 = 保留场景「全局」**。zip 内 `<场景>/<名>/SKILL.md` 按 **bundle** 导入(落 `<场景>/<名>/`,**同层文件作附件**;附件名非法 / 空 / 单个 >8 MB / 合计 >16 MB 逐条跳过并回报)。文件按原文落盘,**同名自动跳过并列出名单**(不覆盖既有记忆)。所有被丢弃的内容都会**回报原因**(含 zip 内超限条目),不做静默丢弃;导入时引用到的场景若还不存在会**自动补一条场景记录并在结果里列出**(不静默造数据)。
140
140
  - **记忆 = 一个 Markdown 文件**:`<场景>/<name>.md`(flat)或 `<场景>/<name>/SKILL.md`(bundle,可带附件)。新建时选场景(下拉里是已存在的场景,要去「场景」页先建)、填名称(= 文件名)、描述与正文,frontmatter 全部可选,缺失时插件自动派生。**归属不存在的场景会被明确拒绝**(`场景不存在`),不再静默造场景。
141
141
  - **bundle 附件**:形态选 bundle 时可直接**添加附件**(多选,单个 ≤8 MB、单次 ≤16 MB / 32 个);附件存在记忆目录里,**不会进入提示词**(只有 `SKILL.md` 正文注入),编辑时可逐个移除。flat 是单文件,没有目录可放附件。
142
142
  - **启停与回收**:逐条启停(每行右侧开关,停用的记忆仍留在磁盘上,只是不进提示词)、编辑、移入回收站;页头「回收站」可以**恢复**或**永久删除**已删记忆,删除前有二次确认。`enabled` 等状态存在侧车索引里,绝不回写记忆文件。
143
143
  - **注入预算可见**:页头下方常驻一条预算条(已用 / 上限字节),超限变红并标「已超限」。默认上限 64 KiB;**某条记忆放不下时只跳过它**、继续装后面放得下的小记忆,段尾会附一份「未注入(超出预算)」清单——模型与用户都能看到哪些记忆这次没进提示词,而不是静默丢失。
144
144
  - **不再改写 `~/.dsh/AGENTS.md`**:原"始终层"已下线,公共基线改由 `_shared/` 承担,统一走系统提示词段。
145
- - **场景档案(四段自由搭配)**:卡片上「档案」打开编辑器,四段各自独立"添加/移除"——**MCP 工具集**(两级勾选:先勾服务器,「添加」时按当前运行时状态预勾;不勾=整台停用,勾了但一个工具都不勾=该服务器全停)、**技能集**(勾选器只列实时发现的条目,预勾当前启用状态,勾=启用/未勾=停用)、**子智能体绑定**(勾人设名单,全不选=不限制)、**记忆**(场景卡片列出每个场景的记忆与已勾条数,点「选记忆」进场景内明细;**只影响注入**——没勾的记忆不进系统提示词,文件与内容一律不动)。编辑器顶部有筛选框,长列表不用靠滚动找;弹窗固定高度,加减段不跳动。勾了工具/技能段的场景出现「设为当前模式」按钮:**进入模式 = 快照当前启停 → 先落盘快照 → 应用勾选集 → 记忆收窄到该场景**;「退出模式」按快照**原文**恢复(模式期间写进去的整台停用键随之消失)。运行中手动改动不会偷偷回写,点「保存到场景」才落盘。进入后下一请求生效。任一步失败自动回滚并如实上报(回滚未完成会写进错误文本,不谎报「已回滚」)。
145
+ - **场景页排版**:场景排成**卡片网格**(窄于 640px 自动单列)。每张卡片只有四样东西:名字(+ 磁盘目录名 + 状态标签 + 开关)、**一行描述**、动作按钮(「切入此模式」只在有档案时出现,右侧是「档案 / 改描述 / 删除场景」)。描述上限 **60 字**(新建/编辑时输入框就限住,卡片上超长显示省略号、全文在悬浮提示里)——卡片里**不再堆任何数量**,「现在生效的是什么」只由页首那条「当前模式」讲(**只有真的进入模式后才出现**,没有条 = 没进任何模式)。**保留场景「全局」不在这一页出现**:它恒定注入、不是可切换的预设,MCP/skills/人设各有专页;全局记忆在「记忆」页照常管理。没有任何专门场景时页面显示空态提示——只有需要整套切换 MCP/技能/人设时才需要新建场景。
146
+ - **场景档案(四段自由搭配)**:卡片上「档案」打开编辑器,四段各自独立"添加/移除"——**MCP 工具集**(两级勾选:先勾服务器;不勾=整台停用,勾了但一个工具都不勾=该服务器全停)、**技能集**(勾选器只列实时发现的条目,勾=启用/未勾=停用)、**子智能体绑定**(勾人设名单,全不选=不限制)、**记忆**(**只列被编辑场景自己的记忆**,一排勾选行、可筛选;**只影响注入**——没勾的记忆不进系统提示词,文件与内容一律不动。全局记忆恒定注入、别的场景的勾了也不生效,所以都不在这里出现)。**新添加的段默认一项都不勾**:记忆段只预勾**本场景里已启用**的那几条,其余三段的「添加」就是空集(MCP/技能/记忆段空集 = 该域全部停用,子智能体段空集 = 不限制,段脚注会写出这句话);「全选」= 本场景全部。编辑器顶部有筛选框,长列表不用靠滚动找;记忆描述按 80 字截断(全文在悬浮提示里),弹窗固定高度,加减段不跳动。勾了工具/技能段的场景出现「切入此模式」按钮:**进入模式 = 快照当前启停 → 先落盘快照 → 应用勾选集 → 记忆收窄到该场景**;「退出模式」按快照**原文**恢复(模式期间写进去的整台停用键随之消失)。运行中手动改动不会偷偷回写,点「保存到场景」才落盘。进入后下一请求生效。任一步失败自动回滚并如实上报(回滚未完成会写进错误文本,不谎报「已回滚」)。
146
147
  - **子智能体(人设)**:`~/.dsh/tool-management/agents/<人设>.md`,一个文件一个人设——frontmatter 可选(`description` 何时调用,**一句话即可** / `provider` + `model` 指定模型路由(**两者是一对**:跨来源换模型必须都填,如 `provider: sensenova` + `model: sensenova-6.8-flash-lite`;只填 `model` 会落在主会话的来源上) / `tools` 工具白名单 / `toolsDeny` 工具黑名单,缺省自动派生),正文就是人设提示词。页面上这些都在「**高级选项**」折叠区里(已经在用模型/工具限制的人设自动展开):模型是**下拉选择**(宿主 LLM 目录里的 `provider · model` 对,目录里没有的可切「自定义」手填),工具白/黑名单是**勾选器**——候选是**全部 Agent 预设工具名的并集**并标注「当前会话可见 / 其它预设里可用」,因为人设可能在任何预设下被复用,只列当前会话的工具会让换预设后的子代理启动失败(官方 `toolFilter` 对未知名直接拒绝启动)。页头「导入」支持 `.md` 与 `.zip`(zip 内任意层级的 `.md` 都按文件名导入,**同名自动跳过并列出名单**)。模型用 `subagent_list` 看清单、`subagent_run{agent, task}` 调用:子代理**带人设独立运行**、自动继承当前启用场景的记忆段、只把最终输出回传主模型(≤16 KiB),跑完即弃不进 History。场景档案里绑定「本场景可用哪些人设」(绑定外调用报"人设不可用");运行默认弹确认(花的是真 token),设置 `requireConfirmForModelSubagentRun: false` 可关,完全权限(`approval=never`)下视为已预先批准直接放行(见 FAQ)。**治理边界**:以上约束只覆盖 `subagent_run` 这一条通道——DSH 官方的 `subagent` / `subagent_fork` 是宿主能力,无确认门、也不认这套人设,任何模式下都不受本插件约束(见 FAQ「两条子代理通道」)。
147
148
 
148
149
  ### 让模型和脚本参与管理
@@ -209,11 +210,11 @@ npm run build # 构建(tsc + 同步客户端 bundle)
209
210
  npm run build:client # 只同步 src/client.js → lib/client.js
210
211
  npm run lint # 语法自检(node --check 两个产物)
211
212
  npm run check:i18n # 中英词典键集合 + 占位符对齐
212
- npm test # 构建 + i18n 自检 + 全部语义契约测试(node --test test/*.test.mjs,76 例)
213
+ npm test # 构建 + i18n 自检 + 全部语义契约测试(node --test test/*.test.mjs,11 组 85 例)
213
214
  ```
214
215
 
215
216
  > 本项目的验证方式是**直接跑一遍真实行为**(验收证据与已知问题见 [Changelog](docs/Changelog.md)),而不是断言代码当前怎么实现——
216
- > 后者只是把实现抄一遍,必然通过。例外是十组**语义契约**测试(`npm test`,跑 `lib/` 产物,共 76 例):
217
+ > 后者只是把实现抄一遍,必然通过。例外是十一组**语义契约**测试(`npm test`,跑 `lib/` 产物,共 85 例):
217
218
  > `archive.test.mjs`(引擎状态机:勾=启用、空段可持久化、失败回滚与如实上报)、
218
219
  > `import.test.mjs`(导入展开、落点规划与限额回报)、
219
220
  > `approval-policy.test.mjs`(never 审批策略探测;用真实 cordis + 真实 `ApprovalService` 复现读取链)、
@@ -222,8 +223,9 @@ npm test # 构建 + i18n 自检 + 全部语义契约测试(node --
222
223
  > `hub-layout.test.mjs`(统一数据目录:旧布局搬移不覆盖、保留场景 global 恒在且不可删、记忆必须归属已存在场景、档案记忆段只影响投影)、
223
224
  > `skills-delete.test.mjs`(哪些技能可以删:用户级来源不可删、只读来源仍只读)、
224
225
  > `skills-state.test.mjs`(技能状态文件读取韧性:缺键自愈、类型错仍 fail-closed)、
226
+ > `skills-source-remove.test.mjs`(「移除来源」语义:移除后技能不再被读取、不参与同名优先级、模型侧候选也看不到,而文件零改动、可恢复)、
225
227
  > `client-exports.test.mjs`(**客户端导出契约**:只求值 factory 不跑 `apply` 也能拿到 `dict`/`pages`;禁止把导出写在 `apply` 方法体里)、
226
- > `client-render.test.mjs`(**装配与渲染**:假 ctx 跑完整 `apply`,断言注册了 `settings.section`,并递归渲染整棵组件树不抛错)。
228
+ > `client-render.test.mjs`(**装配与渲染**:假 ctx 跑完整 `apply`,断言注册了 `settings.section`,并递归渲染整棵组件树不抛错;另含**场景页契约**——保留场景「全局」不出现在场景页(只剩全局时显示空态)、可切换的预设照常渲染、描述超长必裁到上限;以及**档案弹窗默认勾选契约**——MCP/技能/子智能体「添加」即空集,记忆只预勾「全局」里已启用的那几条,「全选」是真全选;以及**模式条状态契约**——进入模式才有「当前模式」条,没进模式时那条不出现)。
227
229
  > 它们断言语义契约而非实现抄写;真实行为验收仍以浏览器/宿主实测为准,契约测试不能替代。
228
230
  > 另有 `npm run check:i18n`(中英词典键集合 + 占位符对齐)与 `node scripts/i18n-debt.mjs`(还剩多少硬编码中文,当前 113 条:提示词页 38 / 会话页 75)。
229
231
 
package/README_EN.md CHANGED
@@ -132,14 +132,15 @@ Then remind me to hard-refresh the browser.
132
132
  - **A scene is an explicit record** (name + description, stored in the `scenes` slice of `rules-index.json`); `memories/<scene>/` holds its memories: `~/.dsh/tool-management/memories/办公/流程.md` is one memory in the "办公" scene. Scene names accept any Unicode (≤64 chars, no `/ \ < > : " | ? *`, must not start with a dot, **a single path segment**); `global` is the reserved always-on scene ("Global" in the UI) and `_shared/` is the legacy shared scene.
133
133
  - **New scene**: "New scene" asks for a name and a one-line description (or just `mkdir` under `memories/` — a record is filled in on the next read). **An empty scene is perfectly valid**, so you can create scenes first and add memories later; the card also has "Edit" for the description.
134
134
  - **Every `.md` is one memory**: a sentence or a paragraph, no frontmatter needed, and the whole body is injected. Drop a file into the scene folder and it takes effect, or use "New memory" on the card to write it on the page — **file names can be Chinese** (e.g. `站会流程.md`). A memory whose scene does not exist is **rejected outright** (`scene not found`) instead of silently creating one.
135
- - **Toggle a scene**: the switch on the right of each scene card enables/disables it (same component and layout as the Skills page). Every `.md` inside an enabled scene is **injected into the system prompt automatically**; the model needs no tool call and you never have to explain again. Toggling takes effect on the **very next request**, with no new session and no plugin reload.
136
- - **All scenes are enabled by default**: with no configuration at all, every scene is live ("drop it in and it works"); narrow the set in the UI once you have many scenes. `global` ("Global") and `_shared/` are always on (their cards have no switch).
135
+ - **Toggle a scene**: the switch at the top right of each scene card enables/disables it (same component and layout as the Skills page). Every `.md` inside an enabled scene is **injected into the system prompt automatically**; the model needs no tool call and you never have to explain again. Toggling takes effect on the **very next request**, with no new session and no plugin reload.
136
+ - **All scenes are enabled by default**: with no configuration at all, every scene is live ("drop it in and it works"); narrow the set in the UI once you have many scenes. `global` ("Global") and `_shared/` are always on — **the Scenes page only lists switchable preset scenes**, so the reserved `global` scene does not appear there (the legacy `_shared/` name shows as an "always on" card with no switch if it exists).
137
137
  - **One memory = one Markdown file**: `<scene>/<name>.md` (flat) or `<scene>/<name>/SKILL.md` (bundle, with attachments). When creating, fill in the scene (pick an existing one or **type a new scene name** — its folder is created for you), the name (= file name), description and body — frontmatter is entirely optional and derived automatically when missing.
138
138
  - **Bundle attachments**: with the bundle form you can **add attachments** right in the dialog (multi-select, ≤8 MB each, ≤16 MB / 32 files per upload); they live in the memory folder and are **never injected into the prompt** (only the `SKILL.md` body is), and you can remove them one by one while editing. The flat form is a single file, so it has nowhere to put attachments.
139
139
  - **Toggle & recycle**: enable/disable each memory (the switch on the right of every row — a disabled memory stays on disk and is simply left out of the prompt), edit, and move to trash; the "Trash" button in the page header can **restore** or **permanently delete** removed memories, with a confirmation step before the permanent delete. `enabled` and friends live in the sidecar index and are never written back to your files.
140
140
  - **Injection budget is visible**: a budget bar (used / max bytes) sits under the summary and turns red with an "Over budget" label. Default cap 64 KiB; when one memory does not fit it is **skipped** while smaller ones behind it are still included, and the section tail carries a "not injected (over budget)" list — both the model and you can see what was left out instead of losing it silently.
141
141
  - **`~/.dsh/AGENTS.md` is no longer written**: the old "always layer" is gone; the shared baseline now lives in `_shared/` and flows through the system-prompt section.
142
- - **Scene profile (four free-form sections)**: the "Profile" button opens an editor where **MCP tools**, **skills**, **subagent bindings** and **memories** are added/removed independently. For memories the editor lists each scene as a card (description + how many of its memories are checked) and "Pick memories" drills into that scene; check semantics are the same as the other sections (**unchecked = not injected for that scene; files and content are never touched**). Sections with a defined-but-empty selection disable that whole domain. Every section body has a filter box, and the dialog keeps a fixed height so adding/removing sections never makes it jump. A scene with MCP/skill sections also gets a "Set as active mode" button: entering takes a runtime snapshot, persists it first, applies the selections and narrows memory injection to that scene; exiting restores the snapshot **verbatim**. Failures roll back and are reported honestly (an incomplete rollback is written into the error text rather than claimed as "rolled back").
142
+ - **Scenes page layout**: scenes are laid out as a **card grid** (single column below 640px). Each card carries exactly four things: the name (plus the on-disk folder name, status tags and the switch), a **one-line description**, and its action buttons ("Enter this mode" only when a profile exists; on the right "Profile / Edit / Delete scene" — reserved scenes have neither the switch nor delete). Descriptions are capped at **60 characters** (enforced in the create/edit field, clipped with an ellipsis on the card with the full text in the tooltip) because cards carry **no counters at all**: what is currently in effect is told only by the "Active mode" bar at the top of the page, which **appears only while a mode is actually active** (no bar = no mode). **The reserved `global` scene does not appear on this page**: it is an always-injected baseline rather than a switchable preset, and MCP servers, skills and personas each have their own page; global memories are managed on the Memories page. With no dedicated scene at all the page shows an empty state — you only need to create a scene when you want to switch a whole set of MCP servers / skills / personas at once.
143
+ - **Scene profile (four free-form sections)**: the "Profile" button opens an editor where **MCP tools** (two levels: check a server first; unchecked = the whole server off, checked with no tool picked = all its tools off), **skills** (the picker only lists what is currently discovered; checked = enabled, unchecked = disabled), **subagent bindings** (check personas; nothing checked = no restriction) and **memories** are added/removed independently. For memories the editor lists **only the memories of the scene being edited** as a flat, filterable checkbox list (a scene's memory section can only gate its own memories — global memories are always injected and another scene's choices would have no effect, so neither appears here); check semantics are the same as the other sections (**unchecked = not injected for that scene; files and content are never touched**). **A newly added section starts with nothing checked**: the memory section pre-checks only the **enabled** memories of that same scene, while "Add" on the other three gives an empty set (an empty MCP/skill/memory section disables that domain, an empty subagent section means no restriction — the section footer spells this out), and "Select all" covers every memory of that scene. Every section body has a filter box, and the dialog keeps a fixed height so adding/removing sections never makes it jump; memory descriptions are clipped at 80 characters with the full text in the tooltip. A scene with MCP/skill sections also gets an "Enter this mode" button: entering takes a runtime snapshot, persists it first, applies the selections and narrows memory injection to that scene; exiting restores the snapshot **verbatim**. Failures roll back and are reported honestly (an incomplete rollback is written into the error text rather than claimed as "rolled back").
143
144
  - **Subagents (personas)**: `~/.dsh/tool-management/agents/<persona>.md`, one file per persona — frontmatter is optional (`description` for when to call it, **one sentence is enough**; `provider` + `model` for the model route (**a pair**: switching providers requires both, e.g. `provider: sensenova` + `model: sensenova-6.8-flash-lite`; a bare `model` resolves against the main session's provider); `tools` allowlist; `toolsDeny` denylist), and the body is the persona prompt. On the page all of this sits in an **Advanced options** fold-out (auto-expanded when the persona already uses a model or tool restriction): the model is a **dropdown** (the `provider · model` pairs from the host LLM catalogue, with a "Custom" entry to type one it does not list), and the tool allow/deny lists are **pickers** whose candidates are the **union of tool names across all agent presets**, tagged "available in this session" vs "available in other presets" — a persona can be reused under any preset, and listing only this session's tools would make the child fail to start after a preset switch (the official `toolFilter` rejects unknown names outright).
144
145
 
145
146
  #### Caching and refresh (§5.2)
@@ -254,13 +255,14 @@ npm install
254
255
  npm run build # build (tsc + sync client bundle)
255
256
  npm run build:client # sync src/client.js → lib/client.js only
256
257
  npm run lint # syntax self-check (node --check on both artifacts)
257
- npm test # build + all semantic-contract tests (node --test test/*.test.mjs)
258
+ npm run check:i18n # zh/en dictionary key-set + placeholder alignment
259
+ npm test # build + i18n check + all semantic-contract tests (node --test test/*.test.mjs, 11 groups / 85 cases)
258
260
  ```
259
261
 
260
262
  > Changes are verified by **actually exercising the real behaviour** (evidence and known issues live in
261
263
  > [Changelog](docs/Changelog.md)) instead of asserting what the code currently does — the latter
262
- > just copies the implementation and passes by construction. The exception is ten groups of
263
- > **semantic-contract** tests (`npm test`, run against the built `lib/`, 76 cases):
264
+ > just copies the implementation and passes by construction. The exception is eleven groups of
265
+ > **semantic-contract** tests (`npm test`, run against the built `lib/`, 85 cases):
264
266
  > `archive.test.mjs` (engine state machine), `import.test.mjs` (ZIP expansion, landing plans, limit
265
267
  > reporting), `approval-policy.test.mjs` (never-policy detection, driving a real cordis context and
266
268
  > a real `ApprovalService`), `subagent-scene.test.mjs` (scene binding must reject *before* a
@@ -268,11 +270,23 @@ npm test # build + all semantic-contract tests (node --test test/*.t
268
270
  > `model` and `toolsDeny` survive a UI save; creating a persona with no directory present),
269
271
  > `hub-layout.test.mjs` (unified data directory: legacy layouts move without overwriting, the
270
272
  > reserved `global` scene always exists and cannot be deleted, a memory must belong to an existing
271
- > scene, and the profile memory section only affects projection), `client-exports.test.mjs`
273
+ > scene, and the profile memory section only affects projection), `skills-delete.test.mjs` (which
274
+ > skills may be deleted: user-level sources cannot be, read-only sources stay read-only),
275
+ > `skills-state.test.mjs` (state-file read resilience: missing keys self-heal, type errors stay
276
+ > fail-closed), `skills-source-remove.test.mjs` (the "remove a source" semantics: a removed source is
277
+ > no longer read, drops out of the same-name priority and is invisible to the model, while not a byte
278
+ > on disk changes and it can be restored), `client-exports.test.mjs`
272
279
  > (client export contract: evaluating the factory alone — without running `apply` — must already
273
280
  > expose `dict`/`pages`; exports written inside the `apply` method body are rejected), and
274
281
  > `client-render.test.mjs` (assembly and rendering: a fake ctx drives the whole `apply`, asserts
275
- > `settings.section` is registered, then renders the entire component tree without throwing). They
282
+ > `settings.section` is registered, then renders the entire component tree without throwing; it also
283
+ > pins the **Scenes page contract** — the reserved `global` scene does not appear there (an
284
+ > only-global data set renders the empty state), switchable presets still render, and an over-long
285
+ > description is always clipped to the cap — plus the **profile default-pick contract**: "Add" on
286
+ > MCP/skills/
287
+ > subagents yields an empty set, the memory section pre-checks only the enabled `global` memories,
288
+ > and "Select all" really selects everything — plus the **mode-bar state contract**: the "Active
289
+ > mode" bar exists only while a mode is active and is absent otherwise). They
276
290
  > assert contracts, not
277
291
  > implementation copies; real-behaviour
278
292
  > acceptance still happens
package/docs/Changelog.md CHANGED
@@ -7,8 +7,48 @@
7
7
 
8
8
  ---
9
9
 
10
+ ## v0.5.1(2026-09-14)
11
+
12
+ **发布号说明**:npm 上此前最新是 **`0.5.0`**(2026-09-13 16:17:45 UTC 发布,`latest` 标签即它;再往前是 0.2.0 / 0.1.x)。
13
+ 0.5.1 是紧随其后的**补丁版**:只含本节条目,0.5.0 已发布的功能说明见下一节与其后的「v0.5.0 详情」。
14
+
15
+ **给使用者的三句话**:场景页现在只放**专门设置的、可切换的预设场景**(「全局」不再出现——它恒定注入、
16
+ MCP/skills/人设各有专页);场景档案弹窗的新加段**默认一项都不勾**(记忆段只预勾本场景里已启用的那几条);
17
+ 勾选行长描述不再横向溢出边框、筛选框不再贴死内容。
18
+
19
+ ### 界面
20
+ - **档案弹窗:新加段默认不勾选 + 记忆描述截断 + 勾选行溢出修复**(用户反馈「MCP 工具集、技能集、子智能体绑定内都默认不勾选,记忆除了全局已经启动的勾选,其他也是不勾选;记忆描述要有截断,因为太长会导致文字超出边框;而且输入框和内容有重合」):
21
+ - **默认勾选**(推翻 v0.3 §2.4「预勾当前运行时状态」):`MCP 工具集` / `技能集` 的「添加」由「照抄当前启用状态 / 勾上已启用技能」改为**空集**;`记忆` 的「添加」由「勾上全部」改为只勾**已启用**的那几条,`记忆` 的「全选」拆出来变成真正的全选(`allMemoryIds()`),原先它和「添加」是同一个子集。(作用域随后进一步收窄为「只认本场景」——见下条「记忆段只列被编辑场景自己的记忆」。)
22
+ - **代价写进界面**:勾选集语义是「勾 = 启用」,空段 = 该域全部停用——所以现在点「添加 MCP 工具集」当场就等价于「该场景下 MCP 全停」。段脚注与弹窗顶部说明都写明这句话;**子智能体段例外**(空段 = 不限制,见 `filterBySceneBinding`),因此新增 `memory.archive.emptySubagents` 单独用词,不再复用「= 全部停用」的脚注。
23
+ - **文字溢出段边框(真缺陷,已实测)**:`.dsm-pick-main` 只是个行内 `span`,名称与说明在同一行内联排布,而 `text-overflow:ellipsis` 对行内元素无效 → 长描述**横向溢出**。Playwright 量到:660px 宽的段里描述行宽 **976px、右侧超出段边框 353px**(档案弹窗变体超出 295px)。改为 `display:flex;flex-direction:column`(名称/说明各占一行、各自单行省略)后同一测量为 **-55px / -40px**(不再溢出),行高 54 → 49px。
24
+ - **记忆描述截断**:勾选行里的记忆描述按 **80 字**(`MEM_DESC_MAX`)截断,全文进 `title` 悬浮提示;`pickRow` 新增 `descTitle` 支持。
25
+ - **输入框与内容贴死**:筛选框原来紧贴第一条(间距实测 **0px**)→ `.dsm-seg-filter` 底部补 8px(实测 8px);另加 `.dsm-seg-body>*{flex:none}` 与 `.dsm-tools-grid>*{flex:none}`,禁止滚动容器把子项压缩(压缩是这类「叠在一起」的常见成因)。
26
+ - **场景页(场景档案)排版重做**(用户反馈「全局框只显示描述,不显示当前记忆、skill 等这些数量,描述也要有字数限制,不然会导致全局变成纵向布局;场景档案的排版、交互、组件都很杂乱,丑陋」):
27
+ - **卡片不再挂数量**。原卡片把「描述 + N 条记忆 + 已配:MCP N 台 · 技能 N 个 · 子智能体 N 个 · 记忆 N 条」全塞在一行,且 `dsm-path` 右贴——一行里三类信息互相挤,长描述直接把卡片撑成纵向。现在卡片只有四样:名字(+ 磁盘目录名 + 状态标签 + 开关)、**一行描述**、动作行(主按钮 + 右侧次按钮)。数量统一收进页首新增的「**当前模式**」条(`dsm-mode-bar`:圆点 + 当前模式名 + 档案摘要 + 退出按钮)。
28
+ - **「当前模式」条只在进入模式后出现**(用户反馈「自由模式 / 各场景按自己的启用开关注入记忆…这个是干什么的,感觉没什么用」):原先没有模式时常驻一条「自由模式 + 一句解释」,纯静态解释占一整条,而「没有条 = 没进任何模式」本就不言自明。现在它只承载有状态的信息(哪个场景是当前模式 / 它包含什么档案 / 退出按钮),`scenes.mode.free` 与 `scenes.mode.free.hint` 两个词条一并删除。
29
+ - **描述字数上限 60**:新建/编辑表单加 `maxLength` 与 `n/60` 计数,显示层再 `clipText` 裁一次兜住历史数据(卡片 `text-overflow:ellipsis` 单行,全文进 `title`)。
30
+ - **档案弹窗「记忆」段的场景卡片**:`.dsm-pick-main` 是行内 `span`,名称与说明会**同行内联换行**(这正是「纵向布局」的成因)→ 显式改为纵向 flex,两行各自单行省略。保留场景「全局」恒定注入,「已勾 N/M」对它没有意义,**它的卡片只显示描述**(`sceneMemDesc`);其余场景仍显示「描述 · 勾选进度」。
31
+ > **本节后续已废弃**:这一段整体改成「只列被编辑场景自己的记忆」的平铺勾选行,场景卡片、`sceneMemDesc` 与「选记忆」钻取都已删除,见下一条。
32
+ - **记忆段只列被编辑场景自己的记忆**(用户裁定:「其他场景的记忆也不需要显示全局」):原先这一段列**所有场景的卡片** + 「选记忆」钻取,但宿主侧 `memoryAllowed(archives, file.scene, id)` 是按**记忆所属场景**取档案(`src/rules/archive.ts:135`,渲染调用点 `src/rules/service.ts:1044`;回归测试 `hub-layout.test.mjs`「保留场景 global 的记忆不受其它场景的勾选段影响」)——**A 场景档案里的 memories 段只能门控 A 自己的记忆**,勾别的场景(含全局)完全不生效,列出来就是陷阱。现改为与本弹窗其余三段同构的平铺勾选行:`sceneMemoriesOf(memories, scene)` 只取本场景、并按一级目录归场景(顺带修掉「多级分组的记忆整个看不见」——原先按完整 group 比对,`办公/流程/站会` 归不到 `办公`);「全选」= 本场景全部;「添加」的默认勾选由「全局已启用」改为**本场景已启用**(`memDefaultPickIds(memories, rules, scene)`);场景卡片、钻取视图、`memDrill` 状态、7 条相关词条与 `.dsm-scene-card*` 五条 CSS 一并删除。
33
+ - **交互**:卡片改成响应式网格(`repeat(auto-fill,minmax(300px,1fr))`,容器窄于 640px 单列);「切入此模式」从次要按钮升为该卡的主按钮(有档案才出现,且当前模式那张不显示);历史保留场景 `_shared`(「常开」)不出开关与删除。
34
+ - **场景页不再列保留场景「全局」**(用户裁定:「全局记忆都不用设置场景就能注入上下文,如果我需要设置 skills、mcp,我直接去具体页设置就好了,所以说场景不需要展示全局,不需要设置全局,只有有特定需求才需要设置专门的场景。」):`presetScenes = data.scenes.filter(s => s.global !== true)` —— 页面只放**可切换的预设场景**,三个统计(`个场景` / `个已启用` / `个有档案`)同步只数可见场景;只剩 `global` 时显示空态(词条 `scenes.empty` 改写为「还没有专门设置的场景;全局记忆不需要场景就能注入,需要切换 MCP / 技能 / 人设时再新建。」)。**过滤只做在场景页这一层**:宿主回传的 `scenes` 仍含全局,记忆页的全局分组与各种场景选择器照旧(否则全局记忆在界面上就没有落点)。代价(用户接受):全局的**场景描述与档案**不再有 UI 入口——MCP/skills 各有专页,不需要"全局档案"这层间接。
35
+ - **标签文案**:保留场景的标记从「任何对话都注入」缩成「**常驻**」(六个字压成两个字);档案弹窗「记忆」段里同一含义的「始终注入」一并统一为「常驻」,同一件事全站只有一种说法。英文侧对应 `always injected`(`_shared` 的「常开 / always on」保持不变,两者语义不同)。「常驻」现在只出现在**记忆页**的全局分组(场景页不列全局、档案弹窗记忆段也不再出现全局)。
36
+ - 验收证据(静态预览渲染,**非浏览器活体验收**):以假 ctx + 真实 `dict` 渲染 `ScenesPage` 到静态 HTML(连同 bundle 里的真实 CSS),900px 两列 / 600px 单列各出一张截图逐项核对;另在预览里手工拼出档案弹窗「记忆」段(含超长描述)确认卡片保持横向、描述单行省略。契约测试守住:描述超长必裁(含 CSS 断言 `white-space:nowrap` / `flex-direction:column` 与 `maxLength`)、**全局不出现在场景页**(两种模式状态各渲染一遍断言全文无「全局」「常驻」,预设场景仍在)、**只剩全局时为空态且零卡片**、**记忆段只认本场景的记忆**(全局与其它场景都取不到、多级分组按一级目录归位、默认勾选只含本场景已启用、源码守卫禁止场景卡片与钻取复活)。
37
+
38
+ ### 文档与测试
39
+ - **契约测试 81 → 85 例**:新增/扩写「档案弹窗默认勾选」「记忆段只认本场景的记忆」「场景页不列全局 + 只剩全局时空态」「模式条状态」等客户端契约。**0.5.0 的文档写 76 例是滞后值**(发布时实际已 81 例),本次一并校正。
40
+ - **两版 README 的测试计数校正**:开发小节原写「十组 / 76 例」(英文版还写成 "ten groups / 76 cases"),英文版的组清单还漏列 `skills-delete`、`skills-state`、`skills-source-remove` 三组,中文版漏列 `skills-source-remove`。现两版均改为 11 组 / 85 例,并补上遗漏的组名与各自断言内容。
41
+
42
+ ### 已知限制(未变)
43
+ - 浏览器交互验收仍**未跑**(宿主要 launch token);能进 CI 的两层是导出契约与装配/渲染测试,它们挡不住交互行为。
44
+ - 硬编码中文欠账未清零(提示词页 / 会话页);结构类问题与官方 Config schema 缺失见「已知问题」节。
45
+
46
+ ---
47
+
10
48
  ## v0.5.0(2026-09-13)
11
49
 
50
+ **已发布**:npm `dsh-plugin-tool-management@0.5.0`(2026-09-13 16:17:45 UTC)。本节与下一节「v0.5.0 详情」即该版本的内容。
51
+
12
52
  本次发布把此前散落的四套变更合并成一个版本,并修掉一轮真实故障。**详细条目见下一节「v0.5.0 详情」**
13
53
  (该节原编号为 v0.4,内容不变,只改标题以免与发布号冲突)。
14
54
 
@@ -20,8 +60,9 @@
20
60
  - **统一数据目录**:记忆、场景、人设、AGENTS.md 预设、新建/导入的技能、回收站、索引与配置全在 `~/.dsh/tool-management/` 下;旧位置(`$DSH_HOME/scene-memory/`、`$DSH_HOME/rules/`、`$DSH_HOME/subagents/`、插件 `data/`)首次启动自动搬入。
21
61
  - **场景显式化**:`rules-index.json` 的 `scenes` 切片带 `label` / `description` / `order`;空场景合法;保留场景 `global`(界面「全局」)不可删;根层裸 `.md` 不再注入并会在体检里报 `noScene`。
22
62
  - **技能来源**:新增插件自有来源(界面名 **「导入技能」**)作为新建/导入的落点,优先级高于 `~/.dsh/skills/`;`~/.agents` / `~/.codex` / `~/.claude` 与自定义目录仍为只读接入。
63
+ - **技能来源新增「移除来源」**(本版新增,详细条目见下一节 §界面):把某个来源从**读取范围**里摘掉——不再扫描该目录、技能页不列出、模型侧候选也不产出;**源目录与文件一个字节都不动**,界面单列一组「已移除的来源」可一键「恢复读取」。`DSH 技能` 与 `导入技能` 是保留来源,**不可移除**(否则新建/导入没有落点)。
23
64
  - **界面**:工具页七标签(场景 / MCP / 技能 / 子智能体 / 提示词 / 记忆 / 会话);场景卡片补描述与绑定摘要;场景档案编辑器新增第 4 段「记忆」;人设表单的模型与工具限制收进「高级选项」(模型下拉 + 白/黑名单勾选器)。
24
- - **契约测试 61 → 76 例**:新增客户端导出契约、装配与渲染(含带数据挂载)、技能状态文件读取韧性、技能删除权限四组。
65
+ - **契约测试 61 → 76 例**:新增客户端导出契约、装配与渲染(含带数据挂载)、技能状态文件读取韧性、技能删除权限四组。(文档口径滞后:发布时实际已 **81 例**,0.5.1 一并校正,见上节。)
25
66
  - **DSH Market 收录徽章**:本插件已被 [DSH Market](https://dsh.market/) 收录(issue [#145](https://github.com/2BingLing/dsh-market/issues/145) 标记 `accepted`)。`README.md` 挂中文徽章、`README_EN.md` 挂英文徽章,片段取自该仓库 [PLUGIN-BADGE.md](https://github.com/2BingLing/dsh-market/blob/master/PLUGIN-BADGE.md);两张 SVG 均实测 HTTP 200。
26
67
  - **英文 README 与中文对齐**(`README_EN.md`):此前英文版落后于中文版若干处——「Model tools」重复两行且都写 10 个(实为 12)、UI 写 "six pages"(实为七栏)、缺「记忆导入」「场景档案」「轻量子智能体」三行、多出中文版没有的「Rule checkup」一行(导致整表错位)、快速开始仍写旧的五个独立页面(实为一个「工具」面板七栏)、`rule_manager_*` 描述仍用 "rules"(应为 scene memories)。逐行核对后两版核心亮点表均为 **22 行(20 数据行)且逐行对齐**,21 条图片引用与 git 索引逐字符一致。
27
68
  - **截图 `MCP.png` 大小写修复**:`docs/images/mcp.png` 被提交成小写而 README/screenshots.json 都引用 `MCP.png`,Windows 不区分大小写掩盖了这点,GitHub 上该图 404(其余六张正常)。两步 `git mv` 改名后实测 `MCP.png` → HTTP 200、`mcp.png` → 404。
@@ -40,6 +81,8 @@
40
81
 
41
82
  ---
42
83
 
84
+ ---
85
+
43
86
  ## v0.5.0 详情 — 统一数据目录 + 场景显式化 + 界面重做(2026-09-13)
44
87
 
45
88
  ### 破坏性/结构性变更
@@ -79,8 +122,11 @@
79
122
  ### 界面
80
123
 
81
124
  - **场景页**:卡片补齐「描述 + 记忆条数 + 已绑定摘要(MCP/技能/子智能体/记忆各多少)+ 磁盘目录名」;新增「改描述」(`rules-update-scene`);保留场景不显示删除/停用开关;统计标签从硬编码中文改走 i18n。
125
+ > **0.5.1 已取代**:用户反馈这些数量让卡片「很杂乱、丑陋」,且长描述会把卡片撑成纵向 → 卡片改为「名字/标签/开关 + 一行描述 + 动作行」,数量移入页首「当前模式」条,描述限 60 字。见上节「场景页(场景档案)排版重做」。
82
126
  - **新建/改场景表单**:独立字段与文案(原来直接复用记忆的字段,出现「留空 = 全局」这类错误语义的占位符)。
83
127
  - **场景档案编辑器**:新增第 4 段「记忆」(`archive.memories`,id = `<场景>/<名>`)。勾选语义与其余段同构——**段未定义 = 不碰;段已定义但没勾的记忆不进提示词**;勾选**只影响投影**,记忆文件与内容一律不动。场景改成卡片(描述 + 已勾/总数 + 「始终注入」标记),点「选记忆」进场景内的记忆明细钻取视图。四段都加筛选框。
128
+ > **0.5.1 已收窄**:保留场景「全局」恒定注入,「已勾 N/M」对它没有意义 → 该卡片只显示描述;名称与说明在 `.dsm-pick-main` 里由行内换行改为各占一行(长描述不再把卡片撑成纵向)。
129
+ > **0.5.1 已改**:「添加」不再预勾当前启用状态(改为空集 / 记忆只预勾本场景里已启用的那几条),记忆描述按 80 字截断;场景页也不再列保留场景「全局」,档案弹窗记忆段只列本场景的记忆。理由与实测数据见上节「档案弹窗:新加段默认不勾选…」「场景页(场景档案)排版重做」「记忆段只列被编辑场景自己的记忆」。
84
130
  - **档案弹窗里的勾选区拉高**(用户反馈「记忆、各种集的选择滑动框都很小」):弹窗上限 620px → 720px,段体从写死 `height:216px` 改成 `min-height:300px` + `flex:0 0 auto`(**下限优先**),列表自身出滚动条,装不下时由 `.dsm-form` 整体滚动。
85
131
  两种写法的取舍记一笔:`flex:1` 平分看似「填满空间」,但四项分 720px 每段只剩约 155px,**比原来的 216px 还矮**——第一版就是这么写的,算完才发现。已把这条写成断言(禁止 `flex:1`、下限 ≥ 260px、弹窗上限要装得下四段下限)。
86
132
  - **人设表单**:模型与工具限制收进「**高级选项**」折叠区(已配置则自动展开)。模型 = 宿主 LLM 目录里的 `provider · model` 下拉 + 「自定义」手填兜底;工具白名单/黑名单 = 勾选器,候选是**全部 Agent 预设工具名的并集**并标注「当前会话可见 / 其它预设里可用」。折叠区**首次展开才拉候选**(枚举预设需要 standing mount,不该在开弹窗时付代价)。
package/lib/client.js CHANGED
@@ -56,11 +56,16 @@ window.__ModuleLoader__.load({
56
56
  .dsm-seg-count{color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap}
57
57
  .dsm-seg-actions{display:flex;align-items:center;gap:6px;margin-left:auto;flex:none}
58
58
  .dsm-seg-body{display:flex;height:216px;padding:6px;overflow:auto;flex-direction:column}
59
+ /* 滚动容器里的子项不许被压缩:否则内容一多,筛选框会被压到比 input 还矮、与下面的条目叠在一起。 */
60
+ .dsm-seg-body>*{flex:none}
59
61
  .dsm-seg-foot{display:flex;align-items:center;gap:8px;padding:7px 12px;border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
60
62
  .dsm-pick{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px}
61
63
  .dsm-pick:hover{background:var(--dsw-alias-interactive-bg-hover)}
62
64
  .dsm-pick input[type=checkbox]{flex:none;width:15px;height:15px;margin:0;cursor:pointer;accent-color:var(--dsw-alias-state-success-primary)}
63
- .dsm-pick-main{min-width:0;flex:1}
65
+ /* .dsm-pick-main 必须是**纵向 flex**:它原本只是个行内 span,名称与说明会在同一行里内联排布,
66
+ 而 text-overflow:ellipsis 对行内元素无效 → 长描述直接横向溢出段边框(实测溢出 353px)。
67
+ 改成列向 flex 后两行各自成为块级子项,各自单行省略。 */
68
+ .dsm-pick-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:1px}
64
69
  .dsm-pick-name{overflow:hidden;font-size:13px;line-height:18px;text-overflow:ellipsis;white-space:nowrap}
65
70
  .dsm-pick-desc{overflow:hidden;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;text-overflow:ellipsis;white-space:nowrap}
66
71
  .dsm-pick-meta{display:flex;align-items:center;gap:6px;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap}
@@ -77,18 +82,37 @@ window.__ModuleLoader__.load({
77
82
  .dsm-modal-archive .dsm-seg-body{height:auto;flex:1 1 auto}
78
83
  /* ── 段内筛选(场景/记忆等条目多的选段用;条目少时不必显示)────────────────────
79
84
  .dsm-seg-filter 段体顶部的搜索行;.dsm-seg-cards 场景卡片列表(比纯勾选行信息量大)。 */
80
- .dsm-seg-filter{display:flex;align-items:center;gap:8px;padding:6px 6px 0}
85
+ .dsm-seg-filter{display:flex;align-items:center;gap:8px;padding:6px 6px 8px}
81
86
  .dsm-seg-filter .dsm-control{min-height:30px;font-size:12px}
82
87
  .dsm-seg-group{display:flex;align-items:center;gap:8px;padding:7px 8px 3px;color:var(--dsw-alias-label-tertiary);font-size:11px}
83
88
  .dsm-seg-group-line{height:1px;flex:1;background:var(--dsw-alias-border-l1)}
84
89
  .dsm-seg-sub{padding-left:22px}
85
90
  .dsm-seg-more{padding:6px 8px 2px;color:var(--dsw-alias-label-tertiary);font-size:11px}
86
- .dsm-scene-card{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
87
- .dsm-scene-card + .dsm-scene-card{margin-top:6px}
88
- .dsm-scene-card-head{display:flex;min-height:36px;align-items:center;gap:9px;padding:6px 9px}
89
- .dsm-scene-card-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
90
- .dsm-scene-card-body{border-top:1px solid var(--dsw-alias-border-l1);padding:4px 6px}
91
+ /* ── 场景页(场景档案)────────────────────────────────────────────────────
92
+ 四类信息各占一行,互不挤:标题行(名字/别名/标签/开关)、描述行(单行省略)、动作行(主次按钮)。
93
+ 卡片上**不再出现数量**(记忆条数、已配 N 台 MCP…):它们是上次「卡片显得杂乱」的直接来源,
94
+ 现在统一收进页首的「当前模式」条里,只有一处。 */
95
+ /* 当前模式条:只在进入模式后出现(没有条 = 自由模式,不需要常驻的解释文案)。 */
96
+ .dsm-mode-bar{display:flex;min-width:0;align-items:center;gap:10px;padding:11px 13px;border:1px solid var(--dsw-alias-state-success-primary);border-radius:10px;background:var(--dsw-alias-bg-layer-2)}
97
+ .dsm-mode-bar .dsm-btn{margin-left:auto;flex:none}
98
+ .dsm-mode-dot{width:8px;height:8px;flex:none;border-radius:50%;background:var(--dsw-alias-state-success-primary)}
99
+ .dsm-mode-main{min-width:0;flex:1}
100
+ .dsm-mode-title{overflow:hidden;font-size:13px;font-weight:620;text-overflow:ellipsis;white-space:nowrap}
101
+ .dsm-mode-sub{overflow:hidden;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:17px;text-overflow:ellipsis;white-space:nowrap}
102
+ .dsm-scenes{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
103
+ .dsm-scene-tile{display:flex;min-width:0;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2)}
104
+ .dsm-scene-tile-mode{border-color:var(--dsw-alias-state-success-primary)}
105
+ .dsm-scene-tile-head{display:flex;min-width:0;align-items:center;gap:7px;padding:11px 12px 0}
106
+ .dsm-scene-tile-name{overflow:hidden;font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}
107
+ .dsm-scene-tile-key{flex:none;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px}
108
+ .dsm-scene-tile-head .dsm-tag{flex:none}
109
+ .dsm-scene-tile-switch{margin-left:auto;flex:none}
110
+ .dsm-scene-tile-desc{overflow:hidden;margin:5px 0 0;padding:0 12px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;text-overflow:ellipsis;white-space:nowrap}
111
+ .dsm-scene-tile-foot{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:11px;padding:9px 12px;border-top:1px solid var(--dsw-alias-border-l1)}
112
+ .dsm-scene-tile-links{display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-left:auto}
113
+ @container(max-width:640px){.dsm-scenes{grid-template-columns:1fr}}
91
114
  .dsm-tools-grid{display:flex;max-height:180px;padding:6px;overflow:auto;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
115
+ .dsm-tools-grid>*{flex:none}
92
116
  .dsm-tools-chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
93
117
  .dsm-chip{display:inline-flex;min-height:22px;align-items:center;gap:5px;padding:0 7px;border:1px solid var(--dsw-alias-border-l3);border-radius:6px;color:var(--dsw-alias-label-secondary);font-size:11px}
94
118
  .dsm-chip-deny{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
@@ -237,25 +261,25 @@ window.__ModuleLoader__.load({
237
261
  "tabs.scenes": "场景", "tabs.skills": "技能", "tabs.subagents": "子智能体", "tabs.prompts": "提示词", "tabs.memory": "记忆", "tabs.sessions": "会话",
238
262
  "scenes.title": "场景", "scenes.desc": "管理场景:可以预设不同的使用场景,包含 MCP、Skills、子智能体、记忆",
239
263
  "scenes.stat.total": "个场景", "scenes.stat.active": "个已启用", "scenes.stat.archives": "个有档案",
240
- "scenes.noDesc": "还没有描述(点「改描述」补一句)", "scenes.memCount": "{count} 条记忆",
241
- "scenes.profile.summary": "已配:{parts}", "scenes.profile.empty": "档案为空",
264
+ "scenes.noDesc": "还没有描述",
265
+ "scenes.mode.active": "当前模式:{name}", "scenes.mode.profile": "档案:{parts}", "scenes.mode.noProfile": "这个场景还没有档案",
266
+ "scenes.mode.enter": "切入此模式", "scenes.empty": "还没有专门设置的场景;全局记忆不需要场景就能注入,需要切换 MCP / 技能 / 人设时再新建。",
267
+ "scenes.field.desc.limit": "最多 {count} 字;超出部分在卡片上省略。",
242
268
  "scenes.profile.mcp": "MCP {count} 台", "scenes.profile.skills": "技能 {count} 个", "scenes.profile.subagents": "子智能体 {count} 个", "scenes.profile.memories": "记忆 {count} 条",
243
- "scenes.hasArchive": "档案", "scenes.mcp.hint": "添加「MCP 工具集」后,勾选要启用的服务器(含未运行的);「选工具」可细化到具体工具。",
269
+ "scenes.mcp.hint": "添加「MCP 工具集」后,勾选要启用的服务器(含未运行的);「选工具」可细化到具体工具。",
244
270
  "scenes.seg.selectAll": "全选", "scenes.seg.clear": "清空", "scenes.seg.checked": "{checked}/{total} 已勾选",
245
271
  "scenes.archive.sectionOff": "未定义",
246
272
  "scenes.archive.summary": "档案:MCP {mcp} 台 · 技能 {skills} 个 · 子智能体 {subagents} 个 · 记忆 {memories} 条",
247
- "scenes.archive.note": "段未定义 = 不改动该域;段已定义但一项不勾 = 全部停用(记忆段只影响注入,不会删文件)。",
273
+ "scenes.archive.note": "新添加的段默认一项不勾。段未定义 = 不改动该域;段已定义但一项不勾 = 该域全部停用(子智能体段例外:不勾 = 不限制;记忆段只影响注入,不会删文件)。",
248
274
  "scenes.mcp.noServers": "还没有可选的 MCP 服务器", "scenes.mcp.toolCount": "{count} 个工具", "scenes.skills.empty": "还没有可选的技能",
249
275
  "scenes.mcp.allTools": "全部工具", "scenes.mcp.pickedCount": "指定 {count} 个工具", "scenes.mcp.notRunning": "未运行", "scenes.mcp.pickTools": "选工具",
250
276
  "scenes.mcp.toolsOf": "工具明细", "scenes.mcp.back": "返回", "scenes.mcp.noTools": "该服务器当前没有可列出的工具(未运行或无工具)", "scenes.mcp.drillHint": "勾选 = 该场景下启用;不勾 = 停用。整台勾选时默认全部工具。",
251
277
  "scenes.skills.hint": "添加「技能集」后,勾选该场景下启用的技能。", "scenes.subagents.hint": "添加「子智能体绑定」后,勾选本场景可调用的人设。", "scenes.subagents.empty": "还没有人设——到「子智能体」页创建。",
252
278
  "scenes.filter.skills": "筛选技能(名称)", "scenes.filter.subagents": "筛选人设(名称或描述)", "scenes.filter.servers": "筛选服务器",
253
- "scenes.mem.hint": "添加「记忆」段后,勾选本场景要注入的记忆;不勾的记忆不会进系统提示词(文件保留)。",
254
- "scenes.mem.title": "记忆", "scenes.mem.search": "在场景内筛选记忆(名称或描述)", "scenes.mem.searchScene": "筛选场景(名称、描述或记忆名)",
255
- "scenes.mem.noMatch": "没有匹配项", "scenes.mem.noScenes": "还没有场景——到「场景」页创建。", "scenes.mem.emptyScene": "该场景还没有记忆",
256
- "scenes.mem.pick": "选记忆", "scenes.mem.drill": "记忆明细", "scenes.mem.drillHint": "勾选 = 该场景下注入这条记忆;不勾 = 不注入(文件与内容都不动)。",
257
- "scenes.mem.sceneCount": "{checked}/{total} 条已勾选", "scenes.mem.alwaysOn": "始终注入",
258
- "memory.mode.current": "当前模式", "memory.mode.set": "设为当前模式", "memory.mode.exit": "退出模式",
279
+ "scenes.mem.hint": "添加「记忆」段后,勾选本场景要注入的记忆(只列本场景自己的记忆;全局记忆恒定注入、不需要在这里配置);不勾的记忆不会进系统提示词(文件保留)。",
280
+ "scenes.mem.title": "记忆", "scenes.mem.search": "在本场景内筛选记忆(名称或描述)",
281
+ "scenes.mem.noMatch": "没有匹配项", "scenes.mem.emptyScene": "该场景还没有记忆",
282
+ "memory.mode.current": "当前模式", "memory.mode.exit": "退出模式",
259
283
  "memory.archive.edit": "档案", "memory.archive.title": "场景档案",
260
284
  "memory.archive.tools": "MCP 工具集", "memory.archive.skills": "技能集", "memory.archive.subagents": "子智能体绑定", "memory.archive.memories": "记忆",
261
285
  "memory.archive.removeSection": "移除段", "memory.archive.save": "保存到场景",
@@ -272,7 +296,7 @@ window.__ModuleLoader__.load({
272
296
  "memory.budget.over": "已超限",
273
297
  "memory.budget.truncated": "超出预算,段尾已列出未注入的记忆",
274
298
  "memory.budget.dropped": "本次未注入:{names}",
275
- "memory.scene.enable": "启用", "memory.scene.enableAll": "全部启用", "memory.scene.off": "未启用", "memory.scene.shared": "常开", "memory.scene.global.tag": "任何对话都注入",
299
+ "memory.scene.enable": "启用", "memory.scene.enableAll": "全部启用", "memory.scene.off": "未启用", "memory.scene.shared": "常开", "memory.scene.global.tag": "常驻",
276
300
  "memory.scene.global": "全局", "memory.scene.count": "{count} 条记忆", "memory.scene.new": "新建记忆", "memory.scene.browse": "选择已有",
277
301
  "memory.scene.empty": "该场景暂无记忆",
278
302
  "memory.scene.orphan": "未归属场景", "memory.scene.orphan.tag": "不会注入", "memory.scene.orphan.hint": "这些记忆直接放在 memories/ 根层,没有归属场景,因此不会进入系统提示词。请把它们移入某个场景目录(或放到 memories/global/ 作为「全局」记忆)。",
@@ -309,7 +333,7 @@ window.__ModuleLoader__.load({
309
333
  "memory.result.sceneCreated": "已创建场景:{name}", "memory.result.sceneUpdated": "已保存场景:{name}", "memory.result.sceneRemoved": "已删除场景:{name}",
310
334
  "memory.table.name": "记忆名称与描述", "memory.table.tags": "标记", "memory.table.status": "状态",
311
335
  "error.rules.invalidGroup": "场景/分组名不合法(非空、≤64 字符、不含路径分隔符与 < > : \" | ? *、不以 . 开头)", "error.rules.invalidName": "记忆名不合法(非空、≤64 字符、不含路径分隔符与 < > : \" | ? *、不以 . 开头)", "error.rules.descriptionRequired": "描述不能为空", "error.rules.descriptionTooLong": "描述过长(不能超过 500 字符)", "error.rules.bodyRequired": "正文不能为空", "error.rules.tooLarge": "规则内容过大", "error.rules.shadowed": "规则被同名 bundle 遮蔽,无法写入", "error.rules.notFound": "规则不存在", "error.rules.budgetExceeded": "场景记忆段超出预算", "error.rules.ioFailed": "文件操作失败", "error.rules.sceneNotEmpty": "场景不为空,无法删除", "error.rules.notBundle": "该记忆是 flat(单文件),不能带附件", "error.rules.noFiles": "没有选择附件", "error.rules.emptyFile": "附件内容为空", "error.rules.fileTooLarge": "附件过大(单个上限 {limit} MB)", "error.rules.tooManyFiles": "一次最多 {limit} 个附件", "error.rules.sceneInMode": "该场景正处在当前模式,请先退出模式再删除",
312
- "memory.archive.emptySection": "该段已定义但没有勾选任何条目 = 全部停用",
336
+ "memory.archive.emptySection": "该段已定义但没有勾选任何条目 = 全部停用", "memory.archive.emptySubagents": "该段已定义但没有勾选任何条目 = 不限制(全部人设可用)",
313
337
  "subagents.title": "子智能体", "subagents.desc": "管理人设:一个人设一个文件,正文即子代理的系统提示词。",
314
338
  "prompts.desc": "管理全局指令基线:应用后写入 AGENTS.md,新会话生效。",
315
339
  "sessions.desc": "管理已归档会话:可恢复或永久删除,超保留期自动清理。",
@@ -423,30 +447,30 @@ window.__ModuleLoader__.load({
423
447
  "tabs.scenes": "Scenes", "tabs.skills": "Skills", "tabs.subagents": "Subagents", "tabs.prompts": "Prompts", "tabs.memory": "Memories", "tabs.sessions": "Sessions",
424
448
  "scenes.title": "Scenes", "scenes.desc": "Manage scenes: preset different usage scenes combining MCP servers, skills, subagents and memories",
425
449
  "scenes.stat.total": "scene(s)", "scenes.stat.active": "enabled", "scenes.stat.archives": "with a profile",
426
- "scenes.noDesc": "No description yet (use Edit to add one)", "scenes.memCount": "{count} memor(ies)",
427
- "scenes.profile.summary": "Configured: {parts}", "scenes.profile.empty": "empty profile",
450
+ "scenes.noDesc": "No description yet",
451
+ "scenes.mode.active": "Active mode: {name}", "scenes.mode.profile": "Profile: {parts}", "scenes.mode.noProfile": "This scene has no profile yet",
452
+ "scenes.mode.enter": "Enter this mode", "scenes.empty": "No dedicated scenes yet — global memories are injected without one; create a scene when you need to switch MCP servers, skills or personas.",
453
+ "scenes.field.desc.limit": "Up to {count} characters; longer text is clipped on the card.",
428
454
  "scenes.profile.mcp": "{count} MCP", "scenes.profile.skills": "{count} skill(s)", "scenes.profile.subagents": "{count} subagent(s)", "scenes.profile.memories": "{count} memor(ies)",
429
- "scenes.hasArchive": "profile", "scenes.mcp.hint": "Add the MCP section first, then check the servers to enable (stopped ones included); use Pick tools to narrow to specific tools.",
455
+ "scenes.mcp.hint": "Add the MCP section first, then check the servers to enable (stopped ones included); use Pick tools to narrow to specific tools.",
430
456
  "scenes.seg.selectAll": "Select all", "scenes.seg.clear": "Clear", "scenes.seg.checked": "{checked}/{total} selected",
431
457
  "scenes.archive.sectionOff": "not defined",
432
458
  "scenes.archive.summary": "Profile: {mcp} MCP server(s) · {skills} skill(s) · {subagents} subagent(s) · {memories} memory item(s)",
433
- "scenes.archive.note": "An undefined section leaves that domain untouched; a defined-but-empty section disables everything in it (the memory section only affects injection — no file is deleted).",
459
+ "scenes.archive.note": "A newly added section starts with nothing checked. An undefined section leaves that domain untouched; a defined-but-empty section disables that domain (the subagent section is the exception: empty means no restriction; the memory section only affects injection — no file is deleted).",
434
460
  "scenes.mcp.noServers": "No MCP servers to pick from yet", "scenes.mcp.toolCount": "{count} tool(s)", "scenes.skills.empty": "No skills to pick from yet",
435
461
  "scenes.mcp.allTools": "all tools", "scenes.mcp.pickedCount": "{count} tools picked", "scenes.mcp.notRunning": "not running", "scenes.mcp.pickTools": "Pick tools",
436
462
  "scenes.mcp.toolsOf": "Tool details", "scenes.mcp.back": "Back", "scenes.mcp.noTools": "No tools listed for this server (not running or no tools)", "scenes.mcp.drillHint": "Checked = enabled in this scene; unchecked = disabled. A whole-server check defaults to all tools.",
437
463
  "scenes.skills.hint": "Add the skills section first, then check the skills enabled in this scene.", "scenes.subagents.hint": "Add the subagent section first, then check the personas callable in this scene.", "scenes.subagents.empty": "No personas yet — create one on the Subagents page.",
438
464
  "scenes.filter.skills": "Filter skills (name)", "scenes.filter.subagents": "Filter personas (name or description)", "scenes.filter.servers": "Filter servers",
439
- "scenes.mem.hint": "Add the memory section, then check the memories injected for this scene; unchecked memories never reach the system prompt (their files stay).",
440
- "scenes.mem.title": "Memories", "scenes.mem.search": "Filter memories in this scene (name or description)", "scenes.mem.searchScene": "Filter scenes (name, description or memory name)",
441
- "scenes.mem.noMatch": "Nothing matches", "scenes.mem.noScenes": "No scenes yet — create one on the Scenes page.", "scenes.mem.emptyScene": "This scene has no memories yet",
442
- "scenes.mem.pick": "Pick memories", "scenes.mem.drill": "Memory details", "scenes.mem.drillHint": "Checked = this memory is injected for the scene; unchecked = not injected (file and content untouched).",
443
- "scenes.mem.sceneCount": "{checked}/{total} checked", "scenes.mem.alwaysOn": "always injected",
444
- "memory.mode.current": "Active mode", "memory.mode.set": "Set as active mode", "memory.mode.exit": "Exit mode",
465
+ "scenes.mem.hint": "Add the memory section, then check the memories injected for this scene (only this scene's own memories are listed — global memories are always injected and need no configuration here); unchecked memories never reach the system prompt (their files stay).",
466
+ "scenes.mem.title": "Memories", "scenes.mem.search": "Filter memories in this scene (name or description)",
467
+ "scenes.mem.noMatch": "Nothing matches", "scenes.mem.emptyScene": "This scene has no memories yet",
468
+ "memory.mode.current": "Active mode", "memory.mode.exit": "Exit mode",
445
469
  "memory.archive.edit": "Profile", "memory.archive.title": "Scene profile",
446
470
  "memory.archive.tools": "MCP tools", "memory.archive.skills": "Skills", "memory.archive.subagents": "Subagent binding", "memory.archive.memories": "Memories",
447
471
  "memory.archive.addTools": "+ Tools", "memory.archive.addSkills": "+ Skills", "memory.archive.addSubagents": "+ Subagents", "memory.archive.addMemories": "+ Memories",
448
472
  "memory.archive.removeSection": "Remove section", "memory.archive.save": "Save to scene",
449
- "memory.archive.emptySection": "Section defined with nothing checked = all disabled", "memory.archive.stale": "Stale entries (no longer exist, skipped): {items}",
473
+ "memory.archive.emptySection": "Section defined with nothing checked = all disabled", "memory.archive.emptySubagents": "Section defined with nothing checked = no restriction (all personas allowed)", "memory.archive.stale": "Stale entries (no longer exist, skipped): {items}",
450
474
  "memory.result.archiveSaved": "Scene profile saved: {name}", "memory.result.modeSet": "Entered mode: {name}", "memory.result.modeExited": "Exited mode",
451
475
  "memory.desc": "Manage scene memories: memories in enabled scenes are injected into the system prompt automatically, and a blank scene means global injection.",
452
476
  "memory.btn.refresh": "Refresh", "memory.btn.new": "New memory", "memory.btn.create": "Create", "memory.btn.save": "Save",
@@ -458,7 +482,7 @@ window.__ModuleLoader__.load({
458
482
  "memory.budget.over": "Over budget",
459
483
  "memory.budget.truncated": "Over budget — the memories left out are listed at the end",
460
484
  "memory.budget.dropped": "Not injected this time: {names}",
461
- "memory.scene.enable": "Enabled", "memory.scene.enableAll": "Enable all", "memory.scene.off": "Off", "memory.scene.shared": "always on", "memory.scene.global.tag": "injected everywhere",
485
+ "memory.scene.enable": "Enabled", "memory.scene.enableAll": "Enable all", "memory.scene.off": "Off", "memory.scene.shared": "always on", "memory.scene.global.tag": "always injected",
462
486
  "memory.scene.global": "Global", "memory.scene.count": "{count} memories", "memory.scene.new": "New memory", "memory.scene.browse": "Pick existing",
463
487
  "memory.scene.empty": "No memories in this scene yet",
464
488
  "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 never reach the system prompt. Move them into a scene directory (or into memories/global/ to make them “Global”).",
@@ -1302,7 +1326,8 @@ function callApi(path, options) {
1302
1326
  h('input', { type: 'checkbox', checked: props.checked === true, disabled: props.disabled === true, onChange: props.onChange }),
1303
1327
  h('span', { className: 'dsm-pick-main' },
1304
1328
  h('span', { className: 'dsm-pick-name' }, props.name),
1305
- props.desc ? h('span', { className: 'dsm-pick-desc' }, props.desc) : null),
1329
+ // 行内传进来的 desc 可能已被调用方截断,全文走 descTitle(悬浮可见)。
1330
+ props.desc ? h('span', { className: 'dsm-pick-desc', title: props.descTitle || undefined }, props.desc) : null),
1306
1331
  props.meta ? h('span', { className: 'dsm-pick-meta' }, props.meta) : null,
1307
1332
  props.actions ? h('span', { className: 'dsm-pick-actions' }, props.actions) : null)
1308
1333
  }
@@ -2011,6 +2036,63 @@ function callApi(path, options) {
2011
2036
  return name
2012
2037
  }
2013
2038
 
2039
+ /**
2040
+ * 场景描述在卡片上最多显示多少个字。
2041
+ *
2042
+ * 描述是自由文本,卡片只留一行:不裁的话一个长描述会把卡片撑成纵向(整页高度失控),
2043
+ * 保留场景「全局」尤其明显——它的名字只有两个字,剩下的宽度全被描述占满。
2044
+ * 上限同时写在新建/编辑表单的 `maxLength` 上(见场景表单),显示层再裁一次兜住历史数据。
2045
+ */
2046
+ var SCENE_DESC_MAX = 60
2047
+ /** 记忆描述在勾选行里最多显示多少个字:记忆正文可能很长,行内只留一行,超长截断(全文进 title)。 */
2048
+ var MEM_DESC_MAX = 80
2049
+ /** 压平空白并裁到 max 个字符(含省略号);返回空串 = 没有描述。 */
2050
+ function clipText(value, max) {
2051
+ var s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
2052
+ if (s.length <= max) return s
2053
+ return s.slice(0, max - 1) + '…'
2054
+ }
2055
+ /**
2056
+ * 记忆归属的场景名 = 分组路径的第一段(`办公/流程/站会` → `办公`)。
2057
+ *
2058
+ * 与宿主的 `sceneOf()`、渲染路径里的 `ref.scene` 逐字一致(`src/rules/service.ts:763`、
2059
+ * `scene` 由一级目录写入 `probe.refs`)。界面若按完整 group 比对,多级分组的记忆会整个看不见。
2060
+ */
2061
+ function sceneOfGroup(group) {
2062
+ var s = String(group == null ? '' : group)
2063
+ var i = s.indexOf('/')
2064
+ return i >= 0 ? s.slice(0, i) : s
2065
+ }
2066
+ /** 某个场景的记忆(按名称排序)。**只含该场景自己的**——跨场景勾选在宿主侧是不生效的,见下。 */
2067
+ function sceneMemoriesOf(memories, scene) {
2068
+ var list = Array.isArray(memories) ? memories : []
2069
+ return list
2070
+ .filter(function (m) { return m && sceneOfGroup(m.scene) === scene })
2071
+ .sort(function (a, b) { return String(a.name).localeCompare(String(b.name)) })
2072
+ }
2073
+ /**
2074
+ * 「添加记忆段」的默认勾选:**只勾被编辑场景里已启用的那几条**,其余一律不勾。
2075
+ *
2076
+ * 记忆段的语义是「勾 = 该场景的记忆是否注入」,所以默认值取「用户已经让这个场景生效的那几条」——
2077
+ * 既不改变现状,也不会把没打算注入的记忆塞进档案。启用状态来自 `rules-list`(记忆 id 就是
2078
+ * 规则 id),因此纯客户端可算,不需要宿主配合。
2079
+ */
2080
+ function memDefaultPickIds(memories, rules, scene) {
2081
+ var enabled = {}
2082
+ var rows = Array.isArray(rules) ? rules : []
2083
+ rows.forEach(function (r) {
2084
+ if (!r || r.shadowed === true || r.enabled === false) return
2085
+ enabled[String(r.id)] = true
2086
+ })
2087
+ return sceneMemoriesOf(memories, scene)
2088
+ .filter(function (m) { return enabled[String(m && m.id)] === true })
2089
+ .map(function (m) { return String(m.id) })
2090
+ }
2091
+ /** 场景页卡片上的描述行:**只有描述**,数量(记忆条数 / 已配 N 台 MCP…)不进卡片。 */
2092
+ function sceneTileDesc(scene) {
2093
+ return clipText(scene && scene.description, SCENE_DESC_MAX)
2094
+ }
2095
+
2014
2096
  // ---------- 场景记忆页(场景 = scene-memory/ 下的一级目录;内容自动生效)----------
2015
2097
  //
2016
2098
  // 变更单 01 之后 Rules 与 Scenes 合并为本页:**场景(一级目录)是分组维度,
@@ -2019,7 +2101,7 @@ function callApi(path, options) {
2019
2101
  // ---------- 场景页:场景清单 + 档案(MCP 工具集/技能集/子智能体绑定,三段自由搭配)+ 当前模式 ----------
2020
2102
  function ScenesPage() {
2021
2103
  var EMPTY_MODE = { scene: null, snapshot: null }
2022
- var state = React.useState({ loading: true, error: null, scenes: [], activeMode: 'all', mode: EMPTY_MODE, archives: {} })
2104
+ var state = React.useState({ loading: true, error: null, scenes: [], rules: [], activeMode: 'all', mode: EMPTY_MODE, archives: {} })
2023
2105
  var data = state[0], setData = state[1]
2024
2106
  var bs = React.useState(false)
2025
2107
  var busy = bs[0], setBusy = bs[1]
@@ -2038,7 +2120,9 @@ function callApi(path, options) {
2038
2120
  if (m && m.ok) setData(function (prev) { return Object.assign({}, prev, { mode: m.mode || EMPTY_MODE, archives: m.archives || {} }) })
2039
2121
  }).catch(function () {})
2040
2122
  apiCall('rules-list', {}).then(function (r) {
2041
- if (r && r.ok) setData(function (prev) { return Object.assign({}, prev, { loading: false, error: null, scenes: r.scenes || [], activeMode: r.activeMode === 'custom' ? 'custom' : 'all', stats: r.stats || prev.stats }) })
2123
+ // rules 也留下来:档案弹窗「记忆」段的默认勾选要用每条记忆的启用状态
2124
+ //(scene-inventory 的 memories 里没有 enabled,客户端自己算,省一次宿主改动)。
2125
+ if (r && r.ok) setData(function (prev) { return Object.assign({}, prev, { loading: false, error: null, scenes: r.scenes || [], rules: r.rules || [], activeMode: r.activeMode === 'custom' ? 'custom' : 'all', stats: r.stats || prev.stats }) })
2042
2126
  else setData(function (prev) { return Object.assign({}, prev, { loading: false, error: translateError(t, r) }) })
2043
2127
  }).catch(function (e) { setData(function (prev) { return Object.assign({}, prev, { loading: false, error: String((e && e.message) || e) }) }) })
2044
2128
  }
@@ -2105,7 +2189,7 @@ function callApi(path, options) {
2105
2189
  if (!(inv && inv.ok)) { setResult({ ok: false, text: translateError(t, inv) }); return }
2106
2190
  var archives = (modeRes && modeRes.ok ? modeRes.archives : null) || data.archives || {}
2107
2191
  var archive = archives[name] || {}
2108
- setModal({ type: 'scene-archive', name: name, drill: null, memDrill: null, memQuery: '', skillQuery: '', subQuery: '', mcpQuery: '',
2192
+ setModal({ type: 'scene-archive', name: name, drill: null, memQuery: '', skillQuery: '', subQuery: '', mcpQuery: '',
2109
2193
  sections: {
2110
2194
  mcp: archive.mcp ? Object.assign({}, archive.mcp) : null,
2111
2195
  skills: Array.isArray(archive.skills) ? archive.skills.slice() : null,
@@ -2115,7 +2199,8 @@ function callApi(path, options) {
2115
2199
  inventory: {
2116
2200
  mcpServers: inv.mcpServers || [], skills: inv.skills || [], subagents: inv.subagents || [], tools: inv.tools || [],
2117
2201
  // 记忆段的数据源(宿主 scene-inventory 回传;老宿主缺失时退化为空列表而不是崩)。
2118
- memScenes: inv.scenes || [], memories: inv.memories || [],
2202
+ // 只留扁平 memories——记忆段现在按「记忆所属场景」自己筛,不再需要场景清单。
2203
+ memories: inv.memories || [],
2119
2204
  } })
2120
2205
  }).catch(function (e) { setBusy(false); setResult({ ok: false, text: String((e && e.message) || e) }) })
2121
2206
  }
@@ -2129,24 +2214,15 @@ function callApi(path, options) {
2129
2214
  setSections(Object.assign({}, sections, { mcp: mcp }))
2130
2215
  }
2131
2216
  /**
2132
- * 「添加 MCP 工具集」的初值 = 当前运行时启用状态(设计 §2.4「预勾当前启用状态」)。
2133
- * 勾选集语义是「勾 = 启用」,空段 = 全部停用,所以绝不能拿 {} 当默认值——
2134
- * 否则用户点一下「添加」就把所有 MCP 服务器关掉。
2217
+ * 新加段一律**默认不勾选**(用户明确要求:MCP 工具集 / 技能集 / 子智能体绑定都不预勾)。
2218
+ *
2219
+ * 取 2026-09-13 前的「预勾当前运行时状态」是错的方向:用户点「添加」只是想开始配,
2220
+ * 不是想把自己现有的启用状态抄进档案。代价要讲清楚——勾选集语义是「勾 = 启用」,
2221
+ * 空段 = 全部停用,所以「添加 MCP 工具集」当下就等价于「该场景下全部 MCP 停用」,
2222
+ * 段脚注(`memory.archive.emptySection`)与弹窗顶部说明会同时把这句话显示出来。
2223
+ * 子智能体段例外:空段 = 不限制(全部人设可用,见 filterBySceneBinding)。
2135
2224
  */
2136
- function mcpPreset() {
2137
- var preset = {}
2138
- var states = modal.inventory.tools || []
2139
- ;(modal.inventory.mcpServers || []).forEach(function (server) {
2140
- if (server.allToolsDisabled) return // 整台已停用 → 不勾(等价)
2141
- var prefix = server.name + '/'
2142
- var list = states.filter(function (x) { return x.key.indexOf(prefix) === 0 })
2143
- var enabled = list.filter(function (x) { return x.enabled !== false })
2144
- if (!list.length || enabled.length === list.length) { preset[server.name] = '*'; return }
2145
- if (!enabled.length) return // 全停用 → 不勾(等价)
2146
- preset[server.name] = enabled.map(function (x) { return x.key.slice(prefix.length) })
2147
- })
2148
- return preset
2149
- }
2225
+ function emptyMcpPreset() { return {} }
2150
2226
  /** 全选:列出全部服务器(含未运行的)并各勾「全部工具」。 */
2151
2227
  function mcpSelectAll() {
2152
2228
  var all = {}
@@ -2167,28 +2243,33 @@ function callApi(path, options) {
2167
2243
  onAdd: onAdd, onRemove: onRemove, onAll: onAll, onClear: onClear,
2168
2244
  })
2169
2245
  }
2170
- function archiveSegFoot(defined, count) {
2171
- return segFoot(defined, count, t('memory.archive.emptySection'))
2172
- }
2173
-
2174
- // ── 段 4:记忆(v3)────────────────────────────────────────────────
2175
- // 数据源是宿主的 scene-inventory(scenes + memories 两个扁平列表)。
2176
- // 勾选语义与其余段一致:段已定义 → 没勾的记忆在该场景下不注入;段未定义 = 不碰。
2177
- // 记忆文件不受影响(纯投影),所以这一段没有「运行时副作用」。
2178
- function memGrouped() {
2179
- var scenes = (modal.inventory.memScenes || []).slice()
2180
- var all = modal.inventory.memories || []
2181
- return scenes.map(function (s) {
2182
- return {
2183
- scene: s,
2184
- items: all.filter(function (m) { return m.scene === s.name })
2185
- .sort(function (a, b) { return String(a.name).localeCompare(String(b.name)) }),
2186
- }
2187
- })
2188
- }
2189
- /** 记忆段已定义时的初值 = 当前全部记忆(「添加」不改变现状,是最小惊讶的选择)。 */
2246
+ /**
2247
+ * 段脚注:段已定义但一项未勾必须显式提示,否则像「没保存上」。
2248
+ * 各域的后果不同(MCP/技能/记忆 = 全部停用;子智能体 = 不限制),所以文案由调用方给。
2249
+ */
2250
+ function archiveSegFoot(defined, count, emptyLabel) {
2251
+ return segFoot(defined, count, emptyLabel || t('memory.archive.emptySection'))
2252
+ }
2253
+
2254
+ // ── 段 4:记忆 ─────────────────────────────────────────────────────
2255
+ // **只列被编辑场景自己的记忆**,与 MCP / 技能 / 子智能体三段同构:标题栏 + 一排勾选行,
2256
+ // 没有场景卡片、没有「选记忆」钻取。两个理由:
2257
+ // ① 语义(实测):宿主的 `memoryAllowed(archives, file.scene, id)` 按**记忆所属场景**取档案
2258
+ // (`src/rules/archive.ts:135`,渲染调用点 `src/rules/service.ts:1044`)——A 场景档案里的
2259
+ // memories 段只能门控 A 自己的记忆。勾别的场景(含保留场景 `global`)**完全不生效**,
2260
+ // 列出来就是陷阱。回归测试 `hub-layout.test.mjs`「保留场景 global 的记忆不受其它场景的勾选段影响」。
2261
+ // ② 用户裁定:「其他场景的记忆也不需要显示全局」——全局恒定注入,本来也不需要在这里配置。
2262
+ // 勾选语义与其余段一致:段已定义 → 没勾的该场景记忆不注入;段未定义 = 不碰。文件一律不动。
2263
+ function sceneMemories(sceneName) {
2264
+ return sceneMemoriesOf(modal.inventory.memories, sceneName)
2265
+ }
2266
+ /** 「全选」= 勾上本场景的全部记忆。 */
2267
+ function allMemoryIds() {
2268
+ return sceneMemories(modal.name).map(function (m) { return String(m.id) })
2269
+ }
2270
+ /** 「添加记忆段」的默认勾选:本场景里**已启用**的那几条。 */
2190
2271
  function memPreset() {
2191
- return (modal.inventory.memories || []).map(function (m) { return String(m.id) })
2272
+ return memDefaultPickIds(modal.inventory.memories, data.rules, modal.name)
2192
2273
  }
2193
2274
  function memCheckedCount() {
2194
2275
  var sections = modalSections()
@@ -2201,90 +2282,41 @@ function callApi(path, options) {
2201
2282
  if (i >= 0) list.splice(i, 1); else list.push(id)
2202
2283
  setSections(Object.assign({}, sections, { memories: list }))
2203
2284
  }
2204
- function memDrillList() {
2205
- var all = (modal.inventory.memories || []).filter(function (m) { return m.scene === modal.memDrill })
2206
- var q = String(modal.memQuery || '').trim().toLowerCase()
2207
- var list = q ? all.filter(function (m) {
2208
- return String(m.name).toLowerCase().indexOf(q) >= 0 || String(m.description || '').toLowerCase().indexOf(q) >= 0
2209
- }) : all
2210
- return list.sort(function (a, b) { return String(a.name).localeCompare(String(b.name)) })
2211
- }
2212
2285
  function memoriesSeg() {
2213
2286
  var sections = modalSections()
2214
2287
  var defined = !!sections.memories
2215
- var groups = memGrouped()
2216
- var total = (modal.inventory.memories || []).length
2217
- if (modal.memDrill) {
2218
- // 场景内的记忆明细:与 MCP「选工具」同构的钻取视图,弹窗尺寸不变。
2219
- var scene = (modal.inventory.memScenes || []).filter(function (s) { return s.name === modal.memDrill })[0] || { name: modal.memDrill, label: modal.memDrill, count: 0 }
2220
- var items = memDrillList()
2221
- var sceneIds = (modal.inventory.memories || []).filter(function (m) { return m.scene === modal.memDrill }).map(function (m) { return String(m.id) })
2222
- var checkedInScene = sceneIds.filter(function (id) { return (sections.memories || []).indexOf(id) >= 0 }).length
2223
- return seg({
2224
- title: t('scenes.mem.drill') + ' · ' + (scene.label || scene.name),
2225
- count: t('scenes.seg.checked', { checked: checkedInScene, total: sceneIds.length }),
2226
- actions: [
2227
- React.createElement('button', { key: 'back', type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () { setModal(Object.assign({}, modal, { memDrill: null, memQuery: '' })) } }, t('scenes.mcp.back')),
2228
- React.createElement('button', { key: 'all', type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () {
2229
- var list = (sections.memories || []).slice()
2230
- sceneIds.forEach(function (id) { if (list.indexOf(id) < 0) list.push(id) })
2231
- setSections(Object.assign({}, sections, { memories: list }))
2232
- } }, t('scenes.seg.selectAll')),
2233
- React.createElement('button', { key: 'none', type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () {
2234
- setSections(Object.assign({}, sections, { memories: (sections.memories || []).filter(function (id) { return sceneIds.indexOf(id) < 0 }) }))
2235
- } }, t('scenes.seg.clear')),
2236
- ],
2237
- body: React.createElement('div', { className: 'dsm-seg-body' },
2238
- segFilter(modal.memQuery, function (v) { setModal(Object.assign({}, modal, { memQuery: v })) }, t('scenes.mem.search')),
2239
- items.length
2240
- ? React.createElement('div', null, items.map(function (m) {
2241
- return pickRow({
2242
- disabled: busy,
2243
- key: m.id,
2244
- checked: (sections.memories || []).indexOf(String(m.id)) >= 0,
2245
- name: m.name,
2246
- desc: m.description || null,
2247
- onChange: function () { toggleMemory(String(m.id)) },
2248
- })
2249
- }))
2250
- : React.createElement('div', { className: 'dsm-pick-empty' }, modal.memQuery ? t('scenes.mem.noMatch') : t('scenes.mem.emptyScene'))),
2251
- foot: React.createElement('div', { className: 'dsm-seg-foot' }, t('scenes.mem.drillHint')),
2252
- })
2253
- }
2288
+ var own = sceneMemories(modal.name)
2289
+ var q = String(modal.memQuery || '').trim().toLowerCase()
2290
+ var items = q ? own.filter(function (m) {
2291
+ return String(m.name).toLowerCase().indexOf(q) >= 0
2292
+ || String(m.description || '').toLowerCase().indexOf(q) >= 0
2293
+ }) : own
2254
2294
  return seg({
2255
2295
  title: t('memory.archive.memories'),
2256
- count: defined ? t('scenes.seg.checked', { checked: memCheckedCount(), total: total }) : t('scenes.archive.sectionOff'),
2296
+ count: defined ? t('scenes.seg.checked', { checked: memCheckedCount(), total: own.length }) : t('scenes.archive.sectionOff'),
2257
2297
  actions: archiveSegActions(defined, t('memory.archive.addMemories'),
2258
2298
  function () { setSections(Object.assign({}, modalSections(), { memories: memPreset() })) },
2259
2299
  function () { var s = Object.assign({}, modalSections()); delete s.memories; setSections(s) },
2260
- function () { setSections(Object.assign({}, modalSections(), { memories: memPreset() })) },
2300
+ function () { setSections(Object.assign({}, modalSections(), { memories: allMemoryIds() })) },
2261
2301
  function () { setSections(Object.assign({}, modalSections(), { memories: [] })) }),
2262
2302
  body: defined
2263
2303
  ? React.createElement('div', { className: 'dsm-seg-body' },
2264
- segFilter(modal.memQuery, function (v) { setModal(Object.assign({}, modal, { memQuery: v })) }, t('scenes.mem.searchScene')),
2265
- (function () {
2266
- var q = String(modal.memQuery || '').trim().toLowerCase()
2267
- var shown = q ? groups.filter(function (g) {
2268
- return String(g.scene.label || g.scene.name).toLowerCase().indexOf(q) >= 0
2269
- || String(g.scene.description || '').toLowerCase().indexOf(q) >= 0
2270
- || g.items.some(function (m) { return String(m.name).toLowerCase().indexOf(q) >= 0 })
2271
- }) : groups
2272
- if (!shown.length) return React.createElement('div', { className: 'dsm-pick-empty' }, q ? t('scenes.mem.noMatch') : t('scenes.mem.noScenes'))
2273
- // 场景卡片:一行就能看到「这是在管哪个场景的哪些记忆」,比纯勾选行信息量大。
2274
- return React.createElement('div', null, shown.map(function (g) {
2275
- var ids = g.items.map(function (m) { return String(m.id) })
2276
- var on = ids.filter(function (id) { return (sections.memories || []).indexOf(id) >= 0 }).length
2277
- return React.createElement('div', { key: 'mg:' + g.scene.name, className: 'dsm-scene-card' },
2278
- React.createElement('div', { className: 'dsm-scene-card-head' },
2279
- React.createElement('span', { className: 'dsm-pick-main' },
2280
- React.createElement('span', { className: 'dsm-pick-name' }, g.scene.label || g.scene.name),
2281
- React.createElement('span', { className: 'dsm-pick-desc' },
2282
- (g.scene.description ? g.scene.description + ' · ' : '') + t('scenes.mem.sceneCount', { checked: on, total: ids.length }))),
2283
- g.scene.global ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('scenes.mem.alwaysOn')) : null,
2284
- React.createElement('span', { className: 'dsm-pick-actions' },
2285
- React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy || !g.items.length, onClick: function (e) { e.preventDefault(); e.stopPropagation(); setModal(Object.assign({}, modal, { memDrill: g.scene.name, memQuery: '' })) } }, t('scenes.mem.pick')))))
2286
- }))
2287
- })())
2304
+ segFilter(modal.memQuery, function (v) { setModal(Object.assign({}, modal, { memQuery: v })) }, t('scenes.mem.search')),
2305
+ items.length
2306
+ ? React.createElement('div', null, items.map(function (m) {
2307
+ return pickRow({
2308
+ disabled: busy,
2309
+ key: m.id,
2310
+ checked: (sections.memories || []).indexOf(String(m.id)) >= 0,
2311
+ name: m.name,
2312
+ // 记忆描述是自由正文的首行,可能很长:截断到 MEM_DESC_MAX,全文放 title。
2313
+ // CSS 侧还有单行省略兜底(超长不再横向溢出段边框)。
2314
+ desc: m.description ? clipText(m.description, MEM_DESC_MAX) : null,
2315
+ descTitle: m.description || null,
2316
+ onChange: function () { toggleMemory(String(m.id)) },
2317
+ })
2318
+ }))
2319
+ : React.createElement('div', { className: 'dsm-pick-empty' }, q ? t('scenes.mem.noMatch') : t('scenes.mem.emptyScene')))
2288
2320
  : React.createElement('div', { className: 'dsm-seg-body' }, React.createElement('div', { className: 'dsm-pick-empty' }, t('scenes.mem.hint'))),
2289
2321
  foot: archiveSegFoot(defined, memCheckedCount()),
2290
2322
  })
@@ -2325,7 +2357,7 @@ function callApi(path, options) {
2325
2357
  title: t('memory.archive.tools'),
2326
2358
  count: defined ? t('scenes.seg.checked', { checked: Object.keys(sections.mcp).length, total: servers.length }) : t('scenes.archive.sectionOff'),
2327
2359
  actions: archiveSegActions(defined, t('memory.archive.addTools'),
2328
- function () { setSections(Object.assign({}, modalSections(), { mcp: mcpPreset() })) },
2360
+ function () { setSections(Object.assign({}, modalSections(), { mcp: emptyMcpPreset() })) },
2329
2361
  function () { var s = Object.assign({}, modalSections()); delete s.mcp; setSections(s) },
2330
2362
  mcpSelectAll,
2331
2363
  function () { setSections(Object.assign({}, modalSections(), { mcp: {} })) }),
@@ -2373,7 +2405,7 @@ function callApi(path, options) {
2373
2405
  title: t('memory.archive.skills'),
2374
2406
  count: defined ? t('scenes.seg.checked', { checked: sections.skills.length, total: allItems.length }) : t('scenes.archive.sectionOff'),
2375
2407
  actions: archiveSegActions(defined, t('memory.archive.addSkills'),
2376
- function () { setSections(Object.assign({}, modalSections(), { skills: allItems.filter(function (x) { return x.enabled }).map(function (x) { return x.key }) })) },
2408
+ function () { setSections(Object.assign({}, modalSections(), { skills: [] })) },
2377
2409
  function () { var s = Object.assign({}, modalSections()); delete s.skills; setSections(s) },
2378
2410
  function () { setSections(Object.assign({}, modalSections(), { skills: allItems.map(function (x) { return x.key }) })) },
2379
2411
  function () { setSections(Object.assign({}, modalSections(), { skills: [] })) }),
@@ -2439,7 +2471,7 @@ function callApi(path, options) {
2439
2471
  }))
2440
2472
  : React.createElement('div', { className: 'dsm-pick-empty' }, modal.subQuery ? t('scenes.mem.noMatch') : t('scenes.subagents.empty')))
2441
2473
  : React.createElement('div', { className: 'dsm-seg-body' }, React.createElement('div', { className: 'dsm-pick-empty' }, t('scenes.subagents.hint'))),
2442
- foot: archiveSegFoot(defined, defined ? sections.subagents.length : 0),
2474
+ foot: archiveSegFoot(defined, defined ? sections.subagents.length : 0, t('memory.archive.emptySubagents')),
2443
2475
  })
2444
2476
  }
2445
2477
  /**
@@ -2512,18 +2544,30 @@ function callApi(path, options) {
2512
2544
  }).catch(function (e) { setBusy(false); setResult({ ok: false, text: String((e && e.message) || e) }) })
2513
2545
  }
2514
2546
  var modeScene = data.mode && data.mode.scene
2515
- // 与 MCP / 技能页同构的三格统计(页面之间「头顶长什么样」保持一致)。
2547
+ /**
2548
+ * 场景页只列**可切换的预设场景**:保留场景 `global`(「全局」)不在这里出现。
2549
+ *
2550
+ * 用户裁定:「全局记忆都不用设置场景就能注入上下文,如果我需要设置 skills、mcp,我直接去具体页
2551
+ * 设置就好了,所以说场景不需要展示全局,不需要设置全局,只有有特定需求才需要设置专门的场景。」
2552
+ * —— 全局不是预设而是恒定基线,列成卡片只会让人以为它可启停/可切换;它的记忆在「记忆」页照常
2553
+ * 管理,档案弹窗「记忆」段与各种场景选择器也照常保留全局(否则全局记忆在界面上就没有落点了)。
2554
+ * 过滤只做在这一页:宿主回传的 `scenes` 仍含全局,记忆页与选择器都依赖它。
2555
+ */
2556
+ var presetScenes = (data.scenes || []).filter(function (s) { return s.global !== true })
2557
+ // 与 MCP / 技能页同构的三格统计(页面之间「头顶长什么样」保持一致)——同样只数可见的预设场景。
2516
2558
  var sceneStats = (function () {
2517
- var own = (data.scenes || [])
2559
+ var own = presetScenes
2518
2560
  return {
2519
2561
  total: own.length,
2520
2562
  active: own.filter(function (s) { return s.active === true }).length,
2521
- archives: Object.keys(data.archives || {}).length,
2563
+ archives: own.filter(function (s) { return !!data.archives[s.name] }).length,
2522
2564
  }
2523
2565
  })()
2524
2566
  /**
2525
- * 场景已绑定的东西,一行摘要(没绑的域不出现)。
2526
- * 之前卡片上只有一个「档案」标签,用户点进去才知道里面配了什么。
2567
+ * 档案里已配的东西,一行摘要(没绑的域不出现)。
2568
+ *
2569
+ * 它只出现在页首的「当前模式」条上:卡片上不再挂任何数量——那正是「卡片又乱又挤」
2570
+ * 的来源。全页只有这一处回答「当前生效的配置是什么」。
2527
2571
  */
2528
2572
  function archiveSummary(archive) {
2529
2573
  if (!archive) return ''
@@ -2534,6 +2578,7 @@ function callApi(path, options) {
2534
2578
  if (Array.isArray(archive.memories)) parts.push(t('scenes.profile.memories', { count: archive.memories.length }))
2535
2579
  return parts.join(' · ')
2536
2580
  }
2581
+ var modeSummary = modeScene ? archiveSummary(data.archives[modeScene]) : ''
2537
2582
  return React.createElement('section', { className: 'dsm-section' },
2538
2583
  React.createElement('div', { className: 'dsm-head' },
2539
2584
  React.createElement('div', { className: 'dsm-title-block' },
@@ -2542,8 +2587,6 @@ function callApi(path, options) {
2542
2587
  React.createElement(VersionBadge, null)),
2543
2588
  React.createElement('p', { className: 'dsm-desc' }, t('scenes.desc'))),
2544
2589
  React.createElement('div', { className: 'dsm-actions' },
2545
- modeScene ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('memory.mode.current') + ': ' + (sceneLabel(data.scenes, modeScene) || modeScene)) : null,
2546
- modeScene ? React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet dsm-btn-danger', disabled: busy, onClick: exitMode }, t('memory.mode.exit')) : null,
2547
2590
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy || data.loading, onClick: function () { refresh() } }, t('memory.btn.refresh')),
2548
2591
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy, onClick: openCreateScene }, t('memory.btn.newScene')))),
2549
2592
  React.createElement('div', { key: 'stats', className: 'dsm-summary dsm-summary-3' },
@@ -2551,37 +2594,50 @@ function callApi(path, options) {
2551
2594
  return React.createElement('div', { key: item[1], className: 'dsm-stat' },
2552
2595
  React.createElement('strong', null, item[0]), item[1])
2553
2596
  })),
2597
+ // 当前模式条:**只在真的进入了模式时才出现**。
2598
+ // 以前没有模式时也常驻一条「自由模式 + 一句解释」,用户反馈「这个是干什么的,感觉没什么用」——
2599
+ // 静态解释占一整条,而「没有条 = 没有模式」本来就不言自明。现在它只承载有状态的信息:
2600
+ // 哪个场景是当前模式、它包含什么档案、怎么退出。
2601
+ modeScene ? React.createElement('div', { key: 'mode', className: 'dsm-mode-bar' },
2602
+ React.createElement('span', { className: 'dsm-mode-dot' }),
2603
+ React.createElement('div', { className: 'dsm-mode-main' },
2604
+ React.createElement('div', { className: 'dsm-mode-title' },
2605
+ t('scenes.mode.active', { name: sceneLabel(data.scenes, modeScene) || modeScene })),
2606
+ React.createElement('div', { className: 'dsm-mode-sub' },
2607
+ modeSummary ? t('scenes.mode.profile', { parts: modeSummary }) : t('scenes.mode.noProfile'))),
2608
+ React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy, onClick: exitMode }, t('memory.mode.exit'))) : null,
2554
2609
  React.createElement(Notice, { key: 'notice', kind: result && result.ok ? 'ok' : 'err', text: result && result.text }),
2555
2610
  data.error ? React.createElement('div', { key: 'gerr', className: 'dsm-feedback dsm-error' }, String(data.error)) : null,
2556
2611
  data.loading ? React.createElement('div', { className: 'dsm-empty' }, t('memory.loading'))
2557
- : React.createElement('div', { className: 'dsm-sources' }, (data.scenes || []).map(function (scene) {
2612
+ : presetScenes.length ? React.createElement('div', { key: 'scenes', className: 'dsm-scenes' }, presetScenes.map(function (scene) {
2558
2613
  var name = scene.name
2614
+ var label = sceneLabel(data.scenes, name) || name
2559
2615
  var archive = data.archives[name]
2560
- var hasModeSections = !!(archive && (archive.mcp || Array.isArray(archive.skills)))
2561
- var summary = archiveSummary(archive)
2562
- return React.createElement('div', { key: 's:' + name, className: 'dsm-source' },
2563
- React.createElement('div', { className: 'dsm-source-head' },
2564
- React.createElement('div', { className: 'dsm-source-head-main' },
2565
- React.createElement('span', { className: 'dsm-source-title' }, sceneLabel(data.scenes, name) || name),
2566
- scene.global === true ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('memory.scene.global.tag')) : null,
2567
- scene.shared ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('memory.scene.shared')) : null,
2568
- modeScene === name ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('memory.mode.current')) : null,
2569
- scene.active === false ? React.createElement('span', { className: 'dsm-tag dsm-tag-off' }, t('memory.scene.off')) : null,
2570
- // 描述 + 记忆条数 + 已绑定内容:卡片本身就把「这个场景是什么」说清楚。
2571
- React.createElement('span', { className: 'dsm-note' },
2572
- (scene.description ? scene.description : t('scenes.noDesc'))
2573
- + ' · ' + t('scenes.memCount', { count: scene.count || 0 })
2574
- + (summary ? ' · ' + t('scenes.profile.summary', { parts: summary })
2575
- : (archive ? ' · ' + t('scenes.profile.empty') : ''))),
2576
- // 场景名(磁盘上的目录名)与显示名可能不同——把真名放在路径位,方便对文件。
2577
- React.createElement('span', { className: 'dsm-path' }, name)),
2578
- React.createElement('div', { className: 'dsm-source-actions' },
2616
+ // 全局已被上面的 presetScenes 过滤掉,这里只会遇到可切换的预设;
2617
+ // 历史保留场景 `_shared`(「常开」)仍然恒定注入,所以不给开关也不给删除。
2618
+ var locked = scene.shared === true
2619
+ // 档案里有任何一段才值得「切入此模式」——空档案切进去等于什么都没变。
2620
+ var hasModeSections = !!(archive && (archive.mcp || Array.isArray(archive.skills) || Array.isArray(archive.subagents) || Array.isArray(archive.memories)))
2621
+ var desc = sceneTileDesc(scene)
2622
+ return React.createElement('article', { key: 's:' + name, className: 'dsm-scene-tile' + (modeScene === name ? ' dsm-scene-tile-mode' : '') },
2623
+ React.createElement('div', { className: 'dsm-scene-tile-head' },
2624
+ React.createElement('span', { className: 'dsm-scene-tile-name', title: label }, label),
2625
+ // 显示名与磁盘目录名不同时才标出真名,方便对文件核对。
2626
+ name === label ? null : React.createElement('span', { className: 'dsm-scene-tile-key' }, name),
2627
+ locked ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('memory.scene.shared')) : null,
2628
+ modeScene === name ? React.createElement('span', { className: 'dsm-tag dsm-tag-on' }, t('memory.mode.current')) : null,
2629
+ !locked && scene.active === false ? React.createElement('span', { className: 'dsm-tag dsm-tag-off' }, t('memory.scene.off')) : null,
2630
+ locked ? null : React.createElement('span', { className: 'dsm-scene-tile-switch' },
2631
+ React.createElement(Switch, { on: scene.active === true, disabled: busy, label: t('memory.scene.enable') + ' ' + name, onClick: function () { toggleScene(scene) } }))),
2632
+ // 描述行**只有描述**(数量都收进上面的模式条了);全文放 title,卡片本身永远一行。
2633
+ React.createElement('p', { className: 'dsm-scene-tile-desc', title: desc || '' }, desc || t('scenes.noDesc')),
2634
+ React.createElement('div', { className: 'dsm-scene-tile-foot' },
2635
+ hasModeSections && modeScene !== name ? React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy, onClick: function () { enterMode(name) } }, t('scenes.mode.enter')) : null,
2636
+ React.createElement('div', { className: 'dsm-scene-tile-links' },
2579
2637
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () { openArchive(name) } }, t('memory.archive.edit')),
2580
2638
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () { openEditScene(scene) } }, t('memory.scene.edit')),
2581
- hasModeSections && modeScene !== name ? React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet', disabled: busy, onClick: function () { enterMode(name) } }, t('memory.mode.set')) : null,
2582
- scene.global === true || scene.shared ? null : React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet dsm-btn-danger', disabled: busy, onClick: function () { setModal({ type: 'scene-delete', name: name }) } }, t('memory.btn.deleteScene')),
2583
- scene.global === true || scene.shared ? null : React.createElement(Switch, { on: scene.active === true, disabled: busy, label: t('memory.scene.enable') + ' ' + name, onClick: function () { toggleScene(scene) } }))))
2584
- })),
2639
+ locked ? null : React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-quiet dsm-btn-danger', disabled: busy, onClick: function () { setModal({ type: 'scene-delete', name: name }) } }, t('memory.btn.deleteScene')))))
2640
+ })) : React.createElement('div', { key: 'empty', className: 'dsm-empty' }, t('scenes.empty')),
2585
2641
  modal && (modal.type === 'scene-create' || modal.type === 'scene-edit') ? React.createElement(Modal, { key: 'screate', title: modal.type === 'scene-create' ? t('memory.scene.createTitle') : t('memory.scene.editTitle'), closeLabel: t('btn.cancel'), onClose: function () { setModal(null) } },
2586
2642
  React.createElement('div', { className: 'dsm-form' },
2587
2643
  React.createElement('label', { className: 'dsm-field' },
@@ -2595,13 +2651,18 @@ function callApi(path, options) {
2595
2651
  }),
2596
2652
  React.createElement('p', { className: sceneForm.error ? 'dsm-rule-hint' : 'dsm-help' }, sceneForm.error || (modal.type === 'scene-create' ? t('memory.scene.field.name.hint') : t('memory.scene.field.name.lock')))),
2597
2653
  React.createElement('label', { className: 'dsm-field' },
2598
- React.createElement('span', { className: 'dsm-label' }, t('memory.scene.field.desc')),
2654
+ React.createElement('div', { className: 'dsm-budget-meta' },
2655
+ React.createElement('span', { className: 'dsm-label' }, t('memory.scene.field.desc')),
2656
+ // 与描述行一一对应的字数上限:卡片只显示一行,超长的描述会把卡片撑成纵向。
2657
+ React.createElement('span', { className: 'dsm-char-count' }, String(String(sceneForm.description || '').length) + '/' + SCENE_DESC_MAX)),
2599
2658
  React.createElement('input', {
2600
2659
  className: 'dsm-control',
2601
2660
  value: sceneForm.description || '',
2661
+ maxLength: SCENE_DESC_MAX,
2602
2662
  placeholder: t('memory.scene.field.desc.placeholder'),
2603
2663
  onChange: function (e) { setSceneForm(Object.assign({}, sceneForm, { description: e.target.value, error: null })) },
2604
- })),
2664
+ }),
2665
+ React.createElement('p', { className: 'dsm-help' }, t('scenes.field.desc.limit', { count: SCENE_DESC_MAX }))),
2605
2666
  React.createElement('div', { className: 'dsm-modal-actions' },
2606
2667
  React.createElement('button', { type: 'button', className: 'dsm-btn dsm-btn-secondary', disabled: busy, onClick: function () { setModal(null) } }, t('btn.cancel')),
2607
2668
  React.createElement('button', { type: 'button', className: 'dsm-btn', disabled: busy || !String(sceneForm.name || '').trim(), onClick: submitSceneForm }, t(modal.type === 'scene-create' ? 'memory.btn.create' : 'memory.btn.saveScene'))))) : null,
@@ -3480,6 +3541,16 @@ function callApi(path, options) {
3480
3541
  _pages.SubagentsPage = SubagentsPage
3481
3542
  _pages.MemoryPage = MemoryPage
3482
3543
  _pages.t = t
3544
+ // 纯函数(无状态、可单测):卡片描述行、场景归属判定、记忆段默认勾选都靠它们,
3545
+ // 契约是「描述超长必裁」「记忆段的勾选只认本场景自己的记忆」。测试直接调这几个函数。
3546
+ _pages.sceneTileDesc = sceneTileDesc
3547
+ _pages.clipText = clipText
3548
+ _pages.sceneOfGroup = sceneOfGroup
3549
+ _pages.sceneMemoriesOf = sceneMemoriesOf
3550
+ _pages.memDefaultPickIds = memDefaultPickIds
3551
+ // 常量用函数包一层:测试会把 _pages 的每个值当组件渲染一遍,数字会被 React 当成非法元素类型报警告。
3552
+ _pages.sceneDescMax = function () { return SCENE_DESC_MAX }
3553
+ _pages.memDescMax = function () { return MEM_DESC_MAX }
3483
3554
  },
3484
3555
  }
3485
3556
  return module.exports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, Skills, AGENTS.md presets, archived sessions and scene memories — plus scene memory injected into the system prompt and one-shot persona subagents. Settings UI + HTTP API + 14 model tools (skill_mcp_manager_*, skill_manager_*, agentsmd_*, rule_manager_*, subagent_*). Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
5
5
  "repository": {
6
6
  "type": "git",