@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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/bin/dsh-deltachat.js +140 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +14 -0
- package/index.js +4 -0
- package/locale/en.json +6 -0
- package/package.json +67 -4
- package/src/app.js +628 -0
- package/src/commands.js +429 -0
- package/src/config.js +63 -0
- package/src/constants.js +50 -0
- package/src/controls.js +214 -0
- package/src/core.js +236 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +174 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/sessions.js +380 -0
- package/src/spool.js +172 -0
- package/src/state.js +147 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +161 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
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
|
+
}
|
package/src/outbound.js
ADDED
|
@@ -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
|
+
}
|