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.
@@ -0,0 +1,169 @@
1
+ // Connectors: outside services every Bot can use. GitHub is the first one. Its tools come
2
+ // from GitHub's MCP server through DSH's own MCP client, mounted on the plugin context,
3
+ // so every Bot and Session gets them as mcp__github__<tool>.
4
+ //
5
+ // The token is the GITHUB_TOKEN secret (secrets.js keeps it, redacts it, and offers it to
6
+ // the shell). It reaches GitHub only in the MCP request headers, built here in memory,
7
+ // never written to the DSH profile. A new token is shared with no Bot's shell: the tools
8
+ // do not need it there, and a Bot that reads a planted instruction in an issue could
9
+ // send it anywhere. The Secrets page can still share it. Signing in is `signIn` alone, so a browser login can
10
+ // take its place later without touching the mount.
11
+
12
+ export const GITHUB = 'github'
13
+ export const GITHUB_SECRET = 'GITHUB_TOKEN'
14
+ const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client'
15
+ const DEFAULTS = {
16
+ url: 'https://api.githubcopilot.com/mcp/',
17
+ api: 'https://api.github.com',
18
+ toolsets: 'context,repos,issues,pull_requests',
19
+ }
20
+ const CHECK_TIMEOUT_MS = 15_000
21
+ const INSECURE = 'GitHub addresses must start with https://'
22
+
23
+ // Asks GitHub who the token belongs to. Any token GitHub accepts passes.
24
+ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch } = {}) {
25
+ let response
26
+ try {
27
+ response = await request(`${api.replace(/\/$/, '')}/user`, {
28
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'ds-bot', 'X-GitHub-Api-Version': '2022-11-28' },
29
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
30
+ })
31
+ } catch {
32
+ throw new Error('Could not reach GitHub')
33
+ }
34
+ if (response.status === 401) throw new Error('GitHub did not accept this token')
35
+ if (!response.ok) throw new Error(`GitHub answered ${response.status}`)
36
+ const user = await response.json().catch(() => ({}))
37
+ if (typeof user?.login !== 'string') throw new Error('GitHub did not say who this token belongs to')
38
+ return { login: user.login }
39
+ }
40
+
41
+ export function install(rt) {
42
+ const { ctx, state } = rt
43
+ const options = rt.config.github === false ? null : { ...DEFAULTS, ...rt.config.github }
44
+ const insecure = options !== null && [options.url, options.api].some(address => !/^https:\/\//i.test(String(address)))
45
+ // `token` is what the current mount was made with, so a changed secret mounts again.
46
+ let mount = null
47
+ let status = { state: 'off' }
48
+ let queue = Promise.resolve()
49
+ let stopped = false
50
+
51
+ const unmount = async () => {
52
+ const fiber = mount?.fiber
53
+ mount = null
54
+ if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => {})
55
+ }
56
+
57
+ // The MCP client wraps the server's answer (a 401, say) as the cause.
58
+ const reason = (error) => {
59
+ const text = [error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ')
60
+ return rt.redactSecrets(text || 'error').slice(0, 300)
61
+ }
62
+
63
+ async function reconcile(force) {
64
+ const token = options !== null && !stopped ? rt.secretValue(GITHUB_SECRET) : undefined
65
+ if (!force && mount !== null && mount.token === token) return
66
+ await unmount()
67
+ if (token === undefined) {
68
+ status = { state: 'off' }
69
+ return
70
+ }
71
+ if (insecure) {
72
+ mount = { token }
73
+ status = { state: 'error', error: INSECURE }
74
+ return
75
+ }
76
+ status = { state: 'connecting' }
77
+ const loader = ctx.get?.('loader')
78
+ if (typeof loader?.import !== 'function' || typeof ctx.plugin !== 'function') {
79
+ mount = { token }
80
+ status = { state: 'error', error: 'This DSH cannot load connectors' }
81
+ return
82
+ }
83
+ try {
84
+ const exports = await loader.import(MCP_CLIENT)
85
+ const plugin = loader.unwrapExports?.(exports) ?? exports?.default ?? exports
86
+ const fiber = ctx.plugin(plugin, {
87
+ transport: 'streamable-http',
88
+ serverName: GITHUB,
89
+ url: options.url,
90
+ headers: { Authorization: `Bearer ${token}`, 'X-MCP-Toolsets': options.toolsets },
91
+ failOnStartupError: true,
92
+ })
93
+ mount = { token, fiber }
94
+ await fiber.await()
95
+ if (mount?.fiber !== fiber) return
96
+ status = { state: 'ready' }
97
+ } catch (error) {
98
+ await unmount()
99
+ mount = { token }
100
+ status = { state: 'error', error: reason(error) }
101
+ rt.warn('ds-bot: GitHub connector failed: %s', status.error)
102
+ }
103
+ }
104
+
105
+ // One change at a time; `force` connects again even when the token is the same.
106
+ const sync = (force = false) => {
107
+ queue = queue.then(() => reconcile(force)).catch(error => { rt.warn('ds-bot: connectors: %s', reason(error)) })
108
+ return queue
109
+ }
110
+
111
+ const connectorsView = () => options === null ? [] : [{
112
+ id: GITHUB,
113
+ name: 'GitHub',
114
+ state: status.state,
115
+ ...(status.error !== undefined ? { error: status.error } : {}),
116
+ account: rt.secretValue(GITHUB_SECRET) !== undefined ? state.connectors?.[GITHUB]?.login ?? null : null,
117
+ }]
118
+
119
+ const known = id => {
120
+ if (options === null || id !== GITHUB) throw new Error('No such connector')
121
+ }
122
+
123
+ const endpoints = {
124
+ async 'connector-connect'(body) {
125
+ known(body.id)
126
+ const token = typeof body.token === 'string' ? body.token.trim() : ''
127
+ if (token === '') throw new Error('Paste a token first')
128
+ if (insecure) throw new Error(INSECURE)
129
+ await rt.loadSecrets()
130
+ const kept = rt.secretValue(GITHUB_SECRET) !== undefined
131
+ const { login } = await signIn(token, { api: options.api })
132
+ await rt.secretEndpoint('secret-set', { name: GITHUB_SECRET, value: token, purpose: 'GitHub connector', ...(kept ? {} : { scope: [] }) })
133
+ state.connectors = { ...state.connectors, [GITHUB]: { login, connectedAt: Date.now() } }
134
+ await rt.save()
135
+ await sync()
136
+ return connectorsView()
137
+ },
138
+ async 'connector-disconnect'(body) {
139
+ known(body.id)
140
+ await rt.loadSecrets()
141
+ if (rt.secretValue(GITHUB_SECRET) !== undefined) await rt.secretEndpoint('secret-delete', { name: GITHUB_SECRET })
142
+ if (state.connectors?.[GITHUB] !== undefined) {
143
+ delete state.connectors[GITHUB]
144
+ await rt.save()
145
+ }
146
+ await sync()
147
+ return connectorsView()
148
+ },
149
+ async 'connector-retry'(body) {
150
+ known(body.id)
151
+ await rt.loadSecrets()
152
+ await sync(true)
153
+ return connectorsView()
154
+ },
155
+ }
156
+
157
+ const connectorEndpoint = (endpoint, body) => endpoints[endpoint](body ?? {})
158
+
159
+ ctx.effect(() => {
160
+ stopped = false
161
+ void rt.loadSecrets().then(() => sync())
162
+ return () => {
163
+ stopped = true
164
+ void unmount()
165
+ }
166
+ }, 'ds-bot: connectors')
167
+
168
+ Object.assign(rt, { connectorsView, connectorEndpoint, secretsChanged: () => { void sync() } })
169
+ }
@@ -0,0 +1,62 @@
1
+ // Fixed values shared by the host modules: colors, OCR settings, the client route, and
2
+ // the limits on messages, group chats, and chat reads.
3
+
4
+ // Classic avatar inks, offered as Bot colors.
5
+ export const COLORS = ['deepseek', 'blue', 'violet', 'orange', 'green', 'magenta', 'cyan', 'red', 'yellow', 'brown', 'gray', 'black', 'aurora']
6
+ // Deeper inks, also offered as accent swatches; each is a valid Bot color too.
7
+ export const EXTRA_COLORS = ['charcoal', 'graphite', 'rose', 'sky', 'tangerine', 'cobalt']
8
+ // Renamed color ids, so a Bot saved with an old one keeps its color (LEGACY: old id → new id).
9
+ export const LEGACY_COLORS = { kimi: 'charcoal', zai: 'graphite', minimax: 'rose', stepfun: 'sky', mimo: 'tangerine', qwen: 'cobalt' }
10
+ // Renamed theme ids, so a saved choice keeps its look (LEGACY: old id → new id).
11
+ export const LEGACY_THEMES = { grok: 'mono' }
12
+
13
+ export const OCR_PROMPT = [
14
+ 'Transcribe all text in this image exactly, in its original language, keeping line breaks and reading order; use Markdown tables for tables.',
15
+ 'Then add a line starting with "Image:" that describes in one to three sentences what is not text (photo, chart, diagram, screen layout).',
16
+ 'Reply with only that. If there is no text, reply with only the "Image:" line.',
17
+ ].join(' ')
18
+ export const OCR_TIMEOUT_MS = 120_000
19
+ export const OCR_RETRY_MS = 120_000
20
+ export const OCR_CACHE_MAX = 500
21
+ export const MAX_AVATAR_IMAGE = 160_000
22
+ export const ROUTE = '/api/bot'
23
+ export const MAX_HOPS = 6
24
+ export const MAX_EXCHANGES = 600
25
+ export const ROOM_REPLY_TIMEOUT_MS = 240_000
26
+ export const SOURCE_KIND = 'bot'
27
+ // An answer from a question card, in the shape DSH's own late question replies use.
28
+ export const REPLY_KIND = 'user-question-reply'
29
+ // `everyone`: every member answers a plain message in turn, admin first. `admin`: a
30
+ // plain message goes to the admin, who calls others with @mentions. New groups start
31
+ // in `admin` mode, because every extra speaker re-reads its whole context; a group
32
+ // without an admin still answers as `everyone`.
33
+ export const ROOM_MODES = ['everyone', 'admin']
34
+ export const ROOM_NAME_MAX = 60
35
+ export const ROOM_NOTICE_MAX = 2000
36
+ export const ROOM_MAX_MEMBERS = 12
37
+
38
+ // A team's one DSH Agent: a plain dsh entry in the sidebar, not a Bot. Its Sessions
39
+ // are ordinary root Sessions, so the team tools must not be offered to them.
40
+ export const AGENT_NAME = 'DSH Agent'
41
+ export const AGENT_SESSION_TITLE_MAX = 80
42
+ // Every tool this plugin registers for Bots. `read_browser` only exists in the
43
+ // Desktop profile, so a restriction filters this list down to registered names.
44
+ export const TEAM_TOOLS = [
45
+ 'list_bots', 'message_bot', 'create_bot', 'update_bot', 'list_models',
46
+ 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user',
47
+ 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat',
48
+ 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser',
49
+ ]
50
+ // Rounds Bots may start in one group by posting, before the user speaks there again.
51
+ export const ROOM_BOT_ROUNDS = 3
52
+
53
+ export const OWN_CHAT_LINE_MAX = 400
54
+ // The message an anchor points at is shown whole (up to this), so a long draft takes
55
+ // one read instead of many searches for its pieces.
56
+ export const ANCHOR_LINE_MAX = 8000
57
+ export const OWN_CHAT_BRIEF = 10
58
+ export const OWN_CHAT_READ = 12
59
+ export const OWN_CHAT_SEARCH = 20
60
+ export const HISTORY_PAGE = 200
61
+ export const HISTORY_PAGES_MAX = 50
62
+ export const HISTORY_TIMEOUT_MS = 20_000
@@ -0,0 +1,59 @@
1
+ // Delivery: how one Bot, the user, or DS Bot itself puts a message into a Bot Session.
2
+ import { randomUUID } from 'node:crypto'
3
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
4
+ import { SOURCE_KIND } from './constants.js'
5
+
6
+ export function install(rt) {
7
+ const { ctx, state } = rt
8
+
9
+ const resolveAgent = async (sessionId) => {
10
+ const resolved = await ctx.sessionController.resolveAgent(sessionId)
11
+ if ('error' in resolved) throw new Error(resolved.error?.message ?? 'Session unavailable')
12
+ return resolved.agent
13
+ }
14
+
15
+ const headerFor = (role, fromName, roomName, audience, note = 'new message') => {
16
+ if (role === 'reply') return `[Reply from ${fromName}]`
17
+ if (role === 'room') return `[Group chat "${roomName}" · ${note}]`
18
+ if (role === 'report') return `[Group chat "${roomName}" · replies to your post]`
19
+ if (role === 'kickoff') return '[Bot team setup]'
20
+ if (role === 'secret') return '[Secret card]'
21
+ if (audience === 'user') return `[Message from ${fromName} · the user sees your reply here]`
22
+ return `[Message from ${fromName}]`
23
+ }
24
+
25
+ // `sessionId` defaults to the recipient's own chat; group turns name its group Session.
26
+ async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID() }) {
27
+ const from = rt.botOf(fromId)
28
+ const to = rt.botOf(toId)
29
+ if (to === undefined) throw new Error(`Unknown Bot ${toId}`)
30
+ const fromName = from?.name ?? 'You'
31
+ const room = rt.roomOf(roomId)
32
+ // Session events reject `undefined` members, so optional fields are omitted instead.
33
+ const source = {
34
+ kind: SOURCE_KIND,
35
+ form: 'relay',
36
+ role,
37
+ exchangeId,
38
+ senderName: fromName,
39
+ hop,
40
+ ...(fromId ? { senderSessionId: fromId } : {}),
41
+ ...(replyTo ? { replyTo } : {}),
42
+ ...(roomId ? { roomId } : {}),
43
+ ...(audience === 'user' ? { audience } : {}),
44
+ }
45
+ if (role === 'request' || role === 'reply') {
46
+ state.exchanges.push({ id: exchangeId, from: fromId, to: toId, text, role, time: Date.now(), ...(replyTo ? { replyTo } : {}), ...(audience === 'user' ? { audience } : {}) })
47
+ void rt.save()
48
+ }
49
+ const agent = await resolveAgent(sessionId ?? rt.chatOf(toId))
50
+ agent.followup(createUserMessage({
51
+ content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note)}\n${text}` }],
52
+ source,
53
+ }))
54
+ await ctx.sessions.flush(agent.session)
55
+ return exchangeId
56
+ }
57
+
58
+ Object.assign(rt, { resolveAgent, deliver })
59
+ }
@@ -0,0 +1,247 @@
1
+ // Diagnostics: what went wrong on this host, kept where the user can see it and report it.
2
+ // DSH mounts no exporter for ctx.logger once it has started, so warnings would otherwise
3
+ // reach only stderr. Entries are kept in memory and in <home>/logs/diagnostics.jsonl, so
4
+ // a report written after a restart still has them. Every text is cleaned of saved keys,
5
+ // credential-like strings and the home folder before it is kept and again before it is
6
+ // shown, because the report is meant for a public issue.
7
+ import { readFileSync } from 'node:fs'
8
+ import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
9
+ import { arch, homedir, platform, release } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { format } from 'node:util'
13
+
14
+ export const KEEP = 200
15
+ const LOG_MAX_BYTES = 1 << 20
16
+ const STACK_LINES = 8
17
+ const TEXT_MAX = 2000
18
+ export const REPORT_ENTRIES = 20
19
+ export const ISSUES_URL = 'https://github.com/FeiZhuLulu/DeepSeek-Bot/issues/new'
20
+
21
+ const quote = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
22
+
23
+ // Shapes of credentials that are not saved keys: provider keys, bearer tokens, and
24
+ // token or key parameters in addresses (the Web login address carries one).
25
+ const CREDENTIALS = [
26
+ [/\bsk-[A-Za-z0-9_*.-]{6,}/g, 'sk-[hidden]'],
27
+ [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/g, '[hidden]'],
28
+ [/\bgithub_pat_[A-Za-z0-9_]{16,}/g, '[hidden]'],
29
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[hidden]'],
30
+ [/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[hidden]'],
31
+ [/\bAIza[A-Za-z0-9_-]{20,}/g, '[hidden]'],
32
+ // JSON web tokens: three base64url parts, the first always starting eyJ.
33
+ [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g, '[hidden]'],
34
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=*-]{8,}/gi, '$1 [hidden]'],
35
+ [/([?&#](?:token|access_token|api_key|apikey|key|secret|password)=)[^&\s"']+/gi, '$1[hidden]'],
36
+ [/(["']?(?:api[_-]?key|authorization|x-api-key|password|secret)["']?\s*[:=]\s*["']?)[^\s"',}]{6,}/gi, '$1[hidden]'],
37
+ ]
38
+
39
+ export function cleanText(text, { redact = value => value, home = homedir() } = {}) {
40
+ let out = redact(String(text ?? ''))
41
+ for (const [pattern, replacement] of CREDENTIALS) out = out.replace(pattern, replacement)
42
+ if (home && home.length > 1) {
43
+ out = out.replace(new RegExp(quote(home), 'g'), '~')
44
+ // JSON and Windows paths spell the same folder with doubled or forward slashes.
45
+ const slashes = home.replace(/\\/g, '/')
46
+ if (slashes !== home) out = out.replace(new RegExp(quote(slashes), 'g'), '~')
47
+ const doubled = home.replace(/\\/g, '\\\\')
48
+ if (doubled !== home) out = out.replace(new RegExp(quote(doubled), 'g'), '~')
49
+ }
50
+ return out.length > TEXT_MAX ? `${out.slice(0, TEXT_MAX)}…` : out
51
+ }
52
+
53
+ const errorText = error => `${error.name && error.name !== 'Error' ? `${error.name}: ` : ''}${error.message}`
54
+
55
+ // One log line, as the downloaded log and the report show it.
56
+ export function entryLine(entry) {
57
+ const where = [entry.bot, entry.where].filter(Boolean).join(' · ')
58
+ const code = entry.code ? ` [${entry.code}${entry.status ? ` ${entry.status}` : ''}]` : ''
59
+ return `${new Date(entry.time).toISOString()} ${entry.level.toUpperCase()} ${entry.source}${where ? ` (${where})` : ''}${code} ${entry.text}`
60
+ }
61
+
62
+ // The Markdown block for an issue. `facts` come from this host, `client` from the
63
+ // browser that asked; `entries` are already cleaned.
64
+ export function reportMarkdown({ facts, client = {}, entries = [], clientEntries = [] }) {
65
+ const row = (label, value) => (value === undefined || value === null || value === '' ? null : `| ${label} | ${String(value).replace(/\|/g, '\\|')} |`)
66
+ const lines = [
67
+ '| | |',
68
+ '|---|---|',
69
+ row('DS Bot', facts.botVersion),
70
+ row('DSH', facts.dshVersion),
71
+ row('Surface', client.surface),
72
+ row('OS', facts.os),
73
+ row('Node', facts.node),
74
+ row('Browser', client.userAgent),
75
+ row('Language', client.language),
76
+ row('Team', facts.team),
77
+ row('Models', facts.models),
78
+ row('Read-only', facts.readOnly),
79
+ ].filter(Boolean)
80
+ const recent = entries.slice(-REPORT_ENTRIES)
81
+ const out = [...lines, '']
82
+ out.push(recent.length === 0 ? 'No host errors recorded.' : `Recent host errors (${recent.length} of ${entries.length}, oldest first):`)
83
+ if (recent.length > 0) out.push('```', ...recent.map(entryLine), '```')
84
+ const stacks = recent.filter(entry => entry.stack).slice(-3)
85
+ for (const entry of stacks) out.push('', `<details><summary>Stack: ${entry.text.slice(0, 80).replace(/</g, '&lt;')}</summary>`, '', '```', entry.stack, '```', '</details>')
86
+ if (clientEntries.length > 0) {
87
+ out.push('', `Recent browser errors (${Math.min(clientEntries.length, 10)}):`, '```', ...clientEntries.slice(-10).map(entryLine), '```')
88
+ }
89
+ return out.join('\n')
90
+ }
91
+
92
+ const ownVersion = () => {
93
+ try {
94
+ return JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')).version
95
+ } catch {
96
+ return undefined
97
+ }
98
+ }
99
+
100
+ // DSH packages share one version; the LLM package is always beside the plugin.
101
+ const dshVersion = () => {
102
+ try {
103
+ let dir = dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-llm')))
104
+ for (let depth = 0; depth < 5; depth += 1, dir = dirname(dir)) {
105
+ try {
106
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
107
+ if (manifest.name === '@deepseek-ai/dsh-llm') return manifest.version
108
+ } catch {}
109
+ }
110
+ } catch {}
111
+ return undefined
112
+ }
113
+
114
+ export function install(rt) {
115
+ const { ctx, home, config } = rt
116
+ const logPath = join(home, 'logs', 'diagnostics.jsonl')
117
+ const toFile = config.diagnosticsFile !== false
118
+ let fresh = []
119
+ let writing = Promise.resolve()
120
+ let fileFailed = false
121
+
122
+ const clean = text => cleanText(text, { redact: rt.redactSecrets })
123
+
124
+ // Entries from earlier runs, read once; new ones are kept apart until then.
125
+ const earlier = toFile
126
+ ? readFile(logPath, 'utf8').then(text => text.split('\n').filter(Boolean).slice(-KEEP).flatMap((line) => {
127
+ try {
128
+ const entry = JSON.parse(line)
129
+ return typeof entry?.time === 'number' && typeof entry.text === 'string' ? [entry] : []
130
+ } catch {
131
+ return []
132
+ }
133
+ }), () => [])
134
+ : Promise.resolve([])
135
+ let cleared = 0
136
+
137
+ const append = (entry) => {
138
+ if (!toFile) return
139
+ writing = writing.then(async () => {
140
+ await mkdir(dirname(logPath), { recursive: true })
141
+ const size = await stat(logPath).then(info => info.size, () => 0)
142
+ if (size > LOG_MAX_BYTES) await rename(logPath, `${logPath}.1`)
143
+ await appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8')
144
+ }).catch((error) => {
145
+ if (fileFailed) return
146
+ fileFailed = true
147
+ console.error('[ds-bot] diagnostics log not written:', error?.code ?? error?.message ?? error)
148
+ })
149
+ }
150
+
151
+ // `fields`: source ('host', 'turn', 'api'), level, code, status, bot, where, sessionId, stack.
152
+ const note = ({ level = 'error', source = 'host', text, stack, ...fields }) => {
153
+ const entry = { time: Date.now(), level, source, text: clean(text) }
154
+ for (const [key, value] of Object.entries(fields)) {
155
+ if (value !== undefined && value !== null && value !== '') entry[key] = typeof value === 'string' ? clean(value) : value
156
+ }
157
+ // Frames only: the first line repeats the message, which is already the text.
158
+ const frames = typeof stack === 'string' ? stack.split('\n').filter(line => /^\s+at /.test(line)).slice(0, STACK_LINES).map(line => line.trim()) : []
159
+ if (frames.length > 0) entry.stack = clean(frames.join('\n'))
160
+ fresh.push(entry)
161
+ if (fresh.length > KEEP) fresh = fresh.slice(-KEEP)
162
+ append(entry)
163
+ return entry
164
+ }
165
+
166
+ const warn = (template, ...args) => {
167
+ ctx.logger.warn(template, ...args)
168
+ console.error(`[ds-bot] ${template}`, ...args.map(arg => arg?.stack ?? arg))
169
+ const error = args.find(arg => arg instanceof Error)
170
+ note({
171
+ level: 'warn',
172
+ source: 'host',
173
+ text: format(template, ...args.map(arg => (arg instanceof Error ? errorText(arg) : arg))).replace(/^ds-bot: /, ''),
174
+ code: typeof error?.code === 'string' ? error.code : undefined,
175
+ stack: error?.stack,
176
+ })
177
+ }
178
+
179
+ const entries = async () => {
180
+ const old = (await earlier).filter(entry => entry.time > cleared)
181
+ return [...old, ...fresh].slice(-KEEP).map(entry => ({
182
+ ...entry,
183
+ text: clean(entry.text),
184
+ ...(entry.stack ? { stack: clean(entry.stack) } : {}),
185
+ }))
186
+ }
187
+
188
+ const facts = () => {
189
+ const bots = Object.values(rt.state.bots)
190
+ const models = [...new Set(bots.map(bot => bot.model ?? bot.appliedModel).filter(Boolean))]
191
+ const count = (n, one, many) => `${n} ${n === 1 ? one : many}`
192
+ const groups = Object.keys(rt.state.rooms).length
193
+ return {
194
+ botVersion: ownVersion(),
195
+ dshVersion: dshVersion(),
196
+ os: `${platform()} ${release()} ${arch()}`,
197
+ node: process.version,
198
+ team: `${count(bots.length, 'Bot', 'Bots')}, ${rt.state.mainBotIds.length} Main, ${count(groups, 'group chat', 'group chats')}`,
199
+ models: models.length > 0 ? models.join(', ') : undefined,
200
+ readOnly: rt.readOnly?.() ? 'yes, another dsh holds the team' : undefined,
201
+ }
202
+ }
203
+
204
+ const sanitizeClient = (client) => {
205
+ const pick = (value, max) => (typeof value === 'string' ? clean(value.slice(0, max)) : undefined)
206
+ return {
207
+ surface: pick(client?.surface, 40),
208
+ language: pick(client?.language, 20),
209
+ userAgent: pick(client?.userAgent, 200),
210
+ }
211
+ }
212
+ const sanitizeClientEntries = list => (Array.isArray(list) ? list : []).slice(-20).flatMap((item) => {
213
+ if (typeof item?.text !== 'string' || typeof item.time !== 'number') return []
214
+ return [{ time: item.time, level: item.level === 'warn' ? 'warn' : 'error', source: 'browser', text: clean(item.text.slice(0, 500)), ...(typeof item.code === 'string' ? { code: clean(item.code.slice(0, 40)) } : {}) }]
215
+ })
216
+
217
+ async function report(body = {}) {
218
+ await rt.loadSecrets?.().catch(() => {})
219
+ const list = await entries()
220
+ const client = sanitizeClient(body.client)
221
+ const clientEntries = sanitizeClientEntries(body.clientErrors)
222
+ const hostFacts = facts()
223
+ return {
224
+ facts: hostFacts,
225
+ entries: list.slice(-50).reverse(),
226
+ total: list.length,
227
+ markdown: reportMarkdown({ facts: hostFacts, client, entries: list, clientEntries }),
228
+ issuesUrl: ISSUES_URL,
229
+ }
230
+ }
231
+
232
+ async function logText() {
233
+ await rt.loadSecrets?.().catch(() => {})
234
+ const list = await entries()
235
+ return list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))).join('\n')
236
+ }
237
+
238
+ async function clear() {
239
+ cleared = Date.now()
240
+ fresh = []
241
+ if (!toFile) return
242
+ writing = writing.then(() => writeFile(logPath, '', 'utf8')).catch(() => {})
243
+ await writing
244
+ }
245
+
246
+ Object.assign(rt, { warn, noteFailure: note, diagnostics: { report, logText, clear, entries } })
247
+ }