cortico-world-workbuddy 0.3.0 → 0.3.4

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.
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 0.3.1 回归断言(0.3.0 审查报告新发现的问题)。
4
+ *
5
+ * 由 scripts/contract.mjs 调用:await runRegression(ok, { pkgDir })。
6
+ * 单独成文件是为了让「这次新补的防线」和原有 68 条基线一眼分得开;
7
+ * 它自带依赖与临时目录,不改动 contract.mjs 里的其它断言。
8
+ */
9
+ import { createServer } from 'node:http';
10
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+
14
+ import { WB_DEFAULTS } from '../src/config.js';
15
+ import { attachPromos, parsePromos, priceViewOf } from '../src/promo.js';
16
+ import { UsageLedger } from '../src/usage.js';
17
+ import { WbUpstream, WbUpstreamError } from '../src/upstream.js';
18
+ import { candidateAppExecutables } from '../src/credentials.js';
19
+ import { TOKEN_AUTO, TOKEN_CLEARED, WorkBuddyWorld } from '../src/world.js';
20
+ import { cleanWorkbuddyResidue, WORKBUDDY_DATA_FILES } from './clean-workbuddy.mjs';
21
+
22
+ /** 造一个可记录密钥库写入的最小 World,避免碰真机凭据。 */
23
+ function makeWorld(initialSecret, credentialsDir) {
24
+ let stored = initialSecret;
25
+ const writes = [];
26
+ const ctx = {
27
+ id: 'workbuddy',
28
+ cfg: { ...WB_DEFAULTS, credentialsDir, model: 'deepseek-v4.1-flash' },
29
+ timezone: 'Asia/Shanghai',
30
+ botName: 'Coo',
31
+ botDir: credentialsDir,
32
+ packageDir: '',
33
+ dataDir: mkdtempSync(join(tmpdir(), 'wb-reg-data-')),
34
+ repoRoot: '',
35
+ secret: () => stored,
36
+ storeSecret: (name, value) => {
37
+ writes.push([name, value]);
38
+ stored = value;
39
+ },
40
+ persist: () => {},
41
+ restart: async () => {},
42
+ };
43
+ return { world: new WorkBuddyWorld({ ctx }), writes, getStored: () => stored };
44
+ }
45
+
46
+ export async function runRegression(ok, { pkgDir } = {}) {
47
+ console.log('· 0.3.1 回归防线(审查报告新发现)');
48
+
49
+ /* ---- 1. /v3/config 401/403:分类、状态码、不降级、标记不残留 ---------- */
50
+ let configHits = 0;
51
+ let legacyHits = 0;
52
+ const authServer = createServer((req, res) => {
53
+ if (req.url.startsWith('/v3/config')) {
54
+ configHits += 1;
55
+ if (configHits === 1) {
56
+ res.writeHead(401, { 'Content-Type': 'application/json' });
57
+ res.end('{"msg":"unauthorized"}');
58
+ } else {
59
+ res.writeHead(200, { 'Content-Type': 'application/json' });
60
+ res.end(JSON.stringify({ data: { models: [{ id: 'glm-5.2', credits: 'x0.79' }] } }));
61
+ }
62
+ return;
63
+ }
64
+ legacyHits += 1;
65
+ res.writeHead(200, { 'Content-Type': 'application/json' });
66
+ res.end('{"data":{"models":[]}}');
67
+ });
68
+ await new Promise((resolve) => authServer.listen(0, '127.0.0.1', resolve));
69
+ const authUrl = `http://127.0.0.1:${authServer.address().port}`;
70
+ const authUp = new WbUpstream({ baseUrl: authUrl });
71
+ let deadErr = null;
72
+ try {
73
+ await authUp.fetchCatalog({ accessToken: 'dead' });
74
+ } catch (err) {
75
+ deadErr = err;
76
+ }
77
+ ok(deadErr instanceof WbUpstreamError, '401 抛的是 WbUpstreamError', String(deadErr));
78
+ ok(deadErr?.kind === 'session_dead', '401 的 kind 是 session_dead(0.3.0 传错参数恒为 server)', deadErr?.kind);
79
+ ok(deadErr?.status === 401, '401 的 status 是 401(不是 0)', deadErr?.status);
80
+ ok(configHits === 1, '401 只请求 /v3/config 一次', configHits);
81
+ ok(legacyHits === 0, '401 不降级到 legacy 目录', legacyHits);
82
+
83
+ // 同一个 WbUpstream 复用:上一次的 401 不能污染下一次成功请求
84
+ const second = await authUp.fetchCatalog({ accessToken: 'ok' });
85
+ authServer.close();
86
+ ok(second.source === 'v3-config', '复用 WbUpstream 时上次的 401 标记被重置', second.source);
87
+ ok(legacyHits === 0, '两次请求都没有降级到 legacy', legacyHits);
88
+
89
+ /* ---- 2. 写错的 daily 时段不能当成全天打折 ------------------------------ */
90
+ const brokenPromos = parsePromos(
91
+ [
92
+ {
93
+ id: 'broken-window',
94
+ kind: 'discount',
95
+ enabled: true,
96
+ priority: 100,
97
+ modelIds: ['glm-5.2'],
98
+ discount: { discountedCredits: '0.10x', displayMode: 'replace' },
99
+ schedule: { timezone: 'Asia/Shanghai', daily: [{ start: 'WHAT', end: 'NOPE' }] },
100
+ },
101
+ ],
102
+ [],
103
+ );
104
+ const brokenModel = attachPromos([{ id: 'glm-5.2', credits: 'x0.79' }], brokenPromos)[0];
105
+ ok(brokenPromos[0].schedule?.dailyUnusable === true, '无效时段被标成 dailyUnusable', brokenPromos[0].schedule);
106
+ const brokenNoon = priceViewOf(brokenModel, Date.parse('2026-11-03T12:00:00+08:00'));
107
+ ok(brokenNoon.effective === 0.79, '时段读不懂时按目录价,不误报折扣', brokenNoon.effective);
108
+ ok(brokenNoon.promoId === undefined, '时段读不懂时不挂促销 id', brokenNoon.promoId);
109
+
110
+ /* ---- 3. rediscover 不覆盖手动粘贴的 token ------------------------------ */
111
+ const emptyDir = mkdtempSync(join(tmpdir(), 'wb-reg-empty-'));
112
+ const manualW = makeWorld('manual-token-value', emptyDir);
113
+ const manualResult = await manualW.world.handleInvoke('account', 'rediscover', [], 'zh');
114
+ ok(manualW.getStored() === 'manual-token-value', '手动 token 下 rediscover 不覆盖密钥库', manualW.getStored());
115
+ ok(manualW.writes.length === 0, '手动 token 下 rediscover 完全不写密钥库', manualW.writes);
116
+ ok(
117
+ manualResult.authed === true && manualResult.source === 'manual',
118
+ '手动 token 下 rediscover 仍显示 manual 已登录',
119
+ manualResult,
120
+ );
121
+
122
+ const liveDir = mkdtempSync(join(tmpdir(), 'wb-reg-live-'));
123
+ writeFileSync(join(liveDir, 'workbuddy-desktop.info'), JSON.stringify({ auth: { accessToken: 'auto-found' } }));
124
+ const clearedW = makeWorld(TOKEN_CLEARED, liveDir);
125
+ const clearedResult = await clearedW.world.handleInvoke('account', 'rediscover', [], 'zh');
126
+ ok(clearedW.getStored() === TOKEN_AUTO, '登出哨兵 + 探测成功 → 改写为 wb-auto', clearedW.getStored());
127
+ ok(clearedResult.authed === true, '重新探测后恢复登录态', clearedResult);
128
+
129
+ const clearedEmptyW = makeWorld(TOKEN_CLEARED, emptyDir);
130
+ const clearedEmptyResult = await clearedEmptyW.world.handleInvoke('account', 'rediscover', [], 'zh');
131
+ ok(clearedEmptyW.getStored() === TOKEN_CLEARED, '探测失败时保留 wb-cleared 登出哨兵', clearedEmptyW.getStored());
132
+ ok(clearedEmptyResult.authed === false, '探测失败时仍是未登录', clearedEmptyResult);
133
+
134
+ const clearW = makeWorld('manual-token-value', emptyDir);
135
+ await clearW.world.handleInvoke('account', 'clearToken', [], 'zh');
136
+ ok(/重新探测/.test(String(clearW.world.lastError ?? '')), 'clearToken 当下就给可操作提示', clearW.world.lastError);
137
+
138
+ /* ---- 4. 跨午夜后「今日积分」归零 -------------------------------------- */
139
+ const rollW = makeWorld(TOKEN_AUTO, emptyDir);
140
+ rollW.world.today = { day: '2020-01-01', credit: 12.5, calls: 3, prompt: 100, completion: 50 };
141
+ const rolledVars = rollW.world.envPromptVars();
142
+ ok(rolledVars['workbuddy.todayCredit'] === '0', '跨日后提示词里的今日积分归零', rolledVars['workbuddy.todayCredit']);
143
+ ok(rollW.world.today.day !== '2020-01-01', '跨日后 today.day 翻到当天', rollW.world.today.day);
144
+ const rolledUi = rollW.world.webUi('zh');
145
+ ok(rolledUi.badges.find((b) => b.label === '今日积分')?.value === '0', '跨日后详情页徽标归零');
146
+ // 断言必须跟宿主机时区走:原先硬编码 +08 的 00:30,在 UTC 的 CI 上会算成前一日而红叉。
147
+ // 用本地 getter 拼出期望值,仍能抓住「改用 toISOString 的 UTC 日期」这类回归。
148
+ const midnight = new Date(2026, 10, 3, 0, 30, 0);
149
+ const localLabel = `${midnight.getFullYear()}-${String(midnight.getMonth() + 1).padStart(2, '0')}-${String(
150
+ midnight.getDate(),
151
+ ).padStart(2, '0')}`;
152
+ ok(
153
+ rollW.world.localDay(midnight.getTime()) === localLabel,
154
+ 'localDay 按宿主机本地日历取日(不是 toISOString 的 UTC 日期)',
155
+ rollW.world.localDay(midnight.getTime()),
156
+ );
157
+
158
+ /* ---- 5. 自动解封覆盖两种安装形态 -------------------------------------- */
159
+ const appExes = candidateAppExecutables();
160
+ ok(
161
+ process.platform !== 'win32' || appExes.some((p) => p.endsWith('WorkBuddy AI.exe')),
162
+ 'candidateAppExecutables 覆盖 WorkBuddy AI.exe',
163
+ appExes.slice(0, 4),
164
+ );
165
+
166
+ /* ---- 6. 余额快照并发写不会互相覆盖 ------------------------------------ */
167
+ const balLedger = new UsageLedger({ dataDir: mkdtempSync(join(tmpdir(), 'wb-reg-bal-')), log: { warn: () => {} } });
168
+ await Promise.all(Array.from({ length: 12 }, (_, i) => balLedger.writeBalance({ total: i, at: i })));
169
+ const bal = await balLedger.readBalance();
170
+ ok(bal !== null && typeof bal.total === 'number', '并发写余额快照后仍是可读的完整 JSON', bal);
171
+
172
+ /* ---- 7. 工具描述 / 面板文案与源码一致 --------------------------------- */
173
+ const describeJson = JSON.parse(readFileSync(join(pkgDir, 'describe.json'), 'utf8'));
174
+ const chatDecl = describeJson.tools.find((t) => t.name === 'wb_chat');
175
+ ok(
176
+ chatDecl?.description?.includes('Requires the WorkBuddy desktop app to be running') === true,
177
+ 'wb_chat 描述带「需桌面端在运行」前提',
178
+ chatDecl?.description,
179
+ );
180
+ ok(
181
+ !/Home page/.test(readFileSync(join(pkgDir, 'src', 'webui', 'client.js'), 'utf8')),
182
+ '英文面板不再提已删除的开始页按钮',
183
+ );
184
+ const argsW = makeWorld(TOKEN_AUTO, emptyDir);
185
+ let argsErr = null;
186
+ try {
187
+ await argsW.world.tools()[2].handler(undefined);
188
+ } catch (err) {
189
+ argsErr = err;
190
+ }
191
+ ok(argsErr === null, 'tools() handler 收到 undefined args 不抛 TypeError', String(argsErr));
192
+
193
+ /* ---- 8. 0.3.4 移除开关后:未选模型也能列目录,提示词不再命令每轮调用 -------- */
194
+ const noModelW = makeWorld(TOKEN_AUTO, emptyDir);
195
+ noModelW.world.cfg.model = '';
196
+ noModelW.world.proc = { running: true, pids: [1], mainPid: 1, via: 'test', detail: '', at: Date.now() };
197
+ noModelW.world.tokens = { accessToken: 'test-only', source: 'test' };
198
+ ok(/目录为空/.test(noModelW.world.toolListModels({})), '空目录时 wb_list_models 给出可读提示(不再依赖任何开关)');
199
+ const vars = noModelW.world.envPromptVars();
200
+ ok(
201
+ vars['workbuddy.process'] === 'running' && vars['workbuddy.model'] === '(none selected)',
202
+ '未选模型时环境提示词如实报告进程与占位模型',
203
+ vars,
204
+ );
205
+ ok(!('workbuddy.priority' in vars), '环境提示词变量里不再有 workbuddy.priority');
206
+
207
+ /* ---- 9. 显式卸载清理只删本扩展残留,不动其他配置与密钥 ---------------- */
208
+ const cleanBotDir = mkdtempSync(join(tmpdir(), 'wb-clean-bot-'));
209
+ const cleanDataDir = mkdtempSync(join(tmpdir(), 'wb-clean-data-'));
210
+ writeFileSync(join(cleanBotDir, 'config.json'), JSON.stringify({ worlds: { workbuddy: { enabled: true }, other: { enabled: true } }, keep: 7 }, null, 2));
211
+ writeFileSync(join(cleanBotDir, '.env'), 'OTHER_SECRET=keep\naccessToken=not-printed\n');
212
+ for (const name of WORKBUDDY_DATA_FILES) writeFileSync(join(cleanDataDir, name), name === 'workbuddy-usage.jsonl' ? '' : '{}');
213
+ const checked = cleanWorkbuddyResidue({ botDir: cleanBotDir, dataDir: cleanDataDir, clean: false });
214
+ ok(checked.before.configPresent && checked.before.envPresent && checked.before.dataFiles.length === 3, '清理前能识别配置、凭据与三份数据文件');
215
+ const cleaned = cleanWorkbuddyResidue({ botDir: cleanBotDir, dataDir: cleanDataDir, clean: true });
216
+ ok(!cleaned.after.configPresent && !cleaned.after.envPresent && cleaned.after.dataFiles.length === 0, '显式清理后本扩展无配置、凭据与数据残留');
217
+ const remainingConfig = JSON.parse(readFileSync(join(cleanBotDir, 'config.json'), 'utf8'));
218
+ const remainingEnv = readFileSync(join(cleanBotDir, '.env'), 'utf8');
219
+ ok(remainingConfig.keep === 7 && remainingConfig.worlds.other.enabled === true, '清理保留宿主其他配置', remainingConfig);
220
+ ok(remainingEnv === 'OTHER_SECRET=keep\n', '清理只移除 accessToken 行,保留其他密钥', remainingEnv);
221
+ }
@@ -24,6 +24,7 @@ import { classifyModel, CAUTION_MODEL_IDS, SAFE_MODEL_IDS } from '../src/safety.
24
24
  import { UsageLedger, dayKey, summarize } from '../src/usage.js';
25
25
  import { classifyHttpFailure, WbUpstream } from '../src/upstream.js';
26
26
  import { PANEL_METHODS, TOKEN_AUTO, TOKEN_CLEARED, WorkBuddyWorld } from '../src/world.js';
27
+ import { runRegression } from './contract-regression.mjs';
27
28
 
28
29
  const pkgDir = fileURLToPath(new URL('..', import.meta.url));
29
30
  const verbose = process.argv.includes('--verbose');
@@ -86,6 +87,7 @@ const used = new Set([...describe.envPrompt.matchAll(/\{\{(workbuddy\.[A-Za-z]+)
86
87
  const missing = [...used].filter((name) => !declared.has(name));
87
88
  ok(missing.length === 0, 'envPrompt 里每个 {{workbuddy.x}} 都有对应变量', missing);
88
89
  ok(used.size >= 8, `envPrompt 用到了 ${used.size} 个变量`);
90
+ ok(!used.has('workbuddy.priority'), '环境提示词不再引用已删除的「中转站优先」开关', [...used]);
89
91
 
90
92
  const toolNames = describe.tools.map((t) => t.name).sort();
91
93
  ok(
@@ -175,6 +177,23 @@ ok(numeric.promoId === 'numeric', '数字时间戳(Unix 秒)的有效期被
175
177
  const badZone = priceViewOf(models[2], NOW);
176
178
  ok(badZone.promoId === 'bad-zone', '非法时区退回 UTC 后窗口仍生效', badZone.promoId);
177
179
 
180
+ const defaultZonePromo = parsePromos([{
181
+ id: 'default-zone', modelIds: ['glm-5.2'], discount: { discountedCredits: '0.50x', displayMode: 'replace' },
182
+ schedule: { daily: [{ start: '23:00', end: '7:50' }] },
183
+ }], []);
184
+ const defaultZoneModel = attachPromos([{ id: 'glm-5.2', credits: 'x0.79' }], defaultZonePromo)[0];
185
+ ok(priceViewOf(defaultZoneModel, NOW).effective === 0.5, 'daily 省略 timezone 时按 Asia/Shanghai 判定', priceViewOf(defaultZoneModel, NOW));
186
+
187
+ const dateOnlyPromo = parsePromos([{
188
+ id: 'date-only', modelIds: ['glm-5.2'], discount: { discountedCredits: '0.20x', displayMode: 'replace' },
189
+ schedule: { timezone: 'Asia/Shanghai', validFrom: '2026-11-03', validUntil: '2026-11-03' },
190
+ }], []);
191
+ const dateOnlyModel = attachPromos([{ id: 'glm-5.2', credits: 'x0.79' }], dateOnlyPromo)[0];
192
+ ok(priceViewOf(dateOnlyModel, Date.parse('2026-11-03T00:00:00+08:00')).effective === 0.2, '纯日期 validFrom 从当地 00:00 生效', dateOnlyPromo[0].schedule);
193
+ ok(priceViewOf(dateOnlyModel, Date.parse('2026-11-03T09:00:00+08:00')).effective === 0.2, '纯日期 validUntil 覆盖当天 08:00 之后', dateOnlyPromo[0].schedule);
194
+ ok(priceViewOf(dateOnlyModel, Date.parse('2026-11-04T00:00:00+08:00')).effective === 0.79, '纯日期 validUntil 到次日当地 00:00 才失效');
195
+ ok(priceViewOf(dateOnlyModel, Date.parse('2026-11-03T09:00:00+08:00')).absoluteText.endsWith('2026-11-03'), '纯日期有效期展示不写偏一天', priceViewOf(dateOnlyModel, NOW).absoluteText);
196
+
178
197
  /* ------------------------------------------------------------------- 安全 */
179
198
 
180
199
  console.log('· 安全清单');
@@ -208,7 +227,7 @@ const records = [
208
227
  { ts: nowMs - 3 * 864e5, model: 'glm-5.2', ok: true, promptTokens: 100, completionTokens: 50, totalTokens: 150, credit: 1.25, multiplier: 0.5 },
209
228
  { ts: nowMs - 40 * 864e5, model: 'hy3', ok: false, promptTokens: 0, completionTokens: 0, totalTokens: 0, credit: 0 },
210
229
  ];
211
- const summary = summarize(records, nowMs, { days: 30 });
230
+ const summary = summarize(records, nowMs);
212
231
  ok(summary.totals.today.calls === 1, '今天只统计今天那条', summary.totals.today.calls);
213
232
  ok(summary.totals.week.calls === 2, '近 7 天含 3 天前那条', summary.totals.week.calls);
214
233
  ok(summary.totals.month.calls === 2, '近 30 天不含 40 天前那条', summary.totals.month.calls);
@@ -220,6 +239,10 @@ ok(
220
239
  '最近记录的字段名是 total/prompt/completion(面板按这三个名字读)',
221
240
  );
222
241
  ok(summary.byModel[0].model === 'glm-5.2', '按模型聚合按积分降序', summary.byModel.map((m) => m.model));
242
+ const creditGap = summarize([{ ts: nowMs, model: 'glm-5.2', ok: true, totalTokens: 3 }], nowMs);
243
+ ok(creditGap.totals.today.creditUnknown === 1, '上游未回传 credit 时计为未知,不静默算 0', creditGap.totals.today);
244
+ ok(creditGap.recent[0].credit === null, '最近调用的未知 credit 保持 null,与明确 0 区分', creditGap.recent[0]);
245
+ ok(!('windowDays' in summary) && !('spanDays' in summary), '未接线的 days/spanDays 参数与字段已删除');
223
246
 
224
247
  /* --------------------------------------------------------------- 回归防线 */
225
248
 
@@ -263,12 +286,10 @@ ok(
263
286
  autoWorld.lastError,
264
287
  );
265
288
 
266
- // 计费域:按区域,不按 baseUrl 猜;自定义 baseUrl 时跟着走同一出口
267
- const cnUp = new WbUpstream({ baseUrl: 'https://copilot.tencent.com', region: 'cn' });
268
- ok(cnUp.billingUrl('/x').startsWith('https://www.codebuddy.cn'), 'cn 计费域是 www.codebuddy.cn', cnUp.billingUrl('/x'));
269
- const globalUp = new WbUpstream({ baseUrl: 'https://www.workbuddy.ai', region: 'global' });
270
- ok(globalUp.billingUrl('/x').startsWith('https://www.workbuddy.ai'), 'global 计费域是 www.workbuddy.ai', globalUp.billingUrl('/x'));
271
- const proxyUp = new WbUpstream({ baseUrl: 'https://proxy.example.com', region: 'cn' });
289
+ // 计费域:固定国内计费域,不按 baseUrl 猜;自定义 baseUrl 时跟着走同一出口
290
+ const cnUp = new WbUpstream({ baseUrl: 'https://copilot.tencent.com' });
291
+ ok(cnUp.billingUrl('/x').startsWith('https://www.codebuddy.cn'), '默认计费域是 www.codebuddy.cn', cnUp.billingUrl('/x'));
292
+ const proxyUp = new WbUpstream({ baseUrl: 'https://proxy.example.com' });
272
293
  ok(proxyUp.billingUrl('/x') === 'https://proxy.example.com/x', '自定义 baseUrl 时计费走同一出口', proxyUp.billingUrl('/x'));
273
294
 
274
295
  // 429 的软/硬区分
@@ -281,11 +302,15 @@ ok(
281
302
  '账号面板允许 setToken',
282
303
  );
283
304
  ok(!PANEL_METHODS.usage.includes('setToken'), '用量面板不能间接写密钥库(白名单生效)');
284
- ok(
285
- !PANEL_METHODS.priority.includes('host') && !PANEL_METHODS.priority.includes('repair'),
286
- '0.3.0 删掉主页注入后,priority 面板不再有 host/repair',
287
- PANEL_METHODS.priority,
288
- );
305
+ ok(!('priority' in PANEL_METHODS), '0.3.4 移除「中转站优先」面板后不再有 priority 方法表', Object.keys(PANEL_METHODS));
306
+
307
+ // 0.3.4:界面只剩 3 个面板,也不再声明 storage(宿主会据此生成空白「数据清理」页签)
308
+ const uiWorld = makeWorld(TOKEN_CLEARED);
309
+ const ui = uiWorld.webUi('zh');
310
+ ok(ui.panels.map((p) => p.id).join(',') === 'models,account,usage', '0.3.4 面板只剩 models/account/usage', ui.panels.map((p) => p.id));
311
+ ok(Array.isArray(ui.lamps) && ui.lamps.length === 2, '灯态只保留 WorkBuddy + 主进程两盏', ui.lamps?.length);
312
+ ok(!('storage' in ui), '不再声明 storage(否则宿主会生成空的「数据清理」页签)');
313
+ ok(!('prefer' in uiWorld.cfg), '配置里不再有 prefer 开关字段', Object.keys(uiWorld.cfg));
289
314
 
290
315
  // 账本并发:append 与 prune 串行,不互相覆盖
291
316
  const ledger = new UsageLedger({ dataDir: mkdtempSync(join(tmpdir(), 'wb-ledger-')), log: { warn: () => {} } });
@@ -306,21 +331,21 @@ ok(/^\d{4}-\d{2}-\d{2}$/.test(localDay), 'dayKey 输出 YYYY-MM-DD', localDay);
306
331
  // SSE:最后一段 data 没有换行时也要把 usage 处理掉(否则积分恒 0)
307
332
  const sseServer = createServer((req, res) => {
308
333
  res.writeHead(200, { 'Content-Type': 'text/event-stream' });
309
- res.write('data: {"code":0,"choices":[{"delta":{"content":"嗨"}}]}\n\n');
310
- // 末尾故意不带换行:老实现会把这段丢掉
311
- res.write('data: {"code":0,"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10,"credit":0.02}}');
334
+ res.write('data: {"code":0,"choices":[{"delta":{"content":"嗨"}}],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}\n\n');
335
+ // 末尾只带 credit 且故意不带换行:既不能丢尾帧,也不能覆盖前帧 token。
336
+ res.write('data: {"code":0,"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"credit":0.02}}');
312
337
  res.end();
313
338
  });
314
339
  await new Promise((resolve) => sseServer.listen(0, '127.0.0.1', resolve));
315
340
  const sseUrl = `http://127.0.0.1:${sseServer.address().port}`;
316
- const sseUp = new WbUpstream({ baseUrl: sseUrl, region: 'cn' });
341
+ const sseUp = new WbUpstream({ baseUrl: sseUrl });
317
342
  const chatOut = await sseUp.chat({ accessToken: 'test-token' }, { model: 'deepseek-v4.1-flash', messages: [{ role: 'user', content: 'hi' }], maxTokens: 64 }, () => {});
318
343
  sseServer.close();
319
344
  ok(chatOut.text === '嗨', 'SSE 正文拼接正确', chatOut.text);
320
345
  ok(chatOut.usage?.totalTokens === 10, 'SSE 尾行(无换行)里的 usage 被解析', chatOut.usage);
321
346
  ok(chatOut.credit === 0.02, 'SSE 尾行里的 credit 被解析', chatOut.credit);
322
347
 
323
- // start() 兜底:pushEvent 失败也不能把 started 卡住(否则四盏灯永远「正在读取登录态…」)
348
+ // start() 兜底:pushEvent 失败也不能把 started 卡住(否则两盏灯永远「正在读取登录态…」)
324
349
  const startWorld = makeWorld(TOKEN_CLEARED);
325
350
  const failingHost = {
326
351
  log: { info: () => {}, warn: () => {}, error: () => {} },
@@ -330,6 +355,9 @@ await startWorld.start(failingHost);
330
355
  ok(startWorld.started === true, 'pushEvent 抛错时 start() 仍标记 started(灯态不卡住)');
331
356
  await startWorld.stop();
332
357
 
358
+ // 0.3.1 审查报告新发现的回归防线(单独成文件,便于与上面 68 条基线区分)
359
+ await runRegression(ok, { pkgDir });
360
+
333
361
  /* ------------------------------------------------------------------- 结果 */
334
362
 
335
363
  console.log('');
@@ -89,9 +89,20 @@ if ($NoPack) {
89
89
  New-Item -ItemType Directory -Force $OutDir | Out-Null
90
90
  Push-Location $PackageDir
91
91
  try {
92
- $out = & $npmCmd.Source pack --pack-destination $OutDir 2>&1 | Out-String
93
- if ($LASTEXITCODE -ne 0) { Write-Host $out -ForegroundColor Yellow }
94
- $packed = ($LASTEXITCODE -eq 0) -and (Test-Path $tgz)
92
+ # npm 把 notice 写进 stderr;Windows PowerShell 5.1 在 $ErrorActionPreference='Stop'
93
+ # 下遇到原生命令的 stderr 会抛 NativeCommandError,把正常的 pack 误判成失败,
94
+ # 静默退回手工 tar、绕过 prepack(0.3.1 修复,审查报告第 6 条的真正触发点)。
95
+ # 临时降到 Continue,用真实退出码判断,绝不让一行 notice 改变打包路径。
96
+ $prevEap = $ErrorActionPreference
97
+ $ErrorActionPreference = 'Continue'
98
+ try {
99
+ $out = & $npmCmd.Source pack --pack-destination $OutDir 2>&1 | Out-String
100
+ $packCode = $LASTEXITCODE
101
+ } finally {
102
+ $ErrorActionPreference = $prevEap
103
+ }
104
+ if ($packCode -ne 0) { Write-Host $out -ForegroundColor Yellow }
105
+ $packed = ($packCode -eq 0) -and (Test-Path $tgz)
95
106
  } catch {
96
107
  $packed = $false
97
108
  } finally { Pop-Location }
@@ -128,7 +139,7 @@ if (-not (Test-Path (Join-Path $packRoot 'package.json'))) { Fail "tgz 里没有
128
139
 
129
140
  $packedPkg = Get-Content -Raw -Encoding UTF8 (Join-Path $packRoot 'package.json') | ConvertFrom-Json
130
141
  if ($packedPkg.version -ne $version) { Fail "tgz 里的版本是 $($packedPkg.version),与源码 $version 不一致(先 bump 或重新打包)" }
131
- if (-not (Test-Path (Join-Path $packRoot 'ENV_PROMPT.md'))) { } # 0.2.0 起根 ENV_PROMPT.md 已删除,模板只在 src/ 下
142
+ # 根 ENV_PROMPT.md 自 0.2.0 起已删除,模板只在 src/ 下;这里无需再检查。
132
143
 
133
144
  # --- 3) 卸载旧的,再装新的 ------------------------------------------------
134
145
  Push-Location $extDir
@@ -27,7 +27,7 @@ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
27
27
  /** 没有宿主时能做的检查:语法 + 契约测试。全通过才返回 true。 */
28
28
  function offlineChecks() {
29
29
  const files = [];
30
- for (const dir of ['src', 'src/webui', 'scripts']) {
30
+ for (const dir of ['src', 'src/webui', 'scripts', 'scripts/lib']) {
31
31
  const abs = join(pkgDir, dir);
32
32
  if (!existsSync(abs)) continue;
33
33
  for (const name of readdirSync(abs)) {
@@ -114,7 +114,7 @@ async function api(path, init) {
114
114
  const res = await fetch(`http://127.0.0.1:${PROXY_PORT}${path}`, init);
115
115
  return res.json();
116
116
  }
117
- const panel = (method, args) => api(`/api/webui/providers/world%3Aworkbuddy/panels/priority/${method}`, {
117
+ const panel = (name, method, args) => api(`/api/webui/providers/world%3Aworkbuddy/panels/${name}/${method}`, {
118
118
  method: 'POST',
119
119
  headers: { 'content-type': 'application/json', origin: `http://127.0.0.1:${PROXY_PORT}` },
120
120
  body: JSON.stringify({ args }),
@@ -253,11 +253,12 @@ const readHome = `(() => {
253
253
  labels: [...document.querySelectorAll('.home-pickerlabel')].map((n) => n.textContent.trim()) };
254
254
  })()`;
255
255
 
256
- const readWorldPage = `(() => {
257
- const label = [...document.querySelectorAll('*')].find((n) => n.textContent?.trim() === '是否启用Workbuddy' && n.offsetParent !== null);
258
- const box = document.querySelector('input[type="checkbox"]');
259
- return { hash: location.hash, routeMounted: /provider\\/world%3Aworkbuddy/.test(location.hash),
260
- panelRendered: !!label, checkbox: !!box, checked: box?.checked ?? null,
256
+ const readModelsPage = `(() => {
257
+ const th = [...document.querySelectorAll('th')].map((n) => n.textContent.trim());
258
+ const text = document.body.innerText || '';
259
+ return { policy: th.includes('合规'), headers: th.join('|'),
260
+ rows: document.querySelectorAll('table tr').length,
261
+ proc: /WorkBuddy 主进程:(运行中|未检测到)/.test(text),
261
262
  tabs: [...document.querySelectorAll('button,a')].map((n) => n.textContent.trim()).filter((t) => t && t.length < 24) };
262
263
  })()`;
263
264
 
@@ -269,9 +270,6 @@ for (let run = 1; run <= RUNS; run += 1) {
269
270
  const row = { run, checks: {} };
270
271
  consoleErrors.length = 0;
271
272
 
272
- // 归零
273
- await panel('set', [false]);
274
-
275
273
  // --- 开始页:正常渲染,且确认 0.3.0 已不再注入任何按钮 ---
276
274
  await send('Page.navigate', { url: `${HOME}?t=${tag}` });
277
275
  try {
@@ -296,32 +294,22 @@ for (let run = 1; run <= RUNS; run += 1) {
296
294
  await waitFor('World 页完成加载', async () => (await evaluate(`document.readyState`)) === 'complete');
297
295
  await waitFor('World 页路由挂载', async () => evaluate(`/provider\\/world%3Aworkbuddy/.test(location.hash)`));
298
296
  row.checks.worldRoute = true;
299
- await waitFor('页签出现', async () => evaluate(`[...document.querySelectorAll('button,a')].some((n) => n.textContent.trim() === '是否启用 WorkBuddy')`));
297
+ await waitFor('页签出现', async () => evaluate(`[...document.querySelectorAll('button,a')].some((n) => n.textContent.trim() === '模型目录')`));
300
298
  row.checks.worldTabs = true;
301
- await evaluate(clickByText('是否启用 WorkBuddy'));
302
- const pane = await waitFor('面板渲染(复选框)', async () => {
303
- const v = await evaluate(readWorldPage);
304
- return v.panelRendered && v.checkbox ? v : false;
299
+ // 0.3.4:删掉「中转站优先」页签;不再声明 storage,宿主也不会生成空白「数据清理」页签。
300
+ const tabs = await evaluate(`[...document.querySelectorAll('button,a')].map((n) => n.textContent.trim()).filter((t) => t && t.length < 24)`);
301
+ row.checks.noPriorityTab = !tabs.includes('是否启用 WorkBuddy');
302
+ row.checks.noStorageTab = !tabs.includes('数据清理');
303
+ await evaluate(clickByText('模型目录'));
304
+ const modelsHead = await waitFor('模型目录渲染(只读主进程状态)', async () => {
305
+ const v = await evaluate(readModelsPage);
306
+ return v.proc ? v : false;
305
307
  });
306
308
  row.checks.panelRendered = true;
307
- // 勾上
308
- await evaluate(`document.querySelector('input[type="checkbox"]').click()`);
309
- await waitFor('面板勾选 → prefer=true', async () => {
310
- const dom = await evaluate(`document.querySelector('input[type="checkbox"]')?.checked ?? null`);
311
- const api = await panel('state', []);
312
- return dom === true && api.prefer === true ? true : false;
313
- });
314
- row.checks.panelToggleOn = true;
315
- if (SHOTS && run === 1) { await sleep(400); await shot('world-priority-on'); }
316
- await evaluate(`document.querySelector('input[type="checkbox"]').click()`);
317
- await waitFor('面板取消 → prefer=false', async () => {
318
- const dom = await evaluate(`document.querySelector('input[type="checkbox"]')?.checked ?? null`);
319
- const api = await panel('state', []);
320
- return dom === false && api.prefer === false ? true : false;
321
- });
322
- row.checks.panelToggleOff = true;
309
+ row.checks.modelsProcessChip = modelsHead.proc;
310
+ if (SHOTS && run === 1) { await sleep(300); await shot('world-models'); }
323
311
  // --- 用量面板:页签 → 渲染 → 概览表有行 ---
324
- await evaluate(clickByText('用量'));
312
+ await evaluate(clickByText('用量与成本'));
325
313
  const usage = await waitFor('用量面板渲染', async () => {
326
314
  const v = await evaluate(`(() => {
327
315
  const btn = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '刷新余额' && b.offsetParent !== null);
@@ -353,7 +341,6 @@ for (let run = 1; run <= RUNS; run += 1) {
353
341
  row.checks.modelsHasShowAll = modelsView.checkboxes >= 1;
354
342
  if (SHOTS && run === 1) { await sleep(300); await shot('world-models'); }
355
343
  if (KEEP) await shot(`run${run}-world`);
356
- void pane;
357
344
  } catch (err) {
358
345
  row.checks.worldError = String(err.message || err);
359
346
  hardFailures += 1;
@@ -370,7 +357,6 @@ for (let run = 1; run <= RUNS; run += 1) {
370
357
  }
371
358
 
372
359
  // ---------------------------------------------------------------- 收尾
373
- await panel('set', [false]);
374
360
  ws.close();
375
361
  chrome.kill();
376
362
  proxy.close();
package/src/ENV_PROMPT.md CHANGED
@@ -1,19 +1,13 @@
1
1
  ## WorkBuddy bridge
2
2
 
3
- This machine has a WorkBuddy desktop sign-in bridged in (region: {{workbuddy.region}}; signed in: {{workbuddy.authed}}).
3
+ This machine has a signed-in WorkBuddy desktop app bridged in (signed in: {{workbuddy.authed}}).
4
4
  The model catalog holds {{workbuddy.catalogSize}} models. The currently selected model is `{{workbuddy.model}}` with default credit multiplier {{workbuddy.multiplier}}.
5
- WorkBuddy desktop main process: {{workbuddy.process}}. Relay-first switch: {{workbuddy.priority}}.
5
+ WorkBuddy desktop main process: {{workbuddy.process}}.
6
6
  Spent today through the bridge: {{workbuddy.todayCredit}} credits / {{workbuddy.todayTokens}} tokens (locally recorded).
7
- The multiplier above already includes any time-limited discount or free window that is active right now; `wb_list_models` also marks which offers are active and their hours.
7
+ The multiplier above is the model's own price plus any time-limited price change this bridge can verify at the moment (including free windows). An offer that only shows a badge may not change the price; `wb_list_models` marks which offers are active and their hours.
8
8
 
9
9
  - Use `wb_list_models` to see every model with its multiplier before choosing; prefer low-multiplier models for casual tasks.
10
10
  - Safe-list gate: {{workbuddy.safeOnly}}. When it is `on`, only the models WorkBuddy publishes as public chat models are callable — `disabled` entries, internal routing aliases (`auto`, `fast-model`, `balanced-model`, `deep-model`), media/code models and delisted ones are refused. Do not try to work around a refusal; tell the user why and offer a safe model instead. `wb_list_models` with `all=true` shows the refused ones with reasons.
11
11
  - Use `wb_select_model` when the user asks to switch the WorkBuddy model.
12
- - `wb_chat` only works while the WorkBuddy desktop main process is running; otherwise it returns「未检测到 WorkBuddy 主进程」. Every call spends the user's own credits at that model's multiplier.
12
+ - `wb_chat` only works while the WorkBuddy desktop main process is running; otherwise it returns「未检测到 WorkBuddy 主进程」. Every call spends the user's own credits at that model's multiplier. Call it when the user asks for WorkBuddy or when a WorkBuddy model would clearly help; do not route every turn through it.
13
13
  - `wb_credits` is read-only and costs nothing (also requires the desktop process).
14
-
15
- ### Relay first: {{workbuddy.priority}}
16
-
17
- - Switch `on` **and** process `running`: every user turn that expects a conversational answer (question, chat, writing, reasoning, rewrite, translation, summary) must be answered by a WorkBuddy model first — call `wb_chat` (omit `model` to use `{{workbuddy.model}}`), then speak the returned answer back in your own voice and name the model that answered. Only fall back to your own model if the call fails.
18
- - Switch `off` (default): do not call `wb_chat`; answer with your own model. If the user explicitly asks for WorkBuddy, `wb_chat` is still allowed — the desktop process must be running.
19
- - Tool errors and empty answers are not a reason to flip the switch; just say what happened.
package/src/config.js CHANGED
@@ -4,31 +4,27 @@
4
4
  */
5
5
 
6
6
  /**
7
- * @typedef {'cn' | 'global'} WbRegion
8
7
  * @typedef {object} WbConfigSection
9
8
  * @property {boolean} enabled
10
- * @property {WbRegion} region 国内版 / 国际版。
11
9
  * @property {string} model 当前选用的模型 id;空串 = 未选择。
12
10
  * @property {string} credentialsDir 自定义凭据目录(留空 = 自动发现)。
13
- * @property {string} baseUrl 自定义上游 base URL(留空 = 按 region 取官方域名)。
11
+ * @property {string} baseUrl 自定义上游 base URL(留空 = WorkBuddy 官方域名)。
14
12
  * @property {number} maxTokens 单次 wb_chat 的最大输出 token。
15
13
  * @property {boolean} envPrompt 是否把桥接状态写进 system 前缀的环境段。
16
- * @property {boolean} prefer 「中转站优先」:桌宠每轮对话是否先走 WorkBuddy 模型。默认关。
17
14
  * @property {boolean} safeOnly 只允许调用安全清单内的公开对话模型。默认开。
18
15
  * 注意:手动粘贴的 access token 走 ctx.storeSecret(),不落配置。
19
- * 0.3.0 起不再有 hostInject:扩展不再改写宿主任何文件,「中转站优先」只在 World 详情页开关。
16
+ * 0.3.0 起不再有 hostInject:扩展不再改写宿主任何文件。
17
+ * 0.3.2 起只桥接本机已登录的 WorkBuddy 客户端。
18
+ * 0.3.4 起移除「中转站优先」开关:wb_* 工具按需调用,不再每轮强制走 WorkBuddy。
20
19
  */
21
20
 
22
21
  export const WB_DEFAULTS = {
23
22
  enabled: false,
24
- region: 'cn',
25
23
  model: '',
26
24
  credentialsDir: '',
27
25
  baseUrl: '',
28
26
  maxTokens: 2048,
29
27
  envPrompt: true,
30
- /** 由 World 详情页开关写入(0.3.0 起不再有开始页按钮)。 */
31
- prefer: false,
32
28
  /**
33
29
  * 只允许调用 WorkBuddy 公开对话模型(安全清单)。
34
30
  * 关掉后能用 disabled / 内部别名 / 预览等模型,但那属于「已知会违反对方使用建议」的操作,
@@ -45,13 +41,6 @@ export const WB_CONFIG_GROUP = {
45
41
  type: 'object',
46
42
  title: 'WorkBuddy 桥接',
47
43
  properties: {
48
- 'worlds.workbuddy.region': {
49
- type: 'string',
50
- title: '区域',
51
- description: '国内版(copilot.tencent.com)或国际版(workbuddy.ai),按你登录的客户端选择。改动后需重新读取模型目录。',
52
- enum: ['cn', 'global'],
53
- 'x-hot': true,
54
- },
55
44
  'worlds.workbuddy.model': {
56
45
  type: 'string',
57
46
  title: '当前模型',
@@ -67,7 +56,7 @@ export const WB_CONFIG_GROUP = {
67
56
  'worlds.workbuddy.baseUrl': {
68
57
  type: 'string',
69
58
  title: '上游地址(可选)',
70
- description: '自定义 WorkBuddy 上游 base URL,留空按区域取官方域名。代理 / 中转场景使用;改完重启生效。',
59
+ description: '自定义 WorkBuddy 上游 base URL,留空使用WorkBuddy 官方域名(copilot.tencent.com)。代理 / 中转场景使用;改完重启生效。',
71
60
  'x-hot': false,
72
61
  },
73
62
  'worlds.workbuddy.maxTokens': {
@@ -28,7 +28,7 @@ const FRAMING_FIELD = 2;
28
28
  const FORMAT_FIELD = 'WBEV1';
29
29
 
30
30
  /**
31
- * WorkBuddy 数据目录候选(按平台)。国内版与国际版都扫。
31
+ * WorkBuddy 数据目录候选(按平台)。
32
32
  * @returns {string[]}
33
33
  */
34
34
  export function candidateCredentialDirs() {
@@ -294,11 +294,19 @@ export function resolveTokens(credential, payloadJson = process.env.WORKBUDDY_AT
294
294
  export function candidateAppExecutables() {
295
295
  const exe = [];
296
296
  if (platform() === 'win32') {
297
+ // 桌面端有 WorkBuddy / WorkBuddy AI 两种安装形态,目录名与 exe 名都可能带 AI:
298
+ // 只找 WorkBuddy.exe 时,另一种形态拿不到 at-rest 密钥(审查报告第 9 条)。
299
+ const dirs = ['WorkBuddy', 'WorkBuddy AI'];
300
+ const bins = ['WorkBuddy.exe', 'WorkBuddy AI.exe'];
297
301
  for (const env of ['LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMW6432', 'PROGRAMFILES(X86)']) {
298
302
  const root = process.env[env];
299
303
  if (!root) continue;
300
- exe.push(join(root, 'Programs', 'WorkBuddy', 'WorkBuddy.exe'));
301
- exe.push(join(root, 'WorkBuddy', 'WorkBuddy.exe'));
304
+ for (const dir of dirs) {
305
+ for (const bin of bins) {
306
+ exe.push(join(root, 'Programs', dir, bin));
307
+ exe.push(join(root, dir, bin));
308
+ }
309
+ }
302
310
  }
303
311
  }
304
312
  if (platform() === 'darwin') {
package/src/definition.js CHANGED
@@ -7,7 +7,7 @@ export const WORKBUDDY = {
7
7
  id: 'workbuddy',
8
8
  label: 'WorkBuddy 桥接',
9
9
  defaults: () => ({ ...WB_DEFAULTS }),
10
- // ctx.cfg 是 worlds.workbuddy 的活引用:region / model / maxTokens 等热改即生效。
10
+ // ctx.cfg 是 worlds.workbuddy 的活引用:model / maxTokens 等热改即生效。
11
11
  create: (ctx) => new WorkBuddyWorld({ ctx }),
12
12
  // 自描述:可能出现的全部工具与会推出的全部事件,不进提示词。
13
13
  describe: {
@@ -21,8 +21,8 @@ export const WORKBUDDY = {
21
21
  en: 'Selects the default WorkBuddy model for wb_chat.',
22
22
  },
23
23
  wb_chat: {
24
- zh: '用选中的 WorkBuddy 模型回答一个单轮问题(消耗本人账号积分)。',
25
- en: 'Asks the selected WorkBuddy model a single-turn question (spends your own credits).',
24
+ zh: '用选中的 WorkBuddy 模型回答一个单轮问题(消耗本人账号积分;需桌面端主进程在运行)。',
25
+ en: 'Asks the selected WorkBuddy model a single-turn question (spends your own credits). Requires the WorkBuddy desktop app to be running.',
26
26
  },
27
27
  wb_credits: {
28
28
  zh: '只读查询当前账号的 WorkBuddy 积分余额。',