dsh-llm-workbuddy 0.1.11 → 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 时额外提示 `代理未运行`
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;
@@ -139,6 +145,17 @@ function liveReasoningEffort(entry, catalogEffort) {
139
145
  return lower === "low" || lower === "medium" || lower === "high" ? lower : undefined;
140
146
  }
141
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
+
142
159
 
143
160
  // #region serialize
144
161
 
@@ -345,6 +362,94 @@ function mapUsage(usage) {
345
362
  };
346
363
  }
347
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
+
348
453
  /** Assemble the final ContentBlock for one open block. */
349
454
  function closeBlock(block) {
350
455
  switch (block.kind) {
@@ -443,7 +548,9 @@ async function* translate(payloads) {
443
548
  argumentsDelta: fragment,
444
549
  };
445
550
  }
446
- 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);
447
554
  }
448
555
  if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
449
556
  }
@@ -515,6 +622,12 @@ export class WorkBuddyAdapter extends LlmAdapter {
515
622
  return { id: provider, name: "WorkBuddy" };
516
623
  }
517
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
+
518
631
  async listModels(provider) {
519
632
  const connection = this.config.options();
520
633
  if (!connection.discovery) return connection.models.map((model) => modelInfo(provider, model));
@@ -544,18 +657,31 @@ export class WorkBuddyAdapter extends LlmAdapter {
544
657
  : typeof entry.display_name === "string"
545
658
  ? entry.display_name
546
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,
547
670
  }))
548
671
  .filter((entry) => entry.id.length > 0);
549
672
  const merged = [];
550
673
  const seen = new Set();
674
+ const liveMeta = new Map();
551
675
  for (const entry of live) {
552
676
  if (seen.has(entry.id)) continue;
553
677
  seen.add(entry.id);
554
678
  const catalog = byId.get(entry.id);
555
- // Only keep models the static catalog declares. The proxy announces
556
- // retired models (glm-4.6v, hunyuan-image-v3.0, ...) that upstream
557
- // rejects with `service info not found`; the catalog doubles as the
558
- // whitelist so those never reach the UI.
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.
559
685
  if (catalog === undefined) continue;
560
686
  const effort = liveReasoningEffort(entry, catalog?.reasoningEffort);
561
687
  const reasoning = modelReasoningInfo(effort);
@@ -563,10 +689,13 @@ export class WorkBuddyAdapter extends LlmAdapter {
563
689
  provider,
564
690
  id: entry.id,
565
691
  name: entry.name ?? catalog?.name ?? entry.id,
566
- ...(catalog?.description !== undefined ? { description: catalog.description } : {}),
567
- inputModalities: catalog?.inputModalities ?? ["text"],
692
+ ...(catalog?.description !== undefined || entry.description !== undefined
693
+ ? { description: catalog?.description ?? entry.description }
694
+ : {}),
695
+ inputModalities: catalog?.inputModalities ?? entry.modalities,
568
696
  ...(reasoning === undefined ? {} : { reasoning }),
569
697
  });
698
+ liveMeta.set(entry.id, { contextWindow: entry.contextWindow, maxTokens: entry.maxTokens });
570
699
  }
571
700
  // Catalog entries the proxy did not announce (e.g. unauthenticated or
572
701
  // partial listing) stay selectable.
@@ -576,6 +705,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
576
705
  }
577
706
  const result = merged.length > 0 ? merged : connection.models.map((model) => modelInfo(provider, model));
578
707
  this._cache = { at: now, models: result };
708
+ this._liveMeta = liveMeta;
579
709
  return result;
580
710
  } catch {
581
711
  return connection.models.map((model) => modelInfo(provider, model));
@@ -585,13 +715,18 @@ export class WorkBuddyAdapter extends LlmAdapter {
585
715
  resolveModel(provider, model, _signal) {
586
716
  const connection = this.config.options();
587
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);
588
721
  const base = configured === undefined
589
722
  ? { provider, id: model, name: model, inputModalities: ["text"] }
590
723
  : modelInfo(provider, configured);
591
724
  return Promise.resolve({
592
725
  ...base,
593
- context: { contextWindow: configured?.contextWindow ?? connection.defaultContextWindow },
594
- defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
726
+ context: {
727
+ contextWindow: configured?.contextWindow ?? live?.contextWindow ?? connection.defaultContextWindow,
728
+ },
729
+ defaultMaxTokens: configured?.maxTokens ?? live?.maxTokens ?? connection.maxTokens,
595
730
  });
596
731
  }
597
732
 
@@ -605,13 +740,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
605
740
  );
606
741
  const iterator = this.request(options, watchdog.signal, connection, () => watchdog.pulse())[Symbol.asyncIterator]();
607
742
  let exhausted = false;
743
+ let usage;
608
744
  try {
609
745
  while (true) {
610
746
  const result = await watchdog.next(iterator);
611
747
  if (result.done) {
612
748
  exhausted = true;
749
+ if (usage !== undefined) recordUsage(options.model, usage);
613
750
  return;
614
751
  }
752
+ if (result.value?.type === "usage") usage = result.value.usage;
615
753
  yield result.value;
616
754
  }
617
755
  } catch (error) {
@@ -702,6 +840,10 @@ export const Config = z.object({
702
840
  models: z.array(catalogModel).default(DEFAULT_MODELS),
703
841
  streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
704
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),
705
847
  // Optional path overrides for the login helper. When unset, the plugin uses
706
848
  // the login_workbuddy.py bundled with this package and the default session
707
849
  // file location (~/.codebuddy-session.json).
@@ -764,6 +906,10 @@ export function resolveAdapterOptions(config) {
764
906
  if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
765
907
  throw new Error(`dsh-llm-workbuddy: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
766
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
+ }
767
913
  return {
768
914
  baseURL,
769
915
  apiKey: config.apiKey,
@@ -772,6 +918,8 @@ export function resolveAdapterOptions(config) {
772
918
  models: resolveModels(config.models),
773
919
  streamIdleTimeoutMs,
774
920
  discovery: config.discovery ?? true,
921
+ autoCheckin: config.autoCheckin ?? true,
922
+ checkinAfterHour,
775
923
  loginScript: config.loginScript,
776
924
  sessionFile: config.sessionFile,
777
925
  };
@@ -779,6 +927,176 @@ export function resolveAdapterOptions(config) {
779
927
 
780
928
  // #endregion
781
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
+
782
1100
  // #region login status web API
783
1101
 
784
1102
  /**
@@ -948,14 +1266,18 @@ function diagnoseRestartCommand() {
948
1266
  }
949
1267
 
950
1268
  /**
951
- * Register `GET /api/workbuddy/status`, `POST /api/workbuddy/login`, and
952
- * `POST /api/workbuddy/diagnose` on the DSH web server. The login route spawns
953
- * `login_workbuddy.py`, parses the device-flow `authUrl` it prints to stdout,
954
- * and returns it so the browser can open it in a new tab. The widget then
955
- * polls `/status` until the session file appears and reports
956
- * `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).
957
1279
  */
958
- function registerWorkbuddyRoutes(ctx, config) {
1280
+ function registerWorkbuddyRoutes(ctx, config, adapter, providersHandle) {
959
1281
  // Delay registration until the webServer service exists. The plugin keeps
960
1282
  // `webServer` OUT of `inject` so headless profiles (no HTTP surface) still
961
1283
  // load — but a synchronous `ctx.get("webServer")` probe at apply time races
@@ -983,11 +1305,20 @@ function registerWorkbuddyRoutes(ctx, config) {
983
1305
  }
984
1306
  const session = readSessionStatus(sessionFile);
985
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();
986
1312
  res.writeHead(200, { "content-type": "application/json" });
987
1313
  res.end(JSON.stringify({
988
1314
  ...session,
989
1315
  ...proxy,
990
1316
  loginScriptAvailable: existsSync(loginScript),
1317
+ checkin: {
1318
+ autoEnabled: config?.autoCheckin ?? true,
1319
+ handledToday: checkinState.handledDate === today,
1320
+ lastResult: checkinState.lastResult ?? null,
1321
+ },
991
1322
  }));
992
1323
  },
993
1324
  }));
@@ -1058,6 +1389,101 @@ function registerWorkbuddyRoutes(ctx, config) {
1058
1389
  }));
1059
1390
  },
1060
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
+ }));
1061
1487
  });
1062
1488
  }
1063
1489
 
@@ -1104,14 +1530,15 @@ export function apply(ctx, config) {
1104
1530
  options,
1105
1531
  resolveAttachments: () => ctx.get("attachments"),
1106
1532
  });
1107
- ctx.llm.registerConfigurableProviders([{
1533
+ const providersHandle = ctx.llm.registerConfigurableProviders([{
1108
1534
  provider: PROVIDER,
1109
1535
  displayName: "WorkBuddy",
1110
1536
  settingsNs: NS,
1111
1537
  settingsPath: [],
1112
1538
  }]);
1113
1539
  ctx.llm.registerAdapter([PROVIDER], adapter);
1114
- registerWorkbuddyRoutes(ctx, config);
1540
+ registerWorkbuddyRoutes(ctx, config, adapter, providersHandle);
1541
+ ctx.effect(() => startCheckinScheduler(ctx, options));
1115
1542
  installSettingsSection(ctx, NS, Config, config, {
1116
1543
  setSource: (source) => {
1117
1544
  current = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-workbuddy",
3
- "version": "0.1.11",
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",