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.
@@ -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
- if (cleanName === '') throw new Error('A Bot needs a name')
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
- state.bots[id] = {
67
- id,
68
- name: cleanName,
69
- role: String(role).trim().slice(0, 24),
70
- // A color is only stored when the user picks one; otherwise the id picks it.
71
- ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
72
- ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
73
- ...(modelRef ? { model: modelRef } : {}),
74
- ...(effortId ? { effort: effortId } : {}),
75
- instructions: String(instructions).trim(),
76
- createdAt: Date.now(),
77
- createdBy: createdBy ?? null,
78
- }
79
- if (main) state.mainBotIds.push(id)
80
- const creator = rt.botOf(createdBy)
81
- const original = rt.botOf(copyOf)
82
- const language = rt.uiLanguage?.()
83
- const kickoff = main
84
- ? `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'}.`
85
- : original
86
- ? `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).`
87
- : creator
88
- ? `${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.`
89
- : brief
90
- ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
91
- : '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).'
92
- const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
93
- const bot = state.bots[id]
94
- if (modelRef === undefined) {
95
- bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
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
- state.rooms[id] = {
246
- id,
247
- name: title,
248
- ...(custom ? { named: true } : {}),
249
- members,
250
- admin: adminId,
251
- mode: mode ?? 'admin',
252
- notice: cleanNotice(notice),
253
- createdAt: Date.now(),
254
- createdBy: createdBy ?? null,
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 rt.save()
332
- await ctx.workspaceRegistry.archiveSession(id).catch(() => {})
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
- if (rt.botOf(id) === undefined) throw new Error('Unknown Bot')
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, ...(rt.botOf(id).segments ?? []).map(segment => segment.sessionId)])
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 rt.save()
391
- await ctx.workspaceRegistry.archiveSession(current).catch(() => {})
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() {
@@ -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, { secretsView, secretValue: name => secrets.get(name)?.value, secretEndpoint, forgetBotSecrets, loadSecrets, redactSecrets: text => redact(text) })
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
- const parsed = JSON.parse(await readFile(statePath, 'utf8'))
59
- return parsed?.version === 1 ? parsed : undefined
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
- if (error?.code !== 'ENOENT') warn('ds-bot: unreadable state %s: %s', statePath, error)
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 = false
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) await save()
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
- if (holder === undefined) return
141
- claimLock()
142
- if (holder === undefined) {
143
- warn('ds-bot: the other dsh has closed; this one now saves the Bot team')
144
- warnedReadOnly = false
145
- adopt(await readFileState())
146
- return
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
- if (!warnedReadOnly) {
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
- writing = writing.then(async () => {
175
- await mkdir(home, { recursive: true })
246
+ const run = writing.then(async () => {
176
247
  const temp = `${statePath}.${process.pid}.tmp`
177
- await writeFile(temp, snapshot, 'utf8')
178
- await rename(temp, statePath)
179
- seen = stamp()
180
- }).catch(error => { warn('ds-bot: state write failed: %s', error) })
181
- return writing
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(() => () => lock.release(), 'ds-bot: state lock')
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
- void ctx.workspaceRegistry.archiveSession(sessionId).catch(() => {})
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
+ }