dsh-plugin-tool-management 0.12.0 → 0.13.0

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.
@@ -248,28 +248,32 @@ export function createMcpManager(deps) {
248
248
  notesCache = { at: Date.now(), value: out };
249
249
  return out;
250
250
  }
251
+ /**
252
+ * 备注侧车的读-改-写。**必须在调用方已持有写锁时调用** —— 自己再去拿一次锁会自锁。
253
+ * 空 note = 删掉该条目(与 `mcpm-note` 的语义一致);强制重读,外部改过的侧车是合并而不是覆盖。
254
+ */
255
+ async function writeNoteUnderLock(id, note) {
256
+ const map = Object.assign({}, await readNotes(true));
257
+ if (note)
258
+ map[id] = note;
259
+ else
260
+ delete map[id];
261
+ try {
262
+ await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
263
+ }
264
+ catch (e) {
265
+ return { ok: false, error: '备注保存失败: ' + message(e) };
266
+ }
267
+ notesCache = { at: Date.now(), value: map };
268
+ return { ok: true, notes: map };
269
+ }
251
270
  async function mcpmNote(args) {
252
271
  const id = String((args && args.id) || '').trim();
253
272
  if (!id)
254
273
  return { ok: false, error: '缺少 id' };
255
274
  const note = String((args && args.note) == null ? '' : args.note).trim();
256
- const p = await ensurePaths();
257
- return withWriteLock(async () => {
258
- // Force re-read so an externally edited notes file is merged, not clobbered.
259
- const map = Object.assign({}, await readNotes(true));
260
- if (note)
261
- map[id] = note;
262
- else
263
- delete map[id];
264
- try {
265
- await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
266
- }
267
- catch (e) {
268
- return { ok: false, error: '备注保存失败: ' + message(e) };
269
- }
270
- notesCache = { at: Date.now(), value: map };
271
- return { ok: true, notes: map };
272
- });
275
+ await ensurePaths();
276
+ return withWriteLock(async () => await writeNoteUnderLock(id, note));
273
277
  }
274
278
  // 界面轮询间隔的合法区间:再快没有意义(列表刷新本身要读文件),再慢就失去"自动跟上"的
275
279
  // 意义。两侧都夹住,避免手改侧车文件时写进一个 0 或无穷大把界面刷爆。
@@ -447,8 +451,8 @@ export function createMcpManager(deps) {
447
451
  });
448
452
  }
449
453
  // Visibility seam: keep one active restriction per **agent scope**, refreshed whenever
450
- // the tool set or the disabled set changes. Three facts decide this design(都读过官方源码
451
- // dsh-tools 0.1.5-rc.2):
454
+ // the tool set / the disabled set / this agent's carrier set changes. Three facts decide
455
+ // this design(都读过官方源码 dsh-tools 0.1.5-rc.2):
452
456
  // · `restrict()` **要求 scoped context** —— 插件级 ctx 调用必抛 "requires a scoped
453
457
  // context",这正是「停用工具从模型可见 schema 消失」这半边此前从未生效的原因(V1);
454
458
  // · 每次 layer 变化(restriction 就是一次 layer effect)官方都会 emit `tools/change`
@@ -456,25 +460,62 @@ export function createMcpManager(deps) {
456
460
  // `tools/change` 上 —— 名单没变还重放就是自激;
457
461
  // · 表要取自**不受限**的插件级视图:在 agent scope 里取 `schemas()`,第一次限制生效后
458
462
  // 表里就没有被停用的工具了,第二次重放会把它从 deny 名单里筛掉 —— 停用静默复活。
463
+ // (2026-09-23:名单有三处来源,其中 `carrierHiddenTools` 逐 agent 不同,所以从"一份名单给
464
+ // 所有 agent"改成逐 agent 各装一层 —— 见下面 restrictAgent / appliedByAgent 的注释。)
459
465
  let restrictTimer = null;
460
466
  /** 已应用名单的键;相同即返回(防自激,也让每次 tools/change 变成一次廉价判等)。 */
461
467
  let appliedNamesKey = null;
462
- /** 当前期望的 deny 名单(agent 晚到 / 重放时用它)。 */
468
+ /** 当前期望的**全局** deny 名单(agent 晚到 / 重放时用它,再叠加该 agent 自己那份)。 */
463
469
  let desiredNames = [];
464
470
  /** agent → 撤掉这层限制的 disposer。 */
465
471
  const agentRestrictions = new Map();
472
+ /** agent → 已应用的**合并后**名单键(名单现在与 agent 有关,不能再只记一份全局的)。 */
473
+ const appliedByAgent = new Map();
466
474
  /** 见过的 agent(重放时逐个重新应用;disposed 时移除)。 */
467
475
  const seenAgents = new Set();
476
+ /**
477
+ * 重放代数:`carrierHiddenTools` 是异步的,算名单期间可能又来了新的一轮重放(或 agent
478
+ * 已经下线)。每次重放自增,回来时代数对不上就作废 —— 否则会用旧名单盖掉新的那一层。
479
+ */
480
+ let epoch = 0;
468
481
  function scopedToolsOf(agent) {
469
482
  const scoped = agent && agent.ctx && agent.ctx.tools;
470
483
  return scoped && typeof scoped === 'object' ? scoped : undefined;
471
484
  }
472
- /** 把当前名单装到某个 agent scope 上(空名单 = 没有要摘的工具,不碰它)。 */
473
- function restrictAgent(agent) {
474
- if (desiredNames.length === 0)
485
+ /** 撤掉某个 agent 上的限制(名单变了要重装:`restrict` 是叠加层,装新的不撤旧的会越叠越多)。 */
486
+ function liftRestriction(agent) {
487
+ const release = agentRestrictions.get(agent);
488
+ agentRestrictions.delete(agent);
489
+ appliedByAgent.delete(agent);
490
+ if (release) {
491
+ try {
492
+ release();
493
+ }
494
+ catch (e) { /* ignore */ }
495
+ }
496
+ }
497
+ /** 把该 agent 该背的名单装到它 scope 上(全局那份 ∪ 它自己那份;空名单 = 不碰它)。 */
498
+ async function restrictAgent(agent) {
499
+ const mine = epoch;
500
+ let extra = [];
501
+ if (deps.carrierHiddenTools) {
502
+ // 判据失败一律当"没有额外要藏的":藏错了模型就少一条路,留着只是多花 token。
503
+ try {
504
+ extra = await deps.carrierHiddenTools(agent);
505
+ }
506
+ catch (e) {
507
+ extra = [];
508
+ }
509
+ }
510
+ if (mine !== epoch)
475
511
  return;
512
+ if (!seenAgents.has(agent))
513
+ return;
514
+ const names = [...new Set([...desiredNames, ...extra])];
476
515
  const scoped = scopedToolsOf(agent);
477
516
  if (!scoped || typeof scoped.restrict !== 'function') {
517
+ if (names.length === 0)
518
+ return;
478
519
  // agent scope 上没有 tools 面(或它没有 restrict):这半边做不成,如实说 ——
479
520
  // 静默跳过会让人以为"停用工具从模型工具表里消失"已经生效。
480
521
  noteRuntime({
@@ -482,13 +523,22 @@ export function createMcpManager(deps) {
482
523
  label: '停用工具的可见性',
483
524
  kind: 'write',
484
525
  fallback: 'inform-only',
485
- detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
526
+ detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
486
527
  });
487
528
  return;
488
529
  }
530
+ const key = names.slice().sort().join('\u0000');
531
+ if (appliedByAgent.get(agent) === key)
532
+ return;
533
+ liftRestriction(agent);
534
+ if (names.length === 0) {
535
+ clearRuntimeNote('mcp-tool-visibility');
536
+ return;
537
+ }
489
538
  try {
490
- const dispose = scoped.restrict({ deny: desiredNames });
539
+ const dispose = scoped.restrict({ deny: names });
491
540
  agentRestrictions.set(agent, typeof dispose === 'function' ? dispose : () => { });
541
+ appliedByAgent.set(agent, key);
492
542
  clearRuntimeNote('mcp-tool-visibility');
493
543
  }
494
544
  catch (e) {
@@ -499,7 +549,7 @@ export function createMcpManager(deps) {
499
549
  label: '停用工具的可见性',
500
550
  kind: 'write',
501
551
  fallback: 'inform-only',
502
- detail: '停用的 MCP 工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
552
+ detail: '有工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
503
553
  });
504
554
  }
505
555
  }
@@ -508,19 +558,12 @@ export function createMcpManager(deps) {
508
558
  if (!agent || (typeof agent !== 'object' && typeof agent !== 'function'))
509
559
  return;
510
560
  seenAgents.add(agent);
511
- restrictAgent(agent);
561
+ void restrictAgent(agent).catch(() => { });
512
562
  }
513
- /** agent 下线(`agent/disposed`):先撤限制再销登记(撤限制会把 disposer 摘掉)。 */
563
+ /** agent 下线(`agent/disposed`):先撤限制再销登记。 */
514
564
  function detachAgent(agent) {
515
565
  seenAgents.delete(agent);
516
- const dispose = agentRestrictions.get(agent);
517
- agentRestrictions.delete(agent);
518
- if (dispose) {
519
- try {
520
- dispose();
521
- }
522
- catch (e) { /* ignore */ }
523
- }
566
+ liftRestriction(agent);
524
567
  }
525
568
  async function applyToolRestrictions() {
526
569
  if (typeof tools.restrict !== 'function')
@@ -545,24 +588,33 @@ export function createMcpManager(deps) {
545
588
  if (fullName.startsWith(prefix))
546
589
  wanted.push(fullName);
547
590
  }
548
- const names = wanted.filter((name) => registered.has(name));
549
- const key = names.join('\u0000');
550
- if (key === appliedNamesKey)
551
- return;
552
- appliedNamesKey = key;
553
- desiredNames = names;
554
- // 每次重放先撤旧限制:名单变短(重新启用某个工具)时,只有撤掉这层限制它才会重新可见。
555
- for (const [agent, dispose] of agentRestrictions) {
556
- agentRestrictions.delete(agent);
591
+ // 兼容页「模型工具表」关掉的本插件工具并进同一份名单(理由见 deps.pluginHiddenTools)。
592
+ // 交给上面那个 `registered` 过滤是**必要的**而不是保险:官方对认不出的名字直接抛错,
593
+ // 而关掉的工具里可能有一个这次根本没注册成功(注册失败会被子智能体页的黄条报出来)。
594
+ if (deps.pluginHiddenTools) {
557
595
  try {
558
- dispose();
596
+ for (const name of deps.pluginHiddenTools())
597
+ wanted.push(name);
559
598
  }
560
- catch (e) { /* ignore */ }
599
+ catch (e) { /* 读设置失败 = 这次不摘它们 */ }
561
600
  }
562
- if (!names.length)
601
+ const names = [...new Set(wanted)].filter((name) => registered.has(name));
602
+ const key = names.join('\u0000');
603
+ // 有 `carrierHiddenTools` 时不拿全局键短路:那份名单与"官方工具此时挂没挂"有关,
604
+ // 而它变了不会改全局键(官方挂载/卸载本身就发 tools/change,正是靠这条通路追上)。
605
+ // 代价是每次 tools/change 都重算一遍 —— 逐 agent 的键判等会把绝大多数挡在装层之前,
606
+ // 事实读取也有 TTL 缓存;装层本身会再发一次 tools/change,那一轮键相同即收敛,不自激。
607
+ if (key === appliedNamesKey && deps.carrierHiddenTools === undefined)
563
608
  return;
609
+ appliedNamesKey = key;
610
+ desiredNames = names;
611
+ // 每次重放先撤掉每个 agent 上的旧层:名单变短(重新启用某个工具)时,只有撤掉这层限制
612
+ // 它才会重新可见;名单变了(含逐 agent 那份)也必须重装而不是叠加。
613
+ epoch += 1;
614
+ for (const agent of [...agentRestrictions.keys()])
615
+ liftRestriction(agent);
564
616
  for (const agent of seenAgents)
565
- restrictAgent(agent);
617
+ void restrictAgent(agent).catch(() => { });
566
618
  }
567
619
  function scheduleToolRestrictions() {
568
620
  if (restrictTimer)
@@ -1097,7 +1149,16 @@ export function createMcpManager(deps) {
1097
1149
  catch (e) {
1098
1150
  return { ok: false, error: '写入补丁失败: ' + message(e) };
1099
1151
  }
1100
- return { ok: true, row: { ...row, level, disabled: defaultDisabled }, ...(guardNotes.length ? { warning: guardNotes.join(';') } : {}) };
1152
+ const out = { ok: true, row: { ...row, level, disabled: defaultDisabled }, ...(guardNotes.length ? { warning: guardNotes.join(';') } : {}) };
1153
+ // 备注跟着新增一起落侧车:**id 只有这里算得出来**(重名会加 `-2` 后缀),留给客户端
1154
+ // 补一次 `mcpm-note` 就拿不到最终 id。写失败不回滚已建好的条目,只把失败并进同一条 warning。
1155
+ const note = String((args && args.note) == null ? '' : args.note).trim();
1156
+ if (note) {
1157
+ const noted = await writeNoteUnderLock(id, note);
1158
+ if (!noted.ok)
1159
+ out.warning = out.warning ? out.warning + ';' + noted.error : noted.error;
1160
+ }
1161
+ return out;
1101
1162
  });
1102
1163
  }
1103
1164
  async function mcpmEdit(args) {
@@ -1714,13 +1775,10 @@ export function createMcpManager(deps) {
1714
1775
  restrictTimer = null;
1715
1776
  }
1716
1777
  // 插件卸载时把挂在每个 agent scope 上的限制都撤掉(否则那些 scope 会继续背着一层名单)。
1717
- for (const [, release] of agentRestrictions) {
1718
- try {
1719
- release();
1720
- }
1721
- catch (e) { /* ignore */ }
1722
- }
1723
- agentRestrictions.clear();
1778
+ epoch += 1;
1779
+ for (const agent of [...agentRestrictions.keys()])
1780
+ liftRestriction(agent);
1781
+ appliedByAgent.clear();
1724
1782
  seenAgents.clear();
1725
1783
  }
1726
1784
  return {
@@ -41,8 +41,11 @@
41
41
  // 与子智能体目录同构的 stale-while-revalidate(见 src/subagents/catalog.ts)。
42
42
  /** 段里最多列几台 server;超出部分只报数量。 */
43
43
  export const DEFAULT_MCP_MAX_ENTRIES = 50;
44
- /** 备注截断长度。备注是自由文本,界面侧另有 maxLength(500) 兜底,段侧再截一刀。 */
45
- export const DEFAULT_MCP_NOTE_MAX_LENGTH = 200;
44
+ /**
45
+ * 备注截断长度。界面上那两枚输入框(MCP 页与场景档案)用同一个数当字数上限,
46
+ * 客户端镜像在 `42-shared-ui.js` 的 `MCP_NOTE_MAX` —— 改这里要一起改它。
47
+ */
48
+ export const DEFAULT_MCP_NOTE_MAX_LENGTH = 300;
46
49
  /**
47
50
  * 备注的固定前缀(模型侧口径,用户裁定 2026-09-16:字段与段统一叫 user-hint / 用户提示)。
48
51
  *
@@ -180,6 +180,205 @@ export function createArchiveEngine(deps) {
180
180
  const slice = await deps.loadSlice();
181
181
  return { ok: true, archives: slice.archives, mode: slice.mode, active: slice.active };
182
182
  },
183
+ // 读:把「进入场景会改什么」先算一遍给人看,**一个字节都不写**。
184
+ //
185
+ // 为什么单独一个 op 而不是让界面自己估:进入场景真正的改动来自 `scene-mode-set` 里那三步
186
+ // (服务器级/来源级切换 → 工具级/技能级 → 人设 + 备注),计划由 `computeMcpPlan` /
187
+ // `computeSkillsPlan` 两个**纯函数**算出来。本 op 走同一批纯函数、读同一批现状,
188
+ // 只是把后面的 `apply*` 与 `saveSlice` 全部去掉 —— 于是预览与实际执行不可能各说各话
189
+ // (界面自己数勾出来的数字才会:档案段未定义 = 全关这条语义它并不知道)。
190
+ // 副作用清单:只有读。`loadSlice` / `current*` / `*States` / `known*` 全是读盘或读内存。
191
+ 'scene-mode-preview': async (args) => {
192
+ const hasKey = args && 'scene' in (args || {});
193
+ if (!hasKey)
194
+ return { ok: false, error: '缺少 scene(null = 退出模式)' };
195
+ const raw = (args && args.scene) ?? null;
196
+ const target = raw == null ? null : String(raw).trim() || null;
197
+ const slice = await deps.loadSlice();
198
+ const current = slice.mode && slice.mode.scene ? slice.mode.scene : null;
199
+ const cap = 60;
200
+ let truncated = false;
201
+ const clip = (list) => {
202
+ if (list.length <= cap)
203
+ return list;
204
+ truncated = true;
205
+ return list.slice(0, cap);
206
+ };
207
+ // ── 退出模式:说的是「快照会回写什么」,不是「关掉场景后剩下什么」 ──────────
208
+ if (target === null) {
209
+ if (!current) {
210
+ return { ok: true, current: null, target: null, noChange: true, exit: null, memory: null, mcp: null, skills: null, subagents: null, stale: [], truncated: false };
211
+ }
212
+ const snap = slice.mode.snapshot;
213
+ if (!snap) {
214
+ // 当前在场景内但快照丢了:退出只能落盘模式位,运行时回不来 —— 这句必须说出来。
215
+ return { ok: true, current, target: null, noChange: false, exit: { snapshotMissing: true }, memory: null, mcp: null, skills: null, subagents: null, stale: [], truncated: false };
216
+ }
217
+ const personaStates = snap.subagentsAll;
218
+ return {
219
+ ok: true,
220
+ current,
221
+ target: null,
222
+ noChange: false,
223
+ exit: {
224
+ scene: current,
225
+ mcpServers: Object.keys(snap.mcp || {}).length,
226
+ skills: Object.keys(snap.skills || {}).length,
227
+ subagents: personaStates ? Object.keys(personaStates).length : (snap.subagents ?? []).length + (snap.subagentsOn ?? []).length,
228
+ mcpNotes: (snap.mcpNotes ?? []).length,
229
+ },
230
+ // 记忆启用集**不随退出恢复**(设计 §2.2):退出后仍是这个场景,界面别把它写成"会还原"。
231
+ memory: { from: slice.active, to: slice.active, narrowed: false },
232
+ mcp: null, skills: null, subagents: null, stale: [], truncated: false,
233
+ };
234
+ }
235
+ if (!(await deps.sceneExists(target)))
236
+ return { ok: false, error: `场景不存在: ${target}` };
237
+ if (target === current) {
238
+ return { ok: true, current, target, noChange: true, exit: null, memory: null, mcp: null, skills: null, subagents: null, stale: [], truncated: false };
239
+ }
240
+ const archive = slice.archives[target];
241
+ // 与进入时逐字同口径:mcp / skills / subagents 三段**未定义 = 一个都没勾 = 全部停用**,
242
+ // 备注段是唯一例外(未定义 = 不覆盖)。所以这里不给 `compute*Plan` 兜空对象的分支。
243
+ let mcpPlan;
244
+ let skillsPlan;
245
+ let serverStates;
246
+ let sourceStates;
247
+ let configured = [];
248
+ try {
249
+ serverStates = await deps.mcpServerStates();
250
+ sourceStates = await deps.skillSourceStates();
251
+ configured = await deps.configuredServers();
252
+ mcpPlan = computeMcpPlan((archive && archive.mcp) || {}, {
253
+ configuredServers: configured,
254
+ knownTools: await deps.serverKnownTools(),
255
+ serverStates,
256
+ });
257
+ skillsPlan = computeSkillsPlan((archive && archive.skills) || [], await deps.knownSkillKeys(), sourceStates);
258
+ }
259
+ catch (e) {
260
+ return { ok: false, error: `读取运行时状态失败:${msg(e)}` };
261
+ }
262
+ let personas = null;
263
+ try {
264
+ const bound = archive && Array.isArray(archive.subagents) ? archive.subagents : [];
265
+ const known = await deps.knownPersonas();
266
+ const unbound = [...known].filter((n) => bound.indexOf(n) < 0).sort();
267
+ personas = { on: await deps.disabledPersonas(bound), off: await deps.enabledPersonas(unbound) };
268
+ }
269
+ catch (e) {
270
+ return { ok: false, error: `读取人设开关状态失败:${msg(e)}` };
271
+ }
272
+ // 服务器级:计划里已只含「需要变化」的行,按目标状态分两个方向。
273
+ const serversOn = [];
274
+ const serversOff = [];
275
+ for (const s of mcpPlan.serverSwitches)
276
+ (s.enabled ? serversOn : serversOff).push(s.serverName);
277
+ // 工具级停用表:与现状逐台比对(['*'] 是"整台停用"这个原子状态,不当工具名处理)。
278
+ //
279
+ // ⚠️ 只报**前后都在跑**的服务器(`offServers`)。服务器没在跑的时候,它的工具停用表只是
280
+ // 记账:把一台本来就没启用的(或这次正要停用的)服务器记成「整台停用」没有任何可见效果,
281
+ // 报成「停用 N 个 MCP 工具」是假话 —— 用户实测就是这么看到「停用 6 个 MCP 工具:
282
+ // context7/*、fastgraph/* …」,而那 6 台从来没启用过(2026-09-23)。服务器级的改动
283
+ // 由 `serverSwitches` 那一行如实说(它只含真的会变的),工具级留给逐工具开关真正起作用的
284
+ // 情形。
285
+ const offServers = new Set();
286
+ for (const state of serverStates)
287
+ if (state.disabled)
288
+ offServers.add(state.serverName);
289
+ for (const sw of mcpPlan.serverSwitches)
290
+ if (!sw.enabled)
291
+ offServers.add(sw.serverName);
292
+ const toolsOn = [];
293
+ const toolsOff = [];
294
+ try {
295
+ const now = await deps.currentMcpRaw();
296
+ for (const [server, want] of Object.entries(mcpPlan.entries)) {
297
+ if (offServers.has(server))
298
+ continue;
299
+ const before = now[server] || [];
300
+ const wildcard = (list) => list.length === 1 && list[0] === '*';
301
+ if (wildcard(want)) {
302
+ if (!wildcard(before))
303
+ toolsOff.push(`${server}/*`);
304
+ continue;
305
+ }
306
+ if (wildcard(before)) {
307
+ toolsOn.push(`${server}/*`);
308
+ continue;
309
+ }
310
+ const wantSet = new Set(want);
311
+ const beforeSet = new Set(before);
312
+ for (const tool of want)
313
+ if (!beforeSet.has(tool))
314
+ toolsOff.push(`${server}/${tool}`);
315
+ for (const tool of before)
316
+ if (!wantSet.has(tool))
317
+ toolsOn.push(`${server}/${tool}`);
318
+ }
319
+ }
320
+ catch (e) {
321
+ return { ok: false, error: `读取 MCP 停用表现状失败:${msg(e)}` };
322
+ }
323
+ // 技能级:计划产出的是**已知全集**的目标状态,与现状比对才得到"这次真的会改几个"。
324
+ const skillsOn = [];
325
+ const skillsOff = [];
326
+ try {
327
+ const now = await deps.currentSkills();
328
+ for (const [key, want] of Object.entries(skillsPlan.target)) {
329
+ const before = now[key];
330
+ if (before === undefined)
331
+ continue; // 现状里没有这一行 → 这次不算改动
332
+ if (want && !before)
333
+ skillsOn.push(key);
334
+ else if (!want && before)
335
+ skillsOff.push(key);
336
+ }
337
+ }
338
+ catch (e) {
339
+ return { ok: false, error: `读取技能启停现状失败:${msg(e)}` };
340
+ }
341
+ const sourcesOn = [];
342
+ const sourcesOff = [];
343
+ for (const s of skillsPlan.sourceSwitches)
344
+ (s.enabled ? sourcesOn : sourcesOff).push(s.root);
345
+ const stale = [...mcpPlan.stale, ...skillsPlan.stale];
346
+ const notesDefined = !!(archive && archive.mcpNotes);
347
+ const noteCount = notesDefined
348
+ ? Object.keys(archive.mcpNotes).filter((server) => archive.mcp && server in archive.mcp).length
349
+ : 0;
350
+ // 「进入后处于启用」的服务器(场景勾着的那几台 ∩ 配置里真实存在的)。它是**状态**不是改动,
351
+ // 与上面几行的"会改什么"互补:什么都没变时 MCP 那一段整段消失,读起来像缺了信息(用户
352
+ // 2026-09-23 实测)。工具级不列(服务器没在跑时它的工具数无从得知,报了就是猜)。
353
+ const enterServers = Object.keys((archive && archive.mcp) || {})
354
+ .filter((name) => configured.indexOf(name) >= 0)
355
+ .sort();
356
+ return {
357
+ ok: true,
358
+ current,
359
+ target,
360
+ noChange: false,
361
+ // 目标场景**没存过档案**:进入不是"按档案配",而是四域一律全关(未定义 = 全关)。
362
+ // 这是最容易被当成"点了个没配置的场景,应该什么都不改"的一种,界面必须单独说。
363
+ hasArchive: !!archive,
364
+ exit: current ? { scene: current, snapshotMissing: !slice.mode.snapshot } : null,
365
+ memory: { from: slice.active, to: [target], narrowed: true },
366
+ mcp: {
367
+ serversOn: clip(serversOn), serversOff: clip(serversOff),
368
+ toolsOn: clip(toolsOn), toolsOff: clip(toolsOff),
369
+ notes: noteCount, notesDefined,
370
+ },
371
+ // 进入后**处于启用**的服务器(结果口径;界面上是"进入后启用 N 台…"那一行)。
372
+ enter: { servers: clip(enterServers) },
373
+ skills: {
374
+ on: clip(skillsOn), off: clip(skillsOff),
375
+ sourcesOn: clip(sourcesOn), sourcesOff: clip(sourcesOff),
376
+ },
377
+ subagents: { on: clip(personas.on), off: clip(personas.off) },
378
+ stale: clip(stale),
379
+ truncated,
380
+ };
381
+ },
183
382
  // 写:保存某场景档案(三段整体替换;键对实时发现全集校验,未知键丢弃并报告)
184
383
  'scene-archive-save': (args) => serial(async () => {
185
384
  const scene = String((args && args.scene) || '').trim();
@@ -17,7 +17,7 @@ export const MAX_RULE_BYTES = 1 << 18; // 正文上限 256 KiB
17
17
  export const DEFAULT_ORDER = 1000; // 默认投影 order(索引无记录时)
18
18
  export const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
19
19
  export const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
20
- export const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
20
+ export const DEFAULT_MAX_BYTES = 1 << 17; // 场景记忆段预算上限(字节,=128 KiB)
21
21
  // ── 保留场景名 ─────────────────────────────────────────────────────────────
22
22
  /** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
23
23
  export const GLOBAL_SCENE = 'global';
@@ -204,12 +204,22 @@ export function deriveFromDoc(entry, doc) {
204
204
  }
205
205
  /** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
206
206
  export function projectRule(entry, derived, idxEntry) {
207
+ // 体积按**正文文件**算(bundle 的附件不计:附件不进注入正文)。一次 stat 一条记忆,
208
+ // 结果随快照的 1s TTL 缓存一起复用;读不到就不带这个字段,别把"不知道"报成 0 B。
209
+ let size;
210
+ try {
211
+ size = statSync(entry.docPath).size;
212
+ }
213
+ catch {
214
+ size = undefined;
215
+ }
207
216
  const rule = {
208
217
  id: entry.id,
209
218
  group: entry.group,
210
219
  name: derived.name,
211
220
  form: entry.kind,
212
221
  path: entry.docPath,
222
+ ...(size !== undefined ? { bytes: size } : {}),
213
223
  description: derived.description,
214
224
  ...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
215
225
  ...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),