ds-bot 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/client.js +2164 -1102
- package/index.js +10 -1
- package/package.json +6 -4
- package/src/host/approvals.js +69 -0
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +26 -4
- package/src/host/constants.js +12 -1
- package/src/host/delivery.js +34 -8
- package/src/host/diagnostics.js +7 -5
- package/src/host/groups.js +296 -59
- package/src/host/home-lock.js +16 -10
- package/src/host/images.js +37 -0
- package/src/host/memory.js +27 -16
- package/src/host/parts.js +94 -21
- package/src/host/prompt.js +7 -3
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +110 -56
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +129 -31
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +182 -29
- package/src/host/turns.js +263 -39
- package/src/host/update.js +5 -1
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
package/src/host/memory.js
CHANGED
|
@@ -44,6 +44,9 @@ const QUERY_TERMS_MAX = 6
|
|
|
44
44
|
const SOURCE_READ_MS = 5_000
|
|
45
45
|
const MODEL_TIMEOUT_MS = 120_000
|
|
46
46
|
const SUMMARY_MAX_TOKENS = 2_048
|
|
47
|
+
// A reply cut off at the limit is tried once more with room for reasoning plus
|
|
48
|
+
// the answer; a model that thinks can spend the smaller budget thinking alone.
|
|
49
|
+
const SUMMARY_RETRY_MAX_TOKENS = 8_192
|
|
47
50
|
const SUMMARY_TEXT_MAX = 8_000
|
|
48
51
|
const ASK_TEXT_MAX = 2_000
|
|
49
52
|
const ASK_CHANGES_MAX = 20
|
|
@@ -905,24 +908,32 @@ export function install(rt) {
|
|
|
905
908
|
// the tag the meter would file them under "other".
|
|
906
909
|
async function oneShot(route, system, text, tag) {
|
|
907
910
|
const info = await ctx.llm.resolveModelInfo(route.provider, route.model).catch(() => undefined)
|
|
908
|
-
const
|
|
909
|
-
|
|
911
|
+
const efforts = info?.reasoning?.efforts ?? []
|
|
912
|
+
// Thinking counts against the same output budget as the answer, so ask for
|
|
913
|
+
// the least the model offers: 'off' when it has one, else its lowest level.
|
|
914
|
+
const effort = efforts.some(entry => entry.id === 'off') ? 'off' : efforts.at(0)?.id
|
|
910
915
|
const messages = [{ role: 'user', content: [{ type: 'text', text }] }]
|
|
911
916
|
if (tag !== undefined) rt.tagUsage?.(messages, tag)
|
|
912
|
-
for
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
917
|
+
for (const maxTokens of [SUMMARY_MAX_TOKENS, SUMMARY_RETRY_MAX_TOKENS]) {
|
|
918
|
+
const assembler = new BlockAssembler()
|
|
919
|
+
for await (const chunk of ctx.llm.stream({
|
|
920
|
+
provider: route.provider,
|
|
921
|
+
model: route.model,
|
|
922
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
923
|
+
system,
|
|
924
|
+
messages,
|
|
925
|
+
maxTokens,
|
|
926
|
+
signal: AbortSignal.timeout(MODEL_TIMEOUT_MS),
|
|
927
|
+
})) assembler.push(chunk)
|
|
928
|
+
const finish = assembler.finish
|
|
929
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
|
|
930
|
+
const reply = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
|
|
931
|
+
if (finish.kind !== 'max-tokens' && reply !== '') return reply
|
|
932
|
+
// A reply cut off at the limit — all of it thinking, or a half-written
|
|
933
|
+
// answer — is dropped, never used; the retry gives it room once.
|
|
934
|
+
if (finish.kind !== 'max-tokens' && maxTokens === SUMMARY_MAX_TOKENS) throw new Error('The model wrote nothing')
|
|
935
|
+
}
|
|
936
|
+
throw new Error(`The model's reasoning used up the output budget (${SUMMARY_MAX_TOKENS} and ${SUMMARY_RETRY_MAX_TOKENS} tokens); no complete answer was written`)
|
|
926
937
|
}
|
|
927
938
|
|
|
928
939
|
/** @type {Map<string, {hash:string, text:string, at:number}>} */
|
package/src/host/parts.js
CHANGED
|
@@ -18,7 +18,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
18
18
|
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
19
19
|
import { HISTORY_TIMEOUT_MS, REPLY_KIND, SOURCE_KIND } from './constants.js'
|
|
20
20
|
import { CHECKPOINT_PROMPT, STEWARD_SYSTEM, checkpointCount, checkpointTail, checkpointTailNote, checkpointText, linesFor, parseDecision, stewardRequest } from './steward.js'
|
|
21
|
-
import { clip, createClock, escapeRegExp, estimateTokens, textOf } from './text.js'
|
|
21
|
+
import { clip, createClock, escapeRegExp, estimateTokens, splitRef, textOf } from './text.js'
|
|
22
22
|
|
|
23
23
|
// An ask_user card as the user saw it. The user's reply is often just an option label,
|
|
24
24
|
// which means nothing without the card. Arguments the tool would refuse (no question,
|
|
@@ -136,13 +136,32 @@ export function install(rt) {
|
|
|
136
136
|
const NOTE_COOLDOWN_MS = [60_000, 300_000, 900_000]
|
|
137
137
|
const SWITCH_ATTEMPTS = 5
|
|
138
138
|
const OVERFLOW = 'CONTEXT_WINDOW_EXCEEDED'
|
|
139
|
+
// A failed connection says nothing about the conversation, so it never moves a part on.
|
|
140
|
+
const STUCK_CODES = ['INVALID_REQUEST', 'IMAGE_OFFLOAD_REQUIRED']
|
|
139
141
|
const ORIGINAL_COMPACT = Symbol.for('ds-bot/compaction')
|
|
140
142
|
const HANDOFF_ACK = 'Noted. I have my handoff note and carry on from here.'
|
|
141
143
|
const STEWARD_MAX_TOKENS = 4_096
|
|
142
144
|
const STEWARD_TIMEOUT_MS = 120_000
|
|
143
145
|
const STEWARD_LINES = 40
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
// A broken provider adapter must not crash every turn; estimate from the
|
|
148
|
+
// derived messages instead, with one warning.
|
|
149
|
+
let measureWarned = false
|
|
150
|
+
const tokensOf = (session) => {
|
|
151
|
+
try {
|
|
152
|
+
return ctx.get('tokenMeter')?.measure(session).totalTokens
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!measureWarned) {
|
|
155
|
+
measureWarned = true
|
|
156
|
+
rt.warn('ds-bot: token measure failed; context sizes are estimated: %s', error)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return estimateTokens(session.deriveMessages().map(message => textOf(message.content)).join('\n'))
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
}
|
|
146
165
|
const clock = createClock(config.timeZone)
|
|
147
166
|
|
|
148
167
|
// Lines for the model the Session last routed to; undefined while it has not
|
|
@@ -528,18 +547,22 @@ export function install(rt) {
|
|
|
528
547
|
// together without the model carries the last note the model wrote.
|
|
529
548
|
function noteText(segment) {
|
|
530
549
|
if (segment?.fallback === undefined) return segment?.note
|
|
531
|
-
const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window'
|
|
550
|
+
const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window'
|
|
551
|
+
: segment.fallback === 'failure' ? 'a request in the previous part kept failing'
|
|
552
|
+
: 'the model did not write a usable note'
|
|
532
553
|
const head = `(DS Bot put this note together without the model, because ${why}.`
|
|
533
554
|
return segment.note === undefined
|
|
534
555
|
? `${head} There was no earlier note to carry over. Go by the messages below, and look up anything else.)`
|
|
535
556
|
: `${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}`
|
|
536
557
|
}
|
|
537
558
|
|
|
538
|
-
function handoffPackage({ room, segment, lines, number, resume, overflow }) {
|
|
559
|
+
function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare }) {
|
|
539
560
|
const where = room ? `your conversation in the group chat "${room.name}"` : 'your own chat with the user'
|
|
540
561
|
const lookup = room
|
|
541
562
|
? 'read_group_chat shows the group\'s latest messages, and read_own_chat your own chat.'
|
|
542
563
|
: '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
|
+
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'
|
|
543
566
|
const { recent, earlier, unlisted, complete } = lines
|
|
544
567
|
const omitted = [
|
|
545
568
|
...(unlisted > 0 ? [`(${unlisted} older messages are not listed.)`] : []),
|
|
@@ -547,9 +570,10 @@ export function install(rt) {
|
|
|
547
570
|
]
|
|
548
571
|
return [
|
|
549
572
|
`[Handoff · part ${number}]`,
|
|
550
|
-
`DS Bot moved ${where} on to a fresh conversation, part ${number}, because
|
|
573
|
+
`DS Bot moved ${where} on to a fresh conversation, part ${number}, because ${reason}. This message is not from the user.`,
|
|
551
574
|
'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.',
|
|
552
575
|
`Everything earlier stays saved: ${lookup}`,
|
|
576
|
+
...(failed !== undefined ? ['Images from the previous part are not in this conversation; if one matters, ask the user to send it again.'] : []),
|
|
553
577
|
'',
|
|
554
578
|
'## Handoff note',
|
|
555
579
|
noteText(segment) ?? '(none)',
|
|
@@ -564,8 +588,12 @@ export function install(rt) {
|
|
|
564
588
|
recent.length > 0 ? recent.join('\n') : '(none)',
|
|
565
589
|
'',
|
|
566
590
|
!resume ? 'Every message above has had its answer. Nothing needs an answer now; wait for the next message.'
|
|
567
|
-
:
|
|
568
|
-
|
|
591
|
+
: bare ? `Your last turn could not finish because the model request kept failing, also in a fresh conversation with the latest messages carried over, so they are left out this time. First tell ${room ? 'the group' : 'the user'} in one short sentence that the last message could not be sent, and ask for it again.`
|
|
592
|
+
: failed !== undefined ? room
|
|
593
|
+
? 'Your last turn could not finish because the model request kept failing. Go on with it now from the messages above, and first tell the group in one short sentence that the last message could not be sent as it was and you moved on to a fresh conversation, and write your reply to the group.'
|
|
594
|
+
: 'Your last turn could not finish because the model request kept failing. Go on with it now from the messages above, and first tell the user in one short sentence that the last message could not be sent as it was and you moved on to a fresh conversation.'
|
|
595
|
+
: room ? 'You were in the middle of your turn in the group. Go on with it now and write your reply to the group.'
|
|
596
|
+
: 'You were in the middle of a turn. Go on with it now from where you stopped.',
|
|
569
597
|
].join('\n')
|
|
570
598
|
}
|
|
571
599
|
|
|
@@ -576,10 +604,15 @@ export function install(rt) {
|
|
|
576
604
|
exchangeId: job.info?.exchangeId ?? randomUUID(),
|
|
577
605
|
handoff: true,
|
|
578
606
|
...(resume ? { resume: true } : {}),
|
|
607
|
+
...(resume && job.joined?.length ? { joined: job.joined } : {}),
|
|
579
608
|
})
|
|
580
609
|
|
|
581
|
-
/** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, resume:boolean, overflow:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
|
|
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}>} */
|
|
582
611
|
const switches = new Map()
|
|
612
|
+
// Parts opened because requests kept failing, until one of their turns ends well:
|
|
613
|
+
// 'carried' brought the latest messages along, 'bare' left them out.
|
|
614
|
+
/** @type {Map<string, 'carried'|'bare'>} */
|
|
615
|
+
const rescued = new Map()
|
|
583
616
|
// Parts that have not started a turn yet, with their handoff package's size.
|
|
584
617
|
const freshParts = new Map()
|
|
585
618
|
// Context size where a part started, or where a checkpoint or a failed switch left
|
|
@@ -608,6 +641,7 @@ export function install(rt) {
|
|
|
608
641
|
async function forward(owner, messages) {
|
|
609
642
|
const target = currentOf(owner)
|
|
610
643
|
if (target === undefined || messages.length === 0) return
|
|
644
|
+
await rt.ensureCapabilities(target)
|
|
611
645
|
const agent = await rt.resolveAgent(target)
|
|
612
646
|
for (const message of messages) {
|
|
613
647
|
const copy = createUserMessage({ content: message.content, ...(message.source ? { source: message.source } : {}) })
|
|
@@ -620,10 +654,10 @@ export function install(rt) {
|
|
|
620
654
|
|
|
621
655
|
// `hard`: past the hard line or the window, the part moves on even without a note
|
|
622
656
|
// from the model.
|
|
623
|
-
function startSwitch(agent, { resume = false, carry = [], info, overflow = false, hard = overflow } = {}) {
|
|
657
|
+
function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, hard = overflow } = {}) {
|
|
624
658
|
const place = placeOf(agent.id)
|
|
625
659
|
if (place === undefined || switches.has(agent.id) || readOnly()) return
|
|
626
|
-
const job = { ...place, carry: [...carry], info, resume, overflow, hard }
|
|
660
|
+
const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, hard }
|
|
627
661
|
switches.set(agent.id, job)
|
|
628
662
|
void runSwitch(agent, job)
|
|
629
663
|
.catch(error => rt.warn('ds-bot: %s did not move on to a fresh part: %s', rt.botOf(job.botId)?.name ?? agent.id, error))
|
|
@@ -637,9 +671,10 @@ export function install(rt) {
|
|
|
637
671
|
// The stopped turn goes on where it was.
|
|
638
672
|
const back = job.carry.splice(0)
|
|
639
673
|
if (resume) {
|
|
674
|
+
const carried = job.joined?.length ? { joined: job.joined } : {}
|
|
640
675
|
back.unshift(createUserMessage({
|
|
641
676
|
content: [{ type: 'text', text: 'DS Bot paused your turn for a context check. Go on with it now from where you stopped.' }],
|
|
642
|
-
source: job.info
|
|
677
|
+
source: job.info ? { ...job.info, ...carried } : { kind: SOURCE_KIND, form: 'relay', role: 'resume', exchangeId: randomUUID(), senderName: 'DS Bot', hop: 0, ...carried },
|
|
643
678
|
}))
|
|
644
679
|
}
|
|
645
680
|
await forward(job, back).catch(error => rt.warn('ds-bot: input for %s was not handed on: %s', agent.id, error))
|
|
@@ -678,7 +713,7 @@ export function install(rt) {
|
|
|
678
713
|
const number = parts.length
|
|
679
714
|
let written
|
|
680
715
|
let failure
|
|
681
|
-
if (!job.overflow) {
|
|
716
|
+
if (!job.overflow && job.failed === undefined) {
|
|
682
717
|
try {
|
|
683
718
|
written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]))
|
|
684
719
|
} catch (error) {
|
|
@@ -699,15 +734,16 @@ export function install(rt) {
|
|
|
699
734
|
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)
|
|
700
735
|
const previous = parts.at(-2)
|
|
701
736
|
const noted = written !== undefined ? { note: written.note }
|
|
702
|
-
: { fallback: job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
|
|
737
|
+
: { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
|
|
703
738
|
const budget = Math.min(RECENT_TOKENS, Math.floor((lines?.check ?? 4 * RECENT_TOKENS) / 4))
|
|
704
|
-
const carried = await handoffLines(agent.id, { budget, group: room !== undefined, number })
|
|
739
|
+
const carried = job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : await handoffLines(agent.id, { budget, group: room !== undefined, number })
|
|
705
740
|
if (signal.aborted) throw signal.reason
|
|
706
741
|
const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name)
|
|
707
742
|
const route = session.requestHeader()?.config
|
|
708
|
-
const
|
|
709
|
-
if (
|
|
710
|
-
await rt.
|
|
743
|
+
const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
|
|
744
|
+
if (ref !== undefined) {
|
|
745
|
+
const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref)
|
|
746
|
+
await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) })
|
|
711
747
|
.catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error))
|
|
712
748
|
}
|
|
713
749
|
await rt.applyPermission(bot, freshId)
|
|
@@ -729,11 +765,13 @@ export function install(rt) {
|
|
|
729
765
|
}
|
|
730
766
|
}
|
|
731
767
|
await rt.save()
|
|
768
|
+
if (job.failed !== undefined) rescued.set(freshId, job.bare ? 'bare' : 'carried')
|
|
732
769
|
void rt.moveSchedules(agent.id, freshId)
|
|
733
770
|
job.moved = true
|
|
734
771
|
const resume = job.resume || job.overflow
|
|
735
|
-
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow })
|
|
772
|
+
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
|
|
736
773
|
freshParts.set(freshId, estimateTokens(text))
|
|
774
|
+
await rt.ensureCapabilities(freshId)
|
|
737
775
|
const fresh = await rt.resolveAgent(freshId)
|
|
738
776
|
fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
|
|
739
777
|
await ctx.sessions.flush(fresh.session)
|
|
@@ -781,10 +819,28 @@ export function install(rt) {
|
|
|
781
819
|
// `messages` left the inbox for this step, so the switch carries them along.
|
|
782
820
|
const entry = rt.liveOf(agent.id)
|
|
783
821
|
entry.switched = true
|
|
784
|
-
startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, hard: true })
|
|
822
|
+
startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, hard: true })
|
|
785
823
|
return { kind: 'reject' }
|
|
786
824
|
}, { prepend: true })
|
|
787
825
|
|
|
826
|
+
ctx.on('agent/request-error', async (payload, next) => {
|
|
827
|
+
const failure = payload.failure
|
|
828
|
+
if (!STUCK_CODES.includes(failure?.code)) return next()
|
|
829
|
+
const decided = await next()
|
|
830
|
+
if (decided !== undefined || payload.signal?.aborted) return decided
|
|
831
|
+
await rt.load()
|
|
832
|
+
const { agent } = payload
|
|
833
|
+
if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly() || rescued.get(agent.id) === 'bare') return decided
|
|
834
|
+
// A provider may turn down one request for reasons of its own; a history that
|
|
835
|
+
// breaks every request fails the next turn as well.
|
|
836
|
+
if (failure.code === 'INVALID_REQUEST' && !STUCK_CODES.includes(lastTurnEnd(agent)?.data?.reason?.error?.code)) return decided
|
|
837
|
+
const entry = rt.liveOf(agent.id)
|
|
838
|
+
entry.switched = true
|
|
839
|
+
// The latest messages carried into a rescued part may be what breaks the request.
|
|
840
|
+
startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true, failed: failure.code, bare: rescued.has(agent.id) })
|
|
841
|
+
return undefined
|
|
842
|
+
}, { prepend: true })
|
|
843
|
+
|
|
788
844
|
ctx.on('agent/request-error', async (payload, next) => {
|
|
789
845
|
if (payload.failure?.code !== OVERFLOW || payload.signal?.aborted) return next()
|
|
790
846
|
await rt.load()
|
|
@@ -792,7 +848,7 @@ export function install(rt) {
|
|
|
792
848
|
if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly()) return next()
|
|
793
849
|
const entry = rt.liveOf(agent.id)
|
|
794
850
|
entry.switched = true
|
|
795
|
-
startSwitch(agent, { resume: true, info: entry.info, overflow: true })
|
|
851
|
+
startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true })
|
|
796
852
|
return undefined
|
|
797
853
|
}, { prepend: true })
|
|
798
854
|
|
|
@@ -819,8 +875,25 @@ export function install(rt) {
|
|
|
819
875
|
}
|
|
820
876
|
}
|
|
821
877
|
|
|
878
|
+
// The newest turn/end among the Session's last events in memory.
|
|
879
|
+
function lastTurnEnd(agent) {
|
|
880
|
+
const latest = agent.session.seq - 1
|
|
881
|
+
for (let seq = latest; seq >= Math.max(0, latest - 199); seq -= 1) {
|
|
882
|
+
const event = agent.session.eventAt?.(seq)
|
|
883
|
+
if (event?.type === 'turn/end') return event
|
|
884
|
+
}
|
|
885
|
+
return undefined
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function releaseRescued(agent) {
|
|
889
|
+
if (!rescued.has(agent.id)) return
|
|
890
|
+
const end = lastTurnEnd(agent)
|
|
891
|
+
if (end !== undefined && end.data?.reason?.kind !== 'error') rescued.delete(agent.id)
|
|
892
|
+
}
|
|
893
|
+
|
|
822
894
|
ctx.on('agent/status', ({ agent, status }) => {
|
|
823
895
|
if (status === 'idle' && rt.isBotSession(agent.id)) {
|
|
896
|
+
releaseRescued(agent)
|
|
824
897
|
void checkContext(agent).catch(error => rt.warn('ds-bot: context check for %s failed: %s', agent.id, error))
|
|
825
898
|
}
|
|
826
899
|
})
|
|
@@ -864,7 +937,7 @@ export function install(rt) {
|
|
|
864
937
|
} else if (source?.kind === REPLY_KIND) {
|
|
865
938
|
const reply = readReply(textOf(event.data.content))
|
|
866
939
|
if (reply !== undefined) items.push({ kind: 'answer', questions: reply.questions, answers: reply.answers, time: event.time })
|
|
867
|
-
} else if (source?.kind === SOURCE_KIND && source.handoff !== true && ['request', 'reply', 'report'].includes(source.role)) {
|
|
940
|
+
} else if (source?.kind === SOURCE_KIND && source.handoff !== true && source.deferred !== true && ['request', 'reply', 'report', 'stopped'].includes(source.role)) {
|
|
868
941
|
items.push({ kind: 'event', role: source.role, botId: source.senderSessionId ?? null, name: source.senderName ?? null, roomId: source.roomId ?? null, time: event.time })
|
|
869
942
|
}
|
|
870
943
|
}
|
package/src/host/prompt.js
CHANGED
|
@@ -5,14 +5,17 @@ const TEAM_RULES = [
|
|
|
5
5
|
'## How your Bot team works',
|
|
6
6
|
'- Every Bot is its own long-lived conversation with the user, with its own memory and computer.',
|
|
7
7
|
'- The team changes over time. Call list_bots whenever you need to know who exists; do not rely on memory.',
|
|
8
|
-
'- message_bot sends
|
|
8
|
+
'- message_bot sends a message to another Bot. If it is working, it reads your message at its next step, so you can also use message_bot to warn it off a mistake or redirect it in time; it answers once that work is done. Pass when "queue" only for a new task unrelated to what it is doing now; it then waits until its current work is done. Its reply arrives later as a new "[Reply from …]" message. Never wait, poll, or resend.',
|
|
9
9
|
'- When the user wants other Bots to tell them something themselves (introduce themselves, report in their own chats), call message_bot with reply_to "user": each Bot then answers the user in its own chat and nothing comes back to you. Do not relay those answers yourself.',
|
|
10
|
-
'- When a "[Message from …]"
|
|
10
|
+
'- When a "[Message from …]" opens a turn, your final text in that turn is delivered back to that Bot automatically and the user does not see it. If one arrives while you are working on something else, take it into account in that work but do not answer it there: once the work is done, it comes back to you in a turn of its own to answer. Answer it directly and briefly, as a list of facts. Never invent facts: say "not discussed yet" when you do not know. If the message brings a request or a decision meant for the user, handle it in the same turn too — do the work, or ask the user with ask_user.',
|
|
11
11
|
'- When a "[Message from … · the user sees your reply here]" arrives, your reply is shown to the user in your own chat and is not sent back. Talk to the user directly, as yourself.',
|
|
12
12
|
'- When a "[Reply from …]" arrives, tell the user the outcome if it matters to them.',
|
|
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
|
+
'- 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
|
+
'- 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.',
|
|
14
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.',
|
|
15
|
-
'-
|
|
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. 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.',
|
|
16
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.',
|
|
17
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.',
|
|
18
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.',
|
|
@@ -25,6 +28,7 @@ const MAIN_RULES = [
|
|
|
25
28
|
'- When the user wants something that deserves its own ongoing specialist, call create_bot (check list_bots first to avoid duplicates). Give it a short name, a two-to-four character role label, and concrete instructions.',
|
|
26
29
|
'- After creating a Bot, tell the user in one line that it is ready, using its exact name.',
|
|
27
30
|
'- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
|
|
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.',
|
|
28
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.',
|
|
29
33
|
'- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
|
|
30
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.',
|
|
@@ -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
|
+
}
|