dsh-model 0.0.1 → 0.15.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.
Files changed (83) hide show
  1. package/README.en.md +127 -0
  2. package/README.md +96 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +183 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +215 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +83 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/self.js +116 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +219 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +850 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +90 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +89 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +48 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +77 -7
  80. package/plugin/client.js +688 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +231 -0
  83. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,154 @@
1
+ // Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
2
+ // https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
3
+ // See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
4
+ /**
5
+ * WorkBuddy model catalog: a static fallback list captured from the live
6
+ * endpoint, replaced by the upstream's dynamic answer once it loads.
7
+ *
8
+ * @module dsh-workbuddy-connect/catalog
9
+ */
10
+ import { modelWithCurrentPromotion } from './upstream.js';
11
+ /**
12
+ * Static CLI models observed on the CN endpoint (re-verified against the live
13
+ * `/v3/config` document 2026-09-23, including the thinking-effort and billing
14
+ * metadata). The upstream refresh replaces this list at startup; it exists so
15
+ * the provider registers with a usable catalog even while the first fetch is
16
+ * in flight or offline.
17
+ *
18
+ * The list tracks the `cli` agent's model roster exactly — the 16 models it
19
+ * offered that day. The roster churns quickly (`auto`, `kimi-k3-1`,
20
+ * `minimax-m3` each appeared or vanished within days, and a competing patch's
21
+ * 2026-09-22 snapshot named three ids that were gone a day later), so this
22
+ * table is a boot-time placeholder, never a promise: the live fetch
23
+ * intersects the day's roster with the document's usable rows, and
24
+ * `tests/upstream.spec.ts` pins this table to the same parse so the two
25
+ * cannot drift apart silently. Reasoning metadata is verbatim from the live
26
+ * document, and the `free` flag follows the normalized `x0.00` credits
27
+ * marker.
28
+ *
29
+ * Deliberately NOT baked in: promotional badges. `限时免费` and friends are
30
+ * dynamic console-side promotions with no reliable validity window, so a
31
+ * static table would keep them alive long after the offers end. The live
32
+ * refresh merges the day's badges best-effort from the console document (see
33
+ * `fetchPromoBadges` in upstream.ts); until then the rows simply ship
34
+ * without them.
35
+ */
36
+ export const FALLBACK_WORKBUDDY_MODELS = [
37
+ // Entries stay in the live roster's order. Rows without `supportedEfforts`
38
+ // carry only a default effort, so the adapter offers them no thinking
39
+ // control; see `reasoningFields()` in adapter.ts.
40
+ { id: 'hy4-preview', name: 'Hy4 preview', contextWindow: 1_000_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['high'], defaultEffort: 'high', canDisableThinking: false }, billing: { credits: 'x0.29 credits', free: false } },
41
+ { id: 'hy3', name: 'Hy3', contextWindow: 192_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'high', canDisableThinking: false }, billing: { credits: 'x0.00 credits', free: true } },
42
+ // Distinct display name: the `hy3` row above declares the same 192K window
43
+ // and the document names both "Hy3", so a shared display name left two rows
44
+ // of the same list indistinguishable. Display only — the id stays `hy3-x`,
45
+ // which is what the wire request and the shim route on.
46
+ { id: 'hy3-x', name: 'Hy3-X', contextWindow: 192_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'high', canDisableThinking: false }, billing: { credits: 'x0.05 credits', free: false } },
47
+ { id: 'deepseek-v4.1-flash', name: 'Deepseek-V4.1-Flash', contextWindow: 1_000_000, maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'high', canDisableThinking: false }, billing: { credits: 'x0.03 credits', free: false } },
48
+ { id: 'glm-5.3', name: 'GLM-5.3', contextWindow: 1_000_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.79 credits', free: false } },
49
+ { id: 'glm-5.3-flash', name: 'GLM-5.3-Flash', contextWindow: 1_000_000, maxTokens: 131_072, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'high', 'max'], defaultEffort: 'high', canDisableThinking: true }, billing: { credits: 'x0.06 credits', free: false } },
50
+ { id: 'glm-5.2', name: 'GLM-5.2', contextWindow: 1_000_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.79 credits', free: false } },
51
+ { id: 'glm-5.1', name: 'GLM-5.1', contextWindow: 200_000, maxTokens: 48_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.79 credits', free: false } },
52
+ { id: 'glm-5v-turbo', name: 'GLM-5v-Turbo', contextWindow: 200_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.71 credits', free: false } },
53
+ { id: 'minimax-m3', name: 'MiniMax-M3', contextWindow: 512_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.25 credits', free: false } },
54
+ { id: 'minimax-m2.7', name: 'MiniMax-M2.7', contextWindow: 200_000, maxTokens: 48_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.19 credits', free: false } },
55
+ { id: 'kimi-k3-1', name: 'Kimi-K3', contextWindow: 1_000_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x1.62 credits', free: false } },
56
+ { id: 'kimi-k2.8-preview', name: 'Kimi-K2.8-Preview', contextWindow: 1_000_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'high', 'max'], defaultEffort: 'high', canDisableThinking: true }, billing: { credits: 'x0.77 credits', free: false } },
57
+ { id: 'kimi-k2.7', name: 'Kimi-K2.7-Code', contextWindow: 256_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.57 credits', free: false } },
58
+ { id: 'kimi-k2.6', name: 'Kimi-K2.6', contextWindow: 256_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.52 credits', free: false } },
59
+ { id: 'deepseek-v4-pro', name: 'Deepseek-V4-Pro', contextWindow: 1_000_000, maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'high', canDisableThinking: false }, billing: { credits: 'x0.51 credits', free: false } },
60
+ ];
61
+ /**
62
+ * Static CLI models for the international endpoint, captured 2026-09-11 from
63
+ * the App-form `/v3/config` document (the 20 ids of its `cli` agent, in order).
64
+ *
65
+ * Same purpose and same discipline as {@link FALLBACK_WORKBUDDY_MODELS}: it
66
+ * covers the window before the first successful fetch and an offline start,
67
+ * and it is deliberately *not* a promise about the upstream's current state.
68
+ * Reasoning metadata is verbatim from that snapshot. No promo badge is baked
69
+ * in: promotions are time-boxed (`modelPromotions` carries `validFrom`/
70
+ * `validUntil`), so hard-coding a "Free now" label would keep claiming a
71
+ * discount the upstream may have already ended.
72
+ */
73
+ export const FALLBACK_WORKBUDDY_AI_MODELS = [
74
+ { id: 'default-model', name: 'Auto', contextWindow: 176_000, maxTokens: 24_000, supportsImages: true, reasoning: { supports: false, onlyReasoning: false, canDisableThinking: true }, billing: { free: false } },
75
+ { id: 'fast-model', name: 'Fast', contextWindow: 200_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.34', free: false } },
76
+ { id: 'balanced-model', name: 'Balanced', contextWindow: 256_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.59', free: false } },
77
+ { id: 'primary-model', name: 'Primary', contextWindow: 272_000, maxTokens: 72_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'high', canDisableThinking: false }, billing: { credits: 'x3.31', free: false } },
78
+ { id: 'deep-model', name: 'Deep', contextWindow: 176_000, maxTokens: 24_000, supportsImages: true, reasoning: { supports: false, onlyReasoning: false, canDisableThinking: true }, billing: { credits: 'x3.33', free: false } },
79
+ { id: 'hy4-preview-f', name: 'Hy4 preview', contextWindow: 300_000, defaultContextWindow: 300_000, supportedContextWindows: [300_000, 1_000_000], maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['high'], defaultEffort: 'high', canDisableThinking: false }, billing: { free: false, rateUnknown: true } },
80
+ { id: 'hy3', name: 'Hy3', contextWindow: 192_000, maxTokens: 64_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'high'], defaultEffort: 'high', canDisableThinking: false }, billing: { free: false, rateUnknown: true } },
81
+ { id: 'deepseek-v4.1-flash', name: 'Deepseek-V4.1-Flash', contextWindow: 300_000, defaultContextWindow: 300_000, supportedContextWindows: [300_000, 1_000_000], maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'high', canDisableThinking: false }, billing: { free: false, rateUnknown: true } },
82
+ { id: 'gpt-6-astra', name: 'GPT-6-Astra', contextWindow: 400_000, defaultContextWindow: 400_000, supportedContextWindows: [400_000, 1_000_000], maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium', canDisableThinking: true }, billing: { credits: 'x6.67', free: false } },
83
+ { id: 'gpt-5.6-sol', name: 'GPT-5.6-Sol', contextWindow: 1_000_000, maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium', canDisableThinking: true }, billing: { credits: 'x3.47', free: false } },
84
+ { id: 'gpt-5.6-terra', name: 'GPT-5.6-Terra', contextWindow: 1_000_000, maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium', canDisableThinking: true }, billing: { credits: 'x1.39', free: false } },
85
+ { id: 'gpt-5.6-luna', name: 'GPT-5.6-Luna', contextWindow: 1_000_000, maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium', canDisableThinking: true }, billing: { credits: 'x0.14', free: false } },
86
+ { id: 'gpt-5.5', name: 'GPT-5.5', contextWindow: 1_000_000, maxTokens: 128_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'medium', 'high', 'xhigh'], defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x3.31', free: false } },
87
+ { id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 272_000, maxTokens: 72_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'medium', 'high', 'xhigh'], defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x1.65', free: false } },
88
+ { id: 'gpt-5.3-codex', name: 'GPT-5.3-Codex', contextWindow: 272_000, maxTokens: 72_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x1.25', free: false } },
89
+ { id: 'gemini-3.5-flash', name: 'Gemini-3.5-Flash', contextWindow: 1_000_000, maxTokens: 65_536, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.99', free: false } },
90
+ { id: 'glm-5.3', name: 'GLM-5.3', contextWindow: 1_000_000, maxTokens: 48_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['low', 'high', 'max'], defaultEffort: 'high', canDisableThinking: true }, billing: { credits: 'x0.79', free: false } },
91
+ { id: 'glm-5.2', name: 'GLM-5.2', contextWindow: 1_000_000, maxTokens: 48_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, supportedEfforts: ['high', 'xhigh'], defaultEffort: 'high', canDisableThinking: true }, billing: { credits: 'x0.79', free: false } },
92
+ { id: 'kimi-k3', name: 'Kimi-K3', contextWindow: 1_000_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x1.62', free: false } },
93
+ { id: 'kimi-k2.6', name: 'Kimi-K2.6', contextWindow: 256_000, maxTokens: 32_000, supportsImages: true, reasoning: { supports: true, onlyReasoning: true, defaultEffort: 'medium', canDisableThinking: false }, billing: { credits: 'x0.52', free: false } },
94
+ ];
95
+ /**
96
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
97
+ *
98
+ * Visibility is separate from content. A variant whose app has no credentials
99
+ * must expose *no* models rather than a fallback roster: the DSH model picker
100
+ * drops an empty group, so an empty catalog is exactly how a provider hides
101
+ * without touching registration. Serving the fallback to a signed-out user
102
+ * instead offers models that can only fail (`store.resolve()` throws on the
103
+ * first message), which is worse than showing nothing.
104
+ *
105
+ * The flag defaults to visible so a directly-constructed catalog behaves as it
106
+ * always has; the plugin runtime applies the credential gate.
107
+ */
108
+ export class WorkBuddyCatalog {
109
+ models;
110
+ visible = true;
111
+ useMaximumContextWindow = false;
112
+ constructor(initial = FALLBACK_WORKBUDDY_MODELS) { this.models = initial; }
113
+ /** Current entries; empty while the variant has no usable credential. */
114
+ current() {
115
+ if (!this.visible)
116
+ return [];
117
+ return this.models.map(model => {
118
+ const current = modelWithCurrentPromotion(model);
119
+ const maximum = current.supportedContextWindows === undefined ? undefined : Math.max(...current.supportedContextWindows);
120
+ return this.useMaximumContextWindow && maximum !== undefined && maximum > current.contextWindow
121
+ ? { ...current, defaultContextWindow: current.defaultContextWindow ?? current.contextWindow, contextWindow: maximum }
122
+ : current;
123
+ });
124
+ }
125
+ /** Replace the list; callers invalidate their adapter snapshot after this. */
126
+ set(models) {
127
+ this.models = [...models];
128
+ }
129
+ /** Whether this variant's models are exposed at all. */
130
+ isVisible() {
131
+ return this.visible;
132
+ }
133
+ /**
134
+ * Show or hide the whole catalog. Returns whether the value changed, so the
135
+ * caller can skip an invalidation that would re-render an identical list.
136
+ */
137
+ setVisible(visible) {
138
+ if (this.visible === visible)
139
+ return false;
140
+ this.visible = visible;
141
+ return true;
142
+ }
143
+ /** Select the largest declared international window where the upstream offers one. */
144
+ setUseMaximumContextWindow(useMaximum) {
145
+ if (this.useMaximumContextWindow === useMaximum)
146
+ return false;
147
+ this.useMaximumContextWindow = useMaximum;
148
+ return true;
149
+ }
150
+ /** Models to fall back to when the upstream fetch fails; ignores visibility. */
151
+ fallback() {
152
+ return this.models;
153
+ }
154
+ }
@@ -0,0 +1,234 @@
1
+ // Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
2
+ // https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
3
+ // See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
4
+ /**
5
+ * The desktop-client identity chat requests present as (phase 1 of
6
+ * `docs/upstream-identity-alignment-plan.md`).
7
+ *
8
+ * Chat and its probe sibling carry the User-Agent shape the official desktop
9
+ * client composes — `WorkBuddy/<v> <product>/<v> CLI/<cli>` — where the
10
+ * product token names the app that owns the region's requests: `WorkBuddy`
11
+ * for CN, `WorkBuddy AI` for international. Versions come from the installed
12
+ * App when it can be read, degrade to a per-region saved value, and finally
13
+ * to a compiled-in constant. A CLI version that does not resolve drops the
14
+ * `CLI/…` token instead of inventing one (the official client's own rule for
15
+ * a missing extension).
16
+ *
17
+ * Scope: chat and probe requests ONLY. Refresh, catalog, and billing keep
18
+ * the headers they have always sent; the plan holds the blast radius to this
19
+ * one variable so the live verification matrix stays readable.
20
+ *
21
+ * @module dsh-workbuddy-connect/client-identity
22
+ */
23
+ import { readFile } from 'node:fs/promises';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { writeFileAtomic } from './_compat.js';
27
+ import { resolveDshHome } from './_compat.js';
28
+ import { FALLBACK_APP_VERSION, readBundleVersion, resolveAppVersion, validAppVersion, } from './app-version.js';
29
+ /**
30
+ * Compiled-in CN fallback for the `WorkBuddy/<v>` tokens.
31
+ *
32
+ * Observed on the CN desktop app installed here (research §3.1, verified
33
+ * 2026-09-11); like the international fallback it is a shape requirement,
34
+ * not a currency claim — the gateway has not been observed to branch on it.
35
+ */
36
+ export const FALLBACK_CN_APP_VERSION = '5.5.6';
37
+ /**
38
+ * Basename of the CN saved-version cache under `$DSH_HOME`.
39
+ *
40
+ * Deliberately not the international `.workbuddy-ai-version.json`: that file
41
+ * feeds the international catalog's User-Agent, and a CN App writing its
42
+ * version into it would relabel that request. The two caches stay isolated
43
+ * the way the per-variant catalog files are.
44
+ */
45
+ export const CN_APP_VERSION_FILENAME = '.workbuddy-app-version.json';
46
+ /**
47
+ * Whether a value is a CLI version that may reach a header.
48
+ *
49
+ * Tolerates a prerelease suffix (`2.137.1-rc.1`) because the bundled CLI's
50
+ * own metadata uses that spelling; anything with whitespace, CR or LF never
51
+ * passes — the value is interpolated into an HTTP header.
52
+ */
53
+ export function validCliVersion(value) {
54
+ return typeof value === 'string' && /^\d{1,6}(?:\.\d{1,6}){1,3}(?:-[0-9A-Za-z.]+)?$/u.test(value);
55
+ }
56
+ /** macOS App-bundle roots, searched in the order `app-version.ts` uses. */
57
+ function macAppRoots() {
58
+ return ['/Applications', join(homedir(), 'Applications')];
59
+ }
60
+ /** Path of the bundled agent CLI's package.json inside an App bundle. */
61
+ function cliPackagePath(bundle) {
62
+ return join(bundle, 'Contents', 'Resources', 'app.asar.unpacked', 'cli', 'package.json');
63
+ }
64
+ /**
65
+ * The bundled agent CLI's real version, or `undefined` when it does not resolve.
66
+ *
67
+ * `cli/package.json` ships a `0.0.0` placeholder in `version` with the real
68
+ * version in `publishConfig.customPackage.version`; a valid non-placeholder
69
+ * `version` wins, otherwise the custom-package value applies, and unreadable
70
+ * or invalid metadata yields `undefined` (the caller drops the `CLI/…` UA
71
+ * token rather than guessing).
72
+ */
73
+ export async function readCliVersion(bundle) {
74
+ let document;
75
+ try {
76
+ document = JSON.parse(await readFile(cliPackagePath(bundle), 'utf8'));
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ if (typeof document !== 'object' || document === null || Array.isArray(document))
82
+ return undefined;
83
+ const pkg = document;
84
+ const declared = pkg['version'];
85
+ if (validCliVersion(declared) && declared !== '0.0.0')
86
+ return declared;
87
+ const publishConfig = pkg['publishConfig'];
88
+ const customPackage = typeof publishConfig === 'object' && publishConfig !== null && !Array.isArray(publishConfig)
89
+ ? publishConfig
90
+ : undefined;
91
+ const custom = typeof customPackage?.['customPackage'] === 'object' && customPackage['customPackage'] !== null
92
+ && !Array.isArray(customPackage['customPackage'])
93
+ ? customPackage['customPackage']
94
+ : undefined;
95
+ const customVersion = custom?.['version'];
96
+ return validCliVersion(customVersion) ? customVersion : undefined;
97
+ }
98
+ /**
99
+ * Build the chat User-Agent for one region.
100
+ *
101
+ * Throws on an invalid version rather than interpolating one into a header;
102
+ * `resolveChatIdentity` never produces such an identity, so the throw is a
103
+ * last gate against future call-site mistakes, not an expected path.
104
+ */
105
+ export function chatUserAgent(identity, region) {
106
+ if (!validAppVersion(identity.clientVersion)) {
107
+ throw new Error(`invalid client version for chat User-Agent: ${JSON.stringify(identity.clientVersion)}`);
108
+ }
109
+ if (identity.cliVersion !== undefined && !validCliVersion(identity.cliVersion)) {
110
+ throw new Error(`invalid CLI version for chat User-Agent: ${JSON.stringify(identity.cliVersion)}`);
111
+ }
112
+ const product = region === 'global' ? 'WorkBuddy AI' : 'WorkBuddy';
113
+ const parts = [`WorkBuddy/${identity.clientVersion}`, `${product}/${identity.clientVersion}`];
114
+ if (identity.cliVersion !== undefined)
115
+ parts.push(`CLI/${identity.cliVersion}`);
116
+ return parts.join(' ');
117
+ }
118
+ /** The installed CN desktop bundle, or `undefined` when it is not installed (or not readable). */
119
+ async function installedCnApp() {
120
+ if (process.platform !== 'darwin')
121
+ return undefined;
122
+ for (const root of macAppRoots()) {
123
+ const bundle = join(root, 'WorkBuddy.app');
124
+ const version = await readBundleVersion(join(bundle, 'Contents', 'Info.plist'));
125
+ if (version !== undefined)
126
+ return { version, bundle };
127
+ }
128
+ return undefined;
129
+ }
130
+ /** Default CN saved-cache path. */
131
+ function cnSavedVersionPath() {
132
+ return join(resolveDshHome(), CN_APP_VERSION_FILENAME);
133
+ }
134
+ /**
135
+ * Resolve the chat identity for one region: installed App → region's saved
136
+ * value → compiled-in fallback. Never throws — a missing App, an unreadable
137
+ * plist, a failed cache write, or a reader that throws outright all degrade
138
+ * to {@link fallbackChatIdentity}; resolution never blocks a message.
139
+ *
140
+ * The production path caches per region (a message must not re-read the
141
+ * install tree); any injected option bypasses the cache entirely so tests
142
+ * with different readers cannot observe each other's resolutions.
143
+ */
144
+ export async function resolveChatIdentity(region, options = {}) {
145
+ const injectable = options.installedCn !== undefined
146
+ || options.resolveIntl !== undefined
147
+ || options.cliVersion !== undefined
148
+ || options.cnSavedPath !== undefined;
149
+ if (!injectable) {
150
+ const cached = cache.get(region);
151
+ if (cached !== undefined)
152
+ return cached;
153
+ }
154
+ let identity;
155
+ try {
156
+ identity = region === 'global'
157
+ ? await resolveGlobalIdentity(options)
158
+ : await resolveCnIdentity(options);
159
+ }
160
+ catch {
161
+ // A reader that throws (unexpected filesystem error, injected test
162
+ // double) must not block a message and must not pin this degraded
163
+ // answer for the process lifetime: return the built-in fallback
164
+ // without caching it, so a later call can resolve properly again.
165
+ return fallbackChatIdentity(region);
166
+ }
167
+ if (!injectable)
168
+ cache.set(region, identity);
169
+ return identity;
170
+ }
171
+ const cache = new Map();
172
+ /**
173
+ * The region's compiled-in fallback identity: the desktop form with the
174
+ * built-in version and no `CLI/…` segment. This is the single degraded
175
+ * shape every failure path converges on — a thrown reader, an unreadable
176
+ * bundle, or a missing cache all present this, never the legacy CLI UA.
177
+ */
178
+ export function fallbackChatIdentity(region) {
179
+ return { clientVersion: region === 'global' ? FALLBACK_APP_VERSION : FALLBACK_CN_APP_VERSION };
180
+ }
181
+ /** CN: installed `WorkBuddy.app` → CN saved cache → CN fallback. */
182
+ async function resolveCnIdentity(options) {
183
+ const savedPath = options.cnSavedPath ?? cnSavedVersionPath();
184
+ const installed = await (options.installedCn ?? installedCnApp)();
185
+ if (installed !== undefined && validAppVersion(installed.version)) {
186
+ const cliVersion = await (options.cliVersion ?? readCliVersion)(installed.bundle);
187
+ const identity = {
188
+ clientVersion: installed.version,
189
+ ...(cliVersion !== undefined && validCliVersion(cliVersion) ? { cliVersion } : {}),
190
+ };
191
+ // Best-effort remember of the App version only: the CLI version is read
192
+ // live from the bundle whenever the bundle exists, and once the App is
193
+ // gone the plan's degradation omits the `CLI/…` token instead of
194
+ // continuing to claim a version whose source no longer exists.
195
+ try {
196
+ await writeFileAtomic(savedPath, `${JSON.stringify({ version: identity.clientVersion, observedAt: Date.now() }, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
197
+ }
198
+ catch {
199
+ // Identity resolved; losing the memory of it is not a failure.
200
+ }
201
+ return identity;
202
+ }
203
+ try {
204
+ const saved = JSON.parse(await readFile(savedPath, 'utf8'));
205
+ if (typeof saved === 'object' && saved !== null && !Array.isArray(saved)) {
206
+ const document = saved;
207
+ if (validAppVersion(document['version'])) {
208
+ return { clientVersion: document['version'] };
209
+ }
210
+ }
211
+ }
212
+ catch {
213
+ // Absent or malformed cache: fall through to the compiled-in constant.
214
+ }
215
+ return fallbackChatIdentity('cn');
216
+ }
217
+ /**
218
+ * International: reuse `app-version.ts`'s installed → saved → fallback chain
219
+ * (its cache format and the catalog's version source stay untouched). The
220
+ * CLI version is read only when that chain reports the installed bundle; a
221
+ * saved or fallback resolution has no bundle path and drops the `CLI/…` token.
222
+ * The CN cache is never read or written on this path.
223
+ */
224
+ async function resolveGlobalIdentity(options) {
225
+ const info = await (options.resolveIntl ?? resolveAppVersion)();
226
+ const clientVersion = validAppVersion(info.version) ? info.version : FALLBACK_APP_VERSION;
227
+ let cliVersion;
228
+ if (info.bundle !== undefined) {
229
+ const read = await (options.cliVersion ?? readCliVersion)(info.bundle);
230
+ if (read !== undefined && validCliVersion(read))
231
+ cliVersion = read;
232
+ }
233
+ return { clientVersion, ...cliVersion === undefined ? {} : { cliVersion } };
234
+ }