@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,151 @@
|
|
|
1
|
+
// Scheduled tasks. DSH's schedule service stores each task with the Session it belongs
|
|
2
|
+
// to and, when it is due, sends its prompt to that Session as a new message. This
|
|
3
|
+
// module lists the team's tasks for Settings, creates and removes them there, and moves
|
|
4
|
+
// a Bot's tasks along when its chat goes on in a fresh part, so the Bot keeps seeing
|
|
5
|
+
// them. A group chat's task lives on the group's own Session, so the reminder reaches
|
|
6
|
+
// the group like a message from the user (see reminderText). DSH 0.2.0 mounts the service only with its Automation tasks bundle on, so
|
|
7
|
+
// the service is optional and every endpoint reports whether it is there.
|
|
8
|
+
|
|
9
|
+
const MAX_TITLE = 120
|
|
10
|
+
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/
|
|
11
|
+
|
|
12
|
+
const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' })
|
|
13
|
+
|
|
14
|
+
// What a group sees of a due reminder: DSH frames it for one Session's model
|
|
15
|
+
// ("[SCHEDULE REMINDER]" with JSON fields); the group gets each prompt as one line.
|
|
16
|
+
export function reminderText(text) {
|
|
17
|
+
const prompts = []
|
|
18
|
+
const one = /^reminder_prompt_json: (.*)$/m.exec(text)
|
|
19
|
+
const batch = /^reminders_json: (.*)$/m.exec(text)
|
|
20
|
+
try {
|
|
21
|
+
if (one) prompts.push(JSON.parse(one[1]))
|
|
22
|
+
if (batch) for (const entry of JSON.parse(batch[1])) prompts.push(entry.reminder_prompt)
|
|
23
|
+
} catch {
|
|
24
|
+
// A framing this module cannot read goes to the group as it came.
|
|
25
|
+
return text
|
|
26
|
+
}
|
|
27
|
+
const lines = prompts.filter(prompt => typeof prompt === 'string' && prompt.trim() !== '')
|
|
28
|
+
return lines.length === 0 ? text : lines.map(prompt => `[Scheduled task] ${prompt}`).join('\n')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The create request that gives a stored record its timing again, for a move.
|
|
32
|
+
function requestOf(record) {
|
|
33
|
+
const base = { title: record.title, prompt: record.prompt }
|
|
34
|
+
if (record.kind === 'after' || record.kind === 'at') return { ...base, at: record.scheduledAt }
|
|
35
|
+
if (record.kind === 'every') return { ...base, every_seconds: record.everySeconds }
|
|
36
|
+
if (record.kind === 'daily') return { ...base, daily: { time: record.time, time_zone: record.timeZone } }
|
|
37
|
+
if (record.kind === 'weekly') return { ...base, weekly: { time: record.time, time_zone: record.timeZone, weekdays: record.weekdays } }
|
|
38
|
+
if (record.kind === 'cron') return { ...base, cron: { expression: record.expression, time_zone: record.timeZone } }
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The settings form's timing as a create request selector.
|
|
43
|
+
function selectorOf(when) {
|
|
44
|
+
const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC'
|
|
45
|
+
switch (when?.kind) {
|
|
46
|
+
case 'at': {
|
|
47
|
+
const at = new Date(String(when.at ?? ''))
|
|
48
|
+
if (Number.isNaN(at.getTime())) throw invalid('Pick a date and time')
|
|
49
|
+
if (at.getTime() <= Date.now()) throw invalid('Pick a time in the future')
|
|
50
|
+
return { at: at.toISOString() }
|
|
51
|
+
}
|
|
52
|
+
case 'daily':
|
|
53
|
+
if (!TIME.test(String(when.time))) throw invalid('Pick a time of day')
|
|
54
|
+
return { daily: { time: `${when.time}:00`, time_zone: zone } }
|
|
55
|
+
case 'weekly': {
|
|
56
|
+
if (!TIME.test(String(when.time))) throw invalid('Pick a time of day')
|
|
57
|
+
const weekdays = [...new Set((Array.isArray(when.weekdays) ? when.weekdays : []).map(Number))].filter(day => Number.isInteger(day) && day >= 1 && day <= 7).sort()
|
|
58
|
+
if (weekdays.length === 0) throw invalid('Pick at least one day')
|
|
59
|
+
return { weekly: { time: `${when.time}:00`, time_zone: zone, weekdays } }
|
|
60
|
+
}
|
|
61
|
+
case 'every': {
|
|
62
|
+
const minutes = Number(when.minutes)
|
|
63
|
+
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 7 * 24 * 60) throw invalid('Repeat every 1 minute to 7 days')
|
|
64
|
+
return { every_seconds: minutes * 60 }
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
throw invalid('Pick when it runs')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function install(rt) {
|
|
72
|
+
const { ctx, state } = rt
|
|
73
|
+
let service = null
|
|
74
|
+
|
|
75
|
+
const attach = (sub) => {
|
|
76
|
+
const own = sub.schedule
|
|
77
|
+
service = own
|
|
78
|
+
sub.effect(() => () => { if (service === own) service = null }, 'ds-bot: schedule service')
|
|
79
|
+
}
|
|
80
|
+
if (typeof ctx.inject === 'function') ctx.inject(['schedule'], attach)
|
|
81
|
+
else if (ctx.get?.('schedule') !== undefined) attach({ schedule: ctx.get('schedule'), effect: (execute, label) => ctx.effect(execute, label) })
|
|
82
|
+
|
|
83
|
+
// The Bot (and group chat) a task's Session speaks for, the group chat for a group's
|
|
84
|
+
// own Session, or nothing for a Session outside the team.
|
|
85
|
+
const ownerOf = (sessionId) => {
|
|
86
|
+
const chat = rt.chatOwners.get(sessionId)
|
|
87
|
+
if (chat !== undefined) return { botId: chat }
|
|
88
|
+
if (rt.roomOf(sessionId) !== undefined) return { roomId: sessionId }
|
|
89
|
+
return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId)
|
|
90
|
+
}
|
|
91
|
+
const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined)
|
|
92
|
+
|
|
93
|
+
async function schedulesView() {
|
|
94
|
+
if (service === null) return { available: false, tasks: [] }
|
|
95
|
+
const tasks = []
|
|
96
|
+
for (const entry of await service.catalog()) {
|
|
97
|
+
const owner = ownerOf(entry.sessionId)
|
|
98
|
+
if (!known(owner)) continue
|
|
99
|
+
tasks.push({ ...entry, ...owner })
|
|
100
|
+
}
|
|
101
|
+
return { available: true, tasks }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const endpoints = {
|
|
105
|
+
schedules: () => schedulesView(),
|
|
106
|
+
async 'schedule-create'(body) {
|
|
107
|
+
if (service === null) throw invalid('Automation tasks are off in DSH')
|
|
108
|
+
const bot = state.bots[body.target]
|
|
109
|
+
const room = state.rooms[body.target]
|
|
110
|
+
if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat')
|
|
111
|
+
const title = String(body.title ?? '').trim()
|
|
112
|
+
const prompt = String(body.prompt ?? '').trim()
|
|
113
|
+
if (title === '') throw invalid('Give the task a name')
|
|
114
|
+
if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`)
|
|
115
|
+
if (prompt === '') throw invalid('Say what the Bot should do')
|
|
116
|
+
await service.create(bot ? rt.chatOf(bot.id) : room.id, { title, prompt, ...selectorOf(body.when) })
|
|
117
|
+
return schedulesView()
|
|
118
|
+
},
|
|
119
|
+
async 'schedule-delete'(body) {
|
|
120
|
+
if (service === null) throw invalid('Automation tasks are off in DSH')
|
|
121
|
+
if (ownerOf(body.sessionId) === undefined) throw invalid('No such task')
|
|
122
|
+
await service.delete({ sessionId: body.sessionId, id: body.id })
|
|
123
|
+
return schedulesView()
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A fresh part takes over the earlier part's active tasks: each is created again on
|
|
128
|
+
// the fresh Session before the old one goes, so a failure leaves it where it was.
|
|
129
|
+
async function moveSchedules(fromId, toId) {
|
|
130
|
+
if (service === null) return
|
|
131
|
+
let records
|
|
132
|
+
try {
|
|
133
|
+
records = await service.list({ sessionId: fromId })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
rt.warn('ds-bot: tasks of %s not moved: %s', fromId, error?.message ?? error)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
for (const record of records) {
|
|
139
|
+
const request = requestOf(record)
|
|
140
|
+
if (request === null || (request.at !== undefined && Date.parse(request.at) <= Date.now())) continue
|
|
141
|
+
try {
|
|
142
|
+
await service.create(toId, request)
|
|
143
|
+
await service.delete({ sessionId: fromId, id: record.id })
|
|
144
|
+
} catch (error) {
|
|
145
|
+
rt.warn('ds-bot: task %s stayed with the earlier part: %s', record.title, error?.message ?? error)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Object.assign(rt, { scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), moveSchedules })
|
|
151
|
+
}
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Secrets: keys the user types into a card for a Bot. A value lives only in
|
|
2
|
+
// secrets.json (mode 600) and in this process; Bots use it as $DSH_SECRET_<NAME> in
|
|
3
|
+
// their shell, tool results lose it before they are saved, and every model request
|
|
4
|
+
// has it replaced by [secret:<NAME>] before it leaves.
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import { output } from './tools.js'
|
|
10
|
+
|
|
11
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/
|
|
12
|
+
export const ENV_PREFIX = 'DSH_SECRET_'
|
|
13
|
+
// A short value (a PIN, a short password) would also hit ordinary words if it were
|
|
14
|
+
// replaced wherever it appears, so below WHOLE_VALUE it is replaced only where it
|
|
15
|
+
// stands alone: no letter or digit right before or after it.
|
|
16
|
+
export const MIN_VALUE = 4
|
|
17
|
+
export const WHOLE_VALUE = 8
|
|
18
|
+
export const MAX_VALUE = 16384
|
|
19
|
+
const MAX_REQUESTS = 20
|
|
20
|
+
const USED_SAVE_MS = 5000
|
|
21
|
+
|
|
22
|
+
export const envName = name => `${ENV_PREFIX}${name}`
|
|
23
|
+
export const canUse = (secret, botId) => secret !== undefined && botId !== undefined
|
|
24
|
+
&& (secret.scope === 'all' || (Array.isArray(secret.scope) && secret.scope.includes(botId)))
|
|
25
|
+
|
|
26
|
+
export function normalizeName(raw) {
|
|
27
|
+
return String(raw ?? '').trim().replace(/^\$(env:)?/i, '').replace(/^DSH_SECRET_/i, '').replace(/[\s-]+/g, '_').toUpperCase()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// One function that replaces every known value in a string, longest first so a value
|
|
31
|
+
// that contains another is replaced whole. JSON-escaped forms are covered too.
|
|
32
|
+
export function redactor(entries) {
|
|
33
|
+
const pairs = []
|
|
34
|
+
for (const { name, value } of entries) {
|
|
35
|
+
if (typeof value !== 'string' || value.length < MIN_VALUE) continue
|
|
36
|
+
const label = `[secret:${name}]`
|
|
37
|
+
const short = value.length < WHOLE_VALUE
|
|
38
|
+
pairs.push([value, label, short])
|
|
39
|
+
const escaped = JSON.stringify(value).slice(1, -1)
|
|
40
|
+
if (escaped !== value) pairs.push([escaped, label, short])
|
|
41
|
+
}
|
|
42
|
+
pairs.sort((a, b) => b[0].length - a[0].length)
|
|
43
|
+
if (pairs.length === 0) return text => text
|
|
44
|
+
// One pass, so a label already written is never matched again.
|
|
45
|
+
const labels = new Map()
|
|
46
|
+
for (const [value, label] of pairs) if (!labels.has(value)) labels.set(value, label)
|
|
47
|
+
const quote = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
48
|
+
const pattern = new RegExp(pairs.map(([value, , short]) => (short ? `(?<![\\p{L}\\p{N}])${quote(value)}(?![\\p{L}\\p{N}])` : quote(value))).join('|'), 'gu')
|
|
49
|
+
return (text) => {
|
|
50
|
+
if (typeof text !== 'string' || !pairs.some(([value]) => text.includes(value))) return text
|
|
51
|
+
return text.replace(pattern, match => labels.get(match) ?? match)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Returns the same reference when nothing changed, so callers can tell cheaply.
|
|
56
|
+
export function redactDeep(value, redact) {
|
|
57
|
+
if (typeof value === 'string') return redact(value)
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
let changed = false
|
|
60
|
+
const next = value.map((item) => {
|
|
61
|
+
const replaced = redactDeep(item, redact)
|
|
62
|
+
if (replaced !== item) changed = true
|
|
63
|
+
return replaced
|
|
64
|
+
})
|
|
65
|
+
return changed ? next : value
|
|
66
|
+
}
|
|
67
|
+
if (value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
68
|
+
let changed = false
|
|
69
|
+
const next = {}
|
|
70
|
+
for (const [key, item] of Object.entries(value)) {
|
|
71
|
+
const replaced = redactDeep(item, redact)
|
|
72
|
+
if (replaced !== item) changed = true
|
|
73
|
+
next[key] = replaced
|
|
74
|
+
}
|
|
75
|
+
return changed ? next : value
|
|
76
|
+
}
|
|
77
|
+
return value
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function install(rt) {
|
|
81
|
+
const { ctx, home, state } = rt
|
|
82
|
+
const path = join(home, 'secrets.json')
|
|
83
|
+
|
|
84
|
+
/** @type {Map<string, {name:string, value:string, purpose:string, scope:'all'|string[], requestedBy:string|null, createdAt:number, updatedAt:number, lastUsedAt?:number}>} */
|
|
85
|
+
const secrets = new Map()
|
|
86
|
+
let loaded = null
|
|
87
|
+
let writing = Promise.resolve()
|
|
88
|
+
let redact = text => text
|
|
89
|
+
let refreshEnv = () => {}
|
|
90
|
+
|
|
91
|
+
const changed = () => {
|
|
92
|
+
redact = redactor([...secrets.values()])
|
|
93
|
+
refreshEnv()
|
|
94
|
+
rt.secretsChanged?.()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Error messages from JSON.parse quote the file, so only the code or name is logged.
|
|
98
|
+
const reason = error => error?.code ?? error?.name ?? 'error'
|
|
99
|
+
|
|
100
|
+
// While another dsh holds the team, that host writes secrets.json, so a reading host
|
|
101
|
+
// reads it again each time; a copy read then is also read again after taking over,
|
|
102
|
+
// before this host saves anything.
|
|
103
|
+
let loadedReadOnly = false
|
|
104
|
+
const loadSecrets = () => {
|
|
105
|
+
if (loaded !== null && (loadedReadOnly || rt.readOnly() !== undefined)) loaded = null
|
|
106
|
+
return loaded ??= readSecrets()
|
|
107
|
+
}
|
|
108
|
+
const readSecrets = async () => {
|
|
109
|
+
loadedReadOnly = rt.readOnly() !== undefined
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'))
|
|
112
|
+
secrets.clear()
|
|
113
|
+
for (const entry of Array.isArray(parsed?.secrets) ? parsed.secrets : []) {
|
|
114
|
+
if (!SECRET_NAME.test(entry?.name ?? '') || typeof entry.value !== 'string') continue
|
|
115
|
+
secrets.set(entry.name, {
|
|
116
|
+
name: entry.name,
|
|
117
|
+
value: entry.value,
|
|
118
|
+
purpose: typeof entry.purpose === 'string' ? entry.purpose : '',
|
|
119
|
+
scope: entry.scope === 'all' ? 'all' : Array.isArray(entry.scope) ? entry.scope.filter(id => typeof id === 'string') : 'all',
|
|
120
|
+
requestedBy: typeof entry.requestedBy === 'string' ? entry.requestedBy : null,
|
|
121
|
+
createdAt: Number(entry.createdAt) || Date.now(),
|
|
122
|
+
updatedAt: Number(entry.updatedAt) || Date.now(),
|
|
123
|
+
...(Number.isFinite(entry.lastUsedAt) ? { lastUsedAt: entry.lastUsedAt } : {}),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
if (!loadedReadOnly) await chmod(path, 0o600).catch(() => {})
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error?.code === 'ENOENT') secrets.clear()
|
|
129
|
+
else rt.warn('ds-bot: unreadable secrets file %s (%s)', path, reason(error))
|
|
130
|
+
}
|
|
131
|
+
changed()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolves once secrets.json holds the current map; rejects when the write failed.
|
|
135
|
+
const persist = () => {
|
|
136
|
+
const readOnly = rt.readOnly()
|
|
137
|
+
if (readOnly !== undefined) return Promise.reject(new Error(readOnly))
|
|
138
|
+
const snapshot = JSON.stringify({ version: 1, secrets: [...secrets.values()] }, null, 2)
|
|
139
|
+
const job = writing.then(async () => {
|
|
140
|
+
await mkdir(home, { recursive: true })
|
|
141
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
142
|
+
try {
|
|
143
|
+
await writeFile(temp, snapshot, { encoding: 'utf8', mode: 0o600 })
|
|
144
|
+
await chmod(temp, 0o600)
|
|
145
|
+
await rename(temp, path)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
await unlink(temp).catch(() => {})
|
|
148
|
+
throw error
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
writing = job.catch(error => rt.warn('ds-bot: secrets write failed (%s)', reason(error)))
|
|
152
|
+
return job
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let usedTimer = null
|
|
156
|
+
const noteUsed = () => {
|
|
157
|
+
if (usedTimer !== null) return
|
|
158
|
+
usedTimer = setTimeout(() => {
|
|
159
|
+
usedTimer = null
|
|
160
|
+
if (loadedReadOnly || rt.readOnly() !== undefined) return
|
|
161
|
+
void persist().then(() => rt.save()).catch(() => {})
|
|
162
|
+
}, USED_SAVE_MS)
|
|
163
|
+
usedTimer.unref?.()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const requestsOf = botId => ((state.secretRequests ??= {})[botId] ??= [])
|
|
167
|
+
const findRequest = (id) => {
|
|
168
|
+
for (const [botId, list] of Object.entries(state.secretRequests ?? {})) {
|
|
169
|
+
const request = list.find(entry => entry.id === id)
|
|
170
|
+
if (request !== undefined) return { botId, request }
|
|
171
|
+
}
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
const trimRequests = (list) => {
|
|
175
|
+
while (list.length > MAX_REQUESTS) {
|
|
176
|
+
const settled = list.findIndex(entry => entry.status !== 'pending')
|
|
177
|
+
list.splice(settled === -1 ? 0 : settled, 1)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const scopeText = (scope) => {
|
|
182
|
+
if (scope === 'all') return 'every Bot'
|
|
183
|
+
const names = scope.map(id => rt.botOf(id)?.name).filter(Boolean)
|
|
184
|
+
return names.length === 0 ? 'no Bot' : names.join(', ')
|
|
185
|
+
}
|
|
186
|
+
const usage = name => `$${envName(name)} in bash, or $env:${envName(name)} in PowerShell`
|
|
187
|
+
|
|
188
|
+
async function tell(botId, text) {
|
|
189
|
+
if (rt.botOf(botId) === undefined) return
|
|
190
|
+
await rt.deliver({ toId: botId, role: 'secret', text })
|
|
191
|
+
.catch(error => rt.warn('ds-bot: secret note to %s failed: %s', botId, error))
|
|
192
|
+
}
|
|
193
|
+
const savedNote = (name, lead) => [
|
|
194
|
+
`${lead} Use it as ${usage(name)}.`,
|
|
195
|
+
'You cannot see the value. Never print, echo, or log it, and never ask the user to paste it into the chat. Carry on with what you needed it for.',
|
|
196
|
+
].join(' ')
|
|
197
|
+
|
|
198
|
+
function settle(botId, request, status) {
|
|
199
|
+
request.status = status
|
|
200
|
+
request.settledAt = Date.now()
|
|
201
|
+
if (status === 'saved' || status === 'allowed') request.scope = secrets.get(request.name)?.scope ?? 'all'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// After any change to `name`, every pending card for it either resolves (its Bot can
|
|
205
|
+
// use the key now) or shows the form that still applies.
|
|
206
|
+
function reconcile(name, notes, except) {
|
|
207
|
+
const secret = secrets.get(name)
|
|
208
|
+
for (const [botId, list] of Object.entries(state.secretRequests ?? {})) {
|
|
209
|
+
for (const request of list) {
|
|
210
|
+
if (request.status !== 'pending' || request.name !== name || request === except) continue
|
|
211
|
+
if (canUse(secret, botId)) {
|
|
212
|
+
settle(botId, request, 'saved')
|
|
213
|
+
notes.push([botId, savedNote(name, `The user saved ${name}, and you may use it now.`)])
|
|
214
|
+
} else {
|
|
215
|
+
request.kind = secret ? 'allow' : 'fill'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function commit(notes) {
|
|
222
|
+
changed()
|
|
223
|
+
await persist()
|
|
224
|
+
await rt.save()
|
|
225
|
+
for (const [botId, text] of notes) await tell(botId, text)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cleanValue = (raw) => {
|
|
229
|
+
if (typeof raw !== 'string') throw new Error('Type the key first')
|
|
230
|
+
const value = raw.trim()
|
|
231
|
+
if (value.length < MIN_VALUE) throw new Error(`A key must have at least ${MIN_VALUE} characters`)
|
|
232
|
+
if (value.length > MAX_VALUE) throw new Error(`A key can have at most ${MAX_VALUE} characters`)
|
|
233
|
+
return value
|
|
234
|
+
}
|
|
235
|
+
const cleanScope = (raw) => {
|
|
236
|
+
if (raw === 'all') return 'all'
|
|
237
|
+
if (!Array.isArray(raw)) throw new Error('Choose every Bot or a list of Bots')
|
|
238
|
+
return [...new Set(raw.filter(id => typeof id === 'string' && rt.botOf(id) !== undefined))]
|
|
239
|
+
}
|
|
240
|
+
const pendingRequest = (id) => {
|
|
241
|
+
const found = findRequest(String(id ?? ''))
|
|
242
|
+
if (found === undefined) throw new Error('This request is gone')
|
|
243
|
+
if (found.request.status !== 'pending') throw new Error('This request was already answered')
|
|
244
|
+
return found
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const secretView = secret => ({
|
|
248
|
+
name: secret.name,
|
|
249
|
+
purpose: secret.purpose,
|
|
250
|
+
scope: secret.scope,
|
|
251
|
+
requestedBy: secret.requestedBy,
|
|
252
|
+
createdAt: secret.createdAt,
|
|
253
|
+
updatedAt: secret.updatedAt,
|
|
254
|
+
...(secret.lastUsedAt !== undefined ? { lastUsedAt: secret.lastUsedAt } : {}),
|
|
255
|
+
})
|
|
256
|
+
const secretsView = () => [...secrets.values()].sort((a, b) => a.name.localeCompare(b.name)).map(secretView)
|
|
257
|
+
|
|
258
|
+
const endpoints = {
|
|
259
|
+
// From a card (requestId) or from the settings page (name).
|
|
260
|
+
async 'secret-set'(body) {
|
|
261
|
+
const value = cleanValue(body.value)
|
|
262
|
+
const now = Date.now()
|
|
263
|
+
const notes = []
|
|
264
|
+
if (body.requestId !== undefined) {
|
|
265
|
+
const { botId, request } = pendingRequest(body.requestId)
|
|
266
|
+
const existing = secrets.get(request.name)
|
|
267
|
+
let scope
|
|
268
|
+
if (existing === undefined || request.kind === 'fill') scope = body.scope === 'bot' ? [botId] : 'all'
|
|
269
|
+
else scope = existing.scope === 'all' ? 'all' : [...new Set([...existing.scope, botId])]
|
|
270
|
+
secrets.set(request.name, {
|
|
271
|
+
name: request.name,
|
|
272
|
+
value,
|
|
273
|
+
purpose: existing?.purpose || request.purpose,
|
|
274
|
+
scope,
|
|
275
|
+
requestedBy: existing?.requestedBy ?? botId,
|
|
276
|
+
createdAt: existing?.createdAt ?? now,
|
|
277
|
+
updatedAt: now,
|
|
278
|
+
...(existing?.lastUsedAt !== undefined ? { lastUsedAt: existing.lastUsedAt } : {}),
|
|
279
|
+
})
|
|
280
|
+
settle(botId, request, 'saved')
|
|
281
|
+
notes.push([botId, savedNote(request.name, `The user saved ${request.name} for ${scopeText(scope)}.`)])
|
|
282
|
+
reconcile(request.name, notes, request)
|
|
283
|
+
await commit(notes)
|
|
284
|
+
return secretView(secrets.get(request.name))
|
|
285
|
+
}
|
|
286
|
+
const name = normalizeName(body.name)
|
|
287
|
+
if (!SECRET_NAME.test(name)) throw new Error('A name uses capitals, digits, and underscores, and starts with a letter')
|
|
288
|
+
const existing = secrets.get(name)
|
|
289
|
+
secrets.set(name, {
|
|
290
|
+
name,
|
|
291
|
+
value,
|
|
292
|
+
purpose: typeof body.purpose === 'string' && body.purpose.trim() !== '' ? body.purpose.trim().slice(0, 300) : existing?.purpose ?? '',
|
|
293
|
+
scope: body.scope !== undefined ? cleanScope(body.scope) : existing?.scope ?? 'all',
|
|
294
|
+
requestedBy: existing?.requestedBy ?? null,
|
|
295
|
+
createdAt: existing?.createdAt ?? now,
|
|
296
|
+
updatedAt: now,
|
|
297
|
+
...(existing?.lastUsedAt !== undefined ? { lastUsedAt: existing.lastUsedAt } : {}),
|
|
298
|
+
})
|
|
299
|
+
reconcile(name, notes)
|
|
300
|
+
await commit(notes)
|
|
301
|
+
return secretView(secrets.get(name))
|
|
302
|
+
},
|
|
303
|
+
async 'secret-allow'(body) {
|
|
304
|
+
const { botId, request } = pendingRequest(body.requestId)
|
|
305
|
+
const secret = secrets.get(request.name)
|
|
306
|
+
if (secret === undefined) throw new Error(`${request.name} is not saved yet`)
|
|
307
|
+
if (!canUse(secret, botId)) secret.scope = [...new Set([...secret.scope, botId])]
|
|
308
|
+
secret.updatedAt = Date.now()
|
|
309
|
+
settle(botId, request, 'allowed')
|
|
310
|
+
const notes = [[botId, savedNote(request.name, `The user let you use ${request.name}.`)]]
|
|
311
|
+
reconcile(request.name, notes, request)
|
|
312
|
+
await commit(notes)
|
|
313
|
+
return secretView(secret)
|
|
314
|
+
},
|
|
315
|
+
async 'secret-cancel'(body) {
|
|
316
|
+
const { botId, request } = pendingRequest(body.requestId)
|
|
317
|
+
settle(botId, request, 'canceled')
|
|
318
|
+
await rt.save()
|
|
319
|
+
await tell(botId, `The user canceled your request for ${request.name}. Do not ask for it again unless the user brings it up; carry on without it, or tell the user what you cannot do without it.`)
|
|
320
|
+
return true
|
|
321
|
+
},
|
|
322
|
+
async 'secret-update'(body) {
|
|
323
|
+
const secret = secrets.get(normalizeName(body.name))
|
|
324
|
+
if (secret === undefined) throw new Error('No such key')
|
|
325
|
+
secret.scope = cleanScope(body.scope)
|
|
326
|
+
secret.updatedAt = Date.now()
|
|
327
|
+
const notes = []
|
|
328
|
+
reconcile(secret.name, notes)
|
|
329
|
+
await commit(notes)
|
|
330
|
+
return secretView(secret)
|
|
331
|
+
},
|
|
332
|
+
async 'secret-delete'(body) {
|
|
333
|
+
const name = normalizeName(body.name)
|
|
334
|
+
if (!secrets.delete(name)) throw new Error('No such key')
|
|
335
|
+
const notes = []
|
|
336
|
+
reconcile(name, notes)
|
|
337
|
+
await commit(notes)
|
|
338
|
+
return true
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function secretEndpoint(endpoint, body) {
|
|
343
|
+
await loadSecrets()
|
|
344
|
+
return endpoints[endpoint](body ?? {})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// A deleted Bot leaves every list it was on, and its cards go with it.
|
|
348
|
+
async function forgetBotSecrets(botId) {
|
|
349
|
+
await loadSecrets()
|
|
350
|
+
if (state.secretRequests !== undefined) delete state.secretRequests[botId]
|
|
351
|
+
let touched = false
|
|
352
|
+
for (const secret of secrets.values()) {
|
|
353
|
+
if (Array.isArray(secret.scope) && secret.scope.includes(botId)) {
|
|
354
|
+
secret.scope = secret.scope.filter(id => id !== botId)
|
|
355
|
+
touched = true
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (touched) {
|
|
359
|
+
changed()
|
|
360
|
+
await persist().catch(() => {})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const callerOf = exec => rt.selfOf(exec.agent?.session?.header?.id ?? exec.agent?.id)
|
|
365
|
+
|
|
366
|
+
ctx.tools.register(defineTool({
|
|
367
|
+
name: 'request_secret',
|
|
368
|
+
description: 'Ask the user for a key, token, or password through a secure card in your own chat. The user types it into the card; you never see the value. It reaches your shell as an environment variable. When the key is already saved for you, this only tells you its variable. Showing a card ends your turn; a note arrives when the user saves or cancels.',
|
|
369
|
+
parameters: {
|
|
370
|
+
name: { type: 'string', required: true, description: 'Key name in capitals, digits, and underscores, e.g. GITHUB_TOKEN' },
|
|
371
|
+
purpose: { type: 'string', required: true, description: 'One short line for the user: what you need it for' },
|
|
372
|
+
},
|
|
373
|
+
output,
|
|
374
|
+
async execute(args, exec) {
|
|
375
|
+
await rt.load()
|
|
376
|
+
await loadSecrets()
|
|
377
|
+
const id = exec.agent?.id
|
|
378
|
+
const bot = rt.botOf(callerOf(exec))
|
|
379
|
+
if (bot === undefined) return 'Only Bots on the team can use request_secret.'
|
|
380
|
+
const name = normalizeName(args.name)
|
|
381
|
+
if (!SECRET_NAME.test(name)) return 'Name the key with capitals, digits, and underscores, starting with a letter (for example GITHUB_TOKEN).'
|
|
382
|
+
const secret = secrets.get(name)
|
|
383
|
+
if (canUse(secret, bot.id)) return `${name} is already saved and you may use it: ${usage(name)}. You cannot see the value; never print it.`
|
|
384
|
+
if (rt.groupOwners.has(id)) return 'Secret cards only work in your own chat. Tell the group you will ask the user in your own chat, and call request_secret there.'
|
|
385
|
+
if (!rt.chatOwners.has(id)) return 'Secret cards only work in your current own chat.'
|
|
386
|
+
const readOnly = rt.readOnly()
|
|
387
|
+
if (readOnly !== undefined) return `No card can be shown now. ${readOnly}`
|
|
388
|
+
const list = requestsOf(bot.id)
|
|
389
|
+
const now = Date.now()
|
|
390
|
+
for (const request of list) {
|
|
391
|
+
if (request.status === 'pending' && request.name === name) {
|
|
392
|
+
request.status = 'canceled'
|
|
393
|
+
request.settledAt = now
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
list.push({
|
|
397
|
+
id: randomUUID(),
|
|
398
|
+
...(exec.callId ? { callId: exec.callId } : {}),
|
|
399
|
+
sessionId: id,
|
|
400
|
+
name,
|
|
401
|
+
purpose: String(args.purpose ?? '').trim().slice(0, 300),
|
|
402
|
+
kind: secret ? 'allow' : 'fill',
|
|
403
|
+
status: 'pending',
|
|
404
|
+
askedAt: now,
|
|
405
|
+
})
|
|
406
|
+
trimRequests(list)
|
|
407
|
+
await rt.save()
|
|
408
|
+
exec.concludeTurn?.()
|
|
409
|
+
return secret
|
|
410
|
+
? `The card asking the user to let you use ${name} is on screen. A note arrives when the user answers; you will not see the value.`
|
|
411
|
+
: `The card asking the user for ${name} is on screen. A note arrives when the user saves or cancels; you will not see the value.`
|
|
412
|
+
},
|
|
413
|
+
}))
|
|
414
|
+
|
|
415
|
+
ctx.tools.register(defineTool({
|
|
416
|
+
name: 'list_secrets',
|
|
417
|
+
description: 'List the keys the user saved for the team: name, purpose, which Bots may use them, and whether you may. Values are never shown.',
|
|
418
|
+
parameters: {},
|
|
419
|
+
output,
|
|
420
|
+
async execute(_args, exec) {
|
|
421
|
+
await rt.load()
|
|
422
|
+
await loadSecrets()
|
|
423
|
+
const self = callerOf(exec)
|
|
424
|
+
if (rt.botOf(self) === undefined) return 'Only Bots on the team can use list_secrets.'
|
|
425
|
+
if (secrets.size === 0) return 'No keys are saved yet. Ask for one with request_secret.'
|
|
426
|
+
return [
|
|
427
|
+
'Saved keys (values are never shown):',
|
|
428
|
+
...secretsView().map(secret => `- ${secret.name}${secret.purpose ? ` — ${secret.purpose}` : ''} — for ${scopeText(secret.scope)} — ${canUse(secrets.get(secret.name), self) ? `you may use it as $${envName(secret.name)}` : 'you may not use it; ask with request_secret'}`),
|
|
429
|
+
].join('\n')
|
|
430
|
+
},
|
|
431
|
+
}))
|
|
432
|
+
|
|
433
|
+
// Tool results lose the values before the registry renders and saves them: a fresh
|
|
434
|
+
// result object is re-rendered from its value, so the model content, the saved
|
|
435
|
+
// `tool/result`, and the tool card's presentation all come from the redacted value.
|
|
436
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
437
|
+
const result = await next()
|
|
438
|
+
if (secrets.size === 0 || result === null || typeof result !== 'object') return result
|
|
439
|
+
if (result.isError) {
|
|
440
|
+
const content = redactDeep(result.content, redact)
|
|
441
|
+
const meta = redactDeep(result.meta, redact)
|
|
442
|
+
const error = redactDeep(result.error, redact)
|
|
443
|
+
if (content === result.content && meta === result.meta && error === result.error) return result
|
|
444
|
+
return { ...result, content, error, ...(meta !== undefined ? { meta } : {}) }
|
|
445
|
+
}
|
|
446
|
+
const value = redactDeep(result.value, redact)
|
|
447
|
+
if (value === result.value) return result
|
|
448
|
+
return { isError: false, value, ...(result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}) }
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// Anything that still carries a value (the user pasted it, an earlier part kept it)
|
|
452
|
+
// is replaced before the request leaves. `next` cannot take new options, so the
|
|
453
|
+
// request starts over; it then carries no value and passes through here.
|
|
454
|
+
// A value that is part of its own label would be replaced again on every pass, so a
|
|
455
|
+
// request this listener rewrote passes straight through.
|
|
456
|
+
const rewritten = new WeakSet()
|
|
457
|
+
ctx.on('llm/stream', (options, next) => {
|
|
458
|
+
if (secrets.size === 0 || rewritten.has(options.messages)) return next()
|
|
459
|
+
const messages = redactDeep(options.messages, redact)
|
|
460
|
+
const system = redactDeep(options.system, redact)
|
|
461
|
+
if (messages === options.messages && system === options.system) return next()
|
|
462
|
+
const fresh = [...(messages ?? [])]
|
|
463
|
+
rewritten.add(fresh)
|
|
464
|
+
rt.carryUsageTag(options.messages, fresh)
|
|
465
|
+
return ctx.llm.stream({ ...options, messages: fresh, ...(system !== undefined ? { system } : {}) })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
function resolveEnv(execution) {
|
|
469
|
+
const botId = rt.selfOf(execution?.agent?.session?.header?.id ?? execution?.agent?.id)
|
|
470
|
+
if (rt.botOf(botId) === undefined) return {}
|
|
471
|
+
const command = typeof execution.arguments?.command === 'string' ? execution.arguments.command : ''
|
|
472
|
+
const values = {}
|
|
473
|
+
let used = false
|
|
474
|
+
for (const secret of secrets.values()) {
|
|
475
|
+
if (!canUse(secret, botId)) continue
|
|
476
|
+
values[envName(secret.name)] = secret.value
|
|
477
|
+
if (command.includes(envName(secret.name))) {
|
|
478
|
+
secret.lastUsedAt = Date.now()
|
|
479
|
+
used = true
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (used) noteUsed()
|
|
483
|
+
return values
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Keys must be declared up front, so the contribution is registered again whenever
|
|
487
|
+
// the set of names (or a purpose, which describes the key) changes.
|
|
488
|
+
function attachShellEnv(sub) {
|
|
489
|
+
let unregister = null
|
|
490
|
+
let declared = ''
|
|
491
|
+
refreshEnv = () => {
|
|
492
|
+
const list = [...secrets.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
493
|
+
const key = list.map(secret => `${secret.name}\u0000${secret.purpose}`).join('\n')
|
|
494
|
+
if (key === declared) return
|
|
495
|
+
unregister?.()
|
|
496
|
+
unregister = null
|
|
497
|
+
declared = key
|
|
498
|
+
if (list.length === 0) return
|
|
499
|
+
try {
|
|
500
|
+
unregister = sub.shellEnv.register({
|
|
501
|
+
name: 'ds-bot-secrets',
|
|
502
|
+
variables: Object.fromEntries(list.map(secret => [envName(secret.name), { description: `DS Bot secret ${secret.name}${secret.purpose ? `: ${secret.purpose}` : ''}` }])),
|
|
503
|
+
resolve: resolveEnv,
|
|
504
|
+
})
|
|
505
|
+
} catch (error) {
|
|
506
|
+
declared = ''
|
|
507
|
+
rt.warn('ds-bot: secrets not offered to the shell: %s', error?.message ?? error)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
refreshEnv()
|
|
511
|
+
sub.effect(() => () => {
|
|
512
|
+
unregister?.()
|
|
513
|
+
unregister = null
|
|
514
|
+
declared = ''
|
|
515
|
+
refreshEnv = () => {}
|
|
516
|
+
}, 'ds-bot: secrets shell env')
|
|
517
|
+
}
|
|
518
|
+
if (typeof ctx.inject === 'function') {
|
|
519
|
+
ctx.inject(['shellEnv'], sub => attachShellEnv(sub))
|
|
520
|
+
} else if (ctx.get?.('shellEnv') !== undefined) {
|
|
521
|
+
attachShellEnv({ shellEnv: ctx.get('shellEnv'), effect: (execute, label) => ctx.effect(execute, label) })
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
ctx.effect(() => {
|
|
525
|
+
void loadSecrets()
|
|
526
|
+
return () => { if (usedTimer !== null) clearTimeout(usedTimer) }
|
|
527
|
+
}, 'ds-bot: load secrets')
|
|
528
|
+
|
|
529
|
+
Object.assign(rt, { secretsView, secretValue: name => secrets.get(name)?.value, secretEndpoint, forgetBotSecrets, loadSecrets, redactSecrets: text => redact(text) })
|
|
530
|
+
}
|