dsh-plugin-tool-management 0.10.0 → 0.12.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -71,6 +71,7 @@
71
71
  // 遥测同理(`noteToolUse` 自己吞异常):它绝不能影响工具本身。
72
72
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
73
73
  import { SessionSeq } from '@deepseek-ai/dsh-session';
74
+ import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
74
75
  /**
75
76
  * 权威域顺序(界面勾选、注入消息先后都按它)。
76
77
  * 场景和记忆排第一:它是"当前模式"的框架,先给框架再给内容。
@@ -415,6 +416,38 @@ function kindOfMessage(message) {
415
416
  const source = message?.source;
416
417
  return source && typeof source.kind === 'string' ? source.kind : undefined;
417
418
  }
419
+ // B4:关域拦截的**顺序自检**。官方那两条注入行与本插件同挂在这条瀑布上,拦截成立的剩余前提是
420
+ // "本插件排在它们上游"(本插件用 `{ prepend: true }` 把自己钉在钩子表最前,所以真正还依赖的
421
+ // 只剩一条:官方那两条仍用**默认 push** 方式注册)。这仍是非契约事实(见 pre-step 监听器里的
422
+ // 注释)。官方哪天改成 prepend 抢到更前面,症状就是「关了域、模型还是收到内容」,且**完全没有
423
+ // 报错**。这里做最轻的自检:关域生效期间连续 N 个真实 turn 一条都没剔到,就上报一次;
424
+ // 剔到过就收掉那条上报。如实写清两种可能(顺序变了 / 官方这一轮本来没内容),
425
+ // 不把它说成一定是事故。
426
+ const SUPPRESSION_SUSPECT_TURNS = 5;
427
+ function suppressionVerdict(active, dropped, counter) {
428
+ if (!active) {
429
+ counter.turns = 0;
430
+ counter.drops = 0;
431
+ clearRuntimeNote('official-suppression');
432
+ return;
433
+ }
434
+ counter.turns += 1;
435
+ counter.drops += dropped;
436
+ if (counter.drops > 0) {
437
+ counter.turns = 0;
438
+ clearRuntimeNote('official-suppression');
439
+ return;
440
+ }
441
+ if (counter.turns < SUPPRESSION_SUSPECT_TURNS)
442
+ return;
443
+ noteRuntime({
444
+ id: 'official-suppression',
445
+ label: '官方注入的关域拦截',
446
+ kind: 'read',
447
+ fallback: 'inform-only',
448
+ detail: `关掉的注入域已连续 ${counter.turns} 轮没有拦到任何官方消息(自检):可能是瀑布注册顺序变了导致拦截失效,也可能是官方那两条注入行这几轮本来就没有内容。可到「注入实况」对照模型实际收到的内容。`,
449
+ });
450
+ }
418
451
  /** 实况展示用的 kind 表:本插件的五条 + 官方两条。 */
419
452
  const LIVE_KINDS = new Map([
420
453
  ...PLUGIN_KINDS,
@@ -567,7 +600,30 @@ export function createContextInjector(deps) {
567
600
  const ctx = deps.ctx;
568
601
  if (!ctx || typeof ctx.on !== 'function')
569
602
  return { dispose: () => { }, live, noteToolUse };
603
+ /** B4 自检计数(每个注入器实例各自一份,随实例销毁而消失)。 */
604
+ const suppressionCounter = { turns: 0, drops: 0 };
605
+ // `{ prepend: true }` —— 位置**必须**钉在钩子表最前,理由见 `next()` 之后那段注释
606
+ // (关域拦截只在上游成立)。这里不靠"谁先注册":cordis 的 Loader 是**并发**挂载条目的
607
+ // (`await Promise.allSettled(config.map((options) => this.create(options)))`,
608
+ // cordis-plugin-loader/lib/index.js),每个官方包 apply 的完成先后由模块导入与 IO 决定,
609
+ // 逐次启动都可能不同;本插件的条目又是补丁层 `insert` 推到条目列表**末尾**的
610
+ // (dsh-app-boot/lib/index.js:顶层 insert 走 `data.push(...insert)`)。
611
+ // 2026-09-20 实测到那次翻转:关掉技能 / 提示词域后,官方注入照旧进上下文。
612
+ // `prepend` 由 cordis `EventsService.register()` 实现(`hooks[options.prepend ? 'unshift' : 'push']`),
613
+ // 于是不论挂载先后,本插件都排在官方那两条**默认 push** 注册的注入行之前 ——
614
+ // 顺序从"竞态"变成"约定"。
570
615
  const stop = ctx.on('agent/pre-step', async (payload, next) => {
616
+ // 令牌门禁在**最前面**:没验过令牌时这一轮整个不放行,`next()` 都不必跑(后面那些注入
617
+ // 本来就是给模型看的,模型这一步根本不会被调用)。宿主据此把 turn 收成 `blocked`。
618
+ // 代价(宿主文档写明):被认领的那条用户消息会被丢弃 —— 这是"拦住"的固有代价,界面侧
619
+ // 的可见提示由兼容页那条「去填令牌」横幅承担。
620
+ try {
621
+ if (deps.tokenGateActive && deps.tokenGateActive()) {
622
+ log('令牌未验证:本轮对话被拒绝(在「工具 → 兼容」页填入访问令牌后恢复)');
623
+ return { kind: 'reject' };
624
+ }
625
+ }
626
+ catch { /* 门禁判定失败不能反过来卡住对话:当作放行 */ }
571
627
  const decision = await next();
572
628
  try {
573
629
  if (!decision || decision.kind === 'reject')
@@ -592,17 +648,31 @@ export function createContextInjector(deps) {
592
648
  // 边界说明:这不是改官方包、也不是改宿主机制 —— pre-step 的 decision 本来就是每个插件
593
649
  // 都能改的那条缝(官方自己就在这里追加消息),我们只把它剔出这一步的批次。代价是官方
594
650
  // 插件每一步都会重新渲染并尝试注入(它的历史读的是会话事件,读不到被拦下的那条),
595
- // 模型侧不受影响。位置在本监听器 `next()` 之后:本插件在这条瀑布里位于官方之前
596
- // (自己的消息总落在批次末尾,实测),所以官方这一步追加的消息在这里看得见。
651
+ // 模型侧不受影响。
652
+ // **为什么 `prepend` 是必需的**:官方那两条注入行都在 `await next()` **之后**才往
653
+ // `decision.messages` 里追加(dsh-tool-skill 末尾 `[...decision.messages, catalog]`;
654
+ // dsh-agent-instructions `toSpliced(lastClaimedIndex + 1, 0, desired)`),而瀑布的
655
+ // `next()` 只做"取钩子表的下一个"。所以只有排在他们**上游**的监听器,其 `next()`
656
+ // 返回时才看得见这些追加 —— 本插件的过滤在 `next()` 之后,位置必须在上游。
597
657
  const suppressedKinds = officialKindsToSuppress(settings);
598
658
  let messages = messagesOf(decision);
599
659
  if (suppressedKinds.size > 0) {
660
+ let dropped = 0;
600
661
  const kept = messages.filter((message) => {
601
662
  const kind = kindOfMessage(message);
602
- return kind === undefined || !suppressedKinds.has(kind);
663
+ if (kind !== undefined && suppressedKinds.has(kind)) {
664
+ dropped += 1;
665
+ return false;
666
+ }
667
+ return true;
603
668
  });
604
669
  if (kept.length !== messages.length)
605
670
  messages = kept;
671
+ // B4 自检:只有"这一步真的在拦"时才有意义(令牌门禁 reject、空 turn 早退都在上面)。
672
+ suppressionVerdict(true, dropped, suppressionCounter);
673
+ }
674
+ else {
675
+ suppressionVerdict(false, 0, suppressionCounter);
606
676
  }
607
677
  const facts = await deps.factsFor(agent);
608
678
  const sections = selectInjections(domains, settings, facts, agent);
@@ -681,13 +751,17 @@ export function createContextInjector(deps) {
681
751
  log('context injection failed: ' + String((error && error.message) || error));
682
752
  return decision;
683
753
  }
684
- });
754
+ }, { prepend: true });
685
755
  return {
686
- dispose: () => { try {
687
- if (typeof stop === 'function')
688
- stop();
689
- }
690
- catch { /* ignore */ } },
756
+ dispose: () => {
757
+ try {
758
+ if (typeof stop === 'function')
759
+ stop();
760
+ }
761
+ catch { /* ignore */ }
762
+ // 注入通道没了,自检结论也失效(B4)——留着会让兼容页报一件不存在的事。
763
+ clearRuntimeNote('official-suppression');
764
+ },
691
765
  live,
692
766
  noteToolUse,
693
767
  };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 宿主身份常量:插件在文件里认的这几个名字只在这里写一次。
3
+ *
4
+ * 以前 `@deepseek-ai/dsh-mcp-client` 在 `index.ts` 里有 6 份副本(2 份写给补丁的 YAML、
5
+ * 2 份解析补丁时的比对、2 份运行时清单匹配),`preset-reach.ts` 里还有第 7 份 —— 官方改包名时
6
+ * 少改一处就是「开关点了没反应」或「列表凭空少一行」。档案目录名同理。
7
+ */
8
+ export const MCP_CLIENT_MODULE = '@deepseek-ai/dsh-mcp-client';
9
+ /** `ensurePaths()` 的档案探测顺序(先 web 后 headless);都不在才退到「任意带 patch 的档案」。 */
10
+ export const PROFILE_CANDIDATES = ['web', 'headless'];
11
+ /** 探测不到任何档案时的兜底目录名(历史上的默认档案)。 */
12
+ export const DEFAULT_PROFILE_NAME = 'web';
package/lib/http-fence.js CHANGED
@@ -21,9 +21,12 @@ const headerValue = (req, name) => {
21
21
  /**
22
22
  * 判定请求是否应被拒(返回 null = 放行)。
23
23
  *
24
- * 优先用宿主栅栏;它不在场时(非 web 组合、服务未注册)退回等价的本地判定,
25
- * 而不是静默放行。栅栏自身抛错时同样退回本地判定 —— 宁可拒绝也不要因为宿主
26
- * 内部变动而变成开放路由。
24
+ * 优先用宿主栅栏;它不在场时(非 web 组合、服务未注册)退回**本地最小判定**,而不是静默放行。
25
+ * 栅栏自身抛错时同样退回本地判定 —— 但这句**不是**"宁可拒绝":本地链不含 cookie 鉴权,
26
+ * 于是丢掉的恰好是宿主栅栏比本地判定多出来的那一层。无 Origin 的非浏览器请求只要带上
27
+ * `Host: localhost` 就能过(本机任意进程都做得到),而写 op 里包含 `mcpm-add`(宿主 stdio
28
+ * 传输会按 command/args spawn 它)与 `history-delete`(永久删除会话)。
29
+ * 准确的说法是:退回一个**不依赖宿主内部**的判定 —— 仍要求回环 Host,但没有会话凭证。
27
30
  *
28
31
  * @param req - Node 请求对象(只读 headers)。
29
32
  * @param connection - ctx.get('connection'),可缺失。
@@ -71,24 +74,41 @@ export function fenceRejection(req, connection) {
71
74
  }
72
75
  return null;
73
76
  }
77
+ // 令牌相关的拒绝文案**只有这一句**,所有出口都引用它(用户裁定 2026-09-18:
78
+ // 「这些关于密钥没填的提示词应该统一文案」;2026-09-19 再简化成一句话)。
79
+ //
80
+ // 2026-09-19 晚再去掉"去哪儿填、点什么"那两句(用户截图:提示条在好几个地方都折成两行)——
81
+ // 提示条右侧本来就挂着「填写令牌」按钮,把按钮名念一遍等于同一件事在一行里说两遍,还占掉
82
+ // 一整行的宽度。这一句只说"缺什么 / 错在哪",动作交给那颗按钮;它跳到哪、填完什么状态,
83
+ // 由兼容页的胶囊与三行自己说。**不要再往这句里加指引**:契约测试钉着它的长度。
84
+ //
85
+ // 为什么必须同源:同一个"没填令牌"会在四个地方冒出来(宿主写门禁、明文门禁的两条分支、
86
+ // 界面的错误码词典),此前各写一套 —— 截图里同一件事出现了三种说法,用户无法判断它们
87
+ // 是不是同一件事。界面靠**文本相等**识别这句话(见 client.js 的 isTokenGateText),
88
+ // 所以改这句必须同时改界面词典里的那三个键。
89
+ export const TOKEN_MSG = '缺少访问令牌,或令牌不对';
90
+ /**
91
+ * 界面按 code 在最右侧挂「填写令牌」跳转按钮(这一族里的 code 都算)。
92
+ *
93
+ * 2026-09-19 之后 `secretOpRejection` **不再产生** NO_HOST(没配令牌就直接放行,见上),
94
+ * 但常量与界面词典里的 `error.secret.noToken` 都留着:宿主没重启时旧响应里还可能出现它,
95
+ * 而界面同时按 code 与**文本相等**两条路识别这一族(见 client.js 的 isTokenGateText)。
96
+ */
97
+ export const TOKEN_CODE_NO_HOST = 'error.secret.noToken';
98
+ export const TOKEN_CODE_BAD = 'error.token.required';
74
99
  /**
75
100
  * 判定敏感 op 是否应被拒(返回 null = 放行)。
76
101
  *
77
- * 两种情况分开报,因为处置方式不同:没配令牌要去宿主配置里加,配了但没带/带错
78
- * 只要在界面里填对即可(界面按 code 决定给不给输入框)。
102
+ * 没配令牌 ⇒ 放行(与写门禁同一激活条件,理由见上方段落)。有令牌 ⇒ 必须带对的。
103
+ *
104
+ * 两个 code 仍然分开报:界面用不到(话已经一样了),但日志与排查需要区分
105
+ * 「宿主根本没配令牌」与「这次带的令牌不对」—— 前者是配置问题,后者是输入问题。
79
106
  */
80
107
  export function secretOpRejection(state) {
81
- if (!state.tokenConfigured) {
82
- return {
83
- code: 'error.secret.noToken',
84
- error: '明文查看与导出已被禁用:宿主未配置访问令牌。请在本插件配置里加 token(或设环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)后重启 DSH,再在界面上填入同一个令牌。',
85
- };
86
- }
108
+ if (!state.tokenConfigured)
109
+ return null;
87
110
  if (!state.tokenAccepted) {
88
- return {
89
- code: 'error.secret.badToken',
90
- error: '访问令牌缺失或不正确:请在界面里填入与宿主配置相同的令牌(随请求以 x-dsh-token 发送)。',
91
- };
111
+ return { code: TOKEN_CODE_BAD, error: TOKEN_MSG };
92
112
  }
93
113
  return null;
94
114
  }
package/lib/hub.js CHANGED
@@ -24,7 +24,13 @@
24
24
  // ├─ mcp-disabled-tools.json | mcp-known-tools.json | mcp-notes.json | mcp-settings.json | mcp-export.json
25
25
  // ├─ tool-management.log 插件日志(滚动 .1)
26
26
  // ├─ backups/cordis.patch.yml.bak-<时间戳> 改宿主 patch 前的备份
27
- // └─ trash/{skills,subagents,prompts,scenes,memories}-trash/<id>/
27
+ // ├─ trash/{skills,subagents,prompts,scenes}-trash/<id>/ 回收站(除记忆外的四类)
28
+ // └─ memories-trash/<id>/ 记忆回收站(**hub 根下独立目录**)
29
+ //
30
+ // 记忆回收站**不在 `trash/` 下**(本机实测 `trash/` 只有 4 个 `-trash` 目录):它走
31
+ // `memories/service.ts` 自己的路径(`join(stateDir, 'memories-trash', id)`,stateDir = hub 根),
32
+ // 不经本模块的 `moveToTrash`。此前这行把它画进 `trash/{…}` 里,按它写备份/迁移脚本会既漏搬
33
+ // 又误判(那是一类真实存在、条目数最多的用户数据)。
28
34
  //
29
35
  // 留在 `$DSH_HOME` 根下的两个文件**不是**插件的:`AGENTS.md`(宿主每轮读取的全局基线,
30
36
  // 插件只是按场景/预设写它)与 `cordis.patch.yml`(宿主加载插件的配置入口)。
@@ -365,14 +371,34 @@ export async function listTrashEntries(kind) {
365
371
  export async function readTrashEntry(kind, id) {
366
372
  return await readManifest(kind, id);
367
373
  }
374
+ /**
375
+ * 条目里的一个负载名是不是"就在这个条目目录里"。
376
+ *
377
+ * 为什么 id 与场景名都有谓词、这里还得多一道:`manifest.json` 的 `files[]` 是**磁盘上的数据**,
378
+ * 它跟 id 不一样 —— id 只由本模块生成(`isValidTrashId` 严格白名单),而 files 可能来自
379
+ * 用户手改、别的进程、或一份被塞进来的恶意档案包。不校验就 `join(dir, dest)` 等于给了
380
+ * "任意相对路径读源 + 任意绝对目录建目标"的能力(`mkdir(dirname(to))` 会顺手把目录建出来)。
381
+ */
382
+ export function isValidTrashPayloadName(dest) {
383
+ const text = String(dest ?? '');
384
+ if (!text || text.startsWith('.') || text.includes('\0'))
385
+ return false;
386
+ if (/[\\/]/.test(text))
387
+ return false;
388
+ if (text === 'manifest.json')
389
+ return false;
390
+ return true;
391
+ }
368
392
  /**
369
393
  * 把条目里的一个负载搬回 `to`。**不覆盖**:调用方必须先确认 `to` 不存在。
370
- * @throws 条目或负载缺失时抛错(调用方翻成人话)。
394
+ * @throws 条目、负载名或负载缺失时抛错(调用方翻成人话)。
371
395
  */
372
396
  export async function moveOutOfTrash(kind, id, dest, to) {
373
397
  const dir = trashEntryPath(kind, id);
374
398
  if (dir === null)
375
399
  throw new Error(`回收站条目 id 非法:${id}`);
400
+ if (!isValidTrashPayloadName(dest))
401
+ throw new Error(`回收站负载名非法:${dest}`);
376
402
  const from = join(dir, dest);
377
403
  await mkdir(dirname(to), { recursive: true });
378
404
  try {
@@ -18,19 +18,22 @@ export function extractText(content) {
18
18
  if (typeof part === 'string')
19
19
  out += part;
20
20
  else if (part && typeof part === 'object') {
21
- if (typeof part.text === 'string')
22
- out += part.text;
23
- else if (typeof part.content === 'string')
24
- out += part.content;
21
+ // JSON 块的实际形状由下方 typeof 运行时比较决定,这里按记录形状读取字段
22
+ const record = part;
23
+ if (typeof record.text === 'string')
24
+ out += record.text;
25
+ else if (typeof record.content === 'string')
26
+ out += record.content;
25
27
  }
26
28
  }
27
29
  return out;
28
30
  }
29
31
  if (content && typeof content === 'object') {
30
- if (typeof content.text === 'string')
31
- return content.text;
32
- if (typeof content.content === 'string')
33
- return content.content;
32
+ const record = content;
33
+ if (typeof record.text === 'string')
34
+ return record.text;
35
+ if (typeof record.content === 'string')
36
+ return record.content;
34
37
  }
35
38
  return '';
36
39
  }
@@ -58,6 +61,7 @@ export function detectFormat(fileName, content) {
58
61
  const first = String(content || '').split(/\r?\n/).map((s) => s.trim()).find((s) => s) || '';
59
62
  if (first.startsWith('{')) {
60
63
  try {
64
+ // JSON 值实际形状未知;若为对象,type/role 字段由下方运行时比较判定
61
65
  const obj = JSON.parse(first);
62
66
  if (obj && typeof obj === 'object' && (obj.type === 'user' || obj.type === 'assistant' || obj.role === 'user' || obj.role === 'assistant'))
63
67
  return 'jsonl';
@@ -83,6 +87,7 @@ export function parseJsonlTranscript(text) {
83
87
  const line = raw.trim();
84
88
  if (!line)
85
89
  continue;
90
+ // JSONL 每行的实际形状未知;字段有效性由下方运行时比较判定
86
91
  let obj;
87
92
  try {
88
93
  obj = JSON.parse(line);
@@ -95,7 +100,7 @@ export function parseJsonlTranscript(text) {
95
100
  let role = null;
96
101
  let content;
97
102
  if (obj.type === 'user' || obj.type === 'assistant') {
98
- const message = obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message) ? obj.message : null;
103
+ const message = (obj.message && typeof obj.message === 'object' && !Array.isArray(obj.message) ? obj.message : null);
99
104
  role = message && (message.role === 'user' || message.role === 'assistant') ? message.role : (obj.type === 'user' ? 'user' : 'assistant');
100
105
  content = message ? message.content : undefined;
101
106
  }
@@ -112,6 +117,7 @@ export function parseJsonlTranscript(text) {
112
117
  lastText += '\n' + piece;
113
118
  }
114
119
  else {
120
+ // lastText 非空 ⇒ lastRole 已随 lastText 同步赋值(非 null),类型层无法表达该不变式
115
121
  if (lastText)
116
122
  turns.push({ role: lastRole, text: lastText });
117
123
  lastRole = role;
@@ -3,12 +3,28 @@
3
3
  // 场景记忆(~/.dsh/tool-management/memories/<场景>/<名>.md;场景留空 = 保留场景 global)。
4
4
  // 约定:只认 .md;zip 内任意层级;隐藏项 / 绝对路径 / `..` 穿越 / 超限条目一律跳过并回报;
5
5
  // 重名策略(跳过 or 覆盖)不在这里实现——由调用方按文件系统现状裁决(本项目取「跳过并报告」)。
6
+ //
7
+ // 两条限额的**口径**要分清(2026-09-19 审计 T-09):`MAX_IMPORT_TOTAL_BYTES` 管的是
8
+ // **上传(编码后)**字节,管不住解压后的体积 —— 一个 8 MiB 条目 × 2000 条 ≈ 16 GiB 会在
9
+ // 单次 `unzipSync` 里被实体化。所以另有 `MAX_IMPORT_UNCOMPRESSED_BYTES` 管**解压后**的
10
+ // 累计量,在 filter 里按档案自报的 `info.originalSize` 累加。
6
11
  import { unzipSync } from 'fflate';
12
+ import { isValidSegment } from '../paths.js';
7
13
  export const MAX_IMPORT_FILES = 200;
8
14
  export const MAX_IMPORT_ENTRY_BYTES = 8 * 1024 * 1024;
15
+ /** 上传(编码后)总量:与传输层的 88 MiB 体限是同一层口径,防止一次请求塞进几百 MiB。 */
9
16
  export const MAX_IMPORT_TOTAL_BYTES = 32 * 1024 * 1024;
10
17
  export const MAX_IMPORT_ENTRIES = 2000;
11
18
  export const MAX_IMPORT_NAME_LENGTH = 64;
19
+ /**
20
+ * 解压后累计上限(与技能上传器 `MAX_UPLOAD_TOTAL_BYTES = 64 MiB` 同层口径)。
21
+ *
22
+ * 为什么必须单列:单条目 8 MiB × 2000 条 ≈ 16 GiB 会在一次 `unzipSync` 里全部展开进内存 ——
23
+ * 传输层与 `MAX_IMPORT_TOTAL_BYTES` 都只数**压缩后**的字节,一个数不到。
24
+ * 已知前提:信任档案自报的 `info.originalSize`(fflate 不独立约束输出长度);谎报只能让
25
+ * 解压产物比申报的大,**不会**绕过这个上限之前的条目数门禁。
26
+ */
27
+ export const MAX_IMPORT_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
12
28
  const message = (e) => String((e && e.message) || e);
13
29
  /** base64 → 字节;容忍 `data:...;base64,` 前缀。 */
14
30
  export function decodeBase64(data) {
@@ -57,6 +73,8 @@ export function expandUploads(files) {
57
73
  problems.push({ name: `(其余 ${all.length - MAX_IMPORT_FILES} 个文件)`, reason: `一次最多导入 ${MAX_IMPORT_FILES} 个文件` });
58
74
  }
59
75
  let total = 0;
76
+ /** 解压后超限只报一次(与"超条目数"同口径,避免几千条问题刷屏)。 */
77
+ let uncompressedReported = false;
60
78
  for (const raw of list) {
61
79
  const file = (raw || {});
62
80
  const name = String(file.name || '').trim() || '(未命名)';
@@ -76,6 +94,7 @@ export function expandUploads(files) {
76
94
  }
77
95
  if (isZipBytes(bytes)) {
78
96
  let unzipped;
97
+ let uncompressed = 0;
79
98
  try {
80
99
  let count = 0;
81
100
  unzipped = unzipSync(bytes, {
@@ -95,6 +114,18 @@ export function expandUploads(files) {
95
114
  problems.push({ name: info.name, reason: `zip 内单条目超过 ${MAX_IMPORT_ENTRY_BYTES >> 20} MiB,已跳过` });
96
115
  return false;
97
116
  }
117
+ // 解压后累计:单条目与条目数都挡不住「2000 × 8 MiB」这种组合,只有累计量挡得住。
118
+ // 目录条目不占解压预算(originalSize 为 0 且不产出内容)。
119
+ if (!info.name.endsWith('/')) {
120
+ uncompressed += info.originalSize;
121
+ if (uncompressed > MAX_IMPORT_UNCOMPRESSED_BYTES) {
122
+ if (!uncompressedReported) {
123
+ uncompressedReported = true;
124
+ problems.push({ name: info.name, reason: `zip 解压后合计超过 ${MAX_IMPORT_UNCOMPRESSED_BYTES >> 20} MiB,该条目及其后条目已忽略` });
125
+ }
126
+ return false;
127
+ }
128
+ }
98
129
  return true;
99
130
  },
100
131
  });
@@ -120,14 +151,22 @@ export function expandUploads(files) {
120
151
  problems.push({ name, reason: '只支持 .md 或 .zip' });
121
152
  continue;
122
153
  }
123
- entries.push({ path: name, bytes });
154
+ // 非 zip 分支同样要走 `normalizeEntryPath`:客户端给的文件名是**外部输入**,
155
+ // `"../../x.md"` / `".hidden/x.md"` 直接进 `RawEntry[]` 就把"隐藏项/穿越一律跳过"
156
+ // 这条承诺交给下游 planner 兜着 —— 本模块的文件头正是这么承诺的(审计 T-10)。
157
+ // 不变量该由承诺方强制:新增的第三个消费者不该靠"运气好下游也查了"才安全。
158
+ const normalized = normalizeEntryPath(name);
159
+ if (!normalized) {
160
+ problems.push({ name, reason: '路径非法或隐藏项,已跳过' });
161
+ continue;
162
+ }
163
+ entries.push({ path: normalized, bytes });
124
164
  }
125
165
  return { entries, problems };
126
166
  }
127
- /** 单个名字段(人设名 / 记忆名 / 场景路径的一段)合法性:与宿主侧校验同口径。 */
167
+ /** 单个名字段(人设名 / 记忆名 / 场景路径的一段)合法性:谓词收敛到 `../paths.ts`。 */
128
168
  export function isValidImportName(name) {
129
- const s = String(name || '');
130
- return s.length > 0 && s.length <= MAX_IMPORT_NAME_LENGTH && s === s.trim() && !s.startsWith('.') && !/[\\/<>:"|?*]/.test(s);
169
+ return isValidSegment(String(name || ''), MAX_IMPORT_NAME_LENGTH);
131
170
  }
132
171
  /** 场景/分组路径合法性:逐段校验(空串 = 根/全局,合法)。 */
133
172
  export function isValidImportGroup(group) {