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,67 @@
1
+ // Prompt: one section whose text depends only on the rendering Session's own Bot
2
+ // record, so the prefix stays byte-stable across turns and roster changes.
3
+
4
+ const TEAM_RULES = [
5
+ '## How your Bot team works',
6
+ '- Every Bot is its own long-lived conversation with the user, with its own memory and computer.',
7
+ '- The team changes over time. Call list_bots whenever you need to know who exists; do not rely on memory.',
8
+ '- message_bot sends an asynchronous message to another Bot. Its reply arrives later as a new "[Reply from …]" message. Never wait, poll, or resend.',
9
+ '- When the user wants other Bots to tell them something themselves (introduce themselves, report in their own chats), call message_bot with reply_to "user": each Bot then answers the user in its own chat and nothing comes back to you. Do not relay those answers yourself.',
10
+ '- When a "[Message from …]" arrives, your final text in that turn is delivered back to that Bot automatically and the user does not see it. Answer it directly and briefly, as a list of facts. Never invent facts: say "not discussed yet" when you do not know.',
11
+ '- When a "[Message from … · the user sees your reply here]" arrives, your reply is shown to the user in your own chat and is not sent back. Talk to the user directly, as yourself.',
12
+ '- When a "[Reply from …]" arrives, tell the user the outcome if it matters to them.',
13
+ '- Chat like a teammate texting: short sentences, often two or three short messages instead of one long one, no headings. When the user asks for a deliverable (a plan, schedule, draft, or list), write it out in full in the chat; never claim you gave something you did not write. Mention other Bots by their exact name.',
14
+ '- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message. Never use ask_user_question: it blocks you, and messages from other Bots would queue behind it.',
15
+ '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.',
16
+ '- When you need a key, token, or password, call request_secret: the user types it into a secure card and you never see it. Never ask the user to paste one into the chat. Use it through its environment variable ($DSH_SECRET_<NAME> in bash, $env:DSH_SECRET_<NAME> in PowerShell) and never print it. list_secrets shows which keys exist.',
17
+ '- web_search results say which search backend answered. If web_search fails, never guess URLs or present pages you remember as search results: use a browser tool if you have one, otherwise tell the user that search is unavailable and the reason it gave.',
18
+ '- Your conversations are long-lived. When one grows long, either its older messages are condensed in place into a summary you wrote (a "[Checkpoint N]" message), or it moves on to a fresh part that opens with a handoff note you wrote and your latest messages (a "[Handoff · part N]" message). What leaves your working context stays saved. When you need the exact earlier wording or a detail the summary or note does not show in full (a full path, a figure, every item of a complete list or a final summary), call read_own_chat with words to look for, or with part N to read the note that closed an earlier part; never fill such a detail in from memory. Each line it returns starts with an anchor such as [part 2 #1234 · 10-08 14:22]; pass the anchor as around to read what was said around it.',
19
+ '- Reply in the user\'s language.',
20
+ ].join('\n')
21
+
22
+ const MAIN_RULES = [
23
+ '## You are the Main Bot (Chief of Staff)',
24
+ '- You run the user\'s Bot team: you decide who does what, create new Bots, and report back.',
25
+ '- When the user wants something that deserves its own ongoing specialist, call create_bot (check list_bots first to avoid duplicates). Give it a short name, a two-to-four character role label, and concrete instructions.',
26
+ '- After creating a Bot, tell the user in one line that it is ready, using its exact name.',
27
+ '- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
28
+ '- Every Bot runs on one model of its own (list_bots shows it). Pass model to create_bot or update_bot only when the user names one; list_models gives the provider/model refs.',
29
+ '- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
30
+ '- You run the group chats too. create_group makes a group (you join it as its admin unless you name another); update_group changes any group\'s name, notice, members, admin, or reply mode; post_to_group posts into any group as you, which starts a round of replies the user reads there; delete_group archives a group, only when the user asks. list_bots shows every group.',
31
+ '- The team can have more than one Main Bot; list_bots marks them. They share these powers, so coordinate with them through message_bot.',
32
+ ].join('\n')
33
+
34
+ export function install(rt) {
35
+ const { ctx } = rt
36
+
37
+ // A group Session renders exactly its Bot's text, so both share one cached prefix.
38
+ const promptFor = (id) => {
39
+ const self = rt.selfOf(id)
40
+ const bot = rt.botOf(self)
41
+ if (bot !== undefined) {
42
+ return [
43
+ '# Your identity',
44
+ `You are ${bot.name}, a Bot on the user's team in DS Bot.${bot.role ? ` Your role: ${bot.role}.` : ''}`,
45
+ bot.instructions ? `\n${bot.instructions}` : '',
46
+ '',
47
+ rt.isMain(self) ? `${MAIN_RULES}\n\n${TEAM_RULES}` : TEAM_RULES,
48
+ ].join('\n')
49
+ }
50
+ const room = rt.roomOf(id)
51
+ if (room !== undefined) {
52
+ return [
53
+ '# Group chat relay',
54
+ 'This conversation is a group chat between the user and several Bots. You are only the relay, not a participant.',
55
+ 'For every new user message: call group_relay exactly once with no arguments, then end your turn with an empty reply. Never add your own text.',
56
+ ].join('\n')
57
+ }
58
+ return ''
59
+ }
60
+
61
+ ctx.effect(() => ctx.systemPrompt.section({
62
+ name: 'bot-identity',
63
+ order: 640,
64
+ interpolate: false,
65
+ text: context => promptFor(context?.agent?.id),
66
+ }), 'ds-bot: identity section')
67
+ }
@@ -0,0 +1,397 @@
1
+ // Roster: creating, changing, and deleting Bots and group chats, the Main Bot role, and
2
+ // the first Main Bot of a new team.
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
6
+ import { currentColor, isColor, unquote } from './text.js'
7
+
8
+ export function install(rt) {
9
+ const { ctx, config, home, defaults, state } = rt
10
+
11
+ let bootstrapping = null
12
+
13
+ async function ensureWorkspace() {
14
+ if (state.workspaceId && ctx.workspaceRegistry.get(state.workspaceId)) return state.workspaceId
15
+ const path = config.workspace ?? join(home, 'workspace')
16
+ await mkdir(path, { recursive: true })
17
+ const workspace = await ctx.workspaceRegistry.resolveByPath(path) ?? await ctx.workspaceRegistry.create(path, 'DS Bot')
18
+ state.workspaceId = workspace.id
19
+ await rt.save()
20
+ return workspace.id
21
+ }
22
+
23
+ async function createSession(title) {
24
+ // A Bot's tools run here too; while another dsh holds the team, a new Session would
25
+ // belong to no saved Bot.
26
+ const readOnly = rt.readOnly()
27
+ if (readOnly !== undefined) throw new Error(readOnly)
28
+ const workspaceId = await ensureWorkspace()
29
+ const { sessionId } = await ctx.sessionController.create({ workspaceId })
30
+ const agent = await rt.resolveAgent(sessionId)
31
+ await ctx.sessionTitle.rename(agent.session, title)
32
+ return sessionId
33
+ }
34
+
35
+ function cleanAvatar(input) {
36
+ if (input === null || typeof input !== 'object') return undefined
37
+ const avatar = {}
38
+ if (typeof input.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(input.shape)) avatar.shape = input.shape
39
+ if (typeof input.image === 'string') {
40
+ if (!/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(input.image)) throw new Error('Avatar images must be PNG, JPEG, WebP, or GIF')
41
+ if (input.image.length > MAX_AVATAR_IMAGE) throw new Error('Avatar image is too large')
42
+ avatar.image = input.image
43
+ }
44
+ return Object.keys(avatar).length > 0 ? avatar : undefined
45
+ }
46
+
47
+ const freshName = (base) => {
48
+ const stem = base.slice(0, 34)
49
+ if (!rt.findBot(stem)) return stem
50
+ let index = 2
51
+ while (rt.findBot(`${stem} ${index}`)) index += 1
52
+ return `${stem} ${index}`
53
+ }
54
+
55
+ // `pickModel` leaves the model to the user: the chat opens on a model card, and the
56
+ // greeting waits until a model is chosen. So does a Bot made when no model is served.
57
+ async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, copyOf, pickModel = false }) {
58
+ const cleanName = String(botName ?? '').trim().slice(0, 40)
59
+ if (cleanName === '') throw new Error('A Bot needs a name')
60
+ if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
61
+ const cleanAvatarValue = cleanAvatar(avatar)
62
+ // The model is fixed at creation, so a later change of the DSH default leaves it alone.
63
+ const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
64
+ const id = await createSession(cleanName)
65
+ state.bots[id] = {
66
+ id,
67
+ name: cleanName,
68
+ role: String(role).trim().slice(0, 24),
69
+ // A color is only stored when the user picks one; otherwise the id picks it.
70
+ ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
71
+ ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
72
+ ...(modelRef ? { model: modelRef } : {}),
73
+ instructions: String(instructions).trim(),
74
+ createdAt: Date.now(),
75
+ createdBy: createdBy ?? null,
76
+ }
77
+ if (main) state.mainBotIds.push(id)
78
+ const creator = rt.botOf(createdBy)
79
+ const original = rt.botOf(copyOf)
80
+ const language = rt.uiLanguage?.()
81
+ const kickoff = main
82
+ ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
83
+ : original
84
+ ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
85
+ : creator
86
+ ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
87
+ : brief
88
+ ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
89
+ : 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
90
+ const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
91
+ const bot = state.bots[id]
92
+ if (modelRef === undefined) {
93
+ bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
94
+ await rt.save()
95
+ return bot
96
+ }
97
+ await rt.save()
98
+ // The greeting runs on the Bot's own model, once its provider is registered.
99
+ void rt.waitForModel(bot)
100
+ .then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
101
+ .then(() => sendKickoff(bot, { text, from: createdBy }))
102
+ return bot
103
+ }
104
+
105
+ function sendKickoff(bot, { text, from }) {
106
+ void rt.deliver({ fromId: from ?? undefined, toId: bot.id, text, role: 'kickoff' })
107
+ .catch(error => rt.warn('ds-bot: kickoff failed: %s', error))
108
+ }
109
+
110
+ // The user wrote first, so the held greeting would only interrupt.
111
+ function dropKickoff(botId) {
112
+ const bot = rt.botOf(botId)
113
+ if (bot?.kickoff === undefined) return
114
+ delete bot.kickoff
115
+ void rt.save()
116
+ }
117
+
118
+ async function duplicateBot(id) {
119
+ const bot = rt.botOf(id)
120
+ if (bot === undefined) throw new Error('Unknown Bot')
121
+ const served = (await rt.catalog()).some(entry => entry.ref === bot.model)
122
+ return createBot({
123
+ name: freshName(`${bot.name} copy`),
124
+ role: bot.role,
125
+ instructions: bot.instructions,
126
+ color: bot.color,
127
+ avatar: bot.avatar,
128
+ model: served ? bot.model : undefined,
129
+ copyOf: bot.id,
130
+ })
131
+ }
132
+
133
+ async function updateBot(id, patch) {
134
+ const bot = rt.botOf(id)
135
+ if (bot === undefined) throw new Error('Unknown Bot')
136
+ if (patch.name !== undefined) {
137
+ const next = String(patch.name).trim().slice(0, 40)
138
+ const clash = rt.findBot(next)
139
+ if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`)
140
+ bot.name = next
141
+ const agent = await rt.resolveAgent(rt.chatOf(id))
142
+ await ctx.sessionTitle.rename(agent.session, next)
143
+ await followMemberNames(Object.values(state.rooms).filter(room => room.members.includes(id)))
144
+ }
145
+ if (patch.role !== undefined) bot.role = String(patch.role).trim().slice(0, 24)
146
+ if (patch.instructions !== undefined) bot.instructions = String(patch.instructions).trim()
147
+ let modelChanged = false
148
+ if (patch.color !== undefined) {
149
+ if (patch.color === null) delete bot.color
150
+ else if (isColor(currentColor(patch.color))) bot.color = currentColor(patch.color)
151
+ }
152
+ if (patch.avatar !== undefined) {
153
+ const avatar = cleanAvatar(patch.avatar)
154
+ if (avatar) bot.avatar = avatar
155
+ else delete bot.avatar
156
+ }
157
+ if (patch.model !== undefined) {
158
+ if (patch.model === null || patch.model === '') delete bot.model
159
+ else bot.model = await rt.resolveModel(patch.model)
160
+ modelChanged = true
161
+ }
162
+ await rt.save()
163
+ if (modelChanged) await rt.applyModel(bot, { force: true })
164
+ if (bot.model && bot.kickoff !== undefined) {
165
+ const held = bot.kickoff
166
+ delete bot.kickoff
167
+ await rt.save()
168
+ sendKickoff(bot, held)
169
+ }
170
+ return bot
171
+ }
172
+
173
+ async function setFlags(id, { pinned, hidden }) {
174
+ const entry = rt.botOf(id) ?? rt.roomOf(id) ?? state.agents[id]
175
+ if (entry === undefined) throw new Error('Unknown conversation')
176
+ if (typeof pinned === 'boolean') entry.pinned = pinned
177
+ if (typeof hidden === 'boolean') entry.hidden = hidden
178
+ await rt.save()
179
+ return entry
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Group chat settings. The user and every Main Bot may change any group; a group's
184
+ // admin may change its own group but not delete it. Only a member can be admin.
185
+
186
+ const roomBusy = id => [...rt.roomProgress.values()].some(round => round.roomId === id)
187
+ const autoRoomName = members => ['You', ...members.map(id => rt.botOf(id)?.name).filter(Boolean)].join(', ')
188
+ const cleanNotice = text => String(text ?? '').trim().slice(0, ROOM_NOTICE_MAX)
189
+
190
+ function cleanRoomName(text, selfId) {
191
+ const next = unquote(text).replace(/\s+/g, ' ').slice(0, ROOM_NAME_MAX)
192
+ if (next !== '' && rt.findRooms(next).some(room => room.id !== selfId)) throw new Error(`Another group chat is already called "${next}"`)
193
+ return next
194
+ }
195
+
196
+ async function retitleRoom(room) {
197
+ try {
198
+ const agent = await rt.resolveAgent(room.id)
199
+ await ctx.sessionTitle.rename(agent.session, room.name)
200
+ } catch (error) {
201
+ rt.warn('ds-bot: group title for %s not updated: %s', room.name, error)
202
+ }
203
+ }
204
+
205
+ // Groups without a chosen name are named after their members, so they follow renames.
206
+ async function followMemberNames(rooms = Object.values(state.rooms)) {
207
+ for (const room of rooms) {
208
+ if (room.named) continue
209
+ const next = autoRoomName(room.members)
210
+ if (next === room.name) continue
211
+ room.name = next
212
+ await retitleRoom(room)
213
+ }
214
+ }
215
+
216
+ function checkMembers(members) {
217
+ if (members.length < 2) throw new Error('A group chat needs at least two Bots')
218
+ if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`)
219
+ }
220
+
221
+ async function createRoom({ members: memberIds = [], name: roomName, notice, mode, admin, createdBy } = {}) {
222
+ const members = [...new Set(memberIds)].filter(id => rt.botOf(id))
223
+ checkMembers(members)
224
+ const custom = cleanRoomName(roomName ?? '')
225
+ // A Main Bot leads the groups it makes; a group the user makes is led by a Main
226
+ // Bot member when it has one.
227
+ const adminId = admin !== undefined ? admin : (members.includes(createdBy) ? createdBy : members.find(rt.isMain) ?? null)
228
+ if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
229
+ if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
230
+ const title = custom || autoRoomName(members)
231
+ const id = await createSession(title)
232
+ state.rooms[id] = {
233
+ id,
234
+ name: title,
235
+ ...(custom ? { named: true } : {}),
236
+ members,
237
+ admin: adminId,
238
+ mode: mode ?? 'admin',
239
+ notice: cleanNotice(notice),
240
+ createdAt: Date.now(),
241
+ createdBy: createdBy ?? null,
242
+ }
243
+ await rt.save()
244
+ return state.rooms[id]
245
+ }
246
+
247
+ // `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
248
+ async function updateRoom(id, patch, actorId) {
249
+ const room = rt.roomOf(id)
250
+ if (room === undefined) throw new Error('Unknown group chat')
251
+ const byMain = actorId === undefined || rt.isMain(actorId)
252
+ const byAdmin = !byMain && rt.isAdminOf(room, actorId)
253
+ if (!byMain && !byAdmin) throw new Error(`Only the user, a Main Bot, or the admin of "${room.name}" can change it`)
254
+ let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members]
255
+ for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member)
256
+ members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member))
257
+ checkMembers(members)
258
+ let admin = room.admin ?? null
259
+ if (patch.admin !== undefined) {
260
+ admin = patch.admin || null
261
+ if (admin === null && byAdmin) throw new Error('Hand the admin role to another member instead of leaving the group without one')
262
+ if (admin !== null && !members.includes(admin)) throw new Error(`${rt.botOf(admin)?.name ?? 'That Bot'} must be a member to be the admin`)
263
+ }
264
+ if (admin !== null && !members.includes(admin)) {
265
+ if (byAdmin && admin === actorId) throw new Error('Hand the admin role to another member before leaving the group')
266
+ admin = null
267
+ }
268
+ if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
269
+ const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
270
+ const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
271
+
272
+ const changes = []
273
+ const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
274
+ const added = members.filter(member => !room.members.includes(member))
275
+ const removed = room.members.filter(member => !members.includes(member))
276
+ if (added.length > 0) changes.push(`added ${names(added)}`)
277
+ if (removed.length > 0) changes.push(`removed ${names(removed)}`)
278
+ // A member who leaves and comes back starts from a fresh look at the log.
279
+ for (const member of removed) {
280
+ if (room.seen) delete room.seen[member]
281
+ rt.dropGroupSession(room, member)
282
+ }
283
+ room.members = members
284
+ if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin')
285
+ room.admin = admin
286
+ if (patch.mode !== undefined && patch.mode !== room.mode) {
287
+ room.mode = patch.mode
288
+ changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn')
289
+ }
290
+ if (notice !== undefined && notice !== (room.notice ?? '')) {
291
+ room.notice = notice
292
+ changes.push(notice ? 'updated the notice' : 'cleared the notice')
293
+ }
294
+ const before = room.name
295
+ if (name !== undefined) {
296
+ if (name !== '') {
297
+ room.named = true
298
+ room.name = name
299
+ } else {
300
+ delete room.named
301
+ }
302
+ }
303
+ if (!room.named) room.name = autoRoomName(room.members)
304
+ if (room.name !== before) {
305
+ changes.push(`renamed it "${room.name}"`)
306
+ await retitleRoom(room)
307
+ }
308
+ await rt.save()
309
+ return { room, changes }
310
+ }
311
+
312
+ async function deleteRoom(id) {
313
+ const room = rt.roomOf(id)
314
+ if (room === undefined) throw new Error('Unknown group chat')
315
+ for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId)
316
+ delete state.rooms[id]
317
+ rt.roomInbox.delete(id)
318
+ await rt.save()
319
+ await ctx.workspaceRegistry.archiveSession(id).catch(() => {})
320
+ }
321
+
322
+ // The first Main Bot is the sidebar tile. `replace` gives a Main Bot's place to
323
+ // another Bot in one step, so the team never has none; `primary` moves a Bot to the
324
+ // front and keeps the others.
325
+ async function setMain(id, main, { primary = false, replace } = {}) {
326
+ const bot = rt.botOf(id)
327
+ if (bot === undefined) throw new Error('Unknown Bot')
328
+ const others = state.mainBotIds.filter(other => other !== id)
329
+ let next
330
+ if (!main) {
331
+ if (!rt.isMain(id)) return
332
+ if (others.length === 0) throw new Error('The team needs at least one Main Bot')
333
+ next = others
334
+ } else if (replace !== undefined && replace !== id) {
335
+ const at = others.indexOf(replace)
336
+ if (at === -1) throw new Error('Only a Main Bot can be replaced')
337
+ next = others.with(at, id)
338
+ } else if (primary) {
339
+ next = [id, ...others]
340
+ } else {
341
+ next = rt.isMain(id) ? state.mainBotIds : [...others, id]
342
+ }
343
+ // The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted.
344
+ if (main) delete bot.hidden
345
+ // A held setup greeting says "you are the Main Bot"; it must not go out after demotion.
346
+ for (const other of state.mainBotIds.filter(other => !next.includes(other))) {
347
+ if (rt.botOf(other)?.kickoff?.main) delete rt.botOf(other).kickoff
348
+ }
349
+ state.mainBotIds = next
350
+ await rt.save()
351
+ }
352
+
353
+ async function deleteBot(id) {
354
+ if (rt.botOf(id) === undefined) throw new Error('Unknown Bot')
355
+ if (rt.isMain(id)) throw new Error('A Main Bot cannot be deleted; remove its Main Bot role first')
356
+ // Before the Bot and its group Sessions leave the state, which the archive reads.
357
+ const archived = rt.archiveBotMemory(id)
358
+ const current = rt.chatOf(id)
359
+ // Questions and exchange turns are kept per Session, so every part of the chat is cleared.
360
+ const parts = new Set([id, current, ...(rt.botOf(id).segments ?? []).map(segment => segment.sessionId)])
361
+ delete state.bots[id]
362
+ for (const part of parts) {
363
+ delete state.questions[part]
364
+ delete state.exchangeTurns[part]
365
+ }
366
+ const touched = []
367
+ for (const room of Object.values(state.rooms)) {
368
+ if (!room.members.includes(id)) continue
369
+ room.members = room.members.filter(member => member !== id)
370
+ if (room.admin === id) room.admin = null
371
+ rt.dropGroupSession(room, id)
372
+ touched.push(room)
373
+ }
374
+ await followMemberNames(touched)
375
+ await rt.forgetBotSecrets(id)
376
+ await archived
377
+ await rt.save()
378
+ await ctx.workspaceRegistry.archiveSession(current).catch(() => {})
379
+ }
380
+
381
+ async function bootstrap() {
382
+ await rt.load()
383
+ void rt.syncModels()
384
+ if (state.mainBotIds.some(id => rt.botOf(id))) return
385
+ // Only the team's first Main Bot is stored as the DeepSeek whale; other Bots get a
386
+ // look from their id, and a Bot made Main Bot later keeps its own.
387
+ bootstrapping ??= createBot({ name: defaults.mainName, role: defaults.mainRole, main: true, color: 'deepseek', avatar: { shape: 'whale' }, pickModel: true })
388
+ .finally(() => { bootstrapping = null })
389
+ await bootstrapping
390
+ }
391
+
392
+ Object.assign(rt, {
393
+ ensureWorkspace, createSession, createBot, duplicateBot, updateBot, setFlags,
394
+ roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom,
395
+ setMain, deleteBot, bootstrap, dropKickoff,
396
+ })
397
+ }
@@ -0,0 +1,151 @@
1
+ // Scheduled tasks. DSH's schedule service stores each task with the Session it belongs
2
+ // to and, when it is due, sends its prompt to that Session as a new message. This
3
+ // module lists the team's tasks for Settings, creates and removes them there, and moves
4
+ // a Bot's tasks along when its chat goes on in a fresh part, so the Bot keeps seeing
5
+ // them. A group chat's task lives on the group's own Session, so the reminder reaches
6
+ // the group like a message from the user (see reminderText). DSH 0.2.0 mounts the service only with its Automation tasks bundle on, so
7
+ // the service is optional and every endpoint reports whether it is there.
8
+
9
+ const MAX_TITLE = 120
10
+ const TIME = /^([01]\d|2[0-3]):[0-5]\d$/
11
+
12
+ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' })
13
+
14
+ // What a group sees of a due reminder: DSH frames it for one Session's model
15
+ // ("[SCHEDULE REMINDER]" with JSON fields); the group gets each prompt as one line.
16
+ export function reminderText(text) {
17
+ const prompts = []
18
+ const one = /^reminder_prompt_json: (.*)$/m.exec(text)
19
+ const batch = /^reminders_json: (.*)$/m.exec(text)
20
+ try {
21
+ if (one) prompts.push(JSON.parse(one[1]))
22
+ if (batch) for (const entry of JSON.parse(batch[1])) prompts.push(entry.reminder_prompt)
23
+ } catch {
24
+ // A framing this module cannot read goes to the group as it came.
25
+ return text
26
+ }
27
+ const lines = prompts.filter(prompt => typeof prompt === 'string' && prompt.trim() !== '')
28
+ return lines.length === 0 ? text : lines.map(prompt => `[Scheduled task] ${prompt}`).join('\n')
29
+ }
30
+
31
+ // The create request that gives a stored record its timing again, for a move.
32
+ function requestOf(record) {
33
+ const base = { title: record.title, prompt: record.prompt }
34
+ if (record.kind === 'after' || record.kind === 'at') return { ...base, at: record.scheduledAt }
35
+ if (record.kind === 'every') return { ...base, every_seconds: record.everySeconds }
36
+ if (record.kind === 'daily') return { ...base, daily: { time: record.time, time_zone: record.timeZone } }
37
+ if (record.kind === 'weekly') return { ...base, weekly: { time: record.time, time_zone: record.timeZone, weekdays: record.weekdays } }
38
+ if (record.kind === 'cron') return { ...base, cron: { expression: record.expression, time_zone: record.timeZone } }
39
+ return null
40
+ }
41
+
42
+ // The settings form's timing as a create request selector.
43
+ function selectorOf(when) {
44
+ const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC'
45
+ switch (when?.kind) {
46
+ case 'at': {
47
+ const at = new Date(String(when.at ?? ''))
48
+ if (Number.isNaN(at.getTime())) throw invalid('Pick a date and time')
49
+ if (at.getTime() <= Date.now()) throw invalid('Pick a time in the future')
50
+ return { at: at.toISOString() }
51
+ }
52
+ case 'daily':
53
+ if (!TIME.test(String(when.time))) throw invalid('Pick a time of day')
54
+ return { daily: { time: `${when.time}:00`, time_zone: zone } }
55
+ case 'weekly': {
56
+ if (!TIME.test(String(when.time))) throw invalid('Pick a time of day')
57
+ const weekdays = [...new Set((Array.isArray(when.weekdays) ? when.weekdays : []).map(Number))].filter(day => Number.isInteger(day) && day >= 1 && day <= 7).sort()
58
+ if (weekdays.length === 0) throw invalid('Pick at least one day')
59
+ return { weekly: { time: `${when.time}:00`, time_zone: zone, weekdays } }
60
+ }
61
+ case 'every': {
62
+ const minutes = Number(when.minutes)
63
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > 7 * 24 * 60) throw invalid('Repeat every 1 minute to 7 days')
64
+ return { every_seconds: minutes * 60 }
65
+ }
66
+ default:
67
+ throw invalid('Pick when it runs')
68
+ }
69
+ }
70
+
71
+ export function install(rt) {
72
+ const { ctx, state } = rt
73
+ let service = null
74
+
75
+ const attach = (sub) => {
76
+ const own = sub.schedule
77
+ service = own
78
+ sub.effect(() => () => { if (service === own) service = null }, 'ds-bot: schedule service')
79
+ }
80
+ if (typeof ctx.inject === 'function') ctx.inject(['schedule'], attach)
81
+ else if (ctx.get?.('schedule') !== undefined) attach({ schedule: ctx.get('schedule'), effect: (execute, label) => ctx.effect(execute, label) })
82
+
83
+ // The Bot (and group chat) a task's Session speaks for, the group chat for a group's
84
+ // own Session, or nothing for a Session outside the team.
85
+ const ownerOf = (sessionId) => {
86
+ const chat = rt.chatOwners.get(sessionId)
87
+ if (chat !== undefined) return { botId: chat }
88
+ if (rt.roomOf(sessionId) !== undefined) return { roomId: sessionId }
89
+ return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId)
90
+ }
91
+ const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined)
92
+
93
+ async function schedulesView() {
94
+ if (service === null) return { available: false, tasks: [] }
95
+ const tasks = []
96
+ for (const entry of await service.catalog()) {
97
+ const owner = ownerOf(entry.sessionId)
98
+ if (!known(owner)) continue
99
+ tasks.push({ ...entry, ...owner })
100
+ }
101
+ return { available: true, tasks }
102
+ }
103
+
104
+ const endpoints = {
105
+ schedules: () => schedulesView(),
106
+ async 'schedule-create'(body) {
107
+ if (service === null) throw invalid('Automation tasks are off in DSH')
108
+ const bot = state.bots[body.target]
109
+ const room = state.rooms[body.target]
110
+ if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat')
111
+ const title = String(body.title ?? '').trim()
112
+ const prompt = String(body.prompt ?? '').trim()
113
+ if (title === '') throw invalid('Give the task a name')
114
+ if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`)
115
+ if (prompt === '') throw invalid('Say what the Bot should do')
116
+ await service.create(bot ? rt.chatOf(bot.id) : room.id, { title, prompt, ...selectorOf(body.when) })
117
+ return schedulesView()
118
+ },
119
+ async 'schedule-delete'(body) {
120
+ if (service === null) throw invalid('Automation tasks are off in DSH')
121
+ if (ownerOf(body.sessionId) === undefined) throw invalid('No such task')
122
+ await service.delete({ sessionId: body.sessionId, id: body.id })
123
+ return schedulesView()
124
+ },
125
+ }
126
+
127
+ // A fresh part takes over the earlier part's active tasks: each is created again on
128
+ // the fresh Session before the old one goes, so a failure leaves it where it was.
129
+ async function moveSchedules(fromId, toId) {
130
+ if (service === null) return
131
+ let records
132
+ try {
133
+ records = await service.list({ sessionId: fromId })
134
+ } catch (error) {
135
+ rt.warn('ds-bot: tasks of %s not moved: %s', fromId, error?.message ?? error)
136
+ return
137
+ }
138
+ for (const record of records) {
139
+ const request = requestOf(record)
140
+ if (request === null || (request.at !== undefined && Date.parse(request.at) <= Date.now())) continue
141
+ try {
142
+ await service.create(toId, request)
143
+ await service.delete({ sessionId: fromId, id: record.id })
144
+ } catch (error) {
145
+ rt.warn('ds-bot: task %s stayed with the earlier part: %s', record.title, error?.message ?? error)
146
+ }
147
+ }
148
+ }
149
+
150
+ Object.assign(rt, { scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), moveSchedules })
151
+ }