dsh-connect-workbuddy 5.8.2 → 5.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +270 -5
- package/lib/{host-heartbeat-C9BFJEBM.js → host-heartbeat-CAX0oKQT.js} +147 -13
- package/lib/index.d.ts +61 -1
- package/lib/index.js +191 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.8.4 (2026-10-24)
|
|
4
|
+
|
|
5
|
+
> 修复版本:热重载一次之后设置卡片上一半按钮永久 405 的那一格(issue #86)。根因不是「宿主在跑旧代码」——是 `benefit/claim` 路由的 disposer 从未被调用,泄漏的路由让**下一次**热重载在它身上抛错,于是排在它后面的 8 条路由一条都没注册上。**请求路径、provider 注册、凭据、计费与换号规则一律未变**;改动是 cleanup 列表里的一行。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **`benefit/claim` 路由的 disposer 从未被调用,热重载一次之后卡片上一半按钮永久 405 空响应(issue #86 的真正触发条件)。**
|
|
10
|
+
- **症状**:设置卡里点「一键签到」「一键测试」「删除」「扫码登录」「保存」等按钮,报「批量操作接口没有应答(HTTP 405,无响应体)」,或扫码登录报 `登录失败: Failed to execute 'json' on 'Response': Unexpected end of JSON input`。模型本身照常能用——坏掉的只有卡片上的这些接口。
|
|
11
|
+
- **判定**:插件每个 handler 都回 JSON,**包括它自己的 405**(`json(res, 405, {error:'method not allowed'})`,带 `Content-Type` 与 body);宿主 HTTP 层回的 405 **没有 body**。所以「405 + 空 body」= 该路径在宿主路由表里根本不存在。
|
|
12
|
+
- **根因**:`registerWorkBuddyStatusRoute()` 里 13 条路由都写成 `const disposeXxx = ctx.webServer.register({...})`,**唯独 `benefit/claim` 那条声明了 `disposeBenefitClaim` 却没把它放进 cleanup 列表**(`tsc` 不报错:`noUnusedLocals` 未开)。宿主 `WebServer.register()` 对同一 `(kind, path)` 第二次注册**直接抛错**(`webserver: duplicate exact route "…"`,已核 `@deepseek-ai/dsh-host-webserver/lib/index.js:328`),且路由只在 disposer 运行时才从表里移除。于是:第一次挂载 → `benefit/claim` 泄漏;热重载再挂 → 按注册顺序走到它撞上重复路径 → 抛错 → 整个 `ctx.effect` 回调中断 → 排在它后面的 8 条路由(`models/refresh`、`probe`、`tps`、`pool`、`__save`、`oauth`、`transfer`、`cli-live`)一次都没注册上。
|
|
13
|
+
- **这解释了 issue #86 里「具体触发条件未复现」的那一格**:触发条件不是启动时序,是**热重载过一次**——只有第二次挂载才会撞上第一次的泄漏。所以「重启即恢复」只是把泄漏的那一份清掉,下次热重载再复发,这正是它看起来像「偶发」的原因。
|
|
14
|
+
- **修法**:把 `disposeBenefitClaim()` 补进 cleanup 列表,与其余 12 条对称。一处一行。并在 cleanup 列表处补注释说明「每条注册都必须在此出现」,因为这个缺陷的代价不是多一个 handler,而是让下一次挂载丢掉一半路由。
|
|
15
|
+
- **验证**:新增 3 条回归用例(`tests/web-status.spec.ts` → `registerWorkBuddyStatusRoute re-mount (issue #86)`),用一个**按宿主真实语义**的 `webServer` 替身(同路径第二次 `register` 抛错、只有 disposer 才移除)——宽松的替身会带着 bug 一起通过,所以这条属性必须照抄。变异验证:注释掉 `disposeBenefitClaim()` → 2 条变红并复现同一句 `webserver: duplicate exact route "/plugins/dsh-connect-workbuddy/benefit/claim"`。
|
|
16
|
+
- **代价**:无。请求路径、provider 注册、凭据、计费与换号规则一律未变。
|
|
17
|
+
|
|
18
|
+
## 5.8.3 (2026-10-24)
|
|
19
|
+
|
|
20
|
+
> 体验版本:把 #132/#133 那几项「只能手改 `cordis.patch.yml` 才生效」的开关搬进插件卡片,用户可以直接勾选,不用再编辑 profile 里的 YAML、也不用知道那个文件在哪。**请求路径、默认字节、计费行为均未改动**:四个开关的默认值与采样时机与 5.8.0 完全一致(三个布尔默认关闭),这次只新增了一条写入它们的 UI 路径。
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
- **上游标识开关进入设置卡片(#133 建议 2/3)。**
|
|
25
|
+
- 卡片「能力」页新增一组开关:`usageIdentity`(让用量页把调用记成官方客户端的样子)、`keepPromptLast`(让用量页记录你真正输入的话)、`sessionTitle`(会话标题请求改用轻量模型)。这三项此前只存在于 `cordis.patch.yml`,用户要手改 profile 里的 YAML 才知道有这个功能。
|
|
26
|
+
- 每个开关都带一行说明和一个「改完需重载插件或重启 DSH」的统一提示——这四项都在插件挂载时采样进 shim,配置改了但没重载就看不到变化。`sessionTitle` 开启时额外显示当前目标模型(只读:该 id 仍由 `sessionTitle.model` 配置,不在这里做第二个未经校验的编辑入口)。
|
|
27
|
+
- `extraInjectedPrefixes` **故意不给输入框**:它是一个字符串数组,塞进复选框形状的 UI 只会比配置文件更难用。改为在 `keepPromptLast` 开启时**报告**当前生效条数,让手写过前缀的用户能确认插件确实看见了。
|
|
28
|
+
- 新增 Host 端点字段:`__save` 现在接受 `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` / `sessionTitle` 四个顶层字段,各自 `set` 自己的路径。**不是**塞进 `cliBridge` 块:那四项管的是所有 provider 共享的请求形态,塞进去会让「关掉 CLI 路由」看起来像把它们一起重置了。
|
|
29
|
+
- 写入沿用既有两条写入器(Host 端点优先、设置作用域兜底):该命名空间的浏览器镜像在有问题的 0.1.7 部署上被文档标注为陈旧,只「看起来翻过去了」的开关正是「关闭不了」那一类报告。
|
|
30
|
+
|
|
31
|
+
### Tests
|
|
32
|
+
|
|
33
|
+
- 新增 [`tests/card-upstream-switches.spec.tsx`](tests/card-upstream-switches.spec.tsx)(9 条):三个开关按**已提交值**渲染、缺省一律读成 OFF(不是「truthy 就算开」)、翻动只写**本字段**、`sessionTitle` 写入保留 `model`(它的字段是对象,退化成布尔就会把用户设的目标模型抹掉)、POST 到 Host 端点、以及重载提示必须在屏上。读取器另有 3 条单独测(volatile 的 `{get()}` 解包、`sessionTitle` 的缺省/畸形回退、前缀列表的去空去重与非数组防御)。
|
|
34
|
+
- [`tests/web-status.spec.ts`](tests/web-status.spec.ts) 增 3 条端点用例:四个字段各自只写自己的路径、白名单外的字段被 400 拒绝且不触达设置服务、期望对象的字段收到标量时拒绝。
|
|
35
|
+
- 变异验证:把 `sessionTitle` 的写入退化成裸布尔 → 1 条红;把「缺省读 OFF」改成「缺省读 ON」→ 4 条红;把新开关的 class 改回 `.dsm-workbuddy-pref-check` → `card-credits-switch` 5 条红(它按「卡片里只有一个」读那个类)。
|
|
36
|
+
- 完整套件 **2268 通过 / 2 条按平台跳过**(此前 2256),`typecheck` 与 `build` 通过。
|
|
37
|
+
|
|
38
|
+
### Docs
|
|
39
|
+
|
|
40
|
+
- 卡片文案中英双语同步新增 8 条 key;`styles.ts` 补上两个新 class 的定义(`client-host-contract` 的「每个渲染出的 class 都必须有样式」守卫要求)。
|
|
41
|
+
|
|
3
42
|
## 5.8.2 (2026-10-24)
|
|
4
43
|
|
|
5
44
|
> 适配版本:跟随宿主 `0.2.1-rc.3`。rc.2 在 web-app 捆绑里新增了官方插件 `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017`(给 DeepSeek 官方 Flash 行画「1017」发布徽章),它与本插件注册**同一个 `single` 槽**、**同一个默认优先级 0**——于是本插件的注册**抛错**,模型选择器里的负载徽章**静默消失**。本版修掉这一处,其余全部兼容。
|
|
@@ -80,6 +119,13 @@
|
|
|
80
119
|
- `current()` 会**先看**池的运行时轮换标记(`rotatedAccountId`),再看用户保存的选择;而 `accounts()` 回答 `selected` 时只看保存的选择。池子把请求轮换到 B 号后,shim 记在 B 号账上,卡片却把 A 号标成「当前选用」——截图里那枚标记落在被限流的号上,而胶囊与行内数字都属于另一个号。
|
|
81
120
|
- 同一份用量文档还会因此**点名两个账号**:`credential` 取自 `current()`(真正计费的那个),而 `account.accountId` / `accountName` 取自 `accounts()`(保存的那个)。现在 `accounts()` 用与 `current()` **完全相同**的优先级解析标记:有活跃轮换且该账号仍在本地,就标它;轮换指向已消失的账号时与 `current()` 一样回落;其余情况仍按「显式选择 / 默认最新」判断。清除轮换后标记随 `current()` 回到用户自己的选择,轮换本身仍然从不写设置。
|
|
82
121
|
- 面板里还有第二处同源分裂:行的**底色**读 credits 端点的 `selected`(保存的选择),**「当前选用」文字**读池文档的 `current`(被计费的账号)。池子一轮换,两者就指向不同行——报告者看到的「底色在一个号、文字在另一个号」。现在整行(底色、文字、切换按钮的禁用态)都读同一个答案;`selected` 只在没有池文档时才作为兜底。
|
|
122
|
+
- **新增 `sessionTitle` 开关:把每个新会话的标题请求改到隐藏轻量模型(#132/#133,报告者实测;本插件未复现)。**
|
|
123
|
+
- DSH 宿主每个新会话发一次标题生成请求,走与对话相同的 provider,按主付费模型费率计费。开启 `sessionTitle.enabled` 后,识别为标题请求的 body(宿主标题模板 `Generate the session title from this JSON array of human messages:` 打头、整条 body 只有这一条 user 消息、无 assistant/tool 消息)被改写到 `sessionTitle.model`(默认 `wb-lite`),并删除 `reasoning_effort` / `reasoning` 字段。报告者实测该模型对这类请求 **0 积分**,但仍是真实上游请求、计入请求数、仅新会话首条触发一次。
|
|
124
|
+
- 识别依据是**本机安装的 DSH 桌面端 0.2.1-rc.2 `app.asar` 的字节级取证**:请求构造与宿主自己的格式校验器都断言这一模板与单 user 消息形状;宿主 `GenerateOptions.purpose: 'session-title'` 不随 OpenAI 线上协议传输,body 形状是 shim 唯一可用的判据。识别 **fail-closed**:宿主改模板则改写静默停止、回到原计费,普通对话不可能命中(模板前缀须从文本开头匹配,正文引用模板的对话不会被改写——各有测试钉住)。
|
|
125
|
+
- **默认 `false`**,与 #132 两项同一理由:这笔请求无论改写与否都会发出,但改写改变的是上游把这笔记进哪个计费桶(0 积分 vs 主模型费率),升级后不该静默变化;且 wb-lite 是隐藏模型、能过渠道闸是报告者账号级的实测。目标模型可配置(`sessionTitle.model`),防 id 变化或区域差异;上游拒绝时仅该会话标题失败一次,不影响主对话。识别先用模板的纯 ASCII 头做预过滤(真实标题 body 必含、普通对话几乎不含),开关开启时普通请求不再为识别付一次全量 JSON 解析;预过滤只跳过工作,不改判定(含模板引用的对话仍由完整检测拒绝)。**生效时机**:`sessionTitle` 与 `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` 都在插件挂载时采样进 shim,修改配置后需重载插件或重启 DSH(loader 的 volatile 更新不重建 shim)。
|
|
126
|
+
- **`keepPromptLast` 认得 billion-context 注入的两类上下文块(#133)。**
|
|
127
|
+
- 5.8.0 的注入前缀清单只覆盖宿主自家的三类(时间采样、`Current runtime context.`、`<system-reminder>`),而 billion-context 压缩插件同样以 `role=user` 注入内容:压缩摘要块以 `[Compressed conversation section]` 标题行打头,效率提示以 `This is an efficiency` 打头。开了 `keepPromptLast` 后这两类漏网,被当成真实提问留在末尾,用量页「请求」列随之记错——报告者已在本地产物上逐字节验证此修法并实测恢复。
|
|
128
|
+
- 注入前缀同时做成**可配置**:新增 `extraInjectedPrefixes`(字符串数组,每项按**字面**前缀匹配、正则元字符会被转义,默认空),压缩插件或宿主将来改文案时不必等插件发版。它只在 `keepPromptLast` 开启时参与匹配,默认行为不变。
|
|
83
129
|
- **企业/教育版账号在账号池里丢失 `enterprise` 标记(#129)。**
|
|
84
130
|
- 存储层早就算出了 `enterprise: true`,但三个转发点把它丢了:`otherAccounts`、`localPoolMembers`、`poolMembersOf`(`poolTargets` 在 5.6.0 就已带上)。结果企业账号在池卡片上显示一个**根本不存在的「领取」按钮**和一列「签到」,而企业成员点这两样都只会被 `10001` 拒绝。
|
|
85
131
|
- 现在这三个转发点都带上了标记,池行据此签到列显示「不适用(企业版)」、权益块整段隐藏;领取路由在**发起任何上游请求之前**就用 409 拒绝(`enterprise-account-unsupported`)。**Web 侧只到账号行这一层**:`toWebAccount` 与用量页的池账号行带标记,用量页顶部那个 `account` 对象没有——它这次修的是**名字**(改用 `preferredAccountName`,此前只取 `credential.nickname` 导致企业账号显示为空),不是标记;那里也不渲染签到/领取。输入框胶囊(`workBuddyAccountCredits`)同样不带标记,它不显示这两个按钮,因此没有对应缺陷。
|
|
@@ -89,15 +135,25 @@
|
|
|
89
135
|
- 设置其实**已经落盘并通过回读校验**,卡住的只是随后那次用量刷新:写入与刷新共用同一个 `saving` 状态,而刷新可能因为账号被限制、网络抖动或上游慢响应而迟迟不返回,用户于是看到「没保存成功」的假象,草稿也被锁死。
|
|
90
136
|
- 现在 `saving` 只覆盖「已校验的设置写入」这一件事:写成功立刻解除 busy 并丢弃草稿,然后 **`await`** 那次回读(不是「再异步刷新」——它是串行等到的,只是结果只影响显示)。这带来一个明确的取舍:回读完成前,卡片上的行还是旧值;`refreshUsage` 失败时不抛而是 resolve,所以「没抛异常」不等于「读到了新值」,旧契约里「保留草稿直到刷新成功」的写法会在这个窗口里拿陈旧的 `saved` 去比对——因此回读结果与保存判定彻底分开。
|
|
91
137
|
- 刷新失败不影响「已保存」的判定,只显示一条**独立**提示(`row.poolSavedStaleRefresh`:「已保存,但面板未能重新读取。设置已写入;下次刷新后会显示。」),它与「保存失败」是两个节点:前者 `role="status"`、后者 `role="alert"`,不再复用 `row.poolSaveFailed`,也就不会出现「保存失败:已保存,但……」这种自相矛盾的一行。只有写入失败才保留草稿。模型选择的保存路径(`WorkBuddyCard.saveModels`)同步修正。
|
|
138
|
+
- **`/cd` 换过目录之后,CLI 与 `/codebuddy` 仍把 CodeBuddy 拉回创建时的那个目录。**
|
|
139
|
+
- **症状**:在一个会话里打开 `~/repos/api`,然后用 `working_directory` 工具 `/cd` 到 `~/repos/web`,接着调用 CodeBuddy 或 `/codebuddy`——CodeBuddy 仍然在 `~/repos/api` 里跑,模型在当前上下文里看到的却是 `~/repos/web`。两个入口还互相不一致:工具侧会退到配置里的 `cliBridge.workingDir` 或临时目录,命令侧只有一级、拿不到就直接报错「这个会话没有工作区路径」。
|
|
140
|
+
- **根因**:两个入口都读 `agent.session.header.cwd`,而 Host 自己的文档把 session header 写成 *detached, deep-frozen creation metadata (format version, cwd, lineage...)*——它是**创建时**的快照,之后再也不更新。活的目录根本不在 header 上:Host 用 `@deepseek-ai/dsh-working-directory` 把它维护成一条 session 投影(`key: "workingDirectory"`,`init: header => header.cwd ?? null`,每次 `working-directory/change` 事件 apply 一次),`/cd` 工具发的就是这种事件。Host 也把这条投影写进模型的系统提示("Current working directory: ..."),官方插件(auto-review、dsh-tools、user-questions)读的都是投影,不是 header。
|
|
141
|
+
- **修法**:新增 `DirectoryOf` 依赖,`sessionDirOf()` 优先读 Host 的 `workingDirectory` 投影,读不到(未发布 / 非字符串 / 空白 / 非绝对路径)才回退 `header.cwd`。`resolveWorkingDir()` 的其余四级阶梯与三重校验(`existsSync` / `statSync.isDirectory` / `isInside` 用 realpath 比对)一行未动。
|
|
142
|
+
- **两个入口同一个裁决器**:`/codebuddy` 改成调用 `sessionDirOf()`,与工具共用同一段逻辑,两边从此不可能再各说各话;都拿不到时的报错文案改为说明「投影与回退都为空」。
|
|
143
|
+
- **降级是硬约束**:Host 没有发布 `sessionProjections` 时,`cliDirectoryOf` 保持 `undefined`,行为与改动前逐字节一致(`src/index.ts` 里 reactive `inject(['sessionProjections'])` 而非 apply 期采样一次,避免在 Desktop 上重演 tools/commands 被丢的历史)。**故意不回退到投影服务自己的 `defaultDirectory`**——Host 的 `get()` 是 `stateOf ?? defaultDirectory`,而那个值是 `process.cwd()`,照抄等于把「不知道」偷换成「用这个」,是本次唯一不许做的事,测试 `degrades to the configured default, not to the Host process directory` 钉死这条。
|
|
144
|
+
- **装配顺序缺陷(审核时发现,随本条一并修复)**:`createCliBridgeRuntime` 在 tools/commands 行触发,可能早于 `sessionProjections` 到位;而 `syncCliBridge` 的重建键是序列化后的设置——投影到位时设置没变,重建是 no-op,runtime 里带着 build 时的 `undefined`,投影**永远不生效且无报错**(症状原样保留)。修法:runtime 接的不是值而是**探针闭包** `cliDirectoryProbe`(每回合读当前格),顺序依赖从此消失;投影服务缺席或卸载时格子清零,探针返回 `null`,回退行为不变。测试 `honours a projection that appears AFTER the runtime was built` 钉死这条时序。
|
|
145
|
+
- **行为变化(读投影的必然后果)**:目录阶梯 roots 白名单的第一项现在是**投影目录**(`/cd` 之后的当前目录),不再是创建时的 header 目录。因此显式传 `working_dir` 指向旧目录(header 目录的子树)在 `/cd` 之后会被三重校验拒绝,报错文案会列出当前 roots——这是「会话在哪、CLI 就在哪」的正确形态,不是回归。**凭据、计费、provider 注册与普通请求路径均未改动**;工作目录阶梯的顺序与校验规则不变,只是第一档从「会话创建时的目录」换成了「会话当前的目录」。
|
|
92
146
|
|
|
93
147
|
### Docs
|
|
94
148
|
|
|
95
149
|
- [`docs/DESIGN.md`](docs/DESIGN.md) 补充 `11128` / `11140` 的分类依据(**按业务码,不按 `displayTips`,也不按裸 `request illegal`**)、`account-restricted` 的停权语义与它唯一实测的回池路径(手动「测试」),以及「渠道拒绝不换号、账号限制要停权」的分界。
|
|
150
|
+
- `src/cli-bridge/services.ts` 记下为什么读 Host 的 `workingDirectory` 投影而不是 `session.header.cwd`,以及 `stateOf` 同步、无文件系统 IO 这两个性质如何让目录阶梯仍然是 `execute` 的同步第一步。
|
|
96
151
|
|
|
97
152
|
### Tests
|
|
98
153
|
|
|
99
154
|
- 新增 11128/11140 分类与账号限制切分(含按信封读码、转义/断行措辞、混合体归属三组反向测试)、企业标记贯穿三个池内转发点与 Web 账号行、领取 409 拒绝、保存后草稿丢弃与陈旧刷新提示(两条 UI 测试原先钉住的正是缺陷本身,已改正)、以及「`selected` 必须跟随 `current()` 的轮换优先级」两条(轮换中只许一个标记、轮换指向已消失账号时回落);会话身份三件套一节 9 条(默认形状不变 / 开启后的 v7+`X-IDE-*`+`X-Request-ID`+`User-Agent` 不变 / 生图不受开关影响);`keepPromptLast` 一节 10 条(默认不动、搬移正确、`system-reminder` 与 `<acp>` 形状、**取自真实会话的组合注入形状**、**不重排普通对话**这条反向测试、system 仍居首、只剥离被记录那一条的前导标记、array content 两种形状、无注入块时不剥离)。
|
|
100
155
|
- 完整套件 **2205 通过 / 2 条按平台跳过**,`typecheck` 与 `build` 通过。所有「守卫能失败」的说法都做过变异验证,逐条结果:X-IDE 或三件套改成「无视开关」→ 默认形状测试变红;开关改成永不生效 → 对齐测试变红;生图 `'image'` 改成 `'chat'`、或删掉 `shape === 'chat'` 判据 → 生图守卫变红(这条只有把 identity 真正传进 `imageHeaders` 之后才成立——`shape` 曾经是空转参数,那时改它不会变红,是真的假阳性,已修);删掉 `isFailoverWorthy` 里的 `account_restricted` → 新增的 shim 换号测试变红(此前全绿);把 11140 的码判定退回紧凑字面量或退回裸 `11140` 文本 → 新增的「按信封读码」测试变红(分别命中 `spaced: client` 与十六进制假阳性);让 `accounts()` 重新忽略轮换标记 → 新增的「标记跟随被计费账号」测试变红;把面板的底色改回读 `selected` → 新增的「底色与文字指向同一行」测试变红。
|
|
156
|
+
- `tests/cli-bridge.spec.ts` 增 7 条(该文件 82 条全过):投影优先于 header、投影为空回退 header、非绝对路径的投影被忽略、不得退到 Host 进程目录(`defaultDirectory`)、`/codebuddy` 跑在投影目录里、投影与 header 都为空时拒绝,加一条时序回归(runtime 先建、投影后发布、不重挂也不改设置,下一回合仍须落在投影目录——把 runtime 接回 build 时的快照值,这条就落回 `header.cwd`,当场变红)。本条并入本版后在 Windows 上重跑:完整套件 **132 files / 2214 tests 全过、0 跳过**(上面两条记录的 2205 与 2 条按平台跳过都落在同一批:那 2 条在本平台不跳),`typecheck`(`tsconfig.json` + `tsconfig.client.json`)与 `tsdown` 构建通过。
|
|
101
157
|
|
|
102
158
|
## 5.7.0 (2026-10-23)
|
|
103
159
|
|
package/README.en.md
CHANGED
|
@@ -50,6 +50,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
|
|
|
50
50
|
- **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
|
|
51
51
|
- **Enterprise / education accounts: check-in and Claim do not apply** — a seat carrying an `enterpriseId` shows **no check-in column, no benefit campaigns, and no Claim request** in the pool (5.8.0, issue #129): upstream refuses the personal-entitlement **write** endpoints for such members (measured `10001`), while its **read** endpoint still lists the campaigns as unclaimed — so "the read said available" can never be the reason to write. Their credits live in a different book too (enterprise usage = limit − used), and the personal credit table simply returns an empty list. The flag now reaches the pool rows (`localPoolMembers`, `poolMembersOf`, `otherAccounts`) and the web account rows, and it is attached the moment an account enters the pool rather than after a click discovers it is unsupported. It is deliberately **not** on the usage page's top-level `account` object (that one was fixed for the display **name** only, via `preferredAccountName`) and not on the composer pill, which renders neither button.
|
|
52
52
|
- **Optional: make the usage page record these calls like the official client's (5.8.0, issue #132, OFF by default)** — with `usageIdentity` on, chat requests carry `X-IDE-Name: WorkBuddy` (plus `X-IDE-Type: CLI` and a version taken from the existing product UA, not a second hard-coded number), so the usage page's client column is no longer blank, and the conversation-identity triple becomes the official **32-character lower-case hex UUIDv7** with `X-Request-ID` alongside it — the gateway echoes that id as the SSE `id` and writes it into the usage page, so hyphenated v4 ids made one account's rows look like two different clients — while "fresh per request, all distinct" has not been relaxed. **`User-Agent` is untouched under either setting**: billing and rate limits recognise the CLI channel. The image endpoint does not follow this switch; it keeps the shape measured on 2026-10-21. It is off by default because the shape fixes nothing on its own (the three `11128` doors read message openings, a missing conversation id, and whether the first message is a system prompt — no header moves any of them) and the only measurement is a reporter's on an older build, so a purely cosmetic alignment does not get to put every account at risk first. There is also an opt-in `keepPromptLast` (**off by default**): the usage page takes the request text from the LAST `user` message, and the host appends its time samples, runtime context and `<system-reminder>` blocks AFTER what you actually typed, so that column recorded injected text; when enabled it moves those injected blocks **above the real prompt** — order only, except that the recorded prompt's own leading `<acp …>mNNNNN</acp>` marker is stripped (leave it and that column still opens with a marker, i.e. nothing was fixed); earlier messages **keep** theirs, because the model cites history by those ids and the only id that can never matter is the message being answered now. It stays off by default because the order is something the upstream counts and a few leading characters are removed from the real prompt. The move matches only the host's own injected prefixes, and a conversation with no injected tail is untouched byte for byte — marker included — pinned by tests written for both.
|
|
53
|
+
- **Optional: move each new session's title request onto the hidden lightweight model (issue #133, OFF by default)** — the DSH host fires one title-generation request per new session through the same provider as the conversation, so it is billed at the main paid model's rate. With `sessionTitle.enabled` on, a request the plugin recognises as a title (its only user message opens with the host's template `Generate the session title from this JSON array of human messages:`) is re-pointed at `sessionTitle.model` (default `wb-lite`). The reporter measured 0 credits for those calls, with three caveats: it is still a real upstream request and counts toward request volume; the measurement is the reporter's account, not reproduced here; detection is fail-closed — if the host rewords the template the rewrite silently stops and billing reverts, and ordinary conversations are never touched. If the upstream rejects the model, only that session's title fails once; the conversation is unaffected. Like `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` in the bullets above, `sessionTitle` is read once when the plugin mounts: **after editing `cordis.patch.yml`, reload the plugin or restart DSH for the change to take effect** — hot edits do not apply.
|
|
53
54
|
- **Benefits: a marker for what is unclaimed, plus a per-account Claim button** — the card marks, account by account, anything in the upstream's benefit campaigns (compensation / gifts) that **has not been claimed yet**. The data comes from the upstream's own two status endpoints; in the marking stage the plugin is **read-only** and never calls the two write endpoints. Three display rules all follow the upstream's state: `active && !claimed` shows the row, `claimed` removes it entirely, and an expired campaign (upstream flips `active` to `false`) disappears by itself — the plugin **never compares `end_time` against the local clock**, so a skewed machine cannot argue with the server; `end_time` only drives the "three days left" highlight. `active` alone is not enough to decide: an already-claimed account still reports `active: true`, and reading it alone would leave a claimed campaign hanging there forever. The read reuses the check-in pipeline (once per account, cached for 30 minutes, no request while the account pool is off), and a failing endpoint counts as "unread" rather than caching "nobody has benefits". Each row ends in a solid amber **Claim pill**, and one click claims **one campaign of one account**: there is no "claim everything", because a click spends one irrevocable real entitlement — and the write route **requires `accountId`**, so the request itself cannot batch. Before writing it **re-reads** the status and decides again; anything already claimed or expired returns 409 without touching upstream. After a successful claim it answers with the upstream's **fresh reading** and refreshes the card, with no optimistic update — even a silent upstream failure cannot leave a false "claimed" behind — and it also drops that account's cached benefit state, so the pill cannot linger on a stale status.
|
|
54
55
|
- **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
|
|
55
56
|
- **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Sign in with QR code** button (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR; it sits just above **More settings** and acts immediately, with nothing to save) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
|
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
- **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**签到勾选的账号**、**测试勾选的账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
|
|
58
58
|
- **企业版 / 教育版账号:签到与「领取」不适用** —— 带 `enterpriseId` 的席位(企业版或教育版)在池子里**不显示签到列、不显示福利活动,也不会发出领取请求**(5.8.0,issue #129):上游对这类成员的个人权益**写**接口一律拒绝(实测 `10001`),而它的**读**接口照样把活动列出来、标着「未领」——所以「读到了」绝不能当成「可以领」。积分也在另一本账上(企业用量 = 上限 − 已用),个人积分表对这种账号返回空列表。这个判断贯穿池卡片、输入框胶囊与 Web 状态页,并且在账号进入池子的那一刻就带上标记,而不是等用户点了按钮才发现不支持。
|
|
59
59
|
- **可选:让上游用量页把这笔记成官方客户端发的那笔(5.8.0,issue #132,默认关闭)** —— 开 `usageIdentity` 后 chat 请求带上 `X-IDE-Name: WorkBuddy`(连同 `X-IDE-Type: CLI` 与版本号;版本号取自现有的产品 UA,不留第二个硬编码数字),用量页的「使用端」由此不再空白;会话身份三件套同时改成官方客户端那种 **32 位十六进制 UUIDv7** 并补上 `X-Request-ID`——网关把这个 id 回显成 SSE 的 `id` 并写进用量页,此前带连字符的 v4 与官方记录摆在同一页上像两种客户端;**每请求现生成、互不相同**这条没有放松。**`User-Agent` 在两种取值下都一字未动**——账单与限流认的是 CLI 通道;生图路径也不跟着这个开关变,它保持自己 2026-10-21 实测过的形状。**默认关**的理由:形状本身不修任何东西(`11128` 的三道门读的是消息开头、会话 id 有没有、首条是不是 system,头改不动其中任何一条),而实测证据来自报告者的账号与旧版本、本插件未在真机复现——一次纯显示对齐不该让所有账号先承担风险。另有可选的 `keepPromptLast`(**默认关闭**):用量页的「请求」列取自 body 里**最后一条** `user` 消息,而宿主把时间采样、运行时上下文、`<system-reminder>` 追加在你真正输入的那句**之后**,那一列于是常年记的是注入块;开启后把这些注入块**搬到真实输入之前**(只变顺序;另外会剥掉**被记录那一条**真实输入前面的 `<acp …>mNNNNN</acp>` 标记,否则那一列仍然以标记开头而等于没修,更早的消息一律保留自己的标记,因为模型靠它们引用历史)。默认关的理由是它会改变上游会数到的消息顺序、并删掉真实输入的几个前导字符;搬移只匹配宿主自己的注入前缀,而普通对话在没有注入尾块时逐字节不变(连标记也不剥)——这两点各有专门测试钉住。
|
|
60
|
+
- **可选:把每个新会话的标题请求改到隐藏轻量模型(issue #133,默认关闭)** —— DSH 宿主每个新会话会发一次标题生成请求,走的是与对话相同的 provider,于是这笔按主付费模型的费率计费。开启 `sessionTitle.enabled` 后,插件把能按宿主标题模板(`Generate the session title from this JSON array of human messages:` 开头、整条 body 只有这一条 user 消息)识别出的标题请求改写到 `sessionTitle.model`(默认 `wb-lite`)。报告者实测该模型对这类请求 **0 积分**,但注意三点:这仍是**真实上游请求**,请求数照算;证据是报告者账号级的,本插件未复现;识别按 fail-closed 设计——宿主若改模板,改写静默停止、回到原计费,不会误伤普通对话。若上游拒绝该模型,只是该会话标题生成失败一次,不影响主对话。另外,`sessionTitle` 与本条及上一条的 `usageIdentity` / `keepPromptLast` / `extraInjectedPrefixes` 一样,都是插件挂载时读一次配置:**改完 `cordis.patch.yml` 后需要重载插件或重启 DSH 才生效**,热改不生效。
|
|
60
61
|
- **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
|
|
61
62
|
- **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
|
|
62
63
|
- **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码登录」按钮(国际版为「网页登录」——那边是浏览器登录页,没有二维码;这一行就在「更多设置」上方,点一下立即生效,不需要保存)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath,
|
|
2
|
+
import { C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as hostPeersInfo, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, V as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, kt as findWorkbuddyAppExecutableWithSource, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-CAX0oKQT.js";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
package/lib/client.js
CHANGED
|
@@ -734,6 +734,97 @@ window.__ModuleLoader__.load({
|
|
|
734
734
|
return typeof bridge === "object" && bridge !== null && bridge.enabled === true;
|
|
735
735
|
}
|
|
736
736
|
/**
|
|
737
|
+
* Read one upstream-identity boolean switch out of a settings snapshot.
|
|
738
|
+
*
|
|
739
|
+
* Only an explicit `true` is ON, exactly like {@link cliBridgeEnabledOf} and for
|
|
740
|
+
* the same reason: these switches change what gets billed or what bytes go
|
|
741
|
+
* upstream, so a missing or malformed value must read as OFF, never as consent.
|
|
742
|
+
*
|
|
743
|
+
* @param configured - the whole settings snapshot.
|
|
744
|
+
* @param field - which switch to read.
|
|
745
|
+
* @returns whether the switch is on.
|
|
746
|
+
*/
|
|
747
|
+
function upstreamSwitchOf(configured, field) {
|
|
748
|
+
const value = configured?.[field];
|
|
749
|
+
return unwrapVolatileDeep(value) === true;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Read the `sessionTitle` switch's two values out of a settings snapshot.
|
|
753
|
+
*
|
|
754
|
+
* Returns the schema's own defaults (`enabled: false`, `model: 'wb-lite'`) for
|
|
755
|
+
* anything missing or malformed, so the card shows what the plugin will actually
|
|
756
|
+
* do rather than an empty control.
|
|
757
|
+
*
|
|
758
|
+
* @param configured - the whole settings snapshot.
|
|
759
|
+
* @returns the switch state and target model.
|
|
760
|
+
*/
|
|
761
|
+
function sessionTitleOf(configured) {
|
|
762
|
+
const raw = unwrapVolatileDeep(configured?.["sessionTitle"]);
|
|
763
|
+
const block = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
764
|
+
const model = block["model"];
|
|
765
|
+
return {
|
|
766
|
+
enabled: block["enabled"] === true,
|
|
767
|
+
model: typeof model === "string" && model.trim() !== "" ? model.trim() : "wb-lite"
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Read the extra injected-prefix list out of a settings snapshot.
|
|
772
|
+
*
|
|
773
|
+
* A non-array or a list containing non-strings reads as empty: a malformed entry
|
|
774
|
+
* must not silently start matching messages it was never meant to match.
|
|
775
|
+
*
|
|
776
|
+
* @param configured - the whole settings snapshot.
|
|
777
|
+
* @returns the literal prefixes, trimmed, without blanks or duplicates.
|
|
778
|
+
*/
|
|
779
|
+
function extraInjectedPrefixesOf(configured) {
|
|
780
|
+
const raw = unwrapVolatileDeep(configured?.["extraInjectedPrefixes"]);
|
|
781
|
+
if (!Array.isArray(raw)) return [];
|
|
782
|
+
const seen = /* @__PURE__ */ new Set();
|
|
783
|
+
for (const entry of raw) {
|
|
784
|
+
if (typeof entry !== "string") continue;
|
|
785
|
+
const trimmed = entry.trim();
|
|
786
|
+
if (trimmed !== "") seen.add(trimmed);
|
|
787
|
+
}
|
|
788
|
+
return [...seen];
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Write one upstream-identity switch, verifying it landed.
|
|
792
|
+
*
|
|
793
|
+
* Same two writers as {@link writeCliBridgeEnabled} and for the same measured
|
|
794
|
+
* reason (the browser mirror of this namespace is documented as stale on the
|
|
795
|
+
* affected 0.1.7 deployments, so a switch that merely *looks* flipped is the
|
|
796
|
+
* "关闭不了" class of report): the Host endpoint first, the settings scope as a
|
|
797
|
+
* fallback. The Host writes only this field's own path, so siblings survive.
|
|
798
|
+
*
|
|
799
|
+
* @param scope - the bound settings scope for this plugin's namespace.
|
|
800
|
+
* @param field - which switch to write.
|
|
801
|
+
* @param value - the desired value.
|
|
802
|
+
* @throws {WorkBuddySettingsWriteError} when neither writer persists the value.
|
|
803
|
+
*/
|
|
804
|
+
async function writeUpstreamSwitch(scope, field, value) {
|
|
805
|
+
let hostError;
|
|
806
|
+
try {
|
|
807
|
+
const authoritative = await saveViaHostEndpoint(field, void 0, value);
|
|
808
|
+
if (authoritative !== void 0) try {
|
|
809
|
+
await scope.set(field, authoritative[field]);
|
|
810
|
+
} catch {}
|
|
811
|
+
return;
|
|
812
|
+
} catch (error) {
|
|
813
|
+
hostError = error;
|
|
814
|
+
}
|
|
815
|
+
let delivered = false;
|
|
816
|
+
try {
|
|
817
|
+
delivered = await scope.set(field, value) !== false;
|
|
818
|
+
} catch {
|
|
819
|
+
delivered = false;
|
|
820
|
+
}
|
|
821
|
+
if (delivered) {
|
|
822
|
+
const after = scope.getSnapshot().value;
|
|
823
|
+
if (field === "usageIdentity" || field === "keepPromptLast" ? upstreamSwitchOf(after, field) === value : field === "sessionTitle" ? JSON.stringify(sessionTitleOf(after)) === JSON.stringify(value) : JSON.stringify(extraInjectedPrefixesOf(after)) === JSON.stringify([...value])) return;
|
|
824
|
+
}
|
|
825
|
+
throw hostError instanceof Error ? hostError : new WorkBuddySettingsWriteError(field, "neither the Host save endpoint nor the settings scope persisted the value");
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
737
828
|
* Write the GLOBAL CodeBuddy-CLI switch, verifying it landed.
|
|
738
829
|
*
|
|
739
830
|
* Deliberately NOT a region field: one route, one budget, one switch. The two
|
|
@@ -2218,6 +2309,7 @@ window.__ModuleLoader__.load({
|
|
|
2218
2309
|
return sorted.find((promo) => promo.badge.display !== "activeOnly" && promoWithinAbsoluteWindow(promo, nowMs) && promo.schedule?.dailyUnusable !== true);
|
|
2219
2310
|
}
|
|
2220
2311
|
"WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0".match(/WorkBuddy\/([^\s]+)/u)?.[1];
|
|
2312
|
+
new RegExp(`^(?:<acp\\b[^>]*>m\\d{1,8}<\\/acp>[ \\t]*\\r?\\n?)*(?:Time sampled while preparing turn \\d+, step \\d+:|Current runtime context\\.|<system-reminder>|\\[Compressed conversation section\\]|This is an efficiency nudge)`, "u");
|
|
2221
2313
|
//#endregion
|
|
2222
2314
|
//#region src/probe.ts
|
|
2223
2315
|
/**
|
|
@@ -4847,7 +4939,13 @@ window.__ModuleLoader__.load({
|
|
|
4847
4939
|
CONTRAST colour, so a ticked box painted itself as a heavy black pill — the
|
|
4848
4940
|
loudest object in a section made of plain 13px text. */
|
|
4849
4941
|
.dsm-workbuddy-pref input{flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
4850
|
-
.dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-cli-bridge-check{inline-size:15px;block-size:15px;margin:3px 0 0}
|
|
4942
|
+
.dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-cli-bridge-check,.dsm-workbuddy-upstream-check{inline-size:15px;block-size:15px;margin:3px 0 0}
|
|
4943
|
+
/* The upstream-identity switches are their own group, so they get a divider
|
|
4944
|
+
above them instead of relying on spacing alone: they change the request the
|
|
4945
|
+
whole plugin sends, not a per-region display choice, and reading them as the
|
|
4946
|
+
tail of the preference list would misrepresent what flipping one costs. */
|
|
4947
|
+
.dsm-workbuddy-upstream-pref{margin-top:4px}
|
|
4948
|
+
.dsm-workbuddy-upstream-pref:first-of-type{border-top:1px solid var(--dsw-alias-bg-layer-3,#2e3034);padding-top:10px}
|
|
4851
4949
|
.dsm-workbuddy-pref input:disabled{cursor:default}
|
|
4852
4950
|
.dsm-workbuddy-pref:has(input:disabled){opacity:.6;cursor:default}
|
|
4853
4951
|
/* The NAME and what it does are TWO lines, not one wrapped blob: first what the
|
|
@@ -6295,6 +6393,21 @@ window.__ModuleLoader__.load({
|
|
|
6295
6393
|
const [cliBridgeRegionOrder, setCliBridgeRegionOrder] = (0, react.useState)(void 0);
|
|
6296
6394
|
const [cliBridgeRegionOrderBusy, setCliBridgeRegionOrderBusy] = (0, react.useState)(false);
|
|
6297
6395
|
/**
|
|
6396
|
+
* The upstream-identity switches the user flipped in this session.
|
|
6397
|
+
*
|
|
6398
|
+
* `undefined` means "not touched here, read the committed settings"; a flipped
|
|
6399
|
+
* value wins until the next snapshot agrees (same rule as `cliBridgeOn`). Each
|
|
6400
|
+
* switch has its own busy flag so flipping one does not disable the others —
|
|
6401
|
+
* they are independent writes, and a shared flag would make the card feel
|
|
6402
|
+
* frozen while one of them is in flight.
|
|
6403
|
+
*/
|
|
6404
|
+
const [usageIdentityOn, setUsageIdentityOn] = (0, react.useState)(void 0);
|
|
6405
|
+
const [usageIdentityBusy, setUsageIdentityBusy] = (0, react.useState)(false);
|
|
6406
|
+
const [keepPromptLastOn, setKeepPromptLastOn] = (0, react.useState)(void 0);
|
|
6407
|
+
const [keepPromptLastBusy, setKeepPromptLastBusy] = (0, react.useState)(false);
|
|
6408
|
+
const [sessionTitleOn, setSessionTitleOn] = (0, react.useState)(void 0);
|
|
6409
|
+
const [sessionTitleBusy, setSessionTitleBusy] = (0, react.useState)(false);
|
|
6410
|
+
/**
|
|
6298
6411
|
* Probe results, keyed by region then model id.
|
|
6299
6412
|
*
|
|
6300
6413
|
* Kept per region for the same reason drafts are: the two tabs are separate
|
|
@@ -6756,6 +6869,51 @@ window.__ModuleLoader__.load({
|
|
|
6756
6869
|
}
|
|
6757
6870
|
};
|
|
6758
6871
|
/**
|
|
6872
|
+
* The three upstream-identity switches, read the same three-level way the CLI
|
|
6873
|
+
* switch is: this card's own successful write, then the settings snapshot.
|
|
6874
|
+
*
|
|
6875
|
+
* Deliberately NOT reading them from the status document: these are
|
|
6876
|
+
* plugin-wide request-shape switches that the Host does not report per region,
|
|
6877
|
+
* and inventing a status field for them would create a second source of truth
|
|
6878
|
+
* for a value the settings document already owns.
|
|
6879
|
+
*/
|
|
6880
|
+
const usageIdentityValue = () => usageIdentityOn ?? upstreamSwitchOf(settingsScope?.getSnapshot().value, "usageIdentity");
|
|
6881
|
+
const keepPromptLastValue = () => keepPromptLastOn ?? upstreamSwitchOf(settingsScope?.getSnapshot().value, "keepPromptLast");
|
|
6882
|
+
const sessionTitleValue = () => {
|
|
6883
|
+
const committed = sessionTitleOf(settingsScope?.getSnapshot().value);
|
|
6884
|
+
return sessionTitleOn === void 0 ? committed : {
|
|
6885
|
+
...committed,
|
|
6886
|
+
enabled: sessionTitleOn
|
|
6887
|
+
};
|
|
6888
|
+
};
|
|
6889
|
+
/** The custom injected prefixes in effect, used only to REPORT their count. */
|
|
6890
|
+
const extraInjectedPrefixes = () => extraInjectedPrefixesOf(settingsScope?.getSnapshot().value);
|
|
6891
|
+
/**
|
|
6892
|
+
* Flip one upstream-identity switch.
|
|
6893
|
+
*
|
|
6894
|
+
* No `refreshUsage` afterwards, unlike the CLI switch: these change the shape
|
|
6895
|
+
* of the NEXT request, not any value the usage document currently reports, so
|
|
6896
|
+
* re-reading the usage would only cost an upstream round-trip (the exact
|
|
6897
|
+
* stall #131 was about) for a number that cannot have moved.
|
|
6898
|
+
*/
|
|
6899
|
+
const toggleUpstreamSwitch = async (field, enabled) => {
|
|
6900
|
+
if (settingsScope === void 0) return;
|
|
6901
|
+
const setBusy = field === "usageIdentity" ? setUsageIdentityBusy : field === "keepPromptLast" ? setKeepPromptLastBusy : setSessionTitleBusy;
|
|
6902
|
+
const setOwn = field === "usageIdentity" ? setUsageIdentityOn : field === "keepPromptLast" ? setKeepPromptLastOn : setSessionTitleOn;
|
|
6903
|
+
setBusy(true);
|
|
6904
|
+
try {
|
|
6905
|
+
await writeUpstreamSwitch(settingsScope, field, field === "sessionTitle" ? {
|
|
6906
|
+
...sessionTitleOf(settingsScope.getSnapshot().value),
|
|
6907
|
+
enabled
|
|
6908
|
+
} : enabled);
|
|
6909
|
+
if (mounted.current) setOwn(enabled);
|
|
6910
|
+
} catch (error) {
|
|
6911
|
+
if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
|
|
6912
|
+
} finally {
|
|
6913
|
+
if (mounted.current) setBusy(false);
|
|
6914
|
+
}
|
|
6915
|
+
};
|
|
6916
|
+
/**
|
|
6759
6917
|
* The pinned model / effort of the CLI route, rendered the same way the
|
|
6760
6918
|
* switch is: this card's own successful write first, then the Host's answer
|
|
6761
6919
|
* (the authoritative mirror), then the settings snapshot. `''` is a real
|
|
@@ -7528,6 +7686,91 @@ window.__ModuleLoader__.load({
|
|
|
7528
7686
|
})]
|
|
7529
7687
|
})
|
|
7530
7688
|
});
|
|
7689
|
+
/**
|
|
7690
|
+
* The upstream-identity switches (issue #133 suggestions 2 and 3).
|
|
7691
|
+
*
|
|
7692
|
+
* These govern what the gateway SEES and how it files the call, not which
|
|
7693
|
+
* provider serves it, so they sit in their own group with their own heading
|
|
7694
|
+
* instead of the preference list below (which is all about the card's own
|
|
7695
|
+
* display choices). Every one of them was already settable by hand in
|
|
7696
|
+
* `cordis.patch.yml`; this is only a UI route to the same fields.
|
|
7697
|
+
*
|
|
7698
|
+
* ONE shared hint about reloading, not one per switch: all four are sampled
|
|
7699
|
+
* when the plugin mounts into the shim, so the rule and its reason are the
|
|
7700
|
+
* same sentence for each, and repeating it four times would bury it.
|
|
7701
|
+
*/
|
|
7702
|
+
const upstreamSwitches = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
7703
|
+
className: "dsm-workbuddy-prefs",
|
|
7704
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
7705
|
+
className: "dsm-workbuddy-pref-hint",
|
|
7706
|
+
children: t("row.upstreamHint")
|
|
7707
|
+
}), [
|
|
7708
|
+
[
|
|
7709
|
+
"usageIdentity",
|
|
7710
|
+
"row.usageIdentityLabel",
|
|
7711
|
+
"row.usageIdentityHint",
|
|
7712
|
+
usageIdentityValue(),
|
|
7713
|
+
usageIdentityBusy,
|
|
7714
|
+
setUsageIdentityOn
|
|
7715
|
+
],
|
|
7716
|
+
[
|
|
7717
|
+
"keepPromptLast",
|
|
7718
|
+
"row.keepPromptLastLabel",
|
|
7719
|
+
"row.keepPromptLastHint",
|
|
7720
|
+
keepPromptLastValue(),
|
|
7721
|
+
keepPromptLastBusy,
|
|
7722
|
+
setKeepPromptLastOn
|
|
7723
|
+
],
|
|
7724
|
+
[
|
|
7725
|
+
"sessionTitle",
|
|
7726
|
+
"row.sessionTitleLabel",
|
|
7727
|
+
"row.sessionTitleHint",
|
|
7728
|
+
sessionTitleValue().enabled,
|
|
7729
|
+
sessionTitleBusy,
|
|
7730
|
+
setSessionTitleOn
|
|
7731
|
+
]
|
|
7732
|
+
].map(([field, labelKey, hintKey, on, busy, setOwn]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
7733
|
+
className: "dsm-workbuddy-pref dsm-workbuddy-upstream-pref",
|
|
7734
|
+
title: t(hintKey),
|
|
7735
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
7736
|
+
type: "checkbox",
|
|
7737
|
+
className: "dsm-workbuddy-upstream-check",
|
|
7738
|
+
checked: on,
|
|
7739
|
+
disabled: !canWrite || saving || poolBusy || busy,
|
|
7740
|
+
onChange: (event) => {
|
|
7741
|
+
toggleUpstreamSwitch(field, event.target.checked);
|
|
7742
|
+
}
|
|
7743
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
7744
|
+
className: "dsm-workbuddy-pref-copy",
|
|
7745
|
+
children: [
|
|
7746
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
7747
|
+
className: "dsm-workbuddy-pref-label",
|
|
7748
|
+
children: t(labelKey)
|
|
7749
|
+
}),
|
|
7750
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
7751
|
+
className: "dsm-workbuddy-pref-hint",
|
|
7752
|
+
children: t(hintKey)
|
|
7753
|
+
}),
|
|
7754
|
+
field === "sessionTitle" && on ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
7755
|
+
className: "dsm-workbuddy-pref-hint",
|
|
7756
|
+
children: [
|
|
7757
|
+
t("row.sessionTitleModel"),
|
|
7758
|
+
" ",
|
|
7759
|
+
sessionTitleValue().model
|
|
7760
|
+
]
|
|
7761
|
+
}) : null,
|
|
7762
|
+
field === "keepPromptLast" && on && extraInjectedPrefixes().length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
7763
|
+
className: "dsm-workbuddy-pref-hint",
|
|
7764
|
+
children: [
|
|
7765
|
+
t("row.extraPrefixesCount"),
|
|
7766
|
+
" ",
|
|
7767
|
+
extraInjectedPrefixes().length
|
|
7768
|
+
]
|
|
7769
|
+
}) : null
|
|
7770
|
+
]
|
|
7771
|
+
})]
|
|
7772
|
+
}, field))]
|
|
7773
|
+
});
|
|
7531
7774
|
const creditPreferences = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
7532
7775
|
className: "dsm-workbuddy-prefs",
|
|
7533
7776
|
children: [
|
|
@@ -8081,10 +8324,14 @@ window.__ModuleLoader__.load({
|
|
|
8081
8324
|
id: "dsm-workbuddy-panel-capabilities",
|
|
8082
8325
|
"aria-labelledby": "dsm-workbuddy-tab-capabilities",
|
|
8083
8326
|
hidden: signedIn && panel !== "capabilities",
|
|
8084
|
-
children: [
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8327
|
+
children: [
|
|
8328
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
8329
|
+
className: "dsm-workbuddy-panel-note",
|
|
8330
|
+
children: t("row.panelCapabilitiesHint")
|
|
8331
|
+
}),
|
|
8332
|
+
creditPreferences,
|
|
8333
|
+
upstreamSwitches
|
|
8334
|
+
]
|
|
8088
8335
|
}),
|
|
8089
8336
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
8090
8337
|
className: "dsm-workbuddy-panel",
|
|
@@ -10230,6 +10477,15 @@ window.__ModuleLoader__.load({
|
|
|
10230
10477
|
"row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
|
|
10231
10478
|
"row.poolRowRemoving": "Removing the account…",
|
|
10232
10479
|
"row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
|
|
10480
|
+
"row.upstreamHint": "These change what the gateway sees, not which provider serves the call. All of them are sampled when the plugin mounts, so reload the plugin (or restart DSH) after changing one.",
|
|
10481
|
+
"row.usageIdentityLabel": "Make the usage page record these calls like the official client",
|
|
10482
|
+
"row.usageIdentityHint": "Adds the X-IDE-* headers (which fill the 使用端 column, otherwise blank) and sends conversation ids as 32-hex UUIDv7. Display alignment only: the User-Agent and the request body never change. Off by default because the evidence comes from a reporter, not from this plugin.",
|
|
10483
|
+
"row.keepPromptLastLabel": "Record what you actually typed, not the injected context",
|
|
10484
|
+
"row.keepPromptLastHint": "Moves the harness-injected runtime context above your real prompt, so the usage page stops showing \"Time sampled…\" as your question. Message count and text are preserved. Off by default because it changes the message order the upstream counts.",
|
|
10485
|
+
"row.sessionTitleLabel": "Send session titles to the lightweight model",
|
|
10486
|
+
"row.sessionTitleHint": "The host fires one title request per new session and bills it at the main model rate. This re-points that one request at a hidden lightweight model. The request still happens either way. Off by default.",
|
|
10487
|
+
"row.sessionTitleModel": "Target model:",
|
|
10488
|
+
"row.extraPrefixesCount": "Custom injected prefixes in effect:",
|
|
10233
10489
|
"row.cliBridgeLabel": "Let the local CodeBuddy CLI do the work",
|
|
10234
10490
|
"row.cliBridgeHint": "Adds the codebuddy_ask tool and the /codebuddy command. Each call spends CodeBuddy quota. injectResult only affects /codebuddy — its result is injected back into the session, costing one DSH turn when the session is idle; tool calls never read it. Rate-limited: 6 turns per session per hour, 20 per process per hour, one turn at a time. Off by default.",
|
|
10235
10491
|
"row.cliBridgeModelLabel": "CLI model",
|
|
@@ -10633,6 +10889,15 @@ window.__ModuleLoader__.load({
|
|
|
10633
10889
|
"row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
|
|
10634
10890
|
"row.poolRowRemoving": "正在删除账号…",
|
|
10635
10891
|
"row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
|
|
10892
|
+
"row.upstreamHint": "这一组改的是网关看到什么,不是由哪个 provider 提供服务。四项都在插件挂载时采样,改完需重载插件或重启 DSH。",
|
|
10893
|
+
"row.usageIdentityLabel": "让用量页把调用记成官方客户端的样子",
|
|
10894
|
+
"row.usageIdentityHint": "补上 X-IDE-* 头(「使用端」那一列就不会空白),并把会话 id 改成 32 位十六进制 UUIDv7。只影响显示:User-Agent 与请求正文永不变。默认关闭——证据来自报告者,本插件未在真机复现。",
|
|
10895
|
+
"row.keepPromptLastLabel": "记录你真正输入的话,而不是注入的上下文",
|
|
10896
|
+
"row.keepPromptLastHint": "把宿主注入的运行上下文挪到真实提问前面,用量页就不会再把「Time sampled…」当成你的提问。消息条数与文本都不变。默认关闭——它会改变上游会数到的消息顺序。",
|
|
10897
|
+
"row.sessionTitleLabel": "会话标题请求改用轻量模型",
|
|
10898
|
+
"row.sessionTitleHint": "宿主每个新会话都会发一次标题请求,并按主模型费率计费;开启后这一次改投隐藏的轻量模型。请求无论开不开都会发出。默认关闭。",
|
|
10899
|
+
"row.sessionTitleModel": "目标模型:",
|
|
10900
|
+
"row.extraPrefixesCount": "生效的自定义注入前缀:",
|
|
10636
10901
|
"row.cliBridgeLabel": "允许调用本机 CodeBuddy CLI 干活",
|
|
10637
10902
|
"row.cliBridgeHint": "开启后多出 codebuddy_ask 工具与 /codebuddy 指令;每次调用花 CodeBuddy 配额。injectResult 只影响 /codebuddy:其结果回注会话,会话空闲时多花一次 DSH 回合;工具调用不读它。限流:每会话每小时 6 回合、每进程每小时 20 回合、同时只跑一个回合。默认关闭。",
|
|
10638
10903
|
"row.cliBridgeModelLabel": "CLI 模型",
|
|
@@ -2115,6 +2115,27 @@ function normalizeVerdictText(text) {
|
|
|
2115
2115
|
return text.normalize("NFKC").replace(/[\u200b-\u200d\ufeff]/gu, "").replace(/\s+/gu, " ").trim().toLowerCase();
|
|
2116
2116
|
}
|
|
2117
2117
|
/**
|
|
2118
|
+
* A `11140` body whose display fields decode to NOTHING at all.
|
|
2119
|
+
*
|
|
2120
|
+
* THE FAILURE MODE THIS CATCHES. `isAccountRestrictedBody` needs the business
|
|
2121
|
+
* code AND a phrase in `displayMsg` / `displayTips` / `msg` — deliberately, so a
|
|
2122
|
+
* shared sentence cannot bench an account. The mirror risk is a MISS: if the
|
|
2123
|
+
* upstream renames those fields, escapes them differently, or moves the
|
|
2124
|
+
* restriction wording somewhere new, every restriction degrades silently to
|
|
2125
|
+
* `policy_reject` and a benched-worthy account keeps taking traffic with no log
|
|
2126
|
+
* line anywhere. A genuine content refusal always carries the fields (its whole
|
|
2127
|
+
* point is a human-facing sentence), so a `11140` that decodes to no display
|
|
2128
|
+
* text at all is an anomalous envelope shape, not a normal refusal — exactly the
|
|
2129
|
+
* narrow condition worth a warning. Bodies WITH display text that merely lack
|
|
2130
|
+
* the restriction phrase are the everyday `policy_reject` and stay silent.
|
|
2131
|
+
*
|
|
2132
|
+
* Pure and side-effect-free: the caller (the transport layer, which holds the
|
|
2133
|
+
* injected logger) decides what to do with the answer.
|
|
2134
|
+
*/
|
|
2135
|
+
function isUndecodablePolicyBody(body) {
|
|
2136
|
+
return isContentPolicyBody(body) && accountVerdictText(body) === "";
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2118
2139
|
* Business code of the MODEL-scoped throttle (`14003`, "too many requests").
|
|
2119
2140
|
*
|
|
2120
2141
|
* The quoted-key form for the same reason as the policy markers: a request id is
|
|
@@ -3342,12 +3363,22 @@ function modelIdOfBody(bodyJson) {
|
|
|
3342
3363
|
}
|
|
3343
3364
|
}
|
|
3344
3365
|
/**
|
|
3366
|
+
* The optional run of `billion-context` markers every injected-prefix match
|
|
3367
|
+
* tolerates in front of the marker text, and the built-in marker alternatives.
|
|
3368
|
+
*/
|
|
3369
|
+
const INJECTED_ACP_MARKER_RUN = "(?:<acp\\b[^>]*>m\\d{1,8}<\\/acp>[ \\t]*\\r?\\n?)*";
|
|
3370
|
+
const INJECTED_BUILT_IN_PREFIXES = "Time sampled while preparing turn \\d+, step \\d+:|Current runtime context\\.|<system-reminder>|\\[Compressed conversation section\\]|This is an efficiency nudge";
|
|
3371
|
+
/**
|
|
3345
3372
|
* Prefixes the DSH harness uses for the context it injects as `user` messages.
|
|
3346
3373
|
*
|
|
3347
3374
|
* MEASURED shapes, not guesses: the harness's time-context provider appends
|
|
3348
3375
|
* `Time sampled while preparing turn N, step M: …`, its runtime-context
|
|
3349
3376
|
* provider appends `Current runtime context. …`, and its reminders arrive as
|
|
3350
|
-
* `<system-reminder>` blocks. `billion-context`
|
|
3377
|
+
* `<system-reminder>` blocks. `billion-context` injects its compression record
|
|
3378
|
+
* and its efficiency note as `user` messages too (issue #133): the compressed
|
|
3379
|
+
* section opens with a bracketed `[Compressed conversation section]` heading
|
|
3380
|
+
* line, and the nudge opens with `This is an efficiency …`. `billion-context`
|
|
3381
|
+
* may additionally wrap each
|
|
3351
3382
|
* message in an `<acp …>mNNNNN</acp>` marker, so the pattern TOLERATES that
|
|
3352
3383
|
* marker in front of an injected block — the marker itself is never removed
|
|
3353
3384
|
* from an injected message (see {@link ACP_LEADING_TAG} for where a marker IS
|
|
@@ -3357,7 +3388,24 @@ function modelIdOfBody(bodyJson) {
|
|
|
3357
3388
|
* as it arrived — the alternative (moving "the last non-empty user message")
|
|
3358
3389
|
* would reorder real conversations from other clients.
|
|
3359
3390
|
*/
|
|
3360
|
-
const INJECTED_USER_PREFIX =
|
|
3391
|
+
const INJECTED_USER_PREFIX = new RegExp(`^${INJECTED_ACP_MARKER_RUN}(?:${INJECTED_BUILT_IN_PREFIXES})`, "u");
|
|
3392
|
+
/**
|
|
3393
|
+
* Escape a user-supplied prefix so it can be embedded as a literal alternative.
|
|
3394
|
+
*/
|
|
3395
|
+
function escapeRegExp(text) {
|
|
3396
|
+
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* The prefix pattern for one request: the built-ins plus any configured
|
|
3400
|
+
* extras, each extra matched literally at the same position the built-ins
|
|
3401
|
+
* match (after optional leading whitespace and the `billion-context` marker).
|
|
3402
|
+
* Returns the shared built-in pattern when there are no usable extras.
|
|
3403
|
+
*/
|
|
3404
|
+
function injectedPrefixPattern(extras) {
|
|
3405
|
+
const literals = (extras ?? []).filter((extra) => typeof extra === "string" && extra !== "");
|
|
3406
|
+
if (literals.length === 0) return INJECTED_USER_PREFIX;
|
|
3407
|
+
return new RegExp(`^${INJECTED_ACP_MARKER_RUN}(?:${INJECTED_BUILT_IN_PREFIXES}|${literals.map(escapeRegExp).join("|")})`, "u");
|
|
3408
|
+
}
|
|
3361
3409
|
/**
|
|
3362
3410
|
* The per-message marker `billion-context` puts in front of every message.
|
|
3363
3411
|
*
|
|
@@ -3431,9 +3479,9 @@ function stripLeadingAcpTag(content) {
|
|
|
3431
3479
|
};
|
|
3432
3480
|
}
|
|
3433
3481
|
/** Whether this message is one of the harness's injected context blocks. */
|
|
3434
|
-
function isInjectedUserMessage(message) {
|
|
3482
|
+
function isInjectedUserMessage(message, pattern) {
|
|
3435
3483
|
if (message["role"] !== "user") return false;
|
|
3436
|
-
return
|
|
3484
|
+
return pattern.test(textOfContent(message["content"]).replace(/^\s+/u, ""));
|
|
3437
3485
|
}
|
|
3438
3486
|
/**
|
|
3439
3487
|
* Move the trailing injected `user` messages above the real prompt.
|
|
@@ -3441,22 +3489,22 @@ function isInjectedUserMessage(message) {
|
|
|
3441
3489
|
* Returns `true` when the order changed, so the caller can serialize only once.
|
|
3442
3490
|
* Never throws, and a body with nothing to move keeps its exact message order.
|
|
3443
3491
|
*/
|
|
3444
|
-
function keepPromptLast(messages) {
|
|
3492
|
+
function keepPromptLast(messages, pattern) {
|
|
3445
3493
|
let promptIndex = -1;
|
|
3446
3494
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
3447
3495
|
const value = messages[index];
|
|
3448
3496
|
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
3449
3497
|
const message = value;
|
|
3450
3498
|
if (message["role"] !== "user") continue;
|
|
3451
|
-
if (isInjectedUserMessage(message)) continue;
|
|
3499
|
+
if (isInjectedUserMessage(message, pattern)) continue;
|
|
3452
3500
|
if (textOfContent(message["content"]).trim() === "") continue;
|
|
3453
3501
|
promptIndex = index;
|
|
3454
3502
|
break;
|
|
3455
3503
|
}
|
|
3456
3504
|
if (promptIndex < 0) return false;
|
|
3457
|
-
const trailing = messages.slice(promptIndex + 1).filter((value) => typeof value === "object" && value !== null && !Array.isArray(value) ? isInjectedUserMessage(value) : false);
|
|
3505
|
+
const trailing = messages.slice(promptIndex + 1).filter((value) => typeof value === "object" && value !== null && !Array.isArray(value) ? isInjectedUserMessage(value, pattern) : false);
|
|
3458
3506
|
if (trailing.length === 0) return false;
|
|
3459
|
-
const kept = messages.filter((value, index) => !(index > promptIndex && typeof value === "object" && value !== null && !Array.isArray(value) && isInjectedUserMessage(value)));
|
|
3507
|
+
const kept = messages.filter((value, index) => !(index > promptIndex && typeof value === "object" && value !== null && !Array.isArray(value) && isInjectedUserMessage(value, pattern)));
|
|
3460
3508
|
kept.splice(promptIndex, 0, ...trailing);
|
|
3461
3509
|
const prompt = kept[promptIndex + trailing.length];
|
|
3462
3510
|
if (typeof prompt === "object" && prompt !== null && !Array.isArray(prompt)) {
|
|
@@ -3501,13 +3549,97 @@ function prepareChatBody(source, options) {
|
|
|
3501
3549
|
if (message["role"] === "developer") message["role"] = "system";
|
|
3502
3550
|
}
|
|
3503
3551
|
ensureSystemHead(obj);
|
|
3504
|
-
if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"]);
|
|
3552
|
+
if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"], injectedPrefixPattern(options.extraInjectedPrefixes));
|
|
3505
3553
|
neutralizeChannelFingerprints(obj);
|
|
3506
3554
|
normalizeToolChoice(obj);
|
|
3507
3555
|
dropRefusedReasoning(obj);
|
|
3508
3556
|
return JSON.stringify(obj);
|
|
3509
3557
|
}
|
|
3510
3558
|
/**
|
|
3559
|
+
* The host's session-title prompt template, verbatim up to the JSON payload.
|
|
3560
|
+
*
|
|
3561
|
+
* MEASURED in the installed DSH desktop 0.2.1-rc.2 `app.asar`, twice: the
|
|
3562
|
+
* request builder interpolates exactly this prefix before the JSON array of
|
|
3563
|
+
* `{seq, text}` records, and the host's own format validator asserts the
|
|
3564
|
+
* session-event carries the same text. Every title plugin the host bundles
|
|
3565
|
+
* (`dsh-session-title-llm`, `…-first-prompt-llm`, `…-all-prompts-llm`) shares
|
|
3566
|
+
* the template, so one prefix covers all variants. `purpose: 'session-title'`
|
|
3567
|
+
* exists on the host's `GenerateOptions` type but does not travel on the
|
|
3568
|
+
* OpenAI wire, so body shape is the only discriminator the shim has.
|
|
3569
|
+
*/
|
|
3570
|
+
const SESSION_TITLE_PROMPT_PREFIX = "Generate the session title from this JSON array of human messages:\n";
|
|
3571
|
+
const SESSION_TITLE_PROMPT_HEAD = "Generate the session title from this JSON array of human messages:";
|
|
3572
|
+
/**
|
|
3573
|
+
* The ASCII-only head of the template, up to (not including) its newline.
|
|
3574
|
+
*
|
|
3575
|
+
* The full prefix contains a raw newline, which a serialized body escapes to
|
|
3576
|
+
* `\n`, so `prepared.includes(SESSION_TITLE_PROMPT_PREFIX)` would MISS every
|
|
3577
|
+
* real title. This head is all plain ASCII, so it appears verbatim in the
|
|
3578
|
+
* serialized body whenever the host sends the template as a single text part
|
|
3579
|
+
* — which its own format validator asserts (`content.length === 1`). It
|
|
3580
|
+
* therefore makes a prefilter without false negatives for the real shape; a
|
|
3581
|
+
* body split mid-template across array parts would slip past it and simply
|
|
3582
|
+
* keep the pre-rewrite behaviour. False positives (a conversation quoting the
|
|
3583
|
+
* template) are fine: the full detection below rejects them — the prefilter
|
|
3584
|
+
* only skips work, never changes a verdict.
|
|
3585
|
+
*/
|
|
3586
|
+
/**
|
|
3587
|
+
* Rewrite a recognized session-title request onto the hidden lightweight model.
|
|
3588
|
+
*
|
|
3589
|
+
* WHY (issues #132/#133). The host asks its title plugins' prompt through the
|
|
3590
|
+
* same provider the conversation uses, so the title of every new session is
|
|
3591
|
+
* billed at the main paid model's rate. The reporter measured the upstream's
|
|
3592
|
+
* hidden `wb-lite` at 0 credits for exactly these requests (real upstream
|
|
3593
|
+
* calls, counted toward request volume, only fired once per new session), so
|
|
3594
|
+
* the rewrite moves the title to that model.
|
|
3595
|
+
*
|
|
3596
|
+
* DETECTION is fail-closed: the body is rewritten only when it has NO
|
|
3597
|
+
* assistant or tool messages and EXACTLY ONE `user` message, and that message
|
|
3598
|
+
* opens with the host's title template above. A real conversation can hardly
|
|
3599
|
+
* collide with the template (the host's own validator asserts it), and the
|
|
3600
|
+
* failure direction if the host ever rewords it is safe: the rewrite simply
|
|
3601
|
+
* stops, and the title is billed as before. `keepPromptLast` is unaffected —
|
|
3602
|
+
* the template prefix is not an injected-context marker, so a title body is
|
|
3603
|
+
* never reordered.
|
|
3604
|
+
*
|
|
3605
|
+
* WHAT is rewritten: the model id and the reasoning fields the lightweight
|
|
3606
|
+
* model does not accept. Everything else — `stream`, `max_tokens`, the
|
|
3607
|
+
* identity headers, every message byte — is untouched, so the other switches
|
|
3608
|
+
* keep behaving exactly as they do for conversation requests.
|
|
3609
|
+
*
|
|
3610
|
+
* Returns the original string unchanged when not enabled or not a title
|
|
3611
|
+
* request, so the caller does not need a separate shape check.
|
|
3612
|
+
*/
|
|
3613
|
+
function rewriteSessionTitleModel(prepared, options) {
|
|
3614
|
+
if (options?.enabled !== true) return prepared;
|
|
3615
|
+
if (!prepared.includes(SESSION_TITLE_PROMPT_HEAD)) return prepared;
|
|
3616
|
+
let body;
|
|
3617
|
+
try {
|
|
3618
|
+
body = JSON.parse(prepared);
|
|
3619
|
+
} catch {
|
|
3620
|
+
return prepared;
|
|
3621
|
+
}
|
|
3622
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return prepared;
|
|
3623
|
+
const obj = body;
|
|
3624
|
+
const messages = obj["messages"];
|
|
3625
|
+
if (!Array.isArray(messages)) return prepared;
|
|
3626
|
+
let titleText;
|
|
3627
|
+
for (const value of messages) {
|
|
3628
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return prepared;
|
|
3629
|
+
const message = value;
|
|
3630
|
+
if (message["role"] === "assistant" || message["role"] === "tool") return prepared;
|
|
3631
|
+
if (message["role"] !== "user") continue;
|
|
3632
|
+
if (titleText !== void 0) return prepared;
|
|
3633
|
+
titleText = textOfContent(message["content"]);
|
|
3634
|
+
}
|
|
3635
|
+
if (titleText === void 0) return prepared;
|
|
3636
|
+
if (!titleText.replace(/^\s+/u, "").startsWith(SESSION_TITLE_PROMPT_PREFIX)) return prepared;
|
|
3637
|
+
obj["model"] = typeof options.model === "string" && options.model.trim() !== "" ? options.model.trim() : "wb-lite";
|
|
3638
|
+
delete obj["reasoning_effort"];
|
|
3639
|
+
delete obj["reasoning"];
|
|
3640
|
+
return JSON.stringify(obj);
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3511
3643
|
* Remove `reasoning_effort: "off"` when it would only ever fail (issue #83).
|
|
3512
3644
|
*
|
|
3513
3645
|
* The refusal table is keyed by exact model id, but the model a request
|
|
@@ -3957,7 +4089,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3957
4089
|
onFallback;
|
|
3958
4090
|
identity;
|
|
3959
4091
|
/**
|
|
3960
|
-
* Reports
|
|
4092
|
+
* Reports an anomaly the reader or classifier had to tolerate.
|
|
3961
4093
|
*
|
|
3962
4094
|
* Injected rather than logged here because this module holds no logger on
|
|
3963
4095
|
* purpose — it is pure transport + parsing, so it stays importable from
|
|
@@ -3995,10 +4127,12 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3995
4127
|
response
|
|
3996
4128
|
};
|
|
3997
4129
|
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
|
|
4130
|
+
const kind = classifyUpstreamError(response.status, text);
|
|
4131
|
+
if (isUndecodablePolicyBody(text)) this.onFallback?.(`11140 失败体没有任何可解码的 displayMsg/displayTips/msg 字段,若上游调整了限制措辞或字段名,账号停权判定会全部漏判。失败体片段:${text.slice(0, 200)}`);
|
|
3998
4132
|
return {
|
|
3999
4133
|
ok: false,
|
|
4000
4134
|
status: response.status,
|
|
4001
|
-
kind
|
|
4135
|
+
kind,
|
|
4002
4136
|
message: text,
|
|
4003
4137
|
detail: parseUpstreamErrorDetail(text)
|
|
4004
4138
|
};
|
|
@@ -6806,7 +6940,7 @@ function describeRosterSeenPayload(raw) {
|
|
|
6806
6940
|
//#endregion
|
|
6807
6941
|
//#region src/version.ts
|
|
6808
6942
|
/** The npm package version this build was produced from. */
|
|
6809
|
-
const WORKBUDDY_CONNECT_VERSION = "5.8.
|
|
6943
|
+
const WORKBUDDY_CONNECT_VERSION = "5.8.4";
|
|
6810
6944
|
//#endregion
|
|
6811
6945
|
//#region src/host-heartbeat.ts
|
|
6812
6946
|
/**
|
|
@@ -6964,4 +7098,4 @@ async function clearHostHeartbeat() {
|
|
|
6964
7098
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
6965
7099
|
}
|
|
6966
7100
|
//#endregion
|
|
6967
|
-
export { parseCreditMultiplier as $, isEncryptedCredentialError as A,
|
|
7101
|
+
export { parseCreditMultiplier as $, isEncryptedCredentialError as A, isEncryptedFieldWrapper as At, WorkBuddyCredentialRejectedError as B, writeFileAtomic as Bt, WorkBuddyCredentialStore as C, WORKBUDDY_APP_EXECUTABLE_ENV as Ct, defaultDesktopAuthDirs as D, fetchAtRestKeyPayload as Dt, defaultDesktopAuthCandidates as E, deriveAtRestKeyId as Et, workbuddyAccountId as F, readAtRestKey as Ft, forceNeutralHead as G, channelGateMemberOf as H, workbuddyOwnAuthPath as I, workbuddyAppExecutableCandidates as It, isCredentialRejectedError as J, isContextTooLongBody as K, CONTEXT_OVERFLOW_MARKER as L, hostPeersInfo as Lt, legacyWorkbuddyOwnAuthPath as M, macosBundleExecutable as Mt, parseWorkBuddyAuth as N, macosNestedAppBundles as Nt, defaultDesktopAuthPath as O, findWorkbuddyAppExecutable as Ot, preferredAccountName as P, openEncryptedField as Pt, openingEvidenceOfBody as Q, CREDENTIAL_REJECTED_CODE as R, resolveDshHome as Rt, WORKBUDDY_AUTH_FILE_ENV as S, nativeModalityOf as St, authFileName as T, deriveAtRestKey as Tt, classifyUpstreamError as U, WorkBuddyUpstreamClient as V, declaredTools as W, knownRegionOf as X, isFirstMessageGateBody as Y, modelIdOfBody as Z, deriveCatalog as _, pickImageModel as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, NATIVE_MODALITY_BY_MODEL_ID as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, WorkBuddyImageError as gt, WorkBuddyCatalog as h, DEFAULT_IMAGE_SIZE as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, isWorkbuddyBundle as jt, hasEncryptedCredentialFields as k, findWorkbuddyAppExecutableWithSource as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseQueueAnswer as vt, WorkBuddyEncryptedCredentialError as w, clearAtRestKeyCache as wt, WORKBUDDY_AUTH_FILENAME as x, imageDefaultFor as xt, withoutRouters as y, queueNoticeText as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, withFileLock as zt };
|
package/lib/index.d.ts
CHANGED
|
@@ -1403,6 +1403,20 @@ interface PrepareChatBodyOptions {
|
|
|
1403
1403
|
* conversation whose trailing user messages are real turns.
|
|
1404
1404
|
*/
|
|
1405
1405
|
keepPromptLast?: boolean;
|
|
1406
|
+
/**
|
|
1407
|
+
* Additional leading markers that mark a trailing `user` message as injected
|
|
1408
|
+
* context, one literal prefix per entry.
|
|
1409
|
+
*
|
|
1410
|
+
* WHY. The built-in pattern at {@link INJECTED_USER_PREFIX} knows only the
|
|
1411
|
+
* prefixes measured at the time it was written; a companion plugin (or a DSH
|
|
1412
|
+
* update) that starts injecting new text would silently fall back to having
|
|
1413
|
+
* its blocks recorded as the prompt. Each entry is matched LITERALLY at the
|
|
1414
|
+
* start of the message text (after leading whitespace and the
|
|
1415
|
+
* `billion-context` marker, which the built-in pattern tolerates) and OR'ed
|
|
1416
|
+
* with the built-ins; entries are escaped, so no configuration value can turn
|
|
1417
|
+
* the pattern invalid.
|
|
1418
|
+
*/
|
|
1419
|
+
extraInjectedPrefixes?: readonly string[];
|
|
1406
1420
|
}
|
|
1407
1421
|
/**
|
|
1408
1422
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream: force
|
|
@@ -1468,7 +1482,7 @@ declare class WorkBuddyUpstreamClient {
|
|
|
1468
1482
|
private readonly onFallback?;
|
|
1469
1483
|
private readonly identity;
|
|
1470
1484
|
/**
|
|
1471
|
-
* Reports
|
|
1485
|
+
* Reports an anomaly the reader or classifier had to tolerate.
|
|
1472
1486
|
*
|
|
1473
1487
|
* Injected rather than logged here because this module holds no logger on
|
|
1474
1488
|
* purpose — it is pure transport + parsing, so it stays importable from
|
|
@@ -1973,6 +1987,24 @@ interface WorkBuddyShimOptions {
|
|
|
1973
1987
|
* Default false: it changes bytes the upstream counts, so it is opt-in.
|
|
1974
1988
|
*/
|
|
1975
1989
|
keepPromptLast?: boolean;
|
|
1990
|
+
/**
|
|
1991
|
+
* Extra literal prefixes (issue #133) treated like the harness's injected
|
|
1992
|
+
* markers under `keepPromptLast`, for context blocks a companion plugin
|
|
1993
|
+
* injects that the built-in list does not know about. Only read when
|
|
1994
|
+
* `keepPromptLast` is on.
|
|
1995
|
+
*/
|
|
1996
|
+
extraInjectedPrefixes?: readonly string[];
|
|
1997
|
+
/**
|
|
1998
|
+
* Move the host's session-title request onto the hidden lightweight model
|
|
1999
|
+
* (issues #132/#133). Disabled by default; when enabled, a body recognized
|
|
2000
|
+
* as the one-message title prompt is re-pointed at `model` (default
|
|
2001
|
+
* `wb-lite`, measured 0 credits) instead of billing the main paid model.
|
|
2002
|
+
* See `rewriteSessionTitleModel` for the fail-closed detection rules.
|
|
2003
|
+
*/
|
|
2004
|
+
sessionTitle?: {
|
|
2005
|
+
enabled?: boolean;
|
|
2006
|
+
model?: string;
|
|
2007
|
+
};
|
|
1976
2008
|
/**
|
|
1977
2009
|
* Pool failover for chat requests. Absent means no failover at all — the
|
|
1978
2010
|
* pre-pool behaviour, where the selected account serves and its failure is
|
|
@@ -4825,6 +4857,34 @@ interface Config {
|
|
|
4825
4857
|
* an explicit opt-in rather than a silent change on upgrade.
|
|
4826
4858
|
*/
|
|
4827
4859
|
keepPromptLast?: boolean;
|
|
4860
|
+
/**
|
|
4861
|
+
* Extra literal prefixes (issue #133) treated like the harness's injected
|
|
4862
|
+
* context markers when `keepPromptLast` is on, for injected blocks a
|
|
4863
|
+
* companion plugin produces that the built-in list does not recognize. Each
|
|
4864
|
+
* entry is matched literally at the start of a trailing `user` message.
|
|
4865
|
+
*/
|
|
4866
|
+
extraInjectedPrefixes?: string[];
|
|
4867
|
+
/**
|
|
4868
|
+
* Move the host's session-title request onto the hidden lightweight model
|
|
4869
|
+
* (issues #132/#133). Off by default: it is a real upstream request either
|
|
4870
|
+
* way, and the rewrite changes which bucket it is billed against.
|
|
4871
|
+
*/
|
|
4872
|
+
sessionTitle?: {
|
|
4873
|
+
/**
|
|
4874
|
+
* Whether title requests are re-pointed at {@link sessionTitle.model}.
|
|
4875
|
+
* The host still fires the request exactly once per new session; the
|
|
4876
|
+
* reporter measured the lightweight model at 0 credits for these calls
|
|
4877
|
+
* (request volume still counts, and the evidence is reporter-level, not
|
|
4878
|
+
* reproduced by this plugin).
|
|
4879
|
+
*/
|
|
4880
|
+
enabled?: boolean;
|
|
4881
|
+
/**
|
|
4882
|
+
* Target model for the rewritten title request. Defaults to `wb-lite`,
|
|
4883
|
+
* the hidden model the reporter measured; configurable in case the id
|
|
4884
|
+
* changes or a region exposes a different one.
|
|
4885
|
+
*/
|
|
4886
|
+
model?: string;
|
|
4887
|
+
};
|
|
4828
4888
|
}
|
|
4829
4889
|
/**
|
|
4830
4890
|
* Mark a schema's field as volatile on the DSH lines that support it.
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as
|
|
1
|
+
import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isEncryptedFieldWrapper, B as WorkBuddyCredentialRejectedError, Bt as writeFileAtomic, C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, Dt as fetchAtRestKeyPayload, E as defaultDesktopAuthCandidates, Et as deriveAtRestKeyId, F as workbuddyAccountId, Ft as readAtRestKey, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, It as workbuddyAppExecutableCandidates, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, M as legacyWorkbuddyOwnAuthPath, Mt as macosBundleExecutable, N as parseWorkBuddyAuth, Nt as macosNestedAppBundles, O as defaultDesktopAuthPath, Ot as findWorkbuddyAppExecutable, P as preferredAccountName, Pt as openEncryptedField, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as resolveDshHome, S as WORKBUDDY_AUTH_FILE_ENV, St as nativeModalityOf, T as authFileName, Tt as deriveAtRestKey, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as pickImageModel, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as NATIVE_MODALITY_BY_MODEL_ID, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as WorkBuddyImageError, h as WorkBuddyCatalog, ht as DEFAULT_IMAGE_SIZE, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as isWorkbuddyBundle, k as hasEncryptedCredentialFields, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseQueueAnswer, w as WorkBuddyEncryptedCredentialError, wt as clearAtRestKeyCache, x as WORKBUDDY_AUTH_FILENAME, xt as imageDefaultFor, y as withoutRouters, yt as queueNoticeText, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as withFileLock } from "./host-heartbeat-CAX0oKQT.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -4229,6 +4229,8 @@ function createWorkBuddyShim(options) {
|
|
|
4229
4229
|
* silent difference after an upgrade.
|
|
4230
4230
|
*/
|
|
4231
4231
|
const keepPromptLastEnabled = options.keepPromptLast === true;
|
|
4232
|
+
const extraInjectedPrefixes = Array.isArray(options.extraInjectedPrefixes) ? options.extraInjectedPrefixes.filter((entry) => typeof entry === "string" && entry !== "") : [];
|
|
4233
|
+
const sessionTitleOptions = options.sessionTitle ?? {};
|
|
4232
4234
|
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
4233
4235
|
/**
|
|
4234
4236
|
* Whether this process's prompts have been PROVEN to need the invisible
|
|
@@ -4382,7 +4384,10 @@ function createWorkBuddyShim(options) {
|
|
|
4382
4384
|
return;
|
|
4383
4385
|
}
|
|
4384
4386
|
const raw = (await readBody(req)).toString("utf8");
|
|
4385
|
-
const prepared = prepareChatBody(raw, {
|
|
4387
|
+
const prepared = rewriteSessionTitleModel(prepareChatBody(raw, {
|
|
4388
|
+
keepPromptLast: keepPromptLastEnabled,
|
|
4389
|
+
extraInjectedPrefixes
|
|
4390
|
+
}), sessionTitleOptions);
|
|
4386
4391
|
const requestModelId = modelIdOfBody(prepared);
|
|
4387
4392
|
if (onRequestBody !== void 0) try {
|
|
4388
4393
|
onRequestBody(prepared);
|
|
@@ -5956,12 +5961,77 @@ function isInside(parent, child) {
|
|
|
5956
5961
|
if (inner === outer) return true;
|
|
5957
5962
|
return inner.startsWith(outer.endsWith(sep) ? outer : outer + sep);
|
|
5958
5963
|
}
|
|
5959
|
-
/** The session directory this
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5964
|
+
/** The session directory this call came from, when the Host supplies one.
|
|
5965
|
+
*
|
|
5966
|
+
* THE ORDER INSIDE THIS FUNCTION IS THE FIX. The session's live projection is
|
|
5967
|
+
* read FIRST, because it is the directory the human and the model are actually
|
|
5968
|
+
* in; the session header is the fallback, because it is where that projection
|
|
5969
|
+
* starts from (`init: header => header.cwd ?? null`) and because a session that
|
|
5970
|
+
* never committed a projection has nothing else.
|
|
5971
|
+
*
|
|
5972
|
+
* Both sources are re-validated here: the session service already rejects
|
|
5973
|
+
* relative headers, but a hand-built agent in a test (or a Host with a looser
|
|
5974
|
+
* contract) must not be able to smuggle a relative path through either one.
|
|
5975
|
+
*/
|
|
5976
|
+
function sessionDirOf(execution, directoryOf) {
|
|
5977
|
+
const dirs = sessionDirCandidates(execution, directoryOf);
|
|
5978
|
+
return dirs.projection !== "" ? dirs.projection : dirs.header;
|
|
5979
|
+
}
|
|
5980
|
+
/**
|
|
5981
|
+
* Both session-directory sources, kept APART so the caller can tell them — and
|
|
5982
|
+
* therefore fall back between them — by validity, not just by presence.
|
|
5983
|
+
*
|
|
5984
|
+
* {@link sessionDirOf} collapses these into one answer, which is right for "where
|
|
5985
|
+
* are we" but loses the information "the projection said X, the header says Y".
|
|
5986
|
+
* The ladder in {@link resolveWorkingDir} needs the pair: a projection can go
|
|
5987
|
+
* STALE (the directory was deleted, moved, or is an offline mount) while the
|
|
5988
|
+
* creation-time header still points at a perfectly good directory. Without the
|
|
5989
|
+
* split, a stale projection is a hard refusal even though a usable directory is
|
|
5990
|
+
* one field away.
|
|
5991
|
+
*
|
|
5992
|
+
* Both sources are re-validated the same way as in {@link sessionDirOf}: absolute
|
|
5993
|
+
* and non-blank, so a hand-built session cannot smuggle a relative path through
|
|
5994
|
+
* either field.
|
|
5995
|
+
*/
|
|
5996
|
+
function sessionDirCandidates(execution, directoryOf) {
|
|
5997
|
+
const session = execution?.agent?.session;
|
|
5998
|
+
let projection = "";
|
|
5999
|
+
if (session !== void 0 && directoryOf !== void 0) {
|
|
6000
|
+
const live = directoryOf(session);
|
|
6001
|
+
if (typeof live === "string") {
|
|
6002
|
+
const trimmed = live.trim();
|
|
6003
|
+
if (trimmed !== "" && isAbsolute(trimmed)) projection = trimmed;
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
const cwd = session?.header?.cwd;
|
|
6007
|
+
let header = "";
|
|
6008
|
+
if (typeof cwd === "string") {
|
|
6009
|
+
const trimmed = cwd.trim();
|
|
6010
|
+
if (trimmed !== "" && isAbsolute(trimmed)) header = trimmed;
|
|
6011
|
+
}
|
|
6012
|
+
return {
|
|
6013
|
+
projection,
|
|
6014
|
+
header
|
|
6015
|
+
};
|
|
6016
|
+
}
|
|
6017
|
+
/**
|
|
6018
|
+
* Why `path` cannot be a turn's working directory, or `''` when it can.
|
|
6019
|
+
*
|
|
6020
|
+
* The three checks the ladder has always applied (exists / is a directory /
|
|
6021
|
+
* readable), stated once so the projection→header fallback in
|
|
6022
|
+
* {@link resolveWorkingDir} re-runs exactly the same gate instead of a weaker
|
|
6023
|
+
* private copy.
|
|
6024
|
+
*/
|
|
6025
|
+
function dirRejection(path) {
|
|
6026
|
+
if (!existsSync(path)) return `working_dir "${path}" 不存在。`;
|
|
6027
|
+
let isDir = false;
|
|
6028
|
+
try {
|
|
6029
|
+
isDir = statSync(path).isDirectory();
|
|
6030
|
+
} catch (error) {
|
|
6031
|
+
return `working_dir "${path}" 无法读取:${error instanceof Error ? error.message : String(error)}`;
|
|
6032
|
+
}
|
|
6033
|
+
if (!isDir) return `working_dir "${path}" 不是一个目录。`;
|
|
6034
|
+
return "";
|
|
5965
6035
|
}
|
|
5966
6036
|
/**
|
|
5967
6037
|
* Decide the directory one turn runs in.
|
|
@@ -5980,8 +6050,10 @@ function sessionDirOf(execution) {
|
|
|
5980
6050
|
* A scratch turn is exempt by construction: it is a fresh empty temp directory
|
|
5981
6051
|
* the bridge just created, so there is nothing in it to leak.
|
|
5982
6052
|
*/
|
|
5983
|
-
function resolveWorkingDir(args, options, execution) {
|
|
5984
|
-
const
|
|
6053
|
+
function resolveWorkingDir(args, options, execution, directoryOf) {
|
|
6054
|
+
const dirs = sessionDirCandidates(execution, directoryOf);
|
|
6055
|
+
const primarySessionDir = dirs.projection !== "" && dirs.projection !== dirs.header ? dirs.projection : dirs.header;
|
|
6056
|
+
const fallbackSessionDir = primarySessionDir !== dirs.header && dirs.header !== "" ? dirs.header : "";
|
|
5985
6057
|
const explicit = typeof args["working_dir"] === "string" ? args["working_dir"].trim() : "";
|
|
5986
6058
|
let chosen = "";
|
|
5987
6059
|
let fromArgument = false;
|
|
@@ -5994,7 +6066,7 @@ function resolveWorkingDir(args, options, execution) {
|
|
|
5994
6066
|
};
|
|
5995
6067
|
chosen = explicit;
|
|
5996
6068
|
fromArgument = true;
|
|
5997
|
-
} else if (
|
|
6069
|
+
} else if (primarySessionDir !== "") chosen = primarySessionDir;
|
|
5998
6070
|
else if (options.workingDir !== "") chosen = options.workingDir;
|
|
5999
6071
|
else if (args["allow_unattended"] === true || options.allowUnattended) return {
|
|
6000
6072
|
ok: true,
|
|
@@ -6008,31 +6080,17 @@ function resolveWorkingDir(args, options, execution) {
|
|
|
6008
6080
|
unattended: false,
|
|
6009
6081
|
reason: "no working directory。宿主目前不把会话 cwd 传给插件工具,所以这个值必须由调用方给:① 传 working_dir 指定一个已存在的绝对路径目录;② 在插件配置里设 cliBridge.workingDir;③ 若本回合只是通用问答、不需要任何目录,传 allow_unattended: true(会在一个临时目录里跑,该目录是它的读写边界,不碰你的 home 或会话工作区)。"
|
|
6010
6082
|
};
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
unattended: false,
|
|
6015
|
-
reason: `working_dir "${chosen}" 不存在。`
|
|
6016
|
-
};
|
|
6017
|
-
let isDir = false;
|
|
6018
|
-
try {
|
|
6019
|
-
isDir = statSync(chosen).isDirectory();
|
|
6020
|
-
} catch (error) {
|
|
6021
|
-
return {
|
|
6022
|
-
ok: false,
|
|
6023
|
-
dir: "",
|
|
6024
|
-
unattended: false,
|
|
6025
|
-
reason: `working_dir "${chosen}" 无法读取:${error instanceof Error ? error.message : String(error)}`
|
|
6026
|
-
};
|
|
6027
|
-
}
|
|
6028
|
-
if (!isDir) return {
|
|
6083
|
+
const rejection = dirRejection(chosen);
|
|
6084
|
+
if (rejection !== "" && !fromArgument && chosen === primarySessionDir && fallbackSessionDir !== "" && dirRejection(fallbackSessionDir) === "") chosen = fallbackSessionDir;
|
|
6085
|
+
else if (rejection !== "") return {
|
|
6029
6086
|
ok: false,
|
|
6030
6087
|
dir: "",
|
|
6031
6088
|
unattended: false,
|
|
6032
|
-
reason:
|
|
6089
|
+
reason: !fromArgument && chosen === primarySessionDir && fallbackSessionDir !== "" && dirRejection(fallbackSessionDir) !== "" ? `${rejection}(回退的会话目录 "${fallbackSessionDir}" 同样不可用,工作目录投影与会话 header 都失效了。)` : rejection
|
|
6033
6090
|
};
|
|
6034
6091
|
const roots = [];
|
|
6035
|
-
if (
|
|
6092
|
+
if (primarySessionDir !== "") roots.push(primarySessionDir);
|
|
6093
|
+
if (fallbackSessionDir !== "") roots.push(fallbackSessionDir);
|
|
6036
6094
|
if (options.workingDir !== "") roots.push(options.workingDir);
|
|
6037
6095
|
roots.push(...options.allowedDirs);
|
|
6038
6096
|
if (!roots.some((root) => root !== "" && isInside(root, chosen))) {
|
|
@@ -6249,7 +6307,7 @@ async function runAsk(args, execution, deps, entrance = "tool") {
|
|
|
6249
6307
|
ok: false,
|
|
6250
6308
|
error: "message 不能为空:要说清楚这一轮让 CodeBuddy 做什么。"
|
|
6251
6309
|
});
|
|
6252
|
-
const dir = resolveWorkingDir(args, options, execution);
|
|
6310
|
+
const dir = resolveWorkingDir(args, options, execution, deps.directoryOf);
|
|
6253
6311
|
if (!dir.ok) return result({
|
|
6254
6312
|
ok: false,
|
|
6255
6313
|
error: dir.reason
|
|
@@ -6497,6 +6555,20 @@ function formatCommandOutcome(value, extra = {}) {
|
|
|
6497
6555
|
return head + denied + footer + truncated + delivery;
|
|
6498
6556
|
}
|
|
6499
6557
|
/**
|
|
6558
|
+
* The command's slice of the invocation the tool's resolver needs.
|
|
6559
|
+
*
|
|
6560
|
+
* The command's agent IS a live agent (the Host's command service passes the
|
|
6561
|
+
* whole thing, not a narrowed subset), so it satisfies `BridgeToolExecution`
|
|
6562
|
+
* structurally. The `signal` field is carried so the shared resolver sees the
|
|
6563
|
+
* same object shape it sees from a tool call.
|
|
6564
|
+
*/
|
|
6565
|
+
function sessionDirOfCommand(invocation, directoryOf) {
|
|
6566
|
+
return sessionDirOf({
|
|
6567
|
+
...invocation.agent !== void 0 ? { agent: invocation.agent } : {},
|
|
6568
|
+
...invocation.signal !== void 0 ? { signal: invocation.signal } : {}
|
|
6569
|
+
}, directoryOf);
|
|
6570
|
+
}
|
|
6571
|
+
/**
|
|
6500
6572
|
* Register `/codebuddy`, returning the disposer.
|
|
6501
6573
|
*
|
|
6502
6574
|
* `commands` is passed in rather than looked up so a Host without the service
|
|
@@ -6519,10 +6591,10 @@ function mountCodebuddyCommand(commands, deps) {
|
|
|
6519
6591
|
description: "用 CodeBuddy 在当前工作区跑一轮;留空 = 评审未提交改动;以 --edit 开头 = 这一轮允许改文件。注意:默认(cliBridge.injectResult=true)跑完会把结论回注给本会话的模型,那一次唤醒会真的消耗 DSH token;设 injectResult=false 才完全不碰模型。CodeBuddy 那一侧的账号额度每次都会消耗。",
|
|
6520
6592
|
input: { hint: "留空 = 评审未提交改动;写要求 = 只读执行(如\"看一下这个函数为什么崩\");要以 --edit 开头 = 允许改文件(如\"--edit 修一下这个空指针\")" },
|
|
6521
6593
|
handler: async (invocation) => {
|
|
6522
|
-
const cwd = invocation.
|
|
6523
|
-
if (
|
|
6594
|
+
const cwd = sessionDirOfCommand(invocation, deps.directoryOf);
|
|
6595
|
+
if (cwd === "") return {
|
|
6524
6596
|
kind: "error",
|
|
6525
|
-
text: "
|
|
6597
|
+
text: "这个会话没有工作区路径(会话的工作目录投影为空,回退的 session.header.cwd 也为空),无法确定 CodeBuddy 该在哪里跑。请在一个打开过目录的会话里再试。"
|
|
6526
6598
|
};
|
|
6527
6599
|
const raw = typeof invocation.rawInput === "string" ? invocation.rawInput : "";
|
|
6528
6600
|
const parsed = parseCommandInput(raw);
|
|
@@ -6750,7 +6822,7 @@ function normalizeCliBridgeOptions(settings, onRejected) {
|
|
|
6750
6822
|
* Returning `undefined` rather than an enabled flag keeps every caller honest:
|
|
6751
6823
|
* there is no "mounted but disabled" state to accidentally treat as live.
|
|
6752
6824
|
*/
|
|
6753
|
-
function createCliBridgeRuntime(settings, regions, log) {
|
|
6825
|
+
function createCliBridgeRuntime(settings, regions, log, directoryOf) {
|
|
6754
6826
|
const options = normalizeCliBridgeOptions(settings, (message) => log(message));
|
|
6755
6827
|
if (!options.enabled) return void 0;
|
|
6756
6828
|
const live = createLiveBoard();
|
|
@@ -6761,7 +6833,8 @@ function createCliBridgeRuntime(settings, regions, log) {
|
|
|
6761
6833
|
regions,
|
|
6762
6834
|
log,
|
|
6763
6835
|
gate: gateFor(options),
|
|
6764
|
-
live
|
|
6836
|
+
live,
|
|
6837
|
+
...directoryOf !== void 0 ? { directoryOf } : {}
|
|
6765
6838
|
},
|
|
6766
6839
|
live
|
|
6767
6840
|
};
|
|
@@ -8528,6 +8601,26 @@ async function writePoolProbes(region, updates) {
|
|
|
8528
8601
|
function safeMessage(error) {
|
|
8529
8602
|
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
8530
8603
|
}
|
|
8604
|
+
/**
|
|
8605
|
+
* The upstream-identity switches the card may write through `__save`.
|
|
8606
|
+
*
|
|
8607
|
+
* These are TOP-LEVEL config fields (see the schema in `index.ts`), not a
|
|
8608
|
+
* nested block, so each is set on its own path and no merge base is involved.
|
|
8609
|
+
* The list is a constant here rather than derived from the schema because this
|
|
8610
|
+
* endpoint must stay a closed allow-list: deriving it would let an unrelated
|
|
8611
|
+
* schema addition quietly widen what the browser can write.
|
|
8612
|
+
*
|
|
8613
|
+
* Every entry is a switch the user can already set by hand in
|
|
8614
|
+
* `cordis.patch.yml`; this list only adds a UI route to the same fields (issue
|
|
8615
|
+
* #133 suggestions 2 and 3), so it can never expose anything the config file
|
|
8616
|
+
* does not already expose.
|
|
8617
|
+
*/
|
|
8618
|
+
const UPSTREAM_SWITCH_FIELDS = /* @__PURE__ */ new Set([
|
|
8619
|
+
"usageIdentity",
|
|
8620
|
+
"keepPromptLast",
|
|
8621
|
+
"extraInjectedPrefixes",
|
|
8622
|
+
"sessionTitle"
|
|
8623
|
+
]);
|
|
8531
8624
|
/** A non-null, non-array object: the only shape the slot merge applies to. */
|
|
8532
8625
|
function isPlainRecord(value) {
|
|
8533
8626
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -9110,7 +9203,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9110
9203
|
}
|
|
9111
9204
|
}
|
|
9112
9205
|
});
|
|
9113
|
-
ctx.webServer.register({
|
|
9206
|
+
const disposeBenefitClaim = ctx.webServer.register({
|
|
9114
9207
|
kind: "exact",
|
|
9115
9208
|
path: WORKBUDDY_BENEFIT_CLAIM_PATH,
|
|
9116
9209
|
handler: async (req, res) => {
|
|
@@ -9373,7 +9466,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9373
9466
|
req.on("error", reject);
|
|
9374
9467
|
});
|
|
9375
9468
|
const field = body.field;
|
|
9376
|
-
if (field !== "regions" && field !== "accounts" && field !== "cliBridge") return json(res, 400, { error: "field must be regions, accounts or
|
|
9469
|
+
if (field !== "regions" && field !== "accounts" && field !== "cliBridge" && !(typeof field === "string" && UPSTREAM_SWITCH_FIELDS.has(field))) return json(res, 400, { error: "field must be regions, accounts, cliBridge, usageIdentity, keepPromptLast, extraInjectedPrefixes or sessionTitle" });
|
|
9377
9470
|
const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
|
|
9378
9471
|
if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
|
|
9379
9472
|
const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
|
|
@@ -9391,6 +9484,17 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9391
9484
|
value: mergedBridge
|
|
9392
9485
|
});
|
|
9393
9486
|
}
|
|
9487
|
+
if (UPSTREAM_SWITCH_FIELDS.has(field)) {
|
|
9488
|
+
await settings.mutate(row.ns, [{
|
|
9489
|
+
op: "set",
|
|
9490
|
+
path: [field],
|
|
9491
|
+
value: body.value
|
|
9492
|
+
}], void 0);
|
|
9493
|
+
return json(res, 200, {
|
|
9494
|
+
ok: true,
|
|
9495
|
+
value: { [field]: body.value }
|
|
9496
|
+
});
|
|
9497
|
+
}
|
|
9394
9498
|
const merged = { ...current };
|
|
9395
9499
|
for (const [key, slot] of Object.entries(incoming)) {
|
|
9396
9500
|
const prior = current[key];
|
|
@@ -9526,6 +9630,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9526
9630
|
}
|
|
9527
9631
|
});
|
|
9528
9632
|
return () => {
|
|
9633
|
+
disposeBenefitClaim();
|
|
9529
9634
|
disposeRefresh();
|
|
9530
9635
|
disposeCheckin();
|
|
9531
9636
|
disposeAccounts();
|
|
@@ -9746,7 +9851,12 @@ const Config = z.object({
|
|
|
9746
9851
|
z.const("always")
|
|
9747
9852
|
]).default("failure").description("When to perturb scanned messages with invisible characters: \"off\" only rescues after a channel-gate refusal, \"failure\" (default) learns from a successful rescue and starts later requests perturbed, \"always\" starts every request with the dense rescue for a gateway whose plain attempt is guaranteed to fail.")),
|
|
9748
9853
|
usageIdentity: asVolatile(z.boolean().default(false).description("Make the upstream's usage page record these calls like the official client's: add the X-IDE-* identity headers (which fill the 使用端 column) and send the conversation ids as 32-hex UUIDv7. Off by default — it is a display alignment measured by a reporter, not by this plugin, and the chat channel identity (User-Agent) and request body never change.")),
|
|
9749
|
-
keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts."))
|
|
9854
|
+
keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts.")),
|
|
9855
|
+
extraInjectedPrefixes: asVolatile(z.array(z.string()).default([]).description("Extra literal prefixes (issue #133) treated like the harness-injected context markers when keepPromptLast is on — for blocks a companion plugin injects that the built-in list does not recognize. Each entry matches the start of a trailing user message; read only while keepPromptLast is enabled. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
|
|
9856
|
+
sessionTitle: asVolatile(z.object({
|
|
9857
|
+
enabled: z.boolean().default(false),
|
|
9858
|
+
model: z.string().default("wb-lite")
|
|
9859
|
+
}).default({}).description("Move the host session-title request onto a hidden lightweight model (issues #132/#133). The host fires one real upstream request per new session either way; enabled re-points it at `model` (default wb-lite, measured 0 credits by the reporter) instead of billing the main paid model. Request volume still counts, and the lightweight model is reporter-measured, not reproduced here. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it."))
|
|
9750
9860
|
});
|
|
9751
9861
|
/**
|
|
9752
9862
|
* One region's saved model state. A config written before the region split has
|
|
@@ -9909,6 +10019,8 @@ function apply(ctx, config) {
|
|
|
9909
10019
|
logger: ctx.logger,
|
|
9910
10020
|
channelPerturbation: unwrapVolatile(config.channelPerturbation) ?? "failure",
|
|
9911
10021
|
keepPromptLast: unwrapVolatile(config.keepPromptLast) === true,
|
|
10022
|
+
extraInjectedPrefixes: unwrapVolatile(config.extraInjectedPrefixes) ?? [],
|
|
10023
|
+
sessionTitle: unwrapVolatile(config.sessionTitle) ?? {},
|
|
9912
10024
|
/**
|
|
9913
10025
|
* Pool routing, bound to THIS region.
|
|
9914
10026
|
*
|
|
@@ -10259,6 +10371,8 @@ function apply(ctx, config) {
|
|
|
10259
10371
|
});
|
|
10260
10372
|
let cliTools;
|
|
10261
10373
|
let cliCommands;
|
|
10374
|
+
let cliDirectoryOf;
|
|
10375
|
+
const cliDirectoryProbe = (session) => cliDirectoryOf?.(session) ?? null;
|
|
10262
10376
|
let cliRuntime;
|
|
10263
10377
|
let cliRuntimeKey = "";
|
|
10264
10378
|
let disposeCliTool;
|
|
@@ -10279,7 +10393,7 @@ function apply(ctx, config) {
|
|
|
10279
10393
|
cliRuntime = createCliBridgeRuntime(settings, REGION_KEYS.map((region) => ({
|
|
10280
10394
|
region,
|
|
10281
10395
|
store: stacks[region].store
|
|
10282
|
-
})), (message) => ctx.logger.warn(`dsh-connect-workbuddy: ${message}`));
|
|
10396
|
+
})), (message) => ctx.logger.warn(`dsh-connect-workbuddy: ${message}`), cliDirectoryProbe);
|
|
10283
10397
|
}
|
|
10284
10398
|
if (cliRuntime === void 0) {
|
|
10285
10399
|
disposeCliSurfaces();
|
|
@@ -10308,6 +10422,42 @@ function apply(ctx, config) {
|
|
|
10308
10422
|
cliCommands = void 0;
|
|
10309
10423
|
});
|
|
10310
10424
|
});
|
|
10425
|
+
ctx.inject(["sessionProjections"], (projectionCtx) => {
|
|
10426
|
+
const reader = projectionCtx.sessionProjections;
|
|
10427
|
+
if (reader === void 0 || typeof reader.stateOf !== "function") return;
|
|
10428
|
+
/**
|
|
10429
|
+
* The one place the projection key is spelled out.
|
|
10430
|
+
*
|
|
10431
|
+
* It must match the Host's own registration — key
|
|
10432
|
+
* `"workingDirectory"`, stateVersion 1, in the Host's
|
|
10433
|
+
* @deepseek-ai/dsh-working-directory package. If the Host renames the key, this is
|
|
10434
|
+
* the line that has to follow, and the comment is what tells the next reader
|
|
10435
|
+
* where to look.
|
|
10436
|
+
*
|
|
10437
|
+
* Returns null for `"no committed directory"`, which is the same signal the header
|
|
10438
|
+
* path produces when it has nothing — so the ladder below it behaves identically.
|
|
10439
|
+
* NOTE what it deliberately does NOT do: fall back to the service's own default.
|
|
10440
|
+
* The Host's get() answers stateOf(...) ?? defaultDirectory, and that fallback is
|
|
10441
|
+
* the Host process's cwd, i.e. a directory nobody chose. Substituting it here would
|
|
10442
|
+
* quietly turn `"we do not know"` into `"use this one"`, which is the one change this
|
|
10443
|
+
* whole refactor must not make.
|
|
10444
|
+
*/
|
|
10445
|
+
const WORKING_DIRECTORY_PROJECTION_KEY = "workingDirectory";
|
|
10446
|
+
cliDirectoryOf = (session) => {
|
|
10447
|
+
let state;
|
|
10448
|
+
try {
|
|
10449
|
+
state = reader.stateOf(session, WORKING_DIRECTORY_PROJECTION_KEY);
|
|
10450
|
+
} catch {
|
|
10451
|
+
return null;
|
|
10452
|
+
}
|
|
10453
|
+
if (typeof state !== "string") return null;
|
|
10454
|
+
const trimmed = state.trim();
|
|
10455
|
+
return trimmed === "" ? null : trimmed;
|
|
10456
|
+
};
|
|
10457
|
+
projectionCtx.effect(() => () => {
|
|
10458
|
+
cliDirectoryOf = void 0;
|
|
10459
|
+
});
|
|
10460
|
+
});
|
|
10311
10461
|
ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
|
|
10312
10462
|
store: (region) => stacks[region].store,
|
|
10313
10463
|
client,
|
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": "5.8.
|
|
5
|
+
"version": "5.8.4",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|