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.
- package/README.en.md +127 -0
- package/README.md +96 -43
- package/bin/dsh-model.js +8 -0
- package/dist/bridge/aggregate.js +91 -0
- package/dist/bridge/login.js +127 -0
- package/dist/bridge/runtime.js +136 -0
- package/dist/bridge/server.js +183 -0
- package/dist/bridge/workbuddy/_compat.js +52 -0
- package/dist/bridge/workbuddy/app-version.js +141 -0
- package/dist/bridge/workbuddy/auth.js +548 -0
- package/dist/bridge/workbuddy/catalog.js +154 -0
- package/dist/bridge/workbuddy/client-identity.js +234 -0
- package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
- package/dist/bridge/workbuddy/loopback.js +52 -0
- package/dist/bridge/workbuddy/probe.js +153 -0
- package/dist/bridge/workbuddy/status-paths.js +49 -0
- package/dist/bridge/workbuddy/upstream.js +1180 -0
- package/dist/bridge/workbuddy/variants.js +101 -0
- package/dist/cli.js +215 -0
- package/dist/commands/bridge.js +68 -0
- package/dist/commands/connect.js +67 -0
- package/dist/commands/engine.js +122 -0
- package/dist/commands/key.js +75 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logs.js +19 -0
- package/dist/commands/opencode.js +83 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/self.js +116 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +219 -0
- package/dist/commands/sources.js +196 -0
- package/dist/commands/status.js +216 -0
- package/dist/commands/uninstall.js +97 -0
- package/dist/commands/workbuddy.js +117 -0
- package/dist/context.js +86 -0
- package/dist/daemon/client.js +27 -0
- package/dist/daemon/core.js +850 -0
- package/dist/daemon/service.js +33 -0
- package/dist/daemon/stats.js +126 -0
- package/dist/daemon/usage.js +213 -0
- package/dist/dsh/cli.js +90 -0
- package/dist/dsh/connect.js +257 -0
- package/dist/dsh/credentials.js +55 -0
- package/dist/dsh/locate.js +129 -0
- package/dist/dsh/patch.js +106 -0
- package/dist/dsh/pick.js +59 -0
- package/dist/engine/auth.js +40 -0
- package/dist/engine/client.js +104 -0
- package/dist/engine/compat.js +94 -0
- package/dist/engine/config.js +47 -0
- package/dist/engine/install.js +118 -0
- package/dist/engine/mgmt.js +99 -0
- package/dist/engine/sourcemap.js +48 -0
- package/dist/errors.js +14 -0
- package/dist/i18n.js +39 -0
- package/dist/integrations/dshplugin.js +89 -0
- package/dist/integrations/opencode.js +101 -0
- package/dist/integrations/workbuddy.js +114 -0
- package/dist/keys.js +54 -0
- package/dist/ops.js +178 -0
- package/dist/remote/public.js +109 -0
- package/dist/secrets.js +13 -0
- package/dist/service/index.js +48 -0
- package/dist/service/launchd.js +89 -0
- package/dist/service/systemd.js +83 -0
- package/dist/sources.js +36 -0
- package/dist/state.js +90 -0
- package/dist/upstreams.js +23 -0
- package/dist/util/diff.js +30 -0
- package/dist/util/exec.js +42 -0
- package/dist/util/fs.js +107 -0
- package/dist/util/output.js +50 -0
- package/dist/util/pkg.js +13 -0
- package/dist/util/port.js +16 -0
- package/dist/util/prompt.js +40 -0
- package/dist/util/proxy.js +73 -0
- package/dist/util/redact.js +7 -0
- package/engine-manifest.json +28 -0
- package/package.json +77 -7
- package/plugin/client.js +688 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +231 -0
- 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
|
+
}
|