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,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, '<')}</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
|
+
}
|