dsh-connect-workbuddy 4.6.4 → 4.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/docs/WINDOWS.md +40 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +5 -1
- package/lib/{host-heartbeat-BvxLN-O5.js → host-heartbeat-NHBxcmkQ.js} +47 -5
- package/lib/index.d.ts +22 -0
- package/lib/index.js +400 -245
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.6 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
> 版本号是 **patch**:只动诊断(`WORKBUDDY_BODY_REPORT` 的报告内容),不改请求、响应、计费或界面。**默认行为完全不变**——报告默认仍是关的,关着时这版与 4.6.5 行为等价。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **body 报告只记「发了什么」不记「上游算了多少」,导致 #59 的抓取无法回答它要问的问题(issue #59)**
|
|
10
|
+
- **缺口**:`WORKBUDDY_BODY_REPORT` 抓的是**本地组装好、发往上游之前**的 body 构成(`bodyBytes` / `chars` / 各块字符数)。而 #59 要判定的是「同一份历史为什么国际网关算 1.94 倍」。**只有「发出侧」时,两种世界长得一模一样**:
|
|
11
|
+
- **A** 网关对几乎相同的字节算出两倍 → **上游的问题**,插件无能为力;
|
|
12
|
+
- **B** 两边发出的 body 其实不同 → **我们的问题**,要修。
|
|
13
|
+
|
|
14
|
+
报告者 10-16 抓到的配对 body 总量只差 **0.3%**(3,554,248 vs 3,565,415 字节),但仅凭这个数无法区分 A 与 B —— 缺的正是「上游回了多少」。
|
|
15
|
+
- **改了什么**:报告每行新增 `promptTokens` / `completionTokens` / `cachedTokens` 三个**可选**字段,取自上游响应流里的 `usage` 块。至此一行同时给出两半:**发出去的字节** 与 **网关算的 token**,两者一比即可定案。
|
|
16
|
+
- **为什么必须新加一个响应侧钩子**:`usage` 走的是**响应流**,而不是请求。`createWorkBuddyShim` 原先只有 `onRequestBody`(请求发出前),响应侧不记任何东西 —— 而 `usage` 只在上游的**最后一帧**才出现。新增 `onUpstreamUsage`,在 `RecoveryStream` 解析每一帧时读取。
|
|
17
|
+
- **★ 一个位置性缺陷,写完就撞上了**:`usage` 帧**不带 `choices`**(它是流末尾的独立帧),而 `handleFrame()` 里第一个早退分支就是「没有 choices 就原样转发」。把读取放在那个分支之后,代码跑起来一切正常、**永不报错**,只是永远拿不到数。守卫 `tests/issue59-upstream-usage.spec.ts` 的第 1 例专门钉这一条:**变异(把读取挪到 `choices` 检查之后)→ 红 2 例**,正是原始的漏读形态。
|
|
18
|
+
- **「没有」不等于「零」**:上游没报 `usage`(请求失败、流被截断、非流式路径)时字段**缺省**,而不是写 `0`。这是全条的关键区分——把「没测到」记成「没花钱」,会让整个对比失效。守卫用一条显式用例钉住(变异:把缺失记成 `promptTokens: 0` → **5 例全红**)。
|
|
19
|
+
- **每个流只报一次**:`usageReported` 幂等,因为 failover 重试会**另起一个 `RecoveryStream`**,同一个 body 可能被量两次;先到的那次是实际服务的那次。
|
|
20
|
+
- **写回同一行,不新起一行**:两个半边的可观测时刻不同(发出侧在请求前、计费侧在流结束后),所以 `recordBodyReport` 改为返回一个 handle,等 `usage` 到了再**回填它自己那一行**。之所以不能追加第三行:报告者的用法是「取 `cn.jsonl` 与 `global.jsonl` 的**最后一行**对比」,多出来的 usage-only 行会**静默变成被比较的那一行**。
|
|
21
|
+
- **行身份靠四元组匹配**(`at` + `region` + `bodyBytes` + `messageCount`),故意不用 `at` 一个字段:同一毫秒内可能有两笔请求。**这里踩到并修掉了一个测试自身的洞**:初版测试标注的是**最后一行**,而回填是从文件末尾**倒着找**的 —— 于是把 `bodyBytes` 校验整个删掉,测试**照样全绿**。改成标注**靠前**那一行后,该变异立刻转红;`messageCount` 同理(构造了两份**字节数完全相同、消息条数不同**的 body:一条 51 字符的消息 与 两条 11 字符的消息,都是 196 字节)。**最终变异验证**:去掉 `bodyBytes` 校验 → 红 1;去掉 `messageCount` 校验 → 红 1;两者都去掉 → 红 2。
|
|
22
|
+
- **两道幂等闸刻意保留冗余**:handle 内的 `done` 与磁盘上的「已标注就跳过」互为备份,各自覆盖另一种情形(后者专治「同一 body 的第二个 handle」)。只删一道**行为上不可观测**,删两道才红——这一点写进了代码注释,免得后人以为其中一道是死代码而删掉。
|
|
23
|
+
- **缓存命中一并记录**:两个网关的缓存命中**拼写不同**(OpenAI 的 `prompt_tokens_details.cached_tokens` 与国内的 `prompt_cache_hit_tokens`),两种都读、取先命中者,否则同一份对话在两个区域会显示成一个有缓存、一个没有。
|
|
24
|
+
- **对 #59 的实际意义**:这一版让报告者**不必再做重放实验**——同一会话两区各抓一次,直接比 `promptTokens` 两列即可区分 A / B。重放要发的是一整份真实历史,既贵,又会引入「两次请求之间还发生了什么」的新变量。
|
|
25
|
+
- **本条不包含对 1.94 倍的结论**:它补的是**验证手段**。那个结论仍待报告者带新字段重抓后才能下。
|
|
26
|
+
|
|
27
|
+
## 4.6.5 (2026-10-16)
|
|
28
|
+
|
|
29
|
+
> 版本号是 **patch**:两处诊断正确性修复,没有接口或配置变更。
|
|
30
|
+
|
|
31
|
+
### Fixes
|
|
32
|
+
|
|
33
|
+
- **国际版 App 探测不到:可执行文件候选缺了 `WorkBuddyAI.exe` 与 `WorkBuddyAI` 目录(issue #68)**
|
|
34
|
+
- **症状**:只装了国际版的机器上,插件拿不到密钥 payload 的**来源** → 凭据读不出 → 界面显示成「属于另一个地区」。而报告者实测国际版装在 **`%LOCALAPPDATA%\Programs\WorkBuddyAI\WorkBuddyAI.exe`** —— **目录名与二进制名都和国内版不同**,`workbuddyAppExecutableCandidates()` 的 win32 分支两个都没覆盖。
|
|
35
|
+
- **为什么这类漏探特别难查**:它的失败形态**不是「找不到可执行文件」**,而是「凭据读不出」,最后被下游渲染成区域问题。探针少一个候选与「用户没装 App」在界面上长得一样。
|
|
36
|
+
- **改了什么**:新增 `WINDOWS_APP_EXECUTABLE_NAMES`(`WorkBuddy.exe` / `WorkBuddyAI.exe`)与 `WINDOWS_APP_SUBDIRECTORIES`(`Programs\WorkBuddy` / `WorkBuddy` / `Programs\WorkBuddyAI` / `WorkBuddyAI`),候选按「三个 base × 四个目录 × 两个二进制」笛卡尔积生成(16 个,原先是 4 个)。**目录与二进制不做配对假设**:观察到的是 `WorkBuddyAI\WorkBuddyAI.exe`,但一个 miss 在这里是不可见的,所以两种拼法都试。
|
|
37
|
+
- **`normalizeRegistryExecutable()` 同步放宽**:它是另一处按文件名判断的地方,也必须认国际版 —— 否则注册表里指向国际版二进制的 `DisplayIcon` 会被当成「别的产品的二进制」拒掉。**这个值是会被启动的**,所以只加白名单里的两个名字,不是「任何 exe 都收」。
|
|
38
|
+
- **守卫**:`tests/at-rest.spec.ts` 新增 2 例并把原先断言候选数为 `4` 的那条改成断言**乘积**(`3 × 4 × 2`)—— 用乘积而不是常量,是为了让「少掉一个维度」这种编辑无法靠集合变小而静默通过。新增例子断言国际版路径在内、国内版仍在且在它之前;另一条断言注册表校验接受两种二进制、且**仍拒绝** `uninstall.exe` 与 `WorkBuddy.exe.bak`。**变异验证**:① 去掉 `WorkBuddyAI.exe` → 红 3 例;② 去掉 `WorkBuddyAI` 目录 → 红 2 例;③ 注册表校验不再限制文件名 → 红 2 例。
|
|
39
|
+
- **证据档位**:报告的路径来自报告者的真机(属 A 档观察);**本仓库无法在真机上复现**(无一等 Windows 机器在手),所以这条修复的依据是 A 档观察 + 本仓库的形态断言,未在真机复核 —— 按 `docs/WINDOWS.md` §2 的口径,其「国际版路径命中」仍是 **C 档(约定推导)**,待 Windows 用户回报后升格。
|
|
40
|
+
|
|
41
|
+
- **「另一个地区」把「解密失败」盖住了:优先级顺序把更具体的诊断吃掉(issue #68)**
|
|
42
|
+
- **症状**:凭据**解密失败**时,卡片显示的是「找到了你的 WorkBuddy 登录信息,但它**属于另一个地区的标签页**」。报告者据此去切 tab,**而切 tab 永远救不了解密失败** —— 两个区域都读不出来,密钥推导才是问题。
|
|
43
|
+
- **根因**:`signedOutNotice()` 里 `wrong-region` **先于** `encrypted` 判定,而这两者**不是互斥的,它们会同时出现**,且同时出现是**常态**:`diagnose()` 会探测**两个区域**的文件,于是一台装了**两个 WorkBuddy 版本**的机器上,新版的文件解密失败(`encrypted`),旧版的文件仍能解开、只是属于另一个区域(`wrong-region`)。先判 `wrong-region` 的后果是:只要有一个可解的兄弟文件,headline 就永远说「切 tab」,把真正的阻塞压在下面。
|
|
44
|
+
- **改了什么**:把 `encrypted` 提到 `wrong-region` **之前**。判据不是「哪个更常见」,而是「**哪个是硬阻塞**」—— 另一个标签页上有可读的登录是一条更便宜的修法,但前提是**有登录可读**。诚实地说:原先的顺序把用户推向唯一不可能起作用的动作,而这**正是这个函数存在的理由**,只是两个原因的位置对调了。
|
|
45
|
+
- **反向那一半也保住了**:没有 `encrypted` 时,`wrong-region` 仍然胜出(新增一条测试钉住),否则就是用一个错 headline 换另一个。
|
|
46
|
+
- **守卫**:`tests/searched-paths.spec.ts` 新增 2 例 —— ① 两者共存时 `encrypted` 胜出,且**断言渲染出的字符串**里不含「切换到上方对应标签页」(文案才是用户看到的东西);② 无阻塞时 `wrong-region` 仍胜出。**变异验证**:把顺序恢复成 `wrong-region` 先判 → 红 1 例。
|
|
47
|
+
|
|
3
48
|
## 4.6.4 (2026-10-16)
|
|
4
49
|
|
|
5
50
|
> 版本号是 **patch**:只动开发脚本与测试,**不改插件运行时行为**。
|
package/docs/WINDOWS.md
CHANGED
|
@@ -40,11 +40,50 @@ Windows 在本项目里不是「顺带能跑」的平台,而是**有专属分
|
|
|
40
40
|
|
|
41
41
|
| 档位 | 含义 | 实例 |
|
|
42
42
|
| --- | --- | --- |
|
|
43
|
-
| **A 真机取证** | 在真实 Windows 上取得,可作为最终判据 | ①
|
|
43
|
+
| **A 真机取证** | 在真实 Windows 上取得,可作为最终判据 | ① 真实 Windows 安装(2026-09-26)里 `workbuddy-desktop.info` 的 `nickname` / `phoneNumber` / `accessToken` 信封**观察到 `keyId=9127dea1b44020a7`**;② issue #13 报告的 `E:\WorkBuddy\WorkBuddy.exe`;③ **§1-5 的控制台闪窗**(见下方专门说明);④ §1-1 凭据目录与 §1-2 可执行文件候选在真机上命中(`doctor` 全绿,见 §7 记录) |
|
|
44
44
|
| **B CI 实测** | `windows-latest` 上跑过,覆盖跨平台正确性 | 套件全绿;`tests/auth.spec.ts` 的 `win32` 分支断言(注入 `platform`/`home`/`env`,**任何主机上都能跑**) |
|
|
45
45
|
| **C 约定推导** | 按平台约定写出 + 单元测试钉住,**未在真机逐项验证** | 四个凭据/可执行文件候选路径中**未在本机命中的那些**(见 §7 记录:本机只命中了 `%LOCALAPPDATA%\Programs\WorkBuddy` 与 `%LOCALAPPDATA%\...\auth`,其余候选仍属 C 档) |
|
|
46
|
+
|
|
47
|
+
> **2026-10-16 更正(issue #68 逼出来的)**:上面 ① 此前写作「`tests/at-rest.spec.ts` 里**固定向量** `keyId=9127dea1b44020a7`」,并把该测试当作这条观察的守卫。**那个说法是错的**:观察本身成立(§7 的记录也独立记着它),但**推导出该 keyId 的那把 secret 从未被留存**,所以测试只能断言 16 位十六进制**形态**,无法复现该值 —— 它此前并未真正钉住这个向量,而文档把它读成了「已钉住」。
|
|
48
|
+
>
|
|
49
|
+
> 现已把测试改名为「derives a 16-hex-char id, which is the shape the real envelopes carry」并在注释里写明它不证明什么;**推导规则**(哈希 base64 串而非解码字节、取 `sha256(key)` 前 16 位十六进制)由它上面的两条测试真正钉住 —— 那两条才是否证得动的。
|
|
50
|
+
>
|
|
51
|
+
> 若要真正钉住这个向量,需要**连同推导它的一把 secret 一起取样**(或至少记录 `sha256(secret)` 的输入形式);在那之前,① 仍是**观察记录**,不是**回归守卫**。登记在此,免得下一个人再把它当成守卫。
|
|
52
|
+
>
|
|
53
|
+
> **2026-10-16 再更正(issue #68 结案,A 档)**:这条「需要连同 secret 一起取样」的待办**已在 macOS 上一台装着两个版本的真机上完成**,三个 keyId 全部对上、且**全部由同一把 secret 推出**。结论与探针见 §2.1。**要点:`keyId` 不是「哪把 key 在加密」的证据** —— 同一个 secret 在不同哈希输入下会给出**三个都合法**的 keyId,而三者互不相等。此前把「exe 报的 keyId ≠ 凭据要求」读成「推导还没定案」,是把身份标识别当成密钥标识用了。
|
|
46
54
|
| **D 未验证** | 无任何上述支撑 | —— |
|
|
47
55
|
|
|
56
|
+
### 2.1 keyId 到底是什么:三值同源,已结案(issue #68,2026-10-16)
|
|
57
|
+
|
|
58
|
+
**争议**:解密路径上出现三个互不相等的 16 位十六进制值,看起来像「有三把不同的 key,不知道哪把对」:
|
|
59
|
+
|
|
60
|
+
| 值 | 出现处 |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `9127dea1b44020a7` | 凭据信封**要求**的 `keyId`(`accessToken` / `nickname` / `refreshToken` 全部一致) |
|
|
63
|
+
| `c74aaf9df09104a5` | `deriveAtRestKeyId()` 对 `Electron` 进程取回的 payload 算出的值 |
|
|
64
|
+
| `66ef584820ad33e7` | 把 `atRestSecretKey` 先 base64 解码、再对**解码字节**做 sha256 得到的值 |
|
|
65
|
+
|
|
66
|
+
三者不等,于是被读成「protector key 的推导没定案」。**这个读法是错的**,因为三个值来自**同一把 secret**,只是哈希输入不同:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
secret = <payload>.atRestSecretKey (base64 文本,32 字节明文)
|
|
70
|
+
key = sha256(secret as utf8) ← 插件采用的形式
|
|
71
|
+
keyId = hex(sha256(key))[0..16]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `c74aaf9df09104a5` = `hex(sha256(secret))[0..16]` —— **秘密本身**的摘要,不是 key 的 keyId。它长得像 keyId,所以极易被误认为「另一把候选 key」。
|
|
75
|
+
- `66ef584820ad33e7` = `hex(sha256(base64_decode(secret)))[0..16]` —— 对**解码后的字节**做哈希。这是最自然的错误猜法(文档里那句「哈希 base64 串而非解码字节」正是为它写的),但它**不打开任何信封**。
|
|
76
|
+
- `9127dea1b44020a7` = `hex(sha256(sha256(secret as utf8)))[0..16]` —— **只有它**能让 GCM 标签通过。
|
|
77
|
+
|
|
78
|
+
**验证(macOS 真机,`/Applications` 下同时装着两个版本)**:对 `workbuddy-desktop.info`(国内)与 `workbuddy-desktop-ai.info`(国际)的 8 个信封逐一开箱,`nickname` 与 `accessToken` 全部解出;`accounts[].nickname` / `allAccounts[].nickname`(长度 16/16/36 字节,都不是令牌)也一致。**两个区域的文件用同一把 key** —— 区域差别在文件与账户,不在密钥。
|
|
79
|
+
|
|
80
|
+
**两个此前被当成「另一条加密路径」的东西,都不是**:
|
|
81
|
+
|
|
82
|
+
- `~/.workbuddy/keyblob` 与 `~/.workbuddy-ai/keyblob` 里的 `keyId`(`1ade3d8d51807afa` / `1d071c4d12239dda`)是**另一个层级**:keyblob 自己的 `keyId` 标识它包裹的那把 data key,而 `slot[0].protectorKeyId` 仍是 `9127dea1b44020a7`(即上面的保护者)。**它们从未出现在凭据链上**,未被也不应被当作凭据 key 的候选。
|
|
83
|
+
- keyblob 的 `wrapped`(32 字节密文)与凭据信封的 16/36 字节 `nickname` 不是同一种载荷,混在一张表里会继续生出假候选。
|
|
84
|
+
|
|
85
|
+
**给下一个人**:判定「哪把 key 对」**不要比 keyId**,要看 GCM 标签是否通过 —— keyId 只是**身份标签**,同一份密钥材料在三种输入形式下有三种合法拼法。要正确定位,跑 `doctor` 拿到的 `atRestDecryption` 只说明**能不能拿到 payload**;`nickname` 是否解出才是**这把 key 对不对**的判据。本机实测的判据链与本节的探针脚本见提交信息(`scripts/` 不入包)。
|
|
86
|
+
|
|
48
87
|
### A 档取证:§1-5 的控制台闪窗(2026-09-26,Windows 11 22621)
|
|
49
88
|
|
|
50
89
|
**缺陷**:`src/host-heartbeat.ts` 的 `processStartTimeMs()` 以 `execFileSync('powershell', …, { encoding: 'utf8' })` 启动子进程,**未设 `windowsHide`**;同仓库的 `src/at-rest.ts` 却设了。两处不一致。
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-
|
|
2
|
+
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-NHBxcmkQ.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|
package/lib/client.js
CHANGED
|
@@ -3652,11 +3652,15 @@ window.__ModuleLoader__.load({
|
|
|
3652
3652
|
* is precisely the action that cannot work when the credential exists but is
|
|
3653
3653
|
* encrypted. Leaving it in the collapsed list meant the headline contradicted
|
|
3654
3654
|
* the detail below it — and the headline is the only part most users read.
|
|
3655
|
+
*
|
|
3656
|
+
* `encrypted` outranks `wrong-region` as well, which is a change (issue #68):
|
|
3657
|
+
* the two are not alternatives, they co-occur, and when they do the unreadable
|
|
3658
|
+
* key is the blocker. See the note in {@link signedOutNotice}.
|
|
3655
3659
|
*/
|
|
3656
3660
|
function signedOutNotice(input) {
|
|
3657
3661
|
if (input.selectionLost) return { key: "row.selectionLostMessage" };
|
|
3658
|
-
if (input.searched.some((item) => item.reason === "wrong-region")) return { key: "row.signedOutWrongRegion" };
|
|
3659
3662
|
if (input.searched.some((item) => item.reason === "encrypted")) return { key: "row.signedOutEncrypted" };
|
|
3663
|
+
if (input.searched.some((item) => item.reason === "wrong-region")) return { key: "row.signedOutWrongRegion" };
|
|
3660
3664
|
if (input.searched.length > 0) return { key: "row.signedOutHint" };
|
|
3661
3665
|
return input.message === void 0 ? { key: "row.signedOutHint" } : {
|
|
3662
3666
|
key: "row.signedOutHint",
|
|
@@ -335,8 +335,41 @@ const SYMMETRIC_SCHEME = "sym-v1";
|
|
|
335
335
|
const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
|
|
336
336
|
/** How long the app is given to answer with its key payload. */
|
|
337
337
|
const KEY_FETCH_TIMEOUT_MS = 1e4;
|
|
338
|
-
/**
|
|
339
|
-
|
|
338
|
+
/**
|
|
339
|
+
* File names the WorkBuddy desktop executable goes by on Windows.
|
|
340
|
+
*
|
|
341
|
+
* `WorkBuddy.exe` is the domestic build. The international build ships as
|
|
342
|
+
* **`WorkBuddyAI.exe` under a `WorkBuddyAI` directory** (issue #68), and the
|
|
343
|
+
* probe list below carries no candidate that matched it — so a machine with only
|
|
344
|
+
* the international app installed could not find the key payload's source and
|
|
345
|
+
* reported the credentials as unreadable, which the UI then described as a
|
|
346
|
+
* wrong-region sign-in.
|
|
347
|
+
*
|
|
348
|
+
* Kept as a LIST rather than a second constant because the same name test is
|
|
349
|
+
* used in two places that must agree: the directory joiners below, and the
|
|
350
|
+
* registry-value validator (`normalizeRegistryExecutable`), which must accept a
|
|
351
|
+
* `DisplayIcon` pointing at either binary. The first entry is the canonical one
|
|
352
|
+
* used when a directory is known to hold the domestic build.
|
|
353
|
+
*/
|
|
354
|
+
const APP_EXECUTABLE_NAMES = ["WorkBuddy.exe", "WorkBuddyAI.exe"];
|
|
355
|
+
/** The canonical executable name; the rest of {@link APP_EXECUTABLE_NAMES}. */
|
|
356
|
+
const APP_EXECUTABLE_NAME = APP_EXECUTABLE_NAMES[0];
|
|
357
|
+
/**
|
|
358
|
+
* Windows install directories the desktop app is observed to use, in probe
|
|
359
|
+
* order, relative to a base such as `%LOCALAPPDATA%` or `%ProgramFiles%`.
|
|
360
|
+
*
|
|
361
|
+
* `Programs\WorkBuddy` is the common per-user layout; `WorkBuddy` is the flatter
|
|
362
|
+
* one some installers produce. The `WorkBuddyAI` pair is the international
|
|
363
|
+
* build's layout (issue #68) — both spellings are carried because the directory
|
|
364
|
+
* and the binary were observed to agree, but a future build could pair either
|
|
365
|
+
* way, and a miss here is invisible (it surfaces as "unreadable credentials").
|
|
366
|
+
*/
|
|
367
|
+
const WINDOWS_APP_SUBDIRECTORIES = [
|
|
368
|
+
["Programs", "WorkBuddy"],
|
|
369
|
+
["WorkBuddy"],
|
|
370
|
+
["Programs", "WorkBuddyAI"],
|
|
371
|
+
["WorkBuddyAI"]
|
|
372
|
+
];
|
|
340
373
|
/**
|
|
341
374
|
* macOS bundles the desktop app may be installed as, in probe order.
|
|
342
375
|
*
|
|
@@ -581,7 +614,8 @@ function normalizeRegistryExecutable(value) {
|
|
|
581
614
|
if (unquoted === void 0 || unquoted === "") return void 0;
|
|
582
615
|
const withoutIndex = unquoted.replace(/,\s*-?\d+\s*$/u, "");
|
|
583
616
|
if (withoutIndex === "") return void 0;
|
|
584
|
-
|
|
617
|
+
const base = win32.basename(withoutIndex).toLowerCase();
|
|
618
|
+
if (!APP_EXECUTABLE_NAMES.some((name) => name.toLowerCase() === base)) return void 0;
|
|
585
619
|
return withoutIndex;
|
|
586
620
|
}
|
|
587
621
|
/**
|
|
@@ -653,7 +687,15 @@ function workbuddyAppExecutableCandidates(platform = process.platform, home = ho
|
|
|
653
687
|
const local = env["LOCALAPPDATA"]?.trim();
|
|
654
688
|
const programFiles = env["ProgramFiles"]?.trim();
|
|
655
689
|
const programFilesX86 = env["ProgramFiles(x86)"]?.trim();
|
|
656
|
-
|
|
690
|
+
const bases = [
|
|
691
|
+
local === void 0 || local === "" ? void 0 : local,
|
|
692
|
+
programFiles === void 0 || programFiles === "" ? void 0 : programFiles,
|
|
693
|
+
programFilesX86 === void 0 || programFilesX86 === "" ? void 0 : programFilesX86
|
|
694
|
+
];
|
|
695
|
+
for (const base of bases) {
|
|
696
|
+
if (base === void 0) continue;
|
|
697
|
+
for (const subdirectory of WINDOWS_APP_SUBDIRECTORIES) for (const name of APP_EXECUTABLE_NAMES) candidates.push(win32.join(base, ...subdirectory, name));
|
|
698
|
+
}
|
|
657
699
|
} else if (platform === "darwin") for (const name of MACOS_APP_BUNDLE_NAMES) candidates.push(readBundleExecutable(join("/Applications", name)), readBundleExecutable(join(home, "Applications", name)));
|
|
658
700
|
return candidates.filter((candidate) => candidate !== void 0 && candidate !== "");
|
|
659
701
|
}
|
|
@@ -3907,7 +3949,7 @@ function generationOf(version) {
|
|
|
3907
3949
|
//#endregion
|
|
3908
3950
|
//#region src/version.ts
|
|
3909
3951
|
/** The npm package version this build was produced from. */
|
|
3910
|
-
const WORKBUDDY_CONNECT_VERSION = "4.6.
|
|
3952
|
+
const WORKBUDDY_CONNECT_VERSION = "4.6.6";
|
|
3911
3953
|
//#endregion
|
|
3912
3954
|
//#region src/host-heartbeat.ts
|
|
3913
3955
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -1166,6 +1166,28 @@ interface WorkBuddyShimOptions {
|
|
|
1166
1166
|
* callee must treat the string as read-only.
|
|
1167
1167
|
*/
|
|
1168
1168
|
onRequestBody?: (bodyJson: string) => void;
|
|
1169
|
+
/**
|
|
1170
|
+
* Called with the token counts the UPSTREAM reported for the request whose
|
|
1171
|
+
* body was just handed to {@link onRequestBody} (issue #59).
|
|
1172
|
+
*
|
|
1173
|
+
* `onRequestBody` records what we SENT; this records what the gateway
|
|
1174
|
+
* CHARGED. The issue cannot be settled with only one of them: a difference
|
|
1175
|
+
* between two captures is ambiguous until both sides are known, because
|
|
1176
|
+
* "the gateway counted the same bytes differently" and "the two bodies were
|
|
1177
|
+
* not the same" look identical from the sent side alone.
|
|
1178
|
+
*
|
|
1179
|
+
* May fire zero times (the upstream reported no `usage`, or the stream was
|
|
1180
|
+
* cut before its final frame) or more than once per body (a failover retry
|
|
1181
|
+
* reaches a second upstream). The callee is expected to keep the FIRST
|
|
1182
|
+
* real measurement, since that is the one the report's line belongs to.
|
|
1183
|
+
*
|
|
1184
|
+
* Fired without awaiting, and never allowed to fail the request.
|
|
1185
|
+
*/
|
|
1186
|
+
onUpstreamUsage?: (usage: {
|
|
1187
|
+
promptTokens?: number;
|
|
1188
|
+
completionTokens?: number;
|
|
1189
|
+
cachedTokens?: number;
|
|
1190
|
+
}) => void;
|
|
1169
1191
|
}
|
|
1170
1192
|
/**
|
|
1171
1193
|
* Start the loopback endpoint. Requests must carry the shim's shared secret;
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-NHBxcmkQ.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
@@ -412,6 +412,354 @@ function createWorkBuddyAdapter(options) {
|
|
|
412
412
|
}
|
|
413
413
|
};
|
|
414
414
|
}
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/body-report.ts
|
|
417
|
+
/**
|
|
418
|
+
* Body-composition report: WHAT was sent upstream, in counts (issue #59).
|
|
419
|
+
*
|
|
420
|
+
* WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
|
|
421
|
+
* ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
|
|
422
|
+
* which turns a region switch into an instant `code 11115 prompt is too long`.
|
|
423
|
+
* Two worlds explain that, and they have opposite owners:
|
|
424
|
+
*
|
|
425
|
+
* - the gateways count identical bytes differently (upstream's business); or
|
|
426
|
+
* - the two requests did not carry the same messages (ours to fix).
|
|
427
|
+
*
|
|
428
|
+
* The second world was left standing by two independent measurements. The
|
|
429
|
+
* reporter ran this project's `--kind`/`--language` probe on BOTH regions and
|
|
430
|
+
* got the same tokens-per-1000-chars on byte-identical prose, code and JSON
|
|
431
|
+
* (ratios 0.98–1.06 across six cells), so identical bytes really do bill
|
|
432
|
+
* identically. And a code trace showed historical `reasoning` never reaches the
|
|
433
|
+
* wire at all: pi-ai only forwards `thinking` blocks as text when
|
|
434
|
+
* `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
|
|
435
|
+
* `reasoning_details` route needs a signature the stream parser never writes.
|
|
436
|
+
* With the counting difference excluded and reasoning excluded, the remaining
|
|
437
|
+
* candidate is that the two bodies differ in the first place.
|
|
438
|
+
*
|
|
439
|
+
* This module exists to settle exactly that, and it can do it WITHOUT either
|
|
440
|
+
* region having credit: the body is assembled locally, before the upstream ever
|
|
441
|
+
* gets a chance to answer `14018 Credits exhausted`. Capture one composition per
|
|
442
|
+
* region in the same session and diff them.
|
|
443
|
+
*
|
|
444
|
+
* It records COUNTS ONLY — never a byte of message text, no account name, no
|
|
445
|
+
* token. That is a hard rule, not a default: the point is to compare shape
|
|
446
|
+
* (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
|
|
447
|
+
* and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
|
|
448
|
+
* asserts the report cannot leak a fixture's text.
|
|
449
|
+
*/
|
|
450
|
+
/**
|
|
451
|
+
* Environment variable that enables the report.
|
|
452
|
+
*
|
|
453
|
+
* Unset or blank means OFF — a diagnostic that writes files must be opt-in.
|
|
454
|
+
* `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
|
|
455
|
+
* directory path, so a user can point it at an existing folder (and, on
|
|
456
|
+
* Windows, one with spaces).
|
|
457
|
+
*/
|
|
458
|
+
const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
|
|
459
|
+
/**
|
|
460
|
+
* Character ranges that tokenize (and therefore bill) like CJK.
|
|
461
|
+
*
|
|
462
|
+
* Deliberately generous: the caller's question is "how much of this is the ~3×
|
|
463
|
+
* kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
|
|
464
|
+
* class that is borderline costs far less than mis-attributing a whole bucket.
|
|
465
|
+
* Ranges follow Unicode blocks, plus the compatibility forms and the two
|
|
466
|
+
* extension planes where rare Han characters live.
|
|
467
|
+
*/
|
|
468
|
+
const CJK_RANGES = [
|
|
469
|
+
[11904, 12031],
|
|
470
|
+
[12288, 12351],
|
|
471
|
+
[12352, 12543],
|
|
472
|
+
[13312, 19903],
|
|
473
|
+
[19968, 40959],
|
|
474
|
+
[63744, 64255],
|
|
475
|
+
[65072, 65103],
|
|
476
|
+
[65280, 65519],
|
|
477
|
+
[131072, 195103]
|
|
478
|
+
];
|
|
479
|
+
/** Whether one code point bills like CJK. */
|
|
480
|
+
function isCjkCodePoint(code) {
|
|
481
|
+
return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
|
|
482
|
+
}
|
|
483
|
+
/** Count characters (code points) and how many of them are CJK. */
|
|
484
|
+
function countChars(text) {
|
|
485
|
+
let chars = 0;
|
|
486
|
+
let cjkChars = 0;
|
|
487
|
+
for (const character of text) {
|
|
488
|
+
chars += 1;
|
|
489
|
+
const code = character.codePointAt(0);
|
|
490
|
+
if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
chars,
|
|
494
|
+
cjkChars
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/** Build one block report from already-extracted text. */
|
|
498
|
+
function block(type, text) {
|
|
499
|
+
return {
|
|
500
|
+
type,
|
|
501
|
+
...countChars(text)
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/** Whether a value is a plain object (not null, not an array). */
|
|
505
|
+
function isRecord(value) {
|
|
506
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Describe one message's blocks.
|
|
510
|
+
*
|
|
511
|
+
* The role decides what a content string IS, and that is not cosmetic: a
|
|
512
|
+
* `tool` message's payload is a tool result, so counting it as `text` would hide
|
|
513
|
+
* the very bucket a long agentic session is mostly made of.
|
|
514
|
+
*/
|
|
515
|
+
function describeMessage(message) {
|
|
516
|
+
const role = typeof message["role"] === "string" ? message["role"] : "unknown";
|
|
517
|
+
const blocks = [];
|
|
518
|
+
const content = message["content"];
|
|
519
|
+
/**
|
|
520
|
+
* The block type a plain string carries, by the ROLE carrying it.
|
|
521
|
+
*
|
|
522
|
+
* A `tool` message's string is a tool RESULT and a `system` one is the system
|
|
523
|
+
* prompt; calling both `text` would fold the biggest bucket of a long agentic
|
|
524
|
+
* session into the smallest one, which is the opposite of what a composition
|
|
525
|
+
* report is for.
|
|
526
|
+
*/
|
|
527
|
+
const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
|
|
528
|
+
if (typeof content === "string") {
|
|
529
|
+
if (content !== "") blocks.push(block(stringType, content));
|
|
530
|
+
} else if (Array.isArray(content)) for (const part of content) {
|
|
531
|
+
if (!isRecord(part)) continue;
|
|
532
|
+
const type = typeof part["type"] === "string" ? part["type"] : "unknown";
|
|
533
|
+
if (type === "text") {
|
|
534
|
+
const text = part["text"];
|
|
535
|
+
if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
blocks.push({
|
|
539
|
+
type: type === "image_url" ? "image" : type,
|
|
540
|
+
chars: 0,
|
|
541
|
+
cjkChars: 0
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
const toolCalls = message["tool_calls"];
|
|
545
|
+
if (Array.isArray(toolCalls)) for (const call of toolCalls) {
|
|
546
|
+
if (!isRecord(call)) continue;
|
|
547
|
+
const fn = call["function"];
|
|
548
|
+
const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
|
|
549
|
+
const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
|
|
550
|
+
blocks.push(block("tool-call", `${name}${args}`));
|
|
551
|
+
}
|
|
552
|
+
for (const field of [
|
|
553
|
+
"reasoning_content",
|
|
554
|
+
"reasoning",
|
|
555
|
+
"reasoning_text"
|
|
556
|
+
]) {
|
|
557
|
+
const value = message[field];
|
|
558
|
+
if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
role,
|
|
562
|
+
chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
|
|
563
|
+
cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
|
|
564
|
+
blocks
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Describe one serialized request body.
|
|
569
|
+
*
|
|
570
|
+
* Returns `undefined` when the body is not a JSON object — a diagnostic must
|
|
571
|
+
* never be the reason a chat request fails, so every caller treats `undefined`
|
|
572
|
+
* as "nothing to report" rather than an error.
|
|
573
|
+
*/
|
|
574
|
+
function describeBody(bodyJson, region, nowMs) {
|
|
575
|
+
let parsed;
|
|
576
|
+
try {
|
|
577
|
+
parsed = JSON.parse(bodyJson);
|
|
578
|
+
} catch {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (!isRecord(parsed)) return void 0;
|
|
582
|
+
const rawMessages = parsed["messages"];
|
|
583
|
+
const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
|
|
584
|
+
const rawTools = parsed["tools"];
|
|
585
|
+
const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
|
|
586
|
+
type: "tools",
|
|
587
|
+
chars: 0,
|
|
588
|
+
cjkChars: 0
|
|
589
|
+
};
|
|
590
|
+
const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
|
|
591
|
+
const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
|
|
592
|
+
return {
|
|
593
|
+
at: new Date(nowMs).toISOString(),
|
|
594
|
+
region,
|
|
595
|
+
...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
|
|
596
|
+
bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
|
|
597
|
+
messageCount: messages.length,
|
|
598
|
+
chars,
|
|
599
|
+
cjkChars,
|
|
600
|
+
tools,
|
|
601
|
+
messages
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/** A finite, non-negative token count, or undefined for anything else. */
|
|
605
|
+
function tokenCount(value) {
|
|
606
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Read the token counts out of one already-parsed SSE chunk.
|
|
610
|
+
*
|
|
611
|
+
* Returns undefined when the chunk carries no `usage` — which is every chunk but
|
|
612
|
+
* the last one on a streamed answer, so this is called for each frame and must
|
|
613
|
+
* stay cheap and total. The cache-hit count is looked for under BOTH spellings
|
|
614
|
+
* observed on the two gateways (`prompt_tokens_details.cached_tokens`, OpenAI's,
|
|
615
|
+
* and `prompt_cache_hit_tokens`, which the domestic gateway also emits); taking
|
|
616
|
+
* the first one present keeps the report comparable across regions instead of
|
|
617
|
+
* showing 0 on whichever side uses the other name.
|
|
618
|
+
*/
|
|
619
|
+
function usageOfChunk(chunk) {
|
|
620
|
+
const usage = chunk["usage"];
|
|
621
|
+
if (typeof usage !== "object" || usage === null || Array.isArray(usage)) return void 0;
|
|
622
|
+
const record = usage;
|
|
623
|
+
const promptTokens = tokenCount(record["prompt_tokens"]);
|
|
624
|
+
const completionTokens = tokenCount(record["completion_tokens"]);
|
|
625
|
+
const details = record["prompt_tokens_details"];
|
|
626
|
+
const cachedTokens = (typeof details === "object" && details !== null && !Array.isArray(details) ? tokenCount(details["cached_tokens"]) : void 0) ?? tokenCount(record["prompt_cache_hit_tokens"]);
|
|
627
|
+
if (promptTokens === void 0 && completionTokens === void 0 && cachedTokens === void 0) return void 0;
|
|
628
|
+
return {
|
|
629
|
+
...promptTokens === void 0 ? {} : { promptTokens },
|
|
630
|
+
...completionTokens === void 0 ? {} : { completionTokens },
|
|
631
|
+
...cachedTokens === void 0 ? {} : { cachedTokens }
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Resolve the directory the report writes to, or `undefined` when disabled.
|
|
636
|
+
*
|
|
637
|
+
* Exported for the same reason the other env-var resolvers in this plugin are:
|
|
638
|
+
* the behaviour is worth a unit test, and the test must not have to set a real
|
|
639
|
+
* process-wide variable to get one.
|
|
640
|
+
*/
|
|
641
|
+
function bodyReportDirectory(env = process.env) {
|
|
642
|
+
const configured = env[WORKBUDDY_BODY_REPORT_ENV];
|
|
643
|
+
if (configured === void 0) return void 0;
|
|
644
|
+
const trimmed = configured.trim();
|
|
645
|
+
if (trimmed === "") return void 0;
|
|
646
|
+
if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
|
|
647
|
+
return trimmed;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Append one composition to `<dir>/<region>.jsonl`.
|
|
651
|
+
*
|
|
652
|
+
* One file per region is the whole point: the reporter captures a session, then
|
|
653
|
+
* diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
|
|
654
|
+
* one file per request because a single turn makes many upstream calls (one per
|
|
655
|
+
* tool step) and the LAST one is the interesting one.
|
|
656
|
+
*
|
|
657
|
+
* Read-modify-write is avoided on purpose — a long session would rewrite a
|
|
658
|
+
* growing file on every step. The lock keeps two concurrent requests from
|
|
659
|
+
* interleaving a line.
|
|
660
|
+
*/
|
|
661
|
+
async function appendBodyReport(directory, report) {
|
|
662
|
+
const file = join(directory, `${report.region}.jsonl`);
|
|
663
|
+
await mkdir(directory, { recursive: true });
|
|
664
|
+
const line = `${JSON.stringify(report)}\n`;
|
|
665
|
+
await withFileLock(file, () => appendFile(file, line, "utf8"));
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Record one outgoing body, if the report is enabled, and return a handle that
|
|
669
|
+
* can carry the upstream's `usage` back onto the SAME line.
|
|
670
|
+
*
|
|
671
|
+
* NEVER THROWS and never awaits long: this is called on the request path, and a
|
|
672
|
+
* chat request must not fail (or stall) because a diagnostic could not write a
|
|
673
|
+
* file. A failure is reported through `onError` so the caller can log it.
|
|
674
|
+
*
|
|
675
|
+
* The returned function exists because the two halves of the #59 comparison are
|
|
676
|
+
* only available at different times: what we SENT is known before the request
|
|
677
|
+
* leaves, what the gateway CHARGED only after the stream ends. Writing a second
|
|
678
|
+
* line for the usage would be worse than useless — the reporter diffs "the last
|
|
679
|
+
* line of cn.jsonl against the last line of global.jsonl", and a trailing
|
|
680
|
+
* usage-only line would silently become the one being compared.
|
|
681
|
+
*/
|
|
682
|
+
async function recordBodyReport(region, bodyJson, options = {}) {
|
|
683
|
+
const directory = bodyReportDirectory(options.env);
|
|
684
|
+
if (directory === void 0) return () => {};
|
|
685
|
+
try {
|
|
686
|
+
const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
|
|
687
|
+
if (report === void 0) return () => {};
|
|
688
|
+
await appendBodyReport(directory, report);
|
|
689
|
+
return attachUsage(directory, report, options.onError);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
options.onError?.(error);
|
|
692
|
+
return () => {};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* How many trailing lines are searched for the line this handle wrote.
|
|
697
|
+
*
|
|
698
|
+
* One would be enough for a single-threaded request, but a turn can have several
|
|
699
|
+
* requests in flight (parallel tool steps) and their lines interleave, so the
|
|
700
|
+
* target is not necessarily last. A small bounded window keeps the read cheap on
|
|
701
|
+
* a file that grows to tens of megabytes, while still finding a line that landed
|
|
702
|
+
* a couple of appends ago.
|
|
703
|
+
*/
|
|
704
|
+
const USAGE_ATTACH_WINDOW = 32;
|
|
705
|
+
/**
|
|
706
|
+
* Return a callback that rewrites its own line with the upstream's counts.
|
|
707
|
+
*
|
|
708
|
+
* Matched by `at` + `region` + `bodyBytes` + `messageCount`, all four being
|
|
709
|
+
* stable for a finished request: `at` alone can collide when two requests are
|
|
710
|
+
* timestamped in the same millisecond, and `bodyBytes` tells apart the two
|
|
711
|
+
* different bodies a failover or a retry can produce. A line that is already
|
|
712
|
+
* annotated is left alone, so a second call cannot double-apply.
|
|
713
|
+
*/
|
|
714
|
+
function attachUsage(directory, report, onError) {
|
|
715
|
+
/**
|
|
716
|
+
* Two independent idempotence guards, kept redundant on purpose.
|
|
717
|
+
*
|
|
718
|
+
* `done` avoids a second disk read within one handle's lifetime; the
|
|
719
|
+
* "already annotated" check inside the loop catches the OTHER case — a second
|
|
720
|
+
* handle for the same body, which is what a failover retry produces (it opens
|
|
721
|
+
* its own stream, and therefore its own handle). Either alone is enough for
|
|
722
|
+
* the common path, so removing just one is not observable behaviourally;
|
|
723
|
+
* removing BOTH is, and `tests/issue59-body-report.spec.ts` pins that.
|
|
724
|
+
*/
|
|
725
|
+
let done = false;
|
|
726
|
+
return (usage) => {
|
|
727
|
+
if (usage === void 0 || done) return;
|
|
728
|
+
done = true;
|
|
729
|
+
const file = join(directory, `${report.region}.jsonl`);
|
|
730
|
+
(async () => {
|
|
731
|
+
try {
|
|
732
|
+
await withFileLock(file, async () => {
|
|
733
|
+
const lines = (await readFile(file, "utf8")).split("\n");
|
|
734
|
+
for (let index = lines.length - 1; index >= 0 && index >= lines.length - USAGE_ATTACH_WINDOW; index -= 1) {
|
|
735
|
+
const line = lines[index];
|
|
736
|
+
if (line === void 0 || line === "") continue;
|
|
737
|
+
let parsed;
|
|
738
|
+
try {
|
|
739
|
+
parsed = JSON.parse(line);
|
|
740
|
+
} catch {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (!isRecord(parsed)) continue;
|
|
744
|
+
if (parsed["at"] !== report.at) continue;
|
|
745
|
+
if (parsed["region"] !== report.region) continue;
|
|
746
|
+
if (parsed["bodyBytes"] !== report.bodyBytes) continue;
|
|
747
|
+
if (parsed["messageCount"] !== report.messageCount) continue;
|
|
748
|
+
if (parsed["promptTokens"] !== void 0) return;
|
|
749
|
+
lines[index] = JSON.stringify({
|
|
750
|
+
...parsed,
|
|
751
|
+
...usage
|
|
752
|
+
});
|
|
753
|
+
await writeFile(file, lines.join("\n"), "utf8");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
} catch (error) {
|
|
758
|
+
onError?.(error);
|
|
759
|
+
}
|
|
760
|
+
})();
|
|
761
|
+
};
|
|
762
|
+
}
|
|
415
763
|
/** One or two full-width bars — the count is a spelling variable. */
|
|
416
764
|
const BARS = `||?`;
|
|
417
765
|
new RegExp(`${BARS}DSML${BARS}`, "g");
|
|
@@ -1337,6 +1685,7 @@ function readBody(req) {
|
|
|
1337
1685
|
var RecoveryStream = class {
|
|
1338
1686
|
res;
|
|
1339
1687
|
logger;
|
|
1688
|
+
onUsage;
|
|
1340
1689
|
carry = "";
|
|
1341
1690
|
decoder = new TextDecoder("utf-8");
|
|
1342
1691
|
/** Frames received after the hold began, in arrival order. */
|
|
@@ -1377,9 +1726,10 @@ var RecoveryStream = class {
|
|
|
1377
1726
|
* a stray line behind.
|
|
1378
1727
|
*/
|
|
1379
1728
|
notice;
|
|
1380
|
-
constructor(res, gate, logger, notice) {
|
|
1729
|
+
constructor(res, gate, logger, notice, onUsage) {
|
|
1381
1730
|
this.res = res;
|
|
1382
1731
|
this.logger = logger;
|
|
1732
|
+
this.onUsage = onUsage;
|
|
1383
1733
|
this.buffer = gate === void 0 ? void 0 : new DsmlStreamBuffer(gate);
|
|
1384
1734
|
this.notice = notice;
|
|
1385
1735
|
}
|
|
@@ -1510,6 +1860,33 @@ var RecoveryStream = class {
|
|
|
1510
1860
|
start = newline + 1;
|
|
1511
1861
|
}
|
|
1512
1862
|
}
|
|
1863
|
+
/** Whether a usage measurement has already been reported for this stream. */
|
|
1864
|
+
usageReported = false;
|
|
1865
|
+
/**
|
|
1866
|
+
* Read the upstream's token counts out of one parsed chunk and report them.
|
|
1867
|
+
*
|
|
1868
|
+
* FIRES AT MOST ONCE per stream, and only when there is a real number to
|
|
1869
|
+
* report. Two reasons, both load-bearing:
|
|
1870
|
+
*
|
|
1871
|
+
* - a streamed answer's `usage` arrives on its final frame, but a failover
|
|
1872
|
+
* retry starts a NEW `RecoveryStream`, so a second call here would be a
|
|
1873
|
+
* second measurement for the same line;
|
|
1874
|
+
* - a chunk with no `usage` (which is every chunk but the last) must not be
|
|
1875
|
+
* reported as zero — an absent measurement and a measurement of zero are
|
|
1876
|
+
* different facts, and the #59 comparison turns entirely on that
|
|
1877
|
+
* difference.
|
|
1878
|
+
*/
|
|
1879
|
+
noteUsage(chunk) {
|
|
1880
|
+
if (this.usageReported || this.onUsage === void 0) return;
|
|
1881
|
+
const usage = usageOfChunk(chunk);
|
|
1882
|
+
if (usage === void 0) return;
|
|
1883
|
+
this.usageReported = true;
|
|
1884
|
+
try {
|
|
1885
|
+
this.onUsage(usage);
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
this.logger?.warn("dsh-connect-workbuddy: usage report failed", error);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1513
1890
|
/** Handle one complete frame, rewriting it only when recovery produced something. */
|
|
1514
1891
|
handleFrame(frame) {
|
|
1515
1892
|
const payload = dataPayload(frame);
|
|
@@ -1530,6 +1907,7 @@ var RecoveryStream = class {
|
|
|
1530
1907
|
return;
|
|
1531
1908
|
}
|
|
1532
1909
|
const choices = chunk["choices"];
|
|
1910
|
+
this.noteUsage(chunk);
|
|
1533
1911
|
const choice = Array.isArray(choices) ? choices[0] : void 0;
|
|
1534
1912
|
if (typeof choice !== "object" || choice === null || Array.isArray(choice)) {
|
|
1535
1913
|
this.send(frame, false);
|
|
@@ -1932,6 +2310,7 @@ function createWorkBuddyShim(options) {
|
|
|
1932
2310
|
const prepareAccount = options.prepareAccount;
|
|
1933
2311
|
const onAccountFailure = options.onAccountFailure;
|
|
1934
2312
|
const onRequestBody = options.onRequestBody;
|
|
2313
|
+
const onUpstreamUsage = options.onUpstreamUsage;
|
|
1935
2314
|
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
1936
2315
|
/** Constant-time bearer check; absent or mismatched bearers are rejected. */
|
|
1937
2316
|
function bearerOk(req) {
|
|
@@ -2181,7 +2560,7 @@ function createWorkBuddyShim(options) {
|
|
|
2181
2560
|
...declared.pinnedToolName === void 0 ? {} : { pinnedToolName: declared.pinnedToolName }
|
|
2182
2561
|
};
|
|
2183
2562
|
const notice = switchedFrom === void 0 || switchedBecause === void 0 ? void 0 : failoverNoticeText(switchedFrom, switchedBecause);
|
|
2184
|
-
const writer = new RecoveryStream(res, gate, logger, notice);
|
|
2563
|
+
const writer = new RecoveryStream(res, gate, logger, notice, onUpstreamUsage);
|
|
2185
2564
|
await writer.consume(result.response.body);
|
|
2186
2565
|
if (gate !== void 0 && !writer.delivered && writer.residueSeen && !controller.signal.aborted) {
|
|
2187
2566
|
logger?.warn("dsh-connect-workbuddy: the turn produced DSML markup only; retrying it once");
|
|
@@ -2223,246 +2602,6 @@ function createWorkBuddyShim(options) {
|
|
|
2223
2602
|
};
|
|
2224
2603
|
}
|
|
2225
2604
|
//#endregion
|
|
2226
|
-
//#region src/body-report.ts
|
|
2227
|
-
/**
|
|
2228
|
-
* Body-composition report: WHAT was sent upstream, in counts (issue #59).
|
|
2229
|
-
*
|
|
2230
|
-
* WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
|
|
2231
|
-
* ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
|
|
2232
|
-
* which turns a region switch into an instant `code 11115 prompt is too long`.
|
|
2233
|
-
* Two worlds explain that, and they have opposite owners:
|
|
2234
|
-
*
|
|
2235
|
-
* - the gateways count identical bytes differently (upstream's business); or
|
|
2236
|
-
* - the two requests did not carry the same messages (ours to fix).
|
|
2237
|
-
*
|
|
2238
|
-
* The second world was left standing by two independent measurements. The
|
|
2239
|
-
* reporter ran this project's `--kind`/`--language` probe on BOTH regions and
|
|
2240
|
-
* got the same tokens-per-1000-chars on byte-identical prose, code and JSON
|
|
2241
|
-
* (ratios 0.98–1.06 across six cells), so identical bytes really do bill
|
|
2242
|
-
* identically. And a code trace showed historical `reasoning` never reaches the
|
|
2243
|
-
* wire at all: pi-ai only forwards `thinking` blocks as text when
|
|
2244
|
-
* `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
|
|
2245
|
-
* `reasoning_details` route needs a signature the stream parser never writes.
|
|
2246
|
-
* With the counting difference excluded and reasoning excluded, the remaining
|
|
2247
|
-
* candidate is that the two bodies differ in the first place.
|
|
2248
|
-
*
|
|
2249
|
-
* This module exists to settle exactly that, and it can do it WITHOUT either
|
|
2250
|
-
* region having credit: the body is assembled locally, before the upstream ever
|
|
2251
|
-
* gets a chance to answer `14018 Credits exhausted`. Capture one composition per
|
|
2252
|
-
* region in the same session and diff them.
|
|
2253
|
-
*
|
|
2254
|
-
* It records COUNTS ONLY — never a byte of message text, no account name, no
|
|
2255
|
-
* token. That is a hard rule, not a default: the point is to compare shape
|
|
2256
|
-
* (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
|
|
2257
|
-
* and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
|
|
2258
|
-
* asserts the report cannot leak a fixture's text.
|
|
2259
|
-
*/
|
|
2260
|
-
/**
|
|
2261
|
-
* Environment variable that enables the report.
|
|
2262
|
-
*
|
|
2263
|
-
* Unset or blank means OFF — a diagnostic that writes files must be opt-in.
|
|
2264
|
-
* `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
|
|
2265
|
-
* directory path, so a user can point it at an existing folder (and, on
|
|
2266
|
-
* Windows, one with spaces).
|
|
2267
|
-
*/
|
|
2268
|
-
const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
|
|
2269
|
-
/**
|
|
2270
|
-
* Character ranges that tokenize (and therefore bill) like CJK.
|
|
2271
|
-
*
|
|
2272
|
-
* Deliberately generous: the caller's question is "how much of this is the ~3×
|
|
2273
|
-
* kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
|
|
2274
|
-
* class that is borderline costs far less than mis-attributing a whole bucket.
|
|
2275
|
-
* Ranges follow Unicode blocks, plus the compatibility forms and the two
|
|
2276
|
-
* extension planes where rare Han characters live.
|
|
2277
|
-
*/
|
|
2278
|
-
const CJK_RANGES = [
|
|
2279
|
-
[11904, 12031],
|
|
2280
|
-
[12288, 12351],
|
|
2281
|
-
[12352, 12543],
|
|
2282
|
-
[13312, 19903],
|
|
2283
|
-
[19968, 40959],
|
|
2284
|
-
[63744, 64255],
|
|
2285
|
-
[65072, 65103],
|
|
2286
|
-
[65280, 65519],
|
|
2287
|
-
[131072, 195103]
|
|
2288
|
-
];
|
|
2289
|
-
/** Whether one code point bills like CJK. */
|
|
2290
|
-
function isCjkCodePoint(code) {
|
|
2291
|
-
return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
|
|
2292
|
-
}
|
|
2293
|
-
/** Count characters (code points) and how many of them are CJK. */
|
|
2294
|
-
function countChars(text) {
|
|
2295
|
-
let chars = 0;
|
|
2296
|
-
let cjkChars = 0;
|
|
2297
|
-
for (const character of text) {
|
|
2298
|
-
chars += 1;
|
|
2299
|
-
const code = character.codePointAt(0);
|
|
2300
|
-
if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
|
|
2301
|
-
}
|
|
2302
|
-
return {
|
|
2303
|
-
chars,
|
|
2304
|
-
cjkChars
|
|
2305
|
-
};
|
|
2306
|
-
}
|
|
2307
|
-
/** Build one block report from already-extracted text. */
|
|
2308
|
-
function block(type, text) {
|
|
2309
|
-
return {
|
|
2310
|
-
type,
|
|
2311
|
-
...countChars(text)
|
|
2312
|
-
};
|
|
2313
|
-
}
|
|
2314
|
-
/** Whether a value is a plain object (not null, not an array). */
|
|
2315
|
-
function isRecord(value) {
|
|
2316
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2317
|
-
}
|
|
2318
|
-
/**
|
|
2319
|
-
* Describe one message's blocks.
|
|
2320
|
-
*
|
|
2321
|
-
* The role decides what a content string IS, and that is not cosmetic: a
|
|
2322
|
-
* `tool` message's payload is a tool result, so counting it as `text` would hide
|
|
2323
|
-
* the very bucket a long agentic session is mostly made of.
|
|
2324
|
-
*/
|
|
2325
|
-
function describeMessage(message) {
|
|
2326
|
-
const role = typeof message["role"] === "string" ? message["role"] : "unknown";
|
|
2327
|
-
const blocks = [];
|
|
2328
|
-
const content = message["content"];
|
|
2329
|
-
/**
|
|
2330
|
-
* The block type a plain string carries, by the ROLE carrying it.
|
|
2331
|
-
*
|
|
2332
|
-
* A `tool` message's string is a tool RESULT and a `system` one is the system
|
|
2333
|
-
* prompt; calling both `text` would fold the biggest bucket of a long agentic
|
|
2334
|
-
* session into the smallest one, which is the opposite of what a composition
|
|
2335
|
-
* report is for.
|
|
2336
|
-
*/
|
|
2337
|
-
const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
|
|
2338
|
-
if (typeof content === "string") {
|
|
2339
|
-
if (content !== "") blocks.push(block(stringType, content));
|
|
2340
|
-
} else if (Array.isArray(content)) for (const part of content) {
|
|
2341
|
-
if (!isRecord(part)) continue;
|
|
2342
|
-
const type = typeof part["type"] === "string" ? part["type"] : "unknown";
|
|
2343
|
-
if (type === "text") {
|
|
2344
|
-
const text = part["text"];
|
|
2345
|
-
if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
|
|
2346
|
-
continue;
|
|
2347
|
-
}
|
|
2348
|
-
blocks.push({
|
|
2349
|
-
type: type === "image_url" ? "image" : type,
|
|
2350
|
-
chars: 0,
|
|
2351
|
-
cjkChars: 0
|
|
2352
|
-
});
|
|
2353
|
-
}
|
|
2354
|
-
const toolCalls = message["tool_calls"];
|
|
2355
|
-
if (Array.isArray(toolCalls)) for (const call of toolCalls) {
|
|
2356
|
-
if (!isRecord(call)) continue;
|
|
2357
|
-
const fn = call["function"];
|
|
2358
|
-
const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
|
|
2359
|
-
const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
|
|
2360
|
-
blocks.push(block("tool-call", `${name}${args}`));
|
|
2361
|
-
}
|
|
2362
|
-
for (const field of [
|
|
2363
|
-
"reasoning_content",
|
|
2364
|
-
"reasoning",
|
|
2365
|
-
"reasoning_text"
|
|
2366
|
-
]) {
|
|
2367
|
-
const value = message[field];
|
|
2368
|
-
if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
|
|
2369
|
-
}
|
|
2370
|
-
return {
|
|
2371
|
-
role,
|
|
2372
|
-
chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
|
|
2373
|
-
cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
|
|
2374
|
-
blocks
|
|
2375
|
-
};
|
|
2376
|
-
}
|
|
2377
|
-
/**
|
|
2378
|
-
* Describe one serialized request body.
|
|
2379
|
-
*
|
|
2380
|
-
* Returns `undefined` when the body is not a JSON object — a diagnostic must
|
|
2381
|
-
* never be the reason a chat request fails, so every caller treats `undefined`
|
|
2382
|
-
* as "nothing to report" rather than an error.
|
|
2383
|
-
*/
|
|
2384
|
-
function describeBody(bodyJson, region, nowMs) {
|
|
2385
|
-
let parsed;
|
|
2386
|
-
try {
|
|
2387
|
-
parsed = JSON.parse(bodyJson);
|
|
2388
|
-
} catch {
|
|
2389
|
-
return;
|
|
2390
|
-
}
|
|
2391
|
-
if (!isRecord(parsed)) return void 0;
|
|
2392
|
-
const rawMessages = parsed["messages"];
|
|
2393
|
-
const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
|
|
2394
|
-
const rawTools = parsed["tools"];
|
|
2395
|
-
const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
|
|
2396
|
-
type: "tools",
|
|
2397
|
-
chars: 0,
|
|
2398
|
-
cjkChars: 0
|
|
2399
|
-
};
|
|
2400
|
-
const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
|
|
2401
|
-
const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
|
|
2402
|
-
return {
|
|
2403
|
-
at: new Date(nowMs).toISOString(),
|
|
2404
|
-
region,
|
|
2405
|
-
...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
|
|
2406
|
-
bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
|
|
2407
|
-
messageCount: messages.length,
|
|
2408
|
-
chars,
|
|
2409
|
-
cjkChars,
|
|
2410
|
-
tools,
|
|
2411
|
-
messages
|
|
2412
|
-
};
|
|
2413
|
-
}
|
|
2414
|
-
/**
|
|
2415
|
-
* Resolve the directory the report writes to, or `undefined` when disabled.
|
|
2416
|
-
*
|
|
2417
|
-
* Exported for the same reason the other env-var resolvers in this plugin are:
|
|
2418
|
-
* the behaviour is worth a unit test, and the test must not have to set a real
|
|
2419
|
-
* process-wide variable to get one.
|
|
2420
|
-
*/
|
|
2421
|
-
function bodyReportDirectory(env = process.env) {
|
|
2422
|
-
const configured = env[WORKBUDDY_BODY_REPORT_ENV];
|
|
2423
|
-
if (configured === void 0) return void 0;
|
|
2424
|
-
const trimmed = configured.trim();
|
|
2425
|
-
if (trimmed === "") return void 0;
|
|
2426
|
-
if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
|
|
2427
|
-
return trimmed;
|
|
2428
|
-
}
|
|
2429
|
-
/**
|
|
2430
|
-
* Append one composition to `<dir>/<region>.jsonl`.
|
|
2431
|
-
*
|
|
2432
|
-
* One file per region is the whole point: the reporter captures a session, then
|
|
2433
|
-
* diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
|
|
2434
|
-
* one file per request because a single turn makes many upstream calls (one per
|
|
2435
|
-
* tool step) and the LAST one is the interesting one.
|
|
2436
|
-
*
|
|
2437
|
-
* Read-modify-write is avoided on purpose — a long session would rewrite a
|
|
2438
|
-
* growing file on every step. The lock keeps two concurrent requests from
|
|
2439
|
-
* interleaving a line.
|
|
2440
|
-
*/
|
|
2441
|
-
async function appendBodyReport(directory, report) {
|
|
2442
|
-
const file = join(directory, `${report.region}.jsonl`);
|
|
2443
|
-
await mkdir(directory, { recursive: true });
|
|
2444
|
-
const line = `${JSON.stringify(report)}\n`;
|
|
2445
|
-
await withFileLock(file, () => appendFile(file, line, "utf8"));
|
|
2446
|
-
}
|
|
2447
|
-
/**
|
|
2448
|
-
* Record one outgoing body, if the report is enabled.
|
|
2449
|
-
*
|
|
2450
|
-
* NEVER THROWS and never awaits long: this is called on the request path, and a
|
|
2451
|
-
* chat request must not fail (or stall) because a diagnostic could not write a
|
|
2452
|
-
* file. A failure is reported through `onError` so the caller can log it.
|
|
2453
|
-
*/
|
|
2454
|
-
async function recordBodyReport(region, bodyJson, options = {}) {
|
|
2455
|
-
const directory = bodyReportDirectory(options.env);
|
|
2456
|
-
if (directory === void 0) return;
|
|
2457
|
-
try {
|
|
2458
|
-
const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
|
|
2459
|
-
if (report === void 0) return;
|
|
2460
|
-
await appendBodyReport(directory, report);
|
|
2461
|
-
} catch (error) {
|
|
2462
|
-
options.onError?.(error);
|
|
2463
|
-
}
|
|
2464
|
-
}
|
|
2465
|
-
//#endregion
|
|
2466
2605
|
//#region src/probe.ts
|
|
2467
2606
|
/**
|
|
2468
2607
|
* How long to bench a MODEL that answered 14003 with no stated time.
|
|
@@ -5425,6 +5564,17 @@ function apply(ctx, config) {
|
|
|
5425
5564
|
refresh: (credential) => client.refreshToken(credential)
|
|
5426
5565
|
});
|
|
5427
5566
|
const catalog = new WorkBuddyCatalog(region);
|
|
5567
|
+
/**
|
|
5568
|
+
* Where the next upstream `usage` should be written.
|
|
5569
|
+
*
|
|
5570
|
+
* The body report is two-phase (send, then charge), and the two phases are
|
|
5571
|
+
* wired through different callbacks, so the seam between them has to live
|
|
5572
|
+
* somewhere. `noop` rather than `undefined` keeps the usage path free of a
|
|
5573
|
+
* presence check on the hot request path — and a request whose report was
|
|
5574
|
+
* disabled resolves to the same noop, so nothing needs to know whether
|
|
5575
|
+
* reporting is on.
|
|
5576
|
+
*/
|
|
5577
|
+
let pendingUsage = () => {};
|
|
5428
5578
|
stacks[region] = {
|
|
5429
5579
|
store,
|
|
5430
5580
|
catalog,
|
|
@@ -5449,7 +5599,12 @@ function apply(ctx, config) {
|
|
|
5449
5599
|
* setup completes.
|
|
5450
5600
|
*/
|
|
5451
5601
|
onRequestBody: (body) => {
|
|
5452
|
-
recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) })
|
|
5602
|
+
recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) }).then((attach) => {
|
|
5603
|
+
pendingUsage = attach;
|
|
5604
|
+
}).catch((error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error));
|
|
5605
|
+
},
|
|
5606
|
+
onUpstreamUsage: (usage) => {
|
|
5607
|
+
pendingUsage?.(usage);
|
|
5453
5608
|
},
|
|
5454
5609
|
prepareAccount: () => applyPoolSelection(region),
|
|
5455
5610
|
failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
|
|
5
|
-
"version": "4.6.
|
|
5
|
+
"version": "4.6.6",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|