@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 +7 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/app.js +34 -9
- package/src/commands.js +25 -0
- package/src/helpers.js +180 -0
- package/src/operations.js +13 -4
- package/src/reactions.js +125 -0
- package/src/sessions.js +14 -6
- package/src/state.js +18 -3
- package/src/turns.js +46 -10
- package/tools.js +1 -1
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
|
-
-
|
|
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.
|
|
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
|
|
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 {
|
|
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 &&
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
package/src/reactions.js
ADDED
|
@@ -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
|
|
156
|
-
function
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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)
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|