ds-bot 0.1.3 → 0.1.5

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/src/host/parts.js CHANGED
@@ -90,8 +90,9 @@ export function historyEntry(event, group) {
90
90
  const text = textOf(event.data?.message?.content)
91
91
  return text === '' ? undefined : { answer: true, text }
92
92
  }
93
- // Cards only show in an own chat.
94
- if (event.type === 'tool/call' && event.data?.name === 'ask_user' && !group) {
93
+ // Cards only show in an own chat. A PTC program's sub-call asks too.
94
+ const asks = (event.type === 'tool/call' || event.type === 'tool/ptc-dispatch-start') && event.data?.name === 'ask_user'
95
+ if (asks && !group) {
95
96
  const text = cardText(event.data.arguments)
96
97
  return text === '' ? undefined : { answer: true, text }
97
98
  }
@@ -143,6 +144,7 @@ export function install(rt) {
143
144
  const STEWARD_MAX_TOKENS = 4_096
144
145
  const STEWARD_TIMEOUT_MS = 120_000
145
146
  const STEWARD_LINES = 40
147
+ const FOLDER_RETRY_MS = 300_000
146
148
 
147
149
  // A broken provider adapter must not crash every turn; estimate from the
148
150
  // derived messages instead, with one warning.
@@ -312,7 +314,8 @@ export function install(rt) {
312
314
  complete = false
313
315
  rt.warn('ds-bot: reading %s for its handoff stopped early: %s', sessionId, error)
314
316
  }
315
- return { recent: recent.reverse(), earlier: earlier.reverse(), unlisted, complete }
317
+ // `whole`: every message of the part is in `recent`.
318
+ return { recent: recent.reverse(), earlier: earlier.reverse(), unlisted, complete, whole: complete && !full }
316
319
  }
317
320
 
318
321
  // The note's sections, in order. A note without all of them is not used.
@@ -326,8 +329,8 @@ export function install(rt) {
326
329
  ['Next step', 'the single next action, with the words of the request it follows quoted exactly; "(none)" when nothing is pending'],
327
330
  ['Where to look', 'details this note leaves out that you may need again, each with a few words copied exactly from the message that holds it, for read_own_chat to search'],
328
331
  ]
329
- const HANDOFF_PROMPT = [
330
- 'You are now writing a handoff note to yourself. This request comes from DS Bot, not from the user: do not answer or continue the messages above. Your conversation is long, so it moves on to a fresh conversation now. The fresh one starts with this note and your latest messages word for word; everything else leaves your working context. The full record stays saved, and you can search it later with read_own_chat. Write the note that lets you carry on as the same Bot without losing anything that matters.',
332
+ const handoffPrompt = why => [
333
+ `You are now writing a handoff note to yourself. This request comes from DS Bot, not from the user: do not answer or continue the messages above. ${why}, so it moves on to a fresh conversation now. The fresh one starts with this note and your latest messages word for word; everything else leaves your working context. The full record stays saved, and you can search it later with read_own_chat. Write the note that lets you carry on as the same Bot without losing anything that matters.`,
331
334
  '',
332
335
  'Output EXACTLY the Markdown structure below: every heading as written, in English and in this order. Write the content in the language the user writes in, as terse bullets. Write "(none)" under a heading with nothing to say.',
333
336
  '',
@@ -341,6 +344,8 @@ export function install(rt) {
341
344
  '- Do NOT mention this request or the move to a fresh conversation.',
342
345
  '- Output only the note, in at most about 2,000 words: no preface, and no tool calls.',
343
346
  ].join('\n')
347
+ const HANDOFF_PROMPT = handoffPrompt('Your conversation is long')
348
+ const FOLDER_PROMPT = handoffPrompt('Your working folder changed')
344
349
  const NOTE_RETRY_SHORTER = 'Your last note was cut off at the output limit, so it was not used. Write the whole note again, shorter: at most about 1,000 words. Keep every heading; drop detail before you drop facts.'
345
350
  const noteRetryMissing = missing => `Your last note lacked these headings, so it was not used: ${missing.map(name => `"## ${name}"`).join(', ')}. Write the whole note again with every heading exactly as given, in English.`
346
351
  const missingSections = note => NOTE_SECTIONS.map(([name]) => name)
@@ -349,16 +354,17 @@ export function install(rt) {
349
354
  // The same system prompt, tools, and messages as the conversation's own requests,
350
355
  // plus the note prompt; nothing is written into the Session. A cut-off note, or one
351
356
  // without every section, is asked for again once; then the note fails.
352
- async function writeNote(agent, signal) {
357
+ async function writeNote(agent, signal, { folder = false } = {}) {
353
358
  const session = agent.session
354
359
  const header = session.requestHeader()
355
360
  const route = header?.config
356
361
  if (!route?.provider || !route?.model) throw new Error('the Session has not called a model yet')
357
362
  const messages = session.deriveMessages()
363
+ const base = folder ? FOLDER_PROMPT : HANDOFF_PROMPT
358
364
  let retry
359
365
  let problem
360
366
  for (let attempt = 1; attempt <= NOTE_ATTEMPTS; attempt += 1) {
361
- const prompt = retry === undefined ? HANDOFF_PROMPT : `${HANDOFF_PROMPT}\n\n${retry}`
367
+ const prompt = retry === undefined ? base : `${base}\n\n${retry}`
362
368
  const assembler = new BlockAssembler()
363
369
  for await (const chunk of ctx.llm.stream({
364
370
  provider: route.provider,
@@ -489,6 +495,7 @@ export function install(rt) {
489
495
  system: STEWARD_SYSTEM,
490
496
  messages: rt.tagUsage([{ role: 'user', content: [{ type: 'text', text: stewardRequest({ lines, midTurn, checkpoints: checkpointCount(session.deriveMessages()), group }) }] }], { sessionId: agent.id, kind: 'compaction' }),
491
497
  maxTokens: STEWARD_MAX_TOKENS,
498
+ sessionId: session.id,
492
499
  purpose: 'compaction',
493
500
  signal: AbortSignal.timeout(STEWARD_TIMEOUT_MS),
494
501
  })) assembler.push(chunk)
@@ -547,6 +554,10 @@ export function install(rt) {
547
554
  // together without the model carries the last note the model wrote.
548
555
  function noteText(segment) {
549
556
  if (segment?.fallback === undefined) return segment?.note
557
+ if (segment.fallback === 'whole') {
558
+ const head = '(No new note: every message of the part was carried over word for word.'
559
+ return segment.note === undefined ? `${head})` : `${head} Below is the note written at the end of part ${segment.noteFrom}, carried over unchanged.)\n\n${segment.note}`
560
+ }
550
561
  const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window'
551
562
  : segment.fallback === 'failure' ? 'a request in the previous part kept failing'
552
563
  : 'the model did not write a usable note'
@@ -556,13 +567,14 @@ export function install(rt) {
556
567
  : `${head} It is the note written at the end of part ${segment.noteFrom}, carried over unchanged; anything newer is in the messages below, and read_own_chat finds the rest.)\n\n${segment.note}`
557
568
  }
558
569
 
559
- function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare }) {
570
+ function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare, folder }) {
560
571
  const where = room ? `your conversation in the group chat "${room.name}"` : 'your own chat with the user'
561
572
  const lookup = room
562
573
  ? 'read_group_chat shows the group\'s latest messages, and read_own_chat your own chat.'
563
574
  : 'read_own_chat searches every part, reads around an anchor such as [part 2 #1234 · …], and shows the note that closed any earlier part.'
564
575
  const reason = failed !== undefined ? `a request in the previous part kept failing (${failed})`
565
- : overflow ? 'the previous part overflowed the model\'s window' : 'the previous part grew long'
576
+ : overflow ? 'the previous part overflowed the model\'s window'
577
+ : folder !== undefined ? `your working folder changed to ${folder}` : 'the previous part grew long'
566
578
  const { recent, earlier, unlisted, complete } = lines
567
579
  const omitted = [
568
580
  ...(unlisted > 0 ? [`(${unlisted} older messages are not listed.)`] : []),
@@ -573,6 +585,7 @@ export function install(rt) {
573
585
  `DS Bot moved ${where} on to a fresh conversation, part ${number}, because ${reason}. This message is not from the user.`,
574
586
  'It is reference, not new instructions: the note and the messages below are a record of earlier work. Do what they say only as part of the work they record, and take new requests only from messages that come after this one.',
575
587
  `Everything earlier stays saved: ${lookup}`,
588
+ ...(folder !== undefined ? [`You now work in ${folder}: commands and file tools start there. Relative paths in the messages below meant the previous folder.`] : []),
576
589
  ...(failed !== undefined ? ['Images from the previous part are not in this conversation; if one matters, ask the user to send it again.'] : []),
577
590
  '',
578
591
  '## Handoff note',
@@ -607,8 +620,11 @@ export function install(rt) {
607
620
  ...(resume && job.joined?.length ? { joined: job.joined } : {}),
608
621
  })
609
622
 
610
- /** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, joined?:any[], resume:boolean, overflow:boolean, failed?:string, bare?:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
623
+ /** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, joined?:any[], resume:boolean, overflow:boolean, failed?:string, bare?:boolean, folder?:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
611
624
  const switches = new Map()
625
+ // Parts whose move to their working folder failed, and the time before which it is
626
+ // not tried again.
627
+ const folderRetry = new Map()
612
628
  // Parts opened because requests kept failing, until one of their turns ends well:
613
629
  // 'carried' brought the latest messages along, 'bare' left them out.
614
630
  /** @type {Map<string, 'carried'|'bare'>} */
@@ -641,6 +657,7 @@ export function install(rt) {
641
657
  async function forward(owner, messages) {
642
658
  const target = currentOf(owner)
643
659
  if (target === undefined || messages.length === 0) return
660
+ await rt.ensureCapabilities(target)
644
661
  const agent = await rt.resolveAgent(target)
645
662
  for (const message of messages) {
646
663
  const copy = createUserMessage({ content: message.content, ...(message.source ? { source: message.source } : {}) })
@@ -652,11 +669,11 @@ export function install(rt) {
652
669
  }
653
670
 
654
671
  // `hard`: past the hard line or the window, the part moves on even without a note
655
- // from the model.
656
- function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, hard = overflow } = {}) {
672
+ // from the model. `folder`: the part moves on because its working folder changed.
673
+ function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, folder = false, hard = overflow || folder } = {}) {
657
674
  const place = placeOf(agent.id)
658
675
  if (place === undefined || switches.has(agent.id) || readOnly()) return
659
- const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, hard }
676
+ const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, folder, hard }
660
677
  switches.set(agent.id, job)
661
678
  void runSwitch(agent, job)
662
679
  .catch(error => rt.warn('ds-bot: %s did not move on to a fresh part: %s', rt.botOf(job.botId)?.name ?? agent.id, error))
@@ -664,8 +681,12 @@ export function install(rt) {
664
681
  switches.delete(agent.id)
665
682
  if (job.moved) {
666
683
  noteFailures.delete(agent.id)
684
+ folderRetry.delete(agent.id)
685
+ // The folder may have changed again while the switch ran.
686
+ if (job.roomId === undefined) void followFolder(job.botId)
667
687
  return
668
688
  }
689
+ if (folder) folderRetry.set(agent.id, Date.now() + FOLDER_RETRY_MS)
669
690
  if (!job.noteFailed) partBase.set(agent.id, tokensOf(agent.session) ?? 0)
670
691
  // The stopped turn goes on where it was.
671
692
  const back = job.carry.splice(0)
@@ -710,11 +731,16 @@ export function install(rt) {
710
731
  const lines = await contextLines(session).catch(() => undefined)
711
732
  const parts = partsOf(bot.id, room?.id)
712
733
  const number = parts.length
734
+ const budget = Math.min(RECENT_TOKENS, Math.floor((lines?.check ?? 4 * RECENT_TOKENS) / 4))
735
+ const readLines = () => (job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : handoffLines(agent.id, { budget, group: room !== undefined, number }))
736
+ let carried = job.folder ? await readLines() : undefined
737
+ // A short part with no tool work goes over word for word, so it needs no note.
738
+ const whole = carried?.whole === true && !session.deriveMessages().some(message => message.role === 'tool')
713
739
  let written
714
740
  let failure
715
- if (!job.overflow && job.failed === undefined) {
741
+ if (!job.overflow && job.failed === undefined && !whole) {
716
742
  try {
717
- written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]))
743
+ written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]), { folder: job.folder })
718
744
  } catch (error) {
719
745
  if (signal.aborted) throw error
720
746
  failure = error
@@ -733,11 +759,12 @@ export function install(rt) {
733
759
  if (failure !== undefined) rt.warn('ds-bot: handoff note for %s failed past the hard line; moving on with the earlier note: %s', bot.name, failure)
734
760
  const previous = parts.at(-2)
735
761
  const noted = written !== undefined ? { note: written.note }
736
- : { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
737
- const budget = Math.min(RECENT_TOKENS, Math.floor((lines?.check ?? 4 * RECENT_TOKENS) / 4))
738
- const carried = job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : await handoffLines(agent.id, { budget, group: room !== undefined, number })
762
+ : { fallback: whole ? 'whole' : job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
763
+ carried ??= await readLines()
739
764
  if (signal.aborted) throw signal.reason
740
- const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name)
765
+ const dir = rt.workDirOf(job)
766
+ const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder()
767
+ const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir })
741
768
  const route = session.requestHeader()?.config
742
769
  const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
743
770
  if (ref !== undefined) {
@@ -768,18 +795,26 @@ export function install(rt) {
768
795
  void rt.moveSchedules(agent.id, freshId)
769
796
  job.moved = true
770
797
  const resume = job.resume || job.overflow
771
- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
772
- freshParts.set(freshId, estimateTokens(text))
798
+ await rt.ensureCapabilities(freshId)
773
799
  const fresh = await rt.resolveAgent(freshId)
800
+ const cwd = fresh.session.header?.cwd
801
+ const folder = cwd !== undefined && cwd !== session.header?.cwd ? cwd : undefined
802
+ // A fresh part that is not where it was made for would move on at every step.
803
+ if (intended !== undefined && cwd !== undefined && cwd !== intended) {
804
+ folderRetry.set(freshId, Date.now() + FOLDER_RETRY_MS)
805
+ rt.warn('ds-bot: the fresh part of %s did not start in %s; it works in %s', bot.name, intended, cwd)
806
+ }
807
+ const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare, folder })
808
+ freshParts.set(freshId, estimateTokens(text))
774
809
  fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
775
810
  await ctx.sessions.flush(fresh.session)
776
811
  // Then what the old part held: the stopped turn's input, then whatever waited.
777
812
  const held = [...agent.inbox.nextStep, ...agent.inbox.nextTurn]
778
813
  for (const message of held) agent.inbox.remove(message.id)
779
814
  await forward(job, [...job.carry.splice(0), ...held])
780
- ctx.logger.info('ds-bot: %s%s moved on to part %d at %d tokens (check line %d, hard line %d); %s; %d latest and %d earlier messages carried', bot.name, room ? ` in ${room.name}` : '', number + 1, tokens ?? -1, lines?.check ?? -1, lines?.hard ?? -1,
815
+ ctx.logger.info('ds-bot: %s%s moved on to part %d at %d tokens (check line %d, hard line %d); %s; %d latest and %d earlier messages carried%s', bot.name, room ? ` in ${room.name}` : '', number + 1, tokens ?? -1, lines?.check ?? -1, lines?.hard ?? -1,
781
816
  written !== undefined ? `note written in ${written.attempts} attempt(s)` : `note put together by DS Bot (${noted.fallback}${noted.note ? `, carries part ${noted.noteFrom}'s` : ', nothing to carry'})`,
782
- carried.recent.length, carried.earlier.length)
817
+ carried.recent.length, carried.earlier.length, folder === undefined ? '' : `; now works in ${folder}`)
783
818
  }
784
819
 
785
820
  // Runs first: an earlier part hands its input on, a part past the hard line makes
@@ -805,21 +840,54 @@ export function install(rt) {
805
840
  freshParts.delete(agent.id)
806
841
  partBase.set(agent.id, (tokensOf(agent.session) ?? 0) + fresh)
807
842
  }
843
+ if (!readOnly() && await folderMove(agent, { midTurn: payload.step > 1 }) !== undefined) return switchBeforeStep(agent, payload, { folder: true })
808
844
  const lines = await contextLines(agent.session).catch(() => undefined)
809
845
  const tokens = tokensOf(agent.session)
810
846
  if (lines === undefined || tokens === undefined || tokens < lines.hard || !grown(agent.id, tokens, lines) || readOnly()) return next()
811
- const midTurn = payload.step > 1
812
- if (await decide(agent, { midTurn }) === 'compact' && await condense(agent, lines, { inTurn: true, signal: payload.signal })) return next()
813
- if (switches.has(agent.id)) {
814
- switches.get(agent.id).carry.push(...payload.messages)
847
+ if (await decide(agent, { midTurn: payload.step > 1 }) === 'compact' && await condense(agent, lines, { inTurn: true, signal: payload.signal })) return next()
848
+ return switchBeforeStep(agent, payload, { hard: true })
849
+ }, { prepend: true })
850
+
851
+ // `messages` left the inbox for this step, so the switch carries them along.
852
+ function switchBeforeStep(agent, payload, options) {
853
+ // A switch may have started while the checks above awaited.
854
+ const running = switches.get(agent.id)
855
+ if (running !== undefined) {
856
+ running.carry.push(...payload.messages)
815
857
  return { kind: 'reject' }
816
858
  }
817
- // `messages` left the inbox for this step, so the switch carries them along.
859
+ const midTurn = payload.step > 1
818
860
  const entry = rt.liveOf(agent.id)
819
861
  entry.switched = true
820
- startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, hard: true })
862
+ startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, ...options })
821
863
  return { kind: 'reject' }
822
- }, { prepend: true })
864
+ }
865
+
866
+ // The folder a part should move to, when it is not the one the part was made in. A
867
+ // working folder that is gone counts as the team workspace, where a fresh part would
868
+ // start. A group reply ends where it began; the group conversation moves at its next
869
+ // turn there.
870
+ async function folderMove(agent, { midTurn = false } = {}) {
871
+ const place = placeOf(agent.id)
872
+ const cwd = agent.session.header?.cwd
873
+ if (place === undefined || cwd === undefined || (midTurn && place.roomId !== undefined) || Date.now() < (folderRetry.get(agent.id) ?? 0)) return undefined
874
+ const wanted = rt.workDirOf(place)
875
+ const target = wanted !== undefined && await rt.isFolder(wanted) ? wanted : rt.teamFolder()
876
+ return target === undefined || target === cwd ? undefined : target
877
+ }
878
+
879
+ // A Bot's own chat moves to its new folder now when it is idle; a running turn
880
+ // moves at its next step, and each group conversation at its next turn there.
881
+ async function followFolder(botId) {
882
+ const sessionId = rt.chatOf(botId)
883
+ if (sessionId === undefined || switches.has(sessionId) || readOnly()) return
884
+ try {
885
+ const agent = await rt.resolveAgent(sessionId)
886
+ if (agent.status === 'idle' && await folderMove(agent) !== undefined) startSwitch(agent, { folder: true })
887
+ } catch (error) {
888
+ rt.warn('ds-bot: %s did not move to its working folder: %s', rt.botOf(botId)?.name ?? botId, error)
889
+ }
890
+ }
823
891
 
824
892
  ctx.on('agent/request-error', async (payload, next) => {
825
893
  const failure = payload.failure
@@ -900,7 +968,7 @@ export function install(rt) {
900
968
  // in the part's history. When nothing is left to do, that turn's reply is a fixed
901
969
  // line instead of a model call.
902
970
  ctx.on('llm/stream', (options, next) => {
903
- if (options.sessionId === undefined || options.purpose !== undefined) return next()
971
+ if (options.sessionId === undefined || options.purpose !== undefined || rt.ownRequest(options)) return next()
904
972
  const messages = options.messages ?? []
905
973
  const since = messages.findLastIndex(message => message.role === 'assistant' || message.role === 'tool') + 1
906
974
  const opened = messages.slice(since).filter(message => message.role === 'user' && (message.source?.kind === 'user' || message.source?.kind === SOURCE_KIND))
@@ -969,5 +1037,5 @@ export function install(rt) {
969
1037
  }
970
1038
  }
971
1039
 
972
- Object.assign(rt, { clock, partsOf, historyEntry, partLines, noteText, earlierPage, switchJobs: () => switches.values() })
1040
+ Object.assign(rt, { clock, partsOf, historyEntry, partLines, noteText, earlierPage, followFolder, switchJobs: () => switches.values() })
973
1041
  }
@@ -13,9 +13,9 @@ const TEAM_RULES = [
13
13
  '- Chat like a teammate texting: short sentences, often two or three short messages instead of one long one, no headings. When the user asks for a deliverable (a plan, schedule, draft, or list), write it out in full in the chat; never claim you gave something you did not write. Mention other Bots by their exact name.',
14
14
  '- Only for a direct user request in your own chat that needs several steps: write one short sentence of normal reply text before your first tool call, and make that tool call in the same response. Never start such a task with tools alone or end it with a promise. On long tasks, give brief factual updates only at meaningful milestones, then keep working.',
15
15
  '- For "[Group chat …]", "[Message from …]", or "[Reply from …]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.',
16
- '- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message. Never use ask_user_question: it blocks you, and messages from other Bots would queue behind it.',
17
- '- update_bot changes your own settings: name, role label, color, model, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up. Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.',
18
- '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.',
16
+ '- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message.',
17
+ '- update_bot changes your own settings: name, role label, color, model, working folder, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up, for example to work in a project folder (work_dir, a full path). Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.',
18
+ '- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. Any member can hand work to another with @Name, and the one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.',
19
19
  '- When you need a key, token, or password, call request_secret: the user types it into a secure card and you never see it. Never ask the user to paste one into the chat. Use it through its environment variable ($DSH_SECRET_<NAME> in bash, $env:DSH_SECRET_<NAME> in PowerShell) and never print it. list_secrets shows which keys exist.',
20
20
  '- web_search results say which search backend answered. If web_search fails, never guess URLs or present pages you remember as search results: use a browser tool if you have one, otherwise tell the user that search is unavailable and the reason it gave.',
21
21
  '- Your conversations are long-lived. When one grows long, either its older messages are condensed in place into a summary you wrote (a "[Checkpoint N]" message), or it moves on to a fresh part that opens with a handoff note you wrote and your latest messages (a "[Handoff · part N]" message). What leaves your working context stays saved. When you need the exact earlier wording or a detail the summary or note does not show in full (a full path, a figure, every item of a complete list or a final summary), call read_own_chat with words to look for, or with part N to read the note that closed an earlier part; never fill such a detail in from memory. Each line it returns starts with an anchor such as [part 2 #1234 · 10-08 14:22]; pass the anchor as around to read what was said around it.',
@@ -30,8 +30,8 @@ const MAIN_RULES = [
30
30
  '- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
31
31
  '- If a Bot does not heed your message and must not finish what it is doing, stop_bot ends its turn at once and tells it why; the reply comes back to you.',
32
32
  '- Every Bot runs on one model of its own (list_bots shows it). Pass model to create_bot or update_bot only when the user names one; list_models gives the provider/model refs.',
33
- '- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
34
- '- You run the group chats too. create_group makes a group (you join it as its admin unless you name another); update_group changes any group\'s name, notice, members, admin, or reply mode; post_to_group posts into any group as you, which starts a round of replies the user reads there; delete_group archives a group, only when the user asks. list_bots shows every group.',
33
+ '- Use update_bot to rename a Bot, change its role, color, model, working folder, or instructions when the user asks.',
34
+ '- You run the group chats too. create_group makes a group (you join it as its admin unless you name another); update_group changes any group\'s name, notice, members, admin, reply mode, or the folder every member works in there; post_to_group posts into any group as you, which starts a round of replies the user reads there; delete_group archives a group, only when the user asks. list_bots shows every group.',
35
35
  '- The team can have more than one Main Bot; list_bots marks them. They share these powers, so coordinate with them through message_bot.',
36
36
  ].join('\n')
37
37
 
@@ -0,0 +1,247 @@
1
+ // Resume: Bot work that DeepSeek Harness dropped when it closed or restarted. DSH closes
2
+ // a turn it never finished as interrupted when the Session next opens, and a clean
3
+ // shutdown also cancels the messages still waiting in a Session's inbox; nothing would
4
+ // ever run either again, and whoever waited for them was not told. DS Bot notes each
5
+ // running Bot turn and each message it delivers until a turn takes it up, in
6
+ // inflight.json; what is still noted at the next start is picked up once.
7
+ import { randomUUID } from 'node:crypto'
8
+ import { readFileSync } from 'node:fs'
9
+ import { mkdir, writeFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { SOURCE_KIND } from './constants.js'
12
+
13
+ // Long enough for DSH to finish opening its workspaces after a start.
14
+ const RESUME_DELAY_MS = 3000
15
+ // A delivery waiting longer than this was not lost to a restart.
16
+ const QUEUED_KEPT_MS = 24 * 60 * 60 * 1000
17
+ const QUEUED_MAX = 50
18
+
19
+ export const resumeText = record => [
20
+ 'DeepSeek Harness restarted while you were working on this, so your last turn stopped before it finished.',
21
+ record.approval ? `You were waiting for the user's approval to run ${record.approval.tool || 'a tool'}${record.approval.reason ? ` (${record.approval.reason})` : ''}; that request is gone, so ask again if you still need it.` : '',
22
+ 'Check what is already done (files, commands, messages) before you repeat anything, then finish the task and give your reply.',
23
+ ].filter(Boolean).join(' ')
24
+
25
+ // The trigger fields a resumed turn needs to answer the same sender.
26
+ const triggerOf = info => ({
27
+ role: info.role,
28
+ ...(info.exchangeId ? { exchangeId: info.exchangeId } : {}),
29
+ ...(info.senderSessionId ? { senderSessionId: info.senderSessionId } : {}),
30
+ ...(info.roomId ? { roomId: info.roomId } : {}),
31
+ ...(typeof info.hop === 'number' ? { hop: info.hop } : {}),
32
+ ...(info.audience ? { audience: info.audience } : {}),
33
+ ...(info.askedBy ? { askedBy: info.askedBy } : {}),
34
+ ...(info.senderRoomId ? { senderRoomId: info.senderRoomId } : {}),
35
+ })
36
+
37
+ const readNotes = (file) => {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
40
+ const object = value => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {})
41
+ return { turns: object(parsed?.turns), queued: object(parsed?.queued) }
42
+ } catch {
43
+ return { turns: {}, queued: {} }
44
+ }
45
+ }
46
+
47
+ export function install(rt) {
48
+ const { ctx, home } = rt
49
+ const file = join(home, 'inflight.json')
50
+
51
+ // What the last run left; read before this run notes anything.
52
+ let interrupted = readNotes(file)
53
+
54
+ /** @type {Record<string, {botId:string, roomId?:string, since:number, trigger?:any, approval?:{tool:string, reason:string}, resumed?:boolean}>} */
55
+ let turns = {}
56
+ /** @type {Record<string, {at:number, args:any}[]>} deliveries no turn has taken up, by Session */
57
+ let queued = {}
58
+ let writing = Promise.resolve()
59
+ let stopped = false
60
+ let latest
61
+ let due = false
62
+ const persist = () => {
63
+ // Only the host that saves the team runs its work again.
64
+ if (stopped || rt.readOnly() !== undefined) return
65
+ latest = JSON.stringify({ turns, queued })
66
+ // One write waits at a time and takes the latest notes, so a busy round does not
67
+ // write the file once per change.
68
+ if (due) return
69
+ due = true
70
+ // Written in place: a rename over the file fails on Windows while anything reads
71
+ // it, and a torn write only loses what one restart would pick up.
72
+ writing = writing.then(async () => {
73
+ due = false
74
+ const snapshot = latest
75
+ await mkdir(home, { recursive: true })
76
+ await writeFile(file, snapshot, 'utf8')
77
+ }).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error))
78
+ }
79
+ const update = (id, change) => {
80
+ const next = change(turns[id])
81
+ if (next === turns[id]) return
82
+ turns = { ...turns }
83
+ if (next === undefined) delete turns[id]
84
+ else turns[id] = next
85
+ persist()
86
+ }
87
+ const forgetQueued = (exchangeId) => {
88
+ for (const [sessionId, list] of Object.entries(queued)) {
89
+ const at = list.findIndex(item => item.args.exchangeId === exchangeId)
90
+ if (at === -1) continue
91
+ const rest = list.toSpliced(at, 1)
92
+ queued = { ...queued }
93
+ if (rest.length === 0) delete queued[sessionId]
94
+ else queued[sessionId] = rest
95
+ persist()
96
+ return
97
+ }
98
+ }
99
+
100
+ const deliver = rt.deliver
101
+ rt.deliver = async (args) => {
102
+ const noted = { ...args, exchangeId: args.exchangeId ?? randomUUID() }
103
+ const sessionId = noted.sessionId ?? rt.chatOf(noted.toId)
104
+ // Noted before it is sent: a turn may take it up before the send returns.
105
+ queued = { ...queued, [sessionId]: [...(queued[sessionId] ?? []), { at: Date.now(), args: noted }].slice(-QUEUED_MAX) }
106
+ persist()
107
+ try {
108
+ return await deliver(noted)
109
+ } catch (error) {
110
+ forgetQueued(noted.exchangeId)
111
+ throw error
112
+ }
113
+ }
114
+
115
+ // A message is done with once a turn takes it up, or once it leaves the inbox untaken
116
+ // while DS Bot runs: the user stopped the Bot, or DS Bot moved or withdrew it.
117
+ ctx.on('agent/inbox/discarded', ({ message } = {}) => {
118
+ if (message?.source?.kind === SOURCE_KIND && message.source.exchangeId) forgetQueued(message.source.exchangeId)
119
+ })
120
+ // Exchanges a turn took up since this start, until the resends are done, so a resend
121
+ // never repeats one.
122
+ let taken = Object.keys(interrupted.queued).length > 0 ? new Set() : undefined
123
+ // The first turn event of each Session since this start, until the noted turns are
124
+ // picked up. A turn that ends, or still runs, before one starts here outlived the
125
+ // last run: only DS Bot restarted (the plugin reloaded) and DSH went on.
126
+ let firstTurn = Object.keys(interrupted.turns).length > 0 ? new Map() : undefined
127
+ ctx.on('session/event', (session, event) => {
128
+ const id = session?.id
129
+ if (id === undefined) return
130
+ if ((event.type === 'turn/start' || event.type === 'turn/end') && firstTurn?.has(id) === false) firstTurn.set(id, event.type)
131
+ if (event.type === 'user/message' && event.data?.source?.kind === SOURCE_KIND && event.data.source.exchangeId) {
132
+ taken?.add(event.data.source.exchangeId)
133
+ forgetQueued(event.data.source.exchangeId)
134
+ }
135
+ if (!rt.isBotSession(id)) return
136
+ switch (event.type) {
137
+ case 'turn/start': {
138
+ const owner = rt.groupOwners.get(id)
139
+ const botId = owner?.botId ?? rt.chatOwners.get(id)
140
+ update(id, () => ({ botId, ...(owner ? { roomId: owner.roomId } : {}), since: event.time ?? Date.now() }))
141
+ break
142
+ }
143
+ case 'user/message': {
144
+ if (event.data?.source?.kind !== SOURCE_KIND) break
145
+ // turns.js, installed earlier, has already claimed the turn's trigger.
146
+ const info = rt.live.get(id)?.info
147
+ update(id, record => (record === undefined || record.trigger !== undefined || info === undefined ? record
148
+ : { ...record, trigger: triggerOf(info), ...(info.resumed ? { resumed: true } : {}) }))
149
+ break
150
+ }
151
+ case 'turn/end': {
152
+ // DSH stops every turn as it shuts down; that one is picked up at the next start.
153
+ const reason = event.data?.reason
154
+ if (reason?.kind === 'aborted' && reason.reason?.kind === 'disposed') break
155
+ update(id, () => undefined)
156
+ break
157
+ }
158
+ }
159
+ })
160
+
161
+ // An approval the restart drops is named when the turn is picked up.
162
+ rt.noteApproval = (sessionId, approval) => update(sessionId, record => (record === undefined ? record
163
+ : approval === undefined ? (({ approval: _, ...rest }) => rest)(record) : { ...record, approval }))
164
+
165
+ async function resumeTurn(sessionId, record) {
166
+ const bot = rt.botOf(record.botId)
167
+ if (bot === undefined) return
168
+ const text = resumeText(record)
169
+ if (record.roomId !== undefined) {
170
+ const room = rt.roomOf(record.roomId)
171
+ if (room === undefined || !room.members.includes(bot.id)) return
172
+ await rt.postToRoom(room, bot, '', { resumed: true, quiet: true, ...(record.resumed ? { again: true } : {}) })
173
+ // A turn already picked up once and cut off again stays stopped: a restart loop
174
+ // must not run it forever.
175
+ if (record.resumed) return
176
+ await rt.deliver({ toId: bot.id, sessionId: room.sessions?.[bot.id] ?? sessionId, role: 'room', roomId: room.id, note: 'picking up after a restart', resumed: true, text, ...(record.trigger?.askedBy ? { askedBy: record.trigger.askedBy } : {}) })
177
+ return
178
+ }
179
+ if (record.resumed) return
180
+ const trigger = record.trigger
181
+ if ((trigger?.role === 'request' || trigger?.role === 'stopped') && trigger.senderSessionId && trigger.audience !== 'user' && rt.botOf(trigger.senderSessionId)) {
182
+ // The sender still waits for this turn's answer, so it goes on under the same exchange.
183
+ await rt.deliver({ fromId: trigger.senderSessionId, toId: bot.id, role: trigger.role, hop: trigger.hop ?? 0, ...(trigger.exchangeId ? { exchangeId: trigger.exchangeId } : {}), ...(trigger.senderRoomId ? { fromRoomId: trigger.senderRoomId } : {}), resumed: true, text })
184
+ return
185
+ }
186
+ await rt.deliver({ toId: bot.id, role: 'resume', resumed: true, text })
187
+ }
188
+
189
+ // A hard stop keeps the inbox, which the Session runs once it wakes; only what DSH
190
+ // cancelled is sent again.
191
+ async function resendQueued(sessionId, list) {
192
+ // A Session that moved on to a new part since is reached at its current one.
193
+ const current = (args) => {
194
+ if (rt.isBotSession(sessionId)) return sessionId
195
+ return args.roomId === undefined ? rt.chatOf(args.toId) : rt.roomOf(args.roomId)?.sessions?.[args.toId]
196
+ }
197
+ const fresh = list.filter(item => Date.now() - item.at < QUEUED_KEPT_MS && rt.botOf(item.args?.toId) !== undefined
198
+ && current(item.args) !== undefined && taken?.has(item.args.exchangeId) !== true)
199
+ if (fresh.length === 0) return
200
+ const target = current(fresh[0].args)
201
+ const agent = await rt.resolveAgent(target)
202
+ const waiting = new Set([...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])].map(message => message.source?.exchangeId))
203
+ for (const { args } of fresh) {
204
+ if (waiting.has(args.exchangeId) || taken?.has(args.exchangeId) === true) continue
205
+ await rt.deliver({ ...args, sessionId: current(args), steer: false, resent: true })
206
+ }
207
+ }
208
+
209
+ async function resumeAll() {
210
+ await rt.load()
211
+ if (rt.readOnly() !== undefined) return
212
+ const { turns: noted, queued: dropped } = interrupted
213
+ interrupted = { turns: {}, queued: {} }
214
+ // Taken up now, so another restart does not pick them up twice.
215
+ persist()
216
+ // Agents and inboxes are read before anything wakes their Sessions.
217
+ const cut = []
218
+ for (const [sessionId, record] of Object.entries(noted)) {
219
+ const first = firstTurn.get(sessionId)
220
+ if (first === 'turn/end') continue
221
+ // Still running: noted again, as if it had started here.
222
+ if (first === undefined && (await rt.resolveAgent(sessionId).catch(() => undefined))?.status === 'running') update(sessionId, () => record)
223
+ else cut.push([sessionId, record])
224
+ }
225
+ firstTurn = undefined
226
+ for (const [sessionId, list] of Object.entries(dropped)) {
227
+ if (!Array.isArray(list)) continue
228
+ await resendQueued(sessionId, list).catch(error => rt.warn('ds-bot: sending again what %s was sent failed: %s', sessionId, error))
229
+ }
230
+ taken = undefined
231
+ for (const [sessionId, record] of cut) {
232
+ await resumeTurn(sessionId, record).catch(error => rt.warn('ds-bot: picking up the turn of %s failed: %s', sessionId, error))
233
+ }
234
+ }
235
+
236
+ ctx.effect(() => {
237
+ const pending = Object.keys(interrupted.turns).length > 0 || Object.keys(interrupted.queued).length > 0
238
+ const timer = pending ? setTimeout(() => void resumeAll().catch(error => rt.warn('ds-bot: picking up interrupted work failed: %s', error)), RESUME_DELAY_MS) : undefined
239
+ timer?.unref?.()
240
+ // What the turns stopped by this shutdown left stays as last noted.
241
+ return () => {
242
+ stopped = true
243
+ clearTimeout(timer)
244
+ return writing
245
+ }
246
+ }, 'ds-bot: resume interrupted turns')
247
+ }