@soffits/dsh-deltachat-bot 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Working signs: each message the bot takes gets a ⏳ reaction (👀 when it joins a running turn), removed once the answer is sent, also after a failure, `/stop`, `/clear` or a restart.
6
+ - Helper activity: subagents a chat's turn starts are listed in one status message, edited in place as they start and finish; `/agents` lists the session's helpers with their last reports.
7
+ - A turn woken by a helper's report answers the chat whose turn started the helper, also with `mirrorTurns` off.
8
+ - A session's text-only `deltachat_send` into its own chat is refused, so answers no longer arrive twice; text sent there with files is not repeated in the answer.
9
+
3
10
  ## 0.1.0
4
11
 
5
12
  First release: a Delta Chat bot for DSH on several chatmail relays, with a key-fingerprint allowlist, one session per chat (groups included), steering, mirrored turns, pictures, files, voice transcription, quotes, reactions, agent questions and tool approvals, Telegram-style commands, `DeltaChat-<person>-<task>` session titles, the `deltachatChats` send service, the `deltachat_send` tool and the `dsh-deltachat` command.
package/README.md CHANGED
@@ -57,10 +57,12 @@ Every message the agent receives starts with a line the bot adds from the sender
57
57
 
58
58
  - The first message in a chat starts a new session in `cwd`. Each group you create with the bot gets its own session, much like a separate topic.
59
59
  - Session titles use `DeltaChat-<person>-<task>`: the other party in the chat (a group's name, for a group) plus either the conversation title DSH produces or the chat's name until that title exists. A person's name comes from `peopleFile` when configured, else from `allowedUsers`, else from their Delta Chat profile.
60
- - If a message arrives while a turn is running, it joins that turn, as in the Web UI (`busyMessages: steer`; the message gets a 👀 reaction). With `busyMessages: queue`, it waits for the turn to finish.
60
+ - Delta Chat has no typing indicator, so the bot reacts ⏳ to your message as soon as it takes it and removes the reaction once the answer is sent (also when the turn fails or is stopped, and after a restart).
61
+ - If a message arrives while a turn is running, it joins that turn, as in the Web UI (`busyMessages: steer`), and shows 👀 until that turn ends. With `busyMessages: queue`, it waits for the turn to finish and keeps ⏳ until its own turn is answered.
61
62
  - Turns started elsewhere (scheduled tasks, the Web UI) also show up in the chat unless `mirrorTurns` is off.
62
63
  - When the turn ends, replies arrive as plain text: Delta Chat apps do not render Markdown, so the bot turns it into readable text. Files the agent presents, and local images inside an answer, are sent as files. An answer that is exactly `[SILENT]` sends nothing.
63
64
  - The model receives images you send as images; other files come through as attachments. Voice messages are transcribed by DSH's speech-to-text service (or by a command you choose; see `transcriber`) and attached.
65
+ - When the agent starts helpers (subagents) during a turn the chat receives, one status message lists them with their model and running time, and is edited as each one starts and finishes; `/agents` lists the session's helpers with their last reports.
64
66
  - Quoting a message tells the agent what you are replying to. A reaction to the bot's message is saved in the session but does not begin a turn.
65
67
  - When the agent asks a question, the options show as a numbered list: answer with a number or in your own words. A tool that needs approval waits for `/allow` or `/deny`.
66
68
 
@@ -77,6 +79,7 @@ Send these as messages; Delta Chat apps make them tappable.
77
79
  | `/status` | Bot, relay and session status |
78
80
  | `/current` | Show this chat's session |
79
81
  | `/last [1-5]` | Show the latest answers |
82
+ | `/agents` | List the session's helpers and their last reports |
80
83
  | `/sessions` | List workspaces; `/sessions 2` displays workspace 2's sessions; `/sessions 2 3` brings session 3 into this chat (`0` opens a new one) |
81
84
  | `/workspace <n>` | Open a fresh session in workspace n |
82
85
  | `/fork [name]` | Make a new group with the same people and fork the session there |
@@ -110,7 +113,7 @@ await ctx.get('deltachatChats').send({
110
113
 
111
114
  Errors a retry cannot solve carry `retryable: false`. The message is also recorded in the session tied to that chat, without starting a turn, so the agent knows it went out; if the person quotes it later, the agent can tell which job or session sent it. `chats()` lists chats bound to sessions, and `invite()` returns the invite link.
112
115
 
113
- Agents can use the `deltachat_send` tool, which offers the same options (plus `reaction` to react to a message). From a shell:
116
+ Agents can use the `deltachat_send` tool, which offers the same options (plus `reaction` to react to a message). A session's answer already goes to its own chat, so the tool refuses text-only sends from a session into the chat it is bound to, and text it sent there with files is not repeated in the answer. From a shell:
114
117
 
115
118
  ```sh
116
119
  dsh-deltachat send --chat 10 --job backup "Backup done"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soffits/dsh-deltachat-bot",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Delta Chat bot for DeepSeek Harness: an end-to-end encrypted chatmail bot on several relays, one DSH session per chat, commands, files, voice messages and a send service for other plugins.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/app.js CHANGED
@@ -3,15 +3,17 @@ import { randomUUID } from 'node:crypto'
3
3
  import { mkdir, writeFile } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { parseCommand, runCommand } from './commands.js'
6
- import { accountsDir, dataDir, name, POST_SOURCE_KIND, REQUEST_PREFIX, SERVICE_NAME, STEER_REACTION } from './constants.js'
6
+ import { accountsDir, dataDir, name, POST_SOURCE_KIND, REQUEST_PREFIX, SERVICE_NAME } from './constants.js'
7
7
  import * as realCore from './core.js'
8
+ import { createHelpers } from './helpers.js'
8
9
  import { allowedEntry, normalizeFingerprint, shortFingerprint } from './identity.js'
9
10
  import { composePrompt } from './inbound.js'
10
11
  import { createModelCatalog } from './models.js'
11
12
  import { createOperations, primaryAddress } from './operations.js'
12
13
  import { createOutbound } from './outbound.js'
13
14
  import { createPrompts } from './prompts.js'
14
- import { admitPrompt, createSession, recordMessage, resolveAgent, unarchive } from './sessions.js'
15
+ import { createReactions } from './reactions.js'
16
+ import { admitPrompt, createSession, hasRequest, recordMessage, resolveAgent, unarchive } from './sessions.js'
15
17
  import { createSpool } from './spool.js'
16
18
  import { openState } from './state.js'
17
19
  import { clip } from './text.js'
@@ -129,18 +131,21 @@ export function createBridge(ctx, config, core) {
129
131
 
130
132
  const outbound = createOutbound({ requireRuntime, chatState, reportError, messageOf })
131
133
  const { notice, sendText } = outbound
134
+ const reactions = createReactions({ ctx, getStore: () => store, getRuntime: () => runtime, reportError })
132
135
  const turns = createTurns({
133
136
  ctx,
134
137
  getStore: () => store,
135
138
  isRunning: () => runtime !== undefined,
136
139
  setting,
137
140
  outbound,
141
+ reactions,
138
142
  chatState,
139
143
  publishStatus,
140
144
  fireAndForget,
141
145
  messageOf,
142
146
  })
143
147
  const { currentTurn } = turns
148
+ const helpers = createHelpers({ ctx, getStore: () => store, getRuntime: () => runtime, outbound, currentTurn, reportError })
144
149
  const operations = createOperations({
145
150
  ctx,
146
151
  getStore: () => store,
@@ -353,7 +358,8 @@ export function createBridge(ctx, config, core) {
353
358
 
354
359
  /**
355
360
  * Hand a message to the chat's session: into the running turn (`steer`) or
356
- * after it (`queue`), with its sender line, quote and file.
361
+ * after it (`queue`), with its sender line, quote and file. The message gets
362
+ * the working reaction first, so the sender sees at once that it was taken.
357
363
  */
358
364
  async function dispatchPrompt(chatId, msg, sender) {
359
365
  const { dc, accountId } = requireRuntime()
@@ -362,7 +368,15 @@ export function createBridge(ctx, config, core) {
362
368
  while (chat.admission !== undefined) await chat.admission
363
369
  let release
364
370
  chat.admission = new Promise((resolve) => { release = resolve })
371
+ const requestId = `${REQUEST_PREFIX}${chatId}:${msg.id}`
372
+ const steer = setting('busyMessages', 'steer') !== 'queue'
373
+ let react = false
365
374
  try {
375
+ const boundId = store.chat(chatId)?.sessionId
376
+ const live = typeof boundId === 'string' ? ctx.get('agents')?.get(boundId) : undefined
377
+ // A message handed over before (a restart before it was marked seen) gets no new sign.
378
+ react = live === undefined || !hasRequest(live, requestId)
379
+ if (react) await reactions.mark(chatId, msg.id, reactions.emojiFor(live?.status !== undefined && live.status !== 'idle', steer))
366
380
  let quotedOwn = false
367
381
  let sentRecord
368
382
  if (msg.quote?.kind === 'WithMessage') {
@@ -390,12 +404,14 @@ export function createBridge(ctx, config, core) {
390
404
  const sessionId = agent.session?.id ?? agent.id
391
405
  if (await unarchive(ctx, sessionId)) info(`unarchived session ${sessionId} for a message from chat ${chatId}`)
392
406
  const running = agent.status !== undefined && agent.status !== 'idle'
393
- const mode = running && setting('busyMessages', 'steer') !== 'queue' ? 'steer' : 'queue'
394
- const requestId = `${REQUEST_PREFIX}${chatId}:${msg.id}`
395
- await admitPrompt(ctx, agent, { requestId, text: prompt.text, attachments: prompt.attachments, mode }, lifetime.signal)
396
- if (running) {
397
- await dc.rpc.sendReaction(accountId, msg.id, [mode === 'steer' ? STEER_REACTION : '⏳']).catch(() => {})
398
- }
407
+ const mode = running && steer ? 'steer' : 'queue'
408
+ const admitted = await admitPrompt(ctx, agent, { requestId, text: prompt.text, attachments: prompt.attachments, mode }, lifetime.signal)
409
+ if (!react) return
410
+ if (admitted) reactions.admitted(msg.id)
411
+ else await reactions.release([msg.id])
412
+ } catch (error) {
413
+ if (react) await reactions.release([msg.id]).catch(() => {})
414
+ throw error
399
415
  } finally {
400
416
  chat.admission = undefined
401
417
  release()
@@ -487,6 +503,9 @@ export function createBridge(ctx, config, core) {
487
503
  info(`running as ${primary}; invite link written to ${join(dataDir(), 'invite.txt')}`)
488
504
  await publishStatus({ state: 'running', detail: '', primary, invite: link })
489
505
  spool.start().catch((error) => reportError('spool', error))
506
+ // Working reactions left by turns that ended while the bot was down,
507
+ // cleared before unseen messages are handed over (and marked) again.
508
+ await reactions.resume().catch((error) => reportError('reactions', error))
490
509
  // Messages that arrived while the bot was down.
491
510
  for (const msgId of (await setup(dc.rpc.getFreshMsgs(accountId))).sort((left, right) => left - right)) void onIncoming(msgId)
492
511
  } catch (error) {
@@ -586,6 +605,8 @@ export function createBridge(ctx, config, core) {
586
605
  chatState,
587
606
  currentTurn,
588
607
  ensureAgent,
608
+ reactions,
609
+ helpers,
589
610
  notice,
590
611
  sendText,
591
612
  modelCatalog,
@@ -611,6 +632,9 @@ export function createBridge(ctx, config, core) {
611
632
  else if (event?.type === 'request/header') fireAndForget('title', () => startTitle(session.id))
612
633
  }, { global: true })
613
634
 
635
+ ctx.on('subagent/start', (info) => fireAndForget('helpers', () => helpers.onStart(info)), { global: true })
636
+ ctx.on('subagent/end', (info) => fireAndForget('helpers', () => helpers.onEnd(info)), { global: true })
637
+
614
638
  ctx.on('settings/document-updated', (namespace) => {
615
639
  if (namespace === name) fireAndForget('settings', () => applySettings())
616
640
  })
@@ -622,6 +646,7 @@ export function createBridge(ctx, config, core) {
622
646
  lifetime.abort()
623
647
  if (retryTimer !== undefined) clearTimeout(retryTimer)
624
648
  prompts.cancelAll()
649
+ helpers.dispose()
625
650
  stopRuntime()
626
651
  }
627
652
  }, 'dsh-deltachat-bot: runtime')
package/src/commands.js CHANGED
@@ -22,6 +22,7 @@ export const COMMANDS = [
22
22
  ['status', 'Bot and session status'],
23
23
  ['current', "Show this chat's session"],
24
24
  ['last', 'Show the latest answers: /last [1-5]'],
25
+ ['agents', "List this session's helpers and their last reports"],
25
26
  ['sessions', 'Browse workspaces and sessions, continue one here'],
26
27
  ['workspace', 'Start a new session in another workspace'],
27
28
  ['fork', 'Fork this session into a new group: /fork [name]'],
@@ -199,6 +200,7 @@ const HANDLERS = {
199
200
 
200
201
  async clear(bridge, { chatId }) {
201
202
  const removed = clearChatInbox(liveAgent(bridge, chatId), chatId)
203
+ await bridge.reactions.sweep(chatId)
202
204
  await bridge.notice(chatId, removed === 0 ? '🧹 Nothing is waiting.' : `🧹 Dropped ${removed} waiting message(s).`)
203
205
  },
204
206
 
@@ -221,6 +223,29 @@ const HANDLERS = {
221
223
  }
222
224
  },
223
225
 
226
+ async agents(bridge, { chatId }) {
227
+ const sessionId = sessionOf(bridge, chatId)
228
+ if (sessionId === undefined) {
229
+ await bridge.notice(chatId, 'This chat has no session yet.')
230
+ return
231
+ }
232
+ const helpers = await bridge.helpers.list(sessionId)
233
+ if (helpers === undefined) {
234
+ await bridge.notice(chatId, 'Helpers are not available in this DSH.')
235
+ return
236
+ }
237
+ if (helpers.length === 0) {
238
+ await bridge.notice(chatId, 'This session has started no helpers.')
239
+ return
240
+ }
241
+ const lines = ['Helpers of this session:']
242
+ for (const [index, helper] of helpers.entries()) {
243
+ lines.push('', `${index + 1}. ${helper.running ? '⏳' : '✓'} ${helper.label} · ${age(helper.createdAt)}`)
244
+ lines.push(helper.report ? clip(toPlainText(helper.report), 400) : helper.running ? '(working)' : '(no report)')
245
+ }
246
+ await bridge.notice(chatId, lines.join('\n'))
247
+ },
248
+
224
249
  async sessions(bridge, { chatId }, args) {
225
250
  const workspaces = listWorkspaces(bridge.ctx).slice(0, LIST_LIMIT)
226
251
  const [first, second] = args.split(/\s+/u).filter(Boolean)
package/src/helpers.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Helper activity: subagents started under a chat's session are shown in one
3
+ * status message, edited in place as they start and finish and left as it is
4
+ * once all of them are done. `/agents` lists a session's helpers on demand.
5
+ */
6
+ import { sessionModelOf } from './models.js'
7
+ import { recentAssistantTexts } from './sessions.js'
8
+ import { clip } from './text.js'
9
+
10
+ /** Pause that lets several starts or ends share one edit. */
11
+ const EDIT_DELAY_MS = 1500
12
+ /** Parent links followed from a helper up to the chat's session. */
13
+ const MAX_DEPTH = 8
14
+ /** Helpers `/agents` lists at most. */
15
+ const LIST_LIMIT = 8
16
+
17
+ /** @returns minutes between two times, as shown in the status message. */
18
+ function minutes(from, to) {
19
+ const value = Math.floor((to - from) / 60_000)
20
+ return value < 1 ? '<1 min' : `${value} min`
21
+ }
22
+
23
+ /**
24
+ * The status text of one chat's helpers.
25
+ * @param helpers - helper records in start order.
26
+ * @param now - current time in ms.
27
+ */
28
+ export function boardText(helpers, now) {
29
+ const running = helpers.filter((helper) => helper.endedAt === undefined)
30
+ const count = (n) => `${n} helper${n === 1 ? '' : 's'}`
31
+ const head = running.length > 0 ? `🤖 ${count(running.length)} working:` : `🤖 ${count(helpers.length)} done:`
32
+ const lines = helpers.map((helper) => {
33
+ const model = helper.model ? `${helper.model}, ` : ''
34
+ if (helper.endedAt === undefined) return `⏳ ${helper.label} (${model}${minutes(helper.startedAt, now)})`
35
+ const failed = helper.stopReason !== undefined && helper.stopReason !== 'completed'
36
+ return `${failed ? '✗' : '✓'} ${helper.label} (${model}${minutes(helper.startedAt, helper.endedAt)}${failed ? `, ${helper.stopReason}` : ''})`
37
+ })
38
+ return [head, ...lines].join('\n')
39
+ }
40
+
41
+ /**
42
+ * Create the helper tracker.
43
+ * @param deps - `{ ctx, getStore, getRuntime, outbound, currentTurn, reportError, now }`.
44
+ * @returns `{ onStart, onEnd, list, dispose }`.
45
+ */
46
+ export function createHelpers({ ctx, getStore, getRuntime, outbound, currentTurn, reportError, now = Date.now }) {
47
+ const { queueOut, sendText } = outbound
48
+ /** Open status messages by chat id: `{ messageId, helpers: Map<runId, helper>, timer }`. */
49
+ const boards = new Map()
50
+
51
+ /** @returns the chat whose session started this helper (directly or through other helpers). */
52
+ function chatOfHelper(childId) {
53
+ const agents = ctx.get('agents')
54
+ const store = getStore()
55
+ let sessionId = agents?.get(childId)?.session?.header?.parentSession
56
+ for (let depth = 0; depth < MAX_DEPTH && typeof sessionId === 'string'; depth += 1) {
57
+ const chatId = store?.chatOfSession(sessionId)
58
+ if (chatId !== undefined) return { chatId, sessionId }
59
+ sessionId = agents?.get(sessionId)?.session?.header?.parentSession
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * Fill in labels from the parents' catalogs. A child can be announced
66
+ * before its parent's catalog lists it, so this runs at every publish until
67
+ * the label is found; meanwhile the id stands in.
68
+ */
69
+ async function nameHelpers(helpers) {
70
+ for (const helper of helpers) {
71
+ if (helper.named) continue
72
+ try {
73
+ const children = await ctx.get('subagents')?.listChildren(helper.parentId)
74
+ const label = children?.find((child) => child.id === helper.id)?.label
75
+ if (typeof label === 'string' && label.trim()) {
76
+ helper.label = clip(label.trim(), 80)
77
+ helper.named = true
78
+ }
79
+ } catch {
80
+ // The catalog is optional.
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Send or edit a chat's status message after a short pause. */
86
+ function schedule(chatId, board) {
87
+ if (board.timer !== undefined) return
88
+ board.timer = setTimeout(() => {
89
+ board.timer = undefined
90
+ void queueOut(chatId, () => publish(chatId, board)).catch((error) => reportError(`helpers in chat ${chatId}`, error))
91
+ }, EDIT_DELAY_MS)
92
+ board.timer.unref?.()
93
+ }
94
+
95
+ /** Bring the status message up to date; a finished board is left as it is. */
96
+ async function publish(chatId, board) {
97
+ const runtime = getRuntime()
98
+ if (runtime === undefined) return
99
+ const helpers = [...board.helpers.values()]
100
+ await nameHelpers(helpers)
101
+ const text = boardText(helpers, now())
102
+ const done = helpers.every((helper) => helper.endedAt !== undefined)
103
+ if (done && boards.get(chatId) === board) boards.delete(chatId)
104
+ if (board.messageId !== undefined) {
105
+ try {
106
+ await runtime.dc.rpc.sendEditRequest(runtime.accountId, board.messageId, text)
107
+ return
108
+ } catch (error) {
109
+ // Without edits the new state goes out as its own message.
110
+ reportError(`editing helper status ${board.messageId}`, error)
111
+ }
112
+ }
113
+ const [messageId] = await sendText(chatId, text, { plain: true })
114
+ board.messageId = messageId
115
+ }
116
+
117
+ /**
118
+ * A helper started (`subagent/start`).
119
+ * @param info - `{ runId, id, local }`.
120
+ */
121
+ async function onStart(info) {
122
+ const found = chatOfHelper(info?.id)
123
+ if (found === undefined) return
124
+ const { chatId, sessionId } = found
125
+ const turn = currentTurn(sessionId)
126
+ let board = boards.get(chatId)
127
+ // Helpers show where the session's answers show.
128
+ if (board === undefined && !(turn !== undefined && (turn.ours || turn.goal || turn.mirrored))) return
129
+ if (board === undefined) {
130
+ board = { messageId: undefined, helpers: new Map(), timer: undefined }
131
+ boards.set(chatId, board)
132
+ }
133
+ const parentId = ctx.get('agents')?.get(info.id)?.session?.header?.parentSession ?? sessionId
134
+ const helper = { id: info.id, parentId, label: `helper ${String(info.id).slice(0, 8)}`, named: false, model: '', startedAt: now(), endedAt: undefined, stopReason: undefined }
135
+ board.helpers.set(info.runId, helper)
136
+ helper.model = (await sessionModelOf(ctx, info.id).catch(() => undefined))?.model ?? ''
137
+ schedule(chatId, board)
138
+ }
139
+
140
+ /**
141
+ * A helper finished (`subagent/end`).
142
+ * @param info - `{ runId, id, stopReason }`.
143
+ */
144
+ function onEnd(info) {
145
+ for (const [chatId, board] of boards) {
146
+ const helper = board.helpers.get(info?.runId)
147
+ if (helper === undefined) continue
148
+ helper.endedAt = now()
149
+ helper.stopReason = info.stopReason
150
+ schedule(chatId, board)
151
+ return
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The helpers of a session for `/agents`, newest last, with each one's last report.
157
+ * @param sessionId - the chat's session.
158
+ * @returns `[{ id, label, running, createdAt, report }]`.
159
+ */
160
+ async function list(sessionId) {
161
+ const children = await ctx.get('subagents')?.listChildren(sessionId)
162
+ if (!Array.isArray(children)) return undefined
163
+ const result = []
164
+ for (const child of children.slice(-LIST_LIMIT)) {
165
+ const agent = ctx.get('agents')?.get(child.id)
166
+ const running = agent !== undefined && agent.status !== undefined && agent.status !== 'idle'
167
+ const [report = ''] = await recentAssistantTexts(ctx, child.id, 1)
168
+ result.push({ id: child.id, label: child.label || `helper ${String(child.id).slice(0, 8)}`, running, createdAt: child.createdAt, report })
169
+ }
170
+ return result
171
+ }
172
+
173
+ /** Stop pending edits. */
174
+ function dispose() {
175
+ for (const board of boards.values()) if (board.timer !== undefined) clearTimeout(board.timer)
176
+ boards.clear()
177
+ }
178
+
179
+ return { onStart, onEnd, list, dispose }
180
+ }
package/src/operations.js CHANGED
@@ -52,16 +52,25 @@ export function createOperations({ ctx, getStore, storeReady, requireRuntime, ge
52
52
  const text = typeof request?.text === 'string' ? request.text.trim() : ''
53
53
  const paths = Array.isArray(request?.files) ? request.files.map(String) : []
54
54
  if (text.length === 0 && paths.length === 0) throw finalError('Nothing to send: give text or files.')
55
+ const from = request?.from ?? {}
56
+ const sessionId = store.chat(chatId)?.sessionId
57
+ const ownTurn = typeof from.sessionId === 'string' && from.sessionId === sessionId ? currentTurn(sessionId) : undefined
58
+ // The answer of a turn this chat receives is delivered by itself, so text
59
+ // a session sends into its own chat would arrive twice.
60
+ const delivered = ownTurn !== undefined && (ownTurn.ours || ownTurn.goal || ownTurn.mirrored)
61
+ if (delivered && paths.length === 0) {
62
+ throw finalError('This session is bound to that chat and its answer is delivered there when the turn ends: write the text in your answer instead of sending it.')
63
+ }
55
64
  const files = await checkFiles(paths)
56
65
  const messageIds = await queueOut(chatId, async () => {
57
66
  const ids = text.length > 0 ? await sendText(chatId, text, { quote: replyTo }) : []
58
67
  ids.push(...await sendFiles(chatId, files))
59
68
  return ids
60
69
  })
61
- const from = request?.from ?? {}
62
- const sessionId = store.chat(chatId)?.sessionId
63
- const ownTurn = typeof from.sessionId === 'string' && from.sessionId === sessionId ? currentTurn(sessionId) : undefined
64
- if (ownTurn !== undefined) ownTurn.sent = true
70
+ if (ownTurn !== undefined) {
71
+ ownTurn.sent = true
72
+ if (text.length > 0) ownTurn.sentTexts.push(text)
73
+ }
65
74
  await store.noteSent({
66
75
  chatId,
67
76
  messageIds,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Working signs: Delta Chat has no typing indicator, so a message the bot
3
+ * works on carries a reaction from the bot (⏳ for a new or queued turn, 👀
4
+ * when it went into the running turn) until the turn that took it has ended
5
+ * and its answer was sent. Reacted messages are kept in `state.json`, so a
6
+ * sign left by a turn that a Host restart cut off is removed at the next start.
7
+ */
8
+ import { REQUEST_PREFIX, STEER_REACTION } from './constants.js'
9
+ import { inInbox } from './sessions.js'
10
+
11
+ /** Reaction on a message that starts a turn or waits for one. */
12
+ export const WORKING_REACTION = '⏳'
13
+
14
+ /**
15
+ * Create the working-sign tracker.
16
+ * @param deps - `{ ctx, getStore, getRuntime, reportError }`.
17
+ * @returns `{ emojiFor, mark, admitted, attach, finishTurn, sweep, resume, release }`.
18
+ */
19
+ export function createReactions({ ctx, getStore, getRuntime, reportError }) {
20
+ /** Reacted messages by message id: `{ chatId, admitted, turn }`. */
21
+ const tracked = new Map()
22
+ let loaded = false
23
+
24
+ /** Take over the messages a previous run left reacted. */
25
+ function load() {
26
+ if (loaded) return
27
+ const store = getStore()
28
+ if (store === undefined) return
29
+ loaded = true
30
+ for (const [msgId, chatId] of Object.entries(store.reactions())) {
31
+ if (!tracked.has(Number(msgId))) tracked.set(Number(msgId), { chatId, admitted: true, turn: undefined })
32
+ }
33
+ }
34
+
35
+ /** @returns the live agent of a chat's session, or undefined. */
36
+ function agentOf(chatId) {
37
+ const sessionId = getStore()?.chat(chatId)?.sessionId
38
+ return typeof sessionId === 'string' ? ctx.get('agents')?.get(sessionId) : undefined
39
+ }
40
+
41
+ /**
42
+ * The sign for a message about to go to a session.
43
+ * @param running - whether the session runs a turn.
44
+ * @param steer - whether a message during a turn goes into it.
45
+ */
46
+ function emojiFor(running, steer) {
47
+ return running && steer ? STEER_REACTION : WORKING_REACTION
48
+ }
49
+
50
+ /** React to a message and remember it. */
51
+ async function mark(chatId, msgId, emoji) {
52
+ load()
53
+ const runtime = getRuntime()
54
+ if (runtime === undefined) return
55
+ tracked.set(msgId, { chatId, admitted: false, turn: undefined })
56
+ await getStore()?.noteReaction(msgId, chatId)
57
+ await runtime.dc.rpc.sendReaction(runtime.accountId, msgId, [emoji]).catch((error) => reportError(`reaction on ${msgId}`, error))
58
+ }
59
+
60
+ /** The message reached the session, so a sweep may judge it. */
61
+ function admitted(msgId) {
62
+ const entry = tracked.get(msgId)
63
+ if (entry !== undefined) entry.admitted = true
64
+ }
65
+
66
+ /**
67
+ * The message is part of a turn now.
68
+ * @param msgId - Delta Chat message id.
69
+ * @param turn - key of the turn (session id and turn number).
70
+ */
71
+ function attach(msgId, turn) {
72
+ const entry = tracked.get(msgId)
73
+ if (entry !== undefined) entry.turn = turn
74
+ }
75
+
76
+ /** Remove the bot's reaction from messages. */
77
+ async function release(msgIds) {
78
+ const runtime = getRuntime()
79
+ if (msgIds.length === 0 || runtime === undefined) return
80
+ for (const msgId of msgIds) tracked.delete(msgId)
81
+ await getStore()?.dropReactions(msgIds)
82
+ for (const msgId of msgIds) {
83
+ await runtime.dc.rpc.sendReaction(runtime.accountId, msgId, []).catch((error) => reportError(`reaction on ${msgId}`, error))
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Messages of a chat that will get no turn: admitted, not taken into a
89
+ * turn, and no longer waiting in the agent's inbox (dropped by /stop or
90
+ * /clear, or a duplicate).
91
+ */
92
+ function dropped(chatId, { ignoreTurn = false } = {}) {
93
+ const agent = agentOf(chatId)
94
+ return [...tracked].filter(([msgId, entry]) => entry.chatId === chatId && entry.admitted
95
+ && (ignoreTurn || entry.turn === undefined) && !inInbox(agent, `${REQUEST_PREFIX}${chatId}:${msgId}`)).map(([msgId]) => msgId)
96
+ }
97
+
98
+ /**
99
+ * A turn ended and its answer went out: clear its messages (whatever chat
100
+ * the session is bound to now) and the chat's dropped ones.
101
+ */
102
+ async function finishTurn(chatId, turn) {
103
+ load()
104
+ const own = [...tracked].filter(([, entry]) => entry.turn === turn).map(([msgId]) => msgId)
105
+ await release([...own, ...dropped(chatId)])
106
+ }
107
+
108
+ /** Clear the signs of a chat's dropped messages. */
109
+ async function sweep(chatId) {
110
+ load()
111
+ await release(dropped(chatId))
112
+ }
113
+
114
+ /** Clear signs whose session runs no turn, e.g. after a restart. */
115
+ async function resume() {
116
+ load()
117
+ const idle = new Set([...tracked.values()].map((entry) => entry.chatId).filter((chatId) => {
118
+ const agent = agentOf(chatId)
119
+ return agent === undefined || agent.status === undefined || agent.status === 'idle'
120
+ }))
121
+ for (const chatId of idle) await release(dropped(chatId, { ignoreTurn: true }))
122
+ }
123
+
124
+ return { emojiFor, mark, admitted, attach, finishTurn, sweep, resume, release }
125
+ }
package/src/sessions.js CHANGED
@@ -152,11 +152,17 @@ export async function unarchive(ctx, sessionId) {
152
152
  return true
153
153
  }
154
154
 
155
- /** @returns whether a pending or recorded message carries this request id. */
156
- function hasRequest(agent, requestId) {
155
+ /** @returns whether a message the agent has not taken yet carries this request id. */
156
+ export function inInbox(agent, requestId) {
157
157
  const matches = (message) => message?.source?.kind === 'user' && message.source.rpcId === requestId
158
158
  const inbox = agent?.inbox
159
- if (inbox?.nextTurn?.some?.(matches) || inbox?.nextStep?.some?.(matches)) return true
159
+ return inbox?.nextTurn?.some?.(matches) === true || inbox?.nextStep?.some?.(matches) === true
160
+ }
161
+
162
+ /** @returns whether a pending or recorded message carries this request id. */
163
+ export function hasRequest(agent, requestId) {
164
+ if (inInbox(agent, requestId)) return true
165
+ const matches = (message) => message?.source?.kind === 'user' && message.source.rpcId === requestId
160
166
  const events = typeof agent?.session?.snapshotEvents === 'function' ? agent.session.snapshotEvents() : []
161
167
  return events.some((event) => event?.type === 'user/message' && matches(event.data))
162
168
  }
@@ -172,21 +178,23 @@ function hasRequest(agent, requestId) {
172
178
  * @param agent - the session's agent.
173
179
  * @param prompt - `{ requestId, text, attachments, mode }`; `mode` is `queue` or `steer`.
174
180
  * @param signal - request lifetime.
181
+ * @returns whether the prompt was handed over (false for a duplicate or an empty one).
175
182
  */
176
183
  export async function admitPrompt(ctx, agent, { requestId, text, attachments = [], mode }, signal) {
177
- if (hasRequest(agent, requestId)) return
184
+ if (hasRequest(agent, requestId)) return false
178
185
  const content = [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments]
179
- if (content.length === 0) return
186
+ if (content.length === 0) return false
180
187
  const controller = ctx.get('sessionController')
181
188
  const sessionId = agent.session?.id ?? agent.id
182
189
  if (attachments.length === 0 && typeof controller?.prompt === 'function') {
183
190
  await controller.prompt({ sessionId, requestId, mode, content }, signal)
184
- return
191
+ return true
185
192
  }
186
193
  const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId } }
187
194
  if (mode === 'steer' && typeof agent.steer === 'function') agent.steer(message)
188
195
  else if (typeof agent.followup === 'function') agent.followup(message)
189
196
  else agent.send(message, mode === 'steer' ? 'next-step' : 'next-turn', true)
197
+ return true
190
198
  }
191
199
 
192
200
  /**
package/src/state.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Durable bot state: which DSH session each Delta Chat chat is bound to,
3
3
  * contacts that wrote without being allowed, and what was sent on behalf of
4
- * jobs and sessions (so a quoted reply can say where a message came from).
4
+ * jobs and sessions (so a quoted reply can say where a message came from),
5
+ * and messages that carry the bot's working reaction.
5
6
  */
6
7
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
7
8
  import { dirname, join } from 'node:path'
@@ -14,7 +15,7 @@ const PENDING_LIMIT = 50
14
15
 
15
16
  /** @returns an empty state document. */
16
17
  function emptyState() {
17
- return { version: 1, chats: {}, pending: {}, sent: [], picks: {} }
18
+ return { version: 1, chats: {}, pending: {}, sent: [], picks: {}, reactions: {} }
18
19
  }
19
20
 
20
21
  /**
@@ -38,7 +39,7 @@ async function readJson(path) {
38
39
  export async function openState(path = join(dataDir(), 'state.json')) {
39
40
  const loaded = await readJson(path)
40
41
  const state = { ...emptyState(), ...(loaded ?? {}) }
41
- for (const key of ['chats', 'pending', 'picks']) {
42
+ for (const key of ['chats', 'pending', 'picks', 'reactions']) {
42
43
  if (state[key] === null || typeof state[key] !== 'object' || Array.isArray(state[key])) state[key] = {}
43
44
  }
44
45
  if (!Array.isArray(state.sent)) state.sent = []
@@ -135,6 +136,20 @@ export async function openState(path = join(dataDir(), 'state.json')) {
135
136
  if (state.sent.length > SENT_LIMIT) state.sent.splice(0, state.sent.length - SENT_LIMIT)
136
137
  await store.save()
137
138
  },
139
+ /** @returns message id → chat id of messages with the bot's working reaction. */
140
+ reactions() {
141
+ return { ...state.reactions }
142
+ },
143
+ /** Remember a message that got the working reaction. */
144
+ async noteReaction(msgId, chatId) {
145
+ state.reactions[String(msgId)] = chatId
146
+ await store.save()
147
+ },
148
+ /** Forget messages whose working reaction was removed. */
149
+ async dropReactions(msgIds) {
150
+ for (const msgId of msgIds) delete state.reactions[String(msgId)]
151
+ await store.save()
152
+ },
138
153
  /** @returns the send record holding a Delta Chat message id, or undefined. */
139
154
  sentRecord(messageId) {
140
155
  for (let index = state.sent.length - 1; index >= 0; index -= 1) {
package/src/turns.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Turn delivery: follow every session bound to a chat, collect each turn's
3
- * answer, and send it when the turn ends.
3
+ * answer, send it when the turn ends, then clear the working reactions of
4
+ * its messages.
4
5
  */
5
6
  import { basename } from 'node:path'
6
7
  import { REQUEST_PREFIX } from './constants.js'
@@ -8,24 +9,34 @@ import { clip, localImagePaths, messageAnswer, stripSilent } from './text.js'
8
9
 
9
10
  /**
10
11
  * Create the turn tracker.
11
- * @param deps - `{ ctx, getStore, isRunning, setting, outbound, chatState, publishStatus, fireAndForget, messageOf }`.
12
+ * @param deps - `{ ctx, getStore, isRunning, setting, outbound, reactions, chatState, publishStatus, fireAndForget, messageOf }`.
12
13
  * @returns `{ onSessionEvent, currentTurn }`.
13
14
  */
14
- export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatState, publishStatus, fireAndForget, messageOf }) {
15
+ export function createTurns({ ctx, getStore, isRunning, setting, outbound, reactions, chatState, publishStatus, fireAndForget, messageOf }) {
15
16
  const { queueOut, notice, sendText, sendFiles, checkFiles } = outbound
16
- /** Turns of bound sessions by session id: `{ current, turns: Map<turn, record>, pendingOurs }`. */
17
+ /**
18
+ * Turns of bound sessions by session id: `{ current, turns: Map<turn, record>,
19
+ * pendingOurs, pendingIds, lastOurs }`; `pendingIds` are Delta Chat messages
20
+ * taken before their turn started, `lastOurs` whether the last turn that
21
+ * ended was started from Delta Chat.
22
+ */
17
23
  const sessionTurns = new Map()
18
24
 
19
25
  /** @returns the turn bookkeeping of a session. */
20
26
  function turnsOf(sessionId) {
21
27
  let entry = sessionTurns.get(sessionId)
22
28
  if (entry === undefined) {
23
- entry = { current: undefined, turns: new Map(), pendingOurs: undefined }
29
+ entry = { current: undefined, turns: new Map(), pendingOurs: undefined, pendingIds: [], lastOurs: false }
24
30
  sessionTurns.set(sessionId, entry)
25
31
  }
26
32
  return entry
27
33
  }
28
34
 
35
+ /** @returns the key that ties a turn's messages to it. */
36
+ function turnKey(sessionId, turn) {
37
+ return `${sessionId}\u0000${turn}`
38
+ }
39
+
29
40
  /** @returns the record of a session's running turn, or undefined. */
30
41
  function currentTurn(sessionId) {
31
42
  const entry = sessionTurns.get(sessionId)
@@ -46,10 +57,12 @@ export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatS
46
57
  const entry = turnsOf(sessionId)
47
58
  const mirror = setting('mirrorTurns', true) === true
48
59
  if (event.type === 'turn/start') {
49
- const record = { texts: [], ours: entry.pendingOurs !== undefined, lastPrompt: entry.pendingOurs, goal: false, mirrored: mirror, sent: false, stopped: false }
60
+ const record = { texts: [], sentTexts: [], ours: entry.pendingOurs !== undefined, lastPrompt: entry.pendingOurs, goal: false, mirrored: mirror, sent: false, stopped: false }
50
61
  entry.pendingOurs = undefined
51
62
  entry.current = event.data?.turn
52
63
  entry.turns.set(event.data?.turn, record)
64
+ for (const messageId of entry.pendingIds) reactions.attach(messageId, turnKey(sessionId, event.data?.turn))
65
+ entry.pendingIds = []
53
66
  return
54
67
  }
55
68
  const record = entry.current === undefined ? undefined : entry.turns.get(entry.current)
@@ -58,14 +71,22 @@ export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatS
58
71
  const rpcId = typeof source?.rpcId === 'string' ? source.rpcId : ''
59
72
  if (source?.kind === 'user' && rpcId.startsWith(REQUEST_PREFIX)) {
60
73
  const messageId = Number(rpcId.slice(rpcId.lastIndexOf(':') + 1))
61
- if (record === undefined) entry.pendingOurs = messageId
62
- else {
74
+ if (record === undefined) {
75
+ entry.pendingOurs = messageId
76
+ entry.pendingIds.push(messageId)
77
+ } else {
63
78
  record.ours = true
64
79
  record.lastPrompt = messageId
80
+ reactions.attach(messageId, turnKey(sessionId, entry.current))
65
81
  }
66
82
  return
67
83
  }
68
84
  if (rpcId.startsWith('deltachat-claim:')) return
85
+ // A helper's report wakes its parent: that turn answers the chat the delegating turn came from.
86
+ if ((source?.kind === 'subagent-settled' || source?.kind === 'agent-message') && record !== undefined) {
87
+ if (entry.lastOurs) record.ours = true
88
+ return
89
+ }
69
90
  if (source?.kind === 'goal' && record !== undefined) record.goal = true
70
91
  if (!mirror) return
71
92
  const label = source?.kind === 'schedule' ? '⏰ Scheduled task' : source?.kind !== 'user' ? undefined : rpcId.startsWith('telegram') ? '✈️ From Telegram' : '🖥 From Web'
@@ -99,9 +120,17 @@ export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatS
99
120
  if (event.type === 'turn/end') {
100
121
  const turn = event.data?.turn
101
122
  const finished = entry.turns.get(turn)
123
+ if (finished !== undefined) entry.lastOurs = finished.ours
102
124
  entry.turns.delete(turn)
103
125
  if (entry.current === turn) entry.current = undefined
104
- if (finished !== undefined) void queueOut(chatId, () => deliverTurn(chatId, finished, event.data?.reason))
126
+ // The reactions go once the answer is out, also when the turn failed or was stopped.
127
+ void queueOut(chatId, async () => {
128
+ try {
129
+ if (finished !== undefined) await deliverTurn(chatId, finished, event.data?.reason)
130
+ } finally {
131
+ await reactions.finishTurn(chatId, turnKey(sessionId, turn))
132
+ }
133
+ })
105
134
  }
106
135
  }
107
136
 
@@ -122,7 +151,9 @@ export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatS
122
151
  async function deliverTurn(chatId, turn, reason) {
123
152
  if (!(turn.ours || turn.goal || turn.mirrored)) return
124
153
  if (turn.stopped || reason?.kind === 'aborted' || reason?.kind === 'blocked') return
125
- const { text, silent } = stripSilent(turn.texts.join('\n\n'))
154
+ // Text the session already sent here with its files is not repeated.
155
+ const sent = new Set(turn.sentTexts.map(sameText))
156
+ const { text, silent } = stripSilent(turn.texts.filter((part) => !sent.has(sameText(part))).join('\n\n'))
126
157
  if (reason?.kind === 'error' && text.length === 0) {
127
158
  await sendText(chatId, `❌ ${reason.error?.message ?? 'The turn failed.'}`, { plain: true })
128
159
  return
@@ -140,6 +171,11 @@ export function createTurns({ ctx, getStore, isRunning, setting, outbound, chatS
140
171
  if (turn.ours && !silent && !turn.sent && reason?.kind !== 'error') await sendText(chatId, '✅ Done.', { plain: true })
141
172
  }
142
173
 
174
+ /** @returns text reduced to its words, for comparing an answer with a send. */
175
+ function sameText(text) {
176
+ return String(text).replace(/\s+/gu, ' ').trim()
177
+ }
178
+
143
179
  /** Tell the chat when its session's goal changes phase. */
144
180
  async function announceGoal(chatId, sessionId) {
145
181
  const goals = ctx.get('goals')
package/tools.js CHANGED
@@ -32,7 +32,7 @@ export const SEND_SCHEMA = {
32
32
  export function createSendTool(getService) {
33
33
  return {
34
34
  name: 'deltachat_send',
35
- description: 'Send a message, files or a reaction into a Delta Chat chat of the DSH Delta Chat bot, addressed by its numeric chat id. Not for answering a Delta Chat message in its own session: the final answer of the turn is delivered there by itself. Use it to reach a chat from another session or a job, to send files, or to react to a message. The message is also recorded in the session bound to that chat; no turn is started.',
35
+ description: 'Send a message, files or a reaction into a Delta Chat chat of the DSH Delta Chat bot, addressed by its numeric chat id. Not for answering in the chat this session is bound to: the final answer of the turn is delivered there by itself, so text-only sends into that chat are refused. Use it to reach another chat, to send files, or to react to a message. The message is also recorded in the session bound to that chat; no turn is started.',
36
36
  parameters: SEND_SCHEMA,
37
37
  output: { schema: { type: 'object' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
38
38
  async execute(args, exec) {