@soffits/dsh-deltachat-bot 0.0.0-stage → 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 +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -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 +653 -0
- package/src/commands.js +454 -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/helpers.js +180 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +183 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/reactions.js +125 -0
- package/src/sessions.js +388 -0
- package/src/spool.js +172 -0
- package/src/state.js +162 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +197 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
package/src/controls.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session controls from a chat: model, reasoning effort, access mode and
|
|
3
|
+
* context use, picked from numbered lists.
|
|
4
|
+
*/
|
|
5
|
+
import { defaultModelOf, effortChoices, effortName, findModel, modelName, pickedSelection, sessionModelOf } from './models.js'
|
|
6
|
+
import { controllerOf, currentPermission, permissionOptions, setPermission } from './sessions.js'
|
|
7
|
+
|
|
8
|
+
/** @returns `n` as a 1-based list index into `items`, or undefined. */
|
|
9
|
+
export function pick(items, value) {
|
|
10
|
+
const number = Number(value)
|
|
11
|
+
return Number.isSafeInteger(number) && number >= 1 && number <= items.length ? items[number - 1] : undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The chat's session id, or undefined. */
|
|
15
|
+
export function sessionOf(bridge, chatId) {
|
|
16
|
+
const id = bridge.store().chat(chatId)?.sessionId
|
|
17
|
+
return typeof id === 'string' ? id : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The chat session's live agent when it is loaded, or undefined. */
|
|
21
|
+
export function liveAgent(bridge, chatId) {
|
|
22
|
+
const sessionId = sessionOf(bridge, chatId)
|
|
23
|
+
return sessionId === undefined ? undefined : bridge.ctx.get('agents')?.get(sessionId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @returns a compact token count, such as 1M or 12.3k. */
|
|
27
|
+
function formatTokens(value) {
|
|
28
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '?'
|
|
29
|
+
if (value >= 1_000_000) return `${Math.round(value / 100_000) / 10}M`
|
|
30
|
+
if (value >= 1000) return `${Math.round(value / 100) / 10}k`
|
|
31
|
+
return String(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @returns context use, such as "16.8k of 1M" or "16.8k used". */
|
|
35
|
+
function contextLabel({ used, windowSize }) {
|
|
36
|
+
if (used === undefined) return windowSize === undefined ? 'unknown' : `${formatTokens(windowSize)} window`
|
|
37
|
+
return windowSize === undefined ? `${formatTokens(used)} used` : `${formatTokens(used)} of ${formatTokens(windowSize)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Model, effort and access mode of a chat's session. */
|
|
41
|
+
async function controls(bridge, chatId) {
|
|
42
|
+
const catalog = await bridge.modelCatalog.load()
|
|
43
|
+
const sessionId = sessionOf(bridge, chatId)
|
|
44
|
+
const current = await sessionModelOf(bridge.ctx, sessionId) ?? defaultModelOf(bridge.ctx)
|
|
45
|
+
const agent = liveAgent(bridge, chatId)
|
|
46
|
+
const mode = currentPermission(bridge.ctx, agent)
|
|
47
|
+
const modeName = permissionOptions(bridge.ctx).find((option) => option.value === mode)?.name ?? mode ?? '?'
|
|
48
|
+
return { catalog, current, mode, modeName }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Context window and last request size of a chat's session. */
|
|
52
|
+
async function contextUse(bridge, chatId, current) {
|
|
53
|
+
let windowSize
|
|
54
|
+
try {
|
|
55
|
+
const llm = bridge.ctx.get('llm')
|
|
56
|
+
const resolve = llm?.resolveModelInfo ?? llm?.resolveModel
|
|
57
|
+
const info = current === undefined || resolve === undefined ? undefined : await resolve.call(llm, current.provider, current.model)
|
|
58
|
+
windowSize = info?.context?.contextWindow
|
|
59
|
+
} catch {
|
|
60
|
+
// Unknown.
|
|
61
|
+
}
|
|
62
|
+
if (windowSize === undefined) {
|
|
63
|
+
const catalog = await bridge.modelCatalog.load()
|
|
64
|
+
windowSize = findModel(catalog, current)?.model?.contextWindow
|
|
65
|
+
}
|
|
66
|
+
let used
|
|
67
|
+
const sessionId = sessionOf(bridge, chatId)
|
|
68
|
+
if (sessionId !== undefined) {
|
|
69
|
+
try {
|
|
70
|
+
const events = (await bridge.ctx.get('sessionQuery')?.readSurface(sessionId))?.events ?? []
|
|
71
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
72
|
+
const usage = events[index]?.type === 'assistant/message' ? (events[index].data?.usage ?? events[index].data?.message?.usage) : undefined
|
|
73
|
+
if (usage === undefined) continue
|
|
74
|
+
used = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Unknown.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { windowSize, used }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Apply a model selection the way the Web picker does, and remember it for /new. */
|
|
85
|
+
async function selectModel(bridge, chatId, selection) {
|
|
86
|
+
const store = bridge.store()
|
|
87
|
+
store.data.picks[String(chatId)] = selection
|
|
88
|
+
await store.save()
|
|
89
|
+
const agent = await bridge.ensureAgent(chatId, { claim: true })
|
|
90
|
+
const controller = controllerOf(bridge.ctx)
|
|
91
|
+
if (typeof controller?.selectModel === 'function') {
|
|
92
|
+
await controller.selectModel({ sessionId: agent.session?.id ?? agent.id, ...selection })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
agent.model.select(selection)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Handlers of the control commands, merged into the command table. */
|
|
99
|
+
export const CONTROL_HANDLERS = {
|
|
100
|
+
async menu(bridge, { chatId }) {
|
|
101
|
+
const { catalog, current, modeName } = await controls(bridge, chatId)
|
|
102
|
+
const context = await contextUse(bridge, chatId, current)
|
|
103
|
+
await bridge.notice(chatId, [
|
|
104
|
+
'Session controls:',
|
|
105
|
+
`🤖 Model: ${modelName(catalog, current)} — /model`,
|
|
106
|
+
`🧠 Effort: ${effortName(catalog, current, 'Default')} — /effort`,
|
|
107
|
+
`🛡 Access: ${modeName} — /mode`,
|
|
108
|
+
`📚 Context: ${contextLabel(context)} — /context`,
|
|
109
|
+
].join('\n'))
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async model(bridge, { chatId }, args) {
|
|
113
|
+
const { catalog, current } = await controls(bridge, chatId)
|
|
114
|
+
if (catalog === undefined || catalog.groups.length === 0) {
|
|
115
|
+
await bridge.notice(chatId, 'No models are available right now.')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const [first, second] = args.split(/\s+/u).filter(Boolean)
|
|
119
|
+
if (first === undefined) {
|
|
120
|
+
await bridge.notice(chatId, [
|
|
121
|
+
`Model now: ${modelName(catalog, current)}`,
|
|
122
|
+
'',
|
|
123
|
+
...catalog.groups.map((group, index) => `${index + 1}. ${group.name ?? group.id} (${group.models.length})${group.id === current?.provider ? ' ✅' : ''}`),
|
|
124
|
+
...(catalog.failures.length > 0 ? ['', `Unavailable: ${catalog.failures.map((failure) => failure.name ?? failure.id).join(', ')}`] : []),
|
|
125
|
+
'',
|
|
126
|
+
'/model <n> lists the models of provider n.',
|
|
127
|
+
].join('\n'))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const group = pick(catalog.groups, first)
|
|
131
|
+
if (group === undefined) {
|
|
132
|
+
await bridge.notice(chatId, `No provider ${first}; /model lists them.`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (second === undefined) {
|
|
136
|
+
await bridge.notice(chatId, [
|
|
137
|
+
`${group.name ?? group.id}:`,
|
|
138
|
+
...group.models.map((model, index) => `${index + 1}. ${model.name ?? model.id}${group.id === current?.provider && model.id === current?.model ? ' ✅' : ''}`),
|
|
139
|
+
'',
|
|
140
|
+
`/model ${first} <n> switches this chat's session to model n.`,
|
|
141
|
+
].join('\n'))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const model = pick(group.models, second)
|
|
145
|
+
if (model === undefined) {
|
|
146
|
+
await bridge.notice(chatId, `No model ${second}; /model ${first} lists them.`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
await selectModel(bridge, chatId, pickedSelection(current, group.id, model))
|
|
150
|
+
await bridge.notice(chatId, `🤖 Model: ${model.name ?? model.id}`)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async effort(bridge, { chatId }, args) {
|
|
154
|
+
const { catalog, current } = await controls(bridge, chatId)
|
|
155
|
+
const model = findModel(catalog, current)?.model
|
|
156
|
+
const choices = effortChoices(model, 'Default')
|
|
157
|
+
if (choices.length === 0) {
|
|
158
|
+
await bridge.notice(chatId, 'This model has no effort setting.')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const effective = current?.reasoningEffort ?? model?.reasoning?.defaultEffort
|
|
162
|
+
if (args.length === 0) {
|
|
163
|
+
await bridge.notice(chatId, [
|
|
164
|
+
'Reasoning effort:',
|
|
165
|
+
...choices.map((choice, index) => `${index + 1}. ${choice.name}${choice.id === effective ? ' ✅' : ''}`),
|
|
166
|
+
'',
|
|
167
|
+
'/effort <n> picks one.',
|
|
168
|
+
].join('\n'))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const choice = pick(choices, args.split(/\s+/u)[0])
|
|
172
|
+
if (choice === undefined || current === undefined) {
|
|
173
|
+
await bridge.notice(chatId, `No effort ${args}; /effort lists them.`)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
await selectModel(bridge, chatId, { provider: current.provider, model: current.model, ...(choice.id === undefined ? {} : { reasoningEffort: choice.id }) })
|
|
177
|
+
await bridge.notice(chatId, `🧠 Effort: ${choice.name}`)
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async mode(bridge, { chatId }, args) {
|
|
181
|
+
const options = permissionOptions(bridge.ctx)
|
|
182
|
+
const { mode } = await controls(bridge, chatId)
|
|
183
|
+
if (args.length === 0) {
|
|
184
|
+
await bridge.notice(chatId, [
|
|
185
|
+
'Access mode of this session:',
|
|
186
|
+
...options.map((option, index) => `${index + 1}. ${option.name}${option.value === mode ? ' ✅' : ''}`),
|
|
187
|
+
'',
|
|
188
|
+
'/mode <n> picks one.',
|
|
189
|
+
].join('\n'))
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const option = pick(options, args.split(/\s+/u)[0])
|
|
193
|
+
if (option === undefined) {
|
|
194
|
+
await bridge.notice(chatId, `No mode ${args}; /mode lists them.`)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
setPermission(bridge.ctx, await bridge.ensureAgent(chatId, { claim: true }), option.value)
|
|
198
|
+
await bridge.notice(chatId, `🛡 Access: ${option.name}`)
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async context(bridge, { chatId }) {
|
|
202
|
+
const { catalog, current, modeName } = await controls(bridge, chatId)
|
|
203
|
+
const context = await contextUse(bridge, chatId, current)
|
|
204
|
+
await bridge.notice(chatId, [
|
|
205
|
+
'Context:',
|
|
206
|
+
`Model: ${modelName(catalog, current)}${current === undefined ? '' : ` (${current.provider}/${current.model})`}`,
|
|
207
|
+
`Window: ${formatTokens(context.windowSize)}`,
|
|
208
|
+
`Last request: ${context.used === undefined ? 'unknown' : formatTokens(context.used)}`,
|
|
209
|
+
`Effort: ${effortName(catalog, current, 'Default')}`,
|
|
210
|
+
`Access: ${modeName}`,
|
|
211
|
+
].join('\n'))
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Delta Chat side: one chatmail profile driven through the official
|
|
3
|
+
* `deltachat-rpc-server` (JSON-RPC over stdio).
|
|
4
|
+
*
|
|
5
|
+
* The server is spawned here rather than with `startDeltaChat` from
|
|
6
|
+
* `@deltachat/stdio-rpc-server`, because that helper throws from the child's
|
|
7
|
+
* `exit` and `error` handlers, which would take the whole Host process down
|
|
8
|
+
* when the server quits. Here an unexpected exit resolves `exited`.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { extname, join } from 'node:path'
|
|
13
|
+
import { getRPCServerPath } from '@deltachat/stdio-rpc-server'
|
|
14
|
+
import { StdioDeltaChat } from '@deltachat/jsonrpc-client'
|
|
15
|
+
import { vcardFingerprint } from './identity.js'
|
|
16
|
+
|
|
17
|
+
/** Connectivity at or above this value means connected (DC_CONNECTIVITY_CONNECTED is 4000). */
|
|
18
|
+
const CONNECTED = 4000
|
|
19
|
+
/** Connectivity at or above this value is at least trying (connecting, working). */
|
|
20
|
+
const CONNECTING = 3000
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A complete `MessageData` object; the RPC server wants every field.
|
|
24
|
+
* @param data - fields to set.
|
|
25
|
+
* @returns the message data.
|
|
26
|
+
*/
|
|
27
|
+
export function messageData(data) {
|
|
28
|
+
return {
|
|
29
|
+
text: null,
|
|
30
|
+
html: null,
|
|
31
|
+
viewtype: null,
|
|
32
|
+
file: null,
|
|
33
|
+
filename: null,
|
|
34
|
+
location: null,
|
|
35
|
+
overrideSenderName: null,
|
|
36
|
+
quotedMessageId: null,
|
|
37
|
+
quotedText: null,
|
|
38
|
+
...data,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** File extensions and the Delta Chat view type they are sent as. */
|
|
43
|
+
const VIEW_TYPES = new Map([
|
|
44
|
+
['.jpg', 'Image'], ['.jpeg', 'Image'], ['.png', 'Image'], ['.webp', 'Image'], ['.heic', 'Image'],
|
|
45
|
+
['.gif', 'Gif'],
|
|
46
|
+
['.mp4', 'Video'], ['.mov', 'Video'], ['.webm', 'Video'], ['.mkv', 'Video'],
|
|
47
|
+
['.mp3', 'Audio'], ['.m4a', 'Audio'], ['.ogg', 'Audio'], ['.opus', 'Audio'], ['.wav', 'Audio'], ['.flac', 'Audio'],
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a file is shown in Delta Chat, by its extension.
|
|
52
|
+
* @param path - file path.
|
|
53
|
+
* @returns a Delta Chat view type.
|
|
54
|
+
*/
|
|
55
|
+
export function viewTypeOf(path) {
|
|
56
|
+
return VIEW_TYPES.get(extname(String(path)).toLowerCase()) ?? 'File'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start the RPC server on an accounts directory.
|
|
61
|
+
* @param options - `{ dir, log }`.
|
|
62
|
+
* @returns `{ dc, close, exited }`; `dc` is a `StdioDeltaChat` with its event
|
|
63
|
+
* loop running; `exited` resolves with an error when the server quits
|
|
64
|
+
* without `close` (requests in flight then never settle, so callers race
|
|
65
|
+
* their work against it).
|
|
66
|
+
*/
|
|
67
|
+
export async function startServer({ dir, log }) {
|
|
68
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
69
|
+
const binary = getRPCServerPath()
|
|
70
|
+
const child = spawn(binary, [], {
|
|
71
|
+
env: {
|
|
72
|
+
...(process.env.RUST_LOG === undefined ? {} : { RUST_LOG: process.env.RUST_LOG }),
|
|
73
|
+
DC_ACCOUNTS_PATH: dir,
|
|
74
|
+
},
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
|
+
})
|
|
77
|
+
let closing = false
|
|
78
|
+
let stderr = ''
|
|
79
|
+
let reportExit
|
|
80
|
+
const exited = new Promise((resolve) => { reportExit = resolve })
|
|
81
|
+
child.stderr.on('data', (chunk) => {
|
|
82
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-2000)
|
|
83
|
+
})
|
|
84
|
+
child.on('error', (error) => {
|
|
85
|
+
if (!closing) reportExit(new Error(`deltachat-rpc-server could not run: ${error.message}`))
|
|
86
|
+
})
|
|
87
|
+
child.on('exit', (code, signal) => {
|
|
88
|
+
if (closing) return
|
|
89
|
+
const tail = stderr.trim().split('\n').slice(-3).join(' | ')
|
|
90
|
+
reportExit(new Error(`deltachat-rpc-server quit (${signal ?? `code ${code}`})${tail ? `: ${tail}` : ''}`))
|
|
91
|
+
})
|
|
92
|
+
// Writes after the server died must not become uncaught errors.
|
|
93
|
+
child.stdin.on('error', (error) => log?.(`rpc stdin: ${error.message}`))
|
|
94
|
+
const dc = new StdioDeltaChat(child.stdin, child.stdout, true)
|
|
95
|
+
dc.eventTask?.catch?.((error) => {
|
|
96
|
+
if (!closing) log?.(`event loop stopped: ${error?.message ?? error}`)
|
|
97
|
+
})
|
|
98
|
+
return {
|
|
99
|
+
dc,
|
|
100
|
+
exited,
|
|
101
|
+
close() {
|
|
102
|
+
closing = true
|
|
103
|
+
child.kill()
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create the bot profile on the first start, or bring an existing one up to
|
|
110
|
+
* the configured relays and look.
|
|
111
|
+
* @param dc - client.
|
|
112
|
+
* @param options - `{ relays, displayName, avatar, log }`.
|
|
113
|
+
* @returns the account id.
|
|
114
|
+
*/
|
|
115
|
+
export async function ensureAccount(dc, { relays, displayName, avatar, log }) {
|
|
116
|
+
const ids = await dc.rpc.getAllAccountIds()
|
|
117
|
+
const accountId = ids[0] ?? await dc.rpc.addAccount()
|
|
118
|
+
await dc.rpc.batchSetConfig(accountId, { bot: '1', displayname: displayName || 'DSH' })
|
|
119
|
+
if (typeof avatar === 'string' && avatar.length > 0) {
|
|
120
|
+
try {
|
|
121
|
+
await dc.rpc.setConfig(accountId, 'selfavatar', avatar)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log?.(`avatar ${avatar} not set: ${error?.message ?? error}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const wanted = relays.map((relay) => String(relay).trim().toLowerCase()).filter((relay) => relay.length > 0)
|
|
127
|
+
const present = new Set((await dc.rpc.listTransports(accountId).catch(() => []))
|
|
128
|
+
.map((transport) => String(transport.addr).split('@')[1]?.toLowerCase())
|
|
129
|
+
.filter(Boolean))
|
|
130
|
+
const failures = []
|
|
131
|
+
for (const relay of wanted) {
|
|
132
|
+
if (present.has(relay)) continue
|
|
133
|
+
try {
|
|
134
|
+
await dc.rpc.addTransportFromQr(accountId, `dcaccount:${relay}`)
|
|
135
|
+
present.add(relay)
|
|
136
|
+
log?.(`added relay ${relay}`)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
failures.push(`${relay}: ${error?.message ?? error}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!await dc.rpc.isConfigured(accountId)) {
|
|
142
|
+
throw new Error(`no relay could create the Delta Chat profile (${failures.join('; ') || 'no relays configured'})`)
|
|
143
|
+
}
|
|
144
|
+
for (const failure of failures) log?.(`relay not added: ${failure}`)
|
|
145
|
+
return accountId
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write the bot's invite link and its QR code next to the profile.
|
|
150
|
+
* @param dc - client.
|
|
151
|
+
* @param accountId - account.
|
|
152
|
+
* @param dir - directory for `invite.txt` and `invite.svg`.
|
|
153
|
+
* @returns `{ link, svgPath }`.
|
|
154
|
+
*/
|
|
155
|
+
export async function writeInvite(dc, accountId, dir) {
|
|
156
|
+
const link = await dc.rpc.getChatSecurejoinQrCode(accountId, null)
|
|
157
|
+
const svg = await dc.rpc.createQrSvg(link)
|
|
158
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
159
|
+
const svgPath = join(dir, 'invite.svg')
|
|
160
|
+
await writeFile(join(dir, 'invite.txt'), `${link}\n`, { mode: 0o600 })
|
|
161
|
+
await writeFile(svgPath, svg, { mode: 0o600 })
|
|
162
|
+
return { link, svgPath }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fingerprints of contacts, cached by contact id. A contact's key never
|
|
167
|
+
* changes in Delta Chat (a new key is a new contact), so entries stay valid.
|
|
168
|
+
* @param dc - client.
|
|
169
|
+
* @param accountId - account.
|
|
170
|
+
* @returns `get(contactId)` resolving to the fingerprint or ''.
|
|
171
|
+
*/
|
|
172
|
+
export function createFingerprints(dc, accountId) {
|
|
173
|
+
const cache = new Map()
|
|
174
|
+
return {
|
|
175
|
+
async get(contactId) {
|
|
176
|
+
if (cache.has(contactId)) return cache.get(contactId)
|
|
177
|
+
let fingerprint = ''
|
|
178
|
+
try {
|
|
179
|
+
fingerprint = vcardFingerprint(await dc.rpc.makeVcard(accountId, [contactId]))
|
|
180
|
+
} catch {
|
|
181
|
+
fingerprint = ''
|
|
182
|
+
}
|
|
183
|
+
if (fingerprint !== '') cache.set(contactId, fingerprint)
|
|
184
|
+
return fingerprint
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Keep IO healthy: restart it when connectivity stays down or the inbox has
|
|
191
|
+
* been quiet for too long, and nudge the network now and then. Delta Chat
|
|
192
|
+
* recovers by itself in most cases; this covers long sleeps and dead sockets.
|
|
193
|
+
* @param dc - client.
|
|
194
|
+
* @param accountId - account.
|
|
195
|
+
* @param log - logger for restarts.
|
|
196
|
+
* @returns a stop function.
|
|
197
|
+
*/
|
|
198
|
+
export function startWatchdog(dc, accountId, log) {
|
|
199
|
+
let bad = 0
|
|
200
|
+
let lastIdle = Date.now()
|
|
201
|
+
const onIdle = (contextId) => {
|
|
202
|
+
if (contextId === accountId) lastIdle = Date.now()
|
|
203
|
+
}
|
|
204
|
+
dc.on('ImapInboxIdle', onIdle)
|
|
205
|
+
const restart = async (reason) => {
|
|
206
|
+
log(`restarting IO: ${reason}`)
|
|
207
|
+
try {
|
|
208
|
+
await dc.rpc.stopIo(accountId)
|
|
209
|
+
await dc.rpc.startIo(accountId)
|
|
210
|
+
lastIdle = Date.now()
|
|
211
|
+
bad = 0
|
|
212
|
+
} catch (error) {
|
|
213
|
+
log(`IO restart failed: ${error?.message ?? error}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const check = setInterval(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const connectivity = await dc.rpc.getConnectivity(accountId)
|
|
219
|
+
bad = connectivity < CONNECTING ? bad + 1 : 0
|
|
220
|
+
if (bad >= 2) await restart(`connectivity ${connectivity} on two checks`)
|
|
221
|
+
else if (Date.now() - lastIdle > 20 * 60_000 && connectivity < CONNECTED) await restart('no inbox idle for 20 minutes')
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log(`watchdog: ${error?.message ?? error}`)
|
|
224
|
+
}
|
|
225
|
+
}, 5 * 60_000)
|
|
226
|
+
const nudge = setInterval(() => {
|
|
227
|
+
dc.rpc.maybeNetwork().catch(() => {})
|
|
228
|
+
}, 10 * 60_000)
|
|
229
|
+
check.unref?.()
|
|
230
|
+
nudge.unref?.()
|
|
231
|
+
return () => {
|
|
232
|
+
clearInterval(check)
|
|
233
|
+
clearInterval(nudge)
|
|
234
|
+
dc.off?.('ImapInboxIdle', onIdle)
|
|
235
|
+
}
|
|
236
|
+
}
|
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
|
+
}
|