@thewaifu/dsh-bot 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +304 -0
- package/bin/dsh-bot-web.js +33 -0
- package/client.js +13691 -0
- package/cordis.patch.yml +17 -0
- package/index.js +57 -0
- package/package.json +80 -0
- package/src/host/agents.js +186 -0
- package/src/host/approvals.js +63 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +342 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +65 -0
- package/src/host/delivery.js +71 -0
- package/src/host/diagnostics.js +249 -0
- package/src/host/dsh-compat.js +22 -0
- package/src/host/dsh-update.js +170 -0
- package/src/host/groups.js +592 -0
- package/src/host/home-lock.js +97 -0
- package/src/host/images.js +211 -0
- package/src/host/memory.js +1362 -0
- package/src/host/models.js +231 -0
- package/src/host/parts.js +976 -0
- package/src/host/permissions.js +48 -0
- package/src/host/prompt.js +71 -0
- package/src/host/roster.js +471 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +344 -0
- package/src/host/text.js +47 -0
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +425 -0
- package/src/host/turns.js +388 -0
- package/src/host/update.js +237 -0
- package/src/host/usage.js +591 -0
- package/src/host/waifu.js +58 -0
- package/src/shared/mentions.js +65 -0
- package/src/shared/web-port.js +26 -0
- package/web-search.js +764 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// One writer per plugin home. dsh Web and dsh Desktop share ~/.dsh, and each host keeps
|
|
2
|
+
// the team in memory and writes state.json whole, so two hosts at once would undo each
|
|
3
|
+
// other's changes. The first host to load takes `state.lock`; a second one only reads.
|
|
4
|
+
//
|
|
5
|
+
// The lock is a small JSON file (pid, host, a random token) whose mtime the holder bumps
|
|
6
|
+
// on a timer. On the same host, its process decides whether it is live; `staleMs` only
|
|
7
|
+
// covers other hosts and unreadable files. A reused pid after a crash keeps the second
|
|
8
|
+
// host read-only until that process ends. The token, not the pid, decides ownership: a
|
|
9
|
+
// holder that finds another token has lost the lock.
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { hostname } from 'node:os'
|
|
13
|
+
|
|
14
|
+
export function pidAlive(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0)
|
|
17
|
+
return true
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// EPERM: the process exists but belongs to someone else.
|
|
20
|
+
return error?.code === 'EPERM'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostname(), pid = process.pid, isAlive = pidAlive, now = Date.now } = {}) {
|
|
25
|
+
const token = randomUUID()
|
|
26
|
+
let timer
|
|
27
|
+
|
|
28
|
+
// undefined: no lock file; null: a file that does not parse (being written right now).
|
|
29
|
+
const read = () => {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return error?.code === 'ENOENT' ? undefined : null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const age = () => {
|
|
37
|
+
try { return now() - statSync(path).mtimeMs } catch { return Infinity }
|
|
38
|
+
}
|
|
39
|
+
const live = (holder) => {
|
|
40
|
+
if (holder === undefined) return false
|
|
41
|
+
if (holder === null) return age() < staleMs
|
|
42
|
+
if (holder.host === host) return holder.pid !== pid && isAlive(holder.pid)
|
|
43
|
+
return age() < staleMs
|
|
44
|
+
}
|
|
45
|
+
const owns = () => read()?.token === token
|
|
46
|
+
|
|
47
|
+
const beat = () => {
|
|
48
|
+
if (!owns()) {
|
|
49
|
+
stop()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const time = new Date(now())
|
|
53
|
+
try { utimesSync(path, time, time) } catch {}
|
|
54
|
+
}
|
|
55
|
+
const stop = () => {
|
|
56
|
+
if (timer !== undefined) clearInterval(timer)
|
|
57
|
+
timer = undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Take the lock if it is free or its holder is gone. Returns undefined when this
|
|
61
|
+
// process holds it, else what the lock file says about the holder.
|
|
62
|
+
function claim() {
|
|
63
|
+
if (owns()) return undefined
|
|
64
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(path, JSON.stringify({ pid, host, token, since: now() }), { flag: 'wx' })
|
|
67
|
+
stop()
|
|
68
|
+
timer = setInterval(beat, beatMs)
|
|
69
|
+
timer.unref?.()
|
|
70
|
+
return undefined
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code !== 'EEXIST') throw error
|
|
73
|
+
}
|
|
74
|
+
const holder = read()
|
|
75
|
+
if (live(holder)) return holder ?? {}
|
|
76
|
+
const current = read()
|
|
77
|
+
const unchanged = holder === null
|
|
78
|
+
? current === null
|
|
79
|
+
: holder?.token !== undefined
|
|
80
|
+
? current?.token === holder.token
|
|
81
|
+
: JSON.stringify(current) === JSON.stringify(holder)
|
|
82
|
+
if (unchanged) {
|
|
83
|
+
try { unlinkSync(path) } catch {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return read() ?? {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function release() {
|
|
90
|
+
stop()
|
|
91
|
+
if (owns()) {
|
|
92
|
+
try { unlinkSync(path) } catch {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { claim, owns, release, read }
|
|
97
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Images for text-only models. Every model may take images; before a Session request
|
|
2
|
+
// reaches a model that cannot read them, each image becomes text from a vision model.
|
|
3
|
+
// Texts are kept per attachment, so later requests repeat the same words and the
|
|
4
|
+
// prompt cache still hits.
|
|
5
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
|
8
|
+
import { OCR_CACHE_MAX, OCR_PROMPT, OCR_RETRY_MS, OCR_TIMEOUT_MS } from './constants.js'
|
|
9
|
+
import { splitRef } from './text.js'
|
|
10
|
+
|
|
11
|
+
export function offloadTargets(session, beforeSeq) {
|
|
12
|
+
const targets = []
|
|
13
|
+
for (const seq of session.surface.nodes) {
|
|
14
|
+
if (seq >= beforeSeq) continue
|
|
15
|
+
const event = session.eventAt(seq)
|
|
16
|
+
if (event?.type !== 'user/message' && event?.type !== 'tool/result') continue
|
|
17
|
+
const message = session.deriveEventMessage(event)
|
|
18
|
+
const imageIndexes = []
|
|
19
|
+
let imageIndex = 0
|
|
20
|
+
for (const block of message?.content ?? []) {
|
|
21
|
+
if (block?.type !== 'image') continue
|
|
22
|
+
if (block.offloaded !== true) imageIndexes.push(imageIndex)
|
|
23
|
+
imageIndex += 1
|
|
24
|
+
}
|
|
25
|
+
if (imageIndexes.length > 0) targets.push({ seq, imageIndexes })
|
|
26
|
+
}
|
|
27
|
+
return targets
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function install(rt) {
|
|
31
|
+
const { ctx, config, home } = rt
|
|
32
|
+
|
|
33
|
+
// `textOnlyModels` names models whose provider claims image input but rejects it.
|
|
34
|
+
const textOnlyModels = new Set(config.textOnlyModels ?? [])
|
|
35
|
+
const ocrPath = join(home, 'ocr.json')
|
|
36
|
+
let ocrCache = null
|
|
37
|
+
let ocrWriting = Promise.resolve()
|
|
38
|
+
const ocrPending = new Map()
|
|
39
|
+
const ocrFailed = new Map()
|
|
40
|
+
|
|
41
|
+
const liveImages = message => Array.isArray(message?.content)
|
|
42
|
+
&& message.content.some(block => block?.type === 'image' && !block.offloaded)
|
|
43
|
+
|
|
44
|
+
async function readsImages(provider, model) {
|
|
45
|
+
if (textOnlyModels.has(`${provider}/${model}`)) return false
|
|
46
|
+
try {
|
|
47
|
+
const info = await ctx.llm.resolveModelInfo(provider, model)
|
|
48
|
+
return info.inputModalities === undefined || info.inputModalities.includes('image')
|
|
49
|
+
} catch {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The configured `ocrModel` first, then served models that declare image input, the
|
|
55
|
+
// DSH default among them first.
|
|
56
|
+
async function ocrRoute() {
|
|
57
|
+
const models = await rt.catalog()
|
|
58
|
+
const served = new Set(models.map(entry => entry.ref))
|
|
59
|
+
const declared = models.filter(entry => entry.input?.includes('image')).map(entry => entry.ref)
|
|
60
|
+
const fallback = rt.defaultRef()
|
|
61
|
+
for (const ref of new Set([config.ocrModel, ...declared.filter(ref => ref === fallback), ...declared])) {
|
|
62
|
+
const route = splitRef(ref)
|
|
63
|
+
if (route && served.has(ref) && await readsImages(route.provider, route.model)) return { ref, ...route }
|
|
64
|
+
}
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function loadOcr() {
|
|
69
|
+
if (ocrCache !== null) return
|
|
70
|
+
try {
|
|
71
|
+
ocrCache = new Map(Object.entries(JSON.parse(await readFile(ocrPath, 'utf8'))))
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code !== 'ENOENT') rt.warn('ds-bot: unreadable OCR cache %s: %s', ocrPath, error)
|
|
74
|
+
ocrCache = new Map()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveOcr() {
|
|
79
|
+
while (ocrCache.size > OCR_CACHE_MAX) ocrCache.delete(ocrCache.keys().next().value)
|
|
80
|
+
const snapshot = JSON.stringify(Object.fromEntries(ocrCache))
|
|
81
|
+
ocrWriting = ocrWriting.then(async () => {
|
|
82
|
+
await mkdir(home, { recursive: true })
|
|
83
|
+
const temp = `${ocrPath}.${process.pid}.tmp`
|
|
84
|
+
await writeFile(temp, snapshot, 'utf8')
|
|
85
|
+
await rename(temp, ocrPath)
|
|
86
|
+
}).catch(error => rt.warn('ds-bot: OCR cache write failed: %s', error))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `sessionId` is the Session the reading is for; its usage counts there.
|
|
90
|
+
async function readImage(attachment, sessionId) {
|
|
91
|
+
const route = await ocrRoute()
|
|
92
|
+
if (route === undefined) throw new Error('no vision model is available')
|
|
93
|
+
const assembler = new BlockAssembler()
|
|
94
|
+
// No sessionId: this call is not a Session request, so the hook below lets it through.
|
|
95
|
+
for await (const chunk of ctx.llm.stream({
|
|
96
|
+
provider: route.provider,
|
|
97
|
+
model: route.model,
|
|
98
|
+
messages: rt.tagUsage([{ role: 'user', content: [{ type: 'image', attachment }, { type: 'text', text: OCR_PROMPT }] }], { sessionId, kind: 'image' }),
|
|
99
|
+
maxTokens: 8192,
|
|
100
|
+
signal: AbortSignal.timeout(OCR_TIMEOUT_MS),
|
|
101
|
+
})) assembler.push(chunk)
|
|
102
|
+
const finish = assembler.finish
|
|
103
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
|
|
104
|
+
const text = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
|
|
105
|
+
if (text === '') throw new Error('the vision model returned no text')
|
|
106
|
+
const model = (await rt.catalog()).find(entry => entry.ref === route.ref)?.name ?? route.model
|
|
107
|
+
return { text, model }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Resolves to the cached reading, or to null when the image cannot be read now.
|
|
111
|
+
async function imageReading(attachment, sessionId) {
|
|
112
|
+
const id = String(attachment.attachmentId)
|
|
113
|
+
if (ocrCache.has(id)) return ocrCache.get(id)
|
|
114
|
+
const failed = ocrFailed.get(id)
|
|
115
|
+
if (failed && Date.now() - failed.at < OCR_RETRY_MS) return null
|
|
116
|
+
if (!ocrPending.has(id)) {
|
|
117
|
+
ocrPending.set(id, readImage(attachment, sessionId).then((reading) => {
|
|
118
|
+
ocrCache.set(id, reading)
|
|
119
|
+
ocrFailed.delete(id)
|
|
120
|
+
saveOcr()
|
|
121
|
+
return reading
|
|
122
|
+
}, (error) => {
|
|
123
|
+
rt.warn('ds-bot: could not read image %s: %s', id, error)
|
|
124
|
+
ocrFailed.set(id, { at: Date.now() })
|
|
125
|
+
return null
|
|
126
|
+
}).finally(() => ocrPending.delete(id)))
|
|
127
|
+
}
|
|
128
|
+
return ocrPending.get(id)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const imageLabel = attachment => (attachment.name
|
|
132
|
+
? `"${attachment.name}"`
|
|
133
|
+
: `sha256:${String(attachment.attachmentId).slice('sha256:'.length, 'sha256:'.length + 8)}`)
|
|
134
|
+
|
|
135
|
+
function imageAsText(attachment, reading) {
|
|
136
|
+
const label = imageLabel(attachment)
|
|
137
|
+
return {
|
|
138
|
+
type: 'text',
|
|
139
|
+
text: reading === null
|
|
140
|
+
? `[Image ${label}: you cannot see images and it could not be read right now. Ask the user what it shows if it matters.]`
|
|
141
|
+
: `[Image ${label}. You cannot see images, so ${reading.model} read it for you:]\n${reading.text}\n[End of image ${label}]`,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Members read the group as text, so a picture posted there reaches every member,
|
|
146
|
+
// vision model or not, as its reading.
|
|
147
|
+
async function imagesForGroup(attachments, sessionId) {
|
|
148
|
+
await loadOcr()
|
|
149
|
+
return Promise.all(attachments.map(async (attachment) => {
|
|
150
|
+
const reading = await imageReading(attachment, sessionId)
|
|
151
|
+
const label = imageLabel(attachment)
|
|
152
|
+
return reading === null
|
|
153
|
+
? `[Image ${label}: it could not be read right now. Ask the user what it shows if it matters.]`
|
|
154
|
+
: `[Image ${label}, read by ${reading.model}:]\n${reading.text}\n[End of image ${label}]`
|
|
155
|
+
}))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function withImagesAsText(messages, sessionId) {
|
|
159
|
+
await loadOcr()
|
|
160
|
+
const images = new Map()
|
|
161
|
+
for (const message of messages) {
|
|
162
|
+
if (!liveImages(message)) continue
|
|
163
|
+
for (const block of message.content) {
|
|
164
|
+
if (block?.type === 'image' && !block.offloaded) images.set(String(block.attachment.attachmentId), block.attachment)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const readings = new Map(await Promise.all([...images].map(async ([id, attachment]) => [id, await imageReading(attachment, sessionId)])))
|
|
168
|
+
return messages.map(message => (liveImages(message)
|
|
169
|
+
? {
|
|
170
|
+
...message,
|
|
171
|
+
content: message.content.map(block => (block?.type === 'image' && !block.offloaded
|
|
172
|
+
? imageAsText(block.attachment, readings.get(String(block.attachment.attachmentId)))
|
|
173
|
+
: block)),
|
|
174
|
+
}
|
|
175
|
+
: message))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ctx.on('llm/stream', (options, next) => {
|
|
179
|
+
if (options.sessionId === undefined || !(options.messages ?? []).some(liveImages)) return next()
|
|
180
|
+
return (async function* imagesAsTextOrPass() {
|
|
181
|
+
await rt.load()
|
|
182
|
+
if (rt.roomOf(options.sessionId) !== undefined || await readsImages(options.provider, options.model)) {
|
|
183
|
+
yield* next()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
// `next` cannot take new messages, so the rewritten request starts over; it
|
|
187
|
+
// carries no image, so this listener lets it through.
|
|
188
|
+
yield* ctx.llm.stream({ ...options, messages: await withImagesAsText(options.messages, options.sessionId) })
|
|
189
|
+
})()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
193
|
+
if (status !== 'idle' || !agent?.session || !rt.isBotSession(agent.id) || rt.readOnly() !== undefined) return
|
|
194
|
+
const beforeSeq = agent.session.seq
|
|
195
|
+
setImmediate(() => {
|
|
196
|
+
void (async () => {
|
|
197
|
+
try {
|
|
198
|
+
if (!ctx.sessions.messageProjections?.some(projection => projection.type === 'image/offload')) return
|
|
199
|
+
const targets = offloadTargets(agent.session, beforeSeq)
|
|
200
|
+
if (targets.length === 0) return
|
|
201
|
+
agent.session.append('image/offload', { targets })
|
|
202
|
+
await ctx.sessions.flush(agent.session)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
rt.warn('ds-bot: old images for %s not offloaded: %s', agent.id, error)
|
|
205
|
+
}
|
|
206
|
+
})()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
Object.assign(rt, { imagesForGroup })
|
|
211
|
+
}
|