@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.
@@ -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
+ }