ds-bot 0.0.0-stage → 0.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 +236 -3
- package/client.js +12618 -0
- package/cordis.patch.yml +17 -0
- package/index.js +54 -0
- package/package.json +67 -6
- package/src/host/agents.js +186 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +320 -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/dsh-update.js +168 -0
- package/src/host/groups.js +444 -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 +202 -0
- package/src/host/parts.js +902 -0
- package/src/host/permissions.js +42 -0
- package/src/host/prompt.js +67 -0
- package/src/host/roster.js +410 -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 +210 -0
- package/src/host/usage.js +594 -0
- package/web-search.js +748 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// DSH updates: every released DS Bot names the DSH release it was verified
|
|
2
|
+
// against (`dsBot.verifiedDsh` in package.json). The newest released bot wins:
|
|
3
|
+
// offering that version and nothing newer keeps the update path away from DSH
|
|
4
|
+
// releases we have not checked. A DSH installed from npm swaps with
|
|
5
|
+
// `npm i -g`; a packaged app's runtime lives inside its bundle, so the card
|
|
6
|
+
// there only reports current and verified — its own updater chooses versions.
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
CHECK_TTL_MS, cleanVersion, encodeName, fetchJson, installSpecOf,
|
|
13
|
+
newer, registryPlan, repoSlug, sourceOf,
|
|
14
|
+
} from './update.js'
|
|
15
|
+
|
|
16
|
+
const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
17
|
+
const APP_BOOT = '@deepseek-ai/dsh-app-boot'
|
|
18
|
+
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
|
19
|
+
|
|
20
|
+
export function install(rt) {
|
|
21
|
+
const { ctx, config } = rt
|
|
22
|
+
const enabled = config.updateCheck !== false
|
|
23
|
+
|
|
24
|
+
const status = {
|
|
25
|
+
current: null, verified: null, kind: 'npm', blockedBy: null,
|
|
26
|
+
checkedAt: 0, phase: 'idle', installed: null, error: null,
|
|
27
|
+
}
|
|
28
|
+
let target = null
|
|
29
|
+
let checking = null
|
|
30
|
+
let running = null
|
|
31
|
+
|
|
32
|
+
// A newer `minBot` than the running bot gates the offer: the bot goes first.
|
|
33
|
+
const blocked = () => status.blockedBy !== null && newer(status.blockedBy, PKG.version)
|
|
34
|
+
|
|
35
|
+
const view = () => ({
|
|
36
|
+
current: status.current, verified: status.verified, kind: status.kind, blockedBy: status.blockedBy,
|
|
37
|
+
checkedAt: status.checkedAt, phase: status.phase, installed: status.installed, error: status.error,
|
|
38
|
+
// Nothing is offered without a current version to compare against.
|
|
39
|
+
available: status.phase !== 'installed' && target !== null && status.current !== null
|
|
40
|
+
&& status.verified !== null && status.verified !== status.current && !blocked(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// The version of the DSH that is running us: the app-boot export, or — where
|
|
44
|
+
// that import cannot see it — the nearest @deepseek-ai package.json above the
|
|
45
|
+
// entry script.
|
|
46
|
+
async function currentDsh() {
|
|
47
|
+
try {
|
|
48
|
+
const loader = ctx.get?.('loader')
|
|
49
|
+
const loaded = await loader?.import?.(APP_BOOT)
|
|
50
|
+
const appBoot = loader?.unwrapExports?.(loaded) ?? loaded?.default ?? loaded
|
|
51
|
+
const version = cleanVersion(appBoot?.getDshRuntimeVersion?.())
|
|
52
|
+
if (version) return version
|
|
53
|
+
} catch { /* the export is not visible here: read the install instead */ }
|
|
54
|
+
let dir = dirname(String(process.argv[1] ?? ''))
|
|
55
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
56
|
+
try {
|
|
57
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
58
|
+
if (manifest?.name?.startsWith?.('@deepseek-ai/')) {
|
|
59
|
+
const version = cleanVersion(manifest.version)
|
|
60
|
+
if (version) return version
|
|
61
|
+
}
|
|
62
|
+
} catch { /* not a package directory */ }
|
|
63
|
+
const parent = dirname(dir)
|
|
64
|
+
if (parent === dir) break
|
|
65
|
+
dir = parent
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A bundled package manager means the runtime came with the app and only the
|
|
71
|
+
// app's own updater can move it.
|
|
72
|
+
const installKind = () =>
|
|
73
|
+
typeof ctx.get?.('profileContext')?.packageManager?.command === 'string' ? 'app' : 'npm'
|
|
74
|
+
|
|
75
|
+
// The newest released DS Bot's `dsBot` block, through the same channel the bot
|
|
76
|
+
// itself updates from: its registry, or the public repo's latest release.
|
|
77
|
+
async function releasedDeclaration(source) {
|
|
78
|
+
if (source.kind === 'registry') {
|
|
79
|
+
for (const registry of await registryPlan(config, ctx.get?.('pluginManager'))) {
|
|
80
|
+
try {
|
|
81
|
+
const manifest = await fetchJson(`${registry}${encodeName(PKG.name)}/latest`)
|
|
82
|
+
if (manifest?.dsBot?.verifiedDsh) return manifest.dsBot
|
|
83
|
+
} catch { /* unreachable or not published there: ask the next one */ }
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
const repo = repoSlug()
|
|
88
|
+
if (!repo) return null
|
|
89
|
+
try {
|
|
90
|
+
const release = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`, { 'user-agent': `${PKG.name}/${PKG.version}` })
|
|
91
|
+
const tag = cleanVersion(release?.tag_name)
|
|
92
|
+
if (!tag) return null
|
|
93
|
+
const manifest = await fetchJson(`https://raw.githubusercontent.com/${repo}/${release.tag_name}/ds-bot/package.json`, { 'user-agent': `${PKG.name}/${PKG.version}` })
|
|
94
|
+
return manifest?.dsBot?.verifiedDsh ? manifest.dsBot : null
|
|
95
|
+
} catch { return null }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runCheck() {
|
|
99
|
+
try {
|
|
100
|
+
status.current = await currentDsh()
|
|
101
|
+
status.kind = installKind()
|
|
102
|
+
// The release the running package itself verified stays the floor: a bot
|
|
103
|
+
// older than its own declaration has nothing newer to offer.
|
|
104
|
+
const declared = [{ verifiedDsh: PKG.dsBot?.verifiedDsh }]
|
|
105
|
+
const released = await releasedDeclaration(sourceOf(await installSpecOf(ctx)))
|
|
106
|
+
if (released) declared.push(released)
|
|
107
|
+
const newestDeclared = declared
|
|
108
|
+
.filter(entry => cleanVersion(entry.verifiedDsh))
|
|
109
|
+
.sort((a, b) => (newer(cleanVersion(a.verifiedDsh), cleanVersion(b.verifiedDsh)) ? -1 : 1))[0]
|
|
110
|
+
status.verified = cleanVersion(newestDeclared?.verifiedDsh)
|
|
111
|
+
// A release may gate the DSH update on a newer bot: offer nothing until
|
|
112
|
+
// the bot itself is at least `minBot`.
|
|
113
|
+
status.blockedBy = cleanVersion(newestDeclared?.minBot)
|
|
114
|
+
target = status.current !== null && status.verified !== null && status.verified !== status.current
|
|
115
|
+
? { version: status.verified }
|
|
116
|
+
: null
|
|
117
|
+
status.error = null
|
|
118
|
+
} catch (error) {
|
|
119
|
+
rt.warn('ds-bot: dsh update check failed: %s', error?.message ?? error)
|
|
120
|
+
status.verified = null
|
|
121
|
+
status.blockedBy = null
|
|
122
|
+
target = null
|
|
123
|
+
}
|
|
124
|
+
status.checkedAt = Date.now()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read current and verified DSH versions; a fresh check ignores the last answer. */
|
|
128
|
+
async function check({ fresh = false } = {}) {
|
|
129
|
+
if (!enabled) return view()
|
|
130
|
+
if (checking) return checking
|
|
131
|
+
if (!fresh && Date.now() - status.checkedAt < CHECK_TTL_MS) return view()
|
|
132
|
+
checking = runCheck().finally(() => { checking = null })
|
|
133
|
+
await checking
|
|
134
|
+
return view()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Swap an npm-installed DSH for the verified version; the answer comes back through `check`. */
|
|
138
|
+
function start() {
|
|
139
|
+
if (status.phase === 'installing') return view()
|
|
140
|
+
if (target === null || blocked()) throw new Error('No DSH update is available')
|
|
141
|
+
if (status.kind !== 'npm') throw new Error('This DSH updates with its app')
|
|
142
|
+
const chosen = target
|
|
143
|
+
Object.assign(status, { phase: 'installing', error: null })
|
|
144
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
145
|
+
const args = ['install', '-g', `${DSH_PACKAGE}@${chosen.version}`]
|
|
146
|
+
const runner = rt.dshSpawn ?? ((command, argv, options) => spawn(command, argv, options))
|
|
147
|
+
running = runner(npm, args, { windowsHide: true })
|
|
148
|
+
let output = ''
|
|
149
|
+
running.stdout?.on?.('data', chunk => { output = `${output}${chunk}`.slice(-4000) })
|
|
150
|
+
running.stderr?.on?.('data', chunk => { output = `${output}${chunk}`.slice(-4000) })
|
|
151
|
+
const done = new Promise((resolve, reject) => {
|
|
152
|
+
running.once?.('error', reject)
|
|
153
|
+
running.once?.('close', code => (code === 0 ? resolve() : reject(new Error(`npm exited ${code}`))))
|
|
154
|
+
})
|
|
155
|
+
void done
|
|
156
|
+
.then(() => Object.assign(status, { phase: 'installed', installed: chosen.version }))
|
|
157
|
+
.catch((error) => {
|
|
158
|
+
rt.warn('ds-bot: dsh update to %s failed: %s', chosen.version, error?.message ?? error)
|
|
159
|
+
Object.assign(status, { phase: 'failed', error: `${error?.message ?? error}${output ? ` — ${output.trim().slice(-400)}` : ''}`.slice(0, 600) })
|
|
160
|
+
})
|
|
161
|
+
return view()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Whatever npm is still running when the plugin unloads dies with it.
|
|
165
|
+
ctx.effect(() => () => running?.kill?.(), 'ds-bot: dsh update install')
|
|
166
|
+
|
|
167
|
+
Object.assign(rt, { dshUpdateCheck: check, dshUpdateStart: start, dshUpdateView: view })
|
|
168
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Group chats: the room log, members' group Sessions, rounds and @mentions, round
|
|
2
|
+
// reports, and the group tools (group_relay, read_own_chat, read_group_chat).
|
|
3
|
+
//
|
|
4
|
+
// Group chat rounds. A group chat is one shared, ordered conversation, so members
|
|
5
|
+
// speak one at a time: a turn starts only after the previous speaker has posted or
|
|
6
|
+
// passed, and it shows everything that arrived since that member last looked, plus
|
|
7
|
+
// the speaking order and the member's place in it. Nobody answers a room that has
|
|
8
|
+
// moved on, and nobody has to count lines to know who went before. A plain message
|
|
9
|
+
// goes to every member in roster order, admin first, or to the admin alone in admin
|
|
10
|
+
// mode; "@Name" picks members. A reply that @mentions a member calls that member
|
|
11
|
+
// next, bounded per member and per round; the admin's calls reach further.
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { ROOM_REPLY_TIMEOUT_MS, SOURCE_KIND } from './constants.js'
|
|
16
|
+
import { escapeRegExp, splitRef } from './text.js'
|
|
17
|
+
import { GROUP_REF, output } from './tools.js'
|
|
18
|
+
import { failureLine } from './turns.js'
|
|
19
|
+
|
|
20
|
+
const ROOM_LOG_MAX = 60
|
|
21
|
+
const ROOM_FIRST_LOOK = 12
|
|
22
|
+
const ROOM_DELTA_MAX = 24
|
|
23
|
+
const ROOM_EARLIER_MAX = 8
|
|
24
|
+
const ROOM_EXTRA_TURNS = 4
|
|
25
|
+
const ROOM_ADMIN_EXTRA_TURNS = 4
|
|
26
|
+
const ROOM_TURNS_EACH = 2
|
|
27
|
+
const ROOM_ADMIN_TURNS_EACH = 4
|
|
28
|
+
|
|
29
|
+
// Logs written before rooms were versioned get sequence numbers in place.
|
|
30
|
+
const ensureRoomLog = (room) => {
|
|
31
|
+
if (typeof room.seq !== 'number') {
|
|
32
|
+
room.log = (room.log ?? []).map((line, index) => ({ ...line, seq: index + 1 }))
|
|
33
|
+
room.seq = room.log.length
|
|
34
|
+
}
|
|
35
|
+
room.seen ??= {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const appendRoom = (room, line) => {
|
|
39
|
+
room.seq += 1
|
|
40
|
+
room.log = [...room.log, { ...line, seq: room.seq }].slice(-ROOM_LOG_MAX)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A member's own lines are already in its Session, so only the others' are news.
|
|
44
|
+
const unseenLines = (room, memberId) => {
|
|
45
|
+
const since = room.seen[memberId]
|
|
46
|
+
if (since === undefined) return room.log.slice(-ROOM_FIRST_LOOK)
|
|
47
|
+
return room.log.filter(line => line.seq > since && line.botId !== memberId).slice(-ROOM_DELTA_MAX)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lineText = line => (line.kind === 'lookup'
|
|
51
|
+
? `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
|
|
52
|
+
: `${line.who}: ${line.text}`)
|
|
53
|
+
const transcript = lines => lines.map(lineText).join('\n')
|
|
54
|
+
|
|
55
|
+
// Models often echo the transcript format ("Name: …") despite the instruction.
|
|
56
|
+
export const stripOwnName = (name, text) => text.replace(new RegExp(`^\\s*\\**${escapeRegExp(name)}\\**\\s*[::]\\s*`), '').trim()
|
|
57
|
+
export const isPass = text => text === '' || /^\s*[[【((]\s*pass\s*[\]】))]|^\s*pass\s*[.。!!]?\s*$/i.test(text)
|
|
58
|
+
|
|
59
|
+
// "@Name" mentions in order of appearance. Longest names match first so "@Mx" never
|
|
60
|
+
// reads as "@M"; only ASCII letters and digits continue a name, so "@K你好" is K.
|
|
61
|
+
const ROOM_ALL = ['all', 'everyone', '所有人', '全体']
|
|
62
|
+
const NAME_GOES_ON = /^[a-z0-9_-]/i
|
|
63
|
+
export const mentionsIn = (text, members) => {
|
|
64
|
+
const byLength = [...members].sort((a, b) => b.name.length - a.name.length)
|
|
65
|
+
const found = []
|
|
66
|
+
let all = false
|
|
67
|
+
for (let at = text.indexOf('@'); at !== -1; at = text.indexOf('@', at + 1)) {
|
|
68
|
+
if (at > 0 && /[a-z0-9_.]/i.test(text[at - 1])) continue
|
|
69
|
+
const rest = text.slice(at + 1)
|
|
70
|
+
const starts = word => rest.toLowerCase().startsWith(word.toLowerCase()) && !NAME_GOES_ON.test(rest.slice(word.length))
|
|
71
|
+
if (ROOM_ALL.some(starts)) all = true
|
|
72
|
+
const hit = byLength.find(member => starts(member.name))
|
|
73
|
+
if (hit && !found.includes(hit)) found.push(hit)
|
|
74
|
+
}
|
|
75
|
+
return { all, found }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ROOM_REPLY = 'Reply with your message for the group: one or two short messages, no name prefix. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.'
|
|
79
|
+
|
|
80
|
+
const slotLabel = (slot) => {
|
|
81
|
+
if (slot.status === 'speaking') return `${slot.name} (you, now)`
|
|
82
|
+
if (slot.status === 'posted') return `${slot.name} (posted)`
|
|
83
|
+
if (slot.status === 'passed') return `${slot.name} (passed)`
|
|
84
|
+
if (slot.status === 'missed') return `${slot.name} (no answer)`
|
|
85
|
+
if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
|
|
86
|
+
if (slot.status === 'left') return `${slot.name} (left the group)`
|
|
87
|
+
return slot.name
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const adminNote = room => [
|
|
91
|
+
'You are this group\'s admin.',
|
|
92
|
+
room.mode === 'admin' ? 'Plain messages come to you first: answer them, or call the members who should answer with @Name.' : '',
|
|
93
|
+
'Writing "@Name" calls that member to speak right after you, and "@all" calls every member. When the user asks, change the group with update_group.',
|
|
94
|
+
].filter(Boolean).join(' ')
|
|
95
|
+
|
|
96
|
+
// The opening turn of a group the user just made without a first message.
|
|
97
|
+
const roomKickoff = language => `The user just created this group and has not written yet. Greet the user in one short message and ask what they want this group to work on. Do not call members with @ yet, and do not reply [PASS]. No name prefix. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
|
|
98
|
+
|
|
99
|
+
const ROOM_READ_MAX = 30
|
|
100
|
+
|
|
101
|
+
export function install(rt) {
|
|
102
|
+
const { ctx } = rt
|
|
103
|
+
|
|
104
|
+
// A member's group Session, created on its first turn there. A new one starts with
|
|
105
|
+
// a fresh look at the log and a brief of the member's own chat; the brief is the
|
|
106
|
+
// first message, so it stays fixed and the prefix stays cacheable.
|
|
107
|
+
async function groupSessionFor(room, member) {
|
|
108
|
+
room.sessions ??= {}
|
|
109
|
+
const known = room.sessions[member.id]
|
|
110
|
+
if (known !== undefined) return { id: known, brief: undefined }
|
|
111
|
+
const id = await rt.createSession(`${member.name} · ${room.name}`)
|
|
112
|
+
room.sessions[member.id] = id
|
|
113
|
+
rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
|
|
114
|
+
delete room.seen[member.id]
|
|
115
|
+
const ref = await rt.desiredModel(member)
|
|
116
|
+
if (ref !== undefined) {
|
|
117
|
+
await rt.selectSessionModel(id, splitRef(ref))
|
|
118
|
+
.catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error))
|
|
119
|
+
}
|
|
120
|
+
await rt.applyPermission(member, id)
|
|
121
|
+
.catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
|
|
122
|
+
await rt.save()
|
|
123
|
+
return { id, brief: await rt.ownChatBrief(member) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Lines from before the round's opening message are kept apart from this round, so
|
|
127
|
+
// an earlier round (an old count-off, say) never reads as part of the current one.
|
|
128
|
+
function roomPrompt(room, member, round) {
|
|
129
|
+
const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean)
|
|
130
|
+
.map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`)
|
|
131
|
+
const leads = rt.isAdminOf(room, member.id)
|
|
132
|
+
const lines = unseenLines(room, member.id)
|
|
133
|
+
const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX)
|
|
134
|
+
const current = lines.filter(line => line.seq >= round.startSeq)
|
|
135
|
+
const spokeThisRound = (room.seen[member.id] ?? 0) >= round.startSeq
|
|
136
|
+
const index = round.slots.findIndex(slot => slot.status === 'speaking')
|
|
137
|
+
const before = index
|
|
138
|
+
const after = round.slots.length - index - 1
|
|
139
|
+
const members = `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. Members speak one at a time; each message is posted before the next speaker starts.`
|
|
140
|
+
const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : ''
|
|
141
|
+
if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n')
|
|
142
|
+
return [
|
|
143
|
+
members,
|
|
144
|
+
notice,
|
|
145
|
+
leads ? adminNote(room) : '',
|
|
146
|
+
earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '',
|
|
147
|
+
current.length
|
|
148
|
+
? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
|
|
149
|
+
: 'Nothing new in this round since your last message.',
|
|
150
|
+
`Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
|
|
151
|
+
`You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`,
|
|
152
|
+
ROOM_REPLY,
|
|
153
|
+
].filter(Boolean).join('\n\n')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One member turn inside the room; resolves to its final text, `{ failure }` when its
|
|
157
|
+
// model call failed, or null when it did not answer in time or the round was stopped.
|
|
158
|
+
async function roomTurn(room, member, sessionId, text, note, signal) {
|
|
159
|
+
if (signal.aborted) return null
|
|
160
|
+
const exchangeId = randomUUID()
|
|
161
|
+
room.seen[member.id] = room.seq
|
|
162
|
+
const done = new Promise((resolve) => {
|
|
163
|
+
const finish = (value) => {
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
rt.roomWaiters.delete(exchangeId)
|
|
166
|
+
resolve(value)
|
|
167
|
+
}
|
|
168
|
+
const timer = setTimeout(() => finish(null), ROOM_REPLY_TIMEOUT_MS)
|
|
169
|
+
rt.roomWaiters.set(exchangeId, { resolve: finish, fail: failure => finish({ failure }) })
|
|
170
|
+
signal.addEventListener('abort', () => finish(null), { once: true })
|
|
171
|
+
})
|
|
172
|
+
try {
|
|
173
|
+
await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
|
|
176
|
+
rt.roomWaiters.get(exchangeId)?.resolve(null)
|
|
177
|
+
}
|
|
178
|
+
const reply = await done
|
|
179
|
+
if (reply === null || reply?.failure !== undefined) return reply
|
|
180
|
+
return stripOwnName(member.name, String(reply ?? '').trim())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runRound(room, round, signal) {
|
|
184
|
+
const limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0)
|
|
185
|
+
const turns = new Map()
|
|
186
|
+
for (let index = 0; index < round.slots.length && round.turns < limit; index += 1) {
|
|
187
|
+
if (signal.aborted) break
|
|
188
|
+
const slot = round.slots[index]
|
|
189
|
+
const member = rt.botOf(slot.id)
|
|
190
|
+
// Settings may change mid-round, for example when the admin removes someone.
|
|
191
|
+
if (member === undefined || !room.members.includes(slot.id)) {
|
|
192
|
+
slot.status = 'left'
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
slot.status = 'speaking'
|
|
196
|
+
round.speaking = member.id
|
|
197
|
+
round.turns += 1
|
|
198
|
+
turns.set(member.id, (turns.get(member.id) ?? 0) + 1)
|
|
199
|
+
let session
|
|
200
|
+
try {
|
|
201
|
+
session = await groupSessionFor(room, member)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
rt.warn('ds-bot: group Session for %s in %s failed: %s', member.name, room.name, error)
|
|
204
|
+
round.speaking = null
|
|
205
|
+
slot.status = 'failed'
|
|
206
|
+
round.failed.push(member.name)
|
|
207
|
+
round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code: 'SESSION', text: String(error?.message ?? error).slice(0, 300), time: Date.now() })
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
const prompt = roomPrompt(room, member, round)
|
|
211
|
+
const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
|
|
212
|
+
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal)
|
|
213
|
+
round.speaking = null
|
|
214
|
+
if (reply?.failure !== undefined) {
|
|
215
|
+
// Not a pass: the user sees why the member is silent, and the round goes on.
|
|
216
|
+
slot.status = 'failed'
|
|
217
|
+
round.failed.push(member.name)
|
|
218
|
+
const { code, status, message } = reply.failure
|
|
219
|
+
round.replies.push({ kind: 'failed', botId: member.id, name: member.name, color: member.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (reply === null || isPass(reply)) {
|
|
223
|
+
slot.status = reply === null ? 'missed' : 'passed'
|
|
224
|
+
round.passed.push(member.name)
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
slot.status = 'posted'
|
|
228
|
+
appendRoom(room, { who: member.name, botId: member.id, text: reply })
|
|
229
|
+
round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() })
|
|
230
|
+
void rt.save()
|
|
231
|
+
// A called member moves up if it is still waiting, or gets one more turn if it
|
|
232
|
+
// already spoke; either way it goes right after this speaker. Only the admin's
|
|
233
|
+
// "@all" calls everyone, so a member cannot set off a storm.
|
|
234
|
+
const leads = rt.isAdminOf(room, member.id)
|
|
235
|
+
const others = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean)
|
|
236
|
+
const { all, found } = mentionsIn(reply, others)
|
|
237
|
+
const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found
|
|
238
|
+
let next = index + 1
|
|
239
|
+
for (const peer of callees) {
|
|
240
|
+
if ((turns.get(peer.id) ?? 0) >= (leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)) continue
|
|
241
|
+
const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
|
|
242
|
+
const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
|
|
243
|
+
called.note = `${member.name} mentioned you`
|
|
244
|
+
round.slots.splice(next, 0, called)
|
|
245
|
+
next += 1
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ctx.tools.register(defineTool({
|
|
251
|
+
name: 'group_relay',
|
|
252
|
+
description: 'Group chat relay only. Deliver the latest user message to the Bots in this group chat, one at a time, and collect their replies.',
|
|
253
|
+
parameters: {},
|
|
254
|
+
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
|
|
255
|
+
async execute(_args, exec) {
|
|
256
|
+
await rt.load()
|
|
257
|
+
const room = rt.roomOf(exec.agent?.id)
|
|
258
|
+
if (room === undefined) return JSON.stringify({ error: 'Not a group chat' })
|
|
259
|
+
ensureRoomLog(room)
|
|
260
|
+
// Everything queued since the last relay is posted in order, so messages that the
|
|
261
|
+
// Session folds into one turn are not lost; the newest one picks the speakers.
|
|
262
|
+
const queued = rt.roomInbox.get(room.id) ?? []
|
|
263
|
+
rt.roomInbox.delete(room.id)
|
|
264
|
+
const posts = queued.length > 0 ? queued : [{ text: '' }]
|
|
265
|
+
const startSeq = room.seq + 1
|
|
266
|
+
// The opening greeting is a cue for the admin, not a line of the group.
|
|
267
|
+
for (const post of posts.filter(item => !item.kickoff)) {
|
|
268
|
+
// Image readings go into the log only; `@` names are picked from what the user typed.
|
|
269
|
+
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
|
|
270
|
+
appendRoom(room, post.botId
|
|
271
|
+
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
|
|
272
|
+
: { who: 'User', text })
|
|
273
|
+
}
|
|
274
|
+
if (posts.some(post => !post.botId && !post.kickoff)) room.botRounds = 0
|
|
275
|
+
await rt.save()
|
|
276
|
+
const opener = posts.at(-1)
|
|
277
|
+
const round = {
|
|
278
|
+
roomId: room.id,
|
|
279
|
+
members: room.members,
|
|
280
|
+
startSeq,
|
|
281
|
+
...(opener.kickoff ? { kickoff: true } : {}),
|
|
282
|
+
slots: openingSpeakers(room, opener).map(member => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}) })),
|
|
283
|
+
speaking: null,
|
|
284
|
+
turns: 0,
|
|
285
|
+
replies: [],
|
|
286
|
+
passed: [],
|
|
287
|
+
failed: [],
|
|
288
|
+
}
|
|
289
|
+
rt.roomProgress.set(exec.callId, round)
|
|
290
|
+
try {
|
|
291
|
+
await runRound(room, round, exec.signal)
|
|
292
|
+
} catch (error) {
|
|
293
|
+
rt.warn('ds-bot: group round in %s stopped: %s', room.name, error)
|
|
294
|
+
} finally {
|
|
295
|
+
rt.roomProgress.delete(exec.callId)
|
|
296
|
+
}
|
|
297
|
+
await rt.save()
|
|
298
|
+
for (const post of posts) {
|
|
299
|
+
if (post.reportBack && rt.botOf(post.botId)) void reportRound(room, post, round)
|
|
300
|
+
}
|
|
301
|
+
exec.concludeTurn?.()
|
|
302
|
+
return JSON.stringify({ replies: round.replies, passed: round.passed, ...(round.failed.length > 0 ? { failed: round.failed } : {}) })
|
|
303
|
+
},
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
// Private context enters a group only through this tool, and the group sees each use.
|
|
307
|
+
ctx.tools.register(defineTool({
|
|
308
|
+
name: 'read_own_chat',
|
|
309
|
+
description: 'Read or search your own chat with the user, across all its parts, including earlier parts that left your working context. Each line starts with its anchor, such as [part 2 #1234 · 10-08 14:22]; long messages are cut. Pass the anchor as around to read that message in full, with what was said just before and after it. In a group chat the group sees that you looked, so share only what the group needs.',
|
|
310
|
+
parameters: {
|
|
311
|
+
query: { type: 'string', description: 'Words to look for in every part, separated by spaces. A message must have all of them; when none has, those with the most are shown. Omit for the latest messages' },
|
|
312
|
+
around: { type: 'string', description: 'An anchor from an earlier result, such as "part 2 #1234": shows that message in full and the messages just before and after it' },
|
|
313
|
+
part: { type: 'integer', description: 'Number of an earlier part: shows the handoff note you wrote when it ended' },
|
|
314
|
+
},
|
|
315
|
+
output,
|
|
316
|
+
async execute(args, exec) {
|
|
317
|
+
await rt.load()
|
|
318
|
+
const id = exec.agent?.id
|
|
319
|
+
const owner = rt.groupOwners.get(id)
|
|
320
|
+
const botId = owner?.botId ?? rt.chatOwners.get(id)
|
|
321
|
+
if (botId === undefined) return 'Only Bots on the team can use read_own_chat.'
|
|
322
|
+
const member = rt.botOf(botId)
|
|
323
|
+
const room = rt.roomOf(owner?.roomId)
|
|
324
|
+
const query = String(args.query ?? '').trim().slice(0, 80)
|
|
325
|
+
const around = String(args.around ?? '').trim().slice(0, 40)
|
|
326
|
+
const parts = rt.partsOf(botId)
|
|
327
|
+
const number = Number.isInteger(args.part) ? args.part : undefined
|
|
328
|
+
if (room !== undefined && member !== undefined) {
|
|
329
|
+
ensureRoomLog(room)
|
|
330
|
+
const text = query !== '' ? query : around !== '' ? `around ${around}` : number !== undefined ? `part ${number}` : ''
|
|
331
|
+
appendRoom(room, { who: member.name, botId: member.id, kind: 'lookup', text })
|
|
332
|
+
for (const round of rt.roomProgress.values()) {
|
|
333
|
+
if (round.roomId === room.id) round.replies.push({ kind: 'lookup', botId: member.id, name: member.name, color: member.color, text, time: Date.now() })
|
|
334
|
+
}
|
|
335
|
+
void rt.save()
|
|
336
|
+
}
|
|
337
|
+
const partsNote = parts.length > 1 ? `\n\nYour own chat has ${parts.length} parts: ${rt.partIndex(parts)}. Call read_own_chat with part N for the note that closed an earlier part, or with words to search all parts.` : ''
|
|
338
|
+
if (query === '' && around !== '') {
|
|
339
|
+
const found = await rt.aroundOwnChat(botId, around)
|
|
340
|
+
if (found.error) return `${found.error}${partsNote}`
|
|
341
|
+
if (found.lines.length === 0) return `Nothing was said around ${around}.${partsNote}`
|
|
342
|
+
const heading = found.exact ? `around ${around}` : `with no message at ${around}; the nearest ones`
|
|
343
|
+
return `Your own chat ${heading} (oldest first):\n${found.lines.join('\n')}${partsNote}`
|
|
344
|
+
}
|
|
345
|
+
if (query === '' && number !== undefined) {
|
|
346
|
+
const part = parts[number - 1]
|
|
347
|
+
if (part === undefined) return `Your own chat has no part ${number}.${partsNote}`
|
|
348
|
+
if (part.endedAt === undefined) return `Part ${number} is the current one; it is in your context.${partsNote}`
|
|
349
|
+
return `Part ${number} of your own chat (${rt.clock(part.startedAt)} – ${rt.clock(part.endedAt)}). The handoff note that closed it:\n${rt.noteText(part) ?? '(none: the part ended on a context overflow)'}${partsNote}`
|
|
350
|
+
}
|
|
351
|
+
if (query === '') {
|
|
352
|
+
const lines = await rt.latestOwnChat(botId, { askingId: id })
|
|
353
|
+
return lines.length === 0 ? `Your own chat is empty.${partsNote}` : `Your own chat (oldest first):\n${lines.join('\n')}${partsNote}`
|
|
354
|
+
}
|
|
355
|
+
const { lines, every } = await rt.searchOwnChat(botId, query, { askingId: id })
|
|
356
|
+
if (lines.length === 0) return `Nothing in your own chat mentions "${query}".${partsNote}`
|
|
357
|
+
const heading = every ? `messages mentioning "${query}"` : `no message has all of "${query}"; messages with the most of these words`
|
|
358
|
+
return `Your own chat, ${heading} (oldest first):\n${lines.join('\n')}${partsNote}`
|
|
359
|
+
},
|
|
360
|
+
}))
|
|
361
|
+
|
|
362
|
+
ctx.tools.register(defineTool({
|
|
363
|
+
name: 'read_group_chat',
|
|
364
|
+
description: 'Read the latest messages of a group chat, including what you said there. You speak in groups from separate conversations, so your own chat does not show them. Main Bots may read any group; others the groups they are in.',
|
|
365
|
+
parameters: { group: GROUP_REF },
|
|
366
|
+
output,
|
|
367
|
+
async execute(args, exec) {
|
|
368
|
+
await rt.load()
|
|
369
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
370
|
+
if (rt.botOf(self) === undefined) return 'Only Bots on the team can read group chats.'
|
|
371
|
+
const { room, error } = rt.pickRoom(args.group, self)
|
|
372
|
+
if (error) return error
|
|
373
|
+
ensureRoomLog(room)
|
|
374
|
+
const lines = room.log.slice(-ROOM_READ_MAX)
|
|
375
|
+
return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}`
|
|
376
|
+
},
|
|
377
|
+
}))
|
|
378
|
+
|
|
379
|
+
// A Bot that posts never answers its own post. @mentions choose the speakers; else
|
|
380
|
+
// the admin answers alone in admin mode, or first of everyone.
|
|
381
|
+
function openingSpeakers(room, opener) {
|
|
382
|
+
const members = room.members.map(rt.botOf).filter(bot => bot && bot.id !== opener.botId)
|
|
383
|
+
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
|
|
384
|
+
if (opener.kickoff) return admin ? [admin] : []
|
|
385
|
+
const { all, found } = mentionsIn(opener.text, members)
|
|
386
|
+
if (found.length > 0 && !all) return found
|
|
387
|
+
if (!all && room.mode === 'admin' && admin) return [admin]
|
|
388
|
+
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// A group the user just made opens with its admin asking what the group is for.
|
|
392
|
+
async function greetRoom(roomId) {
|
|
393
|
+
const room = rt.roomOf(roomId)
|
|
394
|
+
if (!room?.admin) return
|
|
395
|
+
const agent = await rt.resolveAgent(room.id)
|
|
396
|
+
agent.followup(createUserMessage({
|
|
397
|
+
content: [{ type: 'text', text: '[Group chat created]' }],
|
|
398
|
+
source: { kind: SOURCE_KIND, form: 'relay', role: 'kickoff', exchangeId: randomUUID(), senderName: 'You', hop: 0, roomId: room.id },
|
|
399
|
+
}))
|
|
400
|
+
await ctx.sessions.flush(agent.session)
|
|
401
|
+
}
|
|
402
|
+
rt.greetRoom = greetRoom
|
|
403
|
+
|
|
404
|
+
async function reportRound(room, post, round) {
|
|
405
|
+
const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
|
|
406
|
+
const text = [
|
|
407
|
+
lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.',
|
|
408
|
+
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
|
|
409
|
+
round.failed.length > 0 ? `Could not answer because their model call failed: ${round.replies.filter(reply => reply.kind === 'failed').map(reply => `${reply.name} (${failureLine({ code: reply.code, status: reply.status, message: reply.text })})`).join(', ')}.` : '',
|
|
410
|
+
].filter(Boolean).join('\n\n')
|
|
411
|
+
await rt.deliver({ toId: post.botId, role: 'report', roomId: room.id, text, hop: (post.hop ?? 0) + 1 })
|
|
412
|
+
.catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A group chat's own Session never consults the model: models drift from "only call
|
|
416
|
+
// group_relay" (they message one member directly when the user names it), so the
|
|
417
|
+
// relay step is synthesized here. Auxiliary calls such as titles still reach the model.
|
|
418
|
+
ctx.on('llm/stream', (options, next) => {
|
|
419
|
+
if (options.sessionId === undefined || options.purpose !== undefined) return next()
|
|
420
|
+
return (async function* relayOrPass() {
|
|
421
|
+
await rt.load()
|
|
422
|
+
if (rt.roomOf(options.sessionId) === undefined) {
|
|
423
|
+
yield* next()
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
// Plugins append context as user-role messages (time, attachments), so only a
|
|
427
|
+
// message from the user, a scheduled task, or a Bot's post after the last model or
|
|
428
|
+
// tool step starts a relay.
|
|
429
|
+
const opens = message => message.role === 'user'
|
|
430
|
+
&& (message.source?.kind === 'user' || message.source?.kind === 'schedule' || (message.source?.kind === SOURCE_KIND && (message.source.role === 'post' || message.source.role === 'kickoff')))
|
|
431
|
+
const latest = [...options.messages].reverse()
|
|
432
|
+
.find(message => message.role === 'assistant' || message.role === 'tool' || opens(message))
|
|
433
|
+
if (latest?.role !== 'user') {
|
|
434
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
const id = `toolu_relay_${randomUUID().replace(/-/g, '')}`
|
|
438
|
+
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
|
439
|
+
yield { type: 'tool-call-delta', index: 0, id, name: 'group_relay', argumentsDelta: '{}' }
|
|
440
|
+
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name: 'group_relay', arguments: '{}' } }
|
|
441
|
+
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
|
442
|
+
})()
|
|
443
|
+
})
|
|
444
|
+
}
|