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/README.md +5 -4
- package/client.js +1948 -1269
- package/index.js +12 -3
- package/package.json +1 -1
- package/src/host/approvals.js +8 -2
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +25 -6
- package/src/host/constants.js +11 -0
- package/src/host/delivery.js +21 -6
- package/src/host/folders.js +135 -0
- package/src/host/groups.js +123 -33
- package/src/host/images.js +2 -2
- package/src/host/memory.js +31 -8
- package/src/host/parts.js +100 -32
- package/src/host/prompt.js +5 -5
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +41 -10
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +16 -3
- package/src/host/tools.js +193 -19
- package/src/host/turns.js +126 -14
- package/src/host/usage.js +4 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
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 ?
|
|
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'
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
}
|
|
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
|
}
|
package/src/host/prompt.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
+
}
|