dsh-tg-bridge 1.2.10 → 1.3.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.md CHANGED
@@ -159,6 +159,31 @@ Telegram → bridge.mjs → dsh --profile headless --json → ответ
159
159
  процесс CLI, который умирает после ответа; поэтому «нет процесса DSH» между
160
160
  ходами — это норма, а не сбой.
161
161
 
162
+ ### Дополнение во время хода
163
+
164
+ Если написать в тему, пока ход ещё идёт, сообщение не встаёт в очередь за ним:
165
+ мост кладёт задание в `~/.dsh/tg-bridge/steer/`, плагин `dsh-steer-inbox`
166
+ отдаёт его живому агенту на следующем шаге, и тот сразу отвечает короткой
167
+ строкой в чат — что делает сейчас и куда поставил добавку. Если агент молчит
168
+ 90 секунд, мост сам пишет одну нейтральную строку, чтобы не было тишины.
169
+
170
+ Нужен плагин из этого репозитория:
171
+
172
+ ```bash
173
+ dsh plugin --profile headless add link:<путь-к-репозиторию>/plugin/steer-inbox
174
+ ```
175
+
176
+ и строка в `~/.dsh/profiles/headless/cordis.patch.yml`:
177
+
178
+ ```yaml
179
+ - insert:
180
+ - id: steer-inbox
181
+ name: 'dsh-steer-inbox'
182
+ ```
183
+
184
+ Мост сам проверяет профиль: если строки нет, он не пишет задание (иначе оно
185
+ просто истечёт), а ведёт себя как раньше — ставит сообщение в очередь.
186
+
162
187
  ## Безопасность
163
188
 
164
189
  - `config.json`, `state.json`, `pending/`, логи — в `.gitignore`, в репозиторий
package/bridge.mjs CHANGED
@@ -24,6 +24,7 @@ dns.setDefaultResultOrder('ipv4first');
24
24
  net.setDefaultAutoSelectFamily(false);
25
25
  import { noticeIsObsolete } from './queue-notice.mjs';
26
26
  import { immediateKind } from './immediate.mjs';
27
+ import { writeSteerJob } from './steer-queue.mjs';
27
28
  import { handleControl, CONTROL_COMMANDS } from './control.mjs';
28
29
  import { renderPrices } from './prices.mjs';
29
30
  import { t, resolveLang } from './i18n.mjs';
@@ -37,6 +38,10 @@ const STATE = join(DIR, 'state.json');
37
38
  const LOG = join(DIR, 'bridge.log');
38
39
  const CRED = join(DSH_HOME, '.credentials.yaml');
39
40
  const PENDING_DIR = join(DIR, 'pending');
41
+ const STEER_DIR = join(DIR, 'steer');
42
+ // The helper the agent calls to confirm an addition in the chat while the turn
43
+ // is still running. Lives next to the bridge so an installed copy works too.
44
+ const SAY = join(DIR, 'say.mjs');
40
45
  const DONE_FILE = join(DIR, 'done.json');
41
46
  const RESTART_MARKER = join(DIR, 'restart.pending');
42
47
  // `/restart confirm` leaves a note here for the process that replaces it: the
@@ -66,7 +71,24 @@ try {
66
71
  }
67
72
  } catch {}
68
73
  writeFileSync(PID_FILE, String(process.pid), 'utf8');
69
- process.on('exit', () => {
74
+ /**
75
+ * Silent deaths are undebuggable.
76
+ *
77
+ * A bridge launched by Task Scheduler has no console, so an uncaught error used
78
+ * to kill the poller with nothing in bridge.log but "bridge started" — from the
79
+ * outside the bot simply stopped answering. Every fatal path now leaves a line
80
+ * with the stack in bridge.log, and the exit hook records the code so a kill by
81
+ * Task Scheduler (0xC000013A) is distinguishable from a Node crash (1).
82
+ */
83
+ process.on('uncaughtException', (error) => {
84
+ log(`FATAL uncaught: ${error && error.stack ? error.stack : error}`);
85
+ process.exit(1);
86
+ });
87
+ process.on('unhandledRejection', (reason) => {
88
+ log(`FATAL unhandled rejection: ${reason && reason.stack ? reason.stack : reason}`);
89
+ });
90
+ process.on('exit', (code) => {
91
+ log(`bridge exiting code=${code}`);
70
92
  try {
71
93
  if (readFileSync(PID_FILE, 'utf8').trim() === String(process.pid)) unlinkSync(PID_FILE);
72
94
  } catch {}
@@ -80,6 +102,8 @@ const config = {
80
102
  };
81
103
  /** Turns currently running, keyed like sessions — `/stop` kills one. */
82
104
  const activeTurns = new Map();
105
+ /** Session id each running turn actually adopted, learned from its `session` event. */
106
+ const liveSessions = new Map();
83
107
  /** Wall-clock start of the running turn per session key, for the queue notice. */
84
108
  const turnStartedAt = new Map();
85
109
  try {
@@ -740,6 +764,9 @@ function runTurn(task, sessionId, key = '', onEvent = null, extraArgs = [], cwd
740
764
  child.on('close', (code) => {
741
765
  clearTimeout(timer);
742
766
  if (key) activeTurns.delete(key);
767
+ // No live process means no agent to steer; a job written now would wait
768
+ // for the next turn instead of the current one.
769
+ if (key) liveSessions.delete(key);
743
770
  // Keep the whole diagnostic: the CLI prints the useful phrase
744
771
  // ("pending (waiting for service: speechToText)") on the lines AFTER the
745
772
  // one-line summary, and trimming early hid the real cause from the chat.
@@ -1204,6 +1231,9 @@ ${t(lang, 'help.body')}`,
1204
1231
  const telemetry = { steps: 0, tools: 0, inputTokens: 0, cacheReadTokens: 0, model: config.primaryModel || 'deepseek-v4.1-flash-1017' };
1205
1232
  const onEvent = (event) => {
1206
1233
  try {
1234
+ // The id of the session this process actually works in. A new topic has
1235
+ // no id until the CLI emits one, and steering needs it before that.
1236
+ if (event.type === 'session' && event.sessionId) liveSessions.set(key, event.sessionId);
1207
1237
  progress.handle(event);
1208
1238
  if (event.type === 'tool_call') {
1209
1239
  telemetry.tools += 1;
@@ -1401,6 +1431,58 @@ async function clearBusyNotice(key) {
1401
1431
  }
1402
1432
  }
1403
1433
 
1434
+ /**
1435
+ * Whether this profile can receive mid-turn steers. The bridge only writes a
1436
+ * job when the `steer-inbox` plugin is actually wired into the profile; without
1437
+ * it the file would sit unread until it expired, and the message would be lost.
1438
+ * In that case the message queues the old way.
1439
+ */
1440
+ let steerReady;
1441
+ function steerAvailable() {
1442
+ if (steerReady !== undefined) return steerReady;
1443
+ try {
1444
+ const patch = readFileSync(join(DSH_HOME, 'profiles', config.profile || 'headless', 'cordis.patch.yml'), 'utf8');
1445
+ steerReady = /steer-inbox/.test(patch);
1446
+ } catch {
1447
+ steerReady = false;
1448
+ }
1449
+ return steerReady;
1450
+ }
1451
+
1452
+ /**
1453
+ * Hand a message to the turn that is already running.
1454
+ *
1455
+ * The turn is a separate process, so the bridge writes a job the `steer-inbox`
1456
+ * plugin picks up and feeds to `agent.steer()` — next-step input. The agent is
1457
+ * told (in the job, by the plugin) to send one short line into the chat via
1458
+ * say.mjs; the fallback below covers a model that stays silent.
1459
+ */
1460
+ function steerRunningTurn(msg, text, sessionId, thread) {
1461
+ const key = sessionKey(msg.chat.id, thread);
1462
+ const lang = resolveLang(state, key, msg, config);
1463
+ const job = writeSteerJob(STEER_DIR, {
1464
+ sessionId,
1465
+ text,
1466
+ chatId: msg.chat.id,
1467
+ threadId: thread,
1468
+ lang,
1469
+ say: SAY,
1470
+ });
1471
+ log(`steer ${job.id} -> ${sessionId} (${text.length} chars)`);
1472
+ const timer = setTimeout(async () => {
1473
+ try {
1474
+ if (existsSync(join(STEER_DIR, 'acks', `${job.id}.json`))) return;
1475
+ if (!activeTurns.has(key)) return; // turn ended; its answer is the reply
1476
+ log(`steer ${job.id} unacknowledged after 90s; sending fallback`);
1477
+ await send(msg.chat.id, t(lang, 'steer.ackFallback'), thread);
1478
+ } catch (error) {
1479
+ log(`steer fallback failed: ${error.message}`);
1480
+ }
1481
+ }, 90_000);
1482
+ timer.unref?.();
1483
+ return job;
1484
+ }
1485
+
1404
1486
  function enqueue(key, task, onBusy) {
1405
1487
  const prev = queues.get(key) || Promise.resolve();
1406
1488
  const queued = queues.has(key);
@@ -1715,6 +1797,22 @@ async function main() {
1715
1797
  .catch((error) => log(`command ${immediate} failed: ${error.message}`));
1716
1798
  return;
1717
1799
  }
1800
+ // A plain message during a running turn is an addition to that work, not a
1801
+ // new turn: steer it into the live agent instead of queueing it behind the
1802
+ // very turn it is meant to change.
1803
+ if (msg.text && !msg.text.trimStart().startsWith('/') && config.steerDuringTurn !== false && steerAvailable()) {
1804
+ const sessionId = liveSessions.get(queueKey) || state.sessions[queueKey];
1805
+ if (activeTurns.has(queueKey) && sessionId) {
1806
+ try {
1807
+ const job = steerRunningTurn(msg, msg.text.trim(), sessionId, thread);
1808
+ log(`steer handled outside the queue: ${job.id}`);
1809
+ ack(update.update_id);
1810
+ } catch (error) {
1811
+ log(`steer write failed: ${error.message}`);
1812
+ }
1813
+ return;
1814
+ }
1815
+ }
1718
1816
  enqueue(
1719
1817
  queueKey,
1720
1818
  async () => {
package/i18n.mjs CHANGED
@@ -25,16 +25,17 @@ const DICT = {
25
25
  'btn.retry': 'Retry',
26
26
  'btn.doctor': 'Doctor',
27
27
  'status.cache': 'cache',
28
- 'status.more': '…and {n} more',
29
- 'status.cacheLine': '🧠 cache: {pct}% ({k}k from cache)',
30
- 'msg.topicLog': '📌 New topic: {preview}\n→ work happens in the topic, this is the log.',
31
- 'msg.transcribing': '🎤 Transcribing…',
28
+ 'status.more': '…and {n} more',
29
+ 'status.cacheLine': '🧠 cache: {pct}% ({k}k from cache)',
30
+ 'msg.topicLog': '📌 New topic: {preview}\n→ work happens in the topic, this is the log.',
31
+ 'msg.transcribing': '🎤 Transcribing…',
32
32
  'status.steps': 'steps',
33
33
  'status.thinking': 'waiting for the model',
34
34
  'err.timeout': 'no activity for {min} min — turn interrupted',
35
35
  'err.generic': 'something went wrong',
36
- 'msg.busy.since': '⏳ The previous turn has been running for {min} min — your message is queued.\nIf it stalls, /stop interrupts it and I take yours right away.',
37
- 'msg.busy.now': '⏳ The previous turn is still running — your message is queued.\nIf it stalls, /stop interrupts it and I take yours right away.',
36
+ 'msg.busy.since': '⏳ The previous turn has been running for {min} min — your message is queued.\nIf it stalls, /stop interrupts it and I take yours right away.',
37
+ 'msg.busy.now': '⏳ The previous turn is still running — your message is queued.\nIf it stalls, /stop interrupts it and I take yours right away.',
38
+ 'steer.ackFallback': '↪ Got the addition — folding it into this turn.',
38
39
  'msg.started': 'Telegram bridge for DSH is running (headless). Send a task; /help for commands.',
39
40
  'msg.restarted': 'Bridge restarted — back online (pid {pid}).\nThe topic and session are the same; keep going.',
40
41
  'cmd.restart.prompt': 'To restart the bridge: /restart confirm. For the DSH app: /app restart confirm.',
@@ -81,20 +82,21 @@ const DICT = {
81
82
  'btn.retry': 'Повторить',
82
83
  'btn.doctor': 'Диагноз',
83
84
  'status.cache': 'кэш',
84
- 'status.more': '…и ещё {n}',
85
- 'status.cacheLine': '🧠 кэш: {pct}% ({k}k из кэша)',
86
- 'msg.topicLog': '📌 Новая тема: {preview}\n→ работа идёт в теме, здесь — лог.',
87
- 'msg.transcribing': '🎤 Расшифровываю…',
85
+ 'status.more': '…и ещё {n}',
86
+ 'status.cacheLine': '🧠 кэш: {pct}% ({k}k из кэша)',
87
+ 'msg.topicLog': '📌 Новая тема: {preview}\n→ работа идёт в теме, здесь — лог.',
88
+ 'msg.transcribing': '🎤 Расшифровываю…',
88
89
  'status.steps': 'шагов',
89
90
  'status.thinking': 'жду модель',
90
91
  'err.timeout': 'нет активности {min} мин — ход прерван',
91
92
  'err.generic': 'что-то пошло не так',
92
- 'msg.busy.since': '⏳ Предыдущий ход идёт уже {min} мин — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.',
93
- 'msg.busy.now': '⏳ Предыдущий ход ещё идёт — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.',
93
+ 'msg.busy.since': '⏳ Предыдущий ход идёт уже {min} мин — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.',
94
+ 'msg.busy.now': '⏳ Предыдущий ход ещё идёт — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.',
95
+ 'steer.ackFallback': '↪ Принял дополнение — учту его в этом же ходе.',
94
96
  'msg.started': 'Telegram-мост DSH запущен (headless). Пиши задачу; /help — команды.',
95
- 'msg.restarted': 'Мост перезапущен — на связи (pid {pid}).\nТема и сессия прежние, можно продолжать.',
96
- 'cmd.restart.prompt': 'Перезапуск моста: /restart confirm. Приложение DSH: /app restart confirm.',
97
- 'cmd.restart.started': 'Перезапускаю мост…',
97
+ 'msg.restarted': 'Мост перезапущен — на связи (pid {pid}).\nТема и сессия прежние, можно продолжать.',
98
+ 'cmd.restart.prompt': 'Перезапуск моста: /restart confirm. Приложение DSH: /app restart confirm.',
99
+ 'cmd.restart.started': 'Перезапускаю мост…',
98
100
  'msg.unknownCommand': 'Неизвестная команда: {cmd}. Открой /help — там всё, что умеет бот.',
99
101
  'msg.langSet': 'Язык переключён на русский.',
100
102
  'cmd.help.title': 'Команды',
@@ -137,20 +139,21 @@ const DICT = {
137
139
  'btn.retry': '重试',
138
140
  'btn.doctor': '诊断',
139
141
  'status.cache': '缓存',
140
- 'status.more': '…还有 {n} 条',
141
- 'status.cacheLine': '🧠 缓存:{pct}%({k}k 来自缓存)',
142
- 'msg.topicLog': '📌 新主题:{preview}\n→ 工作在主题里进行,这里是日志。',
143
- 'msg.transcribing': '🎤 正在转写…',
142
+ 'status.more': '…还有 {n} 条',
143
+ 'status.cacheLine': '🧠 缓存:{pct}%({k}k 来自缓存)',
144
+ 'msg.topicLog': '📌 新主题:{preview}\n→ 工作在主题里进行,这里是日志。',
145
+ 'msg.transcribing': '🎤 正在转写…',
144
146
  'status.steps': '步',
145
147
  'status.thinking': '等待模型',
146
148
  'err.timeout': '{min} 分钟无活动 — 已中断',
147
149
  'err.generic': '出现错误',
148
- 'msg.busy.since': '⏳ 上一轮已运行 {min} 分钟 —— 你的消息在队列中。\n如果太久,/stop 会中断它,我马上处理你的消息。',
149
- 'msg.busy.now': '⏳ 上一轮还在运行 —— 你的消息在队列中。\n如果太久,/stop 会中断它,我马上处理你的消息。',
150
+ 'msg.busy.since': '⏳ 上一轮已运行 {min} 分钟 —— 你的消息在队列中。\n如果太久,/stop 会中断它,我马上处理你的消息。',
151
+ 'msg.busy.now': '⏳ 上一轮还在运行 —— 你的消息在队列中。\n如果太久,/stop 会中断它,我马上处理你的消息。',
152
+ 'steer.ackFallback': '↪ 收到补充,会在本轮里一并处理。',
150
153
  'msg.started': 'DSH 的 Telegram 桥已启动(headless)。直接发送任务;/help 查看命令。',
151
- 'msg.restarted': '桥已重启,连接正常(pid {pid})。\n主题和会话不变,可以继续。',
152
- 'cmd.restart.prompt': '重启桥:/restart confirm。重启 DSH 应用:/app restart confirm。',
153
- 'cmd.restart.started': '正在重启桥…',
154
+ 'msg.restarted': '桥已重启,连接正常(pid {pid})。\n主题和会话不变,可以继续。',
155
+ 'cmd.restart.prompt': '重启桥:/restart confirm。重启 DSH 应用:/app restart confirm。',
156
+ 'cmd.restart.started': '正在重启桥…',
154
157
  'msg.unknownCommand': '未知命令:{cmd}。打开 /help 查看机器人支持的全部命令。',
155
158
  'msg.langSet': '界面语言已切换为中文。',
156
159
  'cmd.help.title': '命令',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tg-bridge",
3
- "version": "1.2.10",
3
+ "version": "1.3.0",
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",
@@ -46,6 +46,9 @@
46
46
  "config.example.json",
47
47
  "patches/",
48
48
  "queue-notice.mjs",
49
- "immediate.mjs"
49
+ "immediate.mjs",
50
+ "say.mjs",
51
+ "steer-queue.mjs",
52
+ "plugin/"
50
53
  ]
51
54
  }
@@ -0,0 +1,111 @@
1
+ // dsh-steer-inbox — file-based mid-turn steering for DSH.
2
+ //
3
+ // A turn is one process, so an outside caller (the Telegram bridge) cannot
4
+ // reach the running agent's inbox directly. This plugin runs inside that
5
+ // process, watches a directory, and hands each file to the live agent's
6
+ // `steer()` — next-step input, not a queued next turn — which is what makes
7
+ // "добавь ещё вот это" land inside the current work instead of after it.
8
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, appendFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ /** Cordis plugin name (loader row id is `steer-inbox`). */
13
+ const name = 'steer-inbox';
14
+ /** `ctx.agents` is the live-agent registry this plugin steers. */
15
+ const inject = ['agents'];
16
+
17
+ const HOME = homedir();
18
+ const DSH_HOME = process.env.DSH_HOME || join(HOME, '.dsh');
19
+ const DEFAULT_DIR = join(DSH_HOME, 'tg-bridge', 'steer');
20
+ const LOG_DIR = join(DSH_HOME, 'storages', 'steer-inbox');
21
+
22
+ function log(line) {
23
+ try {
24
+ mkdirSync(LOG_DIR, { recursive: true });
25
+ appendFileSync(join(LOG_DIR, 'steer.ndjson'), `${new Date().toISOString()} ${line}\n`, 'utf8');
26
+ } catch {}
27
+ }
28
+
29
+ function apply(ctx, config = {}) {
30
+ const dir = config.dir || DEFAULT_DIR;
31
+ const doneDir = join(dir, 'done');
32
+ try {
33
+ mkdirSync(dir, { recursive: true });
34
+ mkdirSync(doneDir, { recursive: true });
35
+ } catch {}
36
+
37
+ const tick = () => {
38
+ let files;
39
+ try {
40
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'));
41
+ } catch {
42
+ return;
43
+ }
44
+ for (const file of files) {
45
+ const full = join(dir, file);
46
+ let job;
47
+ try {
48
+ job = JSON.parse(readFileSync(full, 'utf8'));
49
+ } catch {
50
+ continue; // half-written; the writer renames into place atomically
51
+ }
52
+ // A job written for a turn that already ended will never match a live
53
+ // agent again; without this it would sit in the directory forever and
54
+ // could steer a much later session that reuses the id.
55
+ try {
56
+ if (Date.now() - statSync(full).mtimeMs > 10 * 60 * 1000) {
57
+ unlinkSync(full);
58
+ log(`expired ${file}`);
59
+ continue;
60
+ }
61
+ } catch {}
62
+ const handle = job.sessionId ? ctx.agents.get(job.sessionId) : undefined;
63
+ if (!handle) continue; // not this process; leave it for the right one
64
+ // `get()` has returned the Agent itself in practice and an AgentHandle in
65
+ // the docs; accept either instead of betting on one shape.
66
+ const agent = typeof handle.steer === 'function' ? handle : handle.agent;
67
+ if (typeof agent?.steer !== 'function') {
68
+ log(`no steer() on the live agent for ${job.sessionId}`);
69
+ continue;
70
+ }
71
+ try {
72
+ // The addition lands inside the current work. The agent is also told to
73
+ // send one short line into the chat right away — otherwise the person
74
+ // sees nothing until the very end of a long turn and assumes the
75
+ // addition was lost.
76
+ const sayPath = job.say || join(DSH_HOME, 'tg-bridge', 'say.mjs');
77
+ const line = [
78
+ '',
79
+ '---',
80
+ '[mid-turn addition from the person in Telegram]',
81
+ 'Before your next tool call, send them ONE short line in their own language: what you are doing now and where this addition fits.',
82
+ `Use exactly: node "${sayPath}" --steer ${job.id} "one short line"`,
83
+ 'Then continue the work and include this addition. Do not repeat that line in the final answer.',
84
+ ].join('\n');
85
+ agent.steer({
86
+ content: [{ type: 'text', text: `${String(job.text ?? '')}${line}` }],
87
+ source: { kind: 'tg-bridge' },
88
+ });
89
+ log(`steered ${job.sessionId} (${String(job.text ?? '').length} chars) id=${job.id}`);
90
+ try {
91
+ const target = join(doneDir, file);
92
+ try { unlinkSync(target); } catch {}
93
+ renameSync(full, target);
94
+ } catch {}
95
+ } catch (error) {
96
+ log(`steer failed for ${job.sessionId}: ${error?.message ?? error}`);
97
+ // A malformed job must not be retried forever.
98
+ try { renameSync(full, `${full}.failed`); } catch {}
99
+ }
100
+ }
101
+ };
102
+
103
+ ctx.effect(() => {
104
+ const timer = setInterval(tick, 500);
105
+ timer.unref?.();
106
+ log(`watching ${dir} (pid ${process.pid})`);
107
+ return () => clearInterval(timer);
108
+ }, 'steer-inbox.watch');
109
+ }
110
+
111
+ export { name, inject, apply };
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "dsh-steer-inbox",
3
+ "version": "0.1.0",
4
+ "description": "Watches ~/.dsh/tg-bridge/steer for JSON jobs and steers the live agent (next-step input) instead of queueing another turn.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "dsh": {
12
+ "compatibility": {
13
+ "dsh": ">=0.2.1-rc.1 <0.3.0",
14
+ "profiles": [
15
+ "headless",
16
+ "sdk"
17
+ ]
18
+ }
19
+ },
20
+ "license": "MIT"
21
+ }
package/say.mjs ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // say.mjs — one short line from the agent into the Telegram chat, mid-turn.
3
+ //
4
+ // The agent calls this right after it picks up a mid-turn addition, so the
5
+ // person sees "принял, делаю X, добавку поставлю после Y" while the turn is
6
+ // still running instead of silence until the end.
7
+ //
8
+ // node say.mjs --steer <job-id> "text"
9
+ // node say.mjs --chat <chat-id> [--thread <id>] "text"
10
+ //
11
+ // With `--steer` the chat/thread come from the job file and an ack marker is
12
+ // written, which suppresses the bridge's fallback line.
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
18
+ const STEER = join(DSH_HOME, 'tg-bridge', 'steer');
19
+ const ACKS = join(STEER, 'acks');
20
+
21
+ function findToken() {
22
+ const env = (process.env.DSH_TELEGRAM_BOT_TOKEN || '').trim();
23
+ if (env) return env;
24
+ try {
25
+ const text = readFileSync(join(DSH_HOME, '.credentials.yaml'), 'utf8');
26
+ const m = text.match(/^\s*(DSH_TELEGRAM_BOT_TOKEN[A-Z0-9_]*):\s*["']?([^"'\s]+)/m);
27
+ if (m) return m[2];
28
+ } catch {}
29
+ return '';
30
+ }
31
+
32
+ function jobOf(id) {
33
+ for (const path of [join(STEER, 'done', `${id}.json`), join(STEER, `${id}.json`)]) {
34
+ try {
35
+ if (existsSync(path)) return { path, job: JSON.parse(readFileSync(path, 'utf8')) };
36
+ } catch {}
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function usage() {
42
+ console.error('usage: node say.mjs --steer <id> "text" | --chat <id> [--thread <id>] "text"');
43
+ process.exit(2);
44
+ }
45
+
46
+ function parse(argv) {
47
+ const out = { chatId: 0, threadId: 0, steer: '', text: '' };
48
+ const rest = [];
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const arg = argv[i];
51
+ if (arg === '--steer') out.steer = argv[++i] || '';
52
+ else if (arg === '--chat') out.chatId = Number(argv[++i] || 0);
53
+ else if (arg === '--thread') out.threadId = Number(argv[++i] || 0);
54
+ else rest.push(arg);
55
+ }
56
+ out.text = rest.join(' ').trim();
57
+ return out;
58
+ }
59
+
60
+ async function main() {
61
+ const args = parse(process.argv.slice(2));
62
+ if (!args.text) usage();
63
+
64
+ let chatId = args.chatId;
65
+ let threadId = args.threadId;
66
+ let jobPath = '';
67
+ if (args.steer) {
68
+ const found = jobOf(args.steer);
69
+ if (!found) {
70
+ console.error(`steer job ${args.steer} not found — the turn may have ended`);
71
+ process.exit(3);
72
+ }
73
+ chatId = Number(found.job.chatId || 0);
74
+ threadId = Number(found.job.threadId || 0);
75
+ jobPath = found.path;
76
+ }
77
+ if (!chatId) usage();
78
+
79
+ const token = findToken();
80
+ if (!token) {
81
+ console.error('bot token not found (DSH_TELEGRAM_BOT_TOKEN or ~/.dsh/.credentials.yaml)');
82
+ process.exit(5);
83
+ }
84
+
85
+ const body = { chat_id: chatId, text: args.text, disable_web_page_preview: true };
86
+ if (threadId > 0) body.message_thread_id = threadId;
87
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
88
+ method: 'POST',
89
+ headers: { 'content-type': 'application/json' },
90
+ body: JSON.stringify(body),
91
+ });
92
+ const data = await res.json().catch(() => ({}));
93
+ if (!data.ok) {
94
+ console.error(`telegram: ${data.description || res.status}`);
95
+ process.exit(4);
96
+ }
97
+ if (args.steer) {
98
+ try {
99
+ mkdirSync(ACKS, { recursive: true });
100
+ writeFileSync(join(ACKS, `${args.steer}.json`), JSON.stringify({ at: Date.now(), messageId: data.result.message_id, job: jobPath }), 'utf8');
101
+ } catch {}
102
+ }
103
+ console.log(`sent ${data.result.message_id}`);
104
+ }
105
+
106
+ main().catch((error) => {
107
+ console.error(String(error?.message ?? error));
108
+ process.exit(1);
109
+ });
@@ -0,0 +1,32 @@
1
+ // Writing a mid-turn steer job.
2
+ //
3
+ // The bridge cannot reach the agent's inbox: a turn is a separate process. It
4
+ // drops a JSON file into `~/.dsh/tg-bridge/steer/`, the `steer-inbox` plugin
5
+ // notices it within 500 ms and calls `agent.steer()` — next-step input, so the
6
+ // addition lands in the work already running instead of behind it.
7
+ //
8
+ // The file is written to a dot-prefixed temp name and renamed into place, so
9
+ // the plugin can never read half a job.
10
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { join } from 'node:path';
13
+
14
+ export function newSteerId() {
15
+ return `${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
16
+ }
17
+
18
+ /**
19
+ * @param {string} dir steer directory
20
+ * @param {object} job {sessionId, text, chatId, threadId, lang, say}
21
+ * @returns {object} the written job, including its `id` and `at`
22
+ */
23
+ export function writeSteerJob(dir, job) {
24
+ const id = job.id || newSteerId();
25
+ const payload = { ...job, id, at: Date.now() };
26
+ mkdirSync(dir, { recursive: true });
27
+ const final = join(dir, `${id}.json`);
28
+ const tmp = join(dir, `.${id}.tmp`);
29
+ writeFileSync(tmp, JSON.stringify(payload), 'utf8');
30
+ renameSync(tmp, final);
31
+ return payload;
32
+ }