dsh-tg-bridge 1.3.2 → 1.3.4
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/LICENSE +1 -1
- package/README.md +120 -122
- package/bot-menu.mjs +3 -3
- package/bridge.mjs +14 -6
- package/cli.mjs +5 -2
- package/config.example.json +1 -1
- package/control.mjs +43 -28
- package/package.json +1 -1
- package/prices.mjs +94 -22
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,179 +1,179 @@
|
|
|
1
1
|
# dsh-tg-bridge
|
|
2
2
|
|
|
3
|
-
Telegram
|
|
4
|
-
|
|
3
|
+
A Telegram bridge for [DeepSeek Harness](https://deepseek.com): drive the agent from a
|
|
4
|
+
bot with text, voice and photos, without opening the desktop app.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
Each forum topic is a separate project with its own session and its own working
|
|
7
|
+
directory. Replies arrive with progress, a "Stop" button and copyable code
|
|
8
|
+
blocks.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Features
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- **Fail-fast
|
|
19
|
-
- **`dsh-tg check
|
|
20
|
-
-
|
|
21
|
-
-
|
|
12
|
+
- **Text**: a regular task goes to the agent, the answer comes back in the same topic.
|
|
13
|
+
- **Voice**: local faster-whisper, the transcript is shown before the answer.
|
|
14
|
+
- **Photos**: sent to a vision-capable model; text and UI are recognized.
|
|
15
|
+
- **Project per topic**: `/cd my-app`, and all turns run from that directory.
|
|
16
|
+
- **Live progress**: "⏳ Working..." with steps, a timer and the cache hit rate at the end.
|
|
17
|
+
- **No lost messages**: a journal with acknowledgement and replay.
|
|
18
|
+
- **Fail-fast**: when the quota runs out it switches to a free model right away.
|
|
19
|
+
- **`dsh-tg check`**: diagnostics in one command, no changes to the system.
|
|
20
|
+
- **Languages**: menus and service messages in `en`, `ru`, `zh`; `/lang` switches the topic language, a new user gets theirs from the Telegram `language_code` (anything outside ru/zh → `en`).
|
|
21
|
+
- **"Stop"/"Retry" buttons, tool confirmations**: right in the message, no commands to type.
|
|
22
22
|
|
|
23
|
-
##
|
|
23
|
+
## Requirements
|
|
24
24
|
|
|
25
25
|
- Node.js ≥ 20
|
|
26
|
-
-
|
|
27
|
-
-
|
|
26
|
+
- DeepSeek Harness installed (`~/.dsh/profiles/headless`)
|
|
27
|
+
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
|
|
28
28
|
|
|
29
|
-
##
|
|
29
|
+
## Languages
|
|
30
30
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
- Menus (`/` in chat): three sets, `ru` by default, plus `en` and `zh`.
|
|
32
|
+
- Topic language: `/lang en|ru|zh`; stored in `state.json` and survives a restart.
|
|
33
|
+
- A new user gets the language from the Telegram `language_code`; anything that is not `ru`/`zh` → `en`.
|
|
34
|
+
- Translated so far: menus, the start message, turn status, errors, `/cd`, `/new`, `/stop`, `/lang`,
|
|
35
|
+
the reply to an unknown command. Long reports (`/status`, `/sessions`, `/models` and similar)
|
|
36
|
+
are still in Russian, that is the next pass.
|
|
37
37
|
|
|
38
|
-
##
|
|
38
|
+
## Installation
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
One command: your bot token and your numeric id. Nothing to edit by hand.
|
|
41
41
|
|
|
42
42
|
**Windows (PowerShell)**
|
|
43
43
|
|
|
44
44
|
```powershell
|
|
45
|
-
npx dsh-tg-bridge@latest init --token 123456789:AA... --user
|
|
45
|
+
npx dsh-tg-bridge@latest init --token 123456789:AA... --user 123456789
|
|
46
46
|
npx dsh-tg-bridge@latest setup
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
**macOS / Linux**
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
|
-
DSH_TG_TOKEN=123456789:AA... DSH_TG_USER=
|
|
52
|
+
DSH_TG_TOKEN=123456789:AA... DSH_TG_USER=123456789 \
|
|
53
53
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.sh)"
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
**Windows
|
|
56
|
+
**Windows, one line** (same thing, installs and starts):
|
|
57
57
|
|
|
58
58
|
```powershell
|
|
59
|
-
$env:DSH_TG_TOKEN='123456789:AA...'; $env:DSH_TG_USER='
|
|
59
|
+
$env:DSH_TG_TOKEN='123456789:AA...'; $env:DSH_TG_USER='123456789'
|
|
60
60
|
irm https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.ps1 | iex
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
What `init` does:
|
|
64
64
|
|
|
65
|
-
|
|
|
65
|
+
| What | Where |
|
|
66
66
|
|---|---|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
67
|
+
| bot token | `~/.dsh/.credentials.yaml` → `refs.DSH_TELEGRAM_BOT_TOKEN`; other keys are left alone |
|
|
68
|
+
| your id and working directory | `~/.dsh/tg-bridge/config.json`; an existing file is not overwritten |
|
|
69
|
+
| `headless` profile | checked, with a hint on how to create it if missing |
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
`init` flags: `--cwd <path>` (default `~/Projects`), `--voice` (enable
|
|
72
|
+
voice transcription), `--dry-run` (show what would be written, change nothing).
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
Get your id from [@userinfobot](https://t.me/userinfobot), the token from
|
|
75
75
|
[@BotFather](https://t.me/BotFather).
|
|
76
76
|
|
|
77
|
-
###
|
|
77
|
+
### Bot settings in Telegram
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Three things without which the bot will stay silent in a group or work only halfway:
|
|
80
80
|
|
|
81
|
-
1. **Privacy mode → Disable** (`/setprivacy`
|
|
82
|
-
|
|
83
|
-
2.
|
|
84
|
-
|
|
85
|
-
3.
|
|
86
|
-
|
|
81
|
+
1. **Privacy mode → Disable** (`/setprivacy` in BotFather). Otherwise the bot sees
|
|
82
|
+
only commands, not regular text in a topic.
|
|
83
|
+
2. **Topics enabled** in the group settings (Edit → Topics). Without them all work
|
|
84
|
+
goes to a single chat and per-topic sessions are not created.
|
|
85
|
+
3. **Pin Messages** permission for the bot. Only needed to pin the first
|
|
86
|
+
message of a topic; without it the bridge keeps working but will not pin.
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
You do not need to set up the command menu by hand: the bridge installs it on start,
|
|
89
|
+
in Russian, English and Chinese at once. The full settings table, permissions, checks and
|
|
90
|
+
common errors are in [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md).
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
Linux.
|
|
94
|
-
`DSH-Bridge-Kick
|
|
92
|
+
**Autostart** is installed by the setup: `launchd` on macOS, `systemd --user` on
|
|
93
|
+
Linux. On Windows `setup` registers the `DSH-TG-Bridge` task and a one-minute watchdog
|
|
94
|
+
`DSH-Bridge-Kick` that brings the bridge back after a crash.
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
**Manual install** (if you want full control):
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
99
|
git clone https://github.com/aleksvega/dsh-tg-bridge.git ~/.dsh/tg-bridge
|
|
100
100
|
cd ~/.dsh/tg-bridge
|
|
101
|
-
node cli.mjs init --token 123456789:AA... --user
|
|
102
|
-
node cli.mjs check #
|
|
103
|
-
node cli.mjs setup #
|
|
101
|
+
node cli.mjs init --token 123456789:AA... --user 123456789
|
|
102
|
+
node cli.mjs check # report only, changes nothing
|
|
103
|
+
node cli.mjs setup # stop the old bridge and start the new one
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
If the bridge is already installed, `setup` restarts it on the new code and reinstalls
|
|
107
|
+
the command menu.
|
|
108
108
|
|
|
109
|
-
##
|
|
109
|
+
## Troubleshooting
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
Error replies end with a code, which tells you what to fix:
|
|
112
112
|
|
|
113
|
-
|
|
|
113
|
+
| Code | Meaning |
|
|
114
114
|
|---|---|
|
|
115
|
-
| `INSUFFICIENT_QUOTA` |
|
|
116
|
-
| `TG_CONFLICT` |
|
|
117
|
-
| `MISSING_CREDENTIAL` |
|
|
118
|
-
| `NO_DSH_CLI` |
|
|
119
|
-
| `NETWORK` |
|
|
120
|
-
| `SANDBOX_DENY` | DSH
|
|
121
|
-
| `UNKNOWN_MODEL` |
|
|
122
|
-
| `EMPTY_RESPONSE` |
|
|
123
|
-
| `TURN_FAILED` |
|
|
115
|
+
| `INSUFFICIENT_QUOTA` | provider balance is empty: top up or stay on free models (`/prices`, `/models`) |
|
|
116
|
+
| `TG_CONFLICT` | a second process is polling the same bot: `/doctor`, then `/restart` |
|
|
117
|
+
| `MISSING_CREDENTIAL` | no provider key in the profile: add it in the DSH app |
|
|
118
|
+
| `NO_DSH_CLI` | DeepSeek Harness CLI not found: check the app installation |
|
|
119
|
+
| `NETWORK` | no network to the provider: retry, check VPN/DNS |
|
|
120
|
+
| `SANDBOX_DENY` | DSH denied access to a file or directory: check the project path and permissions |
|
|
121
|
+
| `UNKNOWN_MODEL` | the model from the config is unavailable: `/models` lists the working ones |
|
|
122
|
+
| `EMPTY_RESPONSE` | the model returned nothing: retry or switch model (`/model`) |
|
|
123
|
+
| `TURN_FAILED` | cause not recognized: `/doctor` and `/logs errors` |
|
|
124
124
|
|
|
125
|
-
##
|
|
125
|
+
## Checks
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
Send the bot:
|
|
128
128
|
|
|
129
|
-
|
|
|
129
|
+
| Command | Expected |
|
|
130
130
|
|---|---|
|
|
131
|
-
| `/doctor` |
|
|
132
|
-
| `/help` |
|
|
133
|
-
| `/cd
|
|
134
|
-
| `/prices` |
|
|
131
|
+
| `/doctor` | report: pid, Telegram, disk, directory, session |
|
|
132
|
+
| `/help` | commands grouped by category |
|
|
133
|
+
| `/cd my-app` | topic bound to the project |
|
|
134
|
+
| `/prices` | model prices and the cost of the current one |
|
|
135
135
|
|
|
136
|
-
##
|
|
136
|
+
## Commands
|
|
137
137
|
|
|
138
138
|
```
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
139
|
+
Work: /cd · /new · /stop · /send · /seed
|
|
140
|
+
Sessions: /status · /sessions · /resume
|
|
141
|
+
Models: /models · /model · /prices
|
|
142
|
+
Extensions: /skills · /plugins · /plugin · /soul
|
|
143
|
+
Diagnostics: /doctor · /logs [errors|N|text]
|
|
144
|
+
Service: /app · /restart · /help
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
##
|
|
147
|
+
## How it works
|
|
148
148
|
|
|
149
149
|
```
|
|
150
|
-
Telegram → bridge.mjs → dsh --profile headless --json →
|
|
150
|
+
Telegram → bridge.mjs → dsh --profile headless --json → reply
|
|
151
151
|
│
|
|
152
|
-
├─
|
|
153
|
-
├─
|
|
154
|
-
├─
|
|
155
|
-
└─ fail-fast
|
|
152
|
+
├─ pending/ journal + ack (messages are not lost)
|
|
153
|
+
├─ per-topic queue (turns in topics run in parallel)
|
|
154
|
+
├─ progress + buttons (you can see the work is going)
|
|
155
|
+
└─ fail-fast on quota (free models as a fallback)
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
The bridge is a single process with long polling. Each turn spawns a separate
|
|
159
|
+
CLI process that exits after the answer, so "no DSH process" between
|
|
160
|
+
turns is normal, not a failure.
|
|
161
161
|
|
|
162
|
-
###
|
|
162
|
+
### Steering during a turn
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
90
|
|
164
|
+
If you write in a topic while a turn is still running, the message does not queue
|
|
165
|
+
behind it: the bridge drops the task into `~/.dsh/tg-bridge/steer/`, the `dsh-steer-inbox`
|
|
166
|
+
plugin hands it to the live agent on its next step, and the agent immediately replies with a short
|
|
167
|
+
line in the chat: what it is doing now and where it put the addition. If the agent stays silent
|
|
168
|
+
for 90 seconds, the bridge writes one neutral line itself so there is no silence.
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
This needs the plugin from this repository:
|
|
171
171
|
|
|
172
172
|
```bash
|
|
173
|
-
dsh plugin --profile headless add link
|
|
173
|
+
dsh plugin --profile headless add link:<path-to-repository>/plugin/steer-inbox
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
and a line in `~/.dsh/profiles/headless/cordis.patch.yml`:
|
|
177
177
|
|
|
178
178
|
```yaml
|
|
179
179
|
- insert:
|
|
@@ -181,27 +181,25 @@ dsh plugin --profile headless add link:<путь-к-репозиторию>/plug
|
|
|
181
181
|
name: 'dsh-steer-inbox'
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
The bridge checks the profile itself: if the line is missing, it does not write the task
|
|
185
|
+
(it would just expire) and behaves as before, queueing the message.
|
|
186
186
|
|
|
187
|
-
##
|
|
187
|
+
## Security
|
|
188
188
|
|
|
189
|
-
- `config.json`, `state.json`, `pending/`,
|
|
190
|
-
|
|
191
|
-
-
|
|
192
|
-
-
|
|
189
|
+
- `config.json`, `state.json`, `pending/`, logs are in `.gitignore` and never
|
|
190
|
+
end up in the repository.
|
|
191
|
+
- The token is stored in the DSH credentials store, not in code or config.
|
|
192
|
+
- The bridge only replies to ids in `allowedUserIds`.
|
|
193
193
|
|
|
194
|
-
##
|
|
194
|
+
## Documentation
|
|
195
195
|
|
|
196
|
-
- [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md)
|
|
197
|
-
- [docs/
|
|
198
|
-
- [docs/
|
|
199
|
-
- [docs/
|
|
200
|
-
- [docs/
|
|
201
|
-
- [docs/
|
|
202
|
-
- [docs/CHANGELOG.md](docs/CHANGELOG.md) — история изменений
|
|
203
|
-
- [docs/RELEASE-CRITERIA.md](docs/RELEASE-CRITERIA.md) — критерии готовности релиза
|
|
196
|
+
- [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md): BotFather settings, group and bot permissions
|
|
197
|
+
- [docs/ROADMAP.md](docs/ROADMAP.md): what comes next
|
|
198
|
+
- [docs/UX-ANALYSIS.md](docs/UX-ANALYSIS.md): interactive UX review
|
|
199
|
+
- [docs/COST.md](docs/COST.md): what one turn costs and how to reduce it
|
|
200
|
+
- [docs/CHANGELOG.md](docs/CHANGELOG.md): change history
|
|
201
|
+
- [docs/RELEASE-CRITERIA.md](docs/RELEASE-CRITERIA.md): release readiness criteria
|
|
204
202
|
|
|
205
|
-
##
|
|
203
|
+
## License
|
|
206
204
|
|
|
207
|
-
MIT
|
|
205
|
+
MIT, see [LICENSE](LICENSE).
|
package/bot-menu.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const token = (() => {
|
|
|
30
30
|
/** The real, implemented surface of dsh-tg-bridge — nothing aspirational. */
|
|
31
31
|
const COMMANDS = [
|
|
32
32
|
{ command: 'new', description: 'Новая сессия в этой теме' },
|
|
33
|
-
{ command: 'cd', description: 'Привязать тему к проекту: /cd
|
|
33
|
+
{ command: 'cd', description: 'Привязать тему к проекту: /cd my-app' },
|
|
34
34
|
{ command: 'status', description: 'Сессии, модель, активные ходы' },
|
|
35
35
|
{ command: 'stop', description: 'Остановить текущий ход' },
|
|
36
36
|
{ command: 'prices', description: 'Цены моделей и что стоит текущая' },
|
|
@@ -54,7 +54,7 @@ const COMMANDS = [
|
|
|
54
54
|
/** English menu (language_code: en). Same commands, translated descriptions. */
|
|
55
55
|
const COMMANDS_EN = [
|
|
56
56
|
{ command: 'new', description: 'New session in this topic' },
|
|
57
|
-
{ command: 'cd', description: 'Bind this topic to a project: /cd
|
|
57
|
+
{ command: 'cd', description: 'Bind this topic to a project: /cd my-app' },
|
|
58
58
|
{ command: 'status', description: 'Sessions, model, active turns' },
|
|
59
59
|
{ command: 'stop', description: 'Stop the current turn' },
|
|
60
60
|
{ command: 'prices', description: 'Model prices and the cost of the current one' },
|
|
@@ -78,7 +78,7 @@ const COMMANDS_EN = [
|
|
|
78
78
|
/** Chinese menu (language_code: zh-hans). */
|
|
79
79
|
const COMMANDS_ZH = [
|
|
80
80
|
{ command: 'new', description: '在本话题开始新会话' },
|
|
81
|
-
{ command: 'cd', description: '将本话题绑定到项目:/cd
|
|
81
|
+
{ command: 'cd', description: '将本话题绑定到项目:/cd my-app' },
|
|
82
82
|
{ command: 'status', description: '会话、模型、进行中的任务' },
|
|
83
83
|
{ command: 'stop', description: '停止当前任务' },
|
|
84
84
|
{ command: 'prices', description: '模型价格与当前模型花费' },
|
package/bridge.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
readdirSync, statSync, openSync, fsyncSync, closeSync, renameSync, writeFileSync as writeFd,
|
|
12
12
|
} from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { join, basename } from 'node:path';
|
|
14
|
+
import { join, basename, isAbsolute } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
// Force IPv4 for every fetch. DNS returns an AAAA next to the A record, and
|
|
17
17
|
// Happy Eyeballs happily picks the IPv6 path, which here costs ~5 s or dies
|
|
@@ -25,7 +25,7 @@ net.setDefaultAutoSelectFamily(false);
|
|
|
25
25
|
import { noticeIsObsolete } from './queue-notice.mjs';
|
|
26
26
|
import { immediateKind } from './immediate.mjs';
|
|
27
27
|
import { writeSteerJob } from './steer-queue.mjs';
|
|
28
|
-
import { handleControl, CONTROL_COMMANDS } from './control.mjs';
|
|
28
|
+
import { handleControl, CONTROL_COMMANDS, modelOf } from './control.mjs';
|
|
29
29
|
import { renderPrices } from './prices.mjs';
|
|
30
30
|
import { t, resolveLang } from './i18n.mjs';
|
|
31
31
|
|
|
@@ -95,7 +95,7 @@ process.on('exit', (code) => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
const config = {
|
|
98
|
-
allowedUserIds: [
|
|
98
|
+
allowedUserIds: [],
|
|
99
99
|
profile: 'headless',
|
|
100
100
|
cwd: join(HOME, 'Documents', 'deepseek-harness', 'default-workspace'),
|
|
101
101
|
turnTimeoutMs: 20 * 60 * 1000,
|
|
@@ -109,6 +109,11 @@ const turnStartedAt = new Map();
|
|
|
109
109
|
try {
|
|
110
110
|
Object.assign(config, JSON.parse(readFileSync(join(DIR, 'config.json'), 'utf8')));
|
|
111
111
|
} catch {}
|
|
112
|
+
// Old installs wrote these paths relative to the bridge directory, but turns
|
|
113
|
+
// run from the project cwd, where "./transcribe.py" does not exist. Resolve
|
|
114
|
+
// once at load so voice and vision work regardless of the topic's directory.
|
|
115
|
+
if (config.voice?.script && !isAbsolute(config.voice.script)) config.voice.script = join(DIR, config.voice.script);
|
|
116
|
+
if (config.vision?.patch && !isAbsolute(config.vision.patch)) config.vision.patch = join(DIR, config.vision.patch);
|
|
112
117
|
|
|
113
118
|
const state = (() => {
|
|
114
119
|
if (!existsSync(STATE)) return { offset: 0, sessions: {} };
|
|
@@ -709,7 +714,7 @@ function runTurn(task, sessionId, key = '', onEvent = null, extraArgs = [], cwd
|
|
|
709
714
|
args.push(task);
|
|
710
715
|
const child = spawn(EXE, args, {
|
|
711
716
|
// Per-topic project directory: a topic can be bound to one repository so
|
|
712
|
-
// "
|
|
717
|
+
// "work in my-app" stops meaning "cd in every command".
|
|
713
718
|
cwd: cwd && existsSync(cwd) ? cwd : config.cwd,
|
|
714
719
|
env: {
|
|
715
720
|
...process.env,
|
|
@@ -1190,7 +1195,10 @@ ${t(lang, 'help.body')}`,
|
|
|
1190
1195
|
api: tg,
|
|
1191
1196
|
sendFile: (p, caption = '') => sendFile(chatId, threadId, p, caption),
|
|
1192
1197
|
seedTopic: ensureTopicSeed,
|
|
1193
|
-
prices: (filter) =>
|
|
1198
|
+
prices: (filter) => {
|
|
1199
|
+
const route = modelOf(config.profile || 'headless');
|
|
1200
|
+
return renderPrices(filter, { model: route.model, provider: route.provider, lang });
|
|
1201
|
+
},
|
|
1194
1202
|
reply: (out) => send(chatId, out, threadId),
|
|
1195
1203
|
stopTurn: (k) => {
|
|
1196
1204
|
const child = activeTurns.get(k);
|
|
@@ -1228,7 +1236,7 @@ ${t(lang, 'help.body')}`,
|
|
|
1228
1236
|
// Turn telemetry for the log: what the agent did, how much it cost, on which
|
|
1229
1237
|
// model. Without it /logs shows only "in" and "sent" — useless when asking
|
|
1230
1238
|
// "why did this take 9 minutes".
|
|
1231
|
-
const telemetry = { steps: 0, tools: 0, inputTokens: 0, cacheReadTokens: 0, model: config.
|
|
1239
|
+
const telemetry = { steps: 0, tools: 0, inputTokens: 0, cacheReadTokens: 0, model: modelOf(config.profile || 'headless').model };
|
|
1232
1240
|
const onEvent = (event) => {
|
|
1233
1241
|
try {
|
|
1234
1242
|
// The id of the session this process actually works in. A new topic has
|
package/cli.mjs
CHANGED
|
@@ -87,7 +87,7 @@ init:
|
|
|
87
87
|
--voice включить голос (нужен python + faster-whisper)
|
|
88
88
|
--dry-run показать, что будет записано, ничего не менять
|
|
89
89
|
|
|
90
|
-
Docs:
|
|
90
|
+
Docs: README.md, docs/TELEGRAM-SETUP.md, docs/ROADMAP.md.
|
|
91
91
|
`;
|
|
92
92
|
|
|
93
93
|
/**
|
|
@@ -143,7 +143,10 @@ function writeConfig({ userId, cwd, voice }, dryRun) {
|
|
|
143
143
|
...example,
|
|
144
144
|
allowedUserIds: [Number(userId)],
|
|
145
145
|
cwd,
|
|
146
|
-
|
|
146
|
+
// Turns run from the user's project directory, so these must be absolute:
|
|
147
|
+
// a relative "./transcribe.py" silently disables voice and vision.
|
|
148
|
+
voice: { ...example.voice, enabled: Boolean(voice), script: join(RUNTIME, 'transcribe.py') },
|
|
149
|
+
vision: { ...example.vision, patch: join(RUNTIME, 'patches', 'vision.yml') },
|
|
147
150
|
};
|
|
148
151
|
delete config._comment;
|
|
149
152
|
if (dryRun) {
|
package/config.example.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "
|
|
2
|
+
"_comment": "Copy to config.json and fill in. Get your chat id from @userinfobot; without it the bridge answers nobody.",
|
|
3
3
|
"allowedUserIds": [],
|
|
4
4
|
"profile": "headless",
|
|
5
5
|
"cwd": "",
|
package/control.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const PROFILES = join(DSH_HOME, 'profiles');
|
|
|
17
17
|
const APP = join(process.env.LOCALAPPDATA || '', 'Programs', 'DeepSeek Harness');
|
|
18
18
|
const EXE = join(APP, 'DeepSeek Harness.exe');
|
|
19
19
|
const DSH_CMD = join(APP, 'resources', 'runtime', 'cli', 'bin', 'dsh.cmd');
|
|
20
|
+
const CLI_JS = join(APP, 'resources', 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js');
|
|
20
21
|
|
|
21
22
|
/** Live DSH app PIDs (empty when the app is not running). */
|
|
22
23
|
function listPids() {
|
|
@@ -204,10 +205,16 @@ function removeBundle(profile, name) {
|
|
|
204
205
|
/** Run `dsh plugin ...` (which forwards to pnpm) and collect its output. */
|
|
205
206
|
function dshPlugin(profile, args, timeoutMs = 180000) {
|
|
206
207
|
return new Promise((resolve) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
// Spawning the .cmd shim with shell:false fails with EINVAL on modern Node.
|
|
209
|
+
// Run the real CLI entry through the Electron binary instead, like the
|
|
210
|
+
// bridge does for turns; fall back to a plain `dsh` on other platforms.
|
|
211
|
+
const useExe = process.platform === 'win32' && existsSync(EXE) && existsSync(join(APP, 'resources', 'app.asar'));
|
|
212
|
+
const child = useExe
|
|
213
|
+
? spawn(EXE, ['--expose-internals', CLI_JS, 'plugin', '--profile', profile, ...args], {
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
216
|
+
})
|
|
217
|
+
: spawn('dsh', ['plugin', '--profile', profile, ...args], { windowsHide: true });
|
|
211
218
|
let out = '';
|
|
212
219
|
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
213
220
|
child.stdout.setEncoding('utf8');
|
|
@@ -350,7 +357,7 @@ export const CONTROL_COMMANDS = [
|
|
|
350
357
|
];
|
|
351
358
|
|
|
352
359
|
export async function handleControl(ctx) {
|
|
353
|
-
const { command, arg, send, reply, log, profile, state, key, config } = ctx;
|
|
360
|
+
const { command, arg, send, reply, log, profile, state, key, config, lang } = ctx;
|
|
354
361
|
const say = (text) => reply(text);
|
|
355
362
|
|
|
356
363
|
// One command for one job: `/plugins` is both the list and the way to change
|
|
@@ -369,30 +376,37 @@ export async function handleControl(ctx) {
|
|
|
369
376
|
|
|
370
377
|
switch (effective) {
|
|
371
378
|
case '/status': {
|
|
372
|
-
const
|
|
373
|
-
const
|
|
374
|
-
const deskPlugins = pluginsOf('desktop');
|
|
379
|
+
const activeProfile = config.profile || 'headless';
|
|
380
|
+
const head = modelOf(activeProfile);
|
|
375
381
|
const session = state.sessions[key] || '(ещё нет)';
|
|
376
382
|
const active = ctx.activeTurns ? ctx.activeTurns.size : 0;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
383
|
+
const lines = [
|
|
384
|
+
`Профиль: ${activeProfile} (${head.provider}/${head.model})`,
|
|
385
|
+
`Ветка: ${ctx.threadId || 'общая'}`,
|
|
386
|
+
`Сессия: ${session}`,
|
|
387
|
+
`Ходов в работе: ${active}`,
|
|
388
|
+
`Скиллы: ${skillsOf().length}`,
|
|
389
|
+
`DSH_HOME: ${DSH_HOME}`,
|
|
390
|
+
];
|
|
391
|
+
// The desktop profile only matters when this install actually has one.
|
|
392
|
+
if (existsSync(join(PROFILES, 'desktop', 'cordis.patch.yml'))) {
|
|
393
|
+
const desk = modelOf('desktop');
|
|
394
|
+
const deskPlugins = pluginsOf('desktop');
|
|
395
|
+
lines.splice(1, 0, `UI: desktop (${desk.provider}/${desk.model}) — плагинов ${deskPlugins.bundles.length}`);
|
|
396
|
+
}
|
|
397
|
+
say(lines.join('\n'));
|
|
388
398
|
return true;
|
|
389
399
|
}
|
|
390
400
|
|
|
391
401
|
case '/models': {
|
|
392
|
-
const
|
|
393
|
-
const
|
|
402
|
+
const activeProfile = config.profile || 'headless';
|
|
403
|
+
const head = modelOf(activeProfile);
|
|
404
|
+
const ids = modelsOf(activeProfile);
|
|
394
405
|
say(
|
|
395
|
-
[
|
|
406
|
+
[
|
|
407
|
+
ids.length > 0 ? `Модель сейчас: ${head.model}` : `В профиле ${activeProfile} нет списка моделей`,
|
|
408
|
+
...(ids.length > 0 ? ['Доступные:', ...ids.map((id) => ` ${id === head.model ? '*' : ' '} ${id}`)] : []),
|
|
409
|
+
].join('\n'),
|
|
396
410
|
);
|
|
397
411
|
return true;
|
|
398
412
|
}
|
|
@@ -403,18 +417,19 @@ export async function handleControl(ctx) {
|
|
|
403
417
|
say('Использование: /model <id> [both] (/models — список)');
|
|
404
418
|
return true;
|
|
405
419
|
}
|
|
406
|
-
|
|
407
|
-
|
|
420
|
+
const activeProfile = config.profile || 'headless';
|
|
421
|
+
if (!setModel(activeProfile, wanted)) {
|
|
422
|
+
say(`Не смог переключить ${activeProfile} на «${wanted}»: в профиле нет строки agent-default-model. /models — список.`);
|
|
408
423
|
return true;
|
|
409
424
|
}
|
|
410
425
|
const desktop = scope === 'both' ? setModel('desktop', wanted) : false;
|
|
411
|
-
say(`Модель
|
|
426
|
+
say(`Модель ${activeProfile} → ${wanted}${desktop ? ', desktop → ' + wanted : ''}. Действует со следующего хода.`);
|
|
412
427
|
return true;
|
|
413
428
|
}
|
|
414
429
|
|
|
415
430
|
case '/sessions': {
|
|
416
431
|
const limit = Number.parseInt(arg, 10) > 0 ? Math.min(Number.parseInt(arg, 10), 20) : 8;
|
|
417
|
-
const rows = recentSessions(config.cwd, limit);
|
|
432
|
+
const rows = recentSessions((state.cwd && state.cwd[key]) || config.cwd, limit);
|
|
418
433
|
if (rows.length === 0) {
|
|
419
434
|
say('Сессий не нашёл.');
|
|
420
435
|
return true;
|
|
@@ -622,7 +637,7 @@ export async function handleControl(ctx) {
|
|
|
622
637
|
|
|
623
638
|
case '/cd': {
|
|
624
639
|
// Bind THIS topic to a project directory. Every later turn in the topic
|
|
625
|
-
// spawns the CLI with that cwd, so "
|
|
640
|
+
// spawns the CLI with that cwd, so "work in my-app" stops
|
|
626
641
|
// meaning "cd ... in every single command".
|
|
627
642
|
const wanted = (arg || '').trim().replace(/^"|"$/g, '');
|
|
628
643
|
state.cwd = state.cwd || {};
|
|
@@ -630,7 +645,7 @@ export async function handleControl(ctx) {
|
|
|
630
645
|
const current = state.cwd[key] || config.cwd;
|
|
631
646
|
const lines = ['Текущий каталог темы:', current, '', 'Привязать: /cd <путь>', 'Сбросить: /cd reset'];
|
|
632
647
|
try {
|
|
633
|
-
const dirs = readdirSync('
|
|
648
|
+
const dirs = readdirSync(config.projectsDir || join(HOME, 'Projects'), { withFileTypes: true })
|
|
634
649
|
.filter((entry) => entry.isDirectory())
|
|
635
650
|
.map((entry) => entry.name);
|
|
636
651
|
if (dirs.length > 0) lines.push('', 'Проекты:', dirs.slice(0, 20).join(' · '));
|
package/package.json
CHANGED
package/prices.mjs
CHANGED
|
@@ -99,49 +99,121 @@ function usd(value) {
|
|
|
99
99
|
return `$${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
const WORDS = {
|
|
103
|
+
en: {
|
|
104
|
+
prices: 'Prices',
|
|
105
|
+
now: 'Current',
|
|
106
|
+
cheap: 'Cheapest with context >= 128k:',
|
|
107
|
+
none: 'No models matched the filter.',
|
|
108
|
+
fail: 'Could not fetch prices',
|
|
109
|
+
unknown: 'not found in the catalog',
|
|
110
|
+
},
|
|
111
|
+
ru: {
|
|
112
|
+
prices: 'Цены',
|
|
113
|
+
now: 'Сейчас',
|
|
114
|
+
cheap: 'Дешёвые модели с контекстом ≥128k:',
|
|
115
|
+
none: 'Ничего не нашлось по фильтру.',
|
|
116
|
+
fail: 'Не смог получить цены',
|
|
117
|
+
unknown: 'в каталоге не найден',
|
|
118
|
+
},
|
|
119
|
+
zh: {
|
|
120
|
+
prices: '价格',
|
|
121
|
+
now: '当前',
|
|
122
|
+
cheap: '上下文 ≥128k 的最便宜模型:',
|
|
123
|
+
none: '没有符合筛选条件的模型。',
|
|
124
|
+
fail: '无法获取价格',
|
|
125
|
+
unknown: '目录中未找到',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
102
129
|
/**
|
|
103
|
-
*
|
|
130
|
+
* Find a model by id. Providers prefix ids differently (`openai/gpt-4o`,
|
|
131
|
+
* `gpt-4o`, `unorouter/gpt-4o:free`), so exact match first, then the shortest
|
|
132
|
+
* id that contains the wanted token.
|
|
133
|
+
*/
|
|
134
|
+
export function findModel(models, wanted) {
|
|
135
|
+
const needle = String(wanted ?? '').trim().toLowerCase();
|
|
136
|
+
if (!needle) return null;
|
|
137
|
+
const exact = models.find((model) => model.id.toLowerCase() === needle);
|
|
138
|
+
if (exact) return exact;
|
|
139
|
+
const matches = models.filter((model) => model.id.toLowerCase().includes(needle));
|
|
140
|
+
matches.sort((a, b) => a.id.length - b.id.length);
|
|
141
|
+
return matches[0] || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render `/prices [filter]` from every catalog we can reach, so the answer does
|
|
146
|
+
* not depend on one provider: the unorouter page carries live prices for its
|
|
147
|
+
* own routes, models.dev covers the rest of the market. Both are merged; the
|
|
148
|
+
* current model is looked up in the merged list.
|
|
104
149
|
* @param {string} filter optional text to match model ids
|
|
105
|
-
* @param {{ model?: string
|
|
150
|
+
* @param {{ model?: string, provider?: string, lang?: string }} options
|
|
106
151
|
*/
|
|
107
152
|
export async function renderPrices(filter = '', options = {}) {
|
|
153
|
+
const lang = WORDS[options.lang] ? options.lang : 'en';
|
|
154
|
+
const words = WORDS[lang];
|
|
155
|
+
const [page, dev] = await Promise.allSettled([
|
|
156
|
+
fetchText(MODELS_PAGE),
|
|
157
|
+
fetchText(MODELS_DEV, 30000),
|
|
158
|
+
]);
|
|
159
|
+
|
|
108
160
|
let models = [];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
const sources = [];
|
|
162
|
+
if (page.status === 'fulfilled') {
|
|
163
|
+
const parsed = parseModelsPage(page.value);
|
|
164
|
+
if (parsed.length > 0) {
|
|
165
|
+
models.push(...parsed);
|
|
166
|
+
sources.push('unorouter');
|
|
167
|
+
}
|
|
115
168
|
}
|
|
116
|
-
if (
|
|
169
|
+
if (dev.status === 'fulfilled') {
|
|
117
170
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
171
|
+
const parsed = parseModelsDev(JSON.parse(dev.value));
|
|
172
|
+
if (parsed.length > 0) {
|
|
173
|
+
models.push(...parsed);
|
|
174
|
+
sources.push('models.dev');
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
if (models.length === 0) {
|
|
179
|
+
const reason = dev.status === 'rejected'
|
|
180
|
+
? dev.reason?.message
|
|
181
|
+
: page.status === 'rejected'
|
|
182
|
+
? page.reason?.message
|
|
183
|
+
: 'catalogs parsed to 0 models';
|
|
184
|
+
return `${words.fail}: ${reason}`;
|
|
123
185
|
}
|
|
124
186
|
|
|
187
|
+
// The live page wins on duplicate ids; models.dev fills the rest.
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
models = models.filter((model) => {
|
|
190
|
+
const id = model.id.toLowerCase();
|
|
191
|
+
if (seen.has(id)) return false;
|
|
192
|
+
seen.add(id);
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
|
|
125
196
|
const wanted = String(filter ?? '').trim().toLowerCase();
|
|
126
|
-
let rows = models.filter((m) => (m.context ?? 0) >= 128000 || m.context === null);
|
|
197
|
+
let rows = models.filter((m) => ((m.context ?? 0) >= 128000 || m.context === null) && m.inputPrice > 0);
|
|
127
198
|
if (wanted) rows = rows.filter((m) => m.id.toLowerCase().includes(wanted));
|
|
128
199
|
rows.sort((a, b) => a.inputPrice - b.inputPrice);
|
|
129
200
|
|
|
130
|
-
const lines = [
|
|
131
|
-
if (options.model) {
|
|
132
|
-
const current = models
|
|
201
|
+
const lines = [`${words.prices}: ${sources.join(' + ')}`, ''];
|
|
202
|
+
if (options.model && options.model !== '?') {
|
|
203
|
+
const current = findModel(models, options.model);
|
|
204
|
+
const name = options.provider ? `${options.provider}/${options.model}` : options.model;
|
|
133
205
|
lines.push(
|
|
134
206
|
current
|
|
135
|
-
?
|
|
136
|
-
:
|
|
207
|
+
? `${words.now}: ${name} — ${usd(current.inputPrice)} / ${usd(current.outputPrice)} per 1M`
|
|
208
|
+
: `${words.now}: ${name} (${words.unknown})`,
|
|
137
209
|
''
|
|
138
210
|
);
|
|
139
211
|
}
|
|
140
|
-
lines.push(
|
|
212
|
+
lines.push(words.cheap);
|
|
141
213
|
for (const model of rows.slice(0, 12)) {
|
|
142
214
|
const context = model.context ? `${Math.round(model.context / 1000)}k` : '?';
|
|
143
215
|
lines.push(`• ${model.id} — ${usd(model.inputPrice)} / ${usd(model.outputPrice)} · ${context}`);
|
|
144
216
|
}
|
|
145
|
-
if (rows.length === 0) lines.push(
|
|
217
|
+
if (rows.length === 0) lines.push(words.none);
|
|
146
218
|
return lines.join('\n');
|
|
147
219
|
}
|