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.
- package/LICENSE +201 -0
- package/README.md +234 -3
- package/client.js +12176 -0
- package/cordis.patch.yml +17 -0
- package/index.js +52 -0
- package/package.json +64 -6
- package/src/host/agents.js +186 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +313 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +62 -0
- package/src/host/delivery.js +59 -0
- package/src/host/diagnostics.js +247 -0
- package/src/host/groups.js +442 -0
- package/src/host/home-lock.js +91 -0
- package/src/host/images.js +174 -0
- package/src/host/memory.js +1351 -0
- package/src/host/models.js +145 -0
- package/src/host/parts.js +886 -0
- package/src/host/prompt.js +67 -0
- package/src/host/roster.js +397 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +259 -0
- package/src/host/text.js +47 -0
- package/src/host/tools.js +374 -0
- package/src/host/turns.js +250 -0
- package/src/host/update.js +205 -0
- package/src/host/usage.js +572 -0
- package/web-search.js +748 -0
|
@@ -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
|
+
}
|