@soffits/dsh-telegram-bot 0.5.0 → 0.6.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/MAINTAINING.md +2 -1
- package/README.md +10 -1
- package/package.json +1 -1
- package/src/agents.js +440 -0
- package/src/app.js +140 -1
- package/src/constants.js +2 -0
package/MAINTAINING.md
CHANGED
|
@@ -18,6 +18,7 @@ This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github
|
|
|
18
18
|
- Models (`src/models.js`): the session's agent owns its model (`agent.model.current()`); the bot keeps no copy, because a copy drifted on fallback switches and Web UI picks and was carried into `/new`. Sessions are created and resumed through `sessionController.create` / `resolveAgent` (the Web UI's own composition); the raw `agents` registry is only the headless fallback. The picker reads `sessionController.modelCatalog()` and applies picks with `sessionController.selectModel`, which also saves the default, as a Web pick does. `/new` starts the new session on the previous session's current model.
|
|
19
19
|
- Lifecycle notices go to a chat fixed by config (`controlChatOf` in `src/utils.js`: `controlChat`, else the first allowed user's private chat). Upstream moves its control chat to whoever wrote last and stores it in `topics.json`; the fork dropped that file.
|
|
20
20
|
- Final replies keep only the answer (`src/final-text.js`): text written between tool calls streams as a live draft, but at the turn's end the reply is rebuilt from the session's durable messages, keeping the last text block of each message that called no tool. Mirrored turns use the same rule.
|
|
21
|
+
- Subagent activity (`src/agents.js`): `subagent/start` and `subagent/end` (global listeners) drive one status message per topic turn, found by following `session.header.parentSession` from the child up to a bound session. A board is keyed by the topic's `turnEpoch` and opens only while the topic shows a turn (`busy` and not `done`); a nested child joins the board that lists its nearest ancestor. Edits are coalesced (1.5 s) and serialized per board, a running board ticks once a minute but edits only when the text changes, and a board whose children are all done is frozen. The description is the parent catalog's `label` (the delegation's `description`), read again at each edit until found. `/agents` walks `subagents.listChildren` (durable catalogs, bounded reads) and reads each child's `subagentTiming` and `modelSelection` projections and its log for the last `send_message` or tool-free answer.
|
|
21
22
|
- Reply, quote and forward context: the agent sees what a message replies to, quotes or forwards, and forwards sent together become one prompt.
|
|
22
23
|
|
|
23
24
|
Upstream changes are merged in, not rebased, so the history of both lines stays intact.
|
|
@@ -36,4 +37,4 @@ git push origin main
|
|
|
36
37
|
|
|
37
38
|
Conflicts usually land in `src/app.js` (most of the bridge lives there), `README.md` and the `name` and `version` lines of `package.json`. Keep the fork's own name and version: it is published on npm as `@soffits/dsh-telegram-bot` with its own semver line (0.4.0 was the first release, on top of upstream 0.3.0), so bump the minor version for features and upstream merges and the patch version for fixes, and name the merged upstream version in the merge or release commit. Make sure `client.js` still matches `src/client.js` after the merge (`cmp client.js src/client.js`).
|
|
38
39
|
|
|
39
|
-
Fork-only code is kept in separate files where that is natural (`src/topics.js`, `src/outbox.js`, `src/message-context.js`, `src/sent-log.js`, `src/voice.js`, `src/steer.js`, `src/sender.js`, `src/models.js`, `src/final-text.js`, `bin/dsh-telegram-send.js`, `test/fake-dsh.js`, `test/fork-features.test.js`, `test/message-context.test.js`, `test/sent-log.test.js`, `test/steer.test.js`, `test/sender.test.js`, `test/models.test.js`, `test/final-text.test.js`, `test/voice.test.js`), so most upstream edits apply without touching it.
|
|
40
|
+
Fork-only code is kept in separate files where that is natural (`src/topics.js`, `src/outbox.js`, `src/message-context.js`, `src/sent-log.js`, `src/voice.js`, `src/steer.js`, `src/sender.js`, `src/models.js`, `src/final-text.js`, `src/agents.js`, `bin/dsh-telegram-send.js`, `test/fake-dsh.js`, `test/fork-features.test.js`, `test/message-context.test.js`, `test/sent-log.test.js`, `test/steer.test.js`, `test/sender.test.js`, `test/models.test.js`, `test/final-text.test.js`, `test/agents.test.js`, `test/voice.test.js`), so most upstream edits apply without touching it.
|
package/README.md
CHANGED
|
@@ -37,6 +37,15 @@ BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users
|
|
|
37
37
|
- Automatic goal rounds of a topic's session are shown in the topic like a Telegram turn, with thinking, tool calls and the final answer, also when **Show turns from other clients** is off. Their approvals and questions are answered in the topic.
|
|
38
38
|
- While a turn runs in a topic's session (started from Telegram or shown from another client), that topic shows "typing…". Each topic shows its own; it stops when the turn ends, fails or is stopped.
|
|
39
39
|
- Reasoning and tool calls are shown as temporary messages and removed after the answer. Each tool-call entry is capped at two lines.
|
|
40
|
+
- Subagents show in the topic. When a turn the topic shows starts subagents, one quiet status message lists them, each with its description, model and running time, and is edited in place as they start and finish (✓ done, ✗ failed with the reason). Changes that land within about 1.5 seconds share one edit, and running times are refreshed at most once a minute, which keeps well inside Telegram's edit limits. Subagents started by subagents are listed under their parent. Once all of them are done the message stays as it is, and the next subagent opens a new one. For example:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
🤖 3 subagents · 1 running
|
|
44
|
+
✓ Compare flight prices · claude-opus-5-5 · 2m 14s
|
|
45
|
+
↳ ✓ Check baggage rules · claude-haiku-4-5 · 41s
|
|
46
|
+
⏳ Find a hotel near the venue · deepseek-v4-flash · 3m
|
|
47
|
+
```
|
|
48
|
+
- `/agents` lists the session's running and most recent subagents (up to eight, nested ones included), with their state, model, time, when they started and the beginning of their last report (the last message they sent their parent, or their final answer). It reads DSH's saved sessions, so it also covers subagents from before a restart; one cut off by a restart shows as interrupted.
|
|
40
49
|
- Inline controls for model, reasoning effort, permission mode and context usage. The model picker offers every provider and model of the Web UI's model picker (provider first, then its models, with paging) and switches the session the way the Web UI does, which also makes the pick the default for new sessions. The panel always shows the model the session runs now; `/new` starts the next session on that model.
|
|
41
50
|
- Tool approvals with **Deny** / **Allow** buttons.
|
|
42
51
|
- `ask_user_question` support: options, multi-select, custom answers and plan review.
|
|
@@ -45,7 +54,7 @@ BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users
|
|
|
45
54
|
- Every prompt from Telegram starts with a line naming the sender, such as `[Telegram message from Ana Silva (@ana, user id 123)]`, so the agent knows who is writing when several allowed users share the bot. Prompts typed in the Web UI are not labelled. The sender is also recorded as data on the prompt's source as `principal`, the field DSH uses for the caller behind a user message: `{ kind: 'telegram-user', channel: 'telegram', id: '123', name: 'Ana Silva', username: 'ana', chat: { id, thread, name } }`, where `chat` is the chat, its topic thread and the topic's name. Scripts that read session logs can use it instead of parsing the line.
|
|
46
55
|
- Replies, quotes and forwards keep their context. The agent sees the text of the replied-to message (or only the quoted part), who wrote it and when; a replied-to photo or document is attached too. A forwarded message is labelled with its origin (person, hidden account, group or channel with a message link) and original date. Several messages forwarded together become one prompt, and forwarded text never runs as a command. Long quoted text is cut at about 2,000 characters with a note.
|
|
47
56
|
- Replies to messages sent into a topic by a script, job, plugin or another session (see [Send to a topic](#send-to-a-topic)) also carry the full original text, when it was sent, and the sending session and job, even if the topic has moved to a new session since.
|
|
48
|
-
- `/new`, `/workspace`, `/last`, `/goal`, `/rename`, `/topic`, `/fork`, `/clear`, `/stop`, `/restart`, `/status` and `/menu`.
|
|
57
|
+
- `/new`, `/workspace`, `/last`, `/agents`, `/goal`, `/rename`, `/topic`, `/fork`, `/clear`, `/stop`, `/restart`, `/status` and `/menu`.
|
|
49
58
|
- `/goal` shows the current DSH goal and the command hints. `/goal <objective>` creates or replaces the goal and starts working immediately; `/goal edit <objective>` updates the objective and resumes a paused, blocked or disarmed goal; `/goal pause`, `/goal resume` and `/goal clear` manage the current goal. `/goal <objective>` and `/goal edit <objective>` replace the current panel with one fresh message; later goal changes edit that message in place. The panel is never pinned.
|
|
50
59
|
- `/new` in a session topic starts a fresh DSH session in the same workspace and binds it to the same topic; the previous session stays in DSH. In All it opens a new session topic.
|
|
51
60
|
- One session is bound to at most one topic: binding it to a new topic releases the old one.
|
package/package.json
CHANGED
package/src/agents.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent activity in Telegram topics.
|
|
3
|
+
*
|
|
4
|
+
* When a turn the topic is showing starts subagents, one status message lists
|
|
5
|
+
* them (description, model, elapsed time) and is edited in place as they
|
|
6
|
+
* start and finish. Changes that land close together share one edit, and a
|
|
7
|
+
* running board is refreshed at most once a minute so its times move. Once
|
|
8
|
+
* every subagent on it is done the message is left as it is; the next
|
|
9
|
+
* subagent opens a new one. Subagents started by subagents count under the
|
|
10
|
+
* topic of the session at the top of the chain.
|
|
11
|
+
*
|
|
12
|
+
* `/agents` lists a session's subagents from DSH's durable catalogs, so it
|
|
13
|
+
* also works after a restart.
|
|
14
|
+
*/
|
|
15
|
+
import { messageAnswer } from './final-text.js'
|
|
16
|
+
|
|
17
|
+
/** Pause that lets several starts or ends share one edit. */
|
|
18
|
+
export const EDIT_DELAY_MS = 1500
|
|
19
|
+
/** How often a board with running subagents is refreshed for its times. */
|
|
20
|
+
export const TICK_MS = 60_000
|
|
21
|
+
/** Parent links followed from a subagent up to a bound session. */
|
|
22
|
+
const MAX_DEPTH = 8
|
|
23
|
+
/** Subagents `/agents` lists at most, running ones first. */
|
|
24
|
+
export const LIST_LIMIT = 8
|
|
25
|
+
/** Lines a status message shows at most; older finished lines are folded. */
|
|
26
|
+
const MAX_LINES = 25
|
|
27
|
+
/** Longest description shown on a line. */
|
|
28
|
+
const LABEL_MAX = 60
|
|
29
|
+
/** Longest report preview `/agents` shows per subagent. */
|
|
30
|
+
export const REPORT_MAX = 240
|
|
31
|
+
|
|
32
|
+
/** @returns the text cut to `max` characters with an ellipsis. */
|
|
33
|
+
export function clip(text, max) {
|
|
34
|
+
const value = String(text ?? '')
|
|
35
|
+
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format a duration. Running times are shown in whole minutes, so a board
|
|
40
|
+
* refreshed once a minute stays accurate; finished times also show seconds.
|
|
41
|
+
* @param ms - duration in milliseconds.
|
|
42
|
+
* @param precise - include seconds.
|
|
43
|
+
*/
|
|
44
|
+
export function formatElapsed(ms, precise) {
|
|
45
|
+
const seconds = Math.max(0, Math.floor(ms / 1000))
|
|
46
|
+
const minutes = Math.floor(seconds / 60)
|
|
47
|
+
const hours = Math.floor(minutes / 60)
|
|
48
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
|
49
|
+
if (!precise) return minutes < 1 ? '<1m' : `${minutes}m`
|
|
50
|
+
if (minutes < 1) return `${seconds}s`
|
|
51
|
+
return `${minutes}m ${seconds % 60}s`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @returns true when a finished run did not complete normally. */
|
|
55
|
+
function failed(stopReason) {
|
|
56
|
+
return stopReason !== undefined && stopReason !== 'completed'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns the reason text for a failed run (`max_tokens` → `max tokens`). */
|
|
60
|
+
function reasonText(stopReason) {
|
|
61
|
+
return String(stopReason).replace(/[_-]+/gu, ' ')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The status text of one board.
|
|
66
|
+
* @param entries - `{ label, model, depth, startedAt, endedAt, stopReason }` in start order.
|
|
67
|
+
* @param now - current time in ms.
|
|
68
|
+
* @param tr - `(en, ru) => text` for the bot language.
|
|
69
|
+
*/
|
|
70
|
+
export function boardText(entries, now, tr = (en) => en) {
|
|
71
|
+
const running = entries.filter((entry) => entry.endedAt === undefined).length
|
|
72
|
+
const failures = entries.filter((entry) => entry.endedAt !== undefined && failed(entry.stopReason)).length
|
|
73
|
+
const total = entries.length
|
|
74
|
+
const noun = tr(total === 1 ? 'subagent' : 'subagents', 'субагент(ов)')
|
|
75
|
+
let head
|
|
76
|
+
if (running > 0) head = `🤖 ${total} ${noun} · ${running} ${tr('running', 'работают')}`
|
|
77
|
+
else if (failures > 0) head = `🤖 ${total} ${noun} · ${tr('done', 'готово')}, ${failures} ${tr('failed', 'с ошибкой')}`
|
|
78
|
+
else head = `🤖 ${total} ${noun} · ${tr('all done', 'все готовы')}`
|
|
79
|
+
const lines = entries.map((entry) => {
|
|
80
|
+
const indent = entry.depth > 1 ? `${' '.repeat(entry.depth - 2)}↳ ` : ''
|
|
81
|
+
const parts = [clip(entry.label, LABEL_MAX)]
|
|
82
|
+
if (entry.model) parts.push(entry.model)
|
|
83
|
+
if (entry.endedAt === undefined) {
|
|
84
|
+
parts.push(formatElapsed(now - entry.startedAt, false))
|
|
85
|
+
return `${indent}⏳ ${parts.join(' · ')}`
|
|
86
|
+
}
|
|
87
|
+
parts.push(formatElapsed(entry.endedAt - entry.startedAt, true))
|
|
88
|
+
if (failed(entry.stopReason)) parts.push(reasonText(entry.stopReason))
|
|
89
|
+
return `${indent}${failed(entry.stopReason) ? '✗' : '✓'} ${parts.join(' · ')}`
|
|
90
|
+
})
|
|
91
|
+
if (lines.length > MAX_LINES) {
|
|
92
|
+
// Keep running lines and the newest ones; fold the oldest finished lines.
|
|
93
|
+
const keep = new Set()
|
|
94
|
+
entries.forEach((entry, index) => { if (entry.endedAt === undefined) keep.add(index) })
|
|
95
|
+
for (let index = entries.length - 1; index >= 0 && keep.size < MAX_LINES - 1; index -= 1) keep.add(index)
|
|
96
|
+
const folded = lines.length - keep.size
|
|
97
|
+
const shown = lines.filter((_line, index) => keep.has(index))
|
|
98
|
+
return [head, `… ${folded} ${tr('earlier finished', 'завершённых ранее')}`, ...shown].join('\n')
|
|
99
|
+
}
|
|
100
|
+
return [head, ...lines].join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns whether a tool name is the subagent reporting tool (`send_message`, maybe prefixed). */
|
|
104
|
+
function isReportTool(name) {
|
|
105
|
+
return typeof name === 'string' && (name === 'send_message' || name.endsWith('__send_message'))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @returns the `message` argument of a tool call, or ''. */
|
|
109
|
+
function reportArgument(block) {
|
|
110
|
+
let args = block?.arguments ?? block?.input
|
|
111
|
+
if (typeof args === 'string') {
|
|
112
|
+
try {
|
|
113
|
+
args = JSON.parse(args)
|
|
114
|
+
} catch {
|
|
115
|
+
return ''
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return typeof args?.message === 'string' ? args.message.trim() : ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The latest report a subagent left: a message it sent its parent, or the
|
|
123
|
+
* answer of its last tool-free message, whichever is newer.
|
|
124
|
+
* @param events - the child's session events (`{ type, data }`).
|
|
125
|
+
* @returns the report text, or ''.
|
|
126
|
+
*/
|
|
127
|
+
export function reportOf(events) {
|
|
128
|
+
if (!Array.isArray(events)) return ''
|
|
129
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
130
|
+
const event = events[index]
|
|
131
|
+
if (event?.type !== 'assistant/message') continue
|
|
132
|
+
const message = event.data?.message ?? event.data
|
|
133
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
134
|
+
for (let block = content.length - 1; block >= 0; block -= 1) {
|
|
135
|
+
const item = content[block]
|
|
136
|
+
if (item?.type === 'tool-call' && isReportTool(item.name)) {
|
|
137
|
+
const text = reportArgument(item)
|
|
138
|
+
if (text.length > 0) return text
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const answer = messageAnswer(message)
|
|
142
|
+
if (answer.length > 0) return answer
|
|
143
|
+
}
|
|
144
|
+
return ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* State of one listed subagent, from its live agent and durable timing.
|
|
149
|
+
* @param facts - `{ status, timing }`: live agent status (or undefined when not loaded) and the `subagentTiming` view.
|
|
150
|
+
* @param now - current time in ms.
|
|
151
|
+
* @returns `{ state, elapsedMs }`; state is running, done, failed, interrupted or idle.
|
|
152
|
+
*/
|
|
153
|
+
export function childState({ status, timing }, now) {
|
|
154
|
+
const settled = Number.isFinite(timing?.settledMs) ? timing.settledMs : 0
|
|
155
|
+
const active = timing?.active
|
|
156
|
+
if (status === 'running') {
|
|
157
|
+
return { state: 'running', elapsedMs: active === undefined ? settled : settled + Math.max(0, now - active.since) }
|
|
158
|
+
}
|
|
159
|
+
if (active !== undefined) {
|
|
160
|
+
// A turn left open with no live agent was cut off, usually by a restart.
|
|
161
|
+
return { state: status === undefined ? 'interrupted' : 'running', elapsedMs: settled + Math.max(0, (status === undefined ? active.through : now) - active.since) }
|
|
162
|
+
}
|
|
163
|
+
if (timing?.lastTurnCompleted === false) return { state: 'failed', elapsedMs: settled }
|
|
164
|
+
if (timing?.lastTurnCompleted === true) return { state: 'done', elapsedMs: settled }
|
|
165
|
+
return { state: 'idle', elapsedMs: settled }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @returns relative age such as `5m ago`. */
|
|
169
|
+
export function ageText(createdAt, now, tr = (en) => en) {
|
|
170
|
+
if (!Number.isFinite(createdAt)) return ''
|
|
171
|
+
const minutes = Math.max(0, Math.floor((now - createdAt) / 60_000))
|
|
172
|
+
if (minutes < 1) return tr('just now', 'только что')
|
|
173
|
+
if (minutes < 60) return tr(`${minutes}m ago`, `${minutes} мин назад`)
|
|
174
|
+
const hours = Math.floor(minutes / 60)
|
|
175
|
+
if (hours < 48) return tr(`${hours}h ago`, `${hours} ч назад`)
|
|
176
|
+
return tr(`${Math.floor(hours / 24)}d ago`, `${Math.floor(hours / 24)} дн назад`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const STATE_ICONS = { running: '⏳', done: '✓', failed: '✗', interrupted: '⚠️', idle: '•' }
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The `/agents` reply.
|
|
183
|
+
* @param rows - `{ label, model, depth, state, elapsedMs, createdAt, report }`, newest first.
|
|
184
|
+
* @param total - how many subagents the session has in all.
|
|
185
|
+
* @param now - current time in ms.
|
|
186
|
+
* @param tr - `(en, ru) => text`.
|
|
187
|
+
*/
|
|
188
|
+
export function agentsListText(rows, total, now, tr = (en) => en) {
|
|
189
|
+
if (rows.length === 0) return tr('This session has started no subagents.', 'Эта сессия не запускала субагентов.')
|
|
190
|
+
const head = total > rows.length
|
|
191
|
+
? tr(`🤖 Subagents (latest ${rows.length} of ${total}):`, `🤖 Субагенты (последние ${rows.length} из ${total}):`)
|
|
192
|
+
: tr('🤖 Subagents:', '🤖 Субагенты:')
|
|
193
|
+
const states = {
|
|
194
|
+
running: tr('running', 'работает'),
|
|
195
|
+
done: tr('done', 'готово'),
|
|
196
|
+
failed: tr('failed', 'ошибка'),
|
|
197
|
+
interrupted: tr('interrupted', 'прерван'),
|
|
198
|
+
idle: tr('idle', 'ожидает'),
|
|
199
|
+
}
|
|
200
|
+
const blocks = rows.map((row) => {
|
|
201
|
+
const indent = row.depth > 1 ? '↳ ' : ''
|
|
202
|
+
const facts = [states[row.state] ?? row.state]
|
|
203
|
+
if (row.model) facts.push(row.model)
|
|
204
|
+
if (row.elapsedMs > 0 || row.state === 'running') facts.push(formatElapsed(row.elapsedMs, row.state !== 'running'))
|
|
205
|
+
const age = ageText(row.createdAt, now, tr)
|
|
206
|
+
if (age) facts.push(age)
|
|
207
|
+
const lines = [`${indent}${STATE_ICONS[row.state] ?? '•'} ${clip(row.label, LABEL_MAX)}`, ` ${facts.join(' · ')}`]
|
|
208
|
+
const report = clip(String(row.report ?? '').replace(/\s+/gu, ' ').trim(), REPORT_MAX)
|
|
209
|
+
if (report) lines.push(` “${report}”`)
|
|
210
|
+
return lines.join('\n')
|
|
211
|
+
})
|
|
212
|
+
return [head, '', blocks.join('\n\n')].join('\n')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Track subagent runs and keep one status message per batch in the topic.
|
|
217
|
+
* @param deps - Host and Telegram access:
|
|
218
|
+
* - `parentOf(sessionId)`: the parent session id of a live session, or undefined.
|
|
219
|
+
* - `topicOf(sessionId)`: the topic state a session is bound to, or undefined.
|
|
220
|
+
* - `turnOf(topic)`: a key for the turn the topic is showing, or undefined when none.
|
|
221
|
+
* - `labelOf(parentId, childId)`: the child's description from its parent's catalog.
|
|
222
|
+
* - `modelOf(childId)`: the child's model id.
|
|
223
|
+
* - `post(topic, text)`: send a message, resolves to its id or undefined.
|
|
224
|
+
* - `edit(topic, messageId, text)`: resolves to `ok`, `gone` (send a new one) or `failed` (try later).
|
|
225
|
+
* - `reportError(scope, error)`, `tr(en, ru)`, `now()`, `delayMs`, `tickMs`.
|
|
226
|
+
* @returns `{ onStart, onEnd, dispose, boards }`.
|
|
227
|
+
*/
|
|
228
|
+
export function createAgentBoards(deps) {
|
|
229
|
+
const {
|
|
230
|
+
parentOf, topicOf, turnOf, labelOf, modelOf, post, edit, reportError,
|
|
231
|
+
tr = (en) => en, now = Date.now, delayMs = EDIT_DELAY_MS, tickMs = TICK_MS,
|
|
232
|
+
} = deps
|
|
233
|
+
/** Open boards by topic key, oldest first. */
|
|
234
|
+
const open = new Map()
|
|
235
|
+
let disposed = false
|
|
236
|
+
|
|
237
|
+
function boardsOf(key) {
|
|
238
|
+
let list = open.get(key)
|
|
239
|
+
if (list === undefined) {
|
|
240
|
+
list = []
|
|
241
|
+
open.set(key, list)
|
|
242
|
+
}
|
|
243
|
+
return list
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function closeBoard(board) {
|
|
247
|
+
const list = open.get(board.topic.key)
|
|
248
|
+
if (list !== undefined) {
|
|
249
|
+
const index = list.indexOf(board)
|
|
250
|
+
if (index !== -1) list.splice(index, 1)
|
|
251
|
+
if (list.length === 0) open.delete(board.topic.key)
|
|
252
|
+
}
|
|
253
|
+
if (board.tick !== undefined) clearInterval(board.tick)
|
|
254
|
+
board.tick = undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Publish soon; changes before the timer fires share the edit. */
|
|
258
|
+
function schedule(board) {
|
|
259
|
+
if (disposed || board.timer !== undefined) return
|
|
260
|
+
board.timer = setTimeout(() => {
|
|
261
|
+
board.timer = undefined
|
|
262
|
+
board.chain = board.chain
|
|
263
|
+
.then(() => publish(board))
|
|
264
|
+
.catch((error) => reportError('subagent status', error))
|
|
265
|
+
}, delayMs)
|
|
266
|
+
board.timer.unref?.()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function fillLabels(board) {
|
|
270
|
+
await Promise.all([...board.entries.values()].filter((entry) => !entry.labelKnown).map(async (entry) => {
|
|
271
|
+
const label = await Promise.resolve(labelOf(entry.parentId, entry.id)).catch(() => undefined)
|
|
272
|
+
if (typeof label === 'string' && label.trim()) {
|
|
273
|
+
entry.label = label.trim()
|
|
274
|
+
entry.labelKnown = true
|
|
275
|
+
}
|
|
276
|
+
}))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function publish(board) {
|
|
280
|
+
if (disposed) return
|
|
281
|
+
await fillLabels(board)
|
|
282
|
+
const entries = [...board.entries.values()]
|
|
283
|
+
const done = entries.every((entry) => entry.endedAt !== undefined)
|
|
284
|
+
// Freeze before any wait, so a subagent starting meanwhile opens a new board.
|
|
285
|
+
if (done) closeBoard(board)
|
|
286
|
+
const text = boardText(entries, now(), tr)
|
|
287
|
+
if (text === board.lastText) return
|
|
288
|
+
if (board.messageId !== undefined) {
|
|
289
|
+
const outcome = await edit(board.topic, board.messageId, text)
|
|
290
|
+
if (outcome === 'ok') {
|
|
291
|
+
board.lastText = text
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
if (outcome !== 'gone') {
|
|
295
|
+
// A running board retries with its next change or tick; a finished
|
|
296
|
+
// one has neither, so it tries once more after a pause.
|
|
297
|
+
if (done) {
|
|
298
|
+
await new Promise((resolve) => { setTimeout(resolve, delayMs * 4).unref?.() })
|
|
299
|
+
if (!disposed && (await edit(board.topic, board.messageId, text)) === 'ok') board.lastText = text
|
|
300
|
+
}
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
board.messageId = undefined
|
|
304
|
+
}
|
|
305
|
+
const messageId = await post(board.topic, text)
|
|
306
|
+
if (messageId !== undefined) {
|
|
307
|
+
board.messageId = messageId
|
|
308
|
+
board.lastText = text
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A subagent run started (`subagent/start`).
|
|
314
|
+
* @param info - `{ runId, id }`.
|
|
315
|
+
*/
|
|
316
|
+
async function onStart(info) {
|
|
317
|
+
if (disposed || typeof info?.id !== 'string') return
|
|
318
|
+
const parentId = parentOf(info.id)
|
|
319
|
+
let topic
|
|
320
|
+
const ancestors = []
|
|
321
|
+
let current = parentId
|
|
322
|
+
for (let depth = 0; depth < MAX_DEPTH && typeof current === 'string'; depth += 1) {
|
|
323
|
+
topic = topicOf(current)
|
|
324
|
+
if (topic !== undefined) break
|
|
325
|
+
ancestors.push(current)
|
|
326
|
+
current = parentOf(current)
|
|
327
|
+
}
|
|
328
|
+
if (topic === undefined) return
|
|
329
|
+
const list = boardsOf(topic.key)
|
|
330
|
+
// A nested subagent joins the board that shows its nearest ancestor.
|
|
331
|
+
let board
|
|
332
|
+
for (const ancestor of ancestors) {
|
|
333
|
+
board = list.find((candidate) => [...candidate.entries.values()].some((entry) => entry.id === ancestor))
|
|
334
|
+
if (board !== undefined) break
|
|
335
|
+
}
|
|
336
|
+
if (board === undefined) {
|
|
337
|
+
const turn = turnOf(topic)
|
|
338
|
+
if (turn === undefined) {
|
|
339
|
+
if (list.length === 0) open.delete(topic.key)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
board = list.find((candidate) => candidate.turn === turn)
|
|
343
|
+
if (board === undefined) {
|
|
344
|
+
board = { topic, turn, entries: new Map(), messageId: undefined, lastText: undefined, timer: undefined, tick: undefined, chain: Promise.resolve() }
|
|
345
|
+
list.push(board)
|
|
346
|
+
board.tick = setInterval(() => schedule(board), tickMs)
|
|
347
|
+
board.tick.unref?.()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const entry = {
|
|
351
|
+
id: info.id,
|
|
352
|
+
parentId,
|
|
353
|
+
label: tr(`subagent ${info.id.slice(0, 8)}`, `субагент ${info.id.slice(0, 8)}`),
|
|
354
|
+
labelKnown: false,
|
|
355
|
+
model: '',
|
|
356
|
+
depth: ancestors.length + 1,
|
|
357
|
+
startedAt: now(),
|
|
358
|
+
endedAt: undefined,
|
|
359
|
+
stopReason: undefined,
|
|
360
|
+
}
|
|
361
|
+
board.entries.set(info.runId ?? info.id, entry)
|
|
362
|
+
schedule(board)
|
|
363
|
+
const model = await Promise.resolve(modelOf(info.id)).catch(() => undefined)
|
|
364
|
+
if (typeof model === 'string' && model.length > 0) entry.model = model
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* A subagent run ended (`subagent/end`).
|
|
369
|
+
* @param info - `{ runId, id, stopReason }`.
|
|
370
|
+
*/
|
|
371
|
+
function onEnd(info) {
|
|
372
|
+
const key = info?.runId ?? info?.id
|
|
373
|
+
for (const list of open.values()) {
|
|
374
|
+
for (const board of list) {
|
|
375
|
+
const entry = board.entries.get(key)
|
|
376
|
+
if (entry === undefined || entry.endedAt !== undefined) continue
|
|
377
|
+
entry.endedAt = now()
|
|
378
|
+
entry.stopReason = info.stopReason
|
|
379
|
+
schedule(board)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function dispose() {
|
|
386
|
+
disposed = true
|
|
387
|
+
for (const list of open.values()) {
|
|
388
|
+
for (const board of list) {
|
|
389
|
+
if (board.timer !== undefined) clearTimeout(board.timer)
|
|
390
|
+
if (board.tick !== undefined) clearInterval(board.tick)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
open.clear()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { onStart, onEnd, dispose, boards: open }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Collect a session's subagents for `/agents`: every descendant reachable
|
|
401
|
+
* through the catalogs of recent children, newest first, running ones kept.
|
|
402
|
+
* @param listChildren - `(sessionId) => Promise<[{ id, label, createdAt, mode }]>`.
|
|
403
|
+
* @param rootId - the topic's session.
|
|
404
|
+
* @param isRunning - `(id) => boolean` for live, working subagents.
|
|
405
|
+
* @param limit - rows to return.
|
|
406
|
+
* @returns `{ rows: [{ id, label, createdAt, depth }], total }`.
|
|
407
|
+
*/
|
|
408
|
+
export async function collectSubagents(listChildren, rootId, isRunning, limit = LIST_LIMIT) {
|
|
409
|
+
const found = []
|
|
410
|
+
const seen = new Set([rootId])
|
|
411
|
+
/** Catalog reads are bounded: descendants of old children are old too. */
|
|
412
|
+
let budget = limit * 4
|
|
413
|
+
const walk = async (parentId, depth) => {
|
|
414
|
+
let children
|
|
415
|
+
try {
|
|
416
|
+
children = await listChildren(parentId)
|
|
417
|
+
} catch {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
if (!Array.isArray(children)) return
|
|
421
|
+
const recent = [...children].sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
|
|
422
|
+
for (const child of recent) {
|
|
423
|
+
if (seen.has(child.id)) continue
|
|
424
|
+
seen.add(child.id)
|
|
425
|
+
found.push({ id: child.id, label: child.label, createdAt: child.createdAt, depth })
|
|
426
|
+
}
|
|
427
|
+
for (const child of recent) {
|
|
428
|
+
if (child.mode === 'external' || depth >= MAX_DEPTH) continue
|
|
429
|
+
if (budget <= 0 && !isRunning(child.id)) continue
|
|
430
|
+
budget -= 1
|
|
431
|
+
await walk(child.id, depth + 1)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
await walk(rootId, 1)
|
|
435
|
+
found.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
|
|
436
|
+
const running = found.filter((row) => isRunning(row.id))
|
|
437
|
+
const rows = [...running, ...found.filter((row) => !isRunning(row.id)).slice(0, Math.max(0, limit - running.length))]
|
|
438
|
+
rows.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
|
|
439
|
+
return { rows, total: found.length }
|
|
440
|
+
}
|
package/src/app.js
CHANGED
|
@@ -43,6 +43,7 @@ import { createUpdateAdmission } from './update-ledger.js'
|
|
|
43
43
|
import { publishStatus } from './status.js'
|
|
44
44
|
import { messageAnswer, turnAnswer } from './final-text.js'
|
|
45
45
|
import { createOutbox } from './outbox.js'
|
|
46
|
+
import { agentsListText, childState, collectSubagents, createAgentBoards, reportOf } from './agents.js'
|
|
46
47
|
import { deliveredToTopic, isAutoCommandTopic, isPlaceholderTitle, isTopicGone, plannedTitle, resolveSendTarget, sessionTitleOf, topicNameOf, topicTargetFor } from './topics.js'
|
|
47
48
|
import { runProgram, spokenMediaOf, transcribe, transcriptNote } from './voice.js'
|
|
48
49
|
import { createStickerLibrary, describeSticker, STICKER_PREVIEW_DIR } from './stickers.js'
|
|
@@ -4263,6 +4264,110 @@ export function apply(ctx, config) {
|
|
|
4263
4264
|
return texts
|
|
4264
4265
|
}
|
|
4265
4266
|
|
|
4267
|
+
/**
|
|
4268
|
+
* The description a parent gave one of its subagents, from its catalog.
|
|
4269
|
+
* @param parentId - parent session.
|
|
4270
|
+
* @param childId - subagent session.
|
|
4271
|
+
* @returns the label, or undefined.
|
|
4272
|
+
*/
|
|
4273
|
+
async function subagentLabel(parentId, childId) {
|
|
4274
|
+
if (typeof parentId !== 'string') return undefined
|
|
4275
|
+
const children = await ctx.get('subagents')?.listChildren(parentId).catch(() => undefined)
|
|
4276
|
+
const label = Array.isArray(children) ? children.find((child) => child.id === childId)?.label : undefined
|
|
4277
|
+
return typeof label === 'string' && label.trim().length > 0 ? label.trim() : undefined
|
|
4278
|
+
}
|
|
4279
|
+
|
|
4280
|
+
/**
|
|
4281
|
+
* Post a subagent status message, after what the topic has queued.
|
|
4282
|
+
* @param chat - topic state.
|
|
4283
|
+
* @param text - status text.
|
|
4284
|
+
* @returns the message id, or undefined.
|
|
4285
|
+
*/
|
|
4286
|
+
function postAgentStatus(chat, text) {
|
|
4287
|
+
const posted = (chat.answerEdit ?? Promise.resolve()).catch(() => {}).then(async () => {
|
|
4288
|
+
try {
|
|
4289
|
+
const result = await call('sendMessage', {
|
|
4290
|
+
chat_id: chat.chatId,
|
|
4291
|
+
...topicTarget(chat.chatId, chat.threadId),
|
|
4292
|
+
text,
|
|
4293
|
+
disable_notification: true,
|
|
4294
|
+
}, bridgeAbort.signal)
|
|
4295
|
+
return typeof result?.message_id === 'number' ? result.message_id : undefined
|
|
4296
|
+
} catch (error) {
|
|
4297
|
+
if (!bridgeAbort.signal.aborted) warn(`subagent status send failed: ${messageOf(error)}`)
|
|
4298
|
+
return undefined
|
|
4299
|
+
}
|
|
4300
|
+
})
|
|
4301
|
+
chat.answerEdit = posted.then(() => {}, () => {})
|
|
4302
|
+
return posted
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
/**
|
|
4306
|
+
* Edit a subagent status message in place.
|
|
4307
|
+
* @returns `ok`, `gone` when the message can no longer be edited, or `failed`.
|
|
4308
|
+
*/
|
|
4309
|
+
async function editAgentStatus(chat, messageId, text) {
|
|
4310
|
+
try {
|
|
4311
|
+
await call('editMessageText', {
|
|
4312
|
+
chat_id: chat.chatId,
|
|
4313
|
+
message_id: messageId,
|
|
4314
|
+
...(chat.threadId === undefined || chat.threadId === 0 ? {} : { message_thread_id: chat.threadId }),
|
|
4315
|
+
text,
|
|
4316
|
+
}, bridgeAbort.signal)
|
|
4317
|
+
return 'ok'
|
|
4318
|
+
} catch (error) {
|
|
4319
|
+
const message = messageOf(error)
|
|
4320
|
+
if (/not modified/i.test(message)) return 'ok'
|
|
4321
|
+
if (/message to edit not found|message can't be edited|message_id_invalid/i.test(message)) return 'gone'
|
|
4322
|
+
if (!bridgeAbort.signal.aborted) warn(`subagent status edit failed: ${message}`)
|
|
4323
|
+
return 'failed'
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
/**
|
|
4328
|
+
* The `/agents` text for a session: its running and latest subagents with
|
|
4329
|
+
* their state and the start of their last report. Read from DSH's durable
|
|
4330
|
+
* catalogs and session logs, so it works for subagents of earlier runs.
|
|
4331
|
+
* @param sessionId - the topic's session.
|
|
4332
|
+
* @returns the reply text, or undefined when this DSH has no subagent service.
|
|
4333
|
+
*/
|
|
4334
|
+
async function subagentsText(sessionId) {
|
|
4335
|
+
const subagents = ctx.get('subagents')
|
|
4336
|
+
if (typeof subagents?.listChildren !== 'function') return undefined
|
|
4337
|
+
const agents = ctx.get('agents')
|
|
4338
|
+
const isRunning = (id) => agents?.get(id)?.status === 'running'
|
|
4339
|
+
const { rows, total } = await collectSubagents((id) => subagents.listChildren(id), sessionId, isRunning)
|
|
4340
|
+
const now = Date.now()
|
|
4341
|
+
const query = ctx.get('sessionQuery')
|
|
4342
|
+
const detailed = await Promise.all(rows.map(async (row) => {
|
|
4343
|
+
let timing
|
|
4344
|
+
let model
|
|
4345
|
+
let observation
|
|
4346
|
+
try {
|
|
4347
|
+
observation = await query?.observeSession(row.id)
|
|
4348
|
+
const values = observation?.projections?.values
|
|
4349
|
+
timing = values?.subagentTiming
|
|
4350
|
+
const selection = values?.modelSelection
|
|
4351
|
+
model = (selection?.next ?? selection?.pending ?? selection?.lastUsed)?.model
|
|
4352
|
+
} catch {
|
|
4353
|
+
// State falls back to what the live agent says.
|
|
4354
|
+
} finally {
|
|
4355
|
+
observation?.[Symbol.dispose]?.()
|
|
4356
|
+
}
|
|
4357
|
+
const live = agents?.get(row.id)
|
|
4358
|
+
if (typeof live?.model?.current === 'function') model = live.model.current()?.model ?? model
|
|
4359
|
+
let events = []
|
|
4360
|
+
try {
|
|
4361
|
+
events = (await query?.readSurface(row.id))?.events ?? []
|
|
4362
|
+
} catch {
|
|
4363
|
+
// No report preview.
|
|
4364
|
+
}
|
|
4365
|
+
const { state, elapsedMs } = childState({ status: live?.status, timing }, now)
|
|
4366
|
+
return { ...row, label: row.label || `subagent ${String(row.id).slice(0, 8)}`, model, state, elapsedMs, report: reportOf(events) }
|
|
4367
|
+
}))
|
|
4368
|
+
return agentsListText(detailed, total, now, tr)
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4266
4371
|
/**
|
|
4267
4372
|
* Deliver a session preview into its topic.
|
|
4268
4373
|
* @param record - topic mapping.
|
|
@@ -5252,7 +5357,7 @@ export function apply(ctx, config) {
|
|
|
5252
5357
|
? `${tr('To start a conversation, create a topic and send a message in it, or just send a message here in All: Telegram makes a topic for it. In the topic, pick the workspace, then a new session or an existing one. /sessions opens existing sessions in new topics.', 'Чтобы начать разговор, создай топик и напиши в него, или просто напиши здесь, в All: Telegram сам создаст для сообщения топик. В топике выбери workspace, затем новую или существующую сессию. /sessions открывает существующие сессии в новых топиках.')}\n`
|
|
5253
5358
|
: `${tr('This topic has no session yet. Send a message to choose a workspace.', 'У этого топика ещё нет сессии. Напиши сообщение, чтобы выбрать workspace.')}\n`) +
|
|
5254
5359
|
`\n<b>${tr('First run', 'Первый запуск')}:</b> @BotFather → Bot Settings → <b>Threaded mode</b> → ${tr('Enable', 'Включить')}.\n` +
|
|
5255
|
-
`${tr('Commands', 'Команды')}: /sessions, /new, /workspace, /current, /menu, /rename, /topic, /fork, /last, /clear, /stop, /restart, /status, /id, /ping, /goal.`,
|
|
5360
|
+
`${tr('Commands', 'Команды')}: /sessions, /new, /workspace, /current, /menu, /rename, /topic, /fork, /last, /agents, /clear, /stop, /restart, /status, /id, /ping, /goal.`,
|
|
5256
5361
|
signal,
|
|
5257
5362
|
threadId,
|
|
5258
5363
|
)
|
|
@@ -5453,6 +5558,17 @@ export function apply(ctx, config) {
|
|
|
5453
5558
|
return
|
|
5454
5559
|
}
|
|
5455
5560
|
|
|
5561
|
+
if (command === '/agents') {
|
|
5562
|
+
if (!inSession) {
|
|
5563
|
+
await send(chat.id, tr('This topic is not bound to a session. /sessions.', 'Этот topic не привязан к сессии. /sessions.'), signal, threadId)
|
|
5564
|
+
return
|
|
5565
|
+
}
|
|
5566
|
+
const listed = await subagentsText(chatState.sessionId)
|
|
5567
|
+
const body = listed ?? tr('This DSH has no subagent service.', 'В этом DSH нет сервиса субагентов.')
|
|
5568
|
+
await sendPlain(chat.id, body.length > MAX_TELEGRAM_TEXT ? `${body.slice(0, MAX_TELEGRAM_TEXT - 1)}…` : body, signal, threadId)
|
|
5569
|
+
return
|
|
5570
|
+
}
|
|
5571
|
+
|
|
5456
5572
|
if (command === '/fork') {
|
|
5457
5573
|
if (!inSession) {
|
|
5458
5574
|
await send(chat.id, tr('Open a session first: /sessions.', 'Сначала открой сессию: /sessions.'), signal, threadId)
|
|
@@ -6569,6 +6685,29 @@ export function apply(ctx, config) {
|
|
|
6569
6685
|
return () => outbox.stop()
|
|
6570
6686
|
}, 'dsh-telegram-bot: outbox')
|
|
6571
6687
|
|
|
6688
|
+
// Subagents started by a turn the topic shows get one status message per
|
|
6689
|
+
// batch (src/agents.js).
|
|
6690
|
+
const agentBoards = createAgentBoards({
|
|
6691
|
+
parentOf: (id) => ctx.get('agents')?.get(id)?.session?.header?.parentSession,
|
|
6692
|
+
topicOf: (sessionId) => (lifetime === undefined ? undefined : chatForSession(sessionId)),
|
|
6693
|
+
turnOf: (chat) => (chat.busy && !chat.done ? chat.turnEpoch : undefined),
|
|
6694
|
+
labelOf: subagentLabel,
|
|
6695
|
+
modelOf: async (id) => (await sessionModelOf(ctx, id).catch(() => undefined))?.model,
|
|
6696
|
+
post: postAgentStatus,
|
|
6697
|
+
edit: editAgentStatus,
|
|
6698
|
+
reportError,
|
|
6699
|
+
tr,
|
|
6700
|
+
})
|
|
6701
|
+
ctx.on('subagent/start', (info) => fireAndForget('subagent status', () => agentBoards.onStart(info)), { global: true })
|
|
6702
|
+
ctx.on('subagent/end', (info) => {
|
|
6703
|
+
try {
|
|
6704
|
+
agentBoards.onEnd(info)
|
|
6705
|
+
} catch (error) {
|
|
6706
|
+
reportError('subagent status', error)
|
|
6707
|
+
}
|
|
6708
|
+
}, { global: true })
|
|
6709
|
+
ctx.effect(() => () => agentBoards.dispose(), 'dsh-telegram-bot: subagent status')
|
|
6710
|
+
|
|
6572
6711
|
// Record each Telegram sender on the prompt the session controller built for it.
|
|
6573
6712
|
ctx.on('agent/pre-step', (payload, next) => principals.preStep(payload, next), { global: true })
|
|
6574
6713
|
// Host-wide feeds: every session's events, whichever client started the
|
package/src/constants.js
CHANGED
|
@@ -41,6 +41,7 @@ const BOT_COMMANDS = [
|
|
|
41
41
|
{ command: 'topic', description: 'Manage topics: delete, wipe' },
|
|
42
42
|
{ command: 'fork', description: 'Fork this session into a new topic' },
|
|
43
43
|
{ command: 'last', description: 'Show the latest assistant messages' },
|
|
44
|
+
{ command: 'agents', description: 'List running and recent subagents' },
|
|
44
45
|
{ command: 'goal', description: 'Goal: /goal <objective>, edit, pause, resume, clear' },
|
|
45
46
|
{ command: 'restart', description: 'Restart the Telegram bot runtime' },
|
|
46
47
|
{ command: 'id', description: 'Show user and chat ids' },
|
|
@@ -63,6 +64,7 @@ const BOT_COMMANDS_RU = [
|
|
|
63
64
|
{ command: 'topic', description: 'Управление топиками: delete, wipe' },
|
|
64
65
|
{ command: 'fork', description: 'Форкнуть сессию в новый topic' },
|
|
65
66
|
{ command: 'last', description: 'Показать последние ответы ассистента' },
|
|
67
|
+
{ command: 'agents', description: 'Работающие и недавние субагенты' },
|
|
66
68
|
{ command: 'goal', description: 'Цель: /goal <цель>, edit, pause, resume, clear' },
|
|
67
69
|
{ command: 'restart', description: 'Перезапустить Telegram-бота' },
|
|
68
70
|
{ command: 'id', description: 'Показать user id и chat id' },
|