dsh-llm-codebuddy-power 1.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.
Files changed (44) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +66 -0
  3. package/README.zh.md +66 -0
  4. package/cordis.patch.yml +32 -0
  5. package/lib/cli-login.js +2099 -0
  6. package/lib/client.js +2814 -0
  7. package/lib/index.js +3957 -0
  8. package/lib/types/adapter.d.ts +58 -0
  9. package/lib/types/atomic-file.d.ts +43 -0
  10. package/lib/types/auth-service.d.ts +195 -0
  11. package/lib/types/cli/login.d.ts +23 -0
  12. package/lib/types/client/AccountSwitcher.d.ts +53 -0
  13. package/lib/types/client/CodeBuddySection.d.ts +62 -0
  14. package/lib/types/client/ComposerControls.d.ts +29 -0
  15. package/lib/types/client/DrawToolCard.d.ts +24 -0
  16. package/lib/types/client/ModelPanel.d.ts +77 -0
  17. package/lib/types/client/TurnCreditPill.d.ts +33 -0
  18. package/lib/types/client/UsageIndicator.d.ts +21 -0
  19. package/lib/types/client/icons.d.ts +24 -0
  20. package/lib/types/client/index.d.ts +34 -0
  21. package/lib/types/client/locales.d.ts +90 -0
  22. package/lib/types/client/poll.d.ts +19 -0
  23. package/lib/types/client/store.d.ts +59 -0
  24. package/lib/types/client/wire.d.ts +147 -0
  25. package/lib/types/codebuddy.d.ts +187 -0
  26. package/lib/types/constants.d.ts +67 -0
  27. package/lib/types/credit-store.d.ts +25 -0
  28. package/lib/types/image-tool.d.ts +34 -0
  29. package/lib/types/index.d.ts +79 -0
  30. package/lib/types/login.d.ts +43 -0
  31. package/lib/types/model-enrich.d.ts +125 -0
  32. package/lib/types/prefs.d.ts +43 -0
  33. package/lib/types/rpc-route.d.ts +25 -0
  34. package/lib/types/selection.d.ts +35 -0
  35. package/lib/types/serialize.d.ts +37 -0
  36. package/lib/types/session-store.d.ts +63 -0
  37. package/lib/types/session.d.ts +415 -0
  38. package/lib/types/settings.d.ts +18 -0
  39. package/lib/types/sse.d.ts +22 -0
  40. package/lib/types/storage.d.ts +118 -0
  41. package/lib/types/translate.d.ts +61 -0
  42. package/lib/types/types.d.ts +345 -0
  43. package/lib/types/usage.d.ts +109 -0
  44. package/package.json +119 -0
@@ -0,0 +1,2099 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { promises } from "node:fs";
5
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
6
+ import { dirname } from "node:path";
7
+ //#region lib/types/constants.js
8
+ /** CodeBuddy 服务根地址;认证握手与 `/v3/config` 都在此处。 */
9
+ const CODEBUDDY_ENDPOINT = "https://copilot.tencent.com";
10
+ /** 在对话与模型目录请求中上报的 IDE 版本,与官方 CodeBuddy CN 4.11.3 客户端一致。 */
11
+ const CODEBUDDY_IDE_VERSION = "4.11.3";
12
+ /** 官方 IDE 的产品段名称,用于拼接后的 `User-Agent`。 */
13
+ const CODEBUDDY_PRODUCT_NAME = "CodeBuddy";
14
+ /**
15
+ * 官方 IDE 主进程 auth/billing 客户端发送的 User-Agent。
16
+ *
17
+ * 登录与计量请求在 `main.js` 中使用裸 axios 实例(没有拦截器链),因此携带
18
+ * 的是 axios 默认 UA,而不是 CustomFetch 平面使用的 IDE 身份 UA。
19
+ * 实测抓包:`/v2/plugin/auth/*` 与 `/v2/billing/meter/*` 都发送 `axios/1.15.0`。
20
+ */
21
+ const CODEBUDDY_AXIOS_USER_AGENT = "axios/1.15.0";
22
+ /** 浏览器登录流程等待用户完成的时长,单位 ms。 */
23
+ const LOGIN_TIMEOUT_MS = 600 * 1e3;
24
+ /** 等待浏览器登录完成时的轮询间隔,单位 ms。 */
25
+ const LOGIN_POLL_INTERVAL_MS = 1e3;
26
+ //#endregion
27
+ //#region lib/types/codebuddy.js
28
+ /**
29
+ * CodeBuddy 控制面客户端:浏览器 OAuth 握手、令牌刷新,
30
+ * 以及非 OpenAI 模型目录。
31
+ *
32
+ * 这里的每次调用都以 `{code, msg, data}` 信封而非仅 HTTP 状态码
33
+ * 表达结果,因此返回 200 却带有非零 `code` 即为失败,并
34
+ * 按失败上报。本模块只负责传输:不持有状态,也不做
35
+ * 策略决策,因此登录流程、适配器与 CLI 得以共用
36
+ * 同一份实现。
37
+ *
38
+ * @module dsh-llm-codebuddy-power/codebuddy
39
+ */
40
+ /**
41
+ * 官方 CodeBuddy CN 4.11.3 客户端在其 CustomFetch 面(chat、catalog)上
42
+ * 发送的组合 IDE `User-Agent`。实测抓包为 `CodeBuddyIDE/4.11.3 CodeBuddy/4.11.3`。
43
+ * @returns IDE user-agent 字符串。
44
+ */
45
+ function ideUserAgent() {
46
+ return `CodeBuddyIDE/${CODEBUDDY_IDE_VERSION} ${CODEBUDDY_PRODUCT_NAME}/${CODEBUDDY_IDE_VERSION}`;
47
+ }
48
+ /**
49
+ * 官方 IDE 主进程的认证/计费客户端发送的 `User-Agent`。
50
+ *
51
+ * `main.js` 在一个裸 axios 实例上发起登录与计量请求(无拦截器
52
+ * 链),因此它们携带 axios 默认 UA —— 实测在 `/v2/plugin/auth/*` 与
53
+ * `/v2/billing/meter/*` 上抓到的值为 `axios/1.15.0`。
54
+ * @returns axios user-agent 字符串。
55
+ */
56
+ function axiosUserAgent() {
57
+ return CODEBUDDY_AXIOS_USER_AGENT;
58
+ }
59
+ /**
60
+ * 登录握手与计量共用的 axios 面请求头:
61
+ * axios 默认的 `Accept` 与 `User-Agent`,外加每次调用各自的头。
62
+ *
63
+ * 在官方 IDE 中这两个面运行在同一个裸 axios 客户端上,
64
+ * 因此尽管各自的额外头不同,帧结构仍相同。
65
+ * @param extra - 请求专属头(`X-No-*`、`Authorization` 等)。
66
+ * @returns 请求头。
67
+ */
68
+ function axiosRequestHeaders(extra) {
69
+ return {
70
+ "Accept": "application/json, text/plain, */*",
71
+ "User-Agent": axiosUserAgent(),
72
+ ...extra
73
+ };
74
+ }
75
+ function delay(ms, signal) {
76
+ return new Promise((resolve, reject) => {
77
+ if (signal?.aborted) {
78
+ reject(/* @__PURE__ */ new Error("aborted"));
79
+ return;
80
+ }
81
+ const timer = setTimeout(() => {
82
+ signal?.removeEventListener("abort", onAbort);
83
+ resolve();
84
+ }, ms);
85
+ const onAbort = () => {
86
+ clearTimeout(timer);
87
+ reject(/* @__PURE__ */ new Error("aborted"));
88
+ };
89
+ signal?.addEventListener("abort", onAbort, { once: true });
90
+ });
91
+ }
92
+ /**
93
+ * 发起一次浏览器登录握手。
94
+ * @param signal - 可选取消。
95
+ * @returns 握手状态,以及用户必须打开的 URL。
96
+ * @throws Error 服务拒绝或返回不可用的响应体时。
97
+ */
98
+ async function requestAuthState(signal) {
99
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/auth/state?platform=ide`, {
100
+ method: "POST",
101
+ headers: axiosRequestHeaders({
102
+ "Content-Type": "application/x-www-form-urlencoded",
103
+ "X-No-Authorization": "true",
104
+ "X-No-User-Id": "true",
105
+ "X-No-Enterprise-Id": "true"
106
+ }),
107
+ ...signal === void 0 ? {} : { signal }
108
+ });
109
+ if (!response.ok) throw new Error(`CodeBuddy auth state request failed (HTTP ${response.status})`);
110
+ const body = await response.json();
111
+ if (body.code !== 0 || body.data === void 0) throw new Error(`CodeBuddy auth state request failed: ${body.code} - ${body.msg}`);
112
+ return body.data;
113
+ }
114
+ /**
115
+ * 轮询,直到用户在浏览器中完成登录。
116
+ *
117
+ * 服务用 code {@link AUTH_PENDING_CODE} 表示“尚未完成”,
118
+ * 它是唯一让循环继续的 code;其他任何 code 都是已决结果,循环
119
+ * 就此结束。传输错误同样结束循环,因为状态可能已被消耗的
120
+ * 握手不得静默重试。
121
+ * @param state - 来自 {@link requestAuthState} 的握手 id。
122
+ * @param signal - 可选取消。
123
+ * @returns 签发的令牌;登录失败或超时时为 `undefined`。
124
+ */
125
+ async function pollAuthToken(state, signal) {
126
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
127
+ while (Date.now() < deadline) {
128
+ await delay(LOGIN_POLL_INTERVAL_MS, signal);
129
+ let response;
130
+ try {
131
+ response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, {
132
+ method: "GET",
133
+ headers: axiosRequestHeaders({ "X-No-Authorization": "true" }),
134
+ ...signal === void 0 ? {} : { signal }
135
+ });
136
+ } catch {
137
+ return;
138
+ }
139
+ if (!response.ok) continue;
140
+ const body = await response.json();
141
+ if (body.code === 11217) continue;
142
+ if (body.code !== 0) return void 0;
143
+ return body.data;
144
+ }
145
+ }
146
+ /**
147
+ * 读取已登录账号,其 uid 与企业 id 会成为之后每个请求的必需
148
+ * 请求头。
149
+ * @param state - 令牌所对应的握手 id。
150
+ * @param accessToken - 刚签发的 access token。
151
+ * @param domain - 令牌所对应的租户 domain。
152
+ * @returns 账号信息。
153
+ * @throws Error 服务拒绝或返回不可用的响应体时。
154
+ */
155
+ async function getLoginAccount(state, accessToken, domain) {
156
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, {
157
+ method: "GET",
158
+ headers: axiosRequestHeaders({
159
+ "Authorization": `Bearer ${accessToken}`,
160
+ "X-No-User-Id": "true",
161
+ "X-No-Enterprise-Id": "true",
162
+ "X-Domain": domain
163
+ })
164
+ });
165
+ if (!response.ok) throw new Error(`CodeBuddy login account request failed (HTTP ${response.status})`);
166
+ const body = await response.json();
167
+ if (body.code !== 0 || body.data === void 0) throw new Error(`CodeBuddy login account request failed: ${body.code} - ${body.msg}`);
168
+ return normalizeAccount(body.data);
169
+ }
170
+ /**
171
+ * 归一化账号,使空字符串字段等同于缺失。
172
+ *
173
+ * 对于租户未披露的字段,CodeBuddy 的账号响应会返回空字符串
174
+ * (而非省略)→ 例如企业账号会带有 `uin: ""`。整条下游链路(storage、
175
+ * auth 状态、设置 UI)只把 `undefined` 当作“不存在”,因此空字符
176
+ * 串会渲染出一行空内容。在此统一裁剪一次即可覆盖所有消费者,
177
+ * 无需各自重复判断。
178
+ * @param account - 来自 wire 的原始账号。
179
+ * @returns 删除可选字符串字段为空值后的账号。
180
+ */
181
+ function normalizeAccount(account) {
182
+ const pick = (value) => value === void 0 || value.length === 0 ? void 0 : value;
183
+ return {
184
+ uid: account.uid,
185
+ nickname: account.nickname,
186
+ ...pick(account.uin) === void 0 ? {} : { uin: account.uin },
187
+ ...pick(account.enterpriseId) === void 0 ? {} : { enterpriseId: account.enterpriseId },
188
+ ...pick(account.enterpriseName) === void 0 ? {} : { enterpriseName: account.enterpriseName },
189
+ ...pick(account.enterpriseUserName) === void 0 ? {} : { enterpriseUserName: account.enterpriseUserName },
190
+ ...pick(account.departmentFullName) === void 0 ? {} : { departmentFullName: account.departmentFullName }
191
+ };
192
+ }
193
+ /**
194
+ * 用 refresh token 换取新的 access token。
195
+ * @param identity - 当前身份,包含正被替换的 access token。
196
+ * @param refreshToken - 要使用的 refresh token。
197
+ * @returns 新令牌;刷新被拒绝时为 `undefined`。
198
+ */
199
+ async function refreshAccessToken(identity, refreshToken) {
200
+ const headers = axiosRequestHeaders({
201
+ "Authorization": `Bearer ${identity.accessToken}`,
202
+ "X-Domain": identity.domain,
203
+ "X-User-Id": identity.uid,
204
+ "X-Refresh-Token": refreshToken,
205
+ "X-Auth-Refresh-Source": "plugin"
206
+ });
207
+ if (identity.enterpriseId !== void 0) headers["X-Enterprise-Id"] = identity.enterpriseId;
208
+ let response;
209
+ try {
210
+ response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/auth/token/refresh`, {
211
+ method: "POST",
212
+ headers
213
+ });
214
+ } catch {
215
+ return;
216
+ }
217
+ if (!response.ok) return void 0;
218
+ const body = await response.json();
219
+ if (body.code !== 0 || body.data === void 0) return void 0;
220
+ return body.data;
221
+ }
222
+ /**
223
+ * 一个新的无连字符 uuid;CodeBuddy 各面用作请求 id 与
224
+ * 会话 id 的紧凑形式。
225
+ * @returns 32 个十六进制字符的 id。
226
+ */
227
+ function compactUuid() {
228
+ return randomUUID().replace(/-/g, "");
229
+ }
230
+ /**
231
+ * 每个 CustomFetch 面请求(chat、catalog)都携带的共享客户端身份
232
+ * 头,与官方 IDE 4.11.3 的抓包一致:
233
+ * IDE user-agent、SaaS 产品标记、XMLHttpRequest 来源,以及
234
+ * 每个请求一个新生成的紧凑 `X-Request-ID`。
235
+ *
236
+ * CodeBuddy 的安全策略会拒绝任何它不认作自家客户端的 User-Agent
237
+ * (实测:带 harness 标识的 User-Agent 会被以 HTTP 400 / code 11128
238
+ * 拒绝)。提供方自身的产品身份是唯一被接受的载体,因此本函数是
239
+ * chat 与 catalog 两条传输上该身份的唯一来源。
240
+ * @returns 共享身份头,其中 request id 为新生成。
241
+ */
242
+ function clientIdentityHeaders() {
243
+ return {
244
+ "User-Agent": ideUserAgent(),
245
+ "X-Product": "SaaS",
246
+ "X-Requested-With": "XMLHttpRequest",
247
+ "X-Request-ID": compactUuid()
248
+ };
249
+ }
250
+ /**
251
+ * 读取 CodeBuddy 模型目录。
252
+ *
253
+ * 这是该服务中非 OpenAI 兼容的那一半,也是本插件无法被通用
254
+ * OpenAI 兼容路由取代的原因:该响应会披露每个模型的能力标记与
255
+ * 尺寸,而 `GET /models` 列表不会。
256
+ * @param identityHeaders - 已认证的身份头,由会话构建一次
257
+ * (`requestIdentity()`),使 chat 与目录共用同一套。
258
+ * @param signal - 可选取消。
259
+ * @returns 目录。
260
+ * @throws Error 服务拒绝或返回不可用的响应体时。
261
+ */
262
+ async function getConfig(identityHeaders, signal) {
263
+ const headers = {
264
+ ...identityHeaders,
265
+ ...clientIdentityHeaders(),
266
+ "Accept": "application/json"
267
+ };
268
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v3/config`, {
269
+ method: "GET",
270
+ headers,
271
+ ...signal === void 0 ? {} : { signal }
272
+ });
273
+ if (!response.ok) throw new Error(`CodeBuddy config request failed (HTTP ${response.status})`);
274
+ const body = await response.json();
275
+ if (body.code !== 0 || body.data === void 0) throw new Error(`CodeBuddy config request failed: ${body.code} - ${body.msg}`);
276
+ return body.data;
277
+ }
278
+ //#endregion
279
+ //#region lib/types/atomic-file.js
280
+ /**
281
+ * 插件自有 JSON 文件的写入底座:原子替换 + 按路径串行化。
282
+ *
283
+ * 三个存储(账号、会话选择、每轮积分)都是读-改-写的文件。原子替换让
284
+ * 残缺内容永远不会留在磁盘上被当成一份读不出的记录;按路径排队让并发写入
285
+ * 串行执行 —— 后一次读得到前一次的结果,而不是各自基于同一份旧内容覆盖对方。
286
+ * 集中在这里,使三个存储不会各自实现一遍,也不会各自漏掉队列。
287
+ *
288
+ * @module dsh-llm-codebuddy-power/atomic-file
289
+ */
290
+ /** 每个文件一条写队列,保存当前队尾。 */
291
+ const queues = /* @__PURE__ */ new Map();
292
+ /**
293
+ * 排队一次针对某文件的写:前一次写完成后才开始执行。
294
+ *
295
+ * 队尾保存的是已消化失败的 promise —— 它的拒绝无人 await,留着会变成未处理的
296
+ * rejection;调用方从返回的 promise 收到同一个失败。
297
+ * @param path - 被写的文件。
298
+ * @param write - 实际写入;在同一路径的写之间串行执行。
299
+ * @returns 本次写入的结果;失败时以该失败拒绝。
300
+ */
301
+ function queueWrite(path, write) {
302
+ const next = (queues.get(path) ?? Promise.resolve()).then(write);
303
+ const settled = next.then(() => void 0, () => void 0);
304
+ queues.set(path, settled);
305
+ settled.then(() => {
306
+ if (queues.get(path) === settled) queues.delete(path);
307
+ });
308
+ return next;
309
+ }
310
+ /**
311
+ * 以原子替换写入一个文件,并在支持的文件系统上收紧为仅属主可读。
312
+ *
313
+ * 先写同目录下的临时文件再改名:改名在同一文件系统内是原子的,因此读到的
314
+ * 要么是旧内容要么是新内容,不会是写了一半的内容。
315
+ *
316
+ * 内容是调用方给的字符串而不是由本函数序列化:`JSON.stringify` 的默认输出是一个
317
+ * 字节的重复都没有的紧凑格式,可读性全靠调用方自己插入的换行 —— 需要控制"哪里换行"
318
+ * 的存储因此各自决定格式(见 `session-store.ts` 的逐轮一行)。
319
+ * @param path - 目标文件。
320
+ * @param content - 要写入的完整内容。
321
+ */
322
+ async function writeFileAtomic(path, content) {
323
+ await promises.mkdir(dirname(path), { recursive: true });
324
+ const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
325
+ try {
326
+ await promises.writeFile(temp, content, {
327
+ encoding: "utf-8",
328
+ mode: 384
329
+ });
330
+ await promises.rename(temp, path);
331
+ } catch (error) {
332
+ await promises.unlink(temp).catch(() => {});
333
+ throw error;
334
+ }
335
+ await promises.chmod(path, 384).catch(() => {});
336
+ }
337
+ /**
338
+ * 以原子替换写入一个 JSON 文件,缩进两格。
339
+ *
340
+ * 缩进是给需要肉眼排查的凭据文件用的(`storage.ts` 的 refresh token 与过期时间),
341
+ * 不是通用默认值:批量记录类文件改用 {@link writeFileAtomic} 自己排版。
342
+ * @param path - 目标文件。
343
+ * @param value - 要序列化的值。
344
+ */
345
+ async function writeJsonAtomic(path, value) {
346
+ await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
347
+ }
348
+ //#endregion
349
+ //#region lib/types/storage.js
350
+ /**
351
+ * 持久化 OAuth 账号存储,磁盘上仅属主可读。
352
+ *
353
+ * 存储位于 harness home 的规范数据目录
354
+ * (`$DSH_HOME/data/plugins/llm-codebuddy-power/`,通过 harness 所用的同一个
355
+ * `@deepseek-ai/dsh-home-paths` 解析),而不放在插件包内,因此重装不会让
356
+ * 用户退出登录。写入经 [atomic-file.ts](./atomic-file.ts) 做原子替换并按路径串行化:
357
+ * 多个账号的令牌刷新、设置页的增删与 CLI 的调用都在改同一份文件,不串行化时
358
+ * 两个并发写入会各自基于自己读到的旧内容覆盖对方。残缺文件会让用户手里只有
359
+ * 一份读不出的凭据,还无法与“从未登录过”区分开。
360
+ *
361
+ * @module dsh-llm-codebuddy-power/storage
362
+ */
363
+ /** 空存储:读取不到任何可用内容时的结果。 */
364
+ function emptyStorage() {
365
+ return { accounts: [] };
366
+ }
367
+ /**
368
+ * 凭据文件的绝对路径。
369
+ *
370
+ * 通过 `@deepseek-ai/dsh-home-paths` 解析,以跟随 harness 自身的 home 优先级
371
+ * (配置路径 > `$DSH_HOME` > `~/.dsh`),绝不会分叉到另行计算的 home。
372
+ * `DSH_CODEBUDDY_AUTH_FILE` 保留作为测试与迁移用的显式逃生口。
373
+ * @returns 绝对凭据路径,优先遵循环境变量覆盖。
374
+ */
375
+ function getStoragePath() {
376
+ const override = process.env.DSH_CODEBUDDY_AUTH_FILE;
377
+ if (override !== void 0 && override.length > 0) return override;
378
+ return dshHomePath("data", "plugins", "llm-codebuddy-power", "codebuddy-auth.json");
379
+ }
380
+ /**
381
+ * 路径是否仅其属主可读。
382
+ *
383
+ * 与 `@deepseek-ai/dsh-credentials-local` 在加载自己的凭据文档前所做的仅属主
384
+ * 检查一致:只要 group 或 other 的读/写位被置上,即视为文件已暴露,检查
385
+ * 失败。Windows 没有 POSIX 模式,因此在 Windows 上跳过该检查——防护强度
386
+ * 取决于创建与替换 API 所表达的内容,与 dsh-credentials-local 相同。
387
+ * @param path - 凭据文件路径。
388
+ * @returns 文件不存在(尚无可保护内容)或以仅属主权限存在时返回 true;
389
+ * 文件存在且已暴露时返回 false。
390
+ */
391
+ async function isOwnerOnly(path) {
392
+ if (process.platform === "win32") return true;
393
+ let mode;
394
+ try {
395
+ mode = (await promises.stat(path)).mode;
396
+ } catch {
397
+ return true;
398
+ }
399
+ return (mode & 63) === 0;
400
+ }
401
+ /** 账号条目是否携带了成对的令牌、uid 与刷新令牌的到期时刻。 */
402
+ function isAccountEntry(value) {
403
+ if (typeof value !== "object" || value === null) return false;
404
+ const entry = value;
405
+ return typeof entry.auth?.accessToken === "string" && typeof entry.auth.refreshToken === "string" && typeof entry.auth.refreshExpiresAt === "number" && Number.isFinite(entry.auth.refreshExpiresAt) && typeof entry.account?.uid === "string";
406
+ }
407
+ /**
408
+ * 丢弃取值为空字符串的账号字符串字段,把它们当作不存在。
409
+ *
410
+ * 在 `normalizeAccount` 之前写入的较旧凭据可能从 wire 带来 `uin: ""`
411
+ * (以及其他取值为空的可选字符串);这里把它们裁掉,使下游每个消费方的
412
+ * `=== undefined` 判断都成立,而无需各自再加空字符串防护。
413
+ * @param account - 解析后的账号字段。
414
+ * @returns 已移除取值为空的可选字段的账号。
415
+ */
416
+ function trimEmptyAccountFields(account) {
417
+ const pick = (v) => v === void 0 || v.length === 0 ? void 0 : v;
418
+ return {
419
+ uid: account.uid,
420
+ nickname: account.nickname,
421
+ ...pick(account.uin) === void 0 ? {} : { uin: account.uin },
422
+ ...pick(account.enterpriseId) === void 0 ? {} : { enterpriseId: account.enterpriseId },
423
+ ...pick(account.enterpriseName) === void 0 ? {} : { enterpriseName: account.enterpriseName },
424
+ ...pick(account.enterpriseUserName) === void 0 ? {} : { enterpriseUserName: account.enterpriseUserName },
425
+ ...pick(account.departmentFullName) === void 0 ? {} : { departmentFullName: account.departmentFullName }
426
+ };
427
+ }
428
+ /**
429
+ * 把解析出的 JSON 归一化为账号集合。
430
+ *
431
+ * 同时接受当前形态与仅有一个账号的旧形态(顶层 `auth` + `account`):旧文件
432
+ * 迁移成单元素列表,并让那个账号成为默认账号,于是升级不会改变用户下一次
433
+ * 请求用的是谁。`defaultUid` 与 `lastUid` 指向已不存在的账号时丢弃——它们只是
434
+ * 指针,留着会让“有没有默认账号”与实际情况相反。
435
+ * @param parsed - 解析出的 JSON 值。
436
+ * @returns 归一化后的账号集合。
437
+ */
438
+ function normalizeStorage(parsed) {
439
+ if (typeof parsed !== "object" || parsed === null) return emptyStorage();
440
+ const record = parsed;
441
+ if (Array.isArray(record.accounts)) {
442
+ const accounts = record.accounts.filter(isAccountEntry).map((entry) => ({
443
+ auth: entry.auth,
444
+ account: trimEmptyAccountFields(entry.account)
445
+ }));
446
+ const pick = (value) => {
447
+ if (typeof value !== "string") return void 0;
448
+ return accounts.some((a) => a.account.uid === value) ? value : void 0;
449
+ };
450
+ const defaultUid = pick(record.defaultUid);
451
+ const lastUid = pick(record.lastUid);
452
+ return {
453
+ accounts,
454
+ ...defaultUid === void 0 ? {} : { defaultUid },
455
+ ...lastUid === void 0 ? {} : { lastUid }
456
+ };
457
+ }
458
+ if (!isAccountEntry(record)) return emptyStorage();
459
+ return {
460
+ accounts: [{
461
+ auth: record.auth,
462
+ account: trimEmptyAccountFields(record.account)
463
+ }],
464
+ defaultUid: record.account.uid
465
+ };
466
+ }
467
+ /**
468
+ * 读取已存储的账号集合。
469
+ *
470
+ * 在读取任何字节之前先检查文件模式:主机上其他用户可读的凭据会被当作不存在
471
+ * 而不是拿来使用,因此丢失仅属主模式的文件(手动 chmod 出错、从别处拷贝)
472
+ * 绝不会被加载。当作不存在也能自愈——下次登录会用 `0o600` 重写该文件。
473
+ * @returns 账号集合;没有可用内容时为空集合。文件缺失、文件损坏与权限不安全
474
+ * 刻意给出相同答案:都表示“此处没有可安全用于认证的东西”,而登录流程是
475
+ * 每一种情况的修复方式。
476
+ */
477
+ async function loadStorage() {
478
+ const path = getStoragePath();
479
+ try {
480
+ if (!await isOwnerOnly(path)) return emptyStorage();
481
+ const raw = await promises.readFile(path, "utf-8");
482
+ return normalizeStorage(JSON.parse(raw));
483
+ } catch {
484
+ return emptyStorage();
485
+ }
486
+ }
487
+ /**
488
+ * 读-改-写账号集合。
489
+ *
490
+ * 读发生在队列内部,因此排队中的后一次改动看得到前一次的结果,而不是各自
491
+ * 基于同一份旧内容。文件此刻无法安全读取时按空集合起手,与 {@link loadStorage}
492
+ * 对同一情况的判断一致。
493
+ * @param change - 由当前集合算出新集合;不得就地修改传入值。
494
+ */
495
+ async function updateStorage(change) {
496
+ const path = getStoragePath();
497
+ await queueWrite(path, async () => {
498
+ await writeJsonAtomic(path, change(await loadStorage()));
499
+ });
500
+ }
501
+ /**
502
+ * 插入一个账号,或替换同 uid 账号的凭据。
503
+ *
504
+ * 同 uid 视为重新登录同一个账号:令牌被替换而不是追加成第二个条目,否则
505
+ * 设置页会出现两个同名徽章,退出其中一个还会留下另一个。
506
+ * @param entry - 刚登录得到的账号。
507
+ */
508
+ async function upsertAccount(entry) {
509
+ await updateStorage((current) => {
510
+ const uid = entry.account.uid;
511
+ const accounts = current.accounts.some((a) => a.account.uid === uid) ? current.accounts.map((a) => a.account.uid === uid ? entry : a) : [...current.accounts, entry];
512
+ const defaultUid = current.defaultUid ?? (current.accounts.length === 0 ? uid : void 0);
513
+ return {
514
+ accounts,
515
+ ...defaultUid === void 0 ? {} : { defaultUid },
516
+ ...current.lastUid === void 0 ? {} : { lastUid: current.lastUid }
517
+ };
518
+ });
519
+ }
520
+ /**
521
+ * 移除若干账号,并清掉指向它们的默认账号与“最后一次手动切换”的账号。
522
+ * @param uids - 要移除的账号 uid。
523
+ */
524
+ async function removeAccounts(uids) {
525
+ const removed = new Set(uids);
526
+ await updateStorage((current) => {
527
+ const accounts = current.accounts.filter((a) => !removed.has(a.account.uid));
528
+ const gone = (uid) => uid !== void 0 && removed.has(uid) ? void 0 : uid;
529
+ const defaultUid = gone(current.defaultUid);
530
+ const lastUid = gone(current.lastUid);
531
+ return {
532
+ accounts,
533
+ ...defaultUid === void 0 ? {} : { defaultUid },
534
+ ...lastUid === void 0 ? {} : { lastUid }
535
+ };
536
+ });
537
+ }
538
+ /**
539
+ * 指定或取消默认账号。
540
+ * @param uid - 默认账号的 uid;传 `undefined` 取消默认账号。
541
+ */
542
+ async function setDefaultAccount(uid) {
543
+ await updateStorage((current) => {
544
+ const next = {
545
+ accounts: current.accounts,
546
+ ...current.lastUid === void 0 ? {} : { lastUid: current.lastUid }
547
+ };
548
+ if (uid !== void 0 && current.accounts.some((a) => a.account.uid === uid)) next.defaultUid = uid;
549
+ return next;
550
+ });
551
+ }
552
+ /**
553
+ * 记下最后一次被手动切换到的账号(没有默认账号时作为兜底)。
554
+ * @param uid - 被切换到的账号 uid。
555
+ */
556
+ async function setLastAccount(uid) {
557
+ await updateStorage((current) => ({
558
+ ...current,
559
+ lastUid: uid
560
+ }));
561
+ }
562
+ /**
563
+ * 凭据文件的不透明新鲜度令牌:文件每次被重写或删除时它都会变化,因此内存中
564
+ * 的会话能察觉另一个进程(CLI)执行的登录与登出,而不必在什么都没变时
565
+ * 每次调用都重新读取。
566
+ * @returns 一个令牌;没有凭据文件时返回 `undefined`。
567
+ */
568
+ async function storageFreshness() {
569
+ let stat;
570
+ try {
571
+ stat = await promises.stat(getStoragePath());
572
+ } catch {
573
+ return;
574
+ }
575
+ return `${stat.mtimeMs}:${stat.size}`;
576
+ }
577
+ //#endregion
578
+ //#region lib/types/login.js
579
+ /**
580
+ * 浏览器 OAuth 登录流程,从发起到凭据落盘。
581
+ *
582
+ * 全程不索取 API key:握手生成一个 `state`,用户在普通浏览器中
583
+ * 登录,然后依据该 state 从服务轮询取出令牌。用户需要做的
584
+ * 只是在一个登录页面上点击完成。
585
+ *
586
+ * @module dsh-llm-codebuddy-power/login
587
+ */
588
+ /**
589
+ * 由刚签发的令牌与账号信息构建一个可持久化的账号条目。
590
+ *
591
+ * CLI 流程与 Web 认证服务共用,以免两者在存储结构上
592
+ * 产生分歧:两者写入的都是这个对象。
593
+ * @param token - 浏览器登录完成后签发的令牌。
594
+ * @param account - 令牌所对应的已登录账号。
595
+ * @returns 要持久化的账号条目。
596
+ */
597
+ function buildAccount(token, account) {
598
+ return {
599
+ auth: {
600
+ accessToken: token.accessToken,
601
+ expiresAt: Date.now() + token.expiresIn * 1e3,
602
+ refreshToken: token.refreshToken,
603
+ refreshExpiresAt: Date.now() + token.refreshExpiresIn * 1e3,
604
+ domain: token.domain
605
+ },
606
+ account: {
607
+ uid: account.uid,
608
+ nickname: account.nickname,
609
+ ...account.uin === void 0 ? {} : { uin: account.uin },
610
+ ...account.enterpriseId === void 0 ? {} : { enterpriseId: account.enterpriseId },
611
+ ...account.enterpriseName === void 0 ? {} : { enterpriseName: account.enterpriseName },
612
+ ...account.enterpriseUserName === void 0 ? {} : { enterpriseUserName: account.enterpriseUserName },
613
+ ...account.departmentFullName === void 0 ? {} : { departmentFullName: account.departmentFullName }
614
+ }
615
+ };
616
+ }
617
+ /**
618
+ * 尽力在用户的默认浏览器中打开 URL。
619
+ *
620
+ * 失败被刻意设为静默:URL 已经打印出来,因此无头或受限
621
+ * 环境仍有可用的前进路径,否则 spawn 错误会被
622
+ * 读作登录失败。
623
+ */
624
+ function openInBrowser(url) {
625
+ let command;
626
+ let args;
627
+ if (process.platform === "win32") {
628
+ command = "rundll32";
629
+ args = ["url.dll,FileProtocolHandler", url];
630
+ } else {
631
+ command = process.platform === "darwin" ? "open" : "xdg-open";
632
+ args = [url];
633
+ }
634
+ try {
635
+ const child = spawn(command, args, {
636
+ stdio: "ignore",
637
+ detached: true
638
+ });
639
+ child.on("error", () => {});
640
+ child.unref();
641
+ } catch {}
642
+ }
643
+ /**
644
+ * 运行完整的浏览器登录流程。
645
+ * @param hooks - 用户交互钩子。
646
+ * @param signal - 可选取消。
647
+ * @returns 已追加到账号列表的账号与已登录昵称。
648
+ * @throws Error 握手失败、用户未及时完成,或
649
+ * 账号无法读取时。
650
+ */
651
+ async function login(hooks = {}, signal) {
652
+ const state = await requestAuthState(signal);
653
+ hooks.onUrl?.(state.authUrl);
654
+ if (hooks.openBrowser !== false) openInBrowser(state.authUrl);
655
+ const token = await pollAuthToken(state.state, signal);
656
+ if (token === void 0) throw new Error("CodeBuddy sign-in did not complete (it was refused, or it timed out).");
657
+ const account = await getLoginAccount(state.state, token.accessToken, token.domain);
658
+ const entry = buildAccount(token, account);
659
+ await upsertAccount(entry);
660
+ return {
661
+ account: entry,
662
+ nickname: account.nickname
663
+ };
664
+ }
665
+ //#endregion
666
+ //#region lib/types/types.js
667
+ /**
668
+ * 本插件读取的 wire 结构,分属两套互不相关的协议。
669
+ *
670
+ * CodeBuddy 控制平面(`/v2/plugin/auth/*`、`/v3/config`)把每个回复包在
671
+ * `{code, msg, requestId, data}` 中,且不兼容 OpenAI——这正是本插件存在、
672
+ * 而不使用普通 OpenAI 兼容路由的原因。对话平面(`/v2/chat/completions`)
673
+ * 兼容 OpenAI,其 chunk 结构即常见结构。
674
+ *
675
+ * @module dsh-llm-codebuddy-power/types
676
+ */
677
+ /**
678
+ * 目录是否披露了 harness 所需的容量。
679
+ *
680
+ * harness 要求它提供的每个模型都有正的 `contextWindow` 与输出上限,而
681
+ * CodeBuddy 对非对话模型的条目(补全、改写/跳转、图片生成)两者都不给。
682
+ * 为这些条目编造数字会把不可用的模型以猜测的尺寸放进选择器,因此调用方
683
+ * 改为丢弃它们。放在这里、紧邻 wire 类型,使列表与解析路径不会对哪些条目
684
+ * 可提供产生分歧。
685
+ * @param model - 一个目录条目。
686
+ * @returns 两个容量都存在且为正时返回 true。
687
+ */
688
+ function hasDisclosedCapacity(model) {
689
+ return model.maxAllowedSize !== void 0 && model.maxAllowedSize > 0 && model.maxOutputTokens !== void 0 && model.maxOutputTokens > 0;
690
+ }
691
+ /**
692
+ * 插件有意不提供的模型 id,即使目录把它们描述为完全支持对话。
693
+ *
694
+ * CodeBuddy 的目录会列出并非真实模型的默认/别名条目:它们的请求在用量记录
695
+ * 中会落成另一个模型(`default` 别名解析为 `deepseek-v4-flash`),而选择器
696
+ * 会把它显示为独立选项,因此提供它们会误导用户。被屏蔽的 id 只在列表中
697
+ * 滤除;显式指名的 id 仍会走通(服务以 "The requested model is not
698
+ * available" 拒绝),与选择器隐藏服务无法提供选项的做法一致。
699
+ */
700
+ const BLOCKED_MODEL_IDS = new Set(["default", "codewise-default-model-v2"]);
701
+ /**
702
+ * 某个目录条目 id 是否在屏蔽列表中。
703
+ * @param id - 目录条目 id。
704
+ * @returns 插件不得提供该模型时返回 true。
705
+ */
706
+ function isBlockedModelId(id) {
707
+ return BLOCKED_MODEL_IDS.has(id);
708
+ }
709
+ //#endregion
710
+ //#region lib/types/usage.js
711
+ /**
712
+ * CodeBuddy 配额/用量计量器:获取并解析剩余额度。
713
+ *
714
+ * CodeBuddy 对计费面的拆分方式与 gproxy 参考实现相同:企业租户回应
715
+ * `get-enterprise-user-usage`(单个 limit/credit 对),而个人账户回应
716
+ * `get-user-resource`(每个生效套餐一个窗口)。两种结构除了每个 CodeBuddy
717
+ * 请求都携带的认证头之外没有任何共同点,因此传输与解析路径只在已登录账户是否
718
+ * 披露了 `enterpriseId` 这一点上分叉一次。
719
+ *
720
+ * 每个进入 Web 客户端的值都是普通 number/string,因此这里返回的
721
+ * {@link UsageSnapshot} 是自有数据 → 不会有活的会话对象逃出本模块。
722
+ *
723
+ * @module dsh-llm-codebuddy-power/usage
724
+ */
725
+ /**
726
+ * 每个 CodeBuddy 计量请求携带的认证头。
727
+ *
728
+ * 与官方 IDE 主进程的计量调用(`main.js` 中的 `getPersonalUsage`)一致:
729
+ * 在裸 axios 客户端上运行,请求只发送 `Authorization` + `X-User-Id` 以及
730
+ * axios 自身的帧头 — 不含 `X-Domain`/`X-Enterprise-Id`/`X-Tenant-Id`/
731
+ * `X-Department-Info`。这一点已针对 `/v2/billing/meter/get-user-resource`
732
+ * 做过线上验证(抓包恰好显示 `Accept`、`Content-Type`、`Authorization`、
733
+ * `X-User-Id`、`User-Agent: axios/1.15.0`)。
734
+ * @param identity - 已登录的身份。
735
+ * @returns 请求头。
736
+ */
737
+ function meterHeaders(identity) {
738
+ return axiosRequestHeaders({
739
+ "Content-Type": "application/json",
740
+ "Authorization": `Bearer ${identity.accessToken}`,
741
+ "X-User-Id": identity.uid
742
+ });
743
+ }
744
+ /** 读取一个可能以数字或数字字符串形式到达的数值字段。 */
745
+ function number(value, key) {
746
+ if (value === null || typeof value !== "object") return void 0;
747
+ const raw = value[key];
748
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
749
+ if (typeof raw === "string") {
750
+ const parsed = Number(raw);
751
+ return Number.isFinite(parsed) ? parsed : void 0;
752
+ }
753
+ }
754
+ /** 读取一个非空字符串字段。 */
755
+ function string(value, key) {
756
+ if (value === null || typeof value !== "object") return void 0;
757
+ const raw = value[key];
758
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
759
+ }
760
+ /** 沿一条对象键链穿过 JSON 值,返回叶子值或 undefined。 */
761
+ function pointer(value, path) {
762
+ let current = value;
763
+ for (const key of path) {
764
+ if (current === null || typeof current !== "object") return void 0;
765
+ current = current[key];
766
+ }
767
+ return current;
768
+ }
769
+ /**
770
+ * 把 Unix 时间戳按本地时区格式化为 `YYYY-MM-DD HH:mm:ss`,即个人计量面
771
+ * 为其 `SlicePeriod*` 边界所期望的形式。
772
+ *
773
+ * gproxy 参考实现按 UTC 格式化;只要两个边界采用同一约定,CodeBuddy 服务
774
+ * 都接受,而本地格式化与 IDE 客户端发送的一致,因此这次读取更不容易落在
775
+ * 服务端自身预期之外。
776
+ * @param timestamp - Unix 秒。
777
+ * @returns 格式化后的时间戳。
778
+ */
779
+ function formatTime(timestamp) {
780
+ const date = /* @__PURE__ */ new Date(timestamp * 1e3);
781
+ const pad = (n) => n < 10 ? `0${n}` : String(n);
782
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
783
+ }
784
+ /**
785
+ * 归一化个人套餐的重置时间戳。
786
+ *
787
+ * CodeBuddy 把套餐的 `CycleEndTime` 上报为其最后一个有效日的结束时刻
788
+ * (`23:59:59`)。这读起来像是“午夜前重置”,但配额实际在次日 `00:00:00`
789
+ * 重置,因此当该值以 `23:59:59` 结尾时,显示值向后一天顺延一秒。其他任何值
790
+ * 原样通过,无法解析的时间戳按原样返回而不是丢弃(有这个数字总比没有好)。
791
+ * @param raw - `CycleEndTime` 字符串,`YYYY-MM-DD HH:mm:ss`。
792
+ * @returns 归一化后的时间戳字符串。
793
+ */
794
+ function normalizeResetTime(raw) {
795
+ if (!raw.endsWith("23:59:59")) return raw;
796
+ const date = new Date(raw.replace(" ", "T"));
797
+ if (Number.isNaN(date.getTime())) return raw;
798
+ date.setSeconds(date.getSeconds() + 1);
799
+ const pad = (n) => n < 10 ? `0${n}` : String(n);
800
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
801
+ }
802
+ /**
803
+ * 判断某个套餐显示名是否表示一次性赠送额度。
804
+ *
805
+ * CodeBuddy 的个人计量面把推荐/裂变额度命名为“裂变包”,未来任何促销都可能
806
+ * 带有“赠送”;其他一切(试用/可续期主套餐、企业行)都视为主池。显示名是
807
+ * 计量面暴露的唯一区分依据,因此两种写法都会被匹配。
808
+ * @param name - 窗口的显示名。
809
+ * @returns 赠送套餐名返回 true。
810
+ */
811
+ function isGiftName(name) {
812
+ return /裂变|赠送/.test(name);
813
+ }
814
+ /**
815
+ * 把有可用容量的窗口聚合成一个池(used/limit 求和)。
816
+ * 容量未披露或非正数的窗口会退出 — 它们仍留在 `windows` 中。
817
+ * @param windows - 候选窗口。
818
+ * @returns 该池,或当没有可用容量的窗口匹配时不返回。
819
+ */
820
+ function poolOf(windows) {
821
+ let used = 0;
822
+ let limit = 0;
823
+ for (const window of windows) {
824
+ if (window.used === void 0 || window.limit === void 0 || window.limit <= 0) continue;
825
+ used += window.used;
826
+ limit += window.limit;
827
+ }
828
+ if (limit <= 0) return void 0;
829
+ return {
830
+ used,
831
+ limit
832
+ };
833
+ }
834
+ /** 有效的赠送窗口:仍有上限且尚未完全耗尽。 */
835
+ function liveGiftWindows(windows) {
836
+ return windows.filter((window) => window.used !== void 0 && window.limit !== void 0 && window.limit > 0 && window.used < window.limit);
837
+ }
838
+ /**
839
+ * 构建个人快照的主池/赠送池划分,以及提示框的赠送明细(最早过期的有效赠送
840
+ * 在前,最多 5 个)。
841
+ * @param windows - 已解析的窗口。
842
+ * @returns 该划分,仅当对应池有可用 limit 时才包含 `main`/`gift`。
843
+ */
844
+ function splitPools(windows) {
845
+ const mainWindows = [];
846
+ const giftWindows = [];
847
+ for (const window of windows) if (isGiftName(window.name)) giftWindows.push(window);
848
+ else mainWindows.push(window);
849
+ const main = poolOf(mainWindows);
850
+ const live = liveGiftWindows(giftWindows);
851
+ const giftPool = poolOf(live);
852
+ if (giftPool === void 0) return { ...main === void 0 ? {} : { main } };
853
+ const soonest = live.toSorted((a, b) => {
854
+ if (a.resetsAt !== void 0 && b.resetsAt !== void 0) return a.resetsAt.localeCompare(b.resetsAt);
855
+ if (a.resetsAt !== void 0) return -1;
856
+ return b.resetsAt === void 0 ? 0 : 1;
857
+ }).slice(0, 5);
858
+ return {
859
+ ...main === void 0 ? {} : { main },
860
+ gift: {
861
+ used: giftPool.used,
862
+ limit: giftPool.limit,
863
+ soonest
864
+ }
865
+ };
866
+ }
867
+ /**
868
+ * 解析个人账户的 `get-user-resource` 响应。
869
+ *
870
+ * 视请求经过的网关而定,accounts 数组可能位于多个指针根之下;会按顺序逐一
871
+ * 尝试,以找到的第一个数组为准。每个账户贡献一个以其套餐命名的
872
+ * {@link UsageWindow}(披露了显示名时用显示名,否则用套餐代码),其中 `used`
873
+ * 由 `limit - remaining` 推导(因此超过上限的剩余值会被钳制为零已用,而不是
874
+ * 负数)。
875
+ * @param accounts - 定位到的 accounts 数组。
876
+ * @returns 组装好的快照。
877
+ */
878
+ function personalUsage(accounts) {
879
+ const windows = accounts.map((resource, index) => {
880
+ const limit = number(resource, "CycleCapacitySizePrecise") ?? 0;
881
+ const left = number(resource, "CycleCapacityRemainPrecise") ?? 0;
882
+ const used = Math.max(limit - left, 0);
883
+ const name = string(resource, "PackageName") ?? string(resource, "PackageCode") ?? string(resource, "ResourceId") ?? `resource_${index}`;
884
+ const rawReset = string(resource, "CycleEndTime");
885
+ const resetsAt = rawReset === void 0 ? void 0 : normalizeResetTime(rawReset);
886
+ if (limit <= 0) return {
887
+ name,
888
+ ...resetsAt === void 0 ? {} : { resetsAt }
889
+ };
890
+ return {
891
+ name,
892
+ used,
893
+ limit,
894
+ ...resetsAt === void 0 ? {} : { resetsAt }
895
+ };
896
+ });
897
+ const split = splitPools(windows);
898
+ const first = windows[0];
899
+ return {
900
+ windows,
901
+ ...first?.resetsAt === void 0 ? {} : { primaryResetsAt: first.resetsAt },
902
+ ...split.main === void 0 ? {} : { main: split.main },
903
+ ...split.gift === void 0 ? {} : { gift: split.gift }
904
+ };
905
+ }
906
+ /**
907
+ * 解析企业租户的 `get-enterprise-user-usage` 响应。
908
+ *
909
+ * 企业计费面在 `data` 下(网关未包装时则在根上)上报单个 `limitNum`/`credit`
910
+ * 对,因此只构建一个窗口。
911
+ * @param data - 承载这些数字的 data 对象。
912
+ * @returns 组装好的快照,或未披露任何 limit 时为 `undefined`。
913
+ */
914
+ function enterpriseUsage(data) {
915
+ const limit = number(data, "limitNum");
916
+ if (limit === void 0) return void 0;
917
+ const used = number(data, "credit") ?? 0;
918
+ const reset = string(data, "cycleResetTime");
919
+ const window = {
920
+ name: "enterprise",
921
+ used,
922
+ limit,
923
+ ...reset === void 0 ? {} : { resetsAt: reset }
924
+ };
925
+ const main = poolOf([window]);
926
+ return {
927
+ windows: [window],
928
+ ...reset === void 0 ? {} : { primaryResetsAt: reset },
929
+ ...main === void 0 ? {} : { main }
930
+ };
931
+ }
932
+ /**
933
+ * 把一次计量响应解析为快照。
934
+ *
935
+ * gproxy 参考实现会在其已被观察到使用的每个指针根下尝试个人 `Accounts`
936
+ * 数组,只有在没有任何数组匹配时才回退到企业单窗口解析 → 与发出的请求路径
937
+ * 无关。沿用这一顺序,可以让恰好携带 `enterpriseId` 的个人账户(或反之)
938
+ * 按它实际回应的结构解析,而不是按其凭据暗示的结构解析。
939
+ * @param raw - 已解析的响应体。
940
+ * @returns 组装好的快照,或响应体不含任何可解析内容时为 `undefined`。
941
+ */
942
+ function parseUsage(raw) {
943
+ for (const path of [
944
+ [
945
+ "data",
946
+ "Response",
947
+ "Data",
948
+ "Accounts"
949
+ ],
950
+ [
951
+ "data",
952
+ "data",
953
+ "Response",
954
+ "Data",
955
+ "Accounts"
956
+ ],
957
+ [
958
+ "Response",
959
+ "Data",
960
+ "Accounts"
961
+ ]
962
+ ]) {
963
+ const candidate = pointer(raw, path);
964
+ if (Array.isArray(candidate)) return personalUsage(candidate);
965
+ if (candidate === null) return { windows: [] };
966
+ }
967
+ return enterpriseUsage(pointer(raw, ["data", "data"]) ?? pointer(raw, ["data"]) ?? raw);
968
+ }
969
+ /**
970
+ * 向计量端点 POST 并解析响应,所有失败模式都退化为 `undefined` 而不抛出。
971
+ *
972
+ * 用量是设置界面上的参考性读取,因此传输故障、非 2xx 状态、无法解析的响应体
973
+ * 或服务端非零 `code` 都意味着“不显示用量” → 绝不会出现坏掉的侧边栏底部。
974
+ * @param identity - 已登录的身份,由会话刷新。
975
+ * @param path - {@link CODEBUDDY_ENDPOINT} 下的计量路径。
976
+ * @param body - JSON 请求体。
977
+ * @param signal - 可选的取消信号。
978
+ * @returns 已解析的快照,或计费面不可达、回应了不可用响应体时为
979
+ * `undefined`。
980
+ */
981
+ async function postMeter(identity, path, body, signal) {
982
+ let response;
983
+ try {
984
+ response = await fetch(`${CODEBUDDY_ENDPOINT}${path}`, {
985
+ method: "POST",
986
+ headers: meterHeaders(identity),
987
+ body,
988
+ ...signal === void 0 ? {} : { signal }
989
+ });
990
+ } catch {
991
+ return;
992
+ }
993
+ if (!response.ok) return void 0;
994
+ let raw;
995
+ try {
996
+ raw = await response.json();
997
+ } catch {
998
+ return;
999
+ }
1000
+ const envelope = raw;
1001
+ if (envelope !== null && typeof envelope === "object" && envelope.code !== void 0 && envelope.code !== 0) return;
1002
+ return parseUsage(raw);
1003
+ }
1004
+ /**
1005
+ * 获取个人账户的用量:每个生效套餐一个窗口。
1006
+ *
1007
+ * 请求体与官方 IDE 的 `getPersonalUsage` 完全一致(线上抓取):
1008
+ * `PageNumber:1, PageSize:100, ProductCode:"p_tcaca", Status:[0,3]`
1009
+ * 以及从当前时刻起 101 年的 `PackageEndTimeRange` 窗口 — 起点是“现在”,
1010
+ * 终点是 101 年后,依据 `main.js`(`s = new Date`,
1011
+ * `n = s + 101*365*24*60*60*1000`)。
1012
+ * @param identity - 已登录的身份,由会话刷新。
1013
+ * @param signal - 可选的取消信号。
1014
+ * @returns 已解析的快照,或计费面不可达时为 `undefined`。
1015
+ */
1016
+ async function fetchPersonalUsage(identity, signal) {
1017
+ const now = /* @__PURE__ */ new Date();
1018
+ const end = new Date(now.getTime() + 101 * 365 * 24 * 60 * 60 * 1e3);
1019
+ return postMeter(identity, "/v2/billing/meter/get-user-resource", JSON.stringify({
1020
+ PageNumber: 1,
1021
+ PageSize: 100,
1022
+ ProductCode: "p_tcaca",
1023
+ Status: [0, 3],
1024
+ PackageEndTimeRangeBegin: formatTime(now.getTime() / 1e3),
1025
+ PackageEndTimeRangeEnd: formatTime(end.getTime() / 1e3)
1026
+ }), signal);
1027
+ }
1028
+ /**
1029
+ * 获取企业租户的用量:单个 `limitNum`/`credit` 对。
1030
+ *
1031
+ * 企业计费面接受空请求体并在 `data` 下回应,因此该请求就是一次带认证的 POST。
1032
+ * @param identity - 已登录的身份,由会话刷新。
1033
+ * @param signal - 可选的取消信号。
1034
+ * @returns 已解析的快照,或计费面不可达时为 `undefined`。
1035
+ */
1036
+ async function fetchEnterpriseUsage(identity, signal) {
1037
+ return postMeter(identity, "/v2/billing/meter/get-enterprise-user-usage", "{}", signal);
1038
+ }
1039
+ /**
1040
+ * 获取并解析 CodeBuddy 用量快照,按账户类型分叉。
1041
+ *
1042
+ * 视已登录身份是否披露了 `enterpriseId`,委托给 {@link fetchPersonalUsage}
1043
+ * 或 {@link fetchEnterpriseUsage}。所有失败模式都解析为 `undefined` 而不抛出;
1044
+ * 是否重试由调用方决定。
1045
+ * @param identity - 已登录的身份,由会话刷新。
1046
+ * @param signal - 可选的取消信号。
1047
+ * @returns 已解析的快照,或计费面不可达、回应了不可用响应体时为
1048
+ * `undefined`。
1049
+ */
1050
+ async function fetchUsage(identity, signal) {
1051
+ return identity.enterpriseId !== void 0 ? fetchEnterpriseUsage(identity, signal) : fetchPersonalUsage(identity, signal);
1052
+ }
1053
+ //#endregion
1054
+ //#region lib/types/session-store.js
1055
+ /**
1056
+ * 插件自有的会话信息持久化:按会话一个文件,存该会话选用的账号、用过的账号表与
1057
+ * 每轮消耗的积分。
1058
+ *
1059
+ * 这三件事都是"这个会话自己的事实",因此放在同一个文件里
1060
+ * (`$DSH_HOME/data/plugins/llm-codebuddy-power/sessions/<会话id>.json`):读一次
1061
+ * 就拿到它们,也不会出现两份记录各自指向不同账号。文件是纯增量记录 —— 缺文件等价于
1062
+ * "没选过账号、没有积分"。写入是读-改-写的原子替换,并经 [atomic-file.ts](./atomic-file.ts)
1063
+ * 按路径串行化:一轮里画图与聊天会各自累加一次积分,分开读-改-写会丢掉其中一次。
1064
+ *
1065
+ * 文件只增不删:会话被销毁(一次性子代理跑完即如此)不等于用户删除了会话。
1066
+ *
1067
+ * **账号在表里存一次,每轮记录只引用它的序号**:同一个账号会在几十轮里反复出现,逐轮
1068
+ * 内联昵称是同一份事实的重复副本 —— 实测这让文件大了近一倍。键是文件内部自己编的序号
1069
+ * 而不是 uid 的任何截断:一个只在本文件里做引用的键不必携带 uid 的信息,截断反而会
1070
+ * 让两个同前缀的账号共用一条记录(把历史轮次张冠李戴),而序号不可能重复。
1071
+ *
1072
+ * 排版也在这里决定:`credits` 每条一行、`accounts` 每条一行,其余保持缩进。逐条一行
1073
+ * 让追加一轮只改动两行,而整个文件一行虽然更小,却让文件不可读、改动无法辨别。
1074
+ *
1075
+ * @module dsh-llm-codebuddy-power/session-store
1076
+ */
1077
+ /** 空信息:文件缺失、损坏或字段不合法时的结果。 */
1078
+ function emptyInfo() {
1079
+ return {
1080
+ accounts: {},
1081
+ credits: {}
1082
+ };
1083
+ }
1084
+ /** 会话信息文件路径:`$DSH_HOME/data/plugins/llm-codebuddy-power/sessions/<会话id>.json`。
1085
+ * 插件数据统一放 `data/plugins/<插件名(不带dsh-前缀)>/` 下,`data` 是规范的数据目录。 */
1086
+ function sessionInfoPath(sessionId) {
1087
+ return dshHomePath("data", "plugins", "llm-codebuddy-power", "sessions", `${sessionId}.json`);
1088
+ }
1089
+ /**
1090
+ * 账号在账号表里的序号。
1091
+ *
1092
+ * 序号从 0 起、按首次用到该账号的顺序分配,并保留在表里 —— 记录引用的是序号,因此
1093
+ * 序号不能随表内增删而改变,否则已有轮次会指向别的账号。
1094
+ * @param table - 当前的账号表。
1095
+ * @param uid - 要引用的账号 uid。
1096
+ * @returns 该账号的序号;从未用过时是下一个未占用的序号。
1097
+ */
1098
+ function accountKeyFor(table, uid) {
1099
+ for (const [key, ref] of Object.entries(table)) if (ref.uid === uid) return key;
1100
+ let next = 0;
1101
+ for (const key of Object.keys(table)) {
1102
+ const n = Number(key);
1103
+ if (Number.isSafeInteger(n) && n >= next) next = n + 1;
1104
+ }
1105
+ return String(next);
1106
+ }
1107
+ /** 文件读出的账号是否合法(两个事实都得在)。 */
1108
+ function isAccountRef(value) {
1109
+ if (typeof value !== "object" || value === null) return false;
1110
+ const ref = value;
1111
+ return typeof ref.uid === "string" && ref.uid.length > 0 && typeof ref.nick === "string" && ref.nick.length > 0;
1112
+ }
1113
+ /**
1114
+ * 文件读出的记录是否合法。
1115
+ *
1116
+ * `accounts` 与 `credit` 同为必填:一条记录说不出这一轮用的是哪个账号,悬停提示就会
1117
+ * 缺一行,那与"没有这条记录"没有区别,因此不完整即整条丢弃。引用还必须能在同一份
1118
+ * 文件的账号表里解析到 —— 一个悬空引用渲染不出账号,与缺字段是同一种残缺。
1119
+ * 插件尚未发布,磁盘上不存在按旧格式写下的文件,故不保留渐进读取。
1120
+ * @param value - 文件里的一条轮次记录。
1121
+ * @param table - 同一份文件的账号表。
1122
+ * @returns 该记录是否可用。
1123
+ */
1124
+ function isCreditRecord(value, table) {
1125
+ if (typeof value !== "object" || value === null) return false;
1126
+ const record = value;
1127
+ return typeof record.credit === "number" && Number.isFinite(record.credit) && Array.isArray(record.accounts) && record.accounts.length > 0 && record.accounts.every((key) => typeof key === "string" && isAccountRef(table[key]));
1128
+ }
1129
+ /**
1130
+ * 读取一个会话的信息。
1131
+ * @param sessionId - dsh 会话 id。
1132
+ * @returns 该会话选用的账号、用过的账号表与积分记录;缺文件或损坏时返回空信息。
1133
+ */
1134
+ async function loadSessionInfo(sessionId) {
1135
+ try {
1136
+ const parsed = JSON.parse(await promises.readFile(sessionInfoPath(sessionId), "utf-8"));
1137
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return emptyInfo();
1138
+ const record = parsed;
1139
+ const accounts = {};
1140
+ const rawAccounts = record.accounts;
1141
+ if (typeof rawAccounts === "object" && rawAccounts !== null) {
1142
+ for (const [key, value] of Object.entries(rawAccounts)) if (isAccountRef(value)) accounts[key] = value;
1143
+ }
1144
+ const credits = {};
1145
+ const rawCredits = record.credits;
1146
+ if (typeof rawCredits === "object" && rawCredits !== null) {
1147
+ for (const [turn, value] of Object.entries(rawCredits)) if (isCreditRecord(value, accounts)) credits[turn] = value;
1148
+ }
1149
+ const uid = typeof record.uid === "string" && record.uid.length > 0 ? record.uid : void 0;
1150
+ return {
1151
+ ...uid === void 0 ? {} : { uid },
1152
+ accounts,
1153
+ credits
1154
+ };
1155
+ } catch {
1156
+ return emptyInfo();
1157
+ }
1158
+ }
1159
+ /**
1160
+ * 把一份信息渲染成文件内容。
1161
+ *
1162
+ * 逐条一行而不是缩进展开:条目本身是单行记录,缩进只增加字节而不增加可读性。账号表与
1163
+ * 轮次记录各自一行,于是新增一轮只改动两行(diff 可读),而文件整体仍然紧凑。
1164
+ * @param info - 要渲染的信息。
1165
+ * @returns 完整的文件内容,以换行结尾。
1166
+ */
1167
+ function renderSessionInfo(info) {
1168
+ const lines = [];
1169
+ if (info.uid !== void 0) lines.push(` "uid": ${JSON.stringify(info.uid)},`);
1170
+ const accounts = Object.entries(info.accounts);
1171
+ if (accounts.length === 0) lines.push(" \"accounts\": {},");
1172
+ else {
1173
+ lines.push(" \"accounts\": {");
1174
+ accounts.forEach(([key, ref], index) => {
1175
+ lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(ref)}${index === accounts.length - 1 ? "" : ","}`);
1176
+ });
1177
+ lines.push(" },");
1178
+ }
1179
+ const credits = Object.entries(info.credits);
1180
+ if (credits.length === 0) lines.push(" \"credits\": {}");
1181
+ else {
1182
+ lines.push(" \"credits\": {");
1183
+ credits.forEach(([turn, record], index) => {
1184
+ lines.push(` ${JSON.stringify(turn)}: ${JSON.stringify(record)}${index === credits.length - 1 ? "" : ","}`);
1185
+ });
1186
+ lines.push(" }");
1187
+ }
1188
+ return `{\n${lines.join("\n")}\n}\n`;
1189
+ }
1190
+ /**
1191
+ * 读-改-写一个会话的信息(原子替换,文件不存在则新建)。
1192
+ *
1193
+ * 读发生在队列内部,因此排队中的后一次改动看得到前一次的结果,而不是各自基于同一份
1194
+ * 旧内容。文件此刻不可读时按空信息起手,与 {@link loadSessionInfo} 对同一情况的判断一致。
1195
+ * @param sessionId - dsh 会话 id。
1196
+ * @param change - 由当前信息算出新信息;不得就地修改传入值。
1197
+ */
1198
+ async function updateSessionInfo(sessionId, change) {
1199
+ const path = sessionInfoPath(sessionId);
1200
+ await queueWrite(path, async () => {
1201
+ await writeFileAtomic(path, renderSessionInfo(change(await loadSessionInfo(sessionId))));
1202
+ });
1203
+ }
1204
+ //#endregion
1205
+ //#region lib/types/session.js
1206
+ /**
1207
+ * 已登录会话:按账号解析身份、token 新鲜度与缓存的模型目录。
1208
+ *
1209
+ * 一个对象同时持有它们,因为它们共享同一种故障模式 → token 过期会导致
1210
+ * 目录不可读 → 也因为它们都必须在构建请求之前解析完成。刷新是单飞的,而且是
1211
+ * **按账号**单飞:适配器每次流式调用解析一次身份,若不按账号分开,两个账号的
1212
+ * 并发调用会互相取到对方的刷新结果,其中一个的 token 永远停留过期。
1213
+ *
1214
+ * 账号由会话决定:某个会话显式选了账号就用它,否则顺延到派生它的会话(子代理、
1215
+ * fork)、默认账号、最后一次手动切换的账号,最后落到列表第一个。目录与计量快照
1216
+ * 也按账号分别缓存,因为不同账号的可用模型与额度本就可以不同。
1217
+ *
1218
+ * @module dsh-llm-codebuddy-power/session
1219
+ */
1220
+ /** 提前这么久刷新,而不是正好在记录的过期时刻刷新。 */
1221
+ const REFRESH_SKEW_MS = 6e4;
1222
+ /**
1223
+ * 读到的目录在被再次向服务查询之前复用多久。与官方 CLI 的
1224
+ * `PRODUCT_CONFIGURATION_CACHE_TIMEOUT`(48e4 ms)对齐。
1225
+ */
1226
+ const CATALOG_TTL_MS = 480 * 1e3;
1227
+ /**
1228
+ * 计量快照在被再次向真实 CodeBuddy 用量平面查询之前服务多久。浏览器以
1229
+ * 短得多的间隔轮询宿主;这个 TTL 就是让那些轮询不落到计量 API 上的那一层。
1230
+ */
1231
+ const METER_TTL_MS = 60 * 1e3;
1232
+ /** 未登录任何账号时抛出;消息中携带补救方法。 */
1233
+ var NotLoggedInError = class extends Error {
1234
+ constructor(detail) {
1235
+ super(detail);
1236
+ this.name = "NotLoggedInError";
1237
+ }
1238
+ };
1239
+ /**
1240
+ * 把一条文件记录里的账号引用按账号表展开。
1241
+ *
1242
+ * 引用解析不到时返回 `undefined` 而不是抛错:读取路径已经保证不会发生(校验时表已建好),
1243
+ * 这里只是让"残缺记录不交给界面"这条规则不依赖上游。
1244
+ * @param record - 文件里的一条轮次记录。
1245
+ * @param table - 同一份文件的账号表。
1246
+ * @returns 展开后的记录;有引用解析不到时为 `undefined`。
1247
+ */
1248
+ function resolveCreditRecord(record, table) {
1249
+ const accounts = [];
1250
+ for (const key of record.accounts) {
1251
+ const ref = table[key];
1252
+ if (ref === void 0) return void 0;
1253
+ accounts.push({
1254
+ nickname: ref.nick,
1255
+ uid: ref.uid
1256
+ });
1257
+ }
1258
+ return {
1259
+ credit: record.credit,
1260
+ accounts
1261
+ };
1262
+ }
1263
+ /**
1264
+ * 由 dsh 会话身份推导请求头所需的会话 id。
1265
+ *
1266
+ * 官方只在请求头里认紧凑 uuid(无 `session-` 前缀、无连字符),而 Web GUI 的会话
1267
+ * id 正是 `session-<uuid>`:去掉前缀与连字符后恰好 32 位,因此会话 id 可以直接
1268
+ * 换算得到,不必另铸一个随机值 —— 后者一旦承载它的内存状态被丢弃(会话销毁后
1269
+ * 又恢复),同一个会话就会换一个 id。
1270
+ *
1271
+ * 取**最后一个** `session-` 之后的部分,而不是去掉开头那一个:会话 id 还有
1272
+ * `<agent-id>-session-<uuid>` 形态,从开头截取会把 agent 名混进结果,产生非
1273
+ * 十六进制字符。超过 32 位截断,不足则在**前面**补 `0`,使非 uuid 形态(如计数器式
1274
+ * `session-<n>`)也得到定长的合法值;在前面补而不是在后面补,是因为数字只有在
1275
+ * 右对齐时才互不重合 —— 在后面补会让 `session-1` 与 `session-10` 得到同一个值。
1276
+ * @param sessionId - dsh 会话身份。
1277
+ * @returns 32 位紧凑 uuid。
1278
+ */
1279
+ function compactConversationId(sessionId) {
1280
+ const at = sessionId.lastIndexOf("session-");
1281
+ const compact = (at === -1 ? sessionId : sessionId.slice(at + 8)).replace(/-/g, "");
1282
+ return compact.length >= 32 ? compact.slice(0, 32) : compact.padStart(32, "0");
1283
+ }
1284
+ /** 账号状态投影:丢掉取值为空的可选字段,使调用方的 `=== undefined` 判断成立。 */
1285
+ function accountInfo(entry) {
1286
+ const a = entry.account;
1287
+ return {
1288
+ uid: a.uid,
1289
+ nickname: a.nickname,
1290
+ loginExpiresAt: entry.auth.refreshExpiresAt,
1291
+ ...a.uin === void 0 ? {} : { uin: a.uin },
1292
+ ...a.enterpriseId === void 0 ? {} : { enterpriseId: a.enterpriseId },
1293
+ ...a.enterpriseName === void 0 ? {} : { enterpriseName: a.enterpriseName },
1294
+ ...a.enterpriseUserName === void 0 ? {} : { enterpriseUserName: a.enterpriseUserName },
1295
+ ...a.departmentFullName === void 0 ? {} : { departmentFullName: a.departmentFullName }
1296
+ };
1297
+ }
1298
+ /**
1299
+ * 持有单个插件实例的已存账号、每个会话选用的账号与它们的缓存。
1300
+ *
1301
+ * 内存中没有凭据时会从磁盘重新读取,这正是
1302
+ * `dsh plugin --profile web codebuddy-power` 能在不重启的情况下
1303
+ * 让*运行中*的 harness 登录的原因。
1304
+ */
1305
+ var CodeBuddySession = class {
1306
+ logger;
1307
+ onModelsChanged;
1308
+ storage;
1309
+ /** 内存中 `storage` 所读自的凭据文件的新鲜度标记。 */
1310
+ storageKey;
1311
+ /**
1312
+ * {@link storageKey} 是否足以代表 {@link storage}。
1313
+ *
1314
+ * `undefined` 的新鲜度有两种含义 —— 文件真的不存在,或这一次 `fs.stat` 失败(Windows
1315
+ * 上并发替换文件时会短暂如此)—— 所以它不能单独用来判断“没变”。把它当成“没变”而
1316
+ * `storage` 恰好是空的,账号列表就会被读成空、界面显示“未登录”,而磁盘上凭据完好。
1317
+ */
1318
+ storageMemoValid = false;
1319
+ /** 上一次加载时账号集合的 uid 列表,用来判断集合本身有没有变。 */
1320
+ storageUids = "";
1321
+ /** 按账号 uid 单飞的令牌刷新。 */
1322
+ refreshing = /* @__PURE__ */ new Map();
1323
+ /** 按账号 uid 缓存的目录;合并目录由它派生,因此它的增删决定合并目录的内容。 */
1324
+ catalogs = /* @__PURE__ */ new Map();
1325
+ /**
1326
+ * 模型 id → 模型,由 {@link catalogs} 派生的并集索引,同 id 保留先到的那份。
1327
+ *
1328
+ * 集合只由**带会话身份的**读取填充(聊天请求、模型面板),并供拿不到会话身份的
1329
+ * 调用方({@link knownModel}、`listModels`)查询:那些调用方无法知道该用哪个账号
1330
+ * 的目录,主动读取只能落在默认账号上 —— 而当前会话用的可能正是另一个账号。
1331
+ */
1332
+ knownCatalog = /* @__PURE__ */ new Map();
1333
+ /** 按账号 uid 单飞的目录读取。 */
1334
+ catalogReads = /* @__PURE__ */ new Map();
1335
+ /** 按账号 uid 缓存的计量快照;在 {@link METER_TTL_MS} 内直接返回,不访问该平面。 */
1336
+ usageCache = /* @__PURE__ */ new Map();
1337
+ /** 活动会话,以 dsh 会话 id 为键;在 `session/disposed` 时清理。 */
1338
+ tracks = /* @__PURE__ */ new Map();
1339
+ /**
1340
+ * 已读出的会话信息(选用的账号 + 每轮积分),按会话 id。
1341
+ *
1342
+ * 单独于 {@link tracks}:账号选择要在还没有任何回合的会话上就能读到(新建会话后
1343
+ * 立刻切账号,第一条消息就得用它),而 `tracks` 只在 `turn/start` 时才有条目。
1344
+ */
1345
+ infos = /* @__PURE__ */ new Map();
1346
+ /** 正在从文件读取的会话信息,避免并发重复读。 */
1347
+ infoReads = /* @__PURE__ */ new Map();
1348
+ constructor(logger, onModelsChanged) {
1349
+ this.logger = logger;
1350
+ this.onModelsChanged = onModelsChanged;
1351
+ }
1352
+ /**
1353
+ * 在首次见到某会话时以及每个新轮次,为该会话铸出当前轮的请求 id。轮请求 id
1354
+ * 在 harness 打开一个该会话尚未被观察到的轮次时铸出,因此一个用户
1355
+ * 提示词内的所有模型调用都共享它,即使在运行中途插入了提示词。
1356
+ * @param sessionId - dsh 会话身份。
1357
+ * @param turn - harness 刚打开的轮次。
1358
+ * @param parentSessionId - 该会话派生自的会话(子代理、fork),没有时为 `undefined`。
1359
+ */
1360
+ observeTurn(sessionId, turn, parentSessionId) {
1361
+ const current = this.tracks.get(sessionId);
1362
+ if (current === void 0) {
1363
+ this.tracks.set(sessionId, {
1364
+ turn,
1365
+ turnRequestId: compactUuid(),
1366
+ credits: /* @__PURE__ */ new Map(),
1367
+ ...parentSessionId === void 0 ? {} : { parent: parentSessionId }
1368
+ });
1369
+ return;
1370
+ }
1371
+ if (parentSessionId !== void 0) current.parent = parentSessionId;
1372
+ if (current.turn === turn) return;
1373
+ current.turn = turn;
1374
+ current.turnRequestId = compactUuid();
1375
+ }
1376
+ /**
1377
+ * 在 harness 销毁某会话时丢弃它被跟踪的回合状态与已读出的会话信息,使这两个 map
1378
+ * 不会无界增长。磁盘上的会话信息不随之删除:该事件表示会话对象被销毁,而不是用户
1379
+ * 删除了会话 —— 一次性子代理跑完就会触发它。
1380
+ * @param sessionId - dsh 会话身份。
1381
+ */
1382
+ forget(sessionId) {
1383
+ this.tracks.delete(sessionId);
1384
+ this.infos.delete(sessionId);
1385
+ }
1386
+ /**
1387
+ * 某会话对应的 CodeBuddy 会话 id。由 dsh 会话身份推导,不依赖跟踪状态:
1388
+ * 同一会话在任何时刻、任何进程里都得到同一个值。
1389
+ * @param sessionId - dsh 会话身份。
1390
+ * @returns 32 位紧凑 uuid。
1391
+ */
1392
+ conversationIdOf(sessionId) {
1393
+ return compactConversationId(sessionId);
1394
+ }
1395
+ /**
1396
+ * 某会话当前轮的请求 id;该会话尚未被观察到时为 `undefined`。
1397
+ * @param sessionId - dsh 会话身份。
1398
+ * @returns 该轮的请求 id;未被观察到的会话为 `undefined`。
1399
+ */
1400
+ turnRequestIdOf(sessionId) {
1401
+ return this.tracks.get(sessionId)?.turnRequestId;
1402
+ }
1403
+ /**
1404
+ * 读出某会话的信息(选用的账号 + 每轮积分),进程内缓存一次。
1405
+ *
1406
+ * 首读才碰磁盘:账号解析在每次请求上发生,而一个会话的账号选择不会在运行期间被
1407
+ * 别的进程改动(只有本进程写它),因此缓存到会话被销毁即可。
1408
+ * @param sessionId - dsh 会话身份。
1409
+ * @returns 该会话的信息;文件缺失时为“未选账号、无积分”。
1410
+ */
1411
+ async infoOf(sessionId) {
1412
+ const cached = this.infos.get(sessionId);
1413
+ if (cached !== void 0) return cached;
1414
+ let pending = this.infoReads.get(sessionId);
1415
+ if (pending === void 0) {
1416
+ pending = loadSessionInfo(sessionId).finally(() => {
1417
+ if (this.infoReads.get(sessionId) === pending) this.infoReads.delete(sessionId);
1418
+ });
1419
+ this.infoReads.set(sessionId, pending);
1420
+ }
1421
+ const info = await pending;
1422
+ this.infos.set(sessionId, info);
1423
+ return info;
1424
+ }
1425
+ /**
1426
+ * 把一次 CodeBuddy 请求的扣分累加到会话当前轮。聊天请求与画图工具都
1427
+ * 调用:一轮内两者都可能发生(顺序不定),扣分相加。记录合并到插件自有的
1428
+ * 会话信息文件(fire-and-forget),重启后仍可读。模型不存——dsh 用量自带。
1429
+ *
1430
+ * 账号一并记录,且只在首次使用时追加(按完整 uid 判定是否已记过):扣分与"哪个账号
1431
+ * 扣的"必须在同一时刻取自同一个事实,悬停时再查当前账号会把一轮里切换过账号的情况说错。
1432
+ *
1433
+ * 序号在落盘的读-改-写内部才分配:它取决于该文件当前的账号表,而那张表只有在队列里
1434
+ * 读到文件之后才知道。内存里因此存解析好的账号(昵称 + 完整 uid),文件里存序号 —— 两者
1435
+ * 形状不同是有意的,文件只该存一次账号,而每次查询都回表解析会让悬停这条热路径多一层查找。
1436
+ * @param sessionId - dsh 会话 id。
1437
+ * @param credit - 本次请求扣的积分。
1438
+ * @param account - 发起本次请求的账号。
1439
+ */
1440
+ recordCredit(sessionId, credit, account) {
1441
+ const track = this.tracks.get(sessionId);
1442
+ if (track === void 0) return;
1443
+ const turn = track.turn;
1444
+ const existing = track.credits.get(turn);
1445
+ const accounts = existing?.accounts ?? [];
1446
+ const known = accounts.some((entry) => entry.uid === account.uid);
1447
+ const merged = {
1448
+ credit: (existing?.credit ?? 0) + credit,
1449
+ accounts: known ? accounts : [...accounts, {
1450
+ nickname: account.nickname,
1451
+ uid: account.uid
1452
+ }]
1453
+ };
1454
+ track.credits.set(turn, merged);
1455
+ updateSessionInfo(sessionId, (info) => {
1456
+ const table = { ...info.accounts };
1457
+ const keys = merged.accounts.map((entry) => {
1458
+ const key = accountKeyFor(table, entry.uid);
1459
+ table[key] = {
1460
+ uid: entry.uid,
1461
+ nick: entry.nickname
1462
+ };
1463
+ return key;
1464
+ });
1465
+ return {
1466
+ ...info,
1467
+ accounts: table,
1468
+ credits: {
1469
+ ...info.credits,
1470
+ [String(turn)]: {
1471
+ credit: merged.credit,
1472
+ accounts: keys
1473
+ }
1474
+ }
1475
+ };
1476
+ }).catch((error) => {
1477
+ this.logger?.warn(`dsh-codebuddy: could not persist turn credits: ${String(error)}`);
1478
+ });
1479
+ }
1480
+ /**
1481
+ * 某会话某一轮的积分。内存未命中时回退到会话信息文件(重启恢复),
1482
+ * 并回填缓存。
1483
+ * @param sessionId - dsh 会话身份。
1484
+ * @param turn - 轮次号。
1485
+ * @returns 该记录;该轮没有上报任何积分时为 `undefined`。
1486
+ */
1487
+ async creditOf(sessionId, turn) {
1488
+ const memory = this.tracks.get(sessionId)?.credits.get(turn);
1489
+ if (memory !== void 0) return memory;
1490
+ const info = await this.infoOf(sessionId);
1491
+ const track = this.tracks.get(sessionId);
1492
+ for (const [key, record] of Object.entries(info.credits)) {
1493
+ const n = Number(key);
1494
+ if (!Number.isSafeInteger(n)) continue;
1495
+ const resolved = resolveCreditRecord(record, info.accounts);
1496
+ if (resolved !== void 0) track?.credits.set(n, resolved);
1497
+ }
1498
+ const record = info.credits[String(turn)];
1499
+ return record === void 0 ? void 0 : resolveCreditRecord(record, info.accounts);
1500
+ }
1501
+ /**
1502
+ * 遗忘内存中的账号与按账号缓存的数值,并广播这次变化。
1503
+ *
1504
+ * 刻意不动 {@link catalogs} 与由它派生的 {@link knownCatalog}:目录内容
1505
+ * (模型、促销、档位)不随凭据或按模型的覆写变化(覆写每次解析都从磁盘重读,
1506
+ * 见 `model-enrich.ts` 的 `loadOverrides`),而清掉它们会把官方列表的并集一起
1507
+ * 打空 —— 本方法也被改覆写与 401 失效调用,那时别的账号已并进来的模型不该消失。
1508
+ * 账号真的增删时由 {@link forgetDepartedAccounts} 按 uid 精确丢弃。
1509
+ */
1510
+ invalidate() {
1511
+ this.storage = void 0;
1512
+ this.storageMemoValid = false;
1513
+ this.refreshing.clear();
1514
+ this.catalogReads.clear();
1515
+ this.usageCache.clear();
1516
+ this.onModelsChanged?.();
1517
+ }
1518
+ /**
1519
+ * 由当前缓存的各账号目录重建合并目录。
1520
+ *
1521
+ * 账号退出后的处理必须是"重建"而不是"清空":清空会让官方列表塌回默认账号,
1522
+ * 而其余仍登录账号的目录本来就在手上 —— 继续并着才是"退出后重新合并当前已有的
1523
+ * 列表"。重建同时确定性地解决同 id 的归属:只剩下仍在的账号参与竞争。
1524
+ */
1525
+ reindexKnownCatalog() {
1526
+ this.knownCatalog.clear();
1527
+ for (const catalog of this.catalogs.values()) for (const model of catalog.models) if (!this.knownCatalog.has(model.id)) this.knownCatalog.set(model.id, model);
1528
+ }
1529
+ /**
1530
+ * 丢弃已不在账号集合里的账号的缓存与计量快照,并重建合并目录。
1531
+ *
1532
+ * 按成员判定而不是按文件指纹:指纹会被 {@link invalidate} 置空(改一次覆写也走
1533
+ * 那条路),按指纹清空并集会让别的账号的模型凭空消失;而按成员判定是幂等的 ——
1534
+ * 同一个账号仍在时什么都不丢,重复调用也不会多清一次。
1535
+ */
1536
+ forgetDepartedAccounts() {
1537
+ const stored = new Set((this.storage?.accounts ?? []).map((entry) => entry.account.uid));
1538
+ let departed = false;
1539
+ for (const uid of [...this.catalogs.keys()]) {
1540
+ if (stored.has(uid)) continue;
1541
+ this.catalogs.delete(uid);
1542
+ departed = true;
1543
+ }
1544
+ for (const uid of [...this.usageCache.keys()]) if (!stored.has(uid)) this.usageCache.delete(uid);
1545
+ if (departed) this.reindexKnownCatalog();
1546
+ }
1547
+ /**
1548
+ * 只要凭据文件自上次读取以来发生了变化就重新读取,使另一个进程
1549
+ * (CLI)执行的登录或登出能在不重启的情况下到达运行中的 harness:已持有的
1550
+ * `storage` 不能比被重写或删除的凭据文件活得更久。
1551
+ *
1552
+ * 重读之后只有**账号集合本身**变了才算一次账号变更,判据是 uid 列表而不是文件
1553
+ * 指纹:指纹每次写文件都变,于是每刷新一次令牌(另一个进程的 `--status` 也会)
1554
+ * 或每记一次“最后切换的账号”都会被当成账号变更,从而丢掉按账号缓存的计量快照并
1555
+ * 广播一次模型变更 —— 那些都不是账号增删。真的增删时才会丢弃已退出账号的缓存并
1556
+ * 重建合并目录:退出一个账号后官方列表应当继续并着其余账号已读到的目录,而不是
1557
+ * 塌回默认账号一个。
1558
+ */
1559
+ async refreshIfChanged() {
1560
+ const storageKey = await storageFreshness();
1561
+ if (this.storageMemoValid && storageKey !== void 0 && storageKey === this.storageKey) return;
1562
+ this.storageKey = storageKey;
1563
+ this.storage = await loadStorage();
1564
+ this.storageMemoValid = this.storage.accounts.length > 0;
1565
+ const uids = this.storage.accounts.map((account) => account.account.uid).sort().join("\0");
1566
+ if (uids === this.storageUids) return;
1567
+ this.storageUids = uids;
1568
+ this.forgetDepartedAccounts();
1569
+ this.onModelsChanged?.();
1570
+ }
1571
+ /** 账号条目,按 uid 查。 */
1572
+ accountEntry(uid) {
1573
+ return uid === void 0 ? void 0 : this.storage?.accounts.find((a) => a.account.uid === uid);
1574
+ }
1575
+ /**
1576
+ * 该会话自身及其派生祖先显式选用的账号,由近及远。读取某个会话的信息可能要碰盘,
1577
+ * 因此本方法是异步的。
1578
+ *
1579
+ * 走到祖先是因为子代理与 fork 出来的会话在界面上没有自己的账号按钮:它们若不
1580
+ * 沿用父会话,就会静默改用默认账号,把一次派发记到另一个账号头上。
1581
+ */
1582
+ async accountChain(sessionId) {
1583
+ const chain = [];
1584
+ const seen = /* @__PURE__ */ new Set();
1585
+ let current = sessionId;
1586
+ while (current !== void 0 && !seen.has(current)) {
1587
+ seen.add(current);
1588
+ const uid = (await this.infoOf(current)).uid;
1589
+ if (uid !== void 0) chain.push(uid);
1590
+ current = this.tracks.get(current)?.parent;
1591
+ }
1592
+ return chain;
1593
+ }
1594
+ /**
1595
+ * 该会话此刻生效的账号 uid。
1596
+ *
1597
+ * 顺序:会话(或其派生祖先)显式选用的账号 → 默认账号 → 最后一次手动切换的
1598
+ * 账号 → 列表第一个。指向已退出账号的选择会被跳过,而不是当成“没有账号”;
1599
+ * 全部落空时返回 `undefined`,调用方据此报未登录。
1600
+ */
1601
+ async accountUidFor(sessionId) {
1602
+ if (sessionId !== void 0) {
1603
+ for (const uid of await this.accountChain(sessionId)) if (this.accountEntry(uid) !== void 0) return uid;
1604
+ }
1605
+ for (const fallback of [this.storage?.defaultUid, this.storage?.lastUid]) if (this.accountEntry(fallback) !== void 0) return fallback;
1606
+ return this.storage?.accounts[0]?.account.uid;
1607
+ }
1608
+ identityOf(entry) {
1609
+ return {
1610
+ accessToken: entry.auth.accessToken,
1611
+ domain: entry.auth.domain,
1612
+ uid: entry.account.uid,
1613
+ ...entry.account.enterpriseId === void 0 ? {} : { enterpriseId: entry.account.enterpriseId },
1614
+ ...entry.account.departmentFullName === void 0 ? {} : { departmentFullName: entry.account.departmentFullName }
1615
+ };
1616
+ }
1617
+ /** 把刷新后的账号写回内存,并尽力落盘(写不进去也不影响本进程继续使用)。 */
1618
+ async persist(entry) {
1619
+ const accounts = this.storage?.accounts;
1620
+ if (accounts !== void 0) {
1621
+ const at = accounts.findIndex((a) => a.account.uid === entry.account.uid);
1622
+ if (at !== -1) accounts[at] = entry;
1623
+ }
1624
+ try {
1625
+ await updateStorage((current) => ({
1626
+ ...current,
1627
+ accounts: current.accounts.map((a) => a.account.uid === entry.account.uid ? entry : a)
1628
+ }));
1629
+ } catch (error) {
1630
+ this.logger?.warn("dsh-codebuddy: refreshed the session but could not persist it");
1631
+ this.logger?.warn(error);
1632
+ }
1633
+ }
1634
+ /**
1635
+ * 已存账号条目,在首次使用、失效之后以及文件被外部改动时从磁盘读取。
1636
+ * @param sessionId - 用哪个会话的账号;会话未选用时按默认账号解析。
1637
+ * @throws NotLoggedInError 未存有任何凭据时。
1638
+ */
1639
+ async require(sessionId) {
1640
+ await this.refreshIfChanged();
1641
+ const entry = this.accountEntry(await this.accountUidFor(sessionId));
1642
+ if (entry === void 0) throw new NotLoggedInError("CodeBuddy is not signed in. Run `dsh plugin --profile web codebuddy-power` to sign in through your browser; no API key is required.");
1643
+ return entry;
1644
+ }
1645
+ /**
1646
+ * 某账号可用的身份,在访问 token 到达或临近过期时刷新它。同一账号的并发
1647
+ * 调用者共享同一次刷新;不同账号各刷各的。
1648
+ * @param entry - 要解析的账号条目。
1649
+ * @returns 用于认证请求的身份。
1650
+ * @throws NotLoggedInError 刷新 token 本身已过期、只能重新在浏览器登录时。
1651
+ */
1652
+ async identityFor(entry) {
1653
+ const now = Date.now();
1654
+ if (now < entry.auth.expiresAt - REFRESH_SKEW_MS) return this.identityOf(entry);
1655
+ if (now >= entry.auth.refreshExpiresAt) throw new NotLoggedInError(`The CodeBuddy session for ${entry.account.nickname} has expired. Run \`dsh plugin --profile web codebuddy-power\` to sign in again through your browser.`);
1656
+ const uid = entry.account.uid;
1657
+ let pending = this.refreshing.get(uid);
1658
+ if (pending === void 0) {
1659
+ pending = this.refresh(entry).finally(() => {
1660
+ if (this.refreshing.get(uid) === pending) this.refreshing.delete(uid);
1661
+ });
1662
+ this.refreshing.set(uid, pending);
1663
+ }
1664
+ return pending;
1665
+ }
1666
+ async refresh(entry) {
1667
+ const refreshed = await refreshAccessToken(this.identityOf(entry), entry.auth.refreshToken);
1668
+ if (refreshed === void 0) throw new NotLoggedInError(`Refreshing the CodeBuddy session for ${entry.account.nickname} failed. Run \`dsh plugin --profile web codebuddy-power\` to sign in again through your browser.`);
1669
+ const next = {
1670
+ auth: {
1671
+ accessToken: refreshed.accessToken,
1672
+ expiresAt: Date.now() + refreshed.expiresIn * 1e3,
1673
+ refreshToken: refreshed.refreshToken,
1674
+ refreshExpiresAt: Date.now() + refreshed.refreshExpiresIn * 1e3,
1675
+ domain: refreshed.domain
1676
+ },
1677
+ account: entry.account
1678
+ };
1679
+ await this.persist(next);
1680
+ return this.identityOf(next);
1681
+ }
1682
+ /** 由身份构建每个已认证 CodeBuddy 请求都携带的请求头。 */
1683
+ headersFrom(identity) {
1684
+ const headers = {
1685
+ "Authorization": `Bearer ${identity.accessToken}`,
1686
+ "X-Domain": identity.domain,
1687
+ "X-User-Id": identity.uid
1688
+ };
1689
+ if (identity.enterpriseId !== void 0) {
1690
+ headers["X-Enterprise-Id"] = identity.enterpriseId;
1691
+ headers["X-Tenant-Id"] = identity.enterpriseId;
1692
+ }
1693
+ if (identity.departmentFullName !== void 0) headers["X-Department-Info"] = identity.departmentFullName;
1694
+ return headers;
1695
+ }
1696
+ /**
1697
+ * 每个已认证 CodeBuddy 请求都携带的请求头,以及这些头属于哪个账号。
1698
+ *
1699
+ * 两者在**同一次**身份解析中一起给出:分开解析会有两次账号选择,而账号是每次请求
1700
+ * 解析的(用户可在同一步里切换),两次解析可能落到不同账号上 —— 那样记进这一轮的
1701
+ * 账号名就与实际发出去的请求不符。缩写由落盘侧按同一规则算出,故此处给出完整账号。
1702
+ * @param sessionId - 请求所属的会话;决定用哪个账号,缺省时用默认账号。
1703
+ * @returns 身份请求头与发起该请求的账号。
1704
+ */
1705
+ async requestIdentity(sessionId) {
1706
+ const entry = await this.require(sessionId);
1707
+ return {
1708
+ headers: this.headersFrom(await this.identityFor(entry)),
1709
+ account: {
1710
+ uid: entry.account.uid,
1711
+ nickname: entry.account.nickname
1712
+ }
1713
+ };
1714
+ }
1715
+ /**
1716
+ * 是否存在凭据,但不要求必须有。
1717
+ * @returns 已存账号可读取且可用时为 true。
1718
+ */
1719
+ async isLoggedIn() {
1720
+ await this.refreshIfChanged();
1721
+ return (this.storage?.accounts.length ?? 0) > 0;
1722
+ }
1723
+ /**
1724
+ * 已存账号及其默认账号,供设置页与会话切换按钮展示。
1725
+ * @returns 账号列表(顺序与磁盘一致)与默认账号 uid;未登录时列表为空。
1726
+ */
1727
+ async accountList() {
1728
+ await this.refreshIfChanged();
1729
+ return {
1730
+ accounts: (this.storage?.accounts ?? []).map(accountInfo),
1731
+ ...this.storage?.defaultUid === void 0 ? {} : { defaultUid: this.storage.defaultUid }
1732
+ };
1733
+ }
1734
+ /**
1735
+ * 某会话此刻生效的账号 uid。
1736
+ * @param sessionId - dsh 会话身份;缺省时用默认账号。
1737
+ * @returns 账号 uid;一个账号也没有时为 `undefined`。
1738
+ */
1739
+ async activeUid(sessionId) {
1740
+ await this.refreshIfChanged();
1741
+ return this.accountUidFor(sessionId);
1742
+ }
1743
+ /**
1744
+ * 把某会话的账号切换为指定账号,并把它记为最后一次手动切换的账号。
1745
+ * @param sessionId - dsh 会话身份。
1746
+ * @param uid - 要使用的账号 uid。
1747
+ * @returns 该 uid 是已存账号时为 true;否则不做任何改动并返回 false。
1748
+ */
1749
+ async activate(sessionId, uid) {
1750
+ await this.refreshIfChanged();
1751
+ if (this.accountEntry(uid) === void 0) return false;
1752
+ const info = await this.infoOf(sessionId);
1753
+ await updateSessionInfo(sessionId, (current) => ({
1754
+ ...current,
1755
+ uid
1756
+ }));
1757
+ this.infos.set(sessionId, {
1758
+ ...info,
1759
+ uid
1760
+ });
1761
+ await setLastAccount(uid);
1762
+ return true;
1763
+ }
1764
+ /**
1765
+ * CodeBuddy 模型目录,短暂缓存,并在并发读取者之间共享。
1766
+ * @param signal - 底层读取的可选取消信号。
1767
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1768
+ * @returns 目录中的模型,按服务自身的顺序。
1769
+ */
1770
+ async models(signal, sessionId) {
1771
+ return (await this.catalogData(signal, sessionId)).models;
1772
+ }
1773
+ /**
1774
+ * 目录及其推广与档位,在同一个 TTL 下按账号一起缓存,并按账号单飞,使列表与
1775
+ * 面板富化共享同一次读取。
1776
+ * @param signal - 底层读取的可选取消信号。
1777
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1778
+ * @returns 模型、推广与档位。
1779
+ */
1780
+ async catalogData(signal, sessionId) {
1781
+ await this.refreshIfChanged();
1782
+ const uid = await this.accountUidFor(sessionId);
1783
+ if (uid === void 0) throw new NotLoggedInError("CodeBuddy is not signed in. Run `dsh plugin --profile web codebuddy-power` to sign in through your browser; no API key is required.");
1784
+ const cached = this.catalogs.get(uid);
1785
+ if (cached !== void 0 && Date.now() - cached.readAt < CATALOG_TTL_MS) return cached;
1786
+ let pending = this.catalogReads.get(uid);
1787
+ if (pending === void 0) {
1788
+ pending = this.readCatalog(uid, signal).finally(() => {
1789
+ if (this.catalogReads.get(uid) === pending) this.catalogReads.delete(uid);
1790
+ });
1791
+ this.catalogReads.set(uid, pending);
1792
+ }
1793
+ return pending;
1794
+ }
1795
+ async readCatalog(uid, signal) {
1796
+ const entry = this.accountEntry(uid);
1797
+ if (entry === void 0) throw new NotLoggedInError(`The CodeBuddy account ${uid} is no longer stored.`);
1798
+ const config = await getConfig(this.headersFrom(await this.identityFor(entry)), signal);
1799
+ const models = config.models.filter((model) => typeof model.id === "string" && model.id.length > 0);
1800
+ const catalog = {
1801
+ models,
1802
+ promotions: config.modelPromotions ?? [],
1803
+ tiers: config.modelTiers
1804
+ };
1805
+ if (this.accountEntry(uid) === void 0) return catalog;
1806
+ this.catalogs.set(uid, {
1807
+ ...catalog,
1808
+ readAt: Date.now()
1809
+ });
1810
+ let grown = false;
1811
+ for (const model of models) {
1812
+ if (this.knownCatalog.has(model.id)) continue;
1813
+ this.knownCatalog.set(model.id, model);
1814
+ grown = true;
1815
+ }
1816
+ if (grown) this.onModelsChanged?.();
1817
+ return catalog;
1818
+ }
1819
+ /**
1820
+ * 从各账号已读到的目录的并集里查一个模型,**不发起任何请求**。
1821
+ *
1822
+ * 供拿不到会话身份的调用方使用(适配器的 `resolveModel`):它无法知道该用哪个
1823
+ * 账号的目录,主动读取只能落在默认账号上,而当前会话用的可能正是另一个账号 ——
1824
+ * 那会把一个账号独有的模型解析成保守的默认能力(上下文窗口、输出上限、图片输入
1825
+ * 全部按最保守处理)。并集由带会话身份的读取填充,因此这个查询零成本、也不改变
1826
+ * "谁在读目录"这件事。
1827
+ * @param modelId - 模型 id。
1828
+ * @returns 该模型;尚无任何账号读到过它时为 `undefined`。
1829
+ */
1830
+ knownModel(modelId) {
1831
+ return this.knownCatalog.get(modelId);
1832
+ }
1833
+ /**
1834
+ * 各账号已读到的目录并集里的全部模型。
1835
+ *
1836
+ * 与 {@link knownModel} 同为只读查询:官方模型列表按它取数,因此它既包含默认
1837
+ * 账号的模型(冷启动那一次读取必然先做),也包含其他账号在会话里读过之后并进来
1838
+ * 的模型。顺序按各账号目录并入的先后,而非任何排序 —— 官方列表自行决定呈现顺序。
1839
+ * @returns 并集里的模型。
1840
+ */
1841
+ knownModels() {
1842
+ return [...this.knownCatalog.values()];
1843
+ }
1844
+ /**
1845
+ * 目录及其推广/档位;读不到时返回空值 —— 供面板使用的
1846
+ * {@link catalogData} 建议性读取孪生体。
1847
+ * @param signal - 可选取消信号。
1848
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1849
+ * @returns 目录与活动,或空列表。
1850
+ */
1851
+ async catalogDataOrEmpty(signal, sessionId) {
1852
+ try {
1853
+ return await this.catalogData(signal, sessionId);
1854
+ } catch (error) {
1855
+ if (error instanceof NotLoggedInError) return {
1856
+ models: [],
1857
+ promotions: [],
1858
+ tiers: void 0
1859
+ };
1860
+ this.logger?.warn("dsh-codebuddy: could not read the model catalog");
1861
+ this.logger?.warn(error);
1862
+ return {
1863
+ models: [],
1864
+ promotions: [],
1865
+ tiers: void 0
1866
+ };
1867
+ }
1868
+ }
1869
+ /**
1870
+ * 目录;读不到时返回空列表。
1871
+ *
1872
+ * 列出模型是设置页上的浏览动作,所以失败必须降级为“没有可显示的内容”,
1873
+ * 而不是让页面崩掉。请求路径直接使用 {@link models} 并保留真实失败。
1874
+ * @param signal - 可选取消信号。
1875
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1876
+ * @returns 目录,或空列表。
1877
+ */
1878
+ async modelsOrEmpty(signal, sessionId) {
1879
+ try {
1880
+ return await this.models(signal, sessionId);
1881
+ } catch (error) {
1882
+ if (error instanceof NotLoggedInError) return [];
1883
+ this.logger?.warn("dsh-codebuddy: could not read the model catalog");
1884
+ this.logger?.warn(error);
1885
+ return [];
1886
+ }
1887
+ }
1888
+ /**
1889
+ * CodeBuddy 用量/配额快照;读不到时为 `undefined`。
1890
+ *
1891
+ * 用量是界面上的建议性读取,所以计量故障必须降级为“没有可显示的
1892
+ * 内容”而不是向外传播:{@link NotLoggedInError} 表现为未登录状态,
1893
+ * 其他每种失败(传输、解析、刷新过期)都在一条警告之后解析为
1894
+ * `undefined`。身份通过与聊天请求相同的按账号单飞刷新解析,所以并发的计量
1895
+ * 读取绝不会消耗两次刷新 token。
1896
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1897
+ * @param signal - 可选取消信号。
1898
+ * @returns 该快照;未存有任何凭据或计量平面不可达时为 `undefined`。
1899
+ */
1900
+ async usage(sessionId, signal) {
1901
+ await this.refreshIfChanged();
1902
+ const entry = this.accountEntry(await this.accountUidFor(sessionId));
1903
+ if (entry === void 0) return void 0;
1904
+ const uid = entry.account.uid;
1905
+ const cached = this.usageCache.get(uid);
1906
+ if (cached !== void 0 && Date.now() - cached.readAt < METER_TTL_MS) return cached.snapshot;
1907
+ let identity;
1908
+ try {
1909
+ identity = await this.identityFor(entry);
1910
+ } catch (error) {
1911
+ if (error instanceof NotLoggedInError) return void 0;
1912
+ this.logger?.warn("dsh-codebuddy: could not resolve identity for usage read");
1913
+ this.logger?.warn(error);
1914
+ return;
1915
+ }
1916
+ const snapshot = await fetchUsage(identity, signal);
1917
+ if (snapshot !== void 0) {
1918
+ this.usageCache.set(uid, {
1919
+ snapshot,
1920
+ readAt: Date.now()
1921
+ });
1922
+ return snapshot;
1923
+ }
1924
+ return cached?.snapshot;
1925
+ }
1926
+ };
1927
+ //#endregion
1928
+ //#region lib/types/cli/login.js
1929
+ /**
1930
+ * `codebuddy-power`:通过浏览器登录 CodeBuddy,把账号追加到插件读取的账号列表,
1931
+ * 并管理那份列表。
1932
+ *
1933
+ * 刻意作为独立入口而非 harness 内的提示:该流程需要浏览器和
1934
+ * 人工,而运行中的 agent 不应为等待它而阻塞模型调用。已在运行
1935
+ * 的 harness 会在下一次请求时取用该账号,无需重启。
1936
+ *
1937
+ * 用法:
1938
+ * codebuddy-power 登录并追加账号
1939
+ * codebuddy-power --status 列出全部账号与默认账号
1940
+ * codebuddy-power --default <账号> 把某个账号设为默认
1941
+ * codebuddy-power --logout <账号> 退出一个账号
1942
+ * codebuddy-power --logout --all 退出全部账号
1943
+ * codebuddy-power --no-open 只打印 URL,不打开浏览器
1944
+ *
1945
+ * `<账号>` 可以是 `--status` 列出的序号、账号 uid 或昵称。
1946
+ *
1947
+ * @module dsh-llm-codebuddy-power/cli/login
1948
+ */
1949
+ /**
1950
+ * 按 uid、`--status` 列出的序号或昵称定位一个账号。
1951
+ *
1952
+ * 优先级固定为 uid → 序号 → 昵称:昵称可以重复,而 uid 与序号不会,所以更精确
1953
+ * 的写法先被认领。昵称命中多个时不猜,直接报错要求改用前两种写法。
1954
+ * @param accounts - 已存账号,顺序与磁盘一致。
1955
+ * @param spec - 命令行给出的账号写法。
1956
+ * @returns 命中的账号,或失败说明。
1957
+ */
1958
+ function lookupAccount(accounts, spec) {
1959
+ const byUid = accounts.find((account) => account.account.uid === spec);
1960
+ if (byUid !== void 0) return { entry: byUid };
1961
+ if (/^\d+$/.test(spec)) {
1962
+ const byIndex = accounts[Number(spec) - 1];
1963
+ if (byIndex !== void 0) return { entry: byIndex };
1964
+ }
1965
+ const byNickname = accounts.filter((account) => account.account.nickname === spec);
1966
+ if (byNickname.length === 1) return { entry: byNickname[0] };
1967
+ if (byNickname.length > 1) return { failure: `The nickname "${spec}" matches ${byNickname.length} accounts; use a uid or an index instead.` };
1968
+ return { failure: `No stored account matches "${spec}". Run --status to list them.` };
1969
+ }
1970
+ /** 打印全部账号,标出默认账号,再列出默认账号可用的模型。 */
1971
+ async function status() {
1972
+ const stored = await loadStorage();
1973
+ const accounts = stored.accounts;
1974
+ if (accounts.length === 0) {
1975
+ console.log("Not signed in. Run `dsh plugin --profile web codebuddy-power` to sign in through your browser.");
1976
+ return 1;
1977
+ }
1978
+ console.log(`Accounts (${accounts.length}):`);
1979
+ accounts.forEach((account, index) => {
1980
+ const enterprise = account.account.enterpriseName === void 0 ? "" : ` [${account.account.enterpriseName}]`;
1981
+ const isDefault = account.account.uid === stored.defaultUid ? " (default)" : "";
1982
+ console.log(` ${index + 1}. ${account.account.nickname}${enterprise} (uid ${account.account.uid})${isDefault}`);
1983
+ });
1984
+ console.log(`Credential: ${getStoragePath()}`);
1985
+ const models = await new CodeBuddySession().modelsOrEmpty();
1986
+ if (models.length === 0) {
1987
+ console.log("Models: none readable (the session may need refreshing)");
1988
+ return 0;
1989
+ }
1990
+ console.log(`Models (${models.length}):`);
1991
+ for (const model of models) {
1992
+ const label = model.credits === void 0 ? model.name : `${model.name} [${model.credits.trim().replace(/\s+credits$/i, "")}]`;
1993
+ const flags = [
1994
+ model.supportsToolCall === true ? "tools" : void 0,
1995
+ model.supportsReasoning === true ? "reasoning" : void 0,
1996
+ model.supportsImages === true ? "images" : void 0,
1997
+ isBlockedModelId(model.id) ? "blocked" : void 0,
1998
+ hasDisclosedCapacity(model) ? void 0 : "no size, not offered"
1999
+ ].filter(Boolean).join(", ");
2000
+ console.log(` ${model.id} ${label}${flags.length > 0 ? ` (${flags})` : ""}`);
2001
+ }
2002
+ return 0;
2003
+ }
2004
+ /** 设置默认账号。 */
2005
+ async function setDefault(spec) {
2006
+ const found = lookupAccount((await loadStorage()).accounts, spec);
2007
+ if ("failure" in found) {
2008
+ console.error(found.failure);
2009
+ return 1;
2010
+ }
2011
+ await setDefaultAccount(found.entry.account.uid);
2012
+ console.log(`Default account is now ${found.entry.account.nickname} (uid ${found.entry.account.uid}).`);
2013
+ return 0;
2014
+ }
2015
+ /** 退出一个账号,或 `--all` 时退出全部。 */
2016
+ async function logout(spec, all) {
2017
+ const stored = await loadStorage();
2018
+ if (stored.accounts.length === 0) {
2019
+ console.log("Not signed in.");
2020
+ return 0;
2021
+ }
2022
+ let uids;
2023
+ let described;
2024
+ if (all) {
2025
+ uids = stored.accounts.map((account) => account.account.uid);
2026
+ described = `all ${uids.length} account(s)`;
2027
+ } else {
2028
+ if (spec === void 0) {
2029
+ console.error("--logout needs an account (uid, index, or nickname), or --all.");
2030
+ return 1;
2031
+ }
2032
+ const found = lookupAccount(stored.accounts, spec);
2033
+ if ("failure" in found) {
2034
+ console.error(found.failure);
2035
+ return 1;
2036
+ }
2037
+ uids = [found.entry.account.uid];
2038
+ described = `${found.entry.account.nickname} (uid ${found.entry.account.uid})`;
2039
+ }
2040
+ await removeAccounts(uids);
2041
+ console.log(`Signed out: removed ${described}.`);
2042
+ return 0;
2043
+ }
2044
+ async function main() {
2045
+ const argv = process.argv.slice(2);
2046
+ const args = new Set(argv);
2047
+ /** 取 `--flag` 之后的一个值;没有值时返回 undefined。 */
2048
+ const valueOf = (flag) => {
2049
+ const at = argv.indexOf(flag);
2050
+ return at === -1 ? void 0 : argv[at + 1];
2051
+ };
2052
+ if (args.has("--help") || args.has("-h")) {
2053
+ console.log("Usage: dsh plugin --profile web codebuddy-power [--status | --default <account> | --logout <account> | --logout --all | --no-open]");
2054
+ return 0;
2055
+ }
2056
+ if (args.has("--status")) return status();
2057
+ if (args.has("--default")) {
2058
+ const spec = valueOf("--default");
2059
+ if (spec === void 0) {
2060
+ console.error("--default needs an account (uid, index, or nickname).");
2061
+ return 1;
2062
+ }
2063
+ return setDefault(spec);
2064
+ }
2065
+ if (args.has("--logout")) return logout(valueOf("--logout"), args.has("--all"));
2066
+ const controller = new AbortController();
2067
+ const onSignal = () => {
2068
+ controller.abort();
2069
+ };
2070
+ process.once("SIGINT", onSignal);
2071
+ process.once("SIGTERM", onSignal);
2072
+ try {
2073
+ const result = await login({
2074
+ openBrowser: !args.has("--no-open"),
2075
+ onUrl: (url) => {
2076
+ console.log("Open this URL to sign in to CodeBuddy:");
2077
+ console.log(` ${url}`);
2078
+ console.log("Waiting for the browser sign-in to complete...");
2079
+ }
2080
+ }, controller.signal);
2081
+ console.log(`Signed in as ${result.nickname}.`);
2082
+ console.log(`Credential written to ${getStoragePath()}`);
2083
+ return 0;
2084
+ } catch (error) {
2085
+ console.error(`Sign-in failed: ${error instanceof Error ? error.message : String(error)}`);
2086
+ return 1;
2087
+ } finally {
2088
+ process.off("SIGINT", onSignal);
2089
+ process.off("SIGTERM", onSignal);
2090
+ }
2091
+ }
2092
+ main().then((code) => {
2093
+ process.exitCode = code;
2094
+ }).catch((error) => {
2095
+ console.error(error);
2096
+ process.exitCode = 1;
2097
+ });
2098
+ //#endregion
2099
+ export {};