ds-bot 0.1.2 → 0.1.4
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/README.md +3 -3
- package/client.js +2164 -1102
- package/index.js +10 -1
- package/package.json +6 -4
- package/src/host/approvals.js +69 -0
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +26 -4
- package/src/host/constants.js +12 -1
- package/src/host/delivery.js +34 -8
- package/src/host/diagnostics.js +7 -5
- package/src/host/groups.js +296 -59
- package/src/host/home-lock.js +16 -10
- package/src/host/images.js +37 -0
- package/src/host/memory.js +27 -16
- package/src/host/parts.js +94 -21
- package/src/host/prompt.js +7 -3
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +110 -56
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +129 -31
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +182 -29
- package/src/host/turns.js +263 -39
- package/src/host/update.js +5 -1
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
package/src/host/roster.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdir } from 'node:fs/promises'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
|
|
6
6
|
import { currentColor, isColor, unquote } from './text.js'
|
|
7
|
+
import { nameProblem } from '../shared/mentions.js'
|
|
7
8
|
|
|
8
9
|
export function install(rt) {
|
|
9
10
|
const { ctx, config, home, defaults, state } = rt
|
|
@@ -56,52 +57,67 @@ export function install(rt) {
|
|
|
56
57
|
// greeting waits until a model is chosen. So does a Bot made when no model is served.
|
|
57
58
|
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
|
|
58
59
|
const cleanName = String(botName ?? '').trim().slice(0, 40)
|
|
59
|
-
|
|
60
|
+
const problem = nameProblem(cleanName)
|
|
61
|
+
if (problem !== undefined) throw new Error(problem)
|
|
60
62
|
if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
|
|
61
63
|
const cleanAvatarValue = cleanAvatar(avatar)
|
|
62
64
|
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
|
|
63
65
|
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
|
|
64
66
|
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
|
|
65
67
|
const id = await createSession(cleanName)
|
|
66
|
-
|
|
67
|
-
id
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
? `
|
|
87
|
-
:
|
|
88
|
-
?
|
|
89
|
-
:
|
|
90
|
-
?
|
|
91
|
-
:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
bot
|
|
68
|
+
try {
|
|
69
|
+
state.bots[id] = {
|
|
70
|
+
id,
|
|
71
|
+
name: cleanName,
|
|
72
|
+
role: String(role).trim().slice(0, 24),
|
|
73
|
+
// A color is only stored when the user picks one; otherwise the id picks it.
|
|
74
|
+
...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
|
|
75
|
+
...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
|
|
76
|
+
...(modelRef ? { model: modelRef } : {}),
|
|
77
|
+
...(effortId ? { effort: effortId } : {}),
|
|
78
|
+
instructions: String(instructions).trim(),
|
|
79
|
+
createdAt: Date.now(),
|
|
80
|
+
createdBy: createdBy ?? null,
|
|
81
|
+
capabilities: [],
|
|
82
|
+
}
|
|
83
|
+
if (main) state.mainBotIds.push(id)
|
|
84
|
+
const creator = rt.botOf(createdBy)
|
|
85
|
+
const original = rt.botOf(copyOf)
|
|
86
|
+
const language = rt.uiLanguage?.()
|
|
87
|
+
const kickoff = main
|
|
88
|
+
? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
|
|
89
|
+
: original
|
|
90
|
+
? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
|
|
91
|
+
: creator
|
|
92
|
+
? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
|
|
93
|
+
: brief
|
|
94
|
+
? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
|
|
95
|
+
: 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
|
|
96
|
+
const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
|
|
97
|
+
const bot = state.bots[id]
|
|
98
|
+
if (modelRef === undefined) {
|
|
99
|
+
bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
|
|
100
|
+
await rt.save()
|
|
101
|
+
return bot
|
|
102
|
+
}
|
|
96
103
|
await rt.save()
|
|
104
|
+
// The greeting runs on the Bot's own model, once its provider is registered.
|
|
105
|
+
void rt.waitForModel(bot)
|
|
106
|
+
.then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
|
|
107
|
+
.then(() => sendKickoff(bot, { text, from: createdBy }))
|
|
97
108
|
return bot
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// The Bot's Session was already made: archive it instead of leaving an orphan.
|
|
111
|
+
delete state.bots[id]
|
|
112
|
+
state.mainBotIds = state.mainBotIds.filter(other => other !== id)
|
|
113
|
+
rt.chatOwners.delete(id)
|
|
114
|
+
// A save that another change queued meanwhile still carries this Bot; a later
|
|
115
|
+
// one writes the team without it.
|
|
116
|
+
void rt.save().catch(() => {})
|
|
117
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
118
|
+
.catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', cleanName, archiveError))
|
|
119
|
+
throw error
|
|
98
120
|
}
|
|
99
|
-
await rt.save()
|
|
100
|
-
// The greeting runs on the Bot's own model, once its provider is registered.
|
|
101
|
-
void rt.waitForModel(bot)
|
|
102
|
-
.then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
|
|
103
|
-
.then(() => sendKickoff(bot, { text, from: createdBy }))
|
|
104
|
-
return bot
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
function sendKickoff(bot, { text, from }) {
|
|
@@ -114,7 +130,7 @@ export function install(rt) {
|
|
|
114
130
|
const bot = rt.botOf(botId)
|
|
115
131
|
if (bot?.kickoff === undefined) return
|
|
116
132
|
delete bot.kickoff
|
|
117
|
-
void rt.save()
|
|
133
|
+
void rt.save().catch(() => {})
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
async function duplicateBot(id) {
|
|
@@ -140,6 +156,9 @@ export function install(rt) {
|
|
|
140
156
|
const next = String(patch.name).trim().slice(0, 40)
|
|
141
157
|
const clash = rt.findBot(next)
|
|
142
158
|
if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`)
|
|
159
|
+
// A name saved before the rule stays usable until it changes.
|
|
160
|
+
const problem = next === bot.name ? undefined : nameProblem(next)
|
|
161
|
+
if (problem !== undefined) throw new Error(problem)
|
|
143
162
|
bot.name = next
|
|
144
163
|
const agent = await rt.resolveAgent(rt.chatOf(id))
|
|
145
164
|
await ctx.sessionTitle.rename(agent.session, next)
|
|
@@ -242,19 +261,28 @@ export function install(rt) {
|
|
|
242
261
|
if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
243
262
|
const title = custom || autoRoomName(members)
|
|
244
263
|
const id = await createSession(title)
|
|
245
|
-
|
|
246
|
-
id
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
264
|
+
try {
|
|
265
|
+
state.rooms[id] = {
|
|
266
|
+
id,
|
|
267
|
+
name: title,
|
|
268
|
+
...(custom ? { named: true } : {}),
|
|
269
|
+
members,
|
|
270
|
+
admin: adminId,
|
|
271
|
+
mode: mode ?? 'admin',
|
|
272
|
+
notice: cleanNotice(notice),
|
|
273
|
+
createdAt: Date.now(),
|
|
274
|
+
createdBy: createdBy ?? null,
|
|
275
|
+
}
|
|
276
|
+
await rt.save()
|
|
277
|
+
return state.rooms[id]
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// The group's Session was already made: archive it instead of leaving an orphan.
|
|
280
|
+
delete state.rooms[id]
|
|
281
|
+
void rt.save().catch(() => {})
|
|
282
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
283
|
+
.catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
|
|
284
|
+
throw error
|
|
255
285
|
}
|
|
256
|
-
await rt.save()
|
|
257
|
-
return state.rooms[id]
|
|
258
286
|
}
|
|
259
287
|
|
|
260
288
|
// `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
|
|
@@ -328,8 +356,12 @@ export function install(rt) {
|
|
|
328
356
|
for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId)
|
|
329
357
|
delete state.rooms[id]
|
|
330
358
|
rt.roomInbox.delete(id)
|
|
331
|
-
await
|
|
332
|
-
|
|
359
|
+
const failed = await saveForDelete()
|
|
360
|
+
// Armed scheduled tasks refuse a plain archive and would keep delivering into
|
|
361
|
+
// the deleted group; the session-stop that stopActivity runs removes them.
|
|
362
|
+
await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
|
|
363
|
+
.catch(error => rt.warn('ds-bot: group chat %s not archived: %s', room.name, error))
|
|
364
|
+
if (failed !== undefined) throw failed
|
|
333
365
|
}
|
|
334
366
|
|
|
335
367
|
// The first Main Bot is the sidebar tile. `replace` gives a Main Bot's place to
|
|
@@ -364,13 +396,14 @@ export function install(rt) {
|
|
|
364
396
|
}
|
|
365
397
|
|
|
366
398
|
async function deleteBot(id) {
|
|
367
|
-
|
|
399
|
+
const bot = rt.botOf(id)
|
|
400
|
+
if (bot === undefined) throw new Error('Unknown Bot')
|
|
368
401
|
if (rt.isMain(id)) throw new Error('A Main Bot cannot be deleted; remove its Main Bot role first')
|
|
369
402
|
// Before the Bot and its group Sessions leave the state, which the archive reads.
|
|
370
403
|
const archived = rt.archiveBotMemory(id)
|
|
371
404
|
const current = rt.chatOf(id)
|
|
372
405
|
// Questions and exchange turns are kept per Session, so every part of the chat is cleared.
|
|
373
|
-
const parts = new Set([id, current, ...(
|
|
406
|
+
const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)])
|
|
374
407
|
delete state.bots[id]
|
|
375
408
|
for (const part of parts) {
|
|
376
409
|
delete state.questions[part]
|
|
@@ -387,8 +420,29 @@ export function install(rt) {
|
|
|
387
420
|
await followMemberNames(touched)
|
|
388
421
|
await rt.forgetBotSecrets(id)
|
|
389
422
|
await archived
|
|
390
|
-
await
|
|
391
|
-
|
|
423
|
+
const failed = await saveForDelete()
|
|
424
|
+
// Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks.
|
|
425
|
+
// Past parts are archived too: a part move can leave tasks on the old Session,
|
|
426
|
+
// which otherwise stays armed and invisible. Already-archived ids are a no-op.
|
|
427
|
+
for (const part of parts) {
|
|
428
|
+
await ctx.workspaceRegistry.archiveSession(part, { stopActivity: true })
|
|
429
|
+
.catch(error => rt.warn('ds-bot: Bot %s not archived: %s', bot.name, error))
|
|
430
|
+
}
|
|
431
|
+
if (failed !== undefined) throw failed
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A deletion whose save fails stays in memory, and the next save writes it, so its
|
|
435
|
+
// Sessions are stopped anyway: left armed, their scheduled tasks would keep running
|
|
436
|
+
// with nothing in the team to show or delete them. Only a host that lost the team
|
|
437
|
+
// to another dsh keeps them, since the other host's copy still has them.
|
|
438
|
+
async function saveForDelete() {
|
|
439
|
+
try {
|
|
440
|
+
await rt.save()
|
|
441
|
+
return undefined
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error?.code === 'ELOCKLOST' || rt.readOnly() !== undefined) throw error
|
|
444
|
+
return error
|
|
445
|
+
}
|
|
392
446
|
}
|
|
393
447
|
|
|
394
448
|
async function bootstrap() {
|
package/src/host/secrets.js
CHANGED
|
@@ -526,5 +526,13 @@ export function install(rt) {
|
|
|
526
526
|
return () => { if (usedTimer !== null) clearTimeout(usedTimer) }
|
|
527
527
|
}, 'ds-bot: load secrets')
|
|
528
528
|
|
|
529
|
-
Object.assign(rt, {
|
|
529
|
+
Object.assign(rt, {
|
|
530
|
+
secretsView,
|
|
531
|
+
secretValue: name => secrets.get(name)?.value,
|
|
532
|
+
canUseSecret: (name, botId) => canUse(secrets.get(name), botId),
|
|
533
|
+
secretEndpoint,
|
|
534
|
+
forgetBotSecrets,
|
|
535
|
+
loadSecrets,
|
|
536
|
+
redactSecrets: text => redact(text),
|
|
537
|
+
})
|
|
530
538
|
}
|
package/src/host/store.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// host on the same data from writing it, and the indexes that map a Session to the Bot
|
|
3
3
|
// or group chat it belongs to.
|
|
4
4
|
import { mkdirSync, statSync } from 'node:fs'
|
|
5
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js'
|
|
8
8
|
import { homeLock } from './home-lock.js'
|
|
@@ -11,8 +11,8 @@ import { currentColor, normalize, unquote } from './text.js'
|
|
|
11
11
|
export function install(rt) {
|
|
12
12
|
const { ctx, home, statePath } = rt
|
|
13
13
|
|
|
14
|
-
/** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number}} */
|
|
15
|
-
const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 }
|
|
14
|
+
/** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number}} */
|
|
15
|
+
const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 }
|
|
16
16
|
let loaded = null
|
|
17
17
|
let writing = Promise.resolve()
|
|
18
18
|
const { warn } = rt
|
|
@@ -22,7 +22,9 @@ export function install(rt) {
|
|
|
22
22
|
// undefined while this host may write.
|
|
23
23
|
let holder
|
|
24
24
|
let lockBroken = false
|
|
25
|
+
let stuck
|
|
25
26
|
let warnedReadOnly = false
|
|
27
|
+
let stopped = false
|
|
26
28
|
// mtime and size of the state.json this host last read or wrote.
|
|
27
29
|
let seen = ''
|
|
28
30
|
const stamp = () => {
|
|
@@ -40,39 +42,80 @@ export function install(rt) {
|
|
|
40
42
|
mkdirSync(home, { recursive: true })
|
|
41
43
|
holder = lock.claim()
|
|
42
44
|
} catch (error) {
|
|
43
|
-
// An unusable lock (a read-only or odd file system) must not stop the team
|
|
45
|
+
// An unusable lock (a read-only or odd file system) must not stop the team; a
|
|
46
|
+
// save that then cannot write still rejects.
|
|
44
47
|
lockBroken = true
|
|
45
48
|
holder = undefined
|
|
46
49
|
warn('ds-bot: cannot lock %s, so another dsh on the same data could undo changes: %s', home, error)
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
const readOnly = () => {
|
|
53
|
+
if (stuck !== undefined) return `The Bot team file ${statePath} could not be read (${stuck}). DS Bot does not change it; fix or move the file and reload.`
|
|
50
54
|
if (holder === undefined) return undefined
|
|
51
55
|
const where = holder.pid ? `pid ${holder.pid}${holder.host ? ` on ${holder.host}` : ''}` : 'unknown process'
|
|
52
56
|
return `The Bot team in ${home} is open in another dsh (Web or Desktop, ${where}). This window shows the team but cannot change it; close the other one and reload.`
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const readFileState = async () => {
|
|
60
|
+
let contents
|
|
56
61
|
try {
|
|
62
|
+
contents = await readFile(statePath, 'utf8')
|
|
63
|
+
} catch (error) {
|
|
57
64
|
seen = stamp()
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
if (error?.code === 'ENOENT') {
|
|
66
|
+
stuck = undefined
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
stuck = error?.code ?? error?.message ?? String(error)
|
|
70
|
+
warn('ds-bot: unreadable state %s: %s', statePath, error)
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
seen = stamp()
|
|
74
|
+
let parsed
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(contents)
|
|
60
77
|
} catch (error) {
|
|
61
|
-
|
|
78
|
+
const backup = `${statePath}.broken-${Date.now()}`
|
|
79
|
+
try {
|
|
80
|
+
await copyFile(statePath, backup)
|
|
81
|
+
stuck = undefined
|
|
82
|
+
warn('ds-bot: unreadable state %s: %s; backed up as %s', statePath, error, backup)
|
|
83
|
+
} catch (copyError) {
|
|
84
|
+
stuck = copyError?.code ?? copyError?.message ?? error?.message ?? String(error)
|
|
85
|
+
warn('ds-bot: unreadable state %s: %s; backup failed: %s', statePath, error, copyError)
|
|
86
|
+
}
|
|
62
87
|
return undefined
|
|
63
88
|
}
|
|
89
|
+
if (parsed?.version === 1) {
|
|
90
|
+
stuck = undefined
|
|
91
|
+
return parsed
|
|
92
|
+
}
|
|
93
|
+
stuck = 'saved by a newer DS Bot; update DS Bot'
|
|
94
|
+
return undefined
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
// Fill `state` from the saved file and bring an older team up to date. Returns the
|
|
67
98
|
// groups whose Session titles still need the new name.
|
|
68
99
|
const adopt = (parsed) => {
|
|
100
|
+
const hadCapabilities = parsed !== null && parsed !== undefined && Object.hasOwn(parsed, 'capabilities')
|
|
69
101
|
for (const key of Object.keys(state)) delete state[key]
|
|
70
102
|
Object.assign(state, JSON.parse(EMPTY), parsed ?? {})
|
|
71
103
|
state.prefs ??= {}
|
|
72
104
|
// Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset.
|
|
73
105
|
if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {}
|
|
74
|
-
let migrated =
|
|
106
|
+
let migrated = !hadCapabilities
|
|
107
|
+
if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) {
|
|
108
|
+
state.capabilities = { items: {} }
|
|
109
|
+
migrated = true
|
|
110
|
+
} else if (state.capabilities.items === null || typeof state.capabilities.items !== 'object' || Array.isArray(state.capabilities.items)) {
|
|
111
|
+
state.capabilities.items = {}
|
|
112
|
+
migrated = true
|
|
113
|
+
}
|
|
75
114
|
for (const bot of Object.values(state.bots)) {
|
|
115
|
+
if (!Array.isArray(bot.capabilities)) {
|
|
116
|
+
bot.capabilities = []
|
|
117
|
+
migrated = true
|
|
118
|
+
}
|
|
76
119
|
if (bot.color !== currentColor(bot.color)) {
|
|
77
120
|
bot.color = currentColor(bot.color)
|
|
78
121
|
migrated = true
|
|
@@ -129,7 +172,15 @@ export function install(rt) {
|
|
|
129
172
|
if (holder !== undefined) warn('ds-bot: %s', readOnly())
|
|
130
173
|
const { migrated, stale } = adopt(await readFileState())
|
|
131
174
|
if (holder !== undefined) return
|
|
132
|
-
if (migrated
|
|
175
|
+
if (migrated && stuck === undefined) {
|
|
176
|
+
// A failed write must not poison `loaded`: the migrated state stays in
|
|
177
|
+
// memory and the next save() writes it.
|
|
178
|
+
try {
|
|
179
|
+
await save()
|
|
180
|
+
} catch (error) {
|
|
181
|
+
warn('ds-bot: saving the migrated Bot team failed: %s', error)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
133
184
|
// Not awaited: renaming a session must not hold up the first roster read.
|
|
134
185
|
for (const room of stale) void rt.retitleRoom(room)
|
|
135
186
|
})()
|
|
@@ -137,18 +188,42 @@ export function install(rt) {
|
|
|
137
188
|
// A reading host follows the file and takes over once the other host has gone.
|
|
138
189
|
const refresh = async () => {
|
|
139
190
|
await load()
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
191
|
+
const hadHolder = holder !== undefined
|
|
192
|
+
if (hadHolder) {
|
|
193
|
+
claimLock()
|
|
194
|
+
if (holder === undefined) {
|
|
195
|
+
warn('ds-bot: the other dsh has closed; this one now saves the Bot team')
|
|
196
|
+
warnedReadOnly = false
|
|
197
|
+
adopt(await readFileState())
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
} else if (!lockBroken && !lock.owns()) {
|
|
201
|
+
// A cached "writer" is stale once the lock file no longer names this host:
|
|
202
|
+
// another dsh may have taken it over, or the file may just be gone. Re-claim
|
|
203
|
+
// and re-check instead of trusting the flag.
|
|
204
|
+
claimLock()
|
|
205
|
+
if (lockBroken) return
|
|
206
|
+
if (holder === undefined) {
|
|
207
|
+
// The lock was free: still the writer, but the file may carry changes
|
|
208
|
+
// written while no host held it.
|
|
209
|
+
warnedReadOnly = false
|
|
210
|
+
adopt(await readFileState())
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
warn('ds-bot: %s', readOnly())
|
|
147
214
|
}
|
|
148
|
-
if (stamp() !== seen) adopt(await readFileState())
|
|
215
|
+
if ((stuck !== undefined || holder !== undefined) && stamp() !== seen) adopt(await readFileState())
|
|
149
216
|
}
|
|
150
217
|
|
|
218
|
+
const warnReadOnly = () => {
|
|
219
|
+
if (!warnedReadOnly) {
|
|
220
|
+
warnedReadOnly = true
|
|
221
|
+
warn('ds-bot: not saving the Bot team: %s', readOnly())
|
|
222
|
+
}
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
151
225
|
const writable = () => {
|
|
226
|
+
if (stuck !== undefined) return warnReadOnly()
|
|
152
227
|
if (lockBroken) return true
|
|
153
228
|
if (holder === undefined && lock.owns()) return true
|
|
154
229
|
// Lost the lock: retake it when nobody else holds it (its file was deleted), else
|
|
@@ -157,31 +232,51 @@ export function install(rt) {
|
|
|
157
232
|
claimLock()
|
|
158
233
|
if (holder === undefined) return true
|
|
159
234
|
}
|
|
160
|
-
|
|
161
|
-
warnedReadOnly = true
|
|
162
|
-
warn('ds-bot: not saving the Bot team: %s', readOnly())
|
|
163
|
-
}
|
|
164
|
-
return false
|
|
235
|
+
return warnReadOnly()
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
const save = () => {
|
|
239
|
+
if (stopped) return writing
|
|
168
240
|
indexSessions()
|
|
169
241
|
// A reading host keeps the file's revision, so its window does not skip the next change.
|
|
170
242
|
if (!writable()) return writing
|
|
171
243
|
state.revision += 1
|
|
172
244
|
state.exchanges = state.exchanges.slice(-MAX_EXCHANGES)
|
|
173
245
|
const snapshot = JSON.stringify(state, null, 2)
|
|
174
|
-
|
|
175
|
-
await mkdir(home, { recursive: true })
|
|
246
|
+
const run = writing.then(async () => {
|
|
176
247
|
const temp = `${statePath}.${process.pid}.tmp`
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
248
|
+
try {
|
|
249
|
+
await mkdir(home, { recursive: true })
|
|
250
|
+
const ensureLock = () => {
|
|
251
|
+
if (lockBroken) return
|
|
252
|
+
if (!lock.owns()) {
|
|
253
|
+
const error = new Error('ds-bot lost the state lock to another dsh')
|
|
254
|
+
error.code = 'ELOCKLOST'
|
|
255
|
+
throw error
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
ensureLock()
|
|
259
|
+
await writeFile(temp, snapshot, 'utf8')
|
|
260
|
+
ensureLock()
|
|
261
|
+
await rename(temp, statePath)
|
|
262
|
+
seen = stamp()
|
|
263
|
+
rt.capabilitiesStateSaved?.()
|
|
264
|
+
} catch (error) {
|
|
265
|
+
await unlink(temp).catch(() => {})
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
writing = run.catch(() => {})
|
|
270
|
+
return run.catch(error => {
|
|
271
|
+
warn('ds-bot: state write failed: %s', error)
|
|
272
|
+
throw error
|
|
273
|
+
})
|
|
182
274
|
}
|
|
183
275
|
|
|
184
|
-
ctx.effect(() => () =>
|
|
276
|
+
ctx.effect(() => () => {
|
|
277
|
+
stopped = true
|
|
278
|
+
return writing.finally(() => lock.release())
|
|
279
|
+
}, 'ds-bot: state lock')
|
|
185
280
|
|
|
186
281
|
const botOf = id => (id === undefined ? undefined : state.bots[id])
|
|
187
282
|
const roomOf = id => (id === undefined ? undefined : state.rooms[id])
|
|
@@ -233,7 +328,10 @@ export function install(rt) {
|
|
|
233
328
|
delete room.sessions[botId]
|
|
234
329
|
if (room.segments !== undefined) delete room.segments[botId]
|
|
235
330
|
groupOwners.delete(sessionId)
|
|
236
|
-
|
|
331
|
+
rt.cancelApprovals?.(sessionId)
|
|
332
|
+
// A plain archive refuses a running Session, which would go on working for a group
|
|
333
|
+
// that no longer has it.
|
|
334
|
+
void ctx.workspaceRegistry.archiveSession(sessionId, { stopActivity: true }).catch(() => {})
|
|
237
335
|
}
|
|
238
336
|
const findBot = (ref) => {
|
|
239
337
|
if (ref === undefined) return undefined
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
|
|
3
|
+
const SOURCE = 'ds-bot-time-context'
|
|
4
|
+
const isClock = source => source?.kind === SOURCE || source?.kind === 'time-context'
|
|
5
|
+
|
|
6
|
+
export function install(rt) {
|
|
7
|
+
const { ctx, config } = rt
|
|
8
|
+
const interval = config.timeRefreshIntervalMs ?? 600_000
|
|
9
|
+
if (!Number.isSafeInteger(interval) || interval < 0) throw new TypeError('timeRefreshIntervalMs must be a non-negative safe integer')
|
|
10
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
11
|
+
...(config.timeZone ? { timeZone: config.timeZone } : {}),
|
|
12
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
13
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
14
|
+
timeZoneName: 'longOffset',
|
|
15
|
+
})
|
|
16
|
+
const zone = formatter.resolvedOptions().timeZone
|
|
17
|
+
|
|
18
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
19
|
+
const decision = await next()
|
|
20
|
+
if (decision.kind === 'reject' || signal.aborted) return decision
|
|
21
|
+
await rt.load()
|
|
22
|
+
if (!rt.isBotSession(agent.id) || signal.aborted) return decision
|
|
23
|
+
// A loaded DSH clock covers every step itself. Its listener may run before or
|
|
24
|
+
// after this one (a reload reorders them), so its presence is read from its
|
|
25
|
+
// projection, not from the messages added so far.
|
|
26
|
+
if (ctx.get?.('sessionProjections')?.stateOf(agent.session, 'timeContext') !== undefined) return decision
|
|
27
|
+
if (decision.messages.some(message => isClock(message.source))) return decision
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
for await (const event of rt.eventsBackwards(agent.id, signal, Infinity)) {
|
|
30
|
+
if (event.type === 'user/message' && isClock(event.data.source)) {
|
|
31
|
+
if (now >= event.time && now - event.time < interval) return decision
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
if (event.time <= now - interval) break
|
|
35
|
+
}
|
|
36
|
+
if (signal.aborted) return decision
|
|
37
|
+
const text = `Current time: ${formatter.format(now)} (${zone}).`
|
|
38
|
+
return {
|
|
39
|
+
...decision,
|
|
40
|
+
messages: [...decision.messages, createUserMessage({
|
|
41
|
+
content: [{ type: 'text', text }],
|
|
42
|
+
source: { kind: SOURCE, form: 'snapshot', sections: [{ name: SOURCE, text }] },
|
|
43
|
+
})],
|
|
44
|
+
}
|
|
45
|
+
}, { prepend: true })
|
|
46
|
+
}
|