dsh-tg-bridge 1.3.3 → 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/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
 
@@ -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: {} };
@@ -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) => renderPrices(filter, { model: (config.vision && config.vision.model) || 'deepseek-v4.1-flash-1017' }),
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.primaryModel || 'deepseek-v4.1-flash-1017' };
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
@@ -143,7 +143,10 @@ function writeConfig({ userId, cwd, voice }, dryRun) {
143
143
  ...example,
144
144
  allowedUserIds: [Number(userId)],
145
145
  cwd,
146
- voice: { ...example.voice, enabled: Boolean(voice) },
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/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
- const child = spawn(DSH_CMD, ['plugin', '--profile', profile, ...args], {
208
- windowsHide: true,
209
- shell: false,
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 head = modelOf('headless');
373
- const desk = modelOf('desktop');
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
- say(
378
- [
379
- `Голова: ${config.profile} (${head.provider}/${head.model})`,
380
- `UI: desktop (${desk.provider}/${desk.model}) — плагинов ${deskPlugins.bundles.length}, отключено ${deskPlugins.disabled.length}`,
381
- `Ветка: ${ctx.threadId || 'общая'}`,
382
- `Сессия: ${session}`,
383
- `Ходов в работе: ${active}`,
384
- `Плагины: ${deskPlugins.bundles.length} | скиллы: ${skillsOf().length}`,
385
- `DSH_HOME: ${DSH_HOME}`,
386
- ].join('\n'),
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 head = modelOf('headless');
393
- const ids = modelsOf('headless');
402
+ const activeProfile = config.profile || 'headless';
403
+ const head = modelOf(activeProfile);
404
+ const ids = modelsOf(activeProfile);
394
405
  say(
395
- [`Модель сейчас: ${head.model}`, 'Доступные:', ...ids.map((id) => ` ${id === head.model ? '*' : ' '} ${id}`)].join('\n'),
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
- if (!setModel('headless', wanted)) {
407
- say(`Неизвестная модель «${wanted}». /models — список.`);
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(`Модель headless → ${wanted}${desktop ? ', desktop → ' + wanted : ''}. Действует со следующего хода.`);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tg-bridge",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Telegram bridge for DeepSeek Harness: text, voice, photos and project-per-topic sessions without opening the desktop app",
5
5
  "type": "module",
6
6
  "main": "bridge.mjs",
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
- * Render `/prices [filter]`.
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 }} options current route, for a "you are here" line
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
- let source = '';
110
- try {
111
- models = parseModelsPage(await fetchText(MODELS_PAGE));
112
- source = 'unorouter';
113
- } catch (error) {
114
- models = [];
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 (models.length === 0) {
169
+ if (dev.status === 'fulfilled') {
117
170
  try {
118
- models = parseModelsDev(JSON.parse(await fetchText(MODELS_DEV, 30000)));
119
- source = 'models.dev (страница unorouter недоступна)';
120
- } catch (error) {
121
- return `Не смог получить цены ни со страницы unorouter, ни с models.dev: ${error.message}`;
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 = [`Цены: ${source}`, ''];
131
- if (options.model) {
132
- const current = models.find((m) => m.id.toLowerCase().includes(String(options.model).toLowerCase()));
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
- ? `Сейчас: ${options.model} — вход ${usd(current.inputPrice)}, выход ${usd(current.outputPrice)} за 1M`
136
- : `Сейчас: ${options.model} (в каталоге не найден)`,
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('Дешёвые модели с контекстом ≥128k:');
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
  }