dsh-armory 1.9.3 → 1.10.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.
@@ -527,6 +527,127 @@ function parseFailure(error, text) {
527
527
  return 0
528
528
  }
529
529
 
530
+ // ---------------------------------------------------------------------------
531
+ // VS Code `${input:...}`:逐条降级,不整份拒绝
532
+ // ---------------------------------------------------------------------------
533
+
534
+ /**
535
+ * VS Code 的 `${input:名称}`:值由人在**导入时**填写,DSH 读配置的时候无从知道。
536
+ * 它是 VS Code 专属写法,出现在别的客户端配置里也代表同一件事。
537
+ */
538
+ const VSCODE_INPUT = /\$\{input:([^}]*)\}/g
539
+
540
+ /** 一个字符串里出现的 input 名,按出现顺序去重。 */
541
+ function vscodeInputNames(value) {
542
+ if (typeof value !== 'string' || !value.includes('${input:')) return []
543
+ const names = []
544
+ for (const match of value.matchAll(VSCODE_INPUT)) {
545
+ const name = match[1].trim()
546
+ if (name && !names.includes(name)) names.push(name)
547
+ }
548
+ return names
549
+ }
550
+
551
+ /** 报错/提示里统一用 `${input:x}` 原样回显名字(名字本身不是密钥)。 */
552
+ const showInputs = (names) => names.map((name) => `\${input:${name}}`).join('、')
553
+
554
+ /** 条目会走哪条传输:只用于把不支持项说清楚,不参与归一化判定。 */
555
+ function guessTransport(raw) {
556
+ if (typeof raw.type === 'string' && raw.type.trim()) return raw.type.trim()
557
+ if (typeof raw.transport === 'string' && raw.transport.trim()) return raw.transport.trim()
558
+ if (typeof raw.command === 'string' && raw.command.trim()) return 'stdio'
559
+ const url = raw.url ?? raw.serverUrl ?? raw.server_url
560
+ return typeof url === 'string' && url.trim() ? 'streamable-http' : 'unknown'
561
+ }
562
+
563
+ /**
564
+ * 把一条 VS Code 条目里的 `${input:...}` 按位置分三类处理:
565
+ *
566
+ * - **url / command / cwd**:地址或可执行文件本身就是交互值,剔掉就没得导了,
567
+ * 所以整条标记为不支持、不进 `entries`。
568
+ * - **env / headers 的某一项**:先剔掉那一项,剩下的照常导入——少一个环境变量
569
+ * 会变成一个看得见的启动/鉴权失败,比整条消失更容易被用户理解和补救。
570
+ * - **args 的某一个元素**:同上,剔掉那个参数。
571
+ *
572
+ * 返回 `blocked` 非空表示整条不可用;否则 `raw` 是剔干净后的副本。
573
+ */
574
+ function splitVscodeInputs(name, raw) {
575
+ // 顺序固定成 url → command → cwd → args → env → headers:前面出现就整条判死,
576
+ // 后面即使也用到 input 也一并记进 names,方便用户一次看全。
577
+ const hard = []
578
+ for (const field of ['url', 'serverUrl', 'server_url', 'command', 'cwd']) {
579
+ for (const found of vscodeInputNames(raw[field])) if (!hard.includes(found)) hard.push(found)
580
+ }
581
+ if (hard.length) {
582
+ for (const field of ['args', 'env', 'headers']) {
583
+ for (const value of field === 'args' && Array.isArray(raw.args) ? raw.args : Object.values(isObject(raw[field]) ? raw[field] : {})) {
584
+ for (const found of vscodeInputNames(value)) if (!hard.includes(found)) hard.push(found)
585
+ }
586
+ }
587
+ return { blocked: hard, raw: null, warnings: [] }
588
+ }
589
+ const warnings = []
590
+ const soft = []
591
+ const clean = { ...raw }
592
+ const args = stripFromArgs(name, raw.args, soft, warnings)
593
+ if (args === undefined) delete clean.args
594
+ else clean.args = args
595
+ for (const field of ['env', 'headers']) {
596
+ // 不是对象就原样留给下游报错:这里静默删掉会把"env 写错了"变成"没有 env"。
597
+ if (!isObject(raw[field])) continue
598
+ const table = stripFromMap(name, field, raw[field], soft, warnings)
599
+ // 整个字段被剔空时删掉它,别让下游把 `env: {}` 当成"用户写了空表"。
600
+ if (table === undefined) delete clean[field]
601
+ else setOwn(clean, field, table)
602
+ }
603
+ return { blocked: [], raw: clean, warnings, names: soft }
604
+ }
605
+
606
+ /**
607
+ * args 里带 `${input:...}` 的元素剔除。剔除后可能剩一个空数组——那就把字段删掉,
608
+ * 别让下游把 `args: []` 当成"用户显式写了空参数表"。
609
+ */
610
+ function stripFromArgs(name, args, soft, warnings) {
611
+ // `args` 写成单个字符串时下游会原样当成一个参数:它带 input 就同样不可用。
612
+ if (typeof args === 'string') {
613
+ const found = vscodeInputNames(args)
614
+ if (!found.length) return args
615
+ for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
616
+ warnings.push(`${name}.args 里的 ${showInputs(found)} 需要交互取值,已跳过这个参数`)
617
+ return undefined
618
+ }
619
+ if (!Array.isArray(args)) return args
620
+ const kept = []
621
+ for (const item of args) {
622
+ const found = vscodeInputNames(item)
623
+ if (!found.length) {
624
+ kept.push(item)
625
+ continue
626
+ }
627
+ for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
628
+ warnings.push(`${name}.args 里的 ${showInputs(found)} 需要交互取值,已跳过这个参数`)
629
+ }
630
+ return kept.length || args.length === 0 ? kept : undefined
631
+ }
632
+
633
+ /** env / headers 里值带 `${input:...}` 的项剔除。整表剔空时删掉字段。 */
634
+ function stripFromMap(name, field, table, soft, warnings) {
635
+ if (!isObject(table)) return undefined
636
+ const kept = {}
637
+ let dropped = 0
638
+ for (const [key, value] of Object.entries(table)) {
639
+ const found = vscodeInputNames(value)
640
+ if (!found.length) {
641
+ setOwn(kept, key, value)
642
+ continue
643
+ }
644
+ dropped += 1
645
+ for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
646
+ warnings.push(`${name}.${field}.${key} 需要交互取值(${showInputs(found)}),已跳过`)
647
+ }
648
+ return dropped && !Object.keys(kept).length ? undefined : kept
649
+ }
650
+
530
651
  /**
531
652
  * 按来源解析文本并归一化成 DSH spec。逐条目容错:一条不支持不影响其余条目。
532
653
  * 返回的 `spec` 是 Host 侧内部值(含 `!!js` 表达式与字面密钥),**不得**直接发给浏览器。
@@ -541,33 +662,50 @@ export function normalizeSourceText(source, text, format = 'json') {
541
662
  parsed = parseSourceText(format, content)
542
663
  } catch (error) {
543
664
  const line = parseFailure(error, content)
544
- return { entries: [], warnings: [], error: `不是合法 ${PARSE_LABEL[format] || format}${line ? `(第 ${line} 行附近)` : ''}` }
665
+ return { entries: [], warnings: [], unsupportedEntries: [], error: `不是合法 ${PARSE_LABEL[format] || format}${line ? `(第 ${line} 行附近)` : ''}` }
545
666
  }
546
- if (!isObject(parsed)) return { entries: [], warnings: [], error: '配置根节点必须是对象' }
547
- // VS Code 的 ${input:...} 需要交互取值,normalizeMcpImport 会整份拒绝;
548
- // 在取段落之前就给出同样的判断,避免一条输入变量吞掉整份文件。
667
+ if (!isObject(parsed)) return { entries: [], warnings: [], unsupportedEntries: [], error: '配置根节点必须是对象' }
668
+ // OPT-17:VS Code 的 `${input:...}` 原本在这里整份拒绝,结果是一条交互变量吞掉
669
+ // 文件里其余 19 条能用的服务。改成逐条降级:位置可剔的(env/headers/args)剔掉后
670
+ // 照常导入并在 warnings 里说清楚,位置不可剔的(url/command/cwd)只把那一条记进
671
+ // unsupportedEntries,`error` 只留给"整份文件读不懂"。
549
672
  const warnings = []
550
- if (Array.isArray(parsed.inputs) && parsed.inputs.length > 0) {
551
- return { entries: [], warnings, error: 'VS Code inputs 需要交互式取值,DSH 当前不支持安全转换' }
552
- }
673
+ const unsupportedEntries = []
674
+ // 声明了 `inputs` 只说明这份文件"可能"用到交互变量,不再是拒绝理由;真正的判定
675
+ // 落在每个条目上——没被引用的 inputs 声明不该影响任何导入,也不该冒出一条噪音提示。
553
676
  const selected = source.select(parsed, warnings)
554
- if (selected === null) return { entries: [], warnings, error: '没有找到该客户端使用的 MCP 段落' }
677
+ if (selected === null) return { entries: [], warnings, unsupportedEntries, error: '没有找到该客户端使用的 MCP 段落' }
555
678
  const entries = []
556
679
  for (const item of selected) {
557
680
  const carried = item.warnings || []
558
681
  if (item.error) {
559
- entries.push({ name: item.name, spec: null, error: item.error, warnings: carried })
682
+ entries.push({ name: item.name, spec: null, error: item.error, warnings: carried, unsupported: null })
683
+ continue
684
+ }
685
+ const split = splitVscodeInputs(item.name, item.raw)
686
+ const merged = [...carried, ...split.warnings]
687
+ // url / command / cwd 本身是交互值:这条没法安全转换。不进 `entries`(下游只认
688
+ // 可导入的 spec),改在 `unsupportedEntries` 里留一份可展示信息,别让它凭空消失。
689
+ if (split.blocked.length) {
690
+ unsupportedEntries.push({
691
+ name: item.name,
692
+ transport: guessTransport(item.raw),
693
+ reason: `地址/命令里用到 ${showInputs(split.blocked)},需要交互取值,DSH 无法安全转换`,
694
+ names: split.blocked,
695
+ })
560
696
  continue
561
697
  }
562
698
  try {
563
- const normalized = normalizeMcpImport({ mcpServers: { [item.name]: item.raw } })
564
- entries.push({ name: item.name, spec: normalized.servers[0], error: null, warnings: [...carried, ...normalized.warnings] })
699
+ const normalized = normalizeMcpImport({ mcpServers: { [item.name]: split.raw } })
700
+ entries.push({ name: item.name, spec: normalized.servers[0], error: null, warnings: [...merged, ...normalized.warnings], unsupported: null })
565
701
  } catch (error) {
566
- entries.push({ name: item.name, spec: null, error: String(error?.message ?? error), warnings: carried })
702
+ entries.push({ name: item.name, spec: null, error: String(error?.message ?? error), warnings: merged, unsupported: null })
567
703
  }
568
704
  }
569
- if (!entries.length) return { entries, warnings, error: '这条配置里没有 MCP' }
570
- return { entries, warnings, error: '' }
705
+ // 全部条目都不可用时 `error` 仍然是 null:上层要能说"3 条不支持 / 0 条可导入",
706
+ // 而不是一句"失败"。只有真的一条 MCP 都没找到才落到下面的文案。
707
+ if (!entries.length && !unsupportedEntries.length) return { entries, warnings, unsupportedEntries, error: '这条配置里没有 MCP' }
708
+ return { entries, warnings, unsupportedEntries, error: '' }
571
709
  }
572
710
 
573
711
  export function hashSourceText(text) {
@@ -640,6 +778,7 @@ async function inspectImportLocation(source, location, ctx, readText) {
640
778
  error: '',
641
779
  contentHash: '',
642
780
  entries: [],
781
+ unsupportedEntries: [],
643
782
  warnings: [],
644
783
  }
645
784
  const picked = await pickCandidate(location, readText)
@@ -655,6 +794,7 @@ async function inspectImportLocation(source, location, ctx, readText) {
655
794
  row.contentHash = hashSourceText(picked.text)
656
795
  const normalized = normalizeSourceText(source, picked.text, picked.candidate.format)
657
796
  row.entries = normalized.entries
797
+ row.unsupportedEntries = normalized.unsupportedEntries
658
798
  row.warnings = normalized.warnings
659
799
  row.error = normalized.error
660
800
  return row
@@ -25,6 +25,31 @@ const MCP_PREFIX = "mcp__";
25
25
  /** 每处理这么多文件让出一次事件循环,避免大库扫描把宿主卡住。 */
26
26
  const YIELD_EVERY = 16;
27
27
 
28
+ /**
29
+ * `zlib.zstdDecompressSync` 是 Node >= 22.15 才有的 API。缺失时**绝对不能**静默把每个帧
30
+ * 都当成「损坏」吞掉:那会让工具裁缝表现为「扫到了 N 个会话、读到了 N 个日志、调用数整列 0」,
31
+ * 而界面上看不到任何原因 —— 与「只看零调用」筛选的界面表现完全一致,无法自证清白。
32
+ * 所以这里探测一次,让失败有名字(OPT-01)。
33
+ */
34
+ const ZSTD_SUPPORTED = typeof zlib.zstdDecompressSync === "function";
35
+ const ZSTD_UNAVAILABLE_MESSAGE =
36
+ "zstd 解压不可用:当前 Node 不支持 zlib.zstdDecompressSync(需 Node >= 22.15)";
37
+
38
+ /**
39
+ * 单个日志的**落盘体积**上限(OPT-06)。完整链路是「整块读入 → 逐帧解压 → Buffer.concat
40
+ * → toString(utf8) → split(换行)」,峰值内存约为压缩体积的 5~10 倍;没有上限意味着一个异常
41
+ * 长的会话日志就能拖垮主进程。64 MB 相对当前最大单文件(4.4 MB)有十四倍冗余,正常使用碰不到。
42
+ */
43
+ const MAX_LOG_BYTES = 64 * 1024 * 1024;
44
+ /** 解压文本缓存预算:按**字节**控制而非只按条数(见 textCache 注释)。 */
45
+ const TEXT_CACHE_MAX_BYTES = 128 * 1024 * 1024;
46
+ const TEXT_CACHE_MAX_ENTRIES = 32;
47
+
48
+ /** 把字节数格式化成 MB 文案,只用于诊断信息。 */
49
+ function formatMB(bytes) {
50
+ return `${(bytes / 1_048_576).toFixed(1)} MB`;
51
+ }
52
+
28
53
  /**
29
54
  * 解压**多帧** zstd 缓冲(宽容版)。与 dsh-forget-session 的 zstdDecompressAllFrames 同思路:
30
55
  * 按 zstd magic(0x28B52FFD)切帧逐帧解压;单帧文件行为不变。
@@ -33,6 +58,9 @@ const YIELD_EVERY = 16;
33
58
  * @returns {{ text: string, frames: number, failedFrames: number }}
34
59
  */
35
60
  function zstdDecompressAllFramesLenient(buf) {
61
+ // 硬守卫(OPT-01):Node 太旧时**必须**抛出一个有名字的错误,让 scanSessionLedger 的
62
+ // failures() 捕获并写进 failedFileSamples 上屏,而不是逐帧 try/catch 各自吞掉、静默全 0。
63
+ if (!ZSTD_SUPPORTED) throw new Error(ZSTD_UNAVAILABLE_MESSAGE);
36
64
  const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
37
65
  const starts = [];
38
66
  let idx = buf.indexOf(MAGIC, 0);
@@ -54,6 +82,65 @@ function zstdDecompressAllFramesLenient(buf) {
54
82
  return { text: Buffer.concat(parts).toString("utf8"), frames: starts.length, failedFrames };
55
83
  }
56
84
 
85
+ /**
86
+ * 解压文本的跨调用缓存(OPT-03)。收益主体在这里:扫描耗时绝大部分花在「读盘 + 逐帧解压
87
+ * + utf8 解码」上,JSON 解析只占小头。
88
+ *
89
+ * 两条纪律:
90
+ * 1. 键是**日志绝对路径**,戳是 `size:mtimeMs`。日志是追加写,写必然同时抬升 size 与 mtime,
91
+ * 所以戳一变就失效,不会拿到陈旧数据;反过来,戳没变时内容按定义没有变化。
92
+ * 2. 淘汰按**字节预算**而不是只按条数。按报告里 32 条 × 解压后 15 MB 估算是 480 MB 峰值,
93
+ * 在宿主主进程里不可接受;这里 128 MB 封顶,超过就按 LRU 丢到预算以内。
94
+ * 只缓存 scanSessionLedger 需要的那点数据(解压文本 + 帧统计),不缓存任何调用记录。
95
+ */
96
+ const textCache = new Map(); // logPath -> { stamp, bytes, text, frames, failedFrames }
97
+ let textCacheBytes = 0;
98
+
99
+ function dropCacheEntry(logPath) {
100
+ const entry = textCache.get(logPath);
101
+ if (entry) {
102
+ textCacheBytes -= entry.bytes;
103
+ textCache.delete(logPath);
104
+ }
105
+ }
106
+
107
+ function rememberCacheEntry(logPath, entry) {
108
+ dropCacheEntry(logPath);
109
+ textCache.set(logPath, entry);
110
+ textCacheBytes += entry.bytes;
111
+ // Map 的迭代顺序即插入顺序,队首就是最久未使用的一条。
112
+ while (textCache.size > TEXT_CACHE_MAX_ENTRIES || textCacheBytes > TEXT_CACHE_MAX_BYTES) {
113
+ const oldest = textCache.keys().next();
114
+ if (oldest.done) break;
115
+ dropCacheEntry(oldest.value);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 读取并解压一个日志文件,命中缓存则完全跳过 IO 与解压。
121
+ * @param {string} logPath - 日志绝对路径。
122
+ * @param {{ size: number, mtimeMs: number }} st - 已取到的 stat 结果(不额外产生 IO)。
123
+ * @returns {Promise<{ text: string, frames: number, failedFrames: number }>}
124
+ */
125
+ async function readLogTextCached(logPath, st) {
126
+ const stamp = `${st.size}:${st.mtimeMs}`;
127
+ const hit = textCache.get(logPath);
128
+ if (hit && hit.stamp === stamp) {
129
+ // 触碰一次,让它在 LRU 里回到队尾。
130
+ textCache.delete(logPath);
131
+ textCache.set(logPath, hit);
132
+ return hit;
133
+ }
134
+ const raw = await readFile(logPath);
135
+ const value = logPath.endsWith(".zstd")
136
+ ? zstdDecompressAllFramesLenient(raw)
137
+ : { text: raw.toString("utf8"), frames: 0, failedFrames: 0 };
138
+ const entry = { stamp, bytes: Buffer.byteLength(value.text, "utf8"), ...value };
139
+ // 单条就超出总预算的怪物不缓存(否则会把整个缓存挤空,还占着内存)。
140
+ if (entry.bytes <= TEXT_CACHE_MAX_BYTES) rememberCacheEntry(logPath, entry);
141
+ return value;
142
+ }
143
+
57
144
  /**
58
145
  * DSH 数据根(`~/.dsh` 的上一级)。优先 `DSH_SKILL_MCP_BOARD_HOME`(测试隔离用,与
59
146
  * dsh-forget-session 的 `DSH_FORGET_SESSION_HOME` 同一惯用法),否则 OS 家目录。
@@ -115,6 +202,7 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
115
202
  const usage = new Map(); // toolName -> record
116
203
  let filesRead = 0;
117
204
  let filesSkippedStale = 0;
205
+ let filesSkippedOversize = 0;
118
206
  let failedFiles = 0;
119
207
  let failedFileSamples = [];
120
208
  let sessionDirs = 0;
@@ -124,6 +212,16 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
124
212
  let totalErrors = 0;
125
213
  let orphanResults = 0;
126
214
  let failedFrames = 0;
215
+ // §5.4 逐日趋势:扫描时顺手按天分桶,不额外遍历日志。
216
+ // 桶只存「窗口内的相对天序号」(0 = 最早一天,windowDays-1 = 今天),
217
+ // 由界面负责渲染;返回绝对日期反而让前端多一次时区换算的机会出错。
218
+ const dayBuckets = new Array(windowDays).fill(0);
219
+ const dayIndex = (time) => {
220
+ if (typeof time !== "number") return null;
221
+ const offset = now - time;
222
+ const idx = windowDays - 1 - Math.floor(offset / 86_400_000);
223
+ return idx >= 0 && idx < windowDays ? idx : null;
224
+ };
127
225
  const failures = (reason) => {
128
226
  failedFiles += 1;
129
227
  if (failedFileSamples.length < 20) failedFileSamples.push(reason);
@@ -160,32 +258,35 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
160
258
  }
161
259
  for (const logName of logFiles) {
162
260
  const logPath = join(sessDir, logName);
261
+ let st;
163
262
  try {
164
263
  // mtime 早于窗口起点 → 里面不可能有窗口内事件,整个文件跳过。
165
264
  // 依据:日志是追加写,追加必然抬升 mtime。
166
- const st = await stat(logPath);
167
- if (st.mtimeMs < cutoffMs) {
168
- filesSkippedStale += 1;
169
- continue;
170
- }
265
+ st = await stat(logPath);
171
266
  } catch {
172
267
  failures(`${logPath}: stat failed`);
173
268
  continue;
174
269
  }
175
- let text;
270
+ if (st.mtimeMs < cutoffMs) {
271
+ filesSkippedStale += 1;
272
+ continue;
273
+ }
274
+ // 尺寸闸门放在读盘之前(OPT-06):超限就降级跳过,并把原因交给 failures()
275
+ // 上屏,而不是先整块读进来再发现内存不够。
276
+ if (st.size > MAX_LOG_BYTES) {
277
+ filesSkippedOversize += 1;
278
+ failures(`${logPath}: 日志 ${formatMB(st.size)} 超过 ${formatMB(MAX_LOG_BYTES)} 上限,已跳过`);
279
+ continue;
280
+ }
281
+ let loaded;
176
282
  try {
177
- const raw = await readFile(logPath);
178
- if (logName.endsWith(".zstd")) {
179
- const out = zstdDecompressAllFramesLenient(raw);
180
- text = out.text;
181
- failedFrames += out.failedFrames;
182
- } else {
183
- text = raw.toString("utf8");
184
- }
283
+ loaded = await readLogTextCached(logPath, st);
185
284
  } catch (error) {
186
285
  failures(`${logPath}: ${error instanceof Error ? error.message : String(error)}`);
187
286
  continue;
188
287
  }
288
+ const text = loaded.text;
289
+ failedFrames += loaded.failedFrames;
189
290
  filesRead += 1;
190
291
  let dirHadCall = false;
191
292
  for (const line of text.split("\n")) {
@@ -210,6 +311,8 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
210
311
  record.calls += 1;
211
312
  totalCalls += 1;
212
313
  dirHadCall = true;
314
+ const bucket = dayIndex(time);
315
+ if (bucket !== null) dayBuckets[bucket] += 1;
213
316
  if (callId) record.seenCallIds.add(callId);
214
317
  if (time !== null) {
215
318
  if (record.firstUsedMs === null || time < record.firstUsedMs) record.firstUsedMs = time;
@@ -314,9 +417,19 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
314
417
  sessionsWithCalls,
315
418
  filesRead,
316
419
  filesSkippedStale,
420
+ filesSkippedOversize,
317
421
  failedFiles,
318
422
  failedFileSamples,
319
423
  failedFrames,
424
+ // OPT-01/OPT-02:把「解码能力」和「失败原因」显式交给上层,界面才有东西可显示。
425
+ zstdSupported: ZSTD_SUPPORTED,
426
+ // 界面在 zstd 降级时会提示「升级到 22.15 以上」,得能说出用户当前跑的是什么版本,
427
+ // 否则那句提示等于没说。取宿主进程自己的版本(不是 package.json 的 engines 声明)。
428
+ nodeVersion: process.versions.node,
429
+ // OPT-19:解压文本缓存的占用快照(见 textCacheStats)。128 MB 常驻占用此前对用户
430
+ // 完全不可见——报告带上它,面板才有数据展示「缓存占了多少、离上界还有多远」,并
431
+ // 在接近上界时引导用户手动回收(清空动作由 index.js 的 toolTailor 方法承接)。
432
+ textCache: textCacheStats(),
320
433
  totalCalls,
321
434
  totalResults,
322
435
  totalErrors,
@@ -327,6 +440,109 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
327
440
  deadMcpCount: deadMcpTools.length,
328
441
  taxTotalTokens,
329
442
  deadMcpTaxTokens,
443
+ // §5.4 趋势数据:逐日调用量(下标 0 = 窗口最早一天)。
444
+ // 界面只在「确实有调用」时画 sparkline,全 0 从这里就能看出来,不必前端猜。
445
+ dailyCalls: dayBuckets,
330
446
  tools,
331
447
  };
332
448
  }
449
+
450
+ // OPT-19:解压文本缓存(OPT-03)的**可观测性**与**手动回收**。128 MB 上界在长时间运行的
451
+ // 宿主里是笔实实在在的常驻占用,但本模块此前只导出 scanSessionLedger,条数/字节数/新鲜度全
452
+ // 关在模块私有作用域里:用户既看不到占用,也没有任何回收手段,出问题只能重启宿主。下面两个
453
+ // 导出只读写模块私有状态——不读盘、不解压、不新增 RPC 契约(接线到宿主由 index.js 另行负责)。
454
+
455
+ /** utilization 的量化倍数:10^4,即保留 4 位小数(0.0001 精度),避免浮点长尾进入面板文案。 */
456
+ const UTILIZATION_SCALE = 10_000;
457
+
458
+ /**
459
+ * 从缓存条目的戳(`size:mtimeMs`)里取出**毫秒时间戳**。
460
+ *
461
+ * 戳是复合串:它同时编码落盘体积与 mtime,用来判「内容是否变化」(见 readLogTextCached)。
462
+ * 但对外契约要的是 number ——「这条缓存有多久没动」是个时间点,复合串既不是数字、也不能
463
+ * 直接按字典序比较大小(`"9:..."` > `"10:..."`),所以这里只取后段的 mtimeMs。解析不出来
464
+ * (历史遗留条目或将来改了格式)时返回 null,由调用方跳过该条,绝不让 NaN 污染最值聚合。
465
+ * @param {{ stamp?: unknown }} entry - 缓存条目。
466
+ * @returns {number|null} 毫秒时间戳,无法解析时为 null。
467
+ */
468
+ function stampTimeMs(entry) {
469
+ if (typeof entry?.stamp !== "string") return null;
470
+ const sep = entry.stamp.lastIndexOf(":");
471
+ if (sep === -1) return null;
472
+ const ms = Number(entry.stamp.slice(sep + 1));
473
+ return Number.isFinite(ms) ? ms : null;
474
+ }
475
+
476
+ /**
477
+ * 解压文本缓存的只读快照。**纯数据、无副作用**:不读盘、不解压、不改变 LRU 顺序,可以在
478
+ * 面板渲染时顺手取一次而不用担心干扰扫描。
479
+ *
480
+ * `utilization` 取「字节占用率」与「条数占用率」的**较大者**:rememberCacheEntry 的收缩条件
481
+ * 是 `size > maxEntries || bytes > maxBytes`,任一维度逼近上界都会触发淘汰;只看字节会漏掉
482
+ * 「32 条小文件把条数打满、字节却很低」这种同样会开始丢缓存的情况。
483
+ * @returns {{
484
+ * entries: number,
485
+ * bytes: number,
486
+ * maxEntries: number,
487
+ * maxBytes: number,
488
+ * utilization: number,
489
+ * oldestStamp: number|null,
490
+ * newestStamp: number|null
491
+ * }} 缓存占用快照;空缓存时 entries/bytes/utilization 全为 0,两个戳为 null。
492
+ */
493
+ export function textCacheStats() {
494
+ let oldestStamp = null;
495
+ let newestStamp = null;
496
+ for (const entry of textCache.values()) {
497
+ const stamp = stampTimeMs(entry);
498
+ if (stamp === null) continue;
499
+ if (oldestStamp === null || stamp < oldestStamp) oldestStamp = stamp;
500
+ if (newestStamp === null || stamp > newestStamp) newestStamp = stamp;
501
+ }
502
+ const entries = textCache.size;
503
+ const bytes = Number.isFinite(textCacheBytes) ? textCacheBytes : 0;
504
+ // 必须 clamp:淘汰是「先写入、再按预算收缩」,单条最大可到 128 MB,写后到收缩之间瞬时
505
+ // 会越过上界(raw 可能 > 1);下界 0 则是防计数漂移(理论上不该为负,但负数利用率上屏
506
+ // 毫无意义)。四舍五入放在 clamp 之后,保证结果恒在 [0, 1] 且只保留 4 位小数。
507
+ const raw = Math.max(bytes / TEXT_CACHE_MAX_BYTES, entries / TEXT_CACHE_MAX_ENTRIES);
508
+ const utilization = Math.round(Math.min(Math.max(raw, 0), 1) * UTILIZATION_SCALE) / UTILIZATION_SCALE;
509
+ return {
510
+ entries,
511
+ bytes,
512
+ maxEntries: TEXT_CACHE_MAX_ENTRIES,
513
+ maxBytes: TEXT_CACHE_MAX_BYTES,
514
+ utilization,
515
+ oldestStamp,
516
+ newestStamp,
517
+ };
518
+ }
519
+
520
+ /**
521
+ * 清空解压文本缓存并复位计数,返回被清掉的内容概要(供面板说「已释放 x MB」)。
522
+ *
523
+ * 实现顺序是有意为之——**先取 size 快照 → 再 clear → 最后把计数显式归零**:
524
+ * 1. 不能先 `textCache.clear()` 再取 size / 遍历求和:那时 Map 已空,clearedEntries 与
525
+ * clearedBytes 会恒为 0,面板就永远只能说「已释放 0 MB」;
526
+ * 2. 也不能指望 `dropCacheEntry` 顺手收拾计数——它按 logPath 逐条 `-=`,必须先于 clear
527
+ * 调用才有意义;clear 之后它拿不到任何条目,等于什么也没做(这正是最容易被写反的地方);
528
+ * 3. 计数变量 `textCacheBytes` 是**独立**于 Map 的,`Map.clear()` 不会动它。漏掉归零就会
529
+ * 留下一笔幽灵占用:此后 rememberCacheEntry 的预算循环会立刻判定超限并把刚写进去的条目
530
+ * 淘汰掉(表现为缓存「莫名其妙永不命中」)。所以这里无条件赋 0,而不是 `-= clearedBytes`。
531
+ *
532
+ * 安全性:清空后下一次 scanSessionLedger 走的是 readLogTextCached 的未命中分支(读盘 →
533
+ * 解压 → 回填),完全不依赖缓存状态,只是「重新解压一遍」,不会抛错。风险点其实在反方向:
534
+ * 清空后若 `textCacheBytes` 没归零(留着正数),后续每一次 rememberCacheEntry 都会在预算
535
+ * 循环里把刚写进来的条立刻丢掉,看上去就像「缓存失效了」;归零才不会把缓存卡死。
536
+ * 幂等:空缓存上再调一次,返回 clearedEntries 与 clearedBytes 均为 0。
537
+ * @returns {{ clearedEntries: number, clearedBytes: number, entries: number, bytes: number }}
538
+ * 被清掉的条数/字节数;entries 与 bytes 恒为 0,表示清空后的状态。
539
+ */
540
+ export function clearTextCache() {
541
+ const clearedEntries = textCache.size;
542
+ // 负值只可能来自计数漂移;对面板而言「-0.0 MB」没有任何意义,按 0 呈现。清空后的状态
543
+ // 则无条件归零,漂移不会被带进下一轮的预算判断。
544
+ const clearedBytes = Number.isFinite(textCacheBytes) ? Math.max(textCacheBytes, 0) : 0;
545
+ textCache.clear();
546
+ textCacheBytes = 0;
547
+ return { clearedEntries, clearedBytes, entries: 0, bytes: 0 };
548
+ }
package/lib/typert.js CHANGED
@@ -85,6 +85,8 @@ export const TYPERT = {
85
85
  // WorkBuddy 市场直连(连接器 MCP + SkillHub 技能)。payload 全部 $any:数据形状由
86
86
  // lib/wb-market.js 校验,契约层与 builtins/importJson 同一宽容度。
87
87
  mv('wbConnectorCatalog', [], 'WbCatalogResult'),
88
+ // 顶栏「市场」chip 常驻计数:只读磁盘缓存,零网络;无缓存返回 { total: null }。
89
+ mv('wbConnectorCachedCount', [], 'WbCachedCountResult'),
88
90
  mv('wbConnectorPreview', [payloadParam], 'WbPreviewResult'),
89
91
  mv('wbConnectorInstall', [payloadParam], 'NoteResult'),
90
92
  mv('wbSkillCatalog', [payloadParam], 'WbSkillListResult'),
package/lib/wb-market.js CHANGED
@@ -190,6 +190,25 @@ export async function wbConnectorPreview(connectorId) {
190
190
  return { connectorId: id, mcpServers: servers };
191
191
  }
192
192
 
193
+ /**
194
+ * 只读磁盘缓存的市场条目计数(供顶栏 chip 常驻显示,绝不联网)。
195
+ * 磁盘上有 connectors-config.zip 就直接解索引计数;没有就返回 total:null,
196
+ * 等「市场页真实打开过一次」(那会走完整下载并落缓存)之后再查就有数字了。
197
+ * 单独存在的意义:wbConnectorCatalog 即使热缓存也会做一次 304 网络探测,
198
+ * 冷缓存更是 24MB 下载——为顶栏一个数字触发这两者都不值得。
199
+ */
200
+ export async function wbConnectorCachedCount() {
201
+ try {
202
+ const local = await readFile(zipPath());
203
+ const zip = new ZipReader(local);
204
+ const index = JSON.parse(zip.extract(".codebuddy-connector/connectors.json").toString("utf8"));
205
+ const connectors = Array.isArray(index.connectors) ? index.connectors : [];
206
+ return { total: connectors.filter((entry) => String(entry.id || entry.source || "").trim()).length };
207
+ } catch {
208
+ return { total: null };
209
+ }
210
+ }
211
+
193
212
  // ── SkillHub 技能市场 ─────────────────────────────────────────────────
194
213
  // 列表:/api/skills?sortBy=downloads|installs|stars|score&page=N&pageSize=60&keyword=..&category=..
195
214
  // 详情:/api/v1/skills/:slug?namespace=<handle>(带 latestVersion、publisher、securityReports)
package/lib/whats-new.js CHANGED
@@ -267,4 +267,70 @@ export const WHATS_NEW = [
267
267
  },
268
268
  ],
269
269
  },
270
+ {
271
+ version: '1.9.4',
272
+ items: [
273
+ {
274
+ text: '**团队型专家可以召唤了**:以前 53 个专家团(软件开发团队、内容工厂等)在列表里只显示「团队型暂只读」,现在点「召唤专家团」即可用。召唤时除了写入主理人预设,还会把每位成员的人设各自落成一份本地文件(`~/.dsh/wb-market/experts/teams/<专家>/`),送还时整体清除',
275
+ where: '专家 → 任一标注「专家团」的卡片',
276
+ },
277
+ {
278
+ text: '团队主理人会**按编制自动调度成员**:WorkBuddy 的团队人设原本依赖 `TeamCreate` 与 `Agent(name=…)` 两个 DSH 没有的工具。现在召唤时会往主理人人设末尾追加一段运行时适配,把这两类指令映射成 DSH 的 `subagent`(成员人设先 read 出来拼进 prompt),并保留「不得代写成员产出、成员之间不直连」等原始铁律',
279
+ where: '专家 → 专家团详情弹层(「团队编制」区块会列出每位成员与职责)',
280
+ },
281
+ {
282
+ text: '成员人设**不计入系统提示**:5 份人设若全塞进预设,每一轮请求都要为它们白付上下文费。现在只落盘、由主理人调度时按需读取,预设里仍然只有主理人一份人设',
283
+ where: '—(实现细节,影响每轮 token 成本)',
284
+ },
285
+ {
286
+ text: '修复「工具裁缝对所有工具的检测数量全为 0」:那其实是筛选口径造成的错觉——表格默认开着「只看死工具」,屏幕上自然整列 0。现在**默认显示全部工具与真实调用次数**,零调用的行用底色标出;按钮改成「只看零调用(N)」按需开启,顶部常驻回显「显示 N/M 个工具」,不会再被读成「检测全坏」',
287
+ where: '工具裁缝标签页',
288
+ },
289
+ ],
290
+ },
291
+ {
292
+ version: '1.10.0',
293
+ items: [
294
+ {
295
+ text: '**英文界面真的能用了**:`t()` 框架从 1.9.x 起就写着「将来往字典合并译文即可,无需改调用点」,但字典实参一直是空的。现在填满 **720 条键**,覆盖源码里全部 685 条中文原文;语言按 `localStorage["dsh-armory.locale"]` → `navigator.language` → `zh-CN` 三级回落,宿主是英文的用户面板自动变英文。顺带修掉 `fmtInt` / `fmtTax` 把千分位写死 `en-US` 的问题',
296
+ where: '—(跟随宿主语言;想强制英文可设 localStorage 的 dsh-armory.locale)',
297
+ },
298
+ {
299
+ text: '**「明明选过仅侧栏入口,怎么又变回弹窗」根治了**:以前 `whatsNew.seen` 只存一个版本号字符串,升级后整个键被当作过期清掉,入口偏好位跟着一起归零。现在两个偏好各自结构化落盘(`whatsNew.state` / `updateHint.state`),读写都保留下旧裸键的回落(老用户升级不丢状态),升级后只有「弹窗展示」位随版本重置',
300
+ where: '更新提示弹窗 + 面板头部更新提示条',
301
+ },
302
+ {
303
+ text: '**连接诊断卡**:Server 详情页新增分区,直接列出这台服务器的连接状态(含会话槽)、会话复用数、空闲回收倒计时、上次回收耗时(超时标红)、配置是否已改动、同名项目冲突——数据来自早就注册、却从未被面板调用过的 `projectConnections`。状态不是 ready 时,卡片底部直接给「重连这台服务器」。按需拉一次不轮询,右上角可刷新',
304
+ where: 'MCP → 项目作用域 → 任一服务器详情',
305
+ },
306
+ {
307
+ text: '**teardown 残留看得见了**:先说明白一件事——**本插件不 spawn 任何 MCP 子进程**(`lib/` 里 `SIGTERM` / `SIGKILL` / `taskkill` 零命中),所以「插件自己按进程树 kill」在架构上不成立,那是宿主 mcp-client 的职责。这个版本做的是本侧可观测:记录 teardown 的耗时与结局(超过 5s 判卡住,只留最近 64 条、10 分钟过期),经既有的 `projectConnections` 行以 `teardown` 字段流出,**没有新增 RPC**;另附上游提案 `docs/host-proposal-mcp-teardown.md`',
308
+ where: '—(诊断数据见连接诊断卡的「上次回收」一行)',
309
+ },
310
+ {
311
+ text: '**导入带 `${input:xxx}` 的 VS Code 配置不再整份失败**:以前只要有一条引用交互输入,整份文件拒绝导入。现在逐条降级——`url` / `command` / `cwd` 这类核心字段含 input 的条目进「不支持」分组并附原因(去掉等于发明一个猜的值,属静默错误),而 `env.*` / `headers.*` / `args` 元素含 input 的只剔该项并给 warning,条目仍可导入。预览弹层按「可导入 / 不支持」分组呈现',
312
+ where: 'MCP → 导入 → 扫描来源(VS Code 配置)',
313
+ },
314
+ {
315
+ text: '**工具裁缝三处改善**:① 时间窗新增「今日」档,调参后看当天效果不用再切到 7 天;② Node < 22.15 时 zstd 统计全 0 会被误读成「没有调用」,现在这些数字渲染成 `—`(数据缺失),与真实 0 区分开;③ 面板新增「缓存占用:x MB / 32 条」一行,配「清空账本缓存」按钮,清完直接用返回报告刷新、不额外扫一遍',
316
+ where: '工具裁缝标签页',
317
+ },
318
+ {
319
+ text: '**专家详情不再只给看 4000 字**:persona 截断处加「展开全文(N 字符)/ 收起」,魔法数字提为 `PERSONA_PREVIEW_MAX`(已召唤 / 市场两个弹层都覆盖);quickPrompts 超过 3 条给「显示全部 N 条 / 收起」;卡片描述 48 字截断处补 `title`,悬停可看全文',
320
+ where: '专家 → 任一专家详情弹层',
321
+ },
322
+ {
323
+ text: '**认证方式有徽章、分页能跳页、只读看板说明白了**:① 内置 MCP 目录早就带 `auth` / `authNote` 元数据,现在按类型给色——需交互授权的(OAuth / OAuth-or-PAT)用警示色,只需填 key 的中性色,`none` 不占位;这两个字段只是展示元数据,绝不写进用户配置。② 三处分页统一改用同一个 `Pager` 组件,带首页/末页 + 页码跳转 + 「第 x / y 页 · 共 N 项」常驻计数。③ 技能看板顶部直接写明只读:宿主只提供 `listSkills` / `skillDetail`,要改内容请编辑 `SKILL.md`',
324
+ where: 'MCP → 内置目录 / 市场连接器 / 市场技能;技能标签页',
325
+ },
326
+ {
327
+ text: '**通用体验四件**:① 首屏数据未到时给轻骨架(`prefers-reduced-motion` 下停用 shimmer),不再「空白半秒像卡死」;② `FAB_SIZE` / `FAB_EDGE` 提到 CSS 之前并用插值,消除「改一处忘一处」;③ 9 处 `role="dialog"` 统一走 `useFocusTrap`——打开聚焦首个可交互元素、Tab 在框内循环、关闭时焦点还原到触发元素;④ 崩溃面板除 retry 外新增「复制错误详情」(`error.stack` + `componentStack` + 时间与 locale 组装入剪贴板),降低回报成本',
328
+ where: '—(面板任意位置;崩溃面板见出错时)',
329
+ },
330
+ {
331
+ text: '**测试从 13 个套件加到 18 个**,把「注释里承诺过、但没测试守得住」的契约都钉死:契约三处一致性、导入源降级结构、i18n 覆盖率双向差集(英文界面上冒中文正是这个缺口)、persona 阈值、裁缝默认窗口、崩溃详情可复制、teardown 残留诊断。每个套件都内置反向自测,证明断言不是恒真',
332
+ where: '—(开发者视角:`npm test`)',
333
+ },
334
+ ],
335
+ },
270
336
  ]