@thewaifu/dsh-bot 1.1.1
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 +304 -0
- package/bin/dsh-bot-web.js +33 -0
- package/client.js +13691 -0
- package/cordis.patch.yml +17 -0
- package/index.js +57 -0
- package/package.json +80 -0
- package/src/host/agents.js +186 -0
- package/src/host/approvals.js +63 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +342 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +65 -0
- package/src/host/delivery.js +71 -0
- package/src/host/diagnostics.js +249 -0
- package/src/host/dsh-compat.js +22 -0
- package/src/host/dsh-update.js +170 -0
- package/src/host/groups.js +592 -0
- package/src/host/home-lock.js +97 -0
- package/src/host/images.js +211 -0
- package/src/host/memory.js +1362 -0
- package/src/host/models.js +231 -0
- package/src/host/parts.js +976 -0
- package/src/host/permissions.js +48 -0
- package/src/host/prompt.js +71 -0
- package/src/host/roster.js +471 -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 +344 -0
- package/src/host/text.js +47 -0
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +425 -0
- package/src/host/turns.js +388 -0
- package/src/host/update.js +237 -0
- package/src/host/usage.js +591 -0
- package/src/host/waifu.js +58 -0
- package/src/shared/mentions.js +65 -0
- package/src/shared/web-port.js +26 -0
- package/web-search.js +764 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Permissions. A Bot's permission preset is its own: kept on the Bot and set on every
|
|
2
|
+
// Session it speaks from, so a fresh part or a new group Session keeps it.
|
|
3
|
+
// DSH offers Auto review for the current Session only, so a Bot never keeps it.
|
|
4
|
+
const SESSION_ONLY = 'auto'
|
|
5
|
+
|
|
6
|
+
export function install(rt) {
|
|
7
|
+
const { ctx } = rt
|
|
8
|
+
|
|
9
|
+
const presets = () => ctx.get?.('permissionPresets')
|
|
10
|
+
|
|
11
|
+
// The presets DSH offers, or undefined when the profile has none.
|
|
12
|
+
function permissionCatalog() {
|
|
13
|
+
const service = presets()
|
|
14
|
+
if (service === undefined) return undefined
|
|
15
|
+
const catalog = typeof service.catalog === 'function'
|
|
16
|
+
? service.catalog()
|
|
17
|
+
: {
|
|
18
|
+
options: (service.names ?? []).map(name => service.optionOf?.(name) ?? { value: name, name }),
|
|
19
|
+
defaultPreset: service.defaultPreset,
|
|
20
|
+
}
|
|
21
|
+
const { options, defaultPreset } = catalog
|
|
22
|
+
return { options: options.filter(option => option.value !== SESSION_ONLY), defaultPreset }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Sets the Bot's preset on one of its Sessions; a Bot without one keeps DSH's default.
|
|
26
|
+
async function applyPermission(bot, sessionId) {
|
|
27
|
+
const service = presets()
|
|
28
|
+
if (service === undefined || !bot.permission) return
|
|
29
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
30
|
+
service.set(agent.session, bot.permission)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function setPermission(bot, name) {
|
|
34
|
+
const service = presets()
|
|
35
|
+
if (service === undefined) throw new Error('Permissions are not available in this DeepSeek Harness')
|
|
36
|
+
const wanted = String(name ?? '').trim()
|
|
37
|
+
if (wanted === SESSION_ONLY) throw new Error('Auto review applies to one Session only and cannot be a Bot\'s permission')
|
|
38
|
+
service.resolve(wanted)
|
|
39
|
+
await applyPermission({ ...bot, permission: wanted }, rt.chatOf(bot.id))
|
|
40
|
+
bot.permission = wanted
|
|
41
|
+
for (const sessionId of rt.groupSessionsOf(bot.id)) {
|
|
42
|
+
await applyPermission(bot, sessionId)
|
|
43
|
+
.catch(error => rt.warn('ds-bot: permission for %s in a group not applied: %s', bot.name, error))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Object.assign(rt, { permissionCatalog, applyPermission, setPermission })
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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 a message to another Bot. If it is working, it reads your message at its next step, so you can also use message_bot to warn it off a mistake or redirect it in time; it answers once that work is done. Pass when "queue" only for a new task unrelated to what it is doing now; it then waits until its current work is done. 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 …]" opens a turn, your final text in that turn is delivered back to that Bot automatically and the user does not see it. If one arrives while you are working on something else, take it into account in that work but do not answer it there: once the work is done, it comes back to you in a turn of its own to answer. Answer it directly and briefly, as a list of facts. Never invent facts: say "not discussed yet" when you do not know. If the message brings a request or a decision meant for the user, handle it in the same turn too — do the work, or ask the user with ask_user.',
|
|
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
|
+
'- Only for a direct user request in your own chat that needs several steps: write one short sentence of normal reply text before your first tool call, and make that tool call in the same response. Never start such a task with tools alone or end it with a promise. On long tasks, give brief factual updates only at meaningful milestones, then keep working.',
|
|
15
|
+
'- For "[Group chat …]", "[Message from …]", or "[Reply from …]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.',
|
|
16
|
+
'- 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.',
|
|
17
|
+
'- update_bot changes your own settings: name, role label, color, model, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up. Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.',
|
|
18
|
+
'- 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.',
|
|
19
|
+
'- 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.',
|
|
20
|
+
'- 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.',
|
|
21
|
+
'- 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.',
|
|
22
|
+
'- Reply in the user\'s language.',
|
|
23
|
+
].join('\n')
|
|
24
|
+
|
|
25
|
+
const MAIN_RULES = [
|
|
26
|
+
'## You are the Main Bot (Chief of Staff)',
|
|
27
|
+
'- You run the user\'s Bot team: you decide who does what, create new Bots, and report back.',
|
|
28
|
+
'- 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.',
|
|
29
|
+
'- After creating a Bot, tell the user in one line that it is ready, using its exact name.',
|
|
30
|
+
'- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
|
|
31
|
+
'- If a Bot does not heed your message and must not finish what it is doing, stop_bot ends its turn at once and tells it why; the reply comes back to you.',
|
|
32
|
+
'- 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.',
|
|
33
|
+
'- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
|
|
34
|
+
'- 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.',
|
|
35
|
+
'- The team can have more than one Main Bot; list_bots marks them. They share these powers, so coordinate with them through message_bot.',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
export function install(rt) {
|
|
39
|
+
const { ctx } = rt
|
|
40
|
+
|
|
41
|
+
// A group Session renders exactly its Bot's text, so both share one cached prefix.
|
|
42
|
+
const promptFor = (id) => {
|
|
43
|
+
const self = rt.selfOf(id)
|
|
44
|
+
const bot = rt.botOf(self)
|
|
45
|
+
if (bot !== undefined) {
|
|
46
|
+
return [
|
|
47
|
+
'# Your identity',
|
|
48
|
+
`You are ${bot.name}, a Bot on the user's team in DS Bot.${bot.role ? ` Your role: ${bot.role}.` : ''}`,
|
|
49
|
+
bot.instructions ? `\n${bot.instructions}` : '',
|
|
50
|
+
'',
|
|
51
|
+
rt.isMain(self) ? `${MAIN_RULES}\n\n${TEAM_RULES}` : TEAM_RULES,
|
|
52
|
+
].join('\n')
|
|
53
|
+
}
|
|
54
|
+
const room = rt.roomOf(id)
|
|
55
|
+
if (room !== undefined) {
|
|
56
|
+
return [
|
|
57
|
+
'# Group chat relay',
|
|
58
|
+
'This conversation is a group chat between the user and several Bots. You are only the relay, not a participant.',
|
|
59
|
+
'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.',
|
|
60
|
+
].join('\n')
|
|
61
|
+
}
|
|
62
|
+
return ''
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
66
|
+
name: 'bot-identity',
|
|
67
|
+
order: 640,
|
|
68
|
+
interpolate: false,
|
|
69
|
+
text: context => promptFor(context?.agent?.id),
|
|
70
|
+
}), 'ds-bot: identity section')
|
|
71
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
import { nameProblem } from '../shared/mentions.js'
|
|
8
|
+
|
|
9
|
+
export function install(rt) {
|
|
10
|
+
const { ctx, config, home, defaults, state } = rt
|
|
11
|
+
|
|
12
|
+
let bootstrapping = null
|
|
13
|
+
|
|
14
|
+
async function ensureWorkspace() {
|
|
15
|
+
if (state.workspaceId && ctx.workspaceRegistry.get(state.workspaceId)) return state.workspaceId
|
|
16
|
+
const path = config.workspace ?? join(home, 'workspace')
|
|
17
|
+
await mkdir(path, { recursive: true })
|
|
18
|
+
const workspace = await ctx.workspaceRegistry.resolveByPath(path) ?? await ctx.workspaceRegistry.create(path, 'DS Bot')
|
|
19
|
+
state.workspaceId = workspace.id
|
|
20
|
+
await rt.save()
|
|
21
|
+
return workspace.id
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function createSession(title) {
|
|
25
|
+
// A Bot's tools run here too; while another dsh holds the team, a new Session would
|
|
26
|
+
// belong to no saved Bot.
|
|
27
|
+
const readOnly = rt.readOnly()
|
|
28
|
+
if (readOnly !== undefined) throw new Error(readOnly)
|
|
29
|
+
const workspaceId = await ensureWorkspace()
|
|
30
|
+
const { sessionId } = await ctx.sessionController.create({ workspaceId })
|
|
31
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
32
|
+
await ctx.sessionTitle.rename(agent.session, title)
|
|
33
|
+
return sessionId
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanAvatar(input) {
|
|
37
|
+
if (input === null || typeof input !== 'object') return undefined
|
|
38
|
+
const avatar = {}
|
|
39
|
+
if (typeof input.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(input.shape)) avatar.shape = input.shape
|
|
40
|
+
if (typeof input.image === 'string') {
|
|
41
|
+
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')
|
|
42
|
+
if (input.image.length > MAX_AVATAR_IMAGE) throw new Error('Avatar image is too large')
|
|
43
|
+
avatar.image = input.image
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(avatar).length > 0 ? avatar : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const freshName = (base) => {
|
|
49
|
+
const stem = base.slice(0, 34)
|
|
50
|
+
if (!rt.findBot(stem)) return stem
|
|
51
|
+
let index = 2
|
|
52
|
+
while (rt.findBot(`${stem} ${index}`)) index += 1
|
|
53
|
+
return `${stem} ${index}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `pickModel` leaves the model to the user: the chat opens on a model card, and the
|
|
57
|
+
// greeting waits until a model is chosen. So does a Bot made when no model is served.
|
|
58
|
+
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
|
|
59
|
+
const cleanName = String(botName ?? '').trim().slice(0, 40)
|
|
60
|
+
const problem = nameProblem(cleanName)
|
|
61
|
+
if (problem !== undefined) throw new Error(problem)
|
|
62
|
+
if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
|
|
63
|
+
const cleanAvatarValue = cleanAvatar(avatar)
|
|
64
|
+
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
|
|
65
|
+
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
|
|
66
|
+
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
|
|
67
|
+
const id = await createSession(cleanName)
|
|
68
|
+
try {
|
|
69
|
+
state.bots[id] = {
|
|
70
|
+
id,
|
|
71
|
+
name: cleanName,
|
|
72
|
+
role: String(role).trim().slice(0, 24),
|
|
73
|
+
// A color is only stored when the user picks one; otherwise the id picks it.
|
|
74
|
+
...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
|
|
75
|
+
...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
|
|
76
|
+
...(modelRef ? { model: modelRef } : {}),
|
|
77
|
+
...(effortId ? { effort: effortId } : {}),
|
|
78
|
+
instructions: String(instructions).trim(),
|
|
79
|
+
createdAt: Date.now(),
|
|
80
|
+
createdBy: createdBy ?? null,
|
|
81
|
+
}
|
|
82
|
+
if (main) state.mainBotIds.push(id)
|
|
83
|
+
const creator = rt.botOf(createdBy)
|
|
84
|
+
const original = rt.botOf(copyOf)
|
|
85
|
+
const language = rt.uiLanguage?.()
|
|
86
|
+
const kickoff = main
|
|
87
|
+
? `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'}.`
|
|
88
|
+
: original
|
|
89
|
+
? `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).`
|
|
90
|
+
: creator
|
|
91
|
+
? `${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.`
|
|
92
|
+
: brief
|
|
93
|
+
? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
|
|
94
|
+
: '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).'
|
|
95
|
+
const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
|
|
96
|
+
const bot = state.bots[id]
|
|
97
|
+
if (modelRef === undefined) {
|
|
98
|
+
bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
|
|
99
|
+
await rt.save()
|
|
100
|
+
return bot
|
|
101
|
+
}
|
|
102
|
+
await rt.save()
|
|
103
|
+
// The greeting runs on the Bot's own model, once its provider is registered.
|
|
104
|
+
void rt.waitForModel(bot)
|
|
105
|
+
.then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
|
|
106
|
+
.then(() => sendKickoff(bot, { text, from: createdBy }))
|
|
107
|
+
return bot
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// The Bot's Session was already made: archive it instead of leaving an orphan.
|
|
110
|
+
delete state.bots[id]
|
|
111
|
+
state.mainBotIds = state.mainBotIds.filter(other => other !== id)
|
|
112
|
+
rt.chatOwners.delete(id)
|
|
113
|
+
// A save that another change queued meanwhile still carries this Bot; a later
|
|
114
|
+
// one writes the team without it.
|
|
115
|
+
void rt.save().catch(() => {})
|
|
116
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
117
|
+
.catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', cleanName, archiveError))
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sendKickoff(bot, { text, from }) {
|
|
123
|
+
void rt.deliver({ fromId: from ?? undefined, toId: bot.id, text, role: 'kickoff' })
|
|
124
|
+
.catch(error => rt.warn('ds-bot: kickoff failed: %s', error))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The user wrote first, so the held greeting would only interrupt.
|
|
128
|
+
function dropKickoff(botId) {
|
|
129
|
+
const bot = rt.botOf(botId)
|
|
130
|
+
if (bot?.kickoff === undefined) return
|
|
131
|
+
delete bot.kickoff
|
|
132
|
+
void rt.save().catch(() => {})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function duplicateBot(id) {
|
|
136
|
+
const bot = rt.botOf(id)
|
|
137
|
+
if (bot === undefined) throw new Error('Unknown Bot')
|
|
138
|
+
const served = (await rt.catalog()).some(entry => entry.ref === bot.model)
|
|
139
|
+
return createBot({
|
|
140
|
+
name: freshName(`${bot.name} copy`),
|
|
141
|
+
role: bot.role,
|
|
142
|
+
instructions: bot.instructions,
|
|
143
|
+
color: bot.color,
|
|
144
|
+
avatar: bot.avatar,
|
|
145
|
+
model: served ? bot.model : undefined,
|
|
146
|
+
effort: served ? bot.effort : undefined,
|
|
147
|
+
copyOf: bot.id,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function updateBot(id, patch) {
|
|
152
|
+
const bot = rt.botOf(id)
|
|
153
|
+
if (bot === undefined) throw new Error('Unknown Bot')
|
|
154
|
+
if (patch.name !== undefined) {
|
|
155
|
+
const next = String(patch.name).trim().slice(0, 40)
|
|
156
|
+
const clash = rt.findBot(next)
|
|
157
|
+
if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`)
|
|
158
|
+
// A name saved before the rule stays usable until it changes.
|
|
159
|
+
const problem = next === bot.name ? undefined : nameProblem(next)
|
|
160
|
+
if (problem !== undefined) throw new Error(problem)
|
|
161
|
+
bot.name = next
|
|
162
|
+
const agent = await rt.resolveAgent(rt.chatOf(id))
|
|
163
|
+
await ctx.sessionTitle.rename(agent.session, next)
|
|
164
|
+
await followMemberNames(Object.values(state.rooms).filter(room => room.members.includes(id)))
|
|
165
|
+
}
|
|
166
|
+
if (patch.role !== undefined) bot.role = String(patch.role).trim().slice(0, 24)
|
|
167
|
+
if (patch.instructions !== undefined) bot.instructions = String(patch.instructions).trim()
|
|
168
|
+
let modelChanged = false
|
|
169
|
+
if (patch.color !== undefined) {
|
|
170
|
+
if (patch.color === null) delete bot.color
|
|
171
|
+
else if (isColor(currentColor(patch.color))) bot.color = currentColor(patch.color)
|
|
172
|
+
}
|
|
173
|
+
if (patch.avatar !== undefined) {
|
|
174
|
+
const avatar = cleanAvatar(patch.avatar)
|
|
175
|
+
if (avatar) bot.avatar = avatar
|
|
176
|
+
else delete bot.avatar
|
|
177
|
+
}
|
|
178
|
+
if (patch.model !== undefined) {
|
|
179
|
+
if (patch.model === null || patch.model === '') delete bot.model
|
|
180
|
+
else bot.model = await rt.resolveModel(patch.model)
|
|
181
|
+
modelChanged = true
|
|
182
|
+
}
|
|
183
|
+
if (patch.effort !== undefined) {
|
|
184
|
+
if (patch.effort === null || patch.effort === '') delete bot.effort
|
|
185
|
+
else {
|
|
186
|
+
const ref = bot.model ?? bot.appliedModel
|
|
187
|
+
if (ref === undefined) throw new Error('Pick a model before choosing a thinking level')
|
|
188
|
+
bot.effort = await rt.resolveEffort(ref, patch.effort)
|
|
189
|
+
}
|
|
190
|
+
modelChanged = true
|
|
191
|
+
}
|
|
192
|
+
if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
|
|
193
|
+
await rt.save()
|
|
194
|
+
if (modelChanged) await rt.applyModel(bot, { force: true })
|
|
195
|
+
if (bot.model && bot.kickoff !== undefined) {
|
|
196
|
+
const held = bot.kickoff
|
|
197
|
+
delete bot.kickoff
|
|
198
|
+
await rt.save()
|
|
199
|
+
sendKickoff(bot, held)
|
|
200
|
+
}
|
|
201
|
+
return bot
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function setFlags(id, { pinned, hidden }) {
|
|
205
|
+
const entry = rt.botOf(id) ?? rt.roomOf(id) ?? state.agents[id]
|
|
206
|
+
if (entry === undefined) throw new Error('Unknown conversation')
|
|
207
|
+
if (typeof pinned === 'boolean') entry.pinned = pinned
|
|
208
|
+
if (typeof hidden === 'boolean') entry.hidden = hidden
|
|
209
|
+
await rt.save()
|
|
210
|
+
return entry
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Group chat settings. The user and every Main Bot may change any group; a group's
|
|
215
|
+
// admin may change its own group but not delete it. Only a member can be admin.
|
|
216
|
+
|
|
217
|
+
const roomBusy = id => [...rt.roomProgress.values()].some(round => round.roomId === id)
|
|
218
|
+
const autoRoomName = members => ['You', ...members.map(id => rt.botOf(id)?.name).filter(Boolean)].join(', ')
|
|
219
|
+
const cleanNotice = text => String(text ?? '').trim().slice(0, ROOM_NOTICE_MAX)
|
|
220
|
+
|
|
221
|
+
function cleanRoomName(text, selfId) {
|
|
222
|
+
const next = unquote(text).replace(/\s+/g, ' ').slice(0, ROOM_NAME_MAX)
|
|
223
|
+
if (next !== '' && rt.findRooms(next).some(room => room.id !== selfId)) throw new Error(`Another group chat is already called "${next}"`)
|
|
224
|
+
return next
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function retitleRoom(room) {
|
|
228
|
+
try {
|
|
229
|
+
const agent = await rt.resolveAgent(room.id)
|
|
230
|
+
await ctx.sessionTitle.rename(agent.session, room.name)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
rt.warn('ds-bot: group title for %s not updated: %s', room.name, error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Groups without a chosen name are named after their members, so they follow renames.
|
|
237
|
+
async function followMemberNames(rooms = Object.values(state.rooms)) {
|
|
238
|
+
for (const room of rooms) {
|
|
239
|
+
if (room.named) continue
|
|
240
|
+
const next = autoRoomName(room.members)
|
|
241
|
+
if (next === room.name) continue
|
|
242
|
+
room.name = next
|
|
243
|
+
await retitleRoom(room)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function checkMembers(members) {
|
|
248
|
+
if (members.length < 2) throw new Error('A group chat needs at least two Bots')
|
|
249
|
+
if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function createRoom({ members: memberIds = [], name: roomName, notice, mode, admin, createdBy } = {}) {
|
|
253
|
+
const members = [...new Set(memberIds)].filter(id => rt.botOf(id))
|
|
254
|
+
checkMembers(members)
|
|
255
|
+
const custom = cleanRoomName(roomName ?? '')
|
|
256
|
+
// A Main Bot leads the groups it makes; a group the user makes is led by a Main
|
|
257
|
+
// Bot member when it has one.
|
|
258
|
+
const adminId = admin !== undefined ? admin : (members.includes(createdBy) ? createdBy : members.find(rt.isMain) ?? null)
|
|
259
|
+
if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
|
|
260
|
+
if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
261
|
+
const title = custom || autoRoomName(members)
|
|
262
|
+
const id = await createSession(title)
|
|
263
|
+
try {
|
|
264
|
+
state.rooms[id] = {
|
|
265
|
+
id,
|
|
266
|
+
name: title,
|
|
267
|
+
...(custom ? { named: true } : {}),
|
|
268
|
+
members,
|
|
269
|
+
admin: adminId,
|
|
270
|
+
mode: mode ?? 'admin',
|
|
271
|
+
notice: cleanNotice(notice),
|
|
272
|
+
createdAt: Date.now(),
|
|
273
|
+
createdBy: createdBy ?? null,
|
|
274
|
+
}
|
|
275
|
+
await rt.save()
|
|
276
|
+
await rt.applyRoomModel(state.rooms[id])
|
|
277
|
+
.catch(error => rt.warn('ds-bot: relay model for %s not applied: %s', title, error))
|
|
278
|
+
return state.rooms[id]
|
|
279
|
+
} catch (error) {
|
|
280
|
+
// The group's Session was already made: archive it instead of leaving an orphan.
|
|
281
|
+
delete state.rooms[id]
|
|
282
|
+
void rt.save().catch(() => {})
|
|
283
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
284
|
+
.catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
|
|
290
|
+
async function updateRoom(id, patch, actorId) {
|
|
291
|
+
const room = rt.roomOf(id)
|
|
292
|
+
if (room === undefined) throw new Error('Unknown group chat')
|
|
293
|
+
const byMain = actorId === undefined || rt.isMain(actorId)
|
|
294
|
+
const byAdmin = !byMain && rt.isAdminOf(room, actorId)
|
|
295
|
+
if (!byMain && !byAdmin) throw new Error(`Only the user, a Main Bot, or the admin of "${room.name}" can change it`)
|
|
296
|
+
let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members]
|
|
297
|
+
for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member)
|
|
298
|
+
members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member))
|
|
299
|
+
checkMembers(members)
|
|
300
|
+
let admin = room.admin ?? null
|
|
301
|
+
if (patch.admin !== undefined) {
|
|
302
|
+
admin = patch.admin || null
|
|
303
|
+
if (admin === null && byAdmin) throw new Error('Hand the admin role to another member instead of leaving the group without one')
|
|
304
|
+
if (admin !== null && !members.includes(admin)) throw new Error(`${rt.botOf(admin)?.name ?? 'That Bot'} must be a member to be the admin`)
|
|
305
|
+
}
|
|
306
|
+
if (admin !== null && !members.includes(admin)) {
|
|
307
|
+
if (byAdmin && admin === actorId) throw new Error('Hand the admin role to another member before leaving the group')
|
|
308
|
+
admin = null
|
|
309
|
+
}
|
|
310
|
+
if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
311
|
+
const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
|
|
312
|
+
const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
|
|
313
|
+
|
|
314
|
+
const changes = []
|
|
315
|
+
const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
|
|
316
|
+
const added = members.filter(member => !room.members.includes(member))
|
|
317
|
+
const removed = room.members.filter(member => !members.includes(member))
|
|
318
|
+
if (added.length > 0) changes.push(`added ${names(added)}`)
|
|
319
|
+
if (removed.length > 0) changes.push(`removed ${names(removed)}`)
|
|
320
|
+
// A member who leaves and comes back starts from a fresh look at the log.
|
|
321
|
+
for (const member of removed) {
|
|
322
|
+
if (room.seen) delete room.seen[member]
|
|
323
|
+
rt.dropGroupSession(room, member)
|
|
324
|
+
}
|
|
325
|
+
room.members = members
|
|
326
|
+
if (admin !== (room.admin ?? null)) changes.push(admin ? `made ${rt.botOf(admin).name} the admin` : 'removed the admin')
|
|
327
|
+
room.admin = admin
|
|
328
|
+
if (patch.mode !== undefined && patch.mode !== room.mode) {
|
|
329
|
+
room.mode = patch.mode
|
|
330
|
+
changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn')
|
|
331
|
+
}
|
|
332
|
+
if (notice !== undefined && notice !== (room.notice ?? '')) {
|
|
333
|
+
room.notice = notice
|
|
334
|
+
changes.push(notice ? 'updated the notice' : 'cleared the notice')
|
|
335
|
+
}
|
|
336
|
+
const before = room.name
|
|
337
|
+
if (name !== undefined) {
|
|
338
|
+
if (name !== '') {
|
|
339
|
+
room.named = true
|
|
340
|
+
room.name = name
|
|
341
|
+
} else {
|
|
342
|
+
delete room.named
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!room.named) room.name = autoRoomName(room.members)
|
|
346
|
+
if (room.name !== before) {
|
|
347
|
+
changes.push(`renamed it "${room.name}"`)
|
|
348
|
+
await retitleRoom(room)
|
|
349
|
+
}
|
|
350
|
+
await rt.save()
|
|
351
|
+
await rt.applyRoomModel(room, { force: true })
|
|
352
|
+
.catch(error => rt.warn('ds-bot: relay model for %s not applied: %s', room.name, error))
|
|
353
|
+
return { room, changes }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function deleteRoom(id) {
|
|
357
|
+
const room = rt.roomOf(id)
|
|
358
|
+
if (room === undefined) throw new Error('Unknown group chat')
|
|
359
|
+
for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId)
|
|
360
|
+
delete state.rooms[id]
|
|
361
|
+
rt.roomInbox.delete(id)
|
|
362
|
+
const failed = await saveForDelete()
|
|
363
|
+
// Armed scheduled tasks refuse a plain archive and would keep delivering into
|
|
364
|
+
// the deleted group; the session-stop that stopActivity runs removes them.
|
|
365
|
+
await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
|
|
366
|
+
.catch(error => rt.warn('ds-bot: group chat %s not archived: %s', room.name, error))
|
|
367
|
+
if (failed !== undefined) throw failed
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The first Main Bot is the sidebar tile. `replace` gives a Main Bot's place to
|
|
371
|
+
// another Bot in one step, so the team never has none; `primary` moves a Bot to the
|
|
372
|
+
// front and keeps the others.
|
|
373
|
+
async function setMain(id, main, { primary = false, replace } = {}) {
|
|
374
|
+
const bot = rt.botOf(id)
|
|
375
|
+
if (bot === undefined) throw new Error('Unknown Bot')
|
|
376
|
+
const others = state.mainBotIds.filter(other => other !== id)
|
|
377
|
+
let next
|
|
378
|
+
if (!main) {
|
|
379
|
+
if (!rt.isMain(id)) return
|
|
380
|
+
if (others.length === 0) throw new Error('The team needs at least one Main Bot')
|
|
381
|
+
next = others
|
|
382
|
+
} else if (replace !== undefined && replace !== id) {
|
|
383
|
+
const at = others.indexOf(replace)
|
|
384
|
+
if (at === -1) throw new Error('Only a Main Bot can be replaced')
|
|
385
|
+
next = others.with(at, id)
|
|
386
|
+
} else if (primary) {
|
|
387
|
+
next = [id, ...others]
|
|
388
|
+
} else {
|
|
389
|
+
next = rt.isMain(id) ? state.mainBotIds : [...others, id]
|
|
390
|
+
}
|
|
391
|
+
// The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted.
|
|
392
|
+
if (main) delete bot.hidden
|
|
393
|
+
// A held setup greeting says "you are the Main Bot"; it must not go out after demotion.
|
|
394
|
+
for (const other of state.mainBotIds.filter(other => !next.includes(other))) {
|
|
395
|
+
if (rt.botOf(other)?.kickoff?.main) delete rt.botOf(other).kickoff
|
|
396
|
+
}
|
|
397
|
+
state.mainBotIds = next
|
|
398
|
+
await rt.save()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function deleteBot(id) {
|
|
402
|
+
const bot = rt.botOf(id)
|
|
403
|
+
if (bot === undefined) throw new Error('Unknown Bot')
|
|
404
|
+
if (rt.isMain(id)) throw new Error('A Main Bot cannot be deleted; remove its Main Bot role first')
|
|
405
|
+
// Before the Bot and its group Sessions leave the state, which the archive reads.
|
|
406
|
+
const archived = rt.archiveBotMemory(id)
|
|
407
|
+
const current = rt.chatOf(id)
|
|
408
|
+
// Questions and exchange turns are kept per Session, so every part of the chat is cleared.
|
|
409
|
+
const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)])
|
|
410
|
+
delete state.bots[id]
|
|
411
|
+
for (const part of parts) {
|
|
412
|
+
delete state.questions[part]
|
|
413
|
+
delete state.exchangeTurns[part]
|
|
414
|
+
}
|
|
415
|
+
const touched = []
|
|
416
|
+
for (const room of Object.values(state.rooms)) {
|
|
417
|
+
if (!room.members.includes(id)) continue
|
|
418
|
+
room.members = room.members.filter(member => member !== id)
|
|
419
|
+
if (room.admin === id) room.admin = null
|
|
420
|
+
rt.dropGroupSession(room, id)
|
|
421
|
+
touched.push(room)
|
|
422
|
+
}
|
|
423
|
+
await followMemberNames(touched)
|
|
424
|
+
for (const room of touched) {
|
|
425
|
+
await rt.applyRoomModel(room, { force: true })
|
|
426
|
+
.catch(error => rt.warn('ds-bot: relay model for %s not applied: %s', room.name, error))
|
|
427
|
+
}
|
|
428
|
+
await rt.forgetBotSecrets(id)
|
|
429
|
+
await archived
|
|
430
|
+
const failed = await saveForDelete()
|
|
431
|
+
// Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks.
|
|
432
|
+
// Past parts are archived too: a part move can leave tasks on the old Session,
|
|
433
|
+
// which otherwise stays armed and invisible. Already-archived ids are a no-op.
|
|
434
|
+
for (const part of parts) {
|
|
435
|
+
await ctx.workspaceRegistry.archiveSession(part, { stopActivity: true })
|
|
436
|
+
.catch(error => rt.warn('ds-bot: Bot %s not archived: %s', bot.name, error))
|
|
437
|
+
}
|
|
438
|
+
if (failed !== undefined) throw failed
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// A deletion whose save fails stays in memory, and the next save writes it, so its
|
|
442
|
+
// Sessions are stopped anyway: left armed, their scheduled tasks would keep running
|
|
443
|
+
// with nothing in the team to show or delete them. Only a host that lost the team
|
|
444
|
+
// to another dsh keeps them, since the other host's copy still has them.
|
|
445
|
+
async function saveForDelete() {
|
|
446
|
+
try {
|
|
447
|
+
await rt.save()
|
|
448
|
+
return undefined
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error?.code === 'ELOCKLOST' || rt.readOnly() !== undefined) throw error
|
|
451
|
+
return error
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function bootstrap() {
|
|
456
|
+
await rt.load()
|
|
457
|
+
void rt.syncModels()
|
|
458
|
+
if (state.mainBotIds.some(id => rt.botOf(id))) return
|
|
459
|
+
// Only the team's first Main Bot is stored as the DeepSeek whale; other Bots get a
|
|
460
|
+
// look from their id, and a Bot made Main Bot later keeps its own.
|
|
461
|
+
bootstrapping ??= createBot({ name: defaults.mainName, role: defaults.mainRole, main: true, color: 'deepseek', avatar: { shape: 'whale' }, pickModel: true })
|
|
462
|
+
.finally(() => { bootstrapping = null })
|
|
463
|
+
await bootstrapping
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
Object.assign(rt, {
|
|
467
|
+
ensureWorkspace, createSession, createBot, duplicateBot, updateBot, setFlags,
|
|
468
|
+
roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom,
|
|
469
|
+
setMain, deleteBot, bootstrap, dropKickoff,
|
|
470
|
+
})
|
|
471
|
+
}
|