dsh-fps-workbench 0.0.0-stage → 0.1.78

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.
@@ -0,0 +1,39 @@
1
+ # Four-pointed star (FPS)
2
+
3
+ FPS is a conversation workspace for DeepSeek Harness (DSH). Track tool progress, open session outputs, and check context and provider usage from one interface. Return to unsent attachments when you switch sessions, and use a configured vision model to interpret images for a text-only model.
4
+
5
+ ## What you can do
6
+
7
+ - **Context and session outputs.** Open Context in the conversation header to see the model, session state, and estimated context composition. Outputs lists local files produced or explicitly linked in the session. Select a file to open it in DSH's native preview. The header also gives you access to Files, Terminal, and Browser.
8
+ - **Tool progress summaries.** Consecutive file reads, commands, and edits appear in one summary row. Expand it for DSH's native tool details. The task list distinguishes completed, active, and pending work. While the session runs, `ThinKing` shows elapsed time alongside the FPS mark and respects the system's reduced motion setting.
9
+ - **Composer and attachment drafts.** Paste images or use the Add menu to choose images and files. When you return to a session, its draft text and unsent attachments are restored. Reloading or closing the interface clears unsent attachments. Each message supports up to four images of 8 MB each and six files of 20 MB each.
10
+ - **Images with text-only models.** When the selected model does not support images, FPS looks for an available model with image input in DSH. It sends the image and prompt to that model and passes the resulting description to the current session. If no suitable model is available, the composer keeps the draft and shows an error.
11
+ - **Sessions and workspaces.** The sidebar keeps DSH's workspaces, session search, and settings. Use the back and forward arrows beside the sidebar toggle to revisit sessions opened in the current window.
12
+ - **Commands, references, and voice.** Type `/` to find commands and `@` to reference files or sessions. The composer footer gives you access to the workspace, permissions, model, reasoning effort, and usage. Enable the DSH voice input plugin to see a microphone in the composer.
13
+
14
+ FPS sends messages through the native DSH session. Models can use the tools enabled in the current configuration, and DSH runs them or requests approval according to the session permissions.
15
+
16
+ ## Provider usage
17
+
18
+ Click the ring next to the model selector. FPS queries usage using the providers and API keys enabled in DSH. It shows the balance, spending, quota, or reset time returned by a supported endpoint. Zero balances are displayed. Accounts or fields are hidden when an endpoint is unsupported, a query fails, or data is missing. DSH supplies the session's token counts and estimated context usage separately.
19
+
20
+ ## Optional image generation
21
+
22
+ Open **Plugins → Four-pointed star → Image generation** to switch on the providers you want. All four start off. Enabled providers become available once their setup is complete, and compatible session models can call them through DSH according to the session's permissions. FPS saves generated images in DSH's generated files directory, where you can open them with DSH's native preview. Cloud tools send prompts to the selected service, so avoid including confidential material.
23
+
24
+ You can name a provider in your prompt. By default, FPS asks the model to prefer an existing image generation tool. When it uses FPS, it tries to select ComfyUI first if ready, followed by Z-Image Turbo, CogView-3-Flash, and Pollinations. A failed request returns its error so you can decide whether to try again or switch providers.
25
+
26
+ - **Z-Image Turbo.** Uses the official Hugging Face Space without an API key. It generates images at roughly one megapixel and is suitable for quick concepts. The shared service may queue requests or stop accepting them when its GPU quota runs out. The documented anonymous ZeroGPU allowance is two minutes of GPU time per day. [ZeroGPU quota](https://huggingface.co/docs/hub/spaces-zerogpu#usage-tiers)
27
+ - **CogView-3-Flash.** Requires an API key from the Zhipu BigModel platform. Zhipu lists the Flash tier as free. It suits standard-quality images and is subject to the provider's eligibility, rate limits, and availability. FPS uses the specified Flash model only. [Zhipu pricing](https://docs.bigmodel.cn/cn/guide/start/pricing)
28
+ - **Pollinations.** Requires your own Pollinations API key and may require a positive available balance even for a zero-priced model. FPS checks the live price of its SDXL Lightning model before sending a generation request. This community model is suited to quick drafts, and its description states that prompts and completions are logged. [Pollinations API](https://gen.pollinations.ai/docs)
29
+ - **ComfyUI + Comfy MCP.** Switch it on, then start the download from Image generation. The setup installs ComfyUI and its official Comfy MCP integration. It targets Apple Silicon with macOS 14 or newer, 64-bit Windows, and x86-64 Linux with glibc 2.28 or newer. Have Git, Python 3.11–3.13 with `venv` and `pip`, internet access, at least 8 GB of RAM, and 16 GiB of free disk space for setup. A compatible Apple or NVIDIA GPU is recommended. CPU generation can take several minutes. The included SD 1.5 workflow produces local drafts around 512 × 512 pixels by default. Fine text and photorealism can be limited. Local generation needs no Comfy account or credits. [Comfy MCP](https://github.com/Comfy-Org/comfy-mcp) · [Checkpoint and license](https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive)
30
+
31
+ Turning ComfyUI off disables local image generation and keeps the downloaded files for later use. FPS uses the official MCP to run the starter workflow and exposes only image generation to the conversation.
32
+
33
+ Save the CogView-3-Flash and Pollinations keys in the Image generation settings. FPS uses the DSH credential store. You can also provide `FPS_COGVIEW_API_KEY` or `POLLINATIONS_API_KEY` in the environment that starts DSH. Each provider uses its own key.
34
+
35
+ ## Customize
36
+
37
+ - **Appearance.** In DSH, open Settings → General → Appearance and select Light, Dark, or System. FPS follows that setting across its sidebar, conversation, composer, and menus.
38
+ - **Models and usage.** In DSH, open Settings → Models to add or edit your providers, API endpoints, models, and API keys. Reopen the usage panel to query the updated configuration. A custom endpoint needs a matching usage integration to show balances or quotas. If you use a text-only model, you can add a model that supports image input here for FPS to select automatically.
39
+ - **Tools and permissions.** Manage plugins that provide tools in DSH's Plugins page. Use the composer footer to select your session model and permissions. Tool availability depends on the model's capabilities and the session permissions.
@@ -0,0 +1,39 @@
1
+ # Four-pointed star(FPS)
2
+
3
+ FPS 是 DeepSeek Harness(DSH)的会话工作台。你可以在同一处跟踪工具进度、打开会话产物、查看上下文与服务商用量。切换会话后可以找回未发送的附件,使用纯文本模型时也可以借助已配置的视觉模型读图。
4
+
5
+ ## 主要功能
6
+
7
+ - **上下文与会话产物**。点会话顶栏的「上下文」按钮,查看模型、会话状态和上下文占用估算。「输出内容」汇集会话生成或明确链接的本地文件,点击后用 DSH 原生预览打开。顶栏也可切换文件、终端和浏览器。
8
+ - **工具步骤摘要**。连续的读文件、执行命令和编辑操作会汇总成一行,展开可查看 DSH 原生详情。任务列表区分已完成、进行中和待处理。会话运行时,`ThinKing` 显示用时和 FPS 标志动画,遵循系统的减少动态效果设置。
9
+ - **输入与附件草稿**。支持粘贴图片,也可从「添加」菜单选择图片和文件。切换会话再回来时,会恢复该会话的文字草稿和未发送的附件。重新加载或关闭界面会清除未发送的附件。每条消息最多可添加 4 张各 8 MB 的图片和 6 个各 20 MB 的文件。
10
+ - **纯文本模型识图**。如果所选模型不支持图片输入,FPS 会从 DSH 的可用模型中查找支持图片的模型,将图片和问题发给它,再把生成的描述交给当前会话。找不到可用模型时,输入框会保留草稿并提示失败。
11
+ - **会话和工作区**。左栏保留 DSH 的原生工作区、会话搜索和设置。侧栏按钮旁的前进、后退箭头可在本次打开窗口后访问过的会话间切换。
12
+ - **命令、引用和语音**。输入 `/` 查找命令,输入 `@` 引用文件或会话。输入框下方可选择工作区、权限、模型和推理强度,也可查看用量。DSH 启用语音输入插件时,输入框会显示麦克风。
13
+
14
+ FPS 通过 DSH 原生会话发送消息。模型可以使用当前配置已启用的工具,由 DSH 按会话权限执行或请求确认。
15
+
16
+ ## 用量怎么显示
17
+
18
+ 点模型选择器旁的圆环。FPS 根据 DSH 中已启用的服务商及其 API Key 查询用量,按适配接口实际返回的内容显示余额、已用金额、额度或重置时间。零余额正常显示。接口未适配、查询失败或缺少数据时,相应账户或字段会自动隐藏。会话 Token 和上下文占用估算由 DSH 提供,单独显示。
19
+
20
+ ## 可选生图工具
21
+
22
+ 在「插件 → Four-pointed star → 图片生成」中按需开启。四项开关默认关闭。服务开启并准备就绪后,支持工具调用的会话模型可以按 DSH 的会话权限使用。FPS 将图片保存到 DSH 的生成目录,可用 DSH 原生预览打开。使用云端生图时,提示词会发送给相应服务,请避免在提示词里写入机密信息。
23
+
24
+ 可以在提示词里指定服务商。FPS 默认提示模型优先使用已有生图工具。调用 FPS 时,会按 ComfyUI(已就绪)、Z-Image Turbo、CogView-3-Flash、Pollinations 的顺序选取可用服务。请求失败时会返回错误,由你决定重试或换用其他服务。
25
+
26
+ - **Z-Image Turbo**。使用官方 Hugging Face Space,无需 API Key。生成的图片约为一百万像素,适合快速试构图和风格。共享 GPU 繁忙时需要排队,额度用尽时需等服务恢复。ZeroGPU 文档给出的匿名额度为每天两分钟 GPU 时间。[ZeroGPU 额度](https://huggingface.co/docs/hub/spaces-zerogpu#usage-tiers)
27
+ - **CogView-3-Flash**。需要智谱开放平台的 API Key。智谱将该 Flash 模型列为免费,适合常规画质需求,实际可用性取决于账号资格、服务限流与运行状态。FPS 只调用这个 Flash 模型。[智谱定价](https://docs.bigmodel.cn/cn/guide/start/pricing)
28
+ - **Pollinations**。需要自己的 Pollinations API Key。即使使用零价模型,服务也可能要求账户有正的可用余额。FPS 在请求前检查 SDXL Lightning 的实时价格,仅在零价时调用。该社区模型适合快速打样,模型说明标明会记录提示词和生成结果。[Pollinations API](https://gen.pollinations.ai/docs)
29
+ - **ComfyUI + Comfy MCP**。开启开关后,在「图片生成」中启动下载,安装 ComfyUI 和官方 Comfy MCP。安装支持 Apple 芯片的 macOS 14 或更新版本、64 位 Windows,以及 glibc 2.28 或更新版本的 x86-64 Linux。安装需要 Git、带 `venv` 和 `pip` 的 Python 3.11–3.13、网络连接、至少 8 GB 内存和 16 GiB 可用磁盘空间。建议使用兼容的 Apple 或 NVIDIA GPU,CPU 生图可能需要几分钟。随附的 SD 1.5 工作流默认在本地生成约 512 × 512 像素的草图,细小文字和写实效果有限。本地生成无需 Comfy 账号或积分。[Comfy MCP](https://github.com/Comfy-Org/comfy-mcp) · [模型与许可](https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive)
30
+
31
+ 关闭 ComfyUI 开关后,本地生图停用,已下载的文件保留供下次使用。FPS 通过官方 MCP 运行入门工作流,会话中只开放生图功能。
32
+
33
+ 在「图片生成」设置中,可将 CogView-3-Flash 和 Pollinations 的 Key 保存至 DSH 的凭证存储。也可以在启动 DSH 的环境中设置 `FPS_COGVIEW_API_KEY` 或 `POLLINATIONS_API_KEY`。各服务使用自己的 Key。
34
+
35
+ ## 自定义
36
+
37
+ - **外观**。在 DSH「设置 → 通用 → 外观」中选择浅色、深色或跟随系统。FPS 会同步切换侧栏、消息区、输入框和弹层的配色。
38
+ - **模型和用量**。在 DSH「设置 → 模型」中添加或编辑自己的服务商、接口地址、模型和 API Key。再次打开用量面板即可按新配置查询。自定义地址的余额或额度需要对应的用量接口适配。使用纯文本模型时,可在同一处添加支持图片输入的模型,供 FPS 自动选择。
39
+ - **工具和权限**。在 DSH 的「插件」中管理提供工具的插件,并在输入框下方选择会话模型和权限模式。工具能否调用取决于模型能力和当前会话权限。
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Four-pointed star",
4
+ "description": "A DeepSeek Harness workspace with session outputs, tool summaries, attachment drafts, provider usage, vision fallback, and optional cloud and local image generation.",
5
+ "detail": "locale/detail.en.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Four-pointed star",
4
+ "description": "DeepSeek Harness 会话工作台,支持会话产物、工具摘要、附件草稿、服务商用量、纯文本模型识图,以及可选的云端和本地生图。",
5
+ "detail": "locale/detail.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "dsh-fps-workbench",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.78",
4
+ "description": "Session outputs, tool summaries, attachment drafts, provider usage, and optional image tools for DeepSeek Harness.",
5
+ "author": "Blacstar7",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Blacstar7/dsh-fps-workbench.git"
10
+ },
11
+ "homepage": "https://github.com/Blacstar7/dsh-fps-workbench#readme",
12
+ "keywords": ["dsh-plugin", "deepseek-harness", "workbench"],
13
+ "engines": {
14
+ "dsh": ">=0.2.2-rc.1"
15
+ },
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./index.js",
19
+ "./client": "./client.js",
20
+ "./locale/*.json": "./locale/*.json",
21
+ "./icon": "./icon.svg"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "provider-usage.js",
26
+ "image-generation.js",
27
+ "image-providers.js",
28
+ "comfy-image.js",
29
+ "session-outputs.js",
30
+ "client.js",
31
+ "cordis.patch.yml",
32
+ "locale/*.json",
33
+ "locale/detail.*.md",
34
+ "icon.svg",
35
+ "README.md",
36
+ "README.zh.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web",
45
+ "immediately": true,
46
+ "inject": [
47
+ "@deepseek-ai/dsh-client-ui-conversation"
48
+ ]
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,301 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ const KIMI_CODE_ORIGINS = new Set(['https://api.kimi.com', 'https://api.kimi.ai']);
8
+ const KIMI_WINDOWS = [
9
+ ['limit_5h', 'fiveHours'],
10
+ ['limit_7d', 'sevenDays'],
11
+ ['limit_month_total', 'monthTotal'],
12
+ ];
13
+ const USER_AGENT = 'DeepSeek-Harness-Claude-Workbench';
14
+ const DREAMINA_CREDIT_CYCLE_MS = 30 * 24 * 60 * 60 * 1000;
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ function number(value) {
18
+ if (typeof value !== 'number' && !(typeof value === 'string' && value.trim())) return null;
19
+ const parsed = Number(value);
20
+ return Number.isFinite(parsed) ? parsed : null;
21
+ }
22
+
23
+ function money(currency, amount) {
24
+ return typeof currency === 'string' && /^[A-Z]{3}$/u.test(currency) && amount !== null
25
+ ? { currency, amount } : null;
26
+ }
27
+
28
+ function resetTimestamp(value) {
29
+ const timestamp = number(value);
30
+ return timestamp > 0 && Number.isFinite(new Date(timestamp).getTime()) ? timestamp : null;
31
+ }
32
+
33
+ function deepSeekUsage(payload) {
34
+ const balances = Array.isArray(payload?.balance_infos)
35
+ ? payload.balance_infos.map((entry) => money(entry?.currency, number(entry?.total_balance))).filter(Boolean)
36
+ : [];
37
+ return balances.length ? { balances } : null;
38
+ }
39
+
40
+ function kimiCodeUsage(payload) {
41
+ const windows = [];
42
+ for (const [field, id] of KIMI_WINDOWS) {
43
+ const entry = payload?.usages?.[field];
44
+ const ratio = number(entry?.used_ratio);
45
+ if (ratio === null || ratio < 0) continue;
46
+ const resetAt = typeof entry.reset_time === 'string' ? Date.parse(entry.reset_time) : NaN;
47
+ windows.push({ id, ratio, ...(Number.isFinite(resetAt) ? { resetAt } : {}) });
48
+ }
49
+
50
+ const wallet = payload?.booster_wallet ?? payload?.boosterWallet;
51
+ const currency = wallet?.monthlyChargeLimit?.currency ?? wallet?.monthlyUsed?.currency ?? wallet?.topupLimit?.currency;
52
+ const rawBalance = number(wallet?.balance?.amountLeft);
53
+ // Kimi's wallet uses one million fixed-point units per cent.
54
+ const balance = wallet?.balance?.type === 'BOOSTER' && rawBalance !== null
55
+ ? money(currency, (rawBalance > 0 && rawBalance < 1_000_000 ? 1 : Math.round(rawBalance / 1_000_000)) / 100)
56
+ : null;
57
+ return windows.length || balance ? { windows, balance } : null;
58
+ }
59
+
60
+ function kimiApiUsage(payload) {
61
+ if (payload?.code !== 0 || payload?.status !== true) return null;
62
+ const balance = money('CNY', number(payload.data?.available_balance));
63
+ return balance ? { balances: [balance] } : null;
64
+ }
65
+
66
+ function miniMaxUsage(payload) {
67
+ if (payload?.base_resp?.status_code !== 0 || !Array.isArray(payload?.model_remains)) return null;
68
+ const quota = payload.model_remains.find((row) => row?.model_name === 'general');
69
+ if (!quota) return null;
70
+
71
+ const windows = [];
72
+ for (const [id, percentField, resetField] of [
73
+ ['fiveHours', 'current_interval_remaining_percent', 'end_time'],
74
+ ['sevenDays', 'current_weekly_remaining_percent', 'weekly_end_time'],
75
+ ]) {
76
+ const remaining = number(quota[percentField]);
77
+ if (remaining === null || remaining < 0 || remaining > 100) continue;
78
+ const resetAt = resetTimestamp(quota[resetField]);
79
+ windows.push({ id, ratio: (100 - remaining) / 100, ...(resetAt ? { resetAt } : {}) });
80
+ }
81
+ return windows.length ? { windows } : null;
82
+ }
83
+
84
+ function zhipuUsage(payload) {
85
+ const data = payload?.data;
86
+ if (payload?.success !== true || !Array.isArray(data?.limits)) return null;
87
+
88
+ const windows = [];
89
+ // GLM reports the 5-hour and weekly windows as units 3 and 6 respectively.
90
+ for (const [unit, id] of [[3, 'fiveHours'], [6, 'sevenDays']]) {
91
+ const entry = data.limits.find((row) => number(row?.unit) === unit
92
+ && (row.type === 'CREDIT_LIMIT' || row.type === 'TOKENS_LIMIT'));
93
+ const percentage = number(entry?.percentage);
94
+ if (percentage === null || percentage < 0) continue;
95
+
96
+ const window = { id, ratio: percentage / 100 };
97
+ const resetAt = resetTimestamp(entry.nextResetTime);
98
+ if (resetAt) window.resetAt = resetAt;
99
+ const remaining = number(entry.remaining);
100
+ const total = number(entry.usage);
101
+ if (entry.type === 'CREDIT_LIMIT' && remaining !== null && remaining >= 0 && total > 0) {
102
+ window.credits = { remaining, total };
103
+ }
104
+ windows.push(window);
105
+ }
106
+
107
+ const plans = { lite: 'Lite', pro: 'Pro', max: 'Max' };
108
+ const level = typeof data.level === 'string' ? data.level.toLowerCase() : '';
109
+ const plan = Object.hasOwn(plans, level) ? plans[level] : undefined;
110
+ return windows.length ? { windows, plan } : null;
111
+ }
112
+
113
+ function openRouterUsage(payload) {
114
+ const data = payload?.data;
115
+ const charges = [
116
+ ['spent', number(data?.usage)],
117
+ ['keyRemaining', number(data?.limit_remaining)],
118
+ ].flatMap(([id, amount]) => amount !== null && amount >= 0 ? [{ id, currency: 'USD', amount }] : []);
119
+ return charges.length ? { charges } : null;
120
+ }
121
+
122
+ function adapterFor(baseURL) {
123
+ if (typeof baseURL !== 'string') return null;
124
+ let url;
125
+ try { url = new URL(baseURL); } catch { return null; }
126
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null;
127
+ const { origin, pathname } = url;
128
+ if (KIMI_CODE_ORIGINS.has(origin) && /^\/coding(?:\/|$)/u.test(pathname)) {
129
+ return { name: 'Kimi Coding', url: `${origin}/coding/v1/usages`, parse: kimiCodeUsage };
130
+ }
131
+ switch (origin) {
132
+ case 'https://api.deepseek.com':
133
+ return { name: 'DeepSeek', url: `${origin}/user/balance`, parse: deepSeekUsage };
134
+ case 'https://api.moonshot.cn':
135
+ return { name: 'Kimi API', url: `${origin}/v1/users/me/balance`, parse: kimiApiUsage };
136
+ case 'https://open.bigmodel.cn':
137
+ case 'https://api.z.ai':
138
+ return { name: 'GLM Coding Plan', url: `${origin}/api/monitor/usage/quota/limit`, parse: zhipuUsage, rawAuthorization: true };
139
+ case 'https://api.minimaxi.com':
140
+ case 'https://api.minimax.cn':
141
+ return { name: 'MiniMax', url: 'https://www.minimaxi.com/v1/token_plan/remains', parse: miniMaxUsage };
142
+ case 'https://api.minimax.io':
143
+ return { name: 'MiniMax', url: 'https://www.minimax.io/v1/token_plan/remains', parse: miniMaxUsage };
144
+ case 'https://openrouter.ai':
145
+ return { name: 'OpenRouter', url: `${origin}/api/v1/key`, parse: openRouterUsage };
146
+ default:
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function configuredTargets(ctx) {
152
+ const active = new Set(ctx.llm.listProviders().map(({ id }) => id));
153
+ const settings = ctx.get('settings')?.describe({ redactSecrets: true }) ?? [];
154
+ const byNamespace = new Map(settings.map(({ ns, value }) => [ns, value]));
155
+ const targets = [];
156
+ for (const entry of ctx.llm.listConfigurableProviders?.() ?? []) {
157
+ if (!active.has(entry.provider)) continue;
158
+ const profile = entry.settingsPath.reduce((value, key) =>
159
+ value && Object.hasOwn(value, key) ? value[key] : undefined, byNamespace.get(entry.settingsNs));
160
+ if (!profile || typeof profile !== 'object') continue;
161
+ const adapter = adapterFor(profile.baseURL ?? entry.defaultBaseURL);
162
+ if (!adapter) continue;
163
+ const native = adapterFor(entry.defaultBaseURL);
164
+ targets.push({
165
+ id: entry.provider, adapter, ref: profile.apiKeyEnv,
166
+ // The catalog's native credentials belong to its official account and region.
167
+ ...(native?.url === adapter.url ? { nativeRefs: entry.nativeAuth?.env ?? [], nativeId: entry.provider } : {}),
168
+ });
169
+ }
170
+
171
+ const installedDeepSeek = ctx.get('configEditor')?.entries().find(({ options }) =>
172
+ options.name === '@deepseek-ai/dsh-llm-deepseek-api-key');
173
+ const official = byNamespace.get(installedDeepSeek?.options.id ?? 'llm-deepseek');
174
+ if (active.has('deepseek-official') && official) {
175
+ const envBase = ctx.get('launchEnvironment')?.get('DEEPSEEK_BASE_URL')?.value ?? process.env.DEEPSEEK_BASE_URL;
176
+ const adapter = adapterFor(official.baseURL ?? envBase ?? 'https://api.deepseek.com');
177
+ if (adapter?.parse === deepSeekUsage) {
178
+ targets.push({ id: 'deepseek-official', adapter, ref: official.apiKeyEnv ?? 'DEEPSEEK_API_KEY' });
179
+ }
180
+ }
181
+ return targets;
182
+ }
183
+
184
+ async function resolveKey(credentials, target) {
185
+ const readRef = async (ref) => {
186
+ if (typeof ref !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(ref)) return null;
187
+ return (await credentials?.resolve?.(ref))?.value?.trim() || null;
188
+ };
189
+ if (target.ref !== undefined) return readRef(target.ref);
190
+ if (target.nativeId && /^[a-z][a-z0-9-]*$/u.test(target.nativeId)) {
191
+ const record = await credentials?.readRecord?.(`llm-pi-ai/${target.nativeId}`);
192
+ if (record) return record.kind === 'api-key' ? record.key?.trim() || null : null;
193
+ }
194
+ for (const ref of target.nativeRefs ?? []) {
195
+ const key = await readRef(ref);
196
+ if (key) return key;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ async function kimiCodeFromCCSwitch() {
202
+ // This optional source is restricted to the named Kimi Coding entry and official origin.
203
+ let db;
204
+ try {
205
+ const { DatabaseSync } = await import('node:sqlite');
206
+ db = new DatabaseSync(join(homedir(), '.cc-switch', 'cc-switch.db'), { readOnly: true });
207
+ const row = db.prepare("SELECT settings_config FROM providers WHERE app_type = ? AND id = ?")
208
+ .get('opencode', 'kimi-coding');
209
+ const options = row ? JSON.parse(row.settings_config)?.options : null;
210
+ if (typeof options?.baseURL !== 'string' || typeof options.apiKey !== 'string' || !options.apiKey.trim()) return null;
211
+ const base = new URL(options.baseURL);
212
+ if (!KIMI_CODE_ORIGINS.has(base.origin) || !/^\/coding(?:\/v1)?\/?$/u.test(base.pathname)
213
+ || base.username || base.password || base.search || base.hash) return null;
214
+ return { id: 'cc-switch-kimi-code', key: options.apiKey.trim(), adapter: adapterFor(base.href) };
215
+ } catch {
216
+ return null;
217
+ } finally {
218
+ db?.close();
219
+ }
220
+ }
221
+
222
+ async function queryUsage({ id, adapter, key }, signal, request) {
223
+ try {
224
+ const response = await request(adapter.url, {
225
+ headers: {
226
+ Authorization: adapter.rawAuthorization ? key : `Bearer ${key}`,
227
+ Accept: 'application/json',
228
+ 'User-Agent': USER_AGENT,
229
+ },
230
+ redirect: 'error',
231
+ signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]),
232
+ });
233
+ if (!response.ok) return null;
234
+ const usage = adapter.parse(await response.json());
235
+ return usage ? { id, name: adapter.name, ...usage } : null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ async function readLocalSettings() {
242
+ try {
243
+ const directory = process.env.DSH_HOME || join(homedir(), '.dsh');
244
+ return JSON.parse(await readFile(join(directory, 'workbench-usage.json'), 'utf8'));
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function dreaminaCreditReset(subscription, userId) {
251
+ if (!Number.isSafeInteger(userId) || userId !== subscription.userId) return null;
252
+ const openedAt = Date.parse(subscription.openedAt);
253
+ const expiresAt = Date.parse(subscription.expiresAt);
254
+ const now = Date.now();
255
+ if (!(openedAt <= now && now < expiresAt)) return null;
256
+ const nextCycle = Math.floor((now - openedAt) / DREAMINA_CREDIT_CYCLE_MS) + 1;
257
+ const resetAt = openedAt + nextCycle * DREAMINA_CREDIT_CYCLE_MS;
258
+ return resetAt < expiresAt ? resetAt : null;
259
+ }
260
+
261
+ async function dreaminaUsage(subscription, signal) {
262
+ if (!subscription || subscription.enabled === false) return null;
263
+ const executable = process.env.DREAMINA_BIN || join(homedir(), '.local', 'bin', 'dreamina');
264
+ try {
265
+ const { stdout } = await execFileAsync(executable, ['user_credit'], {
266
+ signal, timeout: 8_000, maxBuffer: 64 * 1024,
267
+ });
268
+ const account = JSON.parse(stdout);
269
+ const credits = number(account?.total_credit);
270
+ if (credits === null || credits < 0) return null;
271
+ const resetAt = dreaminaCreditReset(subscription, account.user_id);
272
+ return { id: 'dreamina', name: 'Dreamina', credits, ...(resetAt ? { resetAt } : {}) };
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ export async function providerUsage(ctx, signal, { request = fetch, localSettings = readLocalSettings } = {}) {
279
+ const credentials = ctx.get('credentials');
280
+ const local = await localSettings();
281
+ const resolved = await Promise.allSettled(configuredTargets(ctx).map(async (target) => ({
282
+ ...target, key: await resolveKey(credentials, target),
283
+ })));
284
+ const candidates = resolved.flatMap((result) => result.status === 'fulfilled' && result.value.key ? [result.value] : []);
285
+ if (local?.kimiCode?.source === 'cc-switch') {
286
+ const kimiCode = await kimiCodeFromCCSwitch();
287
+ if (kimiCode) candidates.push(kimiCode);
288
+ }
289
+
290
+ const seen = new Map();
291
+ const jobs = candidates.flatMap((target) => {
292
+ const url = target.adapter.url;
293
+ const keys = seen.get(url) ?? new Set();
294
+ if (keys.has(target.key)) return [];
295
+ keys.add(target.key);
296
+ seen.set(url, keys);
297
+ return [queryUsage(target, signal, request)];
298
+ });
299
+ const sections = (await Promise.all([...jobs, dreaminaUsage(local?.dreamina, signal)])).filter(Boolean);
300
+ return sections;
301
+ }
@@ -0,0 +1,154 @@
1
+ import { isAbsolute, normalize, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const FILE_TOOLS = new Set(['write', 'edit', 'str_replace_editor', 'apply_patch']);
5
+ const IMAGE_TOOL = /(?:^(?:fps_)?|__)generate_image$/u;
6
+
7
+ function isPath(value) {
8
+ return typeof value === 'string' && value.trim() !== '' && !value.includes('\0');
9
+ }
10
+
11
+ function outputPath(cwd, path) {
12
+ if (!isPath(path)) return null;
13
+ if (isAbsolute(path)) return normalize(path);
14
+ return cwd && !path.startsWith('~/') ? resolve(cwd, path) : null;
15
+ }
16
+
17
+ function addOutputs(state, paths) {
18
+ const files = new Set(state.files);
19
+ for (const path of paths) {
20
+ const absolute = outputPath(state.cwd, path);
21
+ if (absolute) files.add(absolute);
22
+ }
23
+ return files.size === state.files.length ? state : { ...state, files: [...files] };
24
+ }
25
+
26
+ function localLinkPath(destination) {
27
+ const value = destination.replace(/\\([\\()[\]<>])/gu, '$1');
28
+ if (/^file:\/\//iu.test(value)) {
29
+ try { return fileURLToPath(value); } catch { return null; }
30
+ }
31
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/|[#?])/iu.test(value)) return null;
32
+ try {
33
+ return decodeURIComponent(value.replace(/#L?\d+(?:-L?\d+)?$/iu, ''));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function messageLinkPaths(content) {
40
+ const paths = [];
41
+ const link = /(?<!\\)\[(?:\\.|[^\]\\\n])+\]\(\s*(?:<([^<>\n]+)>|((?:\\.|[^\s()\\]|\([^()\s]*\))+))(?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'))?\s*\)/gu;
42
+ for (const part of content) {
43
+ if (part.type !== 'text' || typeof part.text !== 'string') continue;
44
+ let fence;
45
+ const prose = part.text.split(/\r?\n/u).map((line) => {
46
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line);
47
+ if (fence) {
48
+ if (marker?.[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = undefined;
49
+ return '';
50
+ }
51
+ if (marker) fence = marker[1];
52
+ return marker || /^(?: {4}|\t)/u.test(line) ? '' : line;
53
+ }).join('\n').replace(/(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/gu, '');
54
+ for (const [, angle, bare] of prose.matchAll(link)) {
55
+ const path = localLinkPath(angle ?? bare);
56
+ if (path) paths.push(path);
57
+ }
58
+ }
59
+ return paths;
60
+ }
61
+
62
+ function toolArgumentPath(name, raw) {
63
+ if (name !== 'write' && name !== 'edit' && name !== 'str_replace_editor') return null;
64
+ let args = raw;
65
+ if (typeof raw === 'string') {
66
+ try {
67
+ args = JSON.parse(raw);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+ if (name === 'write' || name === 'edit') return args?.file_path;
73
+ if (name === 'str_replace_editor' && ['create', 'insert', 'str_replace'].includes(args?.command)) {
74
+ return args.path;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function resultPaths(name, content, meta, argumentPath) {
80
+ if (IMAGE_TOOL.test(name)) {
81
+ return (content ?? []).flatMap((part) => {
82
+ if (part.type !== 'text' || typeof part.text !== 'string') return [];
83
+ // The media tool reports its saved path before the provider and aspect ratio.
84
+ const match = /^Saved image to: (.+) \(provider=[^\r\n]+\)\r?$/mu.exec(part.text);
85
+ return match ? [match[1]] : [];
86
+ });
87
+ }
88
+ if (name === 'apply_patch') {
89
+ return (content ?? []).flatMap((part) => {
90
+ if (part.type !== 'text' || typeof part.text !== 'string' ||
91
+ !part.text.startsWith('Success. Updated the following files:\n')) return [];
92
+ return [...part.text.matchAll(/^[AM] (.+)$/gmu)].map((match) => match[1]);
93
+ });
94
+ }
95
+ if (name === 'str_replace_editor' && !argumentPath) return [];
96
+ return FILE_TOOLS.has(name) ? [meta?.path ?? argumentPath] : [];
97
+ }
98
+
99
+ const filesSchema = {
100
+ parse(files) {
101
+ if (!Array.isArray(files) || !files.every(isPath)) throw new Error('Invalid session output files');
102
+ return files;
103
+ },
104
+ };
105
+
106
+ export const sessionOutputsProjection = {
107
+ key: 'workbenchOutputs',
108
+ stateVersion: 3,
109
+ stateSchema: {
110
+ parse(state) {
111
+ if (!state || !(state.cwd === null || isPath(state.cwd)) || !Array.isArray(state.calls) ||
112
+ !state.calls.every((call) => typeof call?.id === 'string' && typeof call.name === 'string' &&
113
+ (call.path === null || isPath(call.path)))) {
114
+ throw new Error('Invalid session output state');
115
+ }
116
+ filesSchema.parse(state.files);
117
+ return state;
118
+ },
119
+ },
120
+ init: (header) => ({ cwd: header.cwd ?? null, files: [], calls: [] }),
121
+ apply(state, { type, data }) {
122
+ switch (type) {
123
+ case 'working-directory/change':
124
+ return state.cwd === data.cwd ? state : { ...state, cwd: data.cwd };
125
+ case 'deliverables/presented':
126
+ return addOutputs(state, (data.files ?? []).map((file) => file.path));
127
+ case 'assistant/message': {
128
+ const content = data.message.content;
129
+ if (data.interrupted || content.some((part) => part.type === 'tool-call')) return state;
130
+ return addOutputs(state, messageLinkPaths(content));
131
+ }
132
+ case 'tool/call': {
133
+ const { callId, name, arguments: args } = data;
134
+ if (!FILE_TOOLS.has(name) && !IMAGE_TOOL.test(name)) return state;
135
+ const path = outputPath(state.cwd, toolArgumentPath(name, args));
136
+ return { ...state, calls: [...state.calls, { id: callId, name, path }] };
137
+ }
138
+ case 'tool/result': {
139
+ const { message, meta } = data;
140
+ const call = state.calls.find(({ id }) => id === message.source.callId);
141
+ if (!call) return state;
142
+ const settled = { ...state, calls: state.calls.filter(({ id }) => id !== call.id) };
143
+ return message.isError ? settled : addOutputs(settled, resultPaths(call.name, message.content, meta, call.path));
144
+ }
145
+ case 'tool/ptc-dispatch':
146
+ if (data.isError !== false) return state;
147
+ return addOutputs(state, resultPaths(data.name, data.content, data.meta,
148
+ toolArgumentPath(data.name, data.arguments)));
149
+ default:
150
+ return state;
151
+ }
152
+ },
153
+ wire: { summary: false, viewSchema: filesSchema, view: (state) => state.files },
154
+ };