dsh-connect-workbuddy 4.9.3 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BHaBzzOr.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BQHO4oiB.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -78,6 +78,46 @@ async function resolveCredentialRecovery(options) {
78
78
  };
79
79
  return { reloginRequired: false };
80
80
  }
81
+ /** How the display copy names each campaign. */
82
+ const WORKBUDDY_BENEFIT_TITLES = {
83
+ compensation: "模型服务异常补偿",
84
+ gift: "新人首月赠送"
85
+ };
86
+ /**
87
+ * Resolve one campaign against the clock.
88
+ *
89
+ * `active` is the gateway's own window verdict and is trusted as-is; the clock is
90
+ * consulted only for the countdown, never to override `active`. An account whose
91
+ * clock is skewed therefore still renders the upstream's answer rather than a
92
+ * locally-invented one.
93
+ */
94
+ function benefitOf(benefit, nowMs) {
95
+ const daysLeft = benefit.endsAtMs === void 0 ? void 0 : Math.max(0, Math.ceil((benefit.endsAtMs - nowMs) / 864e5));
96
+ const state = benefit.claimed ? "claimed" : benefit.active ? "claimable" : "expired";
97
+ return {
98
+ kind: benefit.kind,
99
+ state,
100
+ creditNum: benefit.creditNum,
101
+ ...benefit.endsAtMs === void 0 ? {} : { endsAtMs: benefit.endsAtMs },
102
+ ...daysLeft === void 0 ? {} : { daysLeft },
103
+ urgent: state === "claimable" && daysLeft !== void 0 && daysLeft <= 3
104
+ };
105
+ }
106
+ /**
107
+ * The claimable campaigns of one account, urgent first.
108
+ *
109
+ * Ordered by `daysLeft` ascending so the soonest-expiring offer leads — the whole
110
+ * point of surfacing this is that a 3-day window otherwise lapses unnoticed. A
111
+ * campaign with no stated end sorts last, because "no deadline" is never more
112
+ * urgent than a deadline.
113
+ *
114
+ * `claimed` and `expired` campaigns are dropped here, not in the renderer: the
115
+ * user asked for them to be ABSENT, and encoding that in one tested place beats
116
+ * asking every caller to remember to filter.
117
+ */
118
+ function claimableBenefits(benefits, nowMs) {
119
+ return benefits.map((benefit) => benefitOf(benefit, nowMs)).filter((view) => view.state === "claimable").sort((left, right) => (left.daysLeft ?? 1e6) - (right.daysLeft ?? 1e6));
120
+ }
81
121
  //#endregion
82
122
  //#region src/adapter.ts
83
123
  /**
@@ -2691,10 +2731,39 @@ function failoverReasonText(failure) {
2691
2731
  case "policy_reject": return "被服务端策略拒绝";
2692
2732
  case "not_found": return "模型不可用";
2693
2733
  case "server": return "上游异常";
2694
- default: return `不可用(http ${failure.status})`;
2734
+ case "client": return "请求被上游拒绝";
2735
+ default: return "不可用";
2695
2736
  }
2696
2737
  }
2697
2738
  /**
2739
+ * One upstream sentence, made safe to paste into a single-line error message.
2740
+ *
2741
+ * `result.message` carries the WHOLE upstream body — for a classified failure
2742
+ * that is a JSON envelope 637–777 characters long whose first 400 characters are
2743
+ * the `code`/`requestId`/`extError` skeleton (`param: ""`, `Request: null`). A
2744
+ * plain `slice(0, 400)` therefore cut the one actionable sentence in half: the
2745
+ * reply ended mid-word at `Pleas` and the reader lost `displayTips` entirely
2746
+ * (issue #78).
2747
+ *
2748
+ * Two rules keep that from happening again:
2749
+ * - whitespace collapses to single spaces, because the body is JSON/HTML and its
2750
+ * newlines would otherwise render as a ragged block inside one paragraph;
2751
+ * - the cut prefers a sentence boundary once one exists past the halfway point,
2752
+ * so a clipped sentence reads as "truncated" instead of as broken prose, and
2753
+ * the `…` always says the text continues.
2754
+ *
2755
+ * This is the LAST resort: a body that parsed into a `detail` with `displayMsg`
2756
+ * or `tips` never reaches it, and those are short by construction.
2757
+ */
2758
+ function clippedUpstreamText(text, limit) {
2759
+ const collapsed = text.replace(/\s+/gu, " ").trim();
2760
+ if (collapsed.length <= limit) return collapsed;
2761
+ const cut = collapsed.slice(0, limit);
2762
+ const stop = Math.max(cut.lastIndexOf("。"), cut.lastIndexOf("!"), cut.lastIndexOf("?"), cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
2763
+ if (stop >= Math.floor(limit / 2)) return `${cut.slice(0, stop + 1).trimEnd()}…`;
2764
+ return `${cut.trimEnd()}…`;
2765
+ }
2766
+ /**
2698
2767
  * The line prepended to an answer that came from a failover account.
2699
2768
  *
2700
2769
  * Block-quoted and bracketed so it reads as machinery rather than as the model's
@@ -3094,11 +3163,11 @@ function createWorkBuddyShim(options) {
3094
3163
  if (!result.ok) {
3095
3164
  const attempts = triedAccountIds.length;
3096
3165
  const note = attempts > 1 ? `(已尝试 ${attempts} 个账号)` : "";
3097
- const shortfall = failoverShortfall === void 0 ? void 0 : await failoverShortfall().catch(() => void 0);
3166
+ const shortfall = failoverShortfall !== void 0 && failoverAccount !== void 0 && isFailoverWorthy(result.kind) ? await failoverShortfall().catch(() => void 0) : void 0;
3098
3167
  const why = shortfall === void 0 ? "" : `:${shortfall}`;
3099
3168
  if (result.kind === "policy_reject") {
3100
3169
  const detail = result.detail;
3101
- const lead = detail?.displayMsg ?? result.message.slice(0, 200);
3170
+ const lead = detail?.displayMsg ?? clippedUpstreamText(result.message, 200);
3102
3171
  const meta = ["服务端策略拒绝"];
3103
3172
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3104
3173
  if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
@@ -3108,7 +3177,7 @@ function createWorkBuddyShim(options) {
3108
3177
  }
3109
3178
  if (result.kind === "model_busy") {
3110
3179
  const detail = result.detail;
3111
- const lead = detail?.displayMsg ?? result.message.slice(0, 200);
3180
+ const lead = detail?.displayMsg ?? clippedUpstreamText(result.message, 200);
3112
3181
  const meta = ["模型当前负载较高"];
3113
3182
  if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3114
3183
  if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
@@ -3116,7 +3185,15 @@ function createWorkBuddyShim(options) {
3116
3185
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, message, hostErrorCode(result.kind));
3117
3186
  return;
3118
3187
  }
3119
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}。${result.message.slice(0, 400)}` + (failoverShortfall === void 0 ? "" : "。池中没有其他可用账号,建议换一个模型继续,或稍后等待该账号的额度恢复"), hostErrorCode(result.kind));
3188
+ const detail = result.detail;
3189
+ const meta = [];
3190
+ if (detail?.upstreamCode !== void 0) meta.push(`code ${detail.upstreamCode}`);
3191
+ if (detail?.requestId !== void 0) meta.push(`requestId ${detail.requestId}`);
3192
+ const stamp = meta.length === 0 ? "" : `(${meta.join(",")})`;
3193
+ const receipt = detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
3194
+ const advice = result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
3195
+ const body = advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
3196
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}${stamp}。${body}`, hostErrorCode(result.kind));
3120
3197
  return;
3121
3198
  }
3122
3199
  res.writeHead(200, {
@@ -4735,6 +4812,15 @@ const WORKBUDDY_USAGE_PATH = "/plugins/dsh-connect-workbuddy/usage";
4735
4812
  * per-account figures.
4736
4813
  */
4737
4814
  const WORKBUDDY_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-workbuddy/account-credits";
4815
+ /**
4816
+ * Plugin-owned benefit claim endpoint: POST + loopback, ONE account per call.
4817
+ *
4818
+ * A credit grant is a write on a real account, so it carries the same guards as
4819
+ * check-in, plus `?accountId=` because the pool can hold accounts this plugin
4820
+ * did not pick. There is deliberately no batch action here — see
4821
+ * `claimBenefit` and the route handler.
4822
+ */
4823
+ const WORKBUDDY_BENEFIT_CLAIM_PATH = "/plugins/dsh-connect-workbuddy/benefit/claim";
4738
4824
  /** Plugin-owned live model refresh endpoint. */
4739
4825
  const WORKBUDDY_MODELS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/models/refresh";
4740
4826
  /** Plugin-owned local account rescan endpoint. */
@@ -5329,6 +5415,7 @@ async function workBuddyWebPool(deps, region, fast = false) {
5329
5415
  const rankedIds = new Set(ranked.map((row) => row.account.id));
5330
5416
  const others = await pool.otherAccounts?.(region) ?? [];
5331
5417
  const checkedIn = fast ? pool.cachedCheckedInToday?.(region) ?? {} : await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
5418
+ const claimable = fast ? pool.cachedBenefits?.(region) ?? {} : await pool.benefits?.(region).catch(() => ({})) ?? {};
5332
5419
  const rows = [...ranked.map((row) => ({
5333
5420
  account: row.account,
5334
5421
  member: true,
@@ -5376,7 +5463,18 @@ async function workBuddyWebPool(deps, region, fast = false) {
5376
5463
  ...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
5377
5464
  current: account.id === currentAccountId,
5378
5465
  member,
5379
- ...checkedIn[account.id] === void 0 ? {} : { checkedInToday: checkedIn[account.id] }
5466
+ ...checkedIn[account.id] === void 0 ? {} : { checkedInToday: checkedIn[account.id] },
5467
+ ...(() => {
5468
+ const views = claimable[account.id];
5469
+ if (views === void 0 || views.length === 0) return {};
5470
+ return { benefits: views.map((view) => ({
5471
+ kind: view.kind,
5472
+ title: WORKBUDDY_BENEFIT_TITLES[view.kind],
5473
+ creditNum: view.creditNum,
5474
+ ...view.daysLeft === void 0 ? {} : { daysLeft: view.daysLeft },
5475
+ urgent: view.urgent
5476
+ })) };
5477
+ })()
5380
5478
  };
5381
5479
  });
5382
5480
  return {
@@ -5506,6 +5604,43 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
5506
5604
  }
5507
5605
  }
5508
5606
  });
5607
+ ctx.webServer.register({
5608
+ kind: "exact",
5609
+ path: WORKBUDDY_BENEFIT_CLAIM_PATH,
5610
+ handler: async (req, res) => {
5611
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
5612
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
5613
+ const region = requestRegion(req, res);
5614
+ if (region === void 0) return;
5615
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5616
+ const accountId = url.searchParams.get(WORKBUDDY_POOL_ACCOUNT_PARAM);
5617
+ const kind = url.searchParams.get("kind");
5618
+ if (accountId === null || accountId === "") return json(res, 400, { error: "accountId is required" });
5619
+ if (kind !== "compensation" && kind !== "gift") return json(res, 400, { error: "kind must be compensation or gift" });
5620
+ try {
5621
+ const credential = await deps.store(region).credentialFor(accountId);
5622
+ if (credential === void 0) return json(res, 404, { error: "no credential for this account" });
5623
+ const target = (await deps.client.fetchBenefits(credential)).find((benefit) => benefit.kind === kind);
5624
+ if (target === void 0) return json(res, 404, { error: "benefit not available on this account" });
5625
+ if (target.claimed) return json(res, 409, {
5626
+ error: "benefit already claimed",
5627
+ benefit: target
5628
+ });
5629
+ if (!target.active) return json(res, 409, {
5630
+ error: "benefit activity is not active",
5631
+ benefit: target
5632
+ });
5633
+ await deps.client.claimBenefit(credential, kind);
5634
+ deps.pool?.invalidateBenefit?.(region, accountId);
5635
+ json(res, 200, {
5636
+ claimed: true,
5637
+ benefit: (await deps.client.fetchBenefits(credential)).find((benefit) => benefit.kind === kind) ?? target
5638
+ });
5639
+ } catch (error) {
5640
+ json(res, 500, { error: safeMessage(error) });
5641
+ }
5642
+ }
5643
+ });
5509
5644
  const disposeRefresh = ctx.webServer.register({
5510
5645
  kind: "exact",
5511
5646
  path: WORKBUDDY_MODELS_REFRESH_PATH,
@@ -6231,6 +6366,16 @@ function apply(ctx, config) {
6231
6366
  * immediately instead of waiting out the TTL.
6232
6367
  */
6233
6368
  const checkinStateCache = {};
6369
+ /**
6370
+ * Per-region, per-account benefit readings (issue #79).
6371
+ *
6372
+ * Same shape and same reasoning as `checkinStateCache`: claim state changes
6373
+ * when a human acts on the web page or in the desktop app, not spontaneously,
6374
+ * so a per-poll read of two endpoints per account would be thousands of
6375
+ * requests a day to learn nothing. Coarser than the check-in TTL because a
6376
+ * benefit window is measured in days, not hours.
6377
+ */
6378
+ const benefitStateCache = {};
6234
6379
  const settingsNs = settingsNamespaceOf(ctx);
6235
6380
  const stacks = {};
6236
6381
  for (const region of REGION_KEYS) {
@@ -6616,6 +6761,77 @@ function apply(ctx, config) {
6616
6761
  }
6617
6762
  return cached;
6618
6763
  },
6764
+ /**
6765
+ * Claimable promotional benefits per account (issue #79).
6766
+ *
6767
+ * Only campaigns the account can STILL claim are returned — a claimed or
6768
+ * lapsed one is absent, which is what the issue asked for. Absent means
6769
+ * the card shows no benefit line at all.
6770
+ *
6771
+ * Two status reads per account, so it is cached for `BENEFIT_CACHE_MS` and
6772
+ * skipped entirely when the pool is off (same reasoning as
6773
+ * `checkedInToday`: no table, no request).
6774
+ */
6775
+ async benefits(region) {
6776
+ if (!poolPreferencesOf(current(), region).enabled) return {};
6777
+ const store = stacks[region].store;
6778
+ const accounts = await store.accounts();
6779
+ const now = Date.now();
6780
+ const entries = await Promise.all(accounts.map(async (account) => {
6781
+ const cached = benefitStateCache[region]?.[account.id];
6782
+ if (cached !== void 0 && now - cached.atMs < 18e5) return [account.id, cached.views];
6783
+ const credential = await store.credentialFor(account.id).catch(() => void 0);
6784
+ if (credential === void 0) return void 0;
6785
+ const read = await client.fetchBenefits(credential).catch(() => void 0);
6786
+ if (read === void 0) return void 0;
6787
+ const views = claimableBenefits(read, now);
6788
+ benefitStateCache[region] = {
6789
+ ...benefitStateCache[region],
6790
+ [account.id]: {
6791
+ views,
6792
+ atMs: now
6793
+ }
6794
+ };
6795
+ return [account.id, views];
6796
+ }));
6797
+ return Object.fromEntries(entries.filter((entry) => entry !== void 0));
6798
+ },
6799
+ /**
6800
+ * Claimable benefits from the readings already taken this process.
6801
+ *
6802
+ * The first-paint counterpart of `benefits`, mirroring
6803
+ * `cachedCheckedInToday`: no request, so painting never blocks on two
6804
+ * calls per account. An unmeasured account is absent, not empty.
6805
+ */
6806
+ cachedBenefits(region) {
6807
+ if (!poolPreferencesOf(current(), region).enabled) return {};
6808
+ const now = Date.now();
6809
+ const state = benefitStateCache[region] ?? {};
6810
+ const cached = {};
6811
+ for (const [accountId, entry] of Object.entries(state)) {
6812
+ if (now - entry.atMs >= 18e5) continue;
6813
+ cached[accountId] = entry.views;
6814
+ }
6815
+ return cached;
6816
+ },
6817
+ /**
6818
+ * Drop one account's cached benefit reading (issue #79).
6819
+ *
6820
+ * The claim route writes upstream itself, so this cache is the ONLY thing
6821
+ * that can keep answering "claimable" after a successful claim — and it was
6822
+ * never invalidated: the credits went up, the 领取 pill stayed for up to 30
6823
+ * minutes, and the claim looked like it had silently failed. That is the
6824
+ * exact symptom the user reported. `checkin` already deletes its entries
6825
+ * after a batch; this is the same rule for the claim.
6826
+ *
6827
+ * Deleting (rather than overwriting with the post-claim read) keeps the
6828
+ * ownership where it belongs: the route already made the authoritative
6829
+ * re-read, and the next `benefits()` refills this from live data.
6830
+ */
6831
+ invalidateBenefit(region, accountId) {
6832
+ const cached = benefitStateCache[region];
6833
+ if (cached !== void 0) delete cached[accountId];
6834
+ },
6619
6835
  async currentAccountId(region) {
6620
6836
  await applyPoolSelection(region).catch(() => void 0);
6621
6837
  const credential = await stacks[region].store.current().catch(() => void 0);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
- "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.9.3",
4
+ "description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
5
+ "version": "4.10.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",