dsh-armory 1.9.4 → 2.0.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.
@@ -212,6 +212,16 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
212
212
  let totalErrors = 0;
213
213
  let orphanResults = 0;
214
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
+ };
215
225
  const failures = (reason) => {
216
226
  failedFiles += 1;
217
227
  if (failedFileSamples.length < 20) failedFileSamples.push(reason);
@@ -301,6 +311,8 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
301
311
  record.calls += 1;
302
312
  totalCalls += 1;
303
313
  dirHadCall = true;
314
+ const bucket = dayIndex(time);
315
+ if (bucket !== null) dayBuckets[bucket] += 1;
304
316
  if (callId) record.seenCallIds.add(callId);
305
317
  if (time !== null) {
306
318
  if (record.firstUsedMs === null || time < record.firstUsedMs) record.firstUsedMs = time;
@@ -414,6 +426,10 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
414
426
  // 界面在 zstd 降级时会提示「升级到 22.15 以上」,得能说出用户当前跑的是什么版本,
415
427
  // 否则那句提示等于没说。取宿主进程自己的版本(不是 package.json 的 engines 声明)。
416
428
  nodeVersion: process.versions.node,
429
+ // OPT-19:解压文本缓存的占用快照(见 textCacheStats)。128 MB 常驻占用此前对用户
430
+ // 完全不可见——报告带上它,面板才有数据展示「缓存占了多少、离上界还有多远」,并
431
+ // 在接近上界时引导用户手动回收(清空动作由 index.js 的 toolTailor 方法承接)。
432
+ textCache: textCacheStats(),
417
433
  totalCalls,
418
434
  totalResults,
419
435
  totalErrors,
@@ -424,6 +440,109 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
424
440
  deadMcpCount: deadMcpTools.length,
425
441
  taxTotalTokens,
426
442
  deadMcpTaxTokens,
443
+ // §5.4 趋势数据:逐日调用量(下标 0 = 窗口最早一天)。
444
+ // 界面只在「确实有调用」时画 sparkline,全 0 从这里就能看出来,不必前端猜。
445
+ dailyCalls: dayBuckets,
427
446
  tools,
428
447
  };
429
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'),
@@ -92,6 +94,22 @@ export const TYPERT = {
92
94
  mv('wbSkillInstall', [payloadParam], 'NoteResult'),
93
95
  mv('wbSkillUninstall', [payloadParam], 'NoteResult'),
94
96
  mv('wbSkillInstalled', [], 'WbInstalledResult'),
97
+ // T03/R4:多市场注册表(读 / 增改)。payload $any,形状由 lib/wb-registry.js 校验。
98
+ mv('wbMarketRegistry', [], 'WbMarketRegistryResult'),
99
+ mv('wbMarketRegistryUpsert', [payloadParam], 'WbMarketRegistryResult'),
100
+ // T03/R5:codebuddy 插件市场(列表 / 详情)。payload $any,形状由 lib/wb-plugin-market.js 校验。
101
+ mv('wbPluginCatalog', [], 'WbPluginCatalogResult'),
102
+ mv('wbPluginDetail', [payloadParam], 'WbPluginDetailResult'),
103
+ // T04/R8:安装状态登记与查询(宿主侧落盘,客户端「已安装」筛选的数据源)。
104
+ mv('wbInstallState', [], 'InstallStateResult'),
105
+ mv('wbInstallStateRecord', [payloadParam], 'NoteResult'),
106
+ mv('wbInstallStateForget', [payloadParam], 'NoteResult'),
107
+ // T05/R9:插件市场更新探测(只做条件请求,不落新包)。
108
+ mv('wbPluginUpdateCheck', [], 'WbUpdateHintResult'),
109
+ // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins,数据层 lib/wb-awesome-market.js)。
110
+ mv('wbAwesomeCatalog', [payloadParam], 'AwesomeCatalogResult'),
111
+ mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
112
+ mv('wbAwesomeUpdateCheck', [], 'WbUpdateHintResult'),
95
113
  // WorkBuddy 专家直连(召唤预设 + 捆绑技能)。payload 全部 $any:数据形状由
96
114
  // lib/expert-market.js 校验,与 wb* 系列同一宽容度。
97
115
  mv('wbExpertCatalog', [], 'WbExpertCatalogResult'),
@@ -106,6 +124,14 @@ export const TYPERT = {
106
124
  mv('expertAttach', [payloadParam], 'NoteResult'),
107
125
  mv('expertDetach', [payloadParam], 'NoteResult'),
108
126
  mv('expertActive', [], 'ExpertActiveResult'),
127
+ // R8:连接器 MCP 的浏览器授权(RFC 9728/8414/7591/7636)。副作用是起本地回调
128
+ // 监听 + 拉系统浏览器,所以 begin/poll 都是独立 RPC,面板按需轮询。
129
+ mv('wbOAuthBegin', [payloadParam], 'NoteResult'),
130
+ mv('wbOAuthPoll', [payloadParam], 'NoteResult'),
131
+ mv('wbOAuthCancel', [payloadParam], 'NoteResult'),
132
+ mv('wbOAuthToken', [payloadParam], 'NoteResult'),
133
+ mv('wbOAuthOverview', [payloadParam], 'NoteResult'),
134
+ mv('wbOAuthOpen', [payloadParam], 'NoteResult'),
109
135
  ],
110
136
  model: { services: [], events: [], objects: [] },
111
137
  }
@@ -0,0 +1,128 @@
1
+ // auth_injection_rules 解析 + mcpServers token 占位符工具(R1 / R7 的纯函数部分)。
2
+ //
3
+ // 设计边界(X9):WB 的 auth_injection_rules 是 WB 账号体系的**代注入**机制,DSH 不做
4
+ // 注入——本模块只解析、只关联、只展示(R1);R7 的令牌引导安装用的是**用户自填 token**
5
+ // 对 `${...}` 占位符的显式替换,与 WB 代注入是两回事。
6
+ //
7
+ // 纯函数库:零 IO、零网络、零 Remote/UI 依赖。所有函数对畸形输入一律防御性返回
8
+ // 空集合/原样副本,绝不抛错——上游 JSON 是远端 zip 里的内容,任何形状都可能出现。
9
+ //
10
+ // 实测参考结构(仅防御解析参考,不硬编码依赖):
11
+ // 规则 = { id, when:{active_in:[]}, applies_to_connectors:[], requires_connected_connectors:[],
12
+ // timing:"request", inject:[{from_connector, token_type, header, value_template}] }
13
+
14
+ /**
15
+ * 解析 connectors.json 顶层的 auth_injection_rules 数组。
16
+ * 非数组 / 字段缺失 / 元素不是对象 → 返回 [](绝不抛错)。
17
+ * 元素原样保留(下游只读 id / applies_to_connectors / inject 等字段做展示)。
18
+ */
19
+ export function parseInjectionRules(indexJson) {
20
+ const rules = indexJson?.auth_injection_rules;
21
+ if (!Array.isArray(rules)) return [];
22
+ return rules.filter((rule) => rule && typeof rule === "object" && !Array.isArray(rule));
23
+ }
24
+
25
+ /**
26
+ * 按 connectorId 命中规则:applies_to_connectors 数组包含该 id 即命中。
27
+ * 返回命中规则数组(空输入 / 非数组 applies_to_connectors 一律不命中)。
28
+ */
29
+ export function rulesFor(rules, connectorId) {
30
+ if (!Array.isArray(rules)) return [];
31
+ const id = String(connectorId ?? "").trim();
32
+ if (!id) return [];
33
+ return rules.filter((rule) => {
34
+ const applies = rule?.applies_to_connectors;
35
+ return Array.isArray(applies) && applies.map((entry) => String(entry ?? "").trim()).includes(id);
36
+ });
37
+ }
38
+
39
+ /** 一个待填 token 槽位:占位符在哪个 server 的哪个容器字段里。 */
40
+ // 形状:{ server, container: "env"|"header", key, template, placeholder, value }
41
+
42
+ const PLACEHOLDER_PATTERN = /\$\{([^}]+)\}/g;
43
+ // 非全局版:.test() 不会推进 lastIndex(全局正则连续 test 会漏判残留占位符)。
44
+ const HAS_PLACEHOLDER = /\$\{[^}]+\}/;
45
+
46
+ /**
47
+ * 扫描 mcpServers 的 env 与 headers 值里的 `${...}` 模板占位符。
48
+ * 返回槽位清单:[{ server, container: "env"|"header", key, template, placeholder, value }]
49
+ * - container 字段容器:env → env 对象;header → headers 对象(对外统一命名)
50
+ * - template 原始模板串(如 "Bearer ${token}")
51
+ * - placeholder 第一个占位符(如 "token");模板无占位符的条目不进清单
52
+ * - value 原值(供 applyTokens 做精确字符串替换与回显)
53
+ * mcpServers / env / headers 畸形(null、数组、值非字符串)一律跳过,不抛错。
54
+ */
55
+ export function scanTokenSlots(mcpServers) {
56
+ if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) return [];
57
+ const slots = [];
58
+ for (const [server, rawServer] of Object.entries(mcpServers)) {
59
+ // transport 可能嵌套(wb-market.serverInstallability 的 nested 形态):env/headers
60
+ // 在 server 本体或 server.transport 上都认。
61
+ const nested = rawServer && typeof rawServer === "object" && typeof rawServer.transport === "object" && rawServer.transport !== null
62
+ ? rawServer.transport
63
+ : (typeof rawServer === "object" && rawServer !== null ? rawServer : null);
64
+ if (!nested) continue;
65
+ const bags = [["env", nested.env], ["header", nested.headers]];
66
+ for (const [container, bag] of bags) {
67
+ if (!bag || typeof bag !== "object" || Array.isArray(bag)) continue;
68
+ for (const [key, value] of Object.entries(bag)) {
69
+ if (typeof value !== "string") continue;
70
+ const matches = [...value.matchAll(PLACEHOLDER_PATTERN)];
71
+ if (!matches.length) continue;
72
+ slots.push({
73
+ server,
74
+ container,
75
+ key,
76
+ template: value,
77
+ placeholder: matches[0][1].trim(),
78
+ value,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ return slots;
84
+ }
85
+
86
+ /**
87
+ * 按槽位把占位符替换为用户 token,产出「可安全写盘」的 mcpServers 副本。
88
+ * - tokens: Record<placeholder, value>,键为占位符名(如 { access_token: "xxx" })。
89
+ * - 未提供 token 的槽位**保留原样**并在 missingSlots 里标记(供 UI 提示缺槽)。
90
+ * 返回 { mcpServers, missingSlots }:
91
+ * mcpServers 深拷贝后的替换结果(原对象永不改动)
92
+ * missingSlots [{ server, container, key, placeholder, template }] 未填槽位清单
93
+ * 输入畸形时返回 { mcpServers: {}, missingSlots: [] };对每个槽位同时应用模板内**所有**
94
+ * 占位符("Bearer ${a}/${b}" 两处都能换),全替换后仍含 `${` 的值按未填槽位上报。
95
+ */
96
+ export function applyTokens(mcpServers, tokens) {
97
+ const source = mcpServers && typeof mcpServers === "object" && !Array.isArray(mcpServers) ? mcpServers : {};
98
+ const tokenMap = tokens && typeof tokens === "object" && !Array.isArray(tokens) ? tokens : {};
99
+ const missingSlots = [];
100
+ const result = structuredClone(source);
101
+ const slots = scanTokenSlots(result);
102
+ for (const slot of slots) {
103
+ const target = slot.container === "header"
104
+ ? result[slot.server]?.transport?.headers ?? result[slot.server]?.headers
105
+ : result[slot.server]?.transport?.env ?? result[slot.server]?.env;
106
+ if (!target || typeof target !== "object") continue;
107
+ let filled = String(target[slot.key]);
108
+ for (const match of filled.matchAll(PLACEHOLDER_PATTERN)) {
109
+ const token = tokenMap[match[1].trim()];
110
+ if (typeof token === "string" && token.length) {
111
+ filled = filled.split(match[0]).join(token);
112
+ }
113
+ }
114
+ if (HAS_PLACEHOLDER.test(filled)) {
115
+ // 仍有残留占位符 → 该槽位缺 token:保留原样并上报。
116
+ missingSlots.push({
117
+ server: slot.server,
118
+ container: slot.container,
119
+ key: slot.key,
120
+ placeholder: slot.placeholder,
121
+ template: slot.template,
122
+ });
123
+ } else {
124
+ target[slot.key] = filled;
125
+ }
126
+ }
127
+ return { mcpServers: result, missingSlots };
128
+ }