dsh-armory 2.1.0 → 2.2.1
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.
- package/README.en.md +20 -5
- package/README.md +26 -8
- package/lib/client.js +1353 -168
- package/lib/index.js +51 -9
- package/lib/mcp-catalog-en.js +244 -0
- package/lib/mcp-config.js +15 -0
- package/lib/mcp-oauth.js +1 -1
- package/lib/skill-remove.js +125 -0
- package/lib/suites-registry.js +179 -0
- package/lib/tool-ledger.js +180 -11
- package/lib/typert.js +9 -0
- package/lib/wb-awesome-market.js +1 -1
- package/lib/wb-install-state.js +1 -1
- package/lib/wb-market.js +44 -21
- package/lib/wb-registry.js +1 -1
- package/lib/whats-new-en.js +406 -0
- package/lib/whats-new.js +45 -0
- package/lib/workspace-runtime.js +1 -1
- package/package.json +2 -2
- package/docs/chunk-split-feasibility.md +0 -153
- package/docs/host-proposal-mcp-teardown.md +0 -184
- package/docs/host-proposal-skill-toggle.md +0 -166
- package/docs/prd-awesome-dsh-plugins.md +0 -105
- package/docs/prd-workbuddy-parity.md +0 -128
- package/docs/system-design-awesome.md +0 -269
- package/docs/system_design.md +0 -530
- package/docs/workbuddy-market-gap-analysis.md +0 -197
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// 自定义套件(Skill 看板)—— ~/.dsh/armory/suites.json 的读写层。
|
|
2
|
+
//
|
|
3
|
+
// 职责:把「一组相关 skill 的命名组合」持久化,供两处消费——
|
|
4
|
+
// 1. Skill 看板工具栏的「自定义套件」按钮 → 套件管理弹窗(增删改);
|
|
5
|
+
// 2. 输入框召唤菜单的技能子菜单 → 选中一个套件即把成员的 `/name` 逐个插进草稿,
|
|
6
|
+
// 一次引用等于同时应用多个相关 skill(纯插件侧展开,宿主零改动)。
|
|
7
|
+
//
|
|
8
|
+
// 契约(与 UI 的分工,勿两头各写一套):
|
|
9
|
+
// - id 由**本层**分配:UI 新建的套件传 id: '',保存后必须以返回值里的 id 为准,
|
|
10
|
+
// 不得拿旧副本再存一次(否则同一套件会分裂成两条)。
|
|
11
|
+
// - members 是**有序**数组(拖拽排序即数组序),展开时按序拼 `/name`。
|
|
12
|
+
// - 保存语义是**整集合覆盖**(UI 的「保存」一次提交右侧全部套件),不是增量 upsert;
|
|
13
|
+
// 原子性靠 staging + rename,所以不存在「写一半」的中间态。
|
|
14
|
+
//
|
|
15
|
+
// 失败模式分档(对齐 execution-safety「失败模式处置分档」):
|
|
16
|
+
// - 拒绝(抛错):入参不是数组 / 条目不是对象 / 名称为空 / 成员为空 /
|
|
17
|
+
// 非空 id 重复 / 套件总数超上限。这些都是「写进去就是脏数据」的形态。
|
|
18
|
+
// - 放行 + 截断:name / desc 超长、members 超上限、members 里的非字符串项——
|
|
19
|
+
// 截断与丢弃都发生在 normalize 里,返回值就是落盘值,UI 以返回值为准。
|
|
20
|
+
// - 读取永不抛错:文件缺失 / JSON 损坏 / 结构不对 → 空集合(与 wb-registry、
|
|
21
|
+
// wb-install-state 同一口径),坏文件不炸面板。
|
|
22
|
+
//
|
|
23
|
+
// 纯数据层:零网络、零宿主依赖。存储根目录可注入(options.rootDir / 环境变量
|
|
24
|
+
// DSH_ARMORY_HOME),测试用 mkdtemp 隔离,绝不碰真实 ~/.dsh。
|
|
25
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const SUITES_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** 套件名上限:召唤菜单一行 28px,名字过长会挤掉成员计数徽章。 */
|
|
32
|
+
export const SUITE_NAME_MAX = 40;
|
|
33
|
+
/** 说明上限:只用于弹窗与菜单 title 悬停,不做正文展示。 */
|
|
34
|
+
export const SUITE_DESC_MAX = 120;
|
|
35
|
+
/** 单套件成员上限:展开后是一整串 `/name`,再多草稿框也读不下去。 */
|
|
36
|
+
export const SUITE_MEMBERS_MAX = 40;
|
|
37
|
+
/** 套件总数上限:文件体积与菜单长度的双重保险丝。 */
|
|
38
|
+
export const SUITES_MAX = 100;
|
|
39
|
+
|
|
40
|
+
/** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry 同一解析序)。 */
|
|
41
|
+
function armoryRoot(options = {}) {
|
|
42
|
+
return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 套件文件绝对路径(入参只能选根目录,文件名不可外部指定——避免路径穿越面)。 */
|
|
46
|
+
export function suitesPath(options = {}) {
|
|
47
|
+
return join(armoryRoot(options), "suites.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asString(value, fallback = "") {
|
|
51
|
+
return typeof value === "string" ? value : fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asTimestamp(value, fallback = 0) {
|
|
55
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 截断到上限(超长属「放行 + 截断」档,不抛错)。 */
|
|
59
|
+
function clampText(value, max) {
|
|
60
|
+
const text = asString(value).trim();
|
|
61
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 成员数组归一:只留非空字符串、按首次出现去重、截到上限(顺序即展开序)。 */
|
|
65
|
+
function normalizeMembers(raw) {
|
|
66
|
+
if (!Array.isArray(raw)) return [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const members = [];
|
|
69
|
+
for (const item of raw) {
|
|
70
|
+
const name = typeof item === "string" ? item.trim() : "";
|
|
71
|
+
if (!name || seen.has(name)) continue;
|
|
72
|
+
seen.add(name);
|
|
73
|
+
members.push(name);
|
|
74
|
+
if (members.length >= SUITE_MEMBERS_MAX) break;
|
|
75
|
+
}
|
|
76
|
+
return members;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把任意原始对象规范成一条完整的 Suite:缺失字段防御性补全,畸形成员丢弃。
|
|
81
|
+
* id 为空表示「新建」,由 saveSuites 分配;normalizeSuite 本身不造 id(保持纯函数)。
|
|
82
|
+
*/
|
|
83
|
+
export function normalizeSuite(raw) {
|
|
84
|
+
return {
|
|
85
|
+
id: asString(raw?.id).trim(),
|
|
86
|
+
name: clampText(raw?.name, SUITE_NAME_MAX),
|
|
87
|
+
desc: clampText(raw?.desc, SUITE_DESC_MAX),
|
|
88
|
+
members: normalizeMembers(raw?.members),
|
|
89
|
+
createdAt: asTimestamp(raw?.createdAt, 0),
|
|
90
|
+
updatedAt: asTimestamp(raw?.updatedAt, 0),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 默认 id 生成器:时间戳 36 进制 + 随机尾,够避免同毫秒连点撞车。 */
|
|
95
|
+
function defaultCreateId() {
|
|
96
|
+
return `suite-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 从磁盘读原始文档;不存在/损坏一律返回 null(由调用方决定回落语义)。 */
|
|
100
|
+
async function readRawDoc(path) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
103
|
+
} catch {
|
|
104
|
+
return null; // ENOENT / ENOTDIR / JSON 解析失败同途处理
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 拆出目录部分(路径既有 / 也有 \ 的 Windows 形态,不为 dirname 多引依赖)。 */
|
|
109
|
+
function dirnameOf(path) {
|
|
110
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
111
|
+
return cut > 0 ? path.slice(0, cut) : path;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式;Windows rename 不覆盖)。 */
|
|
115
|
+
async function writeAtomic(path, doc) {
|
|
116
|
+
await mkdir(dirnameOf(path), { recursive: true });
|
|
117
|
+
await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
|
|
118
|
+
await rm(path, { force: true });
|
|
119
|
+
await rename(`${path}.staging`, path);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 读取时同样过一遍 normalize:手改坏的条目在读侧就被拉回合法形状。 */
|
|
123
|
+
function normalizeList(raw) {
|
|
124
|
+
if (!Array.isArray(raw)) return [];
|
|
125
|
+
return raw
|
|
126
|
+
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
|
|
127
|
+
.map(normalizeSuite)
|
|
128
|
+
.filter((suite) => suite.id && suite.name && suite.members.length);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 读全部套件。文件缺失 / 损坏 / 结构不对 → { version, suites: [] },永不抛错。
|
|
133
|
+
* 读侧过滤掉「无 id / 无名 / 无成员」的残缺条目:它们展开后什么也插不进草稿,
|
|
134
|
+
* 留在列表里只是噪声(写侧本来也拒绝这种形态,读侧过滤只兜手改坏的文件)。
|
|
135
|
+
*/
|
|
136
|
+
export async function readSuites(options = {}) {
|
|
137
|
+
const raw = await readRawDoc(suitesPath(options));
|
|
138
|
+
const version = raw && typeof raw.version === "number" ? raw.version : SUITES_VERSION;
|
|
139
|
+
return { version, suites: normalizeList(raw?.suites) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 整集合覆盖保存(UI「保存」= 一次提交右侧全部套件)。
|
|
144
|
+
*
|
|
145
|
+
* 校验(拒绝档):入参必须是数组;每条必须是对象;名称非空;成员非空;
|
|
146
|
+
* 非空 id 不得重复;总数不超 SUITES_MAX。任一不过 → 抛错且不写盘(原文件保持不变)。
|
|
147
|
+
* 补全:空 id 分配新 id(createId 可注入,测试用确定性实现);缺 createdAt 补 now。
|
|
148
|
+
* updatedAt 由调用方决定(传了就落,没传落 now)——同 payload 重放得到同结果,写侧幂等。
|
|
149
|
+
*
|
|
150
|
+
* 返回写入后的 { version, suites }:UI 必须用它刷新本地副本(新 id 只在这里出现)。
|
|
151
|
+
*/
|
|
152
|
+
export async function saveSuites(suites, options = {}) {
|
|
153
|
+
if (!Array.isArray(suites)) throw new Error("套件集合必须是数组");
|
|
154
|
+
if (suites.length > SUITES_MAX) throw new Error(`套件数量超过上限 ${SUITES_MAX} 个`);
|
|
155
|
+
const createId = typeof options.createId === "function" ? options.createId : defaultCreateId;
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const seenIds = new Set();
|
|
158
|
+
const list = suites.map((raw, index) => {
|
|
159
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
160
|
+
throw new Error(`第 ${index + 1} 条套件不是对象`);
|
|
161
|
+
}
|
|
162
|
+
const suite = normalizeSuite(raw);
|
|
163
|
+
if (!suite.name) throw new Error(`第 ${index + 1} 条套件缺少名称`);
|
|
164
|
+
if (!suite.members.length) throw new Error(`套件「${suite.name}」没有成员`);
|
|
165
|
+
const id = suite.id || String(createId(index) || "").trim();
|
|
166
|
+
if (!id) throw new Error(`第 ${index + 1} 条套件无法分配 id`);
|
|
167
|
+
if (seenIds.has(id)) throw new Error(`套件 id 重复:${id}`);
|
|
168
|
+
seenIds.add(id);
|
|
169
|
+
return {
|
|
170
|
+
...suite,
|
|
171
|
+
id,
|
|
172
|
+
createdAt: suite.createdAt || now,
|
|
173
|
+
updatedAt: suite.updatedAt || now,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
const path = suitesPath(options);
|
|
177
|
+
await writeAtomic(path, { version: SUITES_VERSION, suites: list });
|
|
178
|
+
return { version: SUITES_VERSION, suites: list };
|
|
179
|
+
}
|
package/lib/tool-ledger.js
CHANGED
|
@@ -8,10 +8,17 @@
|
|
|
8
8
|
// dsh-forget-session 里踩过的坑),所以必须按 magic 切帧逐帧解压。
|
|
9
9
|
//
|
|
10
10
|
// 两路数据在这里合并成一张账:
|
|
11
|
-
// 1. 会话日志扫描 → 每个工具的调用次数 / 错误数 /
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
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
|
-
*
|
|
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* 系列同一宽容度。
|
package/lib/wb-awesome-market.js
CHANGED
package/lib/wb-install-state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 本地安装状态 + 来源锁(R8)—— ~/.dsh/armory/install-state.json 的读写层。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 职责(对应设计 §8.3,类图 InstallStateStore):
|
|
4
4
|
// - record(item):按 kind+key 定位,已存在则覆盖并刷新 lastUpdated;
|
|
5
5
|
// 首次写入补 installedAt。connector 不记 filesDigest,plugin/skill 记关键文件摘要。
|
|
6
6
|
// - forget(kind, key):删除一条登记,不存在时为无害空操作。
|
package/lib/wb-market.js
CHANGED
|
@@ -420,8 +420,23 @@ export async function wbConnectorCachedCount() {
|
|
|
420
420
|
// 文件:/api/v1/skills/:slug/files?namespace=<handle>[&version=];/file 同形 + &path=
|
|
421
421
|
// namespace 用 namespace.handle(列表项里是对象), 不是 canonicalName。
|
|
422
422
|
const SKILLHUB_SORTS = new Set(["downloads", "installs", "stars", "score"]);
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
// 2026-10 实测(api.skillhub.cn):`sortBy=downloads` 已不再是可用值——TCP 能连上、
|
|
424
|
+
// 但服务端不回任何字节,一直挂到客户端 AbortSignal 超时;installs / stars / score /
|
|
425
|
+
// updated 仍正常,非法值(download、hot、new…)会立刻 400 而不是挂起。
|
|
426
|
+
// 所以下载量排序不再发给上游,改成本地按当前页排序(见 wbSkillCatalog 尾部)。
|
|
427
|
+
const SKILLHUB_REMOTE_SORTS = new Set(["installs", "stars", "score"]);
|
|
428
|
+
async function skillhubJson(path, { timeoutMs = 15_000 } = {}) {
|
|
429
|
+
let buffer;
|
|
430
|
+
try {
|
|
431
|
+
buffer = await fetchWithLimits(`${SKILLHUB_ORIGIN}${path}`, { timeoutMs });
|
|
432
|
+
} catch (e) {
|
|
433
|
+
// 超时是上游最常见的一种失败(挂起而非 4xx),把 Node 的英文原文换成能看懂的中文。
|
|
434
|
+
const name = e && e.name;
|
|
435
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
436
|
+
throw new Error(`SkillHub 在 ${Math.round(timeoutMs / 1000)} 秒内没有响应,已放弃本次请求;稍后重试即可。`);
|
|
437
|
+
}
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
425
440
|
const body = JSON.parse(buffer.toString("utf8"));
|
|
426
441
|
// SkillHub 的业务错误也在 200 里(code !== 0)。
|
|
427
442
|
if (body && typeof body.code === "number" && body.code !== 0) throw new Error(`SkillHub 返回错误:${body.message || body.code}`);
|
|
@@ -431,13 +446,38 @@ export async function wbSkillCatalog(payload = {}) {
|
|
|
431
446
|
const page = Math.max(1, Math.min(200, Number(payload.page) || 1));
|
|
432
447
|
const pageSize = Math.max(1, Math.min(60, Number(payload.pageSize) || 20));
|
|
433
448
|
const sort = SKILLHUB_SORTS.has(payload.sort) ? payload.sort : "downloads";
|
|
434
|
-
const
|
|
449
|
+
const remoteSort = SKILLHUB_REMOTE_SORTS.has(sort) ? sort : "";
|
|
450
|
+
// 只把上游确认可用的排序发出去:`sortBy=downloads` 会挂起(见 SKILLHUB_REMOTE_SORTS)。
|
|
451
|
+
// 不带 sortBy 时上游按自己的默认序返回,参数缺失不会 400(已实测)。
|
|
452
|
+
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
453
|
+
if (remoteSort) params.set("sortBy", remoteSort);
|
|
435
454
|
const keyword = String(payload.keyword || "").trim();
|
|
436
455
|
if (keyword) params.set("keyword", keyword.slice(0, 64));
|
|
437
456
|
const category = String(payload.category || "").trim();
|
|
438
457
|
if (category) params.set("category", category.slice(0, 64));
|
|
439
458
|
const body = await skillhubJson(`/api/skills?${params.toString()}`);
|
|
440
459
|
const skills = Array.isArray(body?.data?.skills) ? body.data.skills : [];
|
|
460
|
+
const rows = skills.map((skill) => ({
|
|
461
|
+
slug: String(skill.slug || ""),
|
|
462
|
+
name: String(skill.name || skill.slug || ""),
|
|
463
|
+
namespace: String(skill.namespace?.handle || ""),
|
|
464
|
+
canonical: String(skill.namespace?.canonicalName || ""),
|
|
465
|
+
description: String(skill.description_zh || skill.description || ""),
|
|
466
|
+
descriptionEn: String(skill.description_en || ""),
|
|
467
|
+
downloads: Number(skill.downloads) || 0,
|
|
468
|
+
installs: Number(skill.installs) || 0,
|
|
469
|
+
stars: Number(skill.stars) || 0,
|
|
470
|
+
score: Number(skill.score) || 0,
|
|
471
|
+
version: String(skill.version || ""),
|
|
472
|
+
verified: !!skill.verified,
|
|
473
|
+
updatedAt: Number(skill.updated_at) || 0,
|
|
474
|
+
category: String(skill.category || ""),
|
|
475
|
+
tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
|
|
476
|
+
upstreamUrl: String(skill.upstream_url || skill.homepage || ""),
|
|
477
|
+
})).filter((skill) => skill.slug && skill.namespace);
|
|
478
|
+
// 下载量排序上游已不可用,退化为「当前页内按下载量降序」:跨页顺序仍是上游默认序,
|
|
479
|
+
// 所以这是页内排序而不是全库排序(UI 侧「最多下载」的含义随之收窄,见下)。
|
|
480
|
+
if (!remoteSort) rows.sort((a, b) => b.downloads - a.downloads);
|
|
441
481
|
return {
|
|
442
482
|
page,
|
|
443
483
|
pageSize,
|
|
@@ -445,24 +485,7 @@ export async function wbSkillCatalog(payload = {}) {
|
|
|
445
485
|
keyword,
|
|
446
486
|
category,
|
|
447
487
|
total: Number(body?.data?.total) || 0,
|
|
448
|
-
skills:
|
|
449
|
-
slug: String(skill.slug || ""),
|
|
450
|
-
name: String(skill.name || skill.slug || ""),
|
|
451
|
-
namespace: String(skill.namespace?.handle || ""),
|
|
452
|
-
canonical: String(skill.namespace?.canonicalName || ""),
|
|
453
|
-
description: String(skill.description_zh || skill.description || ""),
|
|
454
|
-
descriptionEn: String(skill.description_en || ""),
|
|
455
|
-
downloads: Number(skill.downloads) || 0,
|
|
456
|
-
installs: Number(skill.installs) || 0,
|
|
457
|
-
stars: Number(skill.stars) || 0,
|
|
458
|
-
score: Number(skill.score) || 0,
|
|
459
|
-
version: String(skill.version || ""),
|
|
460
|
-
verified: !!skill.verified,
|
|
461
|
-
updatedAt: Number(skill.updated_at) || 0,
|
|
462
|
-
category: String(skill.category || ""),
|
|
463
|
-
tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
|
|
464
|
-
upstreamUrl: String(skill.upstream_url || skill.homepage || ""),
|
|
465
|
-
})).filter((skill) => skill.slug && skill.namespace),
|
|
488
|
+
skills: rows,
|
|
466
489
|
};
|
|
467
490
|
}
|
|
468
491
|
export async function wbSkillDetail(payload = {}) {
|
package/lib/wb-registry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 职责(对应设计 §8.3,类图 MarketRegistry):
|
|
4
4
|
// - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
|
|
5
5
|
// 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
|
|
6
6
|
// codebuddy-plugins-official 插件源;首读时把默认项落盘。
|