dsh-tg-bridge 1.2.9 → 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 +25 -0
- package/bridge.mjs +102 -23
- package/i18n.mjs +27 -24
- package/immediate.mjs +56 -0
- package/package.json +6 -2
- package/plugin/steer-inbox/index.js +111 -0
- package/plugin/steer-inbox/package.json +21 -0
- package/say.mjs +109 -0
- package/steer-queue.mjs +32 -0
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
|
@@ -23,6 +23,8 @@ import net from 'node:net';
|
|
|
23
23
|
dns.setDefaultResultOrder('ipv4first');
|
|
24
24
|
net.setDefaultAutoSelectFamily(false);
|
|
25
25
|
import { noticeIsObsolete } from './queue-notice.mjs';
|
|
26
|
+
import { immediateKind } from './immediate.mjs';
|
|
27
|
+
import { writeSteerJob } from './steer-queue.mjs';
|
|
26
28
|
import { handleControl, CONTROL_COMMANDS } from './control.mjs';
|
|
27
29
|
import { renderPrices } from './prices.mjs';
|
|
28
30
|
import { t, resolveLang } from './i18n.mjs';
|
|
@@ -36,6 +38,10 @@ const STATE = join(DIR, 'state.json');
|
|
|
36
38
|
const LOG = join(DIR, 'bridge.log');
|
|
37
39
|
const CRED = join(DSH_HOME, '.credentials.yaml');
|
|
38
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');
|
|
39
45
|
const DONE_FILE = join(DIR, 'done.json');
|
|
40
46
|
const RESTART_MARKER = join(DIR, 'restart.pending');
|
|
41
47
|
// `/restart confirm` leaves a note here for the process that replaces it: the
|
|
@@ -65,7 +71,24 @@ try {
|
|
|
65
71
|
}
|
|
66
72
|
} catch {}
|
|
67
73
|
writeFileSync(PID_FILE, String(process.pid), 'utf8');
|
|
68
|
-
|
|
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}`);
|
|
69
92
|
try {
|
|
70
93
|
if (readFileSync(PID_FILE, 'utf8').trim() === String(process.pid)) unlinkSync(PID_FILE);
|
|
71
94
|
} catch {}
|
|
@@ -79,6 +102,8 @@ const config = {
|
|
|
79
102
|
};
|
|
80
103
|
/** Turns currently running, keyed like sessions — `/stop` kills one. */
|
|
81
104
|
const activeTurns = new Map();
|
|
105
|
+
/** Session id each running turn actually adopted, learned from its `session` event. */
|
|
106
|
+
const liveSessions = new Map();
|
|
82
107
|
/** Wall-clock start of the running turn per session key, for the queue notice. */
|
|
83
108
|
const turnStartedAt = new Map();
|
|
84
109
|
try {
|
|
@@ -474,33 +499,13 @@ function commandOf(text) {
|
|
|
474
499
|
return `/${match[1].toLowerCase().replace(/[.-]+/g, '')}`;
|
|
475
500
|
}
|
|
476
501
|
|
|
477
|
-
/**
|
|
478
|
-
* Commands that must run *now*, not behind the turn they are about.
|
|
479
|
-
*
|
|
480
|
-
* Everything in a topic used to be queued, so `/stop` waited for the very turn
|
|
481
|
-
* it was supposed to kill — on 2026-10-24 a 26-minute turn swallowed `/stop`
|
|
482
|
-
* and the person got "ход уже идёт" instead of an interruption. These few
|
|
483
|
-
* commands touch no turn state, so they can run concurrently with one.
|
|
484
|
-
*/
|
|
485
|
-
const IMMEDIATE_COMMANDS = new Set([
|
|
486
|
-
'stop',
|
|
487
|
-
'logs',
|
|
488
|
-
'status',
|
|
489
|
-
'help',
|
|
490
|
-
'doctor',
|
|
491
|
-
'restart',
|
|
492
|
-
]);
|
|
493
|
-
|
|
494
502
|
/** Canonical immediate command name for a message, or '' when it must queue. */
|
|
495
503
|
function immediateCommandOf(text) {
|
|
496
504
|
const trimmed = String(text ?? '').trim();
|
|
497
505
|
if (!trimmed.startsWith('/')) return '';
|
|
498
|
-
const head = trimmed.split(/\s+/)
|
|
506
|
+
const [head, ...rest] = trimmed.split(/\s+/);
|
|
499
507
|
const collapsed = commandOf(head).replace(/^\//, '');
|
|
500
|
-
|
|
501
|
-
return 'restart';
|
|
502
|
-
}
|
|
503
|
-
return IMMEDIATE_COMMANDS.has(collapsed) ? collapsed : '';
|
|
508
|
+
return immediateKind(collapsed, rest);
|
|
504
509
|
}
|
|
505
510
|
|
|
506
511
|
/**
|
|
@@ -759,6 +764,9 @@ function runTurn(task, sessionId, key = '', onEvent = null, extraArgs = [], cwd
|
|
|
759
764
|
child.on('close', (code) => {
|
|
760
765
|
clearTimeout(timer);
|
|
761
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);
|
|
762
770
|
// Keep the whole diagnostic: the CLI prints the useful phrase
|
|
763
771
|
// ("pending (waiting for service: speechToText)") on the lines AFTER the
|
|
764
772
|
// one-line summary, and trimming early hid the real cause from the chat.
|
|
@@ -1223,6 +1231,9 @@ ${t(lang, 'help.body')}`,
|
|
|
1223
1231
|
const telemetry = { steps: 0, tools: 0, inputTokens: 0, cacheReadTokens: 0, model: config.primaryModel || 'deepseek-v4.1-flash-1017' };
|
|
1224
1232
|
const onEvent = (event) => {
|
|
1225
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);
|
|
1226
1237
|
progress.handle(event);
|
|
1227
1238
|
if (event.type === 'tool_call') {
|
|
1228
1239
|
telemetry.tools += 1;
|
|
@@ -1420,6 +1431,58 @@ async function clearBusyNotice(key) {
|
|
|
1420
1431
|
}
|
|
1421
1432
|
}
|
|
1422
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
|
+
|
|
1423
1486
|
function enqueue(key, task, onBusy) {
|
|
1424
1487
|
const prev = queues.get(key) || Promise.resolve();
|
|
1425
1488
|
const queued = queues.has(key);
|
|
@@ -1734,6 +1797,22 @@ async function main() {
|
|
|
1734
1797
|
.catch((error) => log(`command ${immediate} failed: ${error.message}`));
|
|
1735
1798
|
return;
|
|
1736
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
|
+
}
|
|
1737
1816
|
enqueue(
|
|
1738
1817
|
queueKey,
|
|
1739
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/immediate.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Which control commands may run while a turn is still working?
|
|
2
|
+
//
|
|
3
|
+
// Everything in a topic used to be queued, so `/stop` waited for the very turn
|
|
4
|
+
// it was supposed to kill. The rule here is not "important commands", it is
|
|
5
|
+
// "commands that neither read nor write the state of the running turn".
|
|
6
|
+
//
|
|
7
|
+
// Groups:
|
|
8
|
+
// * always immediate — read-only reports and turn control;
|
|
9
|
+
// * immediate only without arguments — the bare form reports, the form with
|
|
10
|
+
// an argument changes state (`/soul <text>`, `/lang en`);
|
|
11
|
+
// * `/plugins` without an action — the list; with add/remove/disable/enable
|
|
12
|
+
// it installs plugins, which must queue like any other mutation;
|
|
13
|
+
// * everything else queues, because it changes the session the turn is using
|
|
14
|
+
// (`/model`, `/new`, `/resume`, `/cd`, `/send`, `/seed`, `/plugin`).
|
|
15
|
+
|
|
16
|
+
const PLUGIN_ACTIONS = new Set(['add', 'remove', 'disable', 'enable']);
|
|
17
|
+
|
|
18
|
+
const ALWAYS = new Set([
|
|
19
|
+
// Turn control.
|
|
20
|
+
'stop',
|
|
21
|
+
'restart',
|
|
22
|
+
// Reports: they read profiles, sessions, skills and the price table, but
|
|
23
|
+
// touch neither the queue nor the session of the running turn.
|
|
24
|
+
'status',
|
|
25
|
+
'logs',
|
|
26
|
+
'help',
|
|
27
|
+
'doctor',
|
|
28
|
+
'models',
|
|
29
|
+
'sessions',
|
|
30
|
+
'skills',
|
|
31
|
+
'prices',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const BARE_ONLY = new Set([
|
|
35
|
+
'soul', // `/soul` reads AGENTS.md, `/soul <text>` rewrites it
|
|
36
|
+
'lang', // `/lang` reports the current language, `/lang en` switches it
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Canonical immediate command name, or '' when the message must queue.
|
|
41
|
+
* `name` is the command without the slash and without a bot mention,
|
|
42
|
+
* `args` is everything after it, already split.
|
|
43
|
+
*/
|
|
44
|
+
export function immediateKind(name, args = []) {
|
|
45
|
+
const command = String(name ?? '').toLowerCase();
|
|
46
|
+
if (command === 'apprestart' || command === 'apprestartconfirm' || command === 'restartconfirm') {
|
|
47
|
+
return 'restart';
|
|
48
|
+
}
|
|
49
|
+
if (ALWAYS.has(command)) return command;
|
|
50
|
+
if (command === 'plugins') {
|
|
51
|
+
const [action = ''] = args;
|
|
52
|
+
return PLUGIN_ACTIONS.has(String(action).toLowerCase()) ? '' : 'plugins';
|
|
53
|
+
}
|
|
54
|
+
if (BARE_ONLY.has(command) && args.length === 0) return command;
|
|
55
|
+
return '';
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tg-bridge",
|
|
3
|
-
"version": "1.
|
|
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",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"LICENSE",
|
|
46
46
|
"config.example.json",
|
|
47
47
|
"patches/",
|
|
48
|
-
"queue-notice.mjs"
|
|
48
|
+
"queue-notice.mjs",
|
|
49
|
+
"immediate.mjs",
|
|
50
|
+
"say.mjs",
|
|
51
|
+
"steer-queue.mjs",
|
|
52
|
+
"plugin/"
|
|
49
53
|
]
|
|
50
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
|
+
});
|
package/steer-queue.mjs
ADDED
|
@@ -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
|
+
}
|