dsh-armory 2.0.0 → 2.2.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.
@@ -8,10 +8,17 @@
8
8
  // dsh-forget-session 里踩过的坑),所以必须按 magic 切帧逐帧解压。
9
9
  //
10
10
  // 两路数据在这里合并成一张账:
11
- // 1. 会话日志扫描 → 每个工具的调用次数 / 错误数 / P50/P95 延迟 / 最后使用时间(窗口内);
11
+ // 1. 会话日志扫描 → 每个工具的调用次数 / 错误数 / 最后使用时间(窗口内);
12
12
  // 2. 当前注册表的 Schema(ctx.tools.schemas())→ 每个工具每次请求占用的上下文 token 估算。
13
13
  // 窗口内零调用的 MCP 工具就是「裁缝建议」:它们既不干活,还每轮请求白收上下文税。
14
14
  //
15
+ // §6.1 第三路(同一趟扫描,不额外遍历):`request/header` 事件的 `data.header.tools` 里存着
16
+ // **该次请求真实下发给模型的工具清单**。上下文税此前只有「注册表当前快照」这一个口径,它是
17
+ // Schema 长度的纯函数,与时间完全无关 —— 于是用户连着几天看到同一个数字,既无法判断裁缝是否
18
+ // 还在工作,也无法判断「我改注册表之后到底降了没有」。真实下发清单补上的是时间维度:按天采样
19
+ // 就能回答「这份清单这几天变过没有、什么时候变的」,并且和注册表快照互为对照(不一致 = 宿主没
20
+ // 重启 / 新工具尚未生效,这类状态此前完全不可见)。
21
+ //
15
22
  // 全程只读:不写任何文件、不碰会话目录内容。
16
23
 
17
24
  import zlib from "node:zlib";
@@ -45,6 +52,15 @@ const MAX_LOG_BYTES = 64 * 1024 * 1024;
45
52
  const TEXT_CACHE_MAX_BYTES = 128 * 1024 * 1024;
46
53
  const TEXT_CACHE_MAX_ENTRIES = 32;
47
54
 
55
+ /**
56
+ * 真实下发工具清单的**种类上限**(§6.1)。每种只在首次出现时算一次全量税,超出后不再新增、
57
+ * 只把 `wireTaxTruncated` 置真并交给界面上屏。设这道闸是因为「每次请求清单都不一样」这种病态
58
+ * 日志会让 wireTaxByKey 无界增长,而那恰恰是要靠趋势线暴露的场景——宁可少报几种并明确说「已
59
+ * 截断」,也不能让只读账本把宿主主进程的内存吃掉。
60
+ */
61
+ const WIRE_TAX_MAX_KEYS = 64;
62
+
63
+
48
64
  /** 把字节数格式化成 MB 文案,只用于诊断信息。 */
49
65
  function formatMB(bytes) {
50
66
  return `${(bytes / 1_048_576).toFixed(1)} MB`;
@@ -162,6 +178,59 @@ function estimateTokens(text) {
162
178
  return Math.ceil(text.length / 4);
163
179
  }
164
180
 
181
+ /**
182
+ * 单个工具 Schema 投影成「要下发的那段 JSON」后的上下文税。
183
+ *
184
+ * 注册表侧与日志真实下发侧**必须共用这一个投影**:两处各写一遍 `{name, description,
185
+ * parameters}` 时,任何一侧改了键集合或兜底写法,「注册表快照 vs 真实下发」的偏差里就会
186
+ * 混进口径差而不是真差异,那个偏差卡就成了长期误报的假告警。
187
+ * @param {{name: string, description?: string, parameters?: unknown}} tool
188
+ * @returns {number} 估算 token 数。
189
+ */
190
+ function schemaTokensOf(tool) {
191
+ return estimateTokens(JSON.stringify({ name: tool.name, description: tool.description || "", parameters: tool.parameters ?? null }));
192
+ }
193
+
194
+ /**
195
+ * 一次请求真实下发的整份工具清单的税(§6.1,口径与 {@link schemaTokensOf} 逐工具一致)。
196
+ * @param {Array<{name?: unknown, description?: unknown, parameters?: unknown}>} tools
197
+ * @returns {{ tokens: number, toolCount: number }}
198
+ */
199
+ function wireToolsSummary(tools) {
200
+ let tokens = 0;
201
+ let counted = 0;
202
+ for (const tool of tools) {
203
+ if (!tool || typeof tool.name !== "string") continue;
204
+ tokens += schemaTokensOf(tool);
205
+ counted += 1;
206
+ }
207
+ return { tokens, toolCount: counted };
208
+ }
209
+
210
+ /**
211
+ * 工具清单的**廉价**内容指纹(§6.1):只读长度与工具名,不做 stringify,所以每个请求事件都算
212
+ * 得起;真正的税计算只在指纹第一次出现时做一次(见 WIRE_TAX_MAX_KEYS)。
213
+ *
214
+ * 精度取舍要说清楚:工具数相同、工具名集合相同、描述总长相同,但**参数结构**不同的两份清单
215
+ * 会撞成同一个指纹,此时后一份沿用前一份的税值。真实改动几乎不可能同时满足这三个条件(改参数
216
+ * 一般会带动描述或增删工具),而撞车时的失真只影响趋势线的颗粒度与 `wireTaxChanges` 的次数,
217
+ * 主口径 `taxTotalTokens` 来自注册表快照、完全不经这里。
218
+ * @param {Array<{name?: unknown, description?: unknown}>} tools
219
+ * @returns {string}
220
+ */
221
+ function wireToolsFingerprint(tools) {
222
+ let count = 0;
223
+ let descLen = 0;
224
+ let hash = 5381;
225
+ for (const tool of tools) {
226
+ if (!tool || typeof tool.name !== "string") continue;
227
+ count += 1;
228
+ descLen += typeof tool.description === "string" ? tool.description.length : 0;
229
+ for (let i = 0; i < tool.name.length; i++) hash = ((hash * 33) + tool.name.charCodeAt(i)) >>> 0;
230
+ }
231
+ return `${count}:${descLen}:${hash >>> 0}`;
232
+ }
233
+
165
234
  function emptyToolRecord() {
166
235
  return { calls: 0, results: 0, errors: 0, seenCallIds: new Set(), firstUsedMs: null, lastUsedMs: null };
167
236
  }
@@ -212,10 +281,29 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
212
281
  let totalErrors = 0;
213
282
  let orphanResults = 0;
214
283
  let failedFrames = 0;
284
+ // §6.2 无 time 的调用事件:窗口过滤写的是「time 是数字且早于 cutoff 才丢」,所以缺 time
285
+ // 的事件会**无条件计入任何窗口**。当前宿主日志 100% 带数字 time(实测 6542/6542),但格式
286
+ // 一变就会静默发生,而且症状恰好是「切窗口数字不动」——与本次排查的表象一模一样。宁可多一个
287
+ // 恒为 0 的计数器,也不能再让这类失配无声无息。
288
+ let undatedCalls = 0;
289
+ // §6.3 本次真正读到的调用事件时间跨度(**含**早于窗口、因此在下面被 `continue` 掉的那些)。
290
+ // 用它才能解释「近 90 天与近 30 天输出一字不差」:机器上只有 14 天日志时,加长窗口本来
291
+ // 就无从改变结果。别用日志文件 mtime 代替:追加写的 mtime 是「最后写入」而不是「最早内容」。
292
+ let scannedEventsOldestMs = null;
293
+ let scannedEventsNewestMs = null;
215
294
  // §5.4 逐日趋势:扫描时顺手按天分桶,不额外遍历日志。
216
295
  // 桶只存「窗口内的相对天序号」(0 = 最早一天,windowDays-1 = 今天),
217
296
  // 由界面负责渲染;返回绝对日期反而让前端多一次时区换算的机会出错。
218
297
  const dayBuckets = new Array(windowDays).fill(0);
298
+ // §6.1 真实下发税的按天采样。`dayTaxKeys` 每个日桶只存**当天最后一次**请求的指纹(数字与
299
+ // 短串,不持有工具数组引用 —— 365 个日桶各留一份 234 项清单会常驻几十 MB,宿主主进程里不
300
+ // 可接受);`wireTaxByKey` 存每个不同清单的税,只在指纹首次出现时算一次,上限 WIRE_TAX_MAX_KEYS
301
+ // 种,超出后置 `wireTaxTruncated` 而不是无界增长。成本与「清单种类数」同阶,与请求数无关。
302
+ const dayTaxKeys = new Array(windowDays); // idx -> { time, fingerprint } | undefined
303
+ const wireTaxByKey = new Map(); // fingerprint -> { firstMs, lastMs, tokens, toolCount }
304
+ let wireTaxTruncated = false;
305
+
306
+
219
307
  const dayIndex = (time) => {
220
308
  if (typeof time !== "number") return null;
221
309
  const offset = now - time;
@@ -298,11 +386,21 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
298
386
  continue;
299
387
  }
300
388
  const time = typeof event.time === "number" ? event.time : null;
389
+ // §6.3 先记时间跨度,再做窗口过滤:被窗口丢掉的事件同样是「本机日志里真实存在过的
390
+ // 调用记录」,界面要用它解释「把窗口从 30 天加到 90 天为什么什么都没变」。
391
+ if (event.type === "tool/call" && time !== null) {
392
+ if (scannedEventsOldestMs === null || time < scannedEventsOldestMs) scannedEventsOldestMs = time;
393
+ if (scannedEventsNewestMs === null || time > scannedEventsNewestMs) scannedEventsNewestMs = time;
394
+ }
301
395
  if (time !== null && time < cutoffMs) continue;
302
396
  if (event.type === "tool/call") {
303
397
  const name = typeof event.data?.name === "string" ? event.data.name : null;
304
398
  const callId = typeof event.data?.callId === "string" ? event.data.callId : null;
305
399
  if (!name) continue;
400
+ // §6.2 计到账上了但没有 time → 这条落在哪个窗口都无法判定,它已经在**任何**窗口
401
+ // 里被计数(含下面的 1 天窗口)。这是口径失配,必须单独报出去。
402
+ if (time === null) undatedCalls += 1;
403
+
306
404
  let record = usage.get(name);
307
405
  if (!record) {
308
406
  record = emptyToolRecord();
@@ -342,6 +440,27 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
342
440
  owner.errors += 1;
343
441
  totalErrors += 1;
344
442
  }
443
+ } else if (event.type === "request/header") {
444
+ // §6.1 该次请求真实下发给模型的整份工具清单。宿主把 `data.header.tools` 原样
445
+ // 落盘,所以「注册表快照」有了一个可对照的真值:两者不一致就是「改了没生效」。
446
+ const tools = event.data?.header?.tools;
447
+ if (!Array.isArray(tools) || tools.length === 0) continue;
448
+ // 没有 time 的清单快照无法归入日桶,直接丢;这类事件的存在由 undatedCalls
449
+ // 那一路负责显形,两条口径不要混在一起。
450
+ const taxBucket = dayIndex(time);
451
+ if (taxBucket === null) continue;
452
+ const fingerprint = wireToolsFingerprint(tools);
453
+ const known = wireTaxByKey.get(fingerprint);
454
+ if (!known) {
455
+ if (wireTaxByKey.size >= WIRE_TAX_MAX_KEYS) {
456
+ wireTaxTruncated = true;
457
+ continue;
458
+ }
459
+ const summary = wireToolsSummary(tools);
460
+ wireTaxByKey.set(fingerprint, { fingerprint, firstMs: time, lastMs: time, ...summary });
461
+ } else if (time > known.lastMs) known.lastMs = time;
462
+ const dayBest = dayTaxKeys[taxBucket];
463
+ if (!dayBest || time > dayBest.time) dayTaxKeys[taxBucket] = { time, fingerprint };
345
464
  }
346
465
  }
347
466
  if (dirHadCall) sessionsWithCalls += 1;
@@ -392,15 +511,14 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
392
511
  errors: 0,
393
512
  errorRate: 0,
394
513
  lastUsedMs: null,
395
- schemaTokens: estimateTokens(JSON.stringify({ name: schema.name, description: schema.description || "", parameters: schema.parameters ?? null })),
514
+ schemaTokens: schemaTokensOf(schema),
396
515
  serverDisabled: classification.server !== null && disabled.has(classification.server),
397
516
  });
398
517
  }
399
518
  // 有调用记录的工具也补 Schema 税(有 Schema 的才有值)。
400
519
  for (const tool of tools) {
401
520
  if (tool.schemaTokens === null && schemaByName.has(tool.name)) {
402
- const schema = schemaByName.get(tool.name);
403
- tool.schemaTokens = estimateTokens(JSON.stringify({ name: schema.name, description: schema.description || "", parameters: schema.parameters ?? null }));
521
+ tool.schemaTokens = schemaTokensOf(schemaByName.get(tool.name));
404
522
  }
405
523
  }
406
524
 
@@ -410,6 +528,23 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
410
528
  const taxTotalTokens = tools.reduce((sum, tool) => sum + (tool.schemaTokens ?? 0), 0);
411
529
  const deadMcpTaxTokens = deadMcpTools.reduce((sum, tool) => sum + (tool.schemaTokens ?? 0), 0);
412
530
 
531
+ // §6.1 收尾:把按天采到的指纹换成税值,并挑出「窗口内出现过的不同清单」按首次出现时间排序。
532
+ // 界面要回答的是「这份清单这几天动过没有」,所以清单种类(通常 1~5 种)远比请求条数有意义。
533
+ const wireTaxDaily = new Array(windowDays).fill(0);
534
+ for (let i = 0; i < windowDays; i++) {
535
+ const dayBest = dayTaxKeys[i];
536
+ if (!dayBest) continue;
537
+ const entry = wireTaxByKey.get(dayBest.fingerprint);
538
+ if (entry) wireTaxDaily[i] = entry.tokens;
539
+ }
540
+ const wireTaxSnapshots = [...wireTaxByKey.values()].sort((a, b) => a.firstMs - b.firstMs);
541
+ // latest = 时间最晚那次请求所用的清单(不是「最后一个非空日桶」:日桶按天存当天最后一次,
542
+ // 但同一天里先后的两份清单都记在 wireTaxSnapshots 的 firstMs/lastMs 上,这里取 lastMs 最大者)。
543
+ let wireTaxLatest = null;
544
+ for (const entry of wireTaxSnapshots) {
545
+ if (wireTaxLatest === null || entry.lastMs > wireTaxLatest.atMs) wireTaxLatest = { atMs: entry.lastMs, tokens: entry.tokens, toolCount: entry.toolCount };
546
+ }
547
+
413
548
  return {
414
549
  windowDays,
415
550
  generatedAt: now,
@@ -427,13 +562,28 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
427
562
  // 否则那句提示等于没说。取宿主进程自己的版本(不是 package.json 的 engines 声明)。
428
563
  nodeVersion: process.versions.node,
429
564
  // OPT-19:解压文本缓存的占用快照(见 textCacheStats)。128 MB 常驻占用此前对用户
430
- // 完全不可见——报告带上它,面板才有数据展示「缓存占了多少、离上界还有多远」,并
431
- // 在接近上界时引导用户手动回收(清空动作由 index.js 的 toolTailor 方法承接)。
565
+ // 完全不可见——报告带上它,面板才有数据展示「缓存占了多少 / 装了多少条」。面板那一行
566
+ // 现在是**只读**的:「手动清空」整条动作链已于 2026-10-23 删除(反馈恒错 + 占用不降 +
567
+ // 本机命中率 0%;理由记在 client.js 的 cacheLine 上方与 index.js 的 toolTailor 里,
568
+ // 护栏是 test/tailor-cache-line.mjs)。utilization 仍照常产出,只是界面不再显示——它把
569
+ // 字节占比与条数占比混成一个数,本机恒为 1,摆上屏只会把人推向那个已经不存在的按钮。
432
570
  textCache: textCacheStats(),
433
571
  totalCalls,
434
572
  totalResults,
435
573
  totalErrors,
436
574
  orphanResults,
575
+ // §6.2 被计入任何窗口但无法定位时间的调用(当前宿主恒为 0;一旦变红就是日志格式失配)。
576
+ undatedCalls,
577
+ // §6.3 本次读到的调用记录跨度。**按窗口变化**:更长的窗口会打开更多日志文件,读到更早的
578
+ // 事件。界面用「spanDays < windowDays」解释为什么加长窗口什么都没改变。
579
+ scannedEventsOldestMs,
580
+ scannedEventsNewestMs,
581
+ // 取整方式由 test/tailor-timeline.mjs §6 钉住:这里**必须**是 round 而不是 ceil。任何事
582
+ // 件的时间戳都严格早于本次扫描的 now(哪怕只早几毫秒),ceil 于是恒定时把「2 天前」印成
583
+ // 「跨 3 天」——正是本功能要消灭的那类「看起来没错、实际误导」的数字。round 的另一半好处
584
+ // 在触发条件:ceil 下 2.0 天前的数据算成 3 天,3 天窗口 `3 > 3` 不成立,归因提示漏报;
585
+ // round 给 2 天,提示正常出现。误差半天的漏报阈值远小于面板的语义颗粒(天)。
586
+ scannedSpanDays: scannedEventsOldestMs === null ? 0 : Math.max(1, Math.round((now - scannedEventsOldestMs) / 86_400_000)),
437
587
  toolCount: tools.length,
438
588
  mcpToolCount: mcpTools.length,
439
589
  deadCount: deadTools.length,
@@ -443,14 +593,27 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
443
593
  // §5.4 趋势数据:逐日调用量(下标 0 = 窗口最早一天)。
444
594
  // 界面只在「确实有调用」时画 sparkline,全 0 从这里就能看出来,不必前端猜。
445
595
  dailyCalls: dayBuckets,
596
+ // §6.1 真实下发税时间线:税值第一次有了「按天」的形状,用户不必再靠「这个数字看着没变」
597
+ // 来猜注册表动没动。0 = 那天没有 request/header 记录(税不可能算成 0,所以 0 就是缺数据)。
598
+ wireTaxDaily,
599
+ wireTaxSnapshots: wireTaxSnapshots.map((entry) => ({ firstMs: entry.firstMs, lastMs: entry.lastMs, tokens: entry.tokens, toolCount: entry.toolCount })),
600
+ wireTaxLatest,
601
+ wireTaxChangeCount: wireTaxByKey.size,
602
+ wireTaxTruncated,
603
+ // 注册表快照与最近一次真实下发的差额(正数 = 注册表比线上更大,例如加了服务器还没生效)。
604
+ // 没有真实下发记录时为 null —— 界面必须把「不知道」和「差 0」分开呈现。
605
+ wireTaxDivergenceTokens: wireTaxLatest === null ? null : taxTotalTokens - wireTaxLatest.tokens,
446
606
  tools,
447
607
  };
448
608
  }
449
609
 
450
- // OPT-19:解压文本缓存(OPT-03)的**可观测性**与**手动回收**。128 MB 上界在长时间运行的
451
- // 宿主里是笔实实在在的常驻占用,但本模块此前只导出 scanSessionLedger,条数/字节数/新鲜度全
452
- // 关在模块私有作用域里:用户既看不到占用,也没有任何回收手段,出问题只能重启宿主。下面两个
453
- // 导出只读写模块私有状态——不读盘、不解压、不新增 RPC 契约(接线到宿主由 index.js 另行负责)。
610
+ // OPT-19:解压文本缓存(OPT-03)的**可观测性**。128 MB 上界在长时间运行的宿主里是笔实实在在的
611
+ // 常驻占用,但本模块此前只导出 scanSessionLedger,条数/字节数/新鲜度全关在模块私有作用域里:
612
+ // 用户既看不到占用,也没有任何回收手段,出问题只能重启宿主。下面两个导出只读写模块私有状态——
613
+ // 不读盘、不解压、不新增 RPC 契约。可观测性已经接上面板(cacheLine 那一行,只读);「手动回收」
614
+ // 也接过一次,但因那条链的反馈恒错且实测不降占用,已于 2026-10-23 连界面入口一起删除,
615
+ // clearTextCache 因此暂时没有调用方——它仍是将来改缓存策略(槽位按文件数自适应、或只缓存最近
616
+ // 若干会话)的落点,所以留着。
454
617
 
455
618
  /** utilization 的量化倍数:10^4,即保留 4 位小数(0.0001 精度),避免浮点长尾进入面板文案。 */
456
619
  const UTILIZATION_SCALE = 10_000;
@@ -518,7 +681,13 @@ export function textCacheStats() {
518
681
  }
519
682
 
520
683
  /**
521
- * 清空解压文本缓存并复位计数,返回被清掉的内容概要(供面板说「已释放 x MB」)。
684
+ * 清空解压文本缓存并复位计数,返回被清掉的内容概要(条数与字节数)。
685
+ *
686
+ * **当前没有调用方**:面板的「清空缓存」按钮与 index.js 的 toolTailor action 分支已于
687
+ * 2026-10-23 一并删除(理由记在 client.js cacheLine 上方)。要把这类动作接回界面,光调这个
688
+ * 函数不够——`textCacheStats()` 里没有 `clearedBytes` 键,宿主必须把本函数的返回值原样放进
689
+ * 报告,否则面板拿不到「释放了多少」,只能落到兜底文案,看上去永远在忙;那正是当初删掉它的
690
+ * 三个理由之一。护栏见 test/tailor-cache-line.mjs。
522
691
  *
523
692
  * 实现顺序是有意为之——**先取 size 快照 → 再 clear → 最后把计数显式归零**:
524
693
  * 1. 不能先 `textCache.clear()` 再取 size / 遍历求和:那时 Map 已空,clearedEntries 与
package/lib/typert.js CHANGED
@@ -78,6 +78,15 @@ export const TYPERT = {
78
78
  mv('listSkills', [payloadParam], 'SkillListResult'),
79
79
  mv('skillDetail', [payloadParam], 'SkillDetailResult'),
80
80
  mv('revealSkill', [payloadParam], 'NoteResult'),
81
+ // 卸载已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。三处契约缺一即 404,
82
+ // 删除规则全在 lib/skill-remove.js(来源白名单 / 根目录包含性 / 符号链接只删链接)。
83
+ mv('removeSkill', [payloadParam], 'NoteResult'),
84
+ // 自定义套件(Skill 看板的「一组相关 skill 打包引用」):读 = 全量集合,
85
+ // 存 = 整集合覆盖(UI 一次提交右侧全部套件,不做增量 upsert)。数据层在
86
+ // lib/suites-registry.js,id 分配 / 成员去重保序 / 脏数据拒绝都在那边,
87
+ // 契约层与 listSkills 同一宽容度(payload $any)。
88
+ mv('listSkillSuites', [], 'SkillSuiteListResult'),
89
+ mv('saveSkillSuites', [payloadParam], 'SkillSuiteSaveResult'),
81
90
  // 工具裁缝(1.7.0 起的宿主方法):此前两端契约双双缺失 = 客户端永远调不到,
82
91
  // 面板上一直是 404 ——「工具裁缝无法正常使用」的根因。payload 形状由
83
92
  // lib/tool-ledger.js 校验,与 wb* 系列同一宽容度。
@@ -109,6 +118,8 @@ export const TYPERT = {
109
118
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins,数据层 lib/wb-awesome-market.js)。
110
119
  mv('wbAwesomeCatalog', [payloadParam], 'AwesomeCatalogResult'),
111
120
  mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
121
+ // README 预览:点击卡片按需取一次(npm registry / GitHub raw),落盘缓存 7 天。
122
+ mv('wbAwesomeReadme', [payloadParam], 'AwesomeReadmeResult'),
112
123
  mv('wbAwesomeUpdateCheck', [], 'WbUpdateHintResult'),
113
124
  // WorkBuddy 专家直连(召唤预设 + 捆绑技能)。payload 全部 $any:数据形状由
114
125
  // lib/expert-market.js 校验,与 wb* 系列同一宽容度。