@soffits/dsh-deltachat-bot 0.0.0-stage → 0.1.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/src/models.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Model selection for chats. The session's live agent owns its model
3
+ * (`agent.model.current()`); the bot keeps no copy of it. Lists come from the
4
+ * same catalog as the Web model picker (`sessionController.modelCatalog()`),
5
+ * so every configured provider and model shows with its Web display name.
6
+ */
7
+
8
+ /** How long one catalog read is reused for labels and menus. */
9
+ const CATALOG_TTL_MS = 60_000
10
+
11
+ /**
12
+ * Copy a selection, dropping an absent effort.
13
+ * @param selection - provider, model and optional effort.
14
+ * @returns a detached selection.
15
+ */
16
+ function toSelection({ provider, model, reasoningEffort }) {
17
+ return { provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }) }
18
+ }
19
+
20
+ /**
21
+ * Read a session's current model: the live agent's own choice, or, for a
22
+ * session that is not running, its recorded selection.
23
+ * @param ctx - Host context.
24
+ * @param sessionId - DSH session id.
25
+ * @returns the selection, or undefined when the session has none recorded.
26
+ */
27
+ export async function sessionModelOf(ctx, sessionId) {
28
+ if (typeof sessionId !== 'string') return undefined
29
+ const agent = ctx.get('agents')?.get(sessionId)
30
+ if (typeof agent?.model?.current === 'function') {
31
+ const current = agent.model.current()
32
+ return current === undefined ? undefined : toSelection(current)
33
+ }
34
+ const query = ctx.get('sessionQuery')
35
+ if (typeof query?.observeSession !== 'function') return undefined
36
+ let observation
37
+ try {
38
+ observation = await query.observeSession(sessionId)
39
+ // The modelSelection projection's view: `next` is the pending choice or
40
+ // the route of the last request.
41
+ const view = observation?.projections?.values?.modelSelection
42
+ const recorded = view?.next ?? view?.pending ?? view?.lastUsed
43
+ return typeof recorded?.provider === 'string' && typeof recorded?.model === 'string' ? toSelection(recorded) : undefined
44
+ } catch {
45
+ return undefined
46
+ } finally {
47
+ observation?.[Symbol.dispose]?.()
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Read the deployment default model (the one the Web UI saves).
53
+ * @param ctx - Host context.
54
+ * @returns the selection, or undefined without a default-model service.
55
+ */
56
+ export function defaultModelOf(ctx) {
57
+ try {
58
+ const selection = ctx.get('agentDefaultModel')?.currentSelection()
59
+ if (typeof selection?.provider === 'string' && typeof selection?.model === 'string') return toSelection(selection)
60
+ } catch {
61
+ // No default configured.
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ /**
67
+ * Whether the catalog no longer offers a selection's provider or model, for
68
+ * example after a provider was removed from the profile. A provider the
69
+ * catalog reports as failing is not counted: its models are unknown now, not
70
+ * gone. Without a catalog nothing is called gone.
71
+ * @param catalog - loaded catalog, or undefined.
72
+ * @param selection - provider and model, or undefined.
73
+ * @returns true when the selection names something the catalog dropped.
74
+ */
75
+ export function isGoneModel(catalog, selection) {
76
+ if (catalog === undefined || typeof selection?.provider !== 'string' || typeof selection?.model !== 'string') return false
77
+ if (catalog.failures.some((failure) => failure?.id === selection.provider)) return false
78
+ return findModel(catalog, selection) === undefined
79
+ }
80
+
81
+ /**
82
+ * Whether two selections name the same provider, model and effort.
83
+ * @param left - first selection.
84
+ * @param right - second selection.
85
+ * @returns true when they agree.
86
+ */
87
+ export function sameSelection(left, right) {
88
+ return left?.provider === right?.provider && left?.model === right?.model && left?.reasoningEffort === right?.reasoningEffort
89
+ }
90
+
91
+ /**
92
+ * Cached reader for the Web model catalog.
93
+ * @param ctx - Host context.
94
+ * @returns `load()` resolving the catalog, or undefined when it is unavailable.
95
+ */
96
+ export function createModelCatalog(ctx) {
97
+ let cached
98
+ let loadedAt = 0
99
+ let pending
100
+ return {
101
+ /**
102
+ * @param fresh - skip the short-lived cache (used before applying a pick).
103
+ * @returns `{ groups, failures }` as the Web picker gets it, or undefined.
104
+ */
105
+ async load(fresh = false) {
106
+ if (!fresh && cached !== undefined && Date.now() - loadedAt < CATALOG_TTL_MS) return cached
107
+ pending ??= (async () => {
108
+ const controller = ctx.get('sessionController')
109
+ if (typeof controller?.modelCatalog !== 'function') return undefined
110
+ const catalog = await controller.modelCatalog()
111
+ cached = { groups: Array.isArray(catalog?.groups) ? catalog.groups : [], failures: Array.isArray(catalog?.failures) ? catalog.failures : [] }
112
+ loadedAt = Date.now()
113
+ return cached
114
+ })().finally(() => { pending = undefined })
115
+ try {
116
+ return await pending
117
+ } catch {
118
+ return cached
119
+ }
120
+ },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Find a catalog entry for a selection.
126
+ * @param catalog - loaded catalog.
127
+ * @param selection - provider and model.
128
+ * @returns `{ group, model }`, or undefined.
129
+ */
130
+ export function findModel(catalog, selection) {
131
+ const group = catalog?.groups.find((entry) => entry.id === selection?.provider)
132
+ const model = group?.models.find((entry) => entry.id === selection?.model)
133
+ return model === undefined ? undefined : { group, model }
134
+ }
135
+
136
+ /**
137
+ * Display name of a selection's model, as the Web picker shows it.
138
+ * @param catalog - loaded catalog, or undefined.
139
+ * @param selection - provider and model.
140
+ * @returns the name, or the raw model id.
141
+ */
142
+ export function modelName(catalog, selection) {
143
+ if (selection === undefined) return '?'
144
+ return findModel(catalog, selection)?.model.name ?? selection.model
145
+ }
146
+
147
+ /**
148
+ * Effort choices for one catalog model, the same list the Web picker builds:
149
+ * a provider-default entry when the model has no default effort, then its
150
+ * efforts. A model without reasoning has none.
151
+ * @param model - catalog model entry.
152
+ * @param defaultLabel - label for the provider default.
153
+ * @returns `{ id, name }` entries; `id` undefined means the provider default.
154
+ */
155
+ export function effortChoices(model, defaultLabel) {
156
+ const reasoning = model?.reasoning
157
+ if (reasoning === undefined) return []
158
+ return [
159
+ ...(reasoning.defaultEffort === undefined ? [{ id: undefined, name: defaultLabel }] : []),
160
+ ...reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name })),
161
+ ]
162
+ }
163
+
164
+ /**
165
+ * Display name of a selection's effective effort.
166
+ * @param catalog - loaded catalog, or undefined.
167
+ * @param selection - current selection.
168
+ * @param defaultLabel - label for the provider default.
169
+ * @returns the name.
170
+ */
171
+ export function effortName(catalog, selection, defaultLabel) {
172
+ const reasoning = findModel(catalog, selection)?.model.reasoning
173
+ const effective = selection?.reasoningEffort ?? reasoning?.defaultEffort
174
+ if (effective === undefined) return defaultLabel
175
+ return reasoning?.efforts.find((effort) => effort.id === effective)?.name ?? effective
176
+ }
177
+
178
+ /**
179
+ * The selection a model pick makes, by the Web picker's rule: the same model
180
+ * keeps its effort, another model starts at its default effort.
181
+ * @param current - the session's current selection.
182
+ * @param provider - picked provider id.
183
+ * @param model - picked catalog model entry.
184
+ * @returns the selection to apply.
185
+ */
186
+ export function pickedSelection(current, provider, model) {
187
+ const same = current?.provider === provider && current?.model === model.id
188
+ const reasoningEffort = same ? current.reasoningEffort ?? model.reasoning?.defaultEffort : model.reasoning?.defaultEffort
189
+ return toSelection({ provider, model: model.id, reasoningEffort })
190
+ }
191
+
@@ -0,0 +1,174 @@
1
+ /**
2
+ * What the bot offers outside chats: the send service, the chat list, the
3
+ * invite, joining someone's invite, allowing a key, and its status.
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import { dataDir, POST_SOURCE_KIND } from './constants.js'
7
+ import { allowedEntry, normalizeFingerprint } from './identity.js'
8
+ import { finalError } from './outbound.js'
9
+ import { recordMessage, resolveAgent } from './sessions.js'
10
+ import { clip } from './text.js'
11
+
12
+ /**
13
+ * The address an invite link names as the profile's primary relay address.
14
+ * @param link - invite link.
15
+ * @returns the address, or undefined.
16
+ */
17
+ export function primaryAddress(link) {
18
+ const match = String(link ?? '').match(/[#&]a=([^&]+)/u)
19
+ return match === null ? undefined : decodeURIComponent(match[1].replace(/\+/gu, ' '))
20
+ }
21
+
22
+ /**
23
+ * Create the operations.
24
+ * @param deps - `{ ctx, getStore, storeReady, requireRuntime, getRuntime, getStatus, setting, outbound, currentTurn, writeInvite, info, reportError, messageOf }`.
25
+ */
26
+ export function createOperations({ ctx, getStore, storeReady, requireRuntime, getRuntime, getStatus, setting, outbound, currentTurn, writeInvite, info, reportError, messageOf }) {
27
+ const { queueOut, sendText, sendFiles, checkFiles } = outbound
28
+
29
+ /**
30
+ * Send text, files or a reaction into a chat on behalf of a job, a session
31
+ * or a command, and record it in the session bound to that chat.
32
+ * @param request - `{ to, text, files, from, replyTo, reaction }`: `to` is
33
+ * the chat id; `from` `{ job, sessionId, label }` names the sender;
34
+ * `replyTo` quotes a message; `reaction` reacts to `replyTo` instead of
35
+ * sending text.
36
+ * @returns where it went and whether the session recorded it.
37
+ */
38
+ async function sendToChat(request) {
39
+ await storeReady
40
+ const store = getStore()
41
+ const { dc, accountId } = requireRuntime()
42
+ const chatId = Number(request?.to)
43
+ if (!Number.isSafeInteger(chatId) || chatId <= 9) throw finalError(`Unknown chat ${request?.to}; /id in a chat shows its id.`)
44
+ const chatInfo = await dc.rpc.getBasicChatInfo(accountId, chatId).catch(() => undefined)
45
+ if (chatInfo === undefined || chatInfo.isDeviceChat) throw finalError(`Unknown chat ${chatId}; /id in a chat shows its id.`)
46
+ const replyTo = request?.replyTo === undefined || request?.replyTo === null ? undefined : Number(request.replyTo)
47
+ if (typeof request?.reaction === 'string') {
48
+ if (!Number.isSafeInteger(replyTo)) throw finalError('A reaction needs replyTo: the message id to react to.')
49
+ await dc.rpc.sendReaction(accountId, replyTo, request.reaction.length === 0 ? [] : [request.reaction])
50
+ return { chatId, reacted: replyTo }
51
+ }
52
+ const text = typeof request?.text === 'string' ? request.text.trim() : ''
53
+ const paths = Array.isArray(request?.files) ? request.files.map(String) : []
54
+ if (text.length === 0 && paths.length === 0) throw finalError('Nothing to send: give text or files.')
55
+ const files = await checkFiles(paths)
56
+ const messageIds = await queueOut(chatId, async () => {
57
+ const ids = text.length > 0 ? await sendText(chatId, text, { quote: replyTo }) : []
58
+ ids.push(...await sendFiles(chatId, files))
59
+ return ids
60
+ })
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
65
+ await store.noteSent({
66
+ chatId,
67
+ messageIds,
68
+ text,
69
+ files: files.map((file) => file.name),
70
+ job: typeof from.job === 'string' && from.job.trim() ? from.job.trim() : null,
71
+ fromSession: typeof from.sessionId === 'string' ? from.sessionId : null,
72
+ label: typeof from.label === 'string' ? from.label : null,
73
+ })
74
+ let recorded = false
75
+ let recordError
76
+ if (typeof sessionId === 'string') {
77
+ try {
78
+ const agent = await resolveAgent(ctx, sessionId)
79
+ const self = sessionId === from.sessionId
80
+ const by = self ? ' by this session' : from.job ? ` by job "${from.job}"` : from.sessionId ? ` by session ${from.sessionId}` : from.label ? ` by ${from.label}` : ''
81
+ const header = `Sent to Delta Chat chat "${chatInfo.name}"${by}`
82
+ const content = [{ type: 'text', text: `[${header}]${text ? `\n${text}` : ''}${files.length > 0 ? `\n[Files: ${files.map((file) => file.name).join(', ')}]` : ''}` }]
83
+ recordMessage(agent, { id: randomUUID(), role: 'user', content, source: { kind: POST_SOURCE_KIND, form: 'notice', summary: clip(header, 120), chatId, self } })
84
+ recorded = true
85
+ } catch (error) {
86
+ recordError = messageOf(error)
87
+ reportError('send-record', error)
88
+ }
89
+ }
90
+ return { chatId, chat: chatInfo.name, messageIds, sessionId: sessionId ?? null, recorded, ...(recordError ? { recordError } : {}) }
91
+ }
92
+
93
+ /** Known chats: every chat bound to a session, with its name. */
94
+ async function listChats() {
95
+ await storeReady
96
+ const store = getStore()
97
+ const { dc, accountId } = requireRuntime()
98
+ const result = []
99
+ for (const [chatId, record] of Object.entries(store.data.chats)) {
100
+ const chatInfo = await dc.rpc.getBasicChatInfo(accountId, Number(chatId)).catch(() => undefined)
101
+ if (chatInfo === undefined) continue
102
+ result.push({ chatId: Number(chatId), name: chatInfo.name, type: chatInfo.chatType, sessionId: record.sessionId ?? null })
103
+ }
104
+ return result
105
+ }
106
+
107
+ /** The bot's invite link and QR code file. */
108
+ async function invite() {
109
+ const { dc, accountId } = requireRuntime()
110
+ return writeInvite(dc, accountId, dataDir())
111
+ }
112
+
113
+ /** Secure-join someone's invite link (a contact or a group). */
114
+ async function joinInvite(link) {
115
+ const { dc, accountId } = requireRuntime()
116
+ const checked = await dc.rpc.checkQr(accountId, link)
117
+ if (!['askVerifyContact', 'askVerifyGroup', 'askJoinBroadcast'].includes(checked?.kind)) {
118
+ throw finalError(`Not an invite link (${checked?.kind ?? 'unknown'}).`)
119
+ }
120
+ const fingerprint = normalizeFingerprint(checked.fingerprint)
121
+ const allowed = allowedEntry(setting('allowedUsers', []), fingerprint)
122
+ if (allowed === undefined) {
123
+ throw finalError(`The invite belongs to key ${fingerprint || 'unknown'}, which is not in allowedUsers. Add it first.`)
124
+ }
125
+ const chatId = await dc.rpc.secureJoin(accountId, link)
126
+ info(`joining ${allowed.name || fingerprint} through their invite (chat ${chatId})`)
127
+ return { chatId, name: allowed.name, fingerprint }
128
+ }
129
+
130
+ /**
131
+ * Add someone to `allowedUsers` through the Host's config editor, which
132
+ * writes the profile's patch the same way the settings page does.
133
+ * @param fingerprint - their key fingerprint.
134
+ * @param label - how their messages are labelled.
135
+ */
136
+ async function allowUser(fingerprint, label) {
137
+ const normalized = normalizeFingerprint(fingerprint)
138
+ if (normalized === '') throw finalError('A key fingerprint has 40 hex characters.')
139
+ const entry = ctx.fiber?.entry
140
+ const editor = ctx.get('configEditor')
141
+ if (entry === undefined || editor === undefined) throw new Error('This profile cannot save the bot\'s configuration; add the fingerprint to allowedUsers by hand.')
142
+ await editor.edit(entry, (current) => {
143
+ const list = Array.isArray(current?.allowedUsers) ? current.allowedUsers.filter((item) => normalizeFingerprint(item?.fingerprint) !== normalized) : []
144
+ return { ...current, allowedUsers: [...list, { fingerprint: normalized, name: String(label ?? '').trim() }] }
145
+ })
146
+ await storeReady
147
+ await getStore().clearPending([normalized])
148
+ return { fingerprint: normalized, name: String(label ?? '').trim() }
149
+ }
150
+
151
+ /** Runtime facts for `/status` and the command line. */
152
+ async function describe() {
153
+ await storeReady
154
+ const store = getStore()
155
+ const runtime = getRuntime()
156
+ if (runtime === undefined) return { running: false, state: getStatus().state, detail: getStatus().detail ?? '' }
157
+ const { dc, accountId, startedAt } = runtime
158
+ const transports = await dc.rpc.listTransports(accountId).catch(() => [])
159
+ const connectivity = await dc.rpc.getConnectivity(accountId).catch(() => undefined)
160
+ const selfAddr = primaryAddress(await dc.rpc.getChatSecurejoinQrCode(accountId, null).catch(() => '')) ?? transports[0]?.addr ?? null
161
+ return {
162
+ running: true,
163
+ uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
164
+ primary: selfAddr,
165
+ relays: transports.map((transport) => transport.addr),
166
+ connectivity,
167
+ allowedUsers: setting('allowedUsers', []).length,
168
+ pending: Object.values(store.data.pending).map((entry) => ({ fingerprint: entry.fingerprint, displayName: entry.displayName, lastSeen: new Date(entry.lastSeen).toISOString(), count: entry.count })),
169
+ chats: Object.keys(store.data.chats).length,
170
+ }
171
+ }
172
+
173
+ return { sendToChat, listChats, invite, joinInvite, allowUser, describe }
174
+ }
@@ -0,0 +1,79 @@
1
+ /** Sending into chats: text made plain and split, files, notices, in order per chat. */
2
+ import { stat } from 'node:fs/promises'
3
+ import { basename, isAbsolute } from 'node:path'
4
+ import { MAX_FILE_BYTES } from './constants.js'
5
+ import { messageData, viewTypeOf } from './core.js'
6
+ import { splitText, toPlainText } from './text.js'
7
+
8
+ /** An error that a retry will not fix. */
9
+ export function finalError(message) {
10
+ return Object.assign(new Error(message), { retryable: false })
11
+ }
12
+
13
+ /**
14
+ * Create the sender.
15
+ * @param deps - `{ requireRuntime, chatState, reportError, messageOf }`.
16
+ * @returns `{ queueOut, sendText, checkFiles, sendFiles, notice }`.
17
+ */
18
+ export function createOutbound({ requireRuntime, chatState, reportError, messageOf }) {
19
+ /** Run output for a chat in order. */
20
+ function queueOut(chatId, task) {
21
+ const chat = chatState(chatId)
22
+ const run = chat.out.catch(() => {}).then(task)
23
+ chat.out = run.catch((error) => reportError('send', error))
24
+ return run
25
+ }
26
+
27
+ // ---------------------------------------------------------------- outbound
28
+
29
+ /**
30
+ * Send text (Markdown is made plain) into a chat.
31
+ * @param chatId - chat id.
32
+ * @param text - Markdown text.
33
+ * @param options - `{ quote, plain }`: message id to quote on the first part;
34
+ * `plain` skips the Markdown conversion.
35
+ * @returns the sent message ids.
36
+ */
37
+ async function sendText(chatId, text, { quote, plain = false } = {}) {
38
+ const { dc, accountId } = requireRuntime()
39
+ const ids = []
40
+ for (const [index, part] of splitText(plain ? text : toPlainText(text)).entries()) {
41
+ ids.push(await dc.rpc.sendMsg(accountId, chatId, messageData({ text: part, ...(index === 0 && Number.isSafeInteger(quote) ? { quotedMessageId: quote } : {}) })))
42
+ }
43
+ return ids
44
+ }
45
+
46
+ /**
47
+ * Check files before anything is sent.
48
+ * @param paths - absolute paths.
49
+ * @returns `{ path, name, size }` entries; throws on the first bad one.
50
+ */
51
+ async function checkFiles(paths) {
52
+ const files = []
53
+ for (const path of paths) {
54
+ if (!isAbsolute(path)) throw finalError(`File paths must be absolute: ${path}`)
55
+ const info = await stat(path).catch((error) => { throw finalError(`${path}: ${messageOf(error)}`) })
56
+ if (!info.isFile()) throw finalError(`${path} is not a file`)
57
+ if (info.size > MAX_FILE_BYTES) throw finalError(`${path} is larger than ${MAX_FILE_BYTES / 1024 / 1024} MB`)
58
+ files.push({ path, name: basename(path), size: info.size })
59
+ }
60
+ return files
61
+ }
62
+
63
+ /** Send checked files; the core copies each into its blob directory. */
64
+ async function sendFiles(chatId, files) {
65
+ const { dc, accountId } = requireRuntime()
66
+ const ids = []
67
+ for (const file of files) {
68
+ ids.push(await dc.rpc.sendMsg(accountId, chatId, messageData({ file: file.path, filename: file.name, viewtype: viewTypeOf(file.path) })))
69
+ }
70
+ return ids
71
+ }
72
+
73
+ /** A short bot notice (already plain text) into a chat. */
74
+ function notice(chatId, text) {
75
+ return queueOut(chatId, () => sendText(chatId, text, { plain: true }))
76
+ }
77
+
78
+ return { queueOut, sendText, checkFiles, sendFiles, notice }
79
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Approval requests and agent questions for turns started from Delta Chat.
3
+ * Delta Chat has no buttons, so a question lists its options as numbers and
4
+ * the next message answers it; an approval waits for /allow or /deny.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ /**
9
+ * Parse a reply to a question with options.
10
+ * @param text - the reply.
11
+ * @param question - `{ options, multiSelect }`.
12
+ * @returns `{ selected, custom }`: option labels picked by number, or the
13
+ * whole reply as a free answer.
14
+ */
15
+ export function parseAnswer(text, question) {
16
+ const options = Array.isArray(question?.options) ? question.options : []
17
+ const trimmed = String(text ?? '').trim()
18
+ if (options.length > 0 && /^\d+(?:[\s,]+\d+)*$/u.test(trimmed)) {
19
+ const numbers = trimmed.split(/[\s,]+/u).map(Number)
20
+ if (numbers.every((number) => number >= 1 && number <= options.length)) {
21
+ const picked = [...new Set(numbers)].map((number) => options[number - 1].label)
22
+ return { selected: question.multiSelect === true ? picked : picked.slice(0, 1), custom: undefined }
23
+ }
24
+ }
25
+ return { selected: [], custom: trimmed }
26
+ }
27
+
28
+ /**
29
+ * Text of one question.
30
+ * @param question - user question.
31
+ * @param index - its position.
32
+ * @param total - questions in the batch.
33
+ */
34
+ export function questionText(question, index, total) {
35
+ const lines = []
36
+ if (question.header) lines.push(question.header)
37
+ lines.push(`❓ ${question.question}`)
38
+ if (question.detail) lines.push('', String(question.detail).slice(0, 3000))
39
+ const options = Array.isArray(question.options) ? question.options : []
40
+ if (options.length > 0) {
41
+ lines.push('')
42
+ for (const [number, option] of options.entries()) {
43
+ lines.push(`${number + 1}. ${option.label}${option.description ? ` — ${option.description}` : ''}`)
44
+ }
45
+ lines.push('', question.multiSelect === true
46
+ ? 'Reply with one or more numbers (like "1 3"), or write your own answer.'
47
+ : 'Reply with a number, or write your own answer.')
48
+ } else {
49
+ lines.push('', 'Reply with your answer.')
50
+ }
51
+ if (total > 1) lines.push(`(Question ${index + 1} of ${total}; /stop cancels.)`)
52
+ return lines.join('\n')
53
+ }
54
+
55
+ /**
56
+ * Create the prompt broker.
57
+ * @param options - `{ chatOfAgent, send, warn }`: `chatOfAgent(agent)` gives
58
+ * the chat that should answer for a turn, or undefined to leave it to DSH.
59
+ * @returns `{ attach, consumeReply, settleApproval, cancelChat, cancelAll, pending }`.
60
+ */
61
+ export function createPrompts({ chatOfAgent, send, warn }) {
62
+ const attached = new WeakSet()
63
+ /** Pending question batches by chat id. */
64
+ const questions = new Map()
65
+ /** Pending approvals by chat id (a list, oldest first). */
66
+ const approvals = new Map()
67
+
68
+ function finishQuestion(chatId, batch, error) {
69
+ if (questions.get(chatId) !== batch) return
70
+ questions.delete(chatId)
71
+ batch.signal?.removeEventListener('abort', batch.onAbort)
72
+ if (error === undefined) batch.resolve({ answers: batch.answers })
73
+ else batch.reject(error)
74
+ }
75
+
76
+ async function askQuestions(chatId, req, next) {
77
+ const list = Array.isArray(req.questions) ? req.questions : []
78
+ if (list.length === 0 || questions.has(chatId)) return next()
79
+ const signal = req.signal
80
+ if (signal?.aborted) return next()
81
+ return new Promise((resolve, reject) => {
82
+ const batch = { id: randomUUID(), questions: list, index: 0, answers: [], resolve, reject, signal, onAbort: undefined }
83
+ batch.onAbort = () => {
84
+ finishQuestion(chatId, batch, signal.reason ?? new Error('question cancelled'))
85
+ void send(chatId, '⏹ Question cancelled.')
86
+ }
87
+ signal?.addEventListener('abort', batch.onAbort, { once: true })
88
+ questions.set(chatId, batch)
89
+ Promise.resolve(send(chatId, questionText(list[0], 0, list.length))).catch((error) => {
90
+ warn(`question not sent: ${error?.message ?? error}`)
91
+ finishQuestion(chatId, batch, error)
92
+ })
93
+ })
94
+ }
95
+
96
+ async function askApproval(chatId, req) {
97
+ if (req.signal?.aborted) return 'cancelled'
98
+ const reason = req.displayReason?.en ?? req.reason ?? 'The tool asked for approval.'
99
+ return new Promise((resolve) => {
100
+ const entry = { resolve, toolName: req.toolName, signal: req.signal, onAbort: undefined }
101
+ entry.onAbort = () => settle(chatId, entry, 'cancelled')
102
+ req.signal?.addEventListener('abort', entry.onAbort, { once: true })
103
+ const list = approvals.get(chatId) ?? []
104
+ list.push(entry)
105
+ approvals.set(chatId, list)
106
+ Promise.resolve(send(chatId, `🔐 Approval needed for ${req.toolName}:\n${reason}\n\nReply /allow or /deny.`)).catch(() => settle(chatId, entry, 'unavailable'))
107
+ })
108
+ }
109
+
110
+ function settle(chatId, entry, outcome) {
111
+ const list = approvals.get(chatId) ?? []
112
+ const index = list.indexOf(entry)
113
+ if (index === -1) return false
114
+ list.splice(index, 1)
115
+ if (list.length === 0) approvals.delete(chatId)
116
+ entry.signal?.removeEventListener('abort', entry.onAbort)
117
+ entry.resolve(outcome)
118
+ return true
119
+ }
120
+
121
+ return {
122
+ /** Answer approval and question requests of an agent while its turn belongs to a chat. */
123
+ attach(agent) {
124
+ if (attached.has(agent) || typeof agent?.ctx?.on !== 'function') return
125
+ attached.add(agent)
126
+ agent.ctx.on('approval/request', (req, next) => {
127
+ const chatId = chatOfAgent(agent)
128
+ if (chatId === undefined) return next()
129
+ return askApproval(chatId, req)
130
+ }, { prepend: true })
131
+ agent.ctx.on('user-questions/request', (req, next) => {
132
+ const chatId = chatOfAgent(agent)
133
+ if (chatId === undefined) return next()
134
+ return askQuestions(chatId, req, next)
135
+ }, { prepend: true })
136
+ },
137
+ /**
138
+ * Take a message as the answer to the chat's open question.
139
+ * @returns true when the message answered a question.
140
+ */
141
+ consumeReply(chatId, text) {
142
+ const batch = questions.get(chatId)
143
+ if (batch === undefined || text.length === 0 || text.startsWith('/')) return false
144
+ const question = batch.questions[batch.index]
145
+ const { selected, custom } = parseAnswer(text, question)
146
+ batch.answers.push({ id: question.id, selected, ...(custom ? { custom } : {}) })
147
+ batch.index += 1
148
+ if (batch.index < batch.questions.length) {
149
+ void send(chatId, questionText(batch.questions[batch.index], batch.index, batch.questions.length))
150
+ } else {
151
+ finishQuestion(chatId, batch)
152
+ }
153
+ return true
154
+ },
155
+ /**
156
+ * Resolve the oldest approval of a chat.
157
+ * @returns the tool name, or undefined when none was waiting.
158
+ */
159
+ settleApproval(chatId, allowed) {
160
+ const entry = approvals.get(chatId)?.[0]
161
+ if (entry === undefined) return undefined
162
+ settle(chatId, entry, allowed ? 'allowed-once' : 'rejected')
163
+ return entry.toolName
164
+ },
165
+ /** Cancel what a chat was asked (on /stop). */
166
+ cancelChat(chatId) {
167
+ const batch = questions.get(chatId)
168
+ if (batch !== undefined) finishQuestion(chatId, batch, new Error('Question cancelled by /stop'))
169
+ for (const entry of [...(approvals.get(chatId) ?? [])]) settle(chatId, entry, 'cancelled')
170
+ },
171
+ cancelAll() {
172
+ for (const chatId of [...questions.keys(), ...approvals.keys()]) this.cancelChat(chatId)
173
+ },
174
+ /** @returns what a chat is being asked: `{ question, approvals }`. */
175
+ pending(chatId) {
176
+ return { question: questions.has(chatId), approvals: approvals.get(chatId)?.length ?? 0 }
177
+ },
178
+ }
179
+ }