dsh-llm-workbuddy 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -199,6 +199,7 @@ Web 登录后终端脚本也读得到同一份会话。
199
199
  | `GET /api/workbuddy/status` | 读取会话文件(默认 `~/.codebuddy-session.json`,或配置的 `sessionFile`)的 `auth.expiresAt` 判断会话是否有效,并 `fetch` 代理 `/health` 判断 `proxyUp`;返回 JSON:`{ sessionFile, authenticated, expiresAt, account, proxyUp, tokenValid, loginScriptAvailable }`;非 GET 返回 405 |
200
200
  | `POST /api/workbuddy/login` | 若已有有效会话则直接返回 `alreadyLoggedIn`;否则用系统 `python3` `spawn` 包内 `login_workbuddy.py --session-file <sessionFile>`,从子进程 stdout 解析出 `authUrl` 立即返回 `{ authUrl, pending:true }`(设备流在后台继续,前端轮询 status 感知完成);非 POST 返回 405 |
201
201
  | `POST /api/workbuddy/diagnose` | **一键诊断**:真实探测健康状态,返回 `{ ok, session, health, chat, loginScriptAvailable, restartCommand }`。与 `/status` 不同,它除了探 `/health`,还会**真实发一次最小模型请求**(`chat.chatWorking`),能戳穿"胶囊显示成功但模型全 500"的假象;`ok:false` 时附带 `restartCommand`(自动区分本地 monorepo 布局与标准安装);非 POST 返回 405 |
202
+ | `GET /api/workbuddy/usage` | **用量统计**:读取本地 token 用量台账(`$DSH_HOME/llm-workbuddy/usage.jsonl`),返回 `{ today, byModel, total }`(今日/按模型/累计的 input/output tokens 与请求次数);非 GET 返回 405 |
202
203
 
203
204
  > 会话文件与登录脚本路径的解析顺序:
204
205
  > 1. 配置里显式指定的 `sessionFile` / `loginScript`;
@@ -214,6 +215,9 @@ Web 登录后终端脚本也读得到同一份会话。
214
215
  - 胶囊里有个 **🔍 诊断** 按钮:点它 `POST /api/workbuddy/diagnose`,弹出一个面板
215
216
  显示**真实健康状态**(登录、会话文件、代理进程、登录令牌、模型能否出字),
216
217
  发现问题时附带**可复制的重启命令**(一键复制到终端执行)。
218
+ - 胶囊里有个 **📊 用量** 按钮:点它 `GET /api/workbuddy/usage`,弹出一个面板
219
+ 显示**今日/累计 token 用量**与**按模型明细**(数据来自本地台账
220
+ `$DSH_HOME/llm-workbuddy/usage.jsonl`)。
217
221
  - 状态映射:
218
222
  - `authenticated && proxyUp` → 🟢 绿,显示 `WorkBuddy · <昵称>`
219
223
  - 否则 → 🔴 红,显示「登录」按钮;`proxyUp` 为 false 时额外提示 `代理未运行`
@@ -280,8 +284,10 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
280
284
 
281
285
  模型列表默认取插件内置目录;代理可达时改为实时拉取 `/v1/models`(支持
282
286
  `{"models": [...]}` / `{"data": [...]}` 两种返回),未列出的模型 id 仍可原样
283
- 传递。每个模型的推理等级(`reasoningEffort`)默认来自内置目录,代理可达时优先
284
- 沿用目录值(代理 `/v1/models` 当前只上报 `High`,不区分模型)。
287
+ 传递。**内置目录充当白名单**:代理返回的已下架/不可用模型(如 glm-4.6v、
288
+ glm-5.0、minimax-m2.5 等,上游返回 `service info not found`)会被过滤,不显示
289
+ 在 UI 中。每个模型的推理等级(`reasoningEffort`)默认来自内置目录,代理可达时
290
+ 优先沿用目录值(代理 `/v1/models` 当前只上报 `High`,不区分模型)。
285
291
 
286
292
  ---
287
293
 
package/lib/client.js CHANGED
@@ -107,6 +107,18 @@ function runWidget() {
107
107
  }
108
108
  // 诊断按钮始终显示,便于随时自查真实健康状态。
109
109
  html += '<button class="wb-btn wb-diagnose" id="wb-diagnose" title="一键诊断">🔍</button>';
110
+ // 用量按钮:查看今日/累计 token 用量与模型明细。
111
+ html += '<button class="wb-btn wb-usage" id="wb-usage" title="用量统计">📊</button>';
112
+ // 签到按钮:✅ 今日已处理 / 🎁 今日未处理(点击立即领取;10 点后也会自动领取)。
113
+ var ck = status.checkin;
114
+ if (ck) {
115
+ var ckMsg = ck.lastResult && ck.lastResult.message ? "\n" + ck.lastResult.message : "";
116
+ var ckTitle = ck.handledToday ? "今日已处理签到" + ckMsg : "今日还没签到(每天 10 点后自动领取)\n点击立即领取";
117
+ var ckIcon = ck.handledToday && ck.lastResult && ck.lastResult.ok ? "✅" : "🎁";
118
+ html += '<button class="wb-btn wb-checkin" id="wb-checkin" title="' + esc(ckTitle) + '">' + ckIcon + "</button>";
119
+ }
120
+ // 刷新模型按钮:从代理重读模型列表(跟随 WorkBuddy 应用更新)并让模型选择器立即重载。
121
+ html += '<button class="wb-btn wb-models" id="wb-models" title="刷新模型列表">🔄</button>';
110
122
  if (!status.proxyUp) html += '<span class="wb-warn">代理未运行</span>';
111
123
  html += "</div>";
112
124
  host.innerHTML = html;
@@ -114,6 +126,12 @@ function runWidget() {
114
126
  if (btn) btn.addEventListener("click", onLogin);
115
127
  var dg = host.querySelector("#wb-diagnose");
116
128
  if (dg) dg.addEventListener("click", onDiagnose);
129
+ var us = host.querySelector("#wb-usage");
130
+ if (us) us.addEventListener("click", onUsage);
131
+ var ckBtn = host.querySelector("#wb-checkin");
132
+ if (ckBtn) ckBtn.addEventListener("click", onCheckin);
133
+ var mdBtn = host.querySelector("#wb-models");
134
+ if (mdBtn) mdBtn.addEventListener("click", onRefreshModels);
117
135
  }
118
136
 
119
137
  function refresh() {
@@ -206,12 +224,92 @@ function runWidget() {
206
224
  });
207
225
  }
208
226
 
227
+ /** 用量统计:读取 /api/workbuddy/usage 并展示今日/累计/模型明细。 */
228
+ function onUsage() {
229
+ var panel = document.getElementById("wb-usage-panel");
230
+ if (panel) { panel.remove(); return; }
231
+ var el = document.createElement("div");
232
+ el.id = "wb-usage-panel";
233
+ el.style.cssText =
234
+ "position:fixed;right:12px;bottom:44px;z-index:2147483647;width:340px;max-height:340px;overflow:auto;" +
235
+ "background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;" +
236
+ "padding:10px;font:12px/1.5 system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.5);";
237
+ el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 用量统计</div>' +
238
+ '<div class="wb-usage-body" style="color:#94a3b8">加载中…</div>';
239
+ document.body.appendChild(el);
240
+
241
+ fetch("/api/workbuddy/usage")
242
+ .then(function (r) { return r.ok ? r.json() : null; })
243
+ .then(function (d) {
244
+ if (!d) throw new Error("空响应");
245
+ var lines = [];
246
+ lines.push("今日:输入 " + d.today.inputTokens + " / 输出 " + d.today.outputTokens + " tokens(" + d.today.requests + " 次请求)");
247
+ lines.push("累计:输入 " + d.total.inputTokens + " / 输出 " + d.total.outputTokens + " tokens(" + d.total.requests + " 次请求)");
248
+ lines.push("");
249
+ lines.push("按模型明细:");
250
+ if (d.byModel.length === 0) lines.push(" 暂无数据");
251
+ for (var i = 0; i < d.byModel.length; i += 1) {
252
+ var m = d.byModel[i];
253
+ lines.push(" " + m.model + ":输入 " + m.inputTokens + " / 输出 " + m.outputTokens + "(" + m.requests + " 次)");
254
+ }
255
+ el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;margin-bottom:6px">WorkBuddy 用量统计</div>' +
256
+ '<div class="wb-usage-body" style="white-space:pre-wrap;word-break:break-word">' + esc(lines.join("\n")) + "</div>" +
257
+ '<div style="margin-top:8px;color:#64748b;font-size:11px">数据来源:本地 token 用量台账(' + esc(d.total.requests) + " 次累计)</div>";
258
+ })
259
+ .catch(function (err) {
260
+ el.innerHTML = '<div class="wb-usage-title" style="font-weight:700;color:#f87171">用量加载失败</div>' +
261
+ '<div class="wb-usage-body">' + esc(String(err && err.message || err)) + "</div>";
262
+ });
263
+ }
264
+
209
265
  function esc(s) {
210
266
  return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
211
267
  return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
212
268
  });
213
269
  }
214
270
 
271
+ /** 手动领取今日签到(幂等:官方对已签到返回业务拒绝,不会出错)。 */
272
+ function onCheckin() {
273
+ var btn = host.querySelector("#wb-checkin");
274
+ if (!btn || btn.dataset.busy) return;
275
+ btn.dataset.busy = "1";
276
+ btn.textContent = "⏳";
277
+ fetch("/api/workbuddy/checkin", { method: "POST" })
278
+ .then(function (r) { return r.json(); })
279
+ .then(function (d) {
280
+ btn.textContent = d && d.ok ? "✅" : "🎁";
281
+ btn.title = (d && d.message) || "签到结果未知";
282
+ refresh();
283
+ })
284
+ .catch(function () {
285
+ btn.textContent = "🎁";
286
+ btn.title = "签到请求失败(代理未运行?)";
287
+ })
288
+ .then(function () { delete btn.dataset.busy; });
289
+ }
290
+
291
+ /** 刷新模型列表:清插件发现缓存 + 触发模型选择器立即重载。 */
292
+ function onRefreshModels() {
293
+ var btn = host.querySelector("#wb-models");
294
+ if (!btn || btn.dataset.busy) return;
295
+ btn.dataset.busy = "1";
296
+ btn.textContent = "…";
297
+ fetch("/api/workbuddy/refresh-models", { method: "POST" })
298
+ .then(function (r) { return r.json(); })
299
+ .then(function (d) {
300
+ btn.textContent = d && typeof d.count === "number" ? d.count + "✓" : "✓";
301
+ btn.title = d && d.announced
302
+ ? "已刷新:" + d.count + " 个模型(模型选择器已更新)"
303
+ : "已刷新模型缓存(事件通知失败,重开设置页可见新列表)";
304
+ setTimeout(function () { btn.textContent = "🔄"; }, 2000);
305
+ })
306
+ .catch(function () {
307
+ btn.textContent = "🔄";
308
+ btn.title = "刷新失败(代理未运行?)";
309
+ })
310
+ .then(function () { delete btn.dataset.busy; });
311
+ }
312
+
215
313
  refresh();
216
314
  timer = setInterval(refresh, POLL_MS);
217
315
  }
package/lib/index.js CHANGED
@@ -17,8 +17,8 @@
17
17
  * to a shipped static catalog when the proxy is not running.
18
18
  */
19
19
  import { spawn } from "node:child_process";
20
- import { existsSync, readFileSync } from "node:fs";
21
- import { dirname, resolve } from "node:path";
20
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, truncateSync, writeSync, closeSync, writeFileSync } from "node:fs";
21
+ import { dirname, join, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import z from "@deepseek-ai/schemastery";
24
24
  import {
@@ -62,6 +62,12 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
62
62
  /** Default per-request output-token cap. */
63
63
  const DEFAULT_MAX_TOKENS = 32_000;
64
64
 
65
+ /** Usage ledger: one JSONL line per completed request, under DSH_HOME. */
66
+ const USAGE_DIR_NAME = "llm-workbuddy";
67
+ const USAGE_FILE_NAME = "usage.jsonl";
68
+ /** Cap on how many historical lines the ledger keeps (avoid unbounded growth). */
69
+ const USAGE_MAX_LINES = 20_000;
70
+
65
71
  /** Live `/v1/models` discovery cache window and request timeout. */
66
72
  const DISCOVERY_TTL_MS = 30_000;
67
73
  const DISCOVERY_TIMEOUT_MS = 2_000;
@@ -75,19 +81,15 @@ const DISCOVERY_TIMEOUT_MS = 2_000;
75
81
  const DEFAULT_MODELS = [
76
82
  { id: "deepseek-v4-pro", name: "Deepseek-V4-Pro", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
77
83
  { id: "deepseek-v4-flash", name: "Deepseek-V4-Flash", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
84
+ { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2", contextWindow: 96_000, maxTokens: 32_000, reasoningEffort: "medium" },
78
85
  { id: "glm-5.2", name: "GLM-5.2", contextWindow: 1_000_000, maxTokens: 48_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
79
86
  { id: "glm-5.1", name: "GLM-5.1", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "medium" },
80
- { id: "glm-5.0", name: "GLM-5.0", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "high" },
81
- { id: "glm-4.7", name: "GLM-4.7", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "high" },
82
- { id: "glm-4.6", name: "GLM-4.6", contextWindow: 168_000, maxTokens: 32_000, reasoningEffort: "high" },
83
87
  { id: "glm-5v-turbo", name: "GLM-5v-Turbo", contextWindow: 200_000, maxTokens: 64_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
84
88
  { id: "kimi-k3-1", name: "Kimi-K3", contextWindow: 1_000_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
85
89
  { id: "kimi-k2.7", name: "Kimi-K2.7-Code", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
86
90
  { id: "kimi-k2.6", name: "Kimi-K2.6", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
87
91
  { id: "kimi-k2.5", name: "Kimi-K2.5", contextWindow: 164_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
88
- { id: "kimi-k2-thinking", name: "Kimi-K2-Thinking", contextWindow: 164_000, maxTokens: 32_000, reasoningEffort: "medium" },
89
92
  { id: "minimax-m3", name: "MiniMax-M3", contextWindow: 512_000, maxTokens: 128_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
90
- { id: "minimax-m2.5", name: "MiniMax-M2.5", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "medium" },
91
93
  { id: "hy3", name: "Hy3", contextWindow: 192_000, maxTokens: 64_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
92
94
  { id: "hunyuan-2.0-thinking", name: "Hunyuan-2.0-Thinking", contextWindow: 128_000, maxTokens: 24_000, reasoningEffort: "medium" },
93
95
  { id: "hunyuan-chat", name: "Hunyuan-Turbos", contextWindow: 200_000, maxTokens: 8_192 },
@@ -143,6 +145,17 @@ function liveReasoningEffort(entry, catalogEffort) {
143
145
  return lower === "low" || lower === "medium" || lower === "high" ? lower : undefined;
144
146
  }
145
147
 
148
+ /**
149
+ * Extract validated input modalities from a live `/v1/models` entry
150
+ * (`input_modalities`, codex format). Unknown or missing values degrade to
151
+ * text-only, matching the official catalog's conservative default.
152
+ */
153
+ function liveInputModalities(entry) {
154
+ const mods = Array.isArray(entry?.input_modalities) ? entry.input_modalities : [];
155
+ const valid = mods.filter((modality) => modality === "text" || modality === "image");
156
+ return valid.length > 0 ? [...new Set(valid)] : ["text"];
157
+ }
158
+
146
159
 
147
160
  // #region serialize
148
161
 
@@ -183,7 +196,10 @@ async function serializeMessages(messages, attachments, signal) {
183
196
  if (attachments === undefined) {
184
197
  throw new LlmError("WorkBuddy image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
185
198
  }
186
- const policy = { maxPixels: 4_000_000, maxBytes: 20_000_000 };
199
+ // Keep images small enough for the upstream platform: the proxy relays the
200
+ // base64 body verbatim, and oversized payloads are dropped/ignored upstream
201
+ // (models reply "no image attached"). Mirror the official adapter budgets.
202
+ const policy = { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 };
187
203
  const ordered = [...refs.values()];
188
204
  const prepared = await Promise.all(ordered.map((ref) => attachments.readImageRequest(ref, policy, signal)));
189
205
  for (let index = 0; index < ordered.length; index += 1) {
@@ -346,6 +362,94 @@ function mapUsage(usage) {
346
362
  };
347
363
  }
348
364
 
365
+ // #region usage ledger
366
+
367
+ /** Resolve the usage ledger file under DSH_HOME (falls back to ~/.dsh). */
368
+ function usageLedgerPath() {
369
+ const configured = process.env.DSH_HOME;
370
+ if (configured !== undefined && configured.trim().length > 0) {
371
+ return join(configured, USAGE_DIR_NAME, USAGE_FILE_NAME);
372
+ }
373
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
374
+ return join(home, ".dsh", USAGE_DIR_NAME, USAGE_FILE_NAME);
375
+ }
376
+
377
+ /** Append one completed request's token usage to the ledger (best-effort). */
378
+ function recordUsage(model, usage) {
379
+ if (usage === undefined) return;
380
+ try {
381
+ const file = usageLedgerPath();
382
+ mkdirSync(dirname(file), { recursive: true });
383
+ const line = JSON.stringify({
384
+ ts: Date.now(),
385
+ model,
386
+ input: usage.inputTokens ?? 0,
387
+ output: usage.outputTokens ?? 0,
388
+ cacheRead: usage.cacheReadTokens ?? 0,
389
+ reasoning: usage.reasoningTokens ?? 0,
390
+ });
391
+ appendFileSync(file, `${line}\n`);
392
+ // Trim the ledger when it exceeds the cap: keep only the newest lines.
393
+ const size = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean).length : 0;
394
+ if (size > USAGE_MAX_LINES) {
395
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-USAGE_MAX_LINES);
396
+ const fd = openSync(file, "w");
397
+ try { writeSync(fd, `${lines.join("\n")}\n`); } finally { closeSync(fd); }
398
+ }
399
+ } catch {
400
+ // usage recording must never break the request path
401
+ }
402
+ }
403
+
404
+ /** Read the ledger and aggregate into daily/model breakdowns. */
405
+ function readUsageLedger() {
406
+ const file = usageLedgerPath();
407
+ const today = new Date();
408
+ today.setHours(0, 0, 0, 0);
409
+ const todayMs = today.getTime();
410
+ const byModel = new Map();
411
+ let todayInput = 0;
412
+ let todayOutput = 0;
413
+ let todayRequests = 0;
414
+ let totalInput = 0;
415
+ let totalOutput = 0;
416
+ let totalRequests = 0;
417
+ if (!existsSync(file)) {
418
+ return { today: { inputTokens: 0, outputTokens: 0, requests: 0 }, byModel: [], total: { inputTokens: 0, outputTokens: 0, requests: 0 } };
419
+ }
420
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
421
+ for (const line of lines) {
422
+ let entry;
423
+ try { entry = JSON.parse(line); } catch { continue; }
424
+ const input = entry.input ?? 0;
425
+ const output = entry.output ?? 0;
426
+ totalInput += input;
427
+ totalOutput += output;
428
+ totalRequests += 1;
429
+ if ((entry.ts ?? 0) >= todayMs) {
430
+ todayInput += input;
431
+ todayOutput += output;
432
+ todayRequests += 1;
433
+ }
434
+ const model = String(entry.model ?? "unknown");
435
+ const agg = byModel.get(model) ?? { inputTokens: 0, outputTokens: 0, requests: 0 };
436
+ agg.inputTokens += input;
437
+ agg.outputTokens += output;
438
+ agg.requests += 1;
439
+ byModel.set(model, agg);
440
+ }
441
+ const byModelList = [...byModel.entries()]
442
+ .map(([model, agg]) => ({ model, ...agg }))
443
+ .sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens));
444
+ return {
445
+ today: { inputTokens: todayInput, outputTokens: todayOutput, requests: todayRequests },
446
+ byModel: byModelList,
447
+ total: { inputTokens: totalInput, outputTokens: totalOutput, requests: totalRequests },
448
+ };
449
+ }
450
+
451
+ // #endregion
452
+
349
453
  /** Assemble the final ContentBlock for one open block. */
350
454
  function closeBlock(block) {
351
455
  switch (block.kind) {
@@ -444,7 +548,9 @@ async function* translate(payloads) {
444
548
  argumentsDelta: fragment,
445
549
  };
446
550
  }
447
- if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
551
+ // Hy (Hunyuan) models emit `"finish_reason": ""` on every intermediate
552
+ // chunk; only treat a non-empty vocabulary value as the real finish.
553
+ if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) pendingFinish = mapFinishReason(choice.finish_reason);
448
554
  }
449
555
  if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
450
556
  }
@@ -516,6 +622,12 @@ export class WorkBuddyAdapter extends LlmAdapter {
516
622
  return { id: provider, name: "WorkBuddy" };
517
623
  }
518
624
 
625
+ /** Drop the discovery cache so the next listModels re-fetches from the proxy. */
626
+ refreshModels() {
627
+ this._cache = undefined;
628
+ this._liveMeta = undefined;
629
+ }
630
+
519
631
  async listModels(provider) {
520
632
  const connection = this.config.options();
521
633
  if (!connection.discovery) return connection.models.map((model) => modelInfo(provider, model));
@@ -545,24 +657,45 @@ export class WorkBuddyAdapter extends LlmAdapter {
545
657
  : typeof entry.display_name === "string"
546
658
  ? entry.display_name
547
659
  : undefined,
660
+ description: typeof entry.description === "string" && entry.description.length > 0
661
+ ? entry.description
662
+ : undefined,
663
+ modalities: liveInputModalities(entry),
664
+ contextWindow: Number.isInteger(entry.context_window) && entry.context_window > 0
665
+ ? entry.context_window
666
+ : undefined,
667
+ maxTokens: Number.isInteger(entry.max_context_window) && entry.max_context_window > 0
668
+ ? entry.max_context_window
669
+ : undefined,
548
670
  }))
549
671
  .filter((entry) => entry.id.length > 0);
550
672
  const merged = [];
551
673
  const seen = new Set();
674
+ const liveMeta = new Map();
552
675
  for (const entry of live) {
553
676
  if (seen.has(entry.id)) continue;
554
677
  seen.add(entry.id);
555
678
  const catalog = byId.get(entry.id);
679
+ // The static catalog doubles as a whitelist. The proxy still announces
680
+ // retired/legacy models (deepseek-v3-1, glm-4.6, glm-4.6v, kimi-k2,
681
+ // kimi-k2-thinking, minimax-m2.5, hunyuan-image-v3.0, ...) that the
682
+ // upstream rejects with `service info not found`. Drop any live entry
683
+ // that is not in the catalog so those never reach the UI; the catalog
684
+ // is the curated set that matches what the official client shows.
685
+ if (catalog === undefined) continue;
556
686
  const effort = liveReasoningEffort(entry, catalog?.reasoningEffort);
557
687
  const reasoning = modelReasoningInfo(effort);
558
688
  merged.push({
559
689
  provider,
560
690
  id: entry.id,
561
691
  name: entry.name ?? catalog?.name ?? entry.id,
562
- ...(catalog?.description !== undefined ? { description: catalog.description } : {}),
563
- inputModalities: catalog?.inputModalities ?? ["text"],
692
+ ...(catalog?.description !== undefined || entry.description !== undefined
693
+ ? { description: catalog?.description ?? entry.description }
694
+ : {}),
695
+ inputModalities: catalog?.inputModalities ?? entry.modalities,
564
696
  ...(reasoning === undefined ? {} : { reasoning }),
565
697
  });
698
+ liveMeta.set(entry.id, { contextWindow: entry.contextWindow, maxTokens: entry.maxTokens });
566
699
  }
567
700
  // Catalog entries the proxy did not announce (e.g. unauthenticated or
568
701
  // partial listing) stay selectable.
@@ -572,6 +705,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
572
705
  }
573
706
  const result = merged.length > 0 ? merged : connection.models.map((model) => modelInfo(provider, model));
574
707
  this._cache = { at: now, models: result };
708
+ this._liveMeta = liveMeta;
575
709
  return result;
576
710
  } catch {
577
711
  return connection.models.map((model) => modelInfo(provider, model));
@@ -581,13 +715,18 @@ export class WorkBuddyAdapter extends LlmAdapter {
581
715
  resolveModel(provider, model, _signal) {
582
716
  const connection = this.config.options();
583
717
  const configured = connection.models.find((entry) => entry.id === model);
718
+ // Models discovered live (proxy-announced, not in the configured catalog)
719
+ // still get their platform-declared capacity from the last discovery.
720
+ const live = this._liveMeta?.get(model);
584
721
  const base = configured === undefined
585
722
  ? { provider, id: model, name: model, inputModalities: ["text"] }
586
723
  : modelInfo(provider, configured);
587
724
  return Promise.resolve({
588
725
  ...base,
589
- context: { contextWindow: configured?.contextWindow ?? connection.defaultContextWindow },
590
- defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
726
+ context: {
727
+ contextWindow: configured?.contextWindow ?? live?.contextWindow ?? connection.defaultContextWindow,
728
+ },
729
+ defaultMaxTokens: configured?.maxTokens ?? live?.maxTokens ?? connection.maxTokens,
591
730
  });
592
731
  }
593
732
 
@@ -601,13 +740,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
601
740
  );
602
741
  const iterator = this.request(options, watchdog.signal, connection, () => watchdog.pulse())[Symbol.asyncIterator]();
603
742
  let exhausted = false;
743
+ let usage;
604
744
  try {
605
745
  while (true) {
606
746
  const result = await watchdog.next(iterator);
607
747
  if (result.done) {
608
748
  exhausted = true;
749
+ if (usage !== undefined) recordUsage(options.model, usage);
609
750
  return;
610
751
  }
752
+ if (result.value?.type === "usage") usage = result.value.usage;
611
753
  yield result.value;
612
754
  }
613
755
  } catch (error) {
@@ -698,6 +840,10 @@ export const Config = z.object({
698
840
  models: z.array(catalogModel).default(DEFAULT_MODELS),
699
841
  streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
700
842
  discovery: z.boolean().default(true),
843
+ // Daily checkin: after `checkinAfterHour` local time, claim once per day if
844
+ // the official activity says today is unclaimed. Disable to check in manually.
845
+ autoCheckin: z.boolean().default(true),
846
+ checkinAfterHour: z.number().step(1).min(0).max(23).default(10),
701
847
  // Optional path overrides for the login helper. When unset, the plugin uses
702
848
  // the login_workbuddy.py bundled with this package and the default session
703
849
  // file location (~/.codebuddy-session.json).
@@ -760,6 +906,10 @@ export function resolveAdapterOptions(config) {
760
906
  if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
761
907
  throw new Error(`dsh-llm-workbuddy: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
762
908
  }
909
+ const checkinAfterHour = config.checkinAfterHour ?? 10;
910
+ if (!Number.isInteger(checkinAfterHour) || checkinAfterHour < 0 || checkinAfterHour > 23) {
911
+ throw new Error("dsh-llm-workbuddy: checkinAfterHour must be an integer between 0 and 23");
912
+ }
763
913
  return {
764
914
  baseURL,
765
915
  apiKey: config.apiKey,
@@ -768,6 +918,8 @@ export function resolveAdapterOptions(config) {
768
918
  models: resolveModels(config.models),
769
919
  streamIdleTimeoutMs,
770
920
  discovery: config.discovery ?? true,
921
+ autoCheckin: config.autoCheckin ?? true,
922
+ checkinAfterHour,
771
923
  loginScript: config.loginScript,
772
924
  sessionFile: config.sessionFile,
773
925
  };
@@ -775,6 +927,176 @@ export function resolveAdapterOptions(config) {
775
927
 
776
928
  // #endregion
777
929
 
930
+ // #region daily checkin
931
+
932
+ /** Checkin state file under DSH_HOME (same ledger dir as usage). */
933
+ const CHECKIN_FILE_NAME = "checkin.json";
934
+ /** How often the auto-claim scheduler wakes up. */
935
+ const CHECKIN_INTERVAL_MS = 30 * 60_000;
936
+ /** Request timeout for the proxy checkin endpoints. */
937
+ const CHECKIN_TIMEOUT_MS = 10_000;
938
+
939
+ /** Resolve the checkin state file path (same convention as usageLedgerPath). */
940
+ function checkinStatePath() {
941
+ const configured = process.env.DSH_HOME;
942
+ if (configured !== undefined && configured.trim().length > 0) {
943
+ return join(configured, USAGE_DIR_NAME, CHECKIN_FILE_NAME);
944
+ }
945
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
946
+ return join(home, ".dsh", USAGE_DIR_NAME, CHECKIN_FILE_NAME);
947
+ }
948
+
949
+ /** Read the persisted checkin state. Never throws. */
950
+ function readCheckinState() {
951
+ try {
952
+ return JSON.parse(readFileSync(checkinStatePath(), "utf-8"));
953
+ } catch {
954
+ return {};
955
+ }
956
+ }
957
+
958
+ /** Persist the checkin state (best-effort; failure must not break the caller). */
959
+ function writeCheckinState(state) {
960
+ try {
961
+ const file = checkinStatePath();
962
+ mkdirSync(dirname(file), { recursive: true });
963
+ writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
964
+ } catch {
965
+ // persistence is advisory
966
+ }
967
+ }
968
+
969
+ /** Local calendar date (`YYYY-MM-DD`) for "did we already handle today". */
970
+ function localDateStr(date = new Date()) {
971
+ const y = date.getFullYear();
972
+ const m = String(date.getMonth() + 1).padStart(2, "0");
973
+ const d = String(date.getDate()).padStart(2, "0");
974
+ return `${y}-${m}-${d}`;
975
+ }
976
+
977
+ /**
978
+ * Call a proxy checkin endpoint. `method` defaults to GET (status probe);
979
+ * claiming passes "POST". Returns the parsed body or an error shape.
980
+ */
981
+ async function proxyCheckinCall(baseURL, path, method = "GET") {
982
+ const controller = new AbortController();
983
+ const timer = setTimeout(() => controller.abort(), CHECKIN_TIMEOUT_MS);
984
+ try {
985
+ const response = await fetch(`${baseURL.replace(/\/$/, "")}${path}`, {
986
+ method,
987
+ ...(method === "POST" ? { headers: { "content-type": "application/json" }, body: "{}" } : {}),
988
+ signal: controller.signal,
989
+ });
990
+ const body = await response.json().catch(() => null);
991
+ if (!response.ok) {
992
+ return { error: `HTTP ${response.status} (${method} ${path})`, ...(body ? { body } : {}) };
993
+ }
994
+ return body ?? { error: "empty response" };
995
+ } catch (error) {
996
+ return { error: String(error?.cause?.message ?? error?.message ?? error) };
997
+ } finally {
998
+ clearTimeout(timer);
999
+ }
1000
+ }
1001
+
1002
+ /** Whether an upstream claim rejection means "already claimed today". */
1003
+ function alreadyClaimedMessage(msg) {
1004
+ return typeof msg === "string" && /(已签到|已经签到|already)/i.test(msg);
1005
+ }
1006
+
1007
+ /**
1008
+ * Run one auto-claim pass. Idempotent per local calendar day:
1009
+ * - before `checkinAfterHour` (default 10:00) local time: no-op;
1010
+ * - already handled today (persisted state): no-op;
1011
+ * - official status says already checked in: mark handled, done;
1012
+ * - activity not open (no status data): mark handled so we stop retrying today;
1013
+ * - otherwise claim, persisting the reward (credit, streak) on success.
1014
+ * Concurrent invocations collapse into the first caller via `inFlight`.
1015
+ * @returns the resulting state snapshot, or undefined when skipped/in-flight.
1016
+ */
1017
+ async function runCheckinOnce(baseURL, config, logger) {
1018
+ if (!config.autoCheckin) return undefined;
1019
+ const now = new Date();
1020
+ if (now.getHours() < config.checkinAfterHour) return undefined;
1021
+ const today = localDateStr(now);
1022
+ const state = readCheckinState();
1023
+ if (state.handledDate === today) return state;
1024
+ if (runCheckinOnce._inFlight) return undefined;
1025
+ runCheckinOnce._inFlight = true;
1026
+ try {
1027
+ const status = await proxyCheckinCall(baseURL, "/checkin-status");
1028
+ const data = status?.upstream?.data;
1029
+ if (!data) {
1030
+ // Activity closed / proxy unreachable / not authenticated: stop for today.
1031
+ const next = {
1032
+ ...state,
1033
+ handledDate: today,
1034
+ lastResult: { ok: false, message: status?.error ?? status?.upstream?.msg ?? "签到活动不可用", at: now.getTime() },
1035
+ };
1036
+ writeCheckinState(next);
1037
+ logger?.debug?.(`dsh-llm-workbuddy: checkin skipped today (${next.lastResult.message})`);
1038
+ return next;
1039
+ }
1040
+ if (data.today_checked_in) {
1041
+ const next = {
1042
+ ...state,
1043
+ handledDate: today,
1044
+ lastResult: { ok: true, message: "今日已签到(官方确认)", streakDays: data.streak_days, at: now.getTime() },
1045
+ };
1046
+ writeCheckinState(next);
1047
+ return next;
1048
+ }
1049
+ const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
1050
+ const payload = claim?.upstream?.data;
1051
+ // "Already claimed" rejections count as handled (the goal is covered).
1052
+ const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
1053
+ const failureDetail = [claim?.upstream?.msg, claim?.error, claim?.body?.detail]
1054
+ .filter(Boolean).join(" ") || "签到失败";
1055
+ const next = {
1056
+ ...state,
1057
+ handledDate: today,
1058
+ lastResult: {
1059
+ ok,
1060
+ message: ok
1061
+ ? payload?.credit !== undefined
1062
+ ? `自动签到成功:+${payload.credit} 积分`
1063
+ : "今日已签到"
1064
+ : failureDetail,
1065
+ ...(payload?.credit !== undefined ? { credit: payload.credit } : {}),
1066
+ ...(payload?.streak_days !== undefined ? { streakDays: payload.streak_days } : {}),
1067
+ at: now.getTime(),
1068
+ },
1069
+ };
1070
+ writeCheckinState(next);
1071
+ logger?.info?.(`dsh-llm-workbuddy: ${next.lastResult.message}`);
1072
+ return next;
1073
+ } finally {
1074
+ runCheckinOnce._inFlight = false;
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * Start the auto-claim scheduler: one immediate pass plus a low-frequency
1080
+ * interval. The interval survives plugin-lifetime only (disposed with the
1081
+ * fiber), and every pass re-reads the live config so settings hot-edit
1082
+ * (autoCheckin / checkinAfterHour) applies to the very next pass.
1083
+ */
1084
+ function startCheckinScheduler(ctx, options) {
1085
+ const tick = () => {
1086
+ const connection = options();
1087
+ runCheckinOnce(connection.baseURL, connection, ctx.logger).catch((error) => {
1088
+ ctx.logger.warn("dsh-llm-workbuddy: auto checkin pass failed");
1089
+ ctx.logger.warn(error);
1090
+ });
1091
+ };
1092
+ tick();
1093
+ const timer = setInterval(tick, CHECKIN_INTERVAL_MS);
1094
+ timer.unref?.();
1095
+ return () => clearInterval(timer);
1096
+ }
1097
+
1098
+ // #endregion
1099
+
778
1100
  // #region login status web API
779
1101
 
780
1102
  /**
@@ -944,14 +1266,18 @@ function diagnoseRestartCommand() {
944
1266
  }
945
1267
 
946
1268
  /**
947
- * Register `GET /api/workbuddy/status`, `POST /api/workbuddy/login`, and
948
- * `POST /api/workbuddy/diagnose` on the DSH web server. The login route spawns
949
- * `login_workbuddy.py`, parses the device-flow `authUrl` it prints to stdout,
950
- * and returns it so the browser can open it in a new tab. The widget then
951
- * polls `/status` until the session file appears and reports
952
- * `authenticated: true`.
1269
+ * Register the WorkBuddy web API routes on the DSH web server:
1270
+ * - `GET /api/workbuddy/status` login/proxy health + checkin digest;
1271
+ * - `POST /api/workbuddy/login` device-flow login kick-off;
1272
+ * - `POST /api/workbuddy/diagnose` real end-to-end health probe;
1273
+ * - `GET /api/workbuddy/usage` token usage ledger;
1274
+ * - `POST /api/workbuddy/refresh-models` drop the discovery cache, re-read the
1275
+ * proxy model list (which tracks the official WorkBuddy app), and announce
1276
+ * `llm/adapters-updated` so open model pickers reload immediately;
1277
+ * - `GET /api/workbuddy/checkin` today's checkin state (official query);
1278
+ * - `POST /api/workbuddy/checkin` claim now (idempotent upstream).
953
1279
  */
954
- function registerWorkbuddyRoutes(ctx, config) {
1280
+ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
955
1281
  // Delay registration until the webServer service exists. The plugin keeps
956
1282
  // `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
957
1283
  // load — but a synchronous `ctx.get("webServer")` probe at apply time races
@@ -979,11 +1305,20 @@ function registerWorkbuddyRoutes(ctx, config) {
979
1305
  }
980
1306
  const session = readSessionStatus(sessionFile);
981
1307
  const proxy = await probeProxy(baseURL);
1308
+ // Checkin digest comes from the persisted state only — never block the
1309
+ // 5s status poll with an upstream call.
1310
+ const checkinState = readCheckinState();
1311
+ const today = localDateStr();
982
1312
  res.writeHead(200, { "content-type": "application/json" });
983
1313
  res.end(JSON.stringify({
984
1314
  ...session,
985
1315
  ...proxy,
986
1316
  loginScriptAvailable: existsSync(loginScript),
1317
+ checkin: {
1318
+ autoEnabled: config?.autoCheckin ?? true,
1319
+ handledToday: checkinState.handledDate === today,
1320
+ lastResult: checkinState.lastResult ?? null,
1321
+ },
987
1322
  }));
988
1323
  },
989
1324
  }));
@@ -1054,6 +1389,101 @@ function registerWorkbuddyRoutes(ctx, config) {
1054
1389
  }));
1055
1390
  },
1056
1391
  }));
1392
+ webCtx.effect(() => webServer.register({
1393
+ kind: "exact",
1394
+ path: "/api/workbuddy/usage",
1395
+ async handler(req, res) {
1396
+ if (req.method !== "GET") {
1397
+ res.writeHead(405, { "content-type": "application/json" });
1398
+ res.end(JSON.stringify({ error: "method not allowed" }));
1399
+ return;
1400
+ }
1401
+ res.writeHead(200, { "content-type": "application/json" });
1402
+ res.end(JSON.stringify(readUsageLedger()));
1403
+ },
1404
+ }));
1405
+ webCtx.effect(() => webServer.register({
1406
+ kind: "exact",
1407
+ path: "/api/workbuddy/refresh-models",
1408
+ async handler(req, res) {
1409
+ if (req.method !== "POST") {
1410
+ res.writeHead(405, { "content-type": "application/json" });
1411
+ res.end(JSON.stringify({ error: "method not allowed" }));
1412
+ return;
1413
+ }
1414
+ // Drop the discovery cache so listModels re-reads the proxy, then
1415
+ // re-commit the (unchanged) configurable-provider entries: commit is
1416
+ // the one mutation point that publishes `llm/adapters-updated`, which
1417
+ // makes every open model picker reload immediately.
1418
+ adapter.refreshModels();
1419
+ let announced = true;
1420
+ try {
1421
+ providersHandle?.replace?.([{ provider: PROVIDER, displayName: "WorkBuddy", settingsNs: NS, settingsPath: [] }]);
1422
+ } catch (error) {
1423
+ announced = false;
1424
+ ctx.logger.warn("dsh-llm-workbuddy: failed to announce adapters-updated");
1425
+ ctx.logger.warn(error);
1426
+ }
1427
+ const models = await adapter.listModels(PROVIDER).catch(() => []);
1428
+ res.writeHead(200, { "content-type": "application/json" });
1429
+ res.end(JSON.stringify({ ok: true, announced, count: models.length, models }));
1430
+ },
1431
+ }));
1432
+ webCtx.effect(() => webServer.register({
1433
+ kind: "exact",
1434
+ path: "/api/workbuddy/checkin",
1435
+ async handler(req, res) {
1436
+ // GET: today's persisted state plus a fresh official status probe.
1437
+ if (req.method === "GET") {
1438
+ const state = readCheckinState();
1439
+ const upstream = await proxyCheckinCall(baseURL, "/checkin-status");
1440
+ const data = upstream?.upstream?.data ?? null;
1441
+ res.writeHead(200, { "content-type": "application/json" });
1442
+ res.end(JSON.stringify({
1443
+ handledToday: state.handledDate === localDateStr(),
1444
+ lastResult: state.lastResult ?? null,
1445
+ official: data === null ? null : {
1446
+ active: Boolean(data.active),
1447
+ todayCheckedIn: Boolean(data.today_checked_in),
1448
+ streakDays: data.streak_days ?? null,
1449
+ todayCredit: data.today_credit ?? null,
1450
+ streakBonusDays: data.streak_bonus_days ?? null,
1451
+ streakBonusCredit: data.streak_bonus_credit ?? null,
1452
+ },
1453
+ ...(upstream?.error ? { error: upstream.error } : {}),
1454
+ }));
1455
+ return;
1456
+ }
1457
+ // POST: claim now. Idempotent — upstream reports "already checked in"
1458
+ // as a business result rather than an error.
1459
+ if (req.method !== "POST") {
1460
+ res.writeHead(405, { "content-type": "application/json" });
1461
+ res.end(JSON.stringify({ error: "method not allowed" }));
1462
+ return;
1463
+ }
1464
+ const claim = await proxyCheckinCall(baseURL, "/checkin", "POST");
1465
+ const payload = claim?.upstream?.data;
1466
+ const ok = Boolean(payload) || alreadyClaimedMessage(claim?.upstream?.msg);
1467
+ const result = {
1468
+ ok,
1469
+ message: ok
1470
+ ? payload?.credit !== undefined
1471
+ ? `签到成功:+${payload.credit} 积分`
1472
+ : "今日已签到"
1473
+ : claim?.upstream?.msg ?? claim?.error ?? "签到失败",
1474
+ ...(payload ?? {}),
1475
+ };
1476
+ if (ok || claim?.upstream?.msg) {
1477
+ writeCheckinState({
1478
+ ...readCheckinState(),
1479
+ handledDate: ok ? localDateStr() : readCheckinState().handledDate,
1480
+ lastResult: { ok, message: result.message, at: Date.now() },
1481
+ });
1482
+ }
1483
+ res.writeHead(200, { "content-type": "application/json" });
1484
+ res.end(JSON.stringify(result));
1485
+ },
1486
+ }));
1057
1487
  });
1058
1488
  }
1059
1489
 
@@ -1100,14 +1530,15 @@ export function apply(ctx, config) {
1100
1530
  options,
1101
1531
  resolveAttachments: () => ctx.get("attachments"),
1102
1532
  });
1103
- ctx.llm.registerConfigurableProviders([{
1533
+ const providersHandle = ctx.llm.registerConfigurableProviders([{
1104
1534
  provider: PROVIDER,
1105
1535
  displayName: "WorkBuddy",
1106
1536
  settingsNs: NS,
1107
1537
  settingsPath: [],
1108
1538
  }]);
1109
1539
  ctx.llm.registerAdapter([PROVIDER], adapter);
1110
- registerWorkbuddyRoutes(ctx, config);
1540
+ registerWorkbuddyRoutes(ctx, config, adapter, providersHandle);
1541
+ ctx.effect(() => startCheckinScheduler(ctx, options));
1111
1542
  installSettingsSection(ctx, NS, Config, config, {
1112
1543
  setSource: (source) => {
1113
1544
  current = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-workbuddy",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "WorkBuddy (via the local workbuddy2api proxy) LLM provider adapter for DeepSeek Harness, with a Web login-status widget",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",