ds-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.
@@ -0,0 +1,313 @@
1
+ // Client channel: the team view the browser renders, and the POST /api/bot endpoints.
2
+ import { randomUUID } from 'node:crypto'
3
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
4
+ import { questionReply } from './parts.js'
5
+ import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js'
6
+
7
+ const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' }
8
+
9
+ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' })
10
+ // Programming and file system errors are DS Bot's own faults, logged with their stack;
11
+ // any other error is a refusal whose message is meant for the user.
12
+ const isInternal = error => error instanceof TypeError || error instanceof ReferenceError || error instanceof RangeError
13
+ || error instanceof SyntaxError || (typeof error?.code === 'string' && /^E[A-Z_]+$/.test(error.code))
14
+ export function errorCode(error) {
15
+ if (typeof error?.code === 'string' && error.code.startsWith('bot/')) return error.code
16
+ return isInternal(error) ? 'bot/internal' : 'bot/refused'
17
+ }
18
+
19
+ export function install(rt) {
20
+ const { ctx, state } = rt
21
+
22
+ // The DSH interface language of the last browser that asked for the team.
23
+ let uiLocale
24
+ rt.uiLanguage = () => (uiLocale === undefined ? undefined : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale)
25
+
26
+ const ok = value => ({ ok: true, value })
27
+ const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } })
28
+ // Models come from DSH, not state.json, so a provider added in Harness settings
29
+ // changes the view without a new revision.
30
+ const modelsKey = () => `${rt.cachedCatalog().map(entry => entry.ref).join(',')}|${rt.cachedDefault() ?? ''}`
31
+ const view = () => ({
32
+ revision: state.revision,
33
+ workspaceId: state.workspaceId ?? null,
34
+ // `mainBotId` is the first Main Bot, which the sidebar shows as its tile.
35
+ mainBotId: state.mainBotIds[0] ?? null,
36
+ mainBotIds: state.mainBotIds,
37
+ // `sessionId` is the Bot's current own-chat part; `parts` its earlier ones.
38
+ // `waiting`: the Bot holds its greeting until the user picks its model.
39
+ bots: Object.values(state.bots).map(({ segments, kickoff, ...bot }) => ({
40
+ ...bot, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}),
41
+ })),
42
+ // `parts`: the members' hidden group Sessions and their earlier segments, for usage.
43
+ rooms: Object.values(state.rooms).map(({ log, seen, botRounds, sessions, segments, ...room }) => {
44
+ const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)]
45
+ const last = (log ?? []).filter(line => line.kind === undefined).at(-1)
46
+ return { ...room, parts, ...(last ? { preview: last.who === 'User' ? last.text : `${last.who}:${last.text}` } : {}) }
47
+ }),
48
+ exchangeTurns: state.exchangeTurns,
49
+ questions: state.questions,
50
+ // The team's DSH Agent (at most one): plain dsh Sessions, not Bots.
51
+ agents: Object.values(state.agents),
52
+ // Key names and who may use them; values never leave the host.
53
+ secrets: rt.secretsView(),
54
+ secretRequests: state.secretRequests ?? {},
55
+ colors: [...COLORS, ...EXTRA_COLORS],
56
+ models: rt.cachedCatalog(),
57
+ defaultModel: rt.cachedDefault() ?? null,
58
+ modelsKey: modelsKey(),
59
+ prefs: state.prefs,
60
+ // Whether a Desktop window should serve Bot browser reads (see browser.js).
61
+ browser: rt.browserEnabled,
62
+ // False when config.memory turns memory off: no Memory row or panel.
63
+ memory: rt.memoryEnabled !== false,
64
+ // Set while another dsh host holds the team (store.js): why this one cannot change it.
65
+ readOnly: rt.readOnly() ?? null,
66
+ })
67
+ // What changes without a new revision, sent with every state answer. `memoryNews`:
68
+ // per Bot, its latest memory change; `memoryReviews`: memory reviews waiting or
69
+ // running.
70
+ const live = () => ({ activity: rt.activityView(), memoryNews: rt.memoryNews(), memoryReviews: rt.memoryReviews(), connectors: rt.connectorsView() })
71
+ // What a host that only reads the team may still answer.
72
+ // Updates change the dsh profile, not the team, so a reading host may run them too.
73
+ const READS = new Set(['state', 'models', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install'])
74
+
75
+ function setPrefs(body) {
76
+ if (body.theme !== undefined) {
77
+ if (typeof body.theme !== 'string' || !/^[a-z][\w-]{0,31}$/.test(body.theme)) throw invalid('Unknown theme')
78
+ state.prefs.theme = body.theme
79
+ }
80
+ if (body.accent !== undefined) {
81
+ if (body.accent === null || body.accent === '') delete state.prefs.accent
82
+ else if (/^#[0-9a-f]{6}$/i.test(body.accent)) state.prefs.accent = body.accent
83
+ else throw invalid('Accent must be a #rrggbb color')
84
+ }
85
+ if (body.motion !== undefined) {
86
+ if (body.motion === null || body.motion === 'normal') delete state.prefs.motion
87
+ else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion
88
+ else throw invalid('Motion must be quiet, normal or lively')
89
+ }
90
+ return rt.save()
91
+ }
92
+
93
+ const handle = async (endpoint, payload) => {
94
+ try {
95
+ const body = payload ?? {}
96
+ await rt.refresh()
97
+ const readOnly = rt.readOnly()
98
+ if (readOnly !== undefined && !READS.has(endpoint)) return fail(readOnly, 'bot/read-only')
99
+ switch (endpoint) {
100
+ case 'state':
101
+ if (typeof body.locale === 'string' && /^[a-z]{2,3}(-[\w-]+)?$/i.test(body.locale)) uiLocale = body.locale
102
+ if (readOnly === undefined) await rt.bootstrap()
103
+ await rt.catalog()
104
+ await rt.loadSecrets()
105
+ if (body.since === state.revision && (body.models === undefined || body.models === modelsKey())) return ok({ revision: state.revision, unchanged: true, ...live() })
106
+ return ok({ ...view(), ...live() })
107
+ case 'models':
108
+ await rt.catalog({ fresh: true })
109
+ return ok({ modelsKey: modelsKey() })
110
+ case 'duplicate-bot':
111
+ await rt.load()
112
+ return ok(await rt.duplicateBot(body.id))
113
+ case 'set-prefs':
114
+ await rt.load()
115
+ await setPrefs(body)
116
+ return ok(state.prefs)
117
+ case 'exchange': {
118
+ await rt.load()
119
+ const pair = new Set([body.a, body.b])
120
+ return ok(state.exchanges.filter(entry => pair.has(entry.from) && pair.has(entry.to)))
121
+ }
122
+ case 'earlier':
123
+ await rt.load()
124
+ return ok(await rt.earlierPage(String(body.sessionId ?? ''), body.cursor ?? undefined))
125
+ case 'usage':
126
+ await rt.load()
127
+ return ok(await rt.usageView({ timeZone: typeof body.timeZone === 'string' ? body.timeZone.slice(0, 64) : undefined }))
128
+ case 'room-progress':
129
+ return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null })))
130
+ case 'create-bot': {
131
+ await rt.bootstrap()
132
+ const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model })
133
+ return ok(bot)
134
+ }
135
+ case 'update-bot':
136
+ await rt.load()
137
+ return ok(await rt.updateBot(body.id, body))
138
+ case 'delete-bot':
139
+ await rt.load()
140
+ await rt.deleteBot(body.id)
141
+ return ok(view())
142
+ case 'set-main':
143
+ await rt.load()
144
+ await rt.setMain(body.id, body.main !== false, {
145
+ primary: body.primary === true,
146
+ replace: typeof body.replace === 'string' ? body.replace : undefined,
147
+ })
148
+ return ok(view())
149
+ case 'create-room': {
150
+ await rt.load()
151
+ const room = await rt.createRoom({ members: Array.isArray(body.members) ? body.members : [], name: body.name, ...(typeof body.admin === 'string' ? { admin: body.admin } : {}) })
152
+ if (body.greet === true) await rt.greetRoom(room.id).catch(error => rt.warn('ds-bot: greeting in %s failed: %s', room.name, error))
153
+ return ok(room)
154
+ }
155
+ case 'update-room': {
156
+ await rt.load()
157
+ const { room, changes } = await rt.updateRoom(body.id, {
158
+ name: body.name,
159
+ notice: body.notice,
160
+ mode: body.mode,
161
+ admin: body.admin,
162
+ members: Array.isArray(body.members) ? body.members : undefined,
163
+ add: Array.isArray(body.add) ? body.add : undefined,
164
+ remove: Array.isArray(body.remove) ? body.remove : undefined,
165
+ })
166
+ return ok({ room: view().rooms.find(entry => entry.id === room.id), changes })
167
+ }
168
+ case 'add-agent':
169
+ await rt.load()
170
+ return ok(await rt.addAgent())
171
+ case 'agent-session':
172
+ await rt.load()
173
+ return ok({ sessionId: await rt.createAgentSession(body.agentId, body.workspaceId) })
174
+ case 'agent-adopt':
175
+ await rt.load()
176
+ return ok({ sessionId: await rt.adoptAgentSession(body.agentId, body.sessionId) })
177
+ case 'agent-archive':
178
+ await rt.load()
179
+ await rt.archiveAgentSession(body.agentId, body.sessionId)
180
+ return ok(true)
181
+ case 'agent-rename':
182
+ await rt.load()
183
+ await rt.renameAgentSession(body.agentId, body.sessionId, body.title)
184
+ return ok(true)
185
+ case 'remove-agent':
186
+ await rt.load()
187
+ await rt.removeAgent(body.agentId)
188
+ return ok(view())
189
+ case 'set-flags':
190
+ await rt.load()
191
+ return ok(await rt.setFlags(body.id, body))
192
+ case 'delete-room':
193
+ await rt.load()
194
+ await rt.deleteRoom(body.id)
195
+ return ok(view())
196
+ case 'dismiss-question':
197
+ await rt.load()
198
+ if (state.questions[body.sessionId] !== undefined) {
199
+ delete state.questions[body.sessionId]
200
+ await rt.save()
201
+ }
202
+ return ok(true)
203
+ case 'secret-set':
204
+ case 'secret-allow':
205
+ case 'secret-cancel':
206
+ case 'secret-update':
207
+ case 'secret-delete':
208
+ await rt.load()
209
+ return ok(await rt.secretEndpoint(endpoint, body))
210
+ case 'connector-connect':
211
+ case 'connector-disconnect':
212
+ case 'connector-retry':
213
+ await rt.load()
214
+ return ok(await rt.connectorEndpoint(endpoint, body))
215
+ case 'schedules':
216
+ case 'schedule-create':
217
+ case 'schedule-delete':
218
+ await rt.load()
219
+ return ok(await rt.scheduleEndpoint(endpoint, body))
220
+ case 'memory-view':
221
+ case 'memory-ask':
222
+ case 'memory-forget':
223
+ await rt.load()
224
+ return ok(await rt.memoryEndpoint(endpoint, body))
225
+ case 'answer-question': {
226
+ if (typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid')
227
+ await rt.load()
228
+ const past = rt.pastOwners.get(body.sessionId)
229
+ const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId
230
+ const question = state.questions[sessionId]
231
+ if (question === undefined || question.id !== body.questionId) return fail('This question is no longer open', 'bot/stale')
232
+ const reply = questionReply(question, body.answer)
233
+ if (reply === undefined) return fail('Nothing to send', 'bot/invalid')
234
+ // Claim the question before queueing so a second answer is refused.
235
+ delete state.questions[sessionId]
236
+ try {
237
+ const agent = await rt.resolveAgent(sessionId)
238
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: reply.text }], source: reply.source }))
239
+ await ctx.sessions.flush(agent.session)
240
+ } catch (error) {
241
+ state.questions[sessionId] ??= question
242
+ throw error
243
+ }
244
+ await rt.save()
245
+ return ok(true)
246
+ }
247
+ case 'send': {
248
+ const text = String(body.text ?? '').trim()
249
+ if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid')
250
+ await rt.load()
251
+ // A Bot id or an earlier part of its own chat means the current part.
252
+ const past = rt.pastOwners.get(body.sessionId)
253
+ const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId
254
+ await ctx.sessionController.prompt({
255
+ requestId: randomUUID(),
256
+ sessionId,
257
+ mode: 'queue',
258
+ content: [{ type: 'text', text }],
259
+ }, AbortSignal.timeout(30_000))
260
+ return ok(true)
261
+ }
262
+ case 'update-check':
263
+ return ok(await rt.updateCheck({ fresh: body.fresh === true }))
264
+ case 'update-install':
265
+ return ok(rt.updateStart())
266
+ // The Desktop window's long poll for read_browser, and its answers.
267
+ case 'browser-wait': {
268
+ if (rt.browserChannel === undefined) return fail('Bot browsers are off on this host', 'bot/browser-off')
269
+ const clientId = typeof body.clientId === 'string' ? body.clientId.slice(0, 100) : ''
270
+ if (clientId === '') return fail('A browser poll needs a clientId', 'bot/invalid')
271
+ await rt.load()
272
+ return ok(await rt.browserChannel.wait(clientId, rt.browserOpen(body.open)))
273
+ }
274
+ case 'browser-result':
275
+ if (rt.browserChannel === undefined) return fail('Bot browsers are off on this host', 'bot/browser-off')
276
+ return ok(rt.browserChannel.result(body))
277
+ case 'diagnostics':
278
+ await rt.load()
279
+ return ok(await rt.diagnostics.report(body))
280
+ case 'diagnostics-log':
281
+ return ok(await rt.diagnostics.logText())
282
+ case 'diagnostics-clear':
283
+ await rt.diagnostics.clear()
284
+ return ok(true)
285
+ default:
286
+ return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found')
287
+ }
288
+ } catch (error) {
289
+ const code = errorCode(error)
290
+ const message = error?.message ?? String(error)
291
+ if (code === 'bot/internal') rt.noteFailure({ source: 'api', text: `${endpoint}: ${message}`, code: error?.code ?? error?.name, stack: error?.stack })
292
+ return fail(message, code)
293
+ }
294
+ }
295
+
296
+ // An exact Fetch route on the shared /api channel; the browser authenticates with its session cookie.
297
+ ctx.effect(() => ctx.connection.fetch.register({
298
+ path: ROUTE,
299
+ methods: ['POST'],
300
+ requestBody: 'buffered',
301
+ fetch: async (request) => {
302
+ let body
303
+ try { body = await request.json() } catch { body = {} }
304
+ const result = await handle(String(body?.endpoint ?? ''), body?.payload)
305
+ return new Response(JSON.stringify(result), { status: 200, headers: { 'content-type': 'application/json' } })
306
+ },
307
+ }), 'ds-bot: api route')
308
+
309
+ ctx.effect(() => {
310
+ void rt.load()
311
+ return () => {}
312
+ }, 'ds-bot: load state')
313
+ }
@@ -0,0 +1,293 @@
1
+ // Reading a Bot's own chat: Session history pages, the SQLite FTS5 index, and the
2
+ // search, latest, and around reads behind read_own_chat.
3
+ //
4
+ // Chat index. read_own_chat searches a SQLite FTS5 table of every part of a Bot's own
5
+ // chat, ranked by BM25. The trigram tokenizer matches any run of three or more
6
+ // characters, so Chinese needs no word splitting; shorter words fall back to LIKE.
7
+ // The index is derived from the Session logs: a part catches up when it is read, an
8
+ // ended part is sealed once it is complete, and deleting the file only costs a
9
+ // rebuild. Without SQLite, read_own_chat scans the logs as before.
10
+ import { mkdir, unlink } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import { ANCHOR_LINE_MAX, HISTORY_PAGE, HISTORY_PAGES_MAX, HISTORY_TIMEOUT_MS, OWN_CHAT_BRIEF, OWN_CHAT_LINE_MAX, OWN_CHAT_READ, OWN_CHAT_SEARCH } from './constants.js'
13
+ import { excerpt } from './text.js'
14
+
15
+ const INDEX_VERSION = 3
16
+ const AROUND_LINES = 6
17
+ const SEARCH_TERMS_MAX = 6
18
+ const ANY_TERM_HITS = 50
19
+ const INDEX_TIMEOUT_MS = 120_000
20
+ // A part still gets a few events (a title, a late reply) just after it ends.
21
+ const SEAL_AFTER_MS = 60_000
22
+
23
+ const likeOf = term => `%${term.replace(/[\\%_]/g, char => `\\${char}`)}%`
24
+ // Each term is a quoted FTS5 phrase when trigrams can match it, else a LIKE filter.
25
+ export function termQuery(terms, sessions, limit, hidden) {
26
+ const long = terms.filter(term => [...term].length >= 3)
27
+ const short = terms.filter(term => [...term].length < 3)
28
+ const where = ['session IN (SELECT value FROM json_each(?))']
29
+ const args = [JSON.stringify(sessions)]
30
+ if (hidden !== undefined) {
31
+ where.push('NOT (session = ? AND seq >= ?)')
32
+ args.push(hidden.session, hidden.from)
33
+ }
34
+ if (long.length > 0) {
35
+ where.unshift('lines MATCH ?')
36
+ args.unshift(long.map(term => `"${term.replace(/"/g, '""')}"`).join(' '))
37
+ }
38
+ for (const term of short) {
39
+ where.push("text LIKE ? ESCAPE '\\'")
40
+ args.push(likeOf(term))
41
+ }
42
+ return { sql: `SELECT text, session, seq, time, who FROM lines WHERE ${where.join(' AND ')} ORDER BY ${long.length > 0 ? 'rank' : 'time DESC'} LIMIT ${limit}`, args }
43
+ }
44
+
45
+ export function install(rt) {
46
+ const { ctx, home } = rt
47
+
48
+ const indexPath = join(home, 'chat-index.sqlite')
49
+ let chatIndex
50
+ let indexOpening
51
+
52
+ // A Session's events from newest to oldest, also for an archived part.
53
+ async function* eventsBackwards(sessionId, signal, maxPages = HISTORY_PAGES_MAX) {
54
+ const agent = await rt.resolveAgent(sessionId)
55
+ const throughSeq = Number(agent.session.seq) - 1
56
+ let beforeSeq
57
+ for (let pages = 0; throughSeq >= 0 && pages < maxPages; pages += 1) {
58
+ const page = await ctx.sessionController.page({
59
+ address: { kind: 'session', sessionId },
60
+ throughSeq,
61
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
62
+ maxMessages: HISTORY_PAGE,
63
+ }, signal)
64
+ const events = page.records.map(record => record.event)
65
+ for (let index = events.length - 1; index >= 0; index -= 1) yield events[index]
66
+ if (!page.hasMore || events.length === 0) return
67
+ beforeSeq = events[0].seq
68
+ }
69
+ }
70
+
71
+ function openIndex() {
72
+ if (chatIndex !== undefined) return Promise.resolve(chatIndex)
73
+ const opening = indexOpening ??= (async () => {
74
+ let DatabaseSync
75
+ try {
76
+ ({ DatabaseSync } = await import('node:sqlite'))
77
+ await mkdir(home, { recursive: true })
78
+ } catch (error) {
79
+ rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error)
80
+ return null
81
+ }
82
+ // A damaged file is rebuilt from the logs once.
83
+ for (const fresh of [false, true]) {
84
+ try {
85
+ if (fresh) await unlink(indexPath).catch(() => {})
86
+ return createIndex(new DatabaseSync(indexPath))
87
+ } catch (error) {
88
+ if (fresh) rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error)
89
+ }
90
+ }
91
+ return null
92
+ })().then((index) => {
93
+ // The plugin may have been disposed while the file opened.
94
+ if (indexOpening !== opening) {
95
+ index?.db.close()
96
+ return null
97
+ }
98
+ return chatIndex = index
99
+ })
100
+ return opening
101
+ }
102
+
103
+ function createIndex(db) {
104
+ try {
105
+ db.exec('PRAGMA journal_mode = WAL')
106
+ if (db.prepare('PRAGMA user_version').get().user_version !== INDEX_VERSION) {
107
+ db.exec(`
108
+ DROP TABLE IF EXISTS lines;
109
+ DROP TABLE IF EXISTS marks;
110
+ CREATE VIRTUAL TABLE lines USING fts5(text, session UNINDEXED, seq UNINDEXED, time UNINDEXED, who UNINDEXED, tokenize = 'trigram');
111
+ CREATE TABLE marks (session TEXT PRIMARY KEY, through INTEGER NOT NULL, keep INTEGER NOT NULL, sealed INTEGER NOT NULL, turn INTEGER NOT NULL);
112
+ PRAGMA user_version = ${INDEX_VERSION};`)
113
+ }
114
+ return {
115
+ db,
116
+ mark: db.prepare('SELECT through, keep, sealed, turn FROM marks WHERE session = ?'),
117
+ setMark: db.prepare('INSERT OR REPLACE INTO marks (session, through, keep, sealed, turn) VALUES (?, ?, ?, ?, ?)'),
118
+ insert: db.prepare('INSERT INTO lines (text, session, seq, time, who) VALUES (?, ?, ?, ?, ?)'),
119
+ before: db.prepare('SELECT text, session, seq, time, who FROM lines WHERE session = ? AND seq < ? ORDER BY seq DESC LIMIT ?'),
120
+ from: db.prepare('SELECT text, session, seq, time, who FROM lines WHERE session = ? AND seq >= ? ORDER BY seq LIMIT ?'),
121
+ }
122
+ } catch (error) {
123
+ db.close()
124
+ throw error
125
+ }
126
+ }
127
+ ctx.effect(() => () => {
128
+ chatIndex?.db.close()
129
+ chatIndex = undefined
130
+ indexOpening = undefined
131
+ }, 'ds-bot: chat index')
132
+
133
+ // Adds what a part said since its mark. Reads of one part wait for each other, so no
134
+ // line is added twice.
135
+ const catchingUp = new Map()
136
+ function catchUp(index, part) {
137
+ const running = catchingUp.get(part.sessionId)
138
+ if (running) return running
139
+ const job = (async () => {
140
+ const mark = index.mark.get(part.sessionId)
141
+ if (mark?.sealed) return
142
+ const through = mark?.through ?? -1
143
+ const events = []
144
+ for await (const event of eventsBackwards(part.sessionId, AbortSignal.timeout(INDEX_TIMEOUT_MS), Infinity)) {
145
+ if (event.seq <= through) break
146
+ events.push(event)
147
+ }
148
+ if (events.length > 0 && events.at(-1).seq !== through + 1) throw new Error(`the history of ${part.sessionId} came back with a gap`)
149
+ events.reverse()
150
+ if (index !== chatIndex) return
151
+ let keep = mark ? mark.keep === 1 : false
152
+ let turn = mark?.turn ?? -1
153
+ const seal = part.endedAt !== undefined && Date.now() - part.endedAt > SEAL_AFTER_MS
154
+ index.db.exec('BEGIN')
155
+ try {
156
+ let last = through
157
+ for (const event of events) {
158
+ last = event.seq
159
+ if (event.type === 'turn/start') turn = event.seq
160
+ const entry = rt.historyEntry(event, false)
161
+ if (entry === undefined) continue
162
+ if (entry.answer) {
163
+ if (keep) index.insert.run(entry.text, part.sessionId, event.seq, event.time ?? 0, 'you')
164
+ continue
165
+ }
166
+ keep = entry.keepAnswers
167
+ if (entry.text !== '') index.insert.run(entry.text, part.sessionId, event.seq, event.time ?? 0, entry.who)
168
+ }
169
+ index.setMark.run(part.sessionId, last, keep ? 1 : 0, seal ? 1 : 0, turn)
170
+ index.db.exec('COMMIT')
171
+ } catch (error) {
172
+ index.db.exec('ROLLBACK')
173
+ throw error
174
+ }
175
+ })().finally(() => catchingUp.delete(part.sessionId))
176
+ catchingUp.set(part.sessionId, job)
177
+ return job
178
+ }
179
+
180
+ // A message's anchor: its part (once there are several) and its seq in that part.
181
+ const anchorOf = (parts, row) => {
182
+ const part = parts.find(item => item.sessionId === row.session)
183
+ return `${parts.length > 1 ? `part ${part?.number ?? '?'} ` : ''}#${row.seq}`
184
+ }
185
+ const rowLine = (parts, row, terms = [], max = OWN_CHAT_LINE_MAX) => {
186
+ const lower = row.text.toLowerCase()
187
+ const wanted = terms.find(term => lower.includes(term)) ?? ''
188
+ const text = row.who === 'user' ? `User: ${row.text}` : row.who === 'you' ? `You: ${row.text}` : row.text
189
+ return `[${anchorOf(parts, row)} · ${rt.clock(row.time)}] ${excerpt(text, wanted, max)}`
190
+ }
191
+
192
+ // Every part, caught up. Asked from the current part, the turn that asks is left out
193
+ // (`hidden`): it is in context, and its own question would match.
194
+ async function indexedParts(botId, askingId) {
195
+ const index = await openIndex()
196
+ if (index === null) return { index }
197
+ const parts = rt.partsOf(botId)
198
+ // A part that cannot be read now is searched as far as it was indexed.
199
+ for (const part of parts) await catchUp(index, part).catch(error => rt.warn('ds-bot: indexing %s failed: %s', part.sessionId, error))
200
+ const turn = askingId !== undefined && askingId === parts.at(-1)?.sessionId ? index.mark.get(askingId)?.turn : undefined
201
+ return { index, parts, hidden: turn === undefined || turn < 0 ? undefined : { session: askingId, from: turn } }
202
+ }
203
+
204
+ // Messages with all the words, best first; with none of them having all, those with
205
+ // the most of them. Shown oldest first.
206
+ async function searchOwnChat(botId, query, { askingId, limit = OWN_CHAT_SEARCH } = {}) {
207
+ const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))].slice(0, SEARCH_TERMS_MAX)
208
+ const { index, parts, hidden } = await indexedParts(botId, askingId)
209
+ if (index === null) return { lines: await scanOwnChat(botId, query, limit), every: true }
210
+ const sessions = parts.map(part => part.sessionId)
211
+ const run = (wanted, max) => {
212
+ const { sql, args } = termQuery(wanted, sessions, max, hidden)
213
+ return index.db.prepare(sql).all(...args)
214
+ }
215
+ const byTime = (a, b) => (a.time - b.time) || (sessions.indexOf(a.session) - sessions.indexOf(b.session)) || (a.seq - b.seq)
216
+ let rows = run(terms, limit)
217
+ let every = true
218
+ if (rows.length === 0 && terms.length > 1) {
219
+ every = false
220
+ const found = new Map()
221
+ for (const term of terms) {
222
+ for (const row of run([term], ANY_TERM_HITS)) {
223
+ const key = `${row.session}#${row.seq}`
224
+ const hit = found.get(key) ?? { row, count: 0 }
225
+ hit.count += 1
226
+ found.set(key, hit)
227
+ }
228
+ }
229
+ rows = [...found.values()].sort((a, b) => (b.count - a.count) || (b.row.time - a.row.time)).slice(0, limit).map(hit => hit.row)
230
+ }
231
+ return { lines: rows.sort(byTime).map(row => rowLine(parts, row, terms)), every }
232
+ }
233
+
234
+ // The latest messages across all parts, oldest first.
235
+ async function latestOwnChat(botId, { askingId, limit = OWN_CHAT_READ } = {}) {
236
+ const { index, parts, hidden } = await indexedParts(botId, askingId)
237
+ if (index === null) return scanOwnChat(botId, '', limit)
238
+ const rows = []
239
+ for (const part of [...parts].reverse()) {
240
+ const below = hidden?.session === part.sessionId ? hidden.from : Number.MAX_SAFE_INTEGER
241
+ rows.push(...index.before.all(part.sessionId, below, limit - rows.length))
242
+ if (rows.length >= limit) break
243
+ }
244
+ return rows.reverse().map(row => rowLine(parts, row))
245
+ }
246
+
247
+ // The messages around an anchor, in its part.
248
+ async function aroundOwnChat(botId, anchor) {
249
+ const { index, parts } = await indexedParts(botId)
250
+ if (index === null) return { error: 'Anchors need the chat index, which is unavailable here. Search with words instead.' }
251
+ const found = /(?:part\s*)?(\d+)?\s*#\s*(\d+)/i.exec(anchor)
252
+ if (found === null) return { error: `"${anchor}" is not an anchor. Anchors look like "part 2 #1234", or "#1234" in the current part.` }
253
+ const number = found[1] === undefined ? parts.length : Number(found[1])
254
+ const part = parts[number - 1]
255
+ if (part === undefined) return { error: `Your own chat has no part ${number}.` }
256
+ const seq = Number(found[2])
257
+ const rows = [...index.before.all(part.sessionId, seq, AROUND_LINES).reverse(), ...index.from.all(part.sessionId, seq, AROUND_LINES + 1)]
258
+ return { part, exact: rows.some(row => row.seq === seq), lines: rows.map(row => rowLine(parts, row, [], row.seq === seq ? ANCHOR_LINE_MAX : OWN_CHAT_LINE_MAX)) }
259
+ }
260
+
261
+ // Without the index: what the user, other Bots, and the Bot itself said, across all
262
+ // parts, oldest first, by reading the logs.
263
+ async function scanOwnChat(botId, query, limit) {
264
+ const wanted = query.toLowerCase()
265
+ const parts = rt.partsOf(botId)
266
+ const signal = AbortSignal.timeout(HISTORY_TIMEOUT_MS)
267
+ const lines = []
268
+ for (const part of [...parts].reverse()) {
269
+ for await (const line of rt.partLines(part.sessionId, signal)) {
270
+ if (wanted !== '' && !line.text.toLowerCase().includes(wanted)) continue
271
+ lines.push(`[${parts.length > 1 ? `part ${part.number} · ` : ''}${rt.clock(line.time)}] ${excerpt(line.text, wanted)}`)
272
+ if (lines.length >= limit) break
273
+ }
274
+ if (lines.length >= limit) break
275
+ }
276
+ return lines.reverse()
277
+ }
278
+
279
+ // The parts of a Bot's own chat, for the model: when each ran.
280
+ const partIndex = parts => parts.map(part => `part ${part.number} (${rt.clock(part.startedAt)} – ${part.endedAt === undefined ? 'now' : rt.clock(part.endedAt)})`).join(', ')
281
+
282
+ async function ownChatBrief(member) {
283
+ let lines = []
284
+ try { lines = await latestOwnChat(member.id, { limit: OWN_CHAT_BRIEF }) } catch (error) { rt.warn('ds-bot: brief for %s failed: %s', member.name, error) }
285
+ return [
286
+ 'Your own chat with the user so far (latest messages, oldest first; private, so share only what the group needs):',
287
+ lines.length > 0 ? lines.join('\n') : 'Nothing yet.',
288
+ 'This brief does not change. Call read_own_chat when you need something newer or older from your own chat.',
289
+ ].join('\n')
290
+ }
291
+
292
+ Object.assign(rt, { eventsBackwards, searchOwnChat, latestOwnChat, aroundOwnChat, partIndex, ownChatBrief })
293
+ }