ds-bot 0.1.1 → 0.1.3
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 -5
- package/client.js +1311 -466
- package/index.js +3 -1
- package/package.json +6 -7
- package/src/host/approvals.js +63 -0
- package/src/host/channel.js +14 -3
- package/src/host/constants.js +4 -1
- package/src/host/delivery.js +19 -7
- package/src/host/diagnostics.js +7 -5
- package/src/host/dsh-update.js +8 -6
- package/src/host/groups.js +195 -47
- 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 +92 -21
- package/src/host/prompt.js +6 -2
- package/src/host/roster.js +109 -56
- package/src/host/store.js +113 -28
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +79 -28
- package/src/host/turns.js +176 -38
- package/src/host/update.js +31 -4
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
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
|
|
@@ -620,10 +653,10 @@ export function install(rt) {
|
|
|
620
653
|
|
|
621
654
|
// `hard`: past the hard line or the window, the part moves on even without a note
|
|
622
655
|
// from the model.
|
|
623
|
-
function startSwitch(agent, { resume = false, carry = [], info, overflow = false, hard = overflow } = {}) {
|
|
656
|
+
function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, hard = overflow } = {}) {
|
|
624
657
|
const place = placeOf(agent.id)
|
|
625
658
|
if (place === undefined || switches.has(agent.id) || readOnly()) return
|
|
626
|
-
const job = { ...place, carry: [...carry], info, resume, overflow, hard }
|
|
659
|
+
const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, hard }
|
|
627
660
|
switches.set(agent.id, job)
|
|
628
661
|
void runSwitch(agent, job)
|
|
629
662
|
.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 +670,10 @@ export function install(rt) {
|
|
|
637
670
|
// The stopped turn goes on where it was.
|
|
638
671
|
const back = job.carry.splice(0)
|
|
639
672
|
if (resume) {
|
|
673
|
+
const carried = job.joined?.length ? { joined: job.joined } : {}
|
|
640
674
|
back.unshift(createUserMessage({
|
|
641
675
|
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
|
|
676
|
+
source: job.info ? { ...job.info, ...carried } : { kind: SOURCE_KIND, form: 'relay', role: 'resume', exchangeId: randomUUID(), senderName: 'DS Bot', hop: 0, ...carried },
|
|
643
677
|
}))
|
|
644
678
|
}
|
|
645
679
|
await forward(job, back).catch(error => rt.warn('ds-bot: input for %s was not handed on: %s', agent.id, error))
|
|
@@ -678,7 +712,7 @@ export function install(rt) {
|
|
|
678
712
|
const number = parts.length
|
|
679
713
|
let written
|
|
680
714
|
let failure
|
|
681
|
-
if (!job.overflow) {
|
|
715
|
+
if (!job.overflow && job.failed === undefined) {
|
|
682
716
|
try {
|
|
683
717
|
written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]))
|
|
684
718
|
} catch (error) {
|
|
@@ -699,15 +733,16 @@ export function install(rt) {
|
|
|
699
733
|
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
734
|
const previous = parts.at(-2)
|
|
701
735
|
const noted = written !== undefined ? { note: written.note }
|
|
702
|
-
: { fallback: job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
|
|
736
|
+
: { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
|
|
703
737
|
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 })
|
|
738
|
+
const carried = job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : await handoffLines(agent.id, { budget, group: room !== undefined, number })
|
|
705
739
|
if (signal.aborted) throw signal.reason
|
|
706
740
|
const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name)
|
|
707
741
|
const route = session.requestHeader()?.config
|
|
708
|
-
const
|
|
709
|
-
if (
|
|
710
|
-
await rt.
|
|
742
|
+
const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
|
|
743
|
+
if (ref !== undefined) {
|
|
744
|
+
const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref)
|
|
745
|
+
await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) })
|
|
711
746
|
.catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error))
|
|
712
747
|
}
|
|
713
748
|
await rt.applyPermission(bot, freshId)
|
|
@@ -729,10 +764,11 @@ export function install(rt) {
|
|
|
729
764
|
}
|
|
730
765
|
}
|
|
731
766
|
await rt.save()
|
|
767
|
+
if (job.failed !== undefined) rescued.set(freshId, job.bare ? 'bare' : 'carried')
|
|
732
768
|
void rt.moveSchedules(agent.id, freshId)
|
|
733
769
|
job.moved = true
|
|
734
770
|
const resume = job.resume || job.overflow
|
|
735
|
-
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow })
|
|
771
|
+
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
|
|
736
772
|
freshParts.set(freshId, estimateTokens(text))
|
|
737
773
|
const fresh = await rt.resolveAgent(freshId)
|
|
738
774
|
fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
|
|
@@ -781,10 +817,28 @@ export function install(rt) {
|
|
|
781
817
|
// `messages` left the inbox for this step, so the switch carries them along.
|
|
782
818
|
const entry = rt.liveOf(agent.id)
|
|
783
819
|
entry.switched = true
|
|
784
|
-
startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, hard: true })
|
|
820
|
+
startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, hard: true })
|
|
785
821
|
return { kind: 'reject' }
|
|
786
822
|
}, { prepend: true })
|
|
787
823
|
|
|
824
|
+
ctx.on('agent/request-error', async (payload, next) => {
|
|
825
|
+
const failure = payload.failure
|
|
826
|
+
if (!STUCK_CODES.includes(failure?.code)) return next()
|
|
827
|
+
const decided = await next()
|
|
828
|
+
if (decided !== undefined || payload.signal?.aborted) return decided
|
|
829
|
+
await rt.load()
|
|
830
|
+
const { agent } = payload
|
|
831
|
+
if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly() || rescued.get(agent.id) === 'bare') return decided
|
|
832
|
+
// A provider may turn down one request for reasons of its own; a history that
|
|
833
|
+
// breaks every request fails the next turn as well.
|
|
834
|
+
if (failure.code === 'INVALID_REQUEST' && !STUCK_CODES.includes(lastTurnEnd(agent)?.data?.reason?.error?.code)) return decided
|
|
835
|
+
const entry = rt.liveOf(agent.id)
|
|
836
|
+
entry.switched = true
|
|
837
|
+
// The latest messages carried into a rescued part may be what breaks the request.
|
|
838
|
+
startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true, failed: failure.code, bare: rescued.has(agent.id) })
|
|
839
|
+
return undefined
|
|
840
|
+
}, { prepend: true })
|
|
841
|
+
|
|
788
842
|
ctx.on('agent/request-error', async (payload, next) => {
|
|
789
843
|
if (payload.failure?.code !== OVERFLOW || payload.signal?.aborted) return next()
|
|
790
844
|
await rt.load()
|
|
@@ -792,7 +846,7 @@ export function install(rt) {
|
|
|
792
846
|
if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly()) return next()
|
|
793
847
|
const entry = rt.liveOf(agent.id)
|
|
794
848
|
entry.switched = true
|
|
795
|
-
startSwitch(agent, { resume: true, info: entry.info, overflow: true })
|
|
849
|
+
startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true })
|
|
796
850
|
return undefined
|
|
797
851
|
}, { prepend: true })
|
|
798
852
|
|
|
@@ -819,8 +873,25 @@ export function install(rt) {
|
|
|
819
873
|
}
|
|
820
874
|
}
|
|
821
875
|
|
|
876
|
+
// The newest turn/end among the Session's last events in memory.
|
|
877
|
+
function lastTurnEnd(agent) {
|
|
878
|
+
const latest = agent.session.seq - 1
|
|
879
|
+
for (let seq = latest; seq >= Math.max(0, latest - 199); seq -= 1) {
|
|
880
|
+
const event = agent.session.eventAt?.(seq)
|
|
881
|
+
if (event?.type === 'turn/end') return event
|
|
882
|
+
}
|
|
883
|
+
return undefined
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function releaseRescued(agent) {
|
|
887
|
+
if (!rescued.has(agent.id)) return
|
|
888
|
+
const end = lastTurnEnd(agent)
|
|
889
|
+
if (end !== undefined && end.data?.reason?.kind !== 'error') rescued.delete(agent.id)
|
|
890
|
+
}
|
|
891
|
+
|
|
822
892
|
ctx.on('agent/status', ({ agent, status }) => {
|
|
823
893
|
if (status === 'idle' && rt.isBotSession(agent.id)) {
|
|
894
|
+
releaseRescued(agent)
|
|
824
895
|
void checkContext(agent).catch(error => rt.warn('ds-bot: context check for %s failed: %s', agent.id, error))
|
|
825
896
|
}
|
|
826
897
|
})
|
|
@@ -864,7 +935,7 @@ export function install(rt) {
|
|
|
864
935
|
} else if (source?.kind === REPLY_KIND) {
|
|
865
936
|
const reply = readReply(textOf(event.data.content))
|
|
866
937
|
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)) {
|
|
938
|
+
} else if (source?.kind === SOURCE_KIND && source.handoff !== true && source.deferred !== true && ['request', 'reply', 'report', 'stopped'].includes(source.role)) {
|
|
868
939
|
items.push({ kind: 'event', role: source.role, botId: source.senderSessionId ?? null, name: source.senderName ?? null, roomId: source.roomId ?? null, time: event.time })
|
|
869
940
|
}
|
|
870
941
|
}
|
package/src/host/prompt.js
CHANGED
|
@@ -5,13 +5,16 @@ 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.',
|
|
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.',
|
|
15
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
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.',
|
|
@@ -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.',
|
package/src/host/roster.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdir } from 'node:fs/promises'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
|
|
6
6
|
import { currentColor, isColor, unquote } from './text.js'
|
|
7
|
+
import { nameProblem } from '../shared/mentions.js'
|
|
7
8
|
|
|
8
9
|
export function install(rt) {
|
|
9
10
|
const { ctx, config, home, defaults, state } = rt
|
|
@@ -56,52 +57,66 @@ export function install(rt) {
|
|
|
56
57
|
// greeting waits until a model is chosen. So does a Bot made when no model is served.
|
|
57
58
|
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
|
|
58
59
|
const cleanName = String(botName ?? '').trim().slice(0, 40)
|
|
59
|
-
|
|
60
|
+
const problem = nameProblem(cleanName)
|
|
61
|
+
if (problem !== undefined) throw new Error(problem)
|
|
60
62
|
if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
|
|
61
63
|
const cleanAvatarValue = cleanAvatar(avatar)
|
|
62
64
|
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
|
|
63
65
|
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
|
|
64
66
|
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
|
|
65
67
|
const id = await createSession(cleanName)
|
|
66
|
-
|
|
67
|
-
id
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
68
|
+
try {
|
|
69
|
+
state.bots[id] = {
|
|
70
|
+
id,
|
|
71
|
+
name: cleanName,
|
|
72
|
+
role: String(role).trim().slice(0, 24),
|
|
73
|
+
// A color is only stored when the user picks one; otherwise the id picks it.
|
|
74
|
+
...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
|
|
75
|
+
...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
|
|
76
|
+
...(modelRef ? { model: modelRef } : {}),
|
|
77
|
+
...(effortId ? { effort: effortId } : {}),
|
|
78
|
+
instructions: String(instructions).trim(),
|
|
79
|
+
createdAt: Date.now(),
|
|
80
|
+
createdBy: createdBy ?? null,
|
|
81
|
+
}
|
|
82
|
+
if (main) state.mainBotIds.push(id)
|
|
83
|
+
const creator = rt.botOf(createdBy)
|
|
84
|
+
const original = rt.botOf(copyOf)
|
|
85
|
+
const language = rt.uiLanguage?.()
|
|
86
|
+
const kickoff = main
|
|
87
|
+
? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
|
|
88
|
+
: original
|
|
89
|
+
? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
|
|
90
|
+
: creator
|
|
91
|
+
? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
|
|
92
|
+
: brief
|
|
93
|
+
? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
|
|
94
|
+
: 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
|
|
95
|
+
const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
|
|
96
|
+
const bot = state.bots[id]
|
|
97
|
+
if (modelRef === undefined) {
|
|
98
|
+
bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
|
|
99
|
+
await rt.save()
|
|
100
|
+
return bot
|
|
101
|
+
}
|
|
96
102
|
await rt.save()
|
|
103
|
+
// The greeting runs on the Bot's own model, once its provider is registered.
|
|
104
|
+
void rt.waitForModel(bot)
|
|
105
|
+
.then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
|
|
106
|
+
.then(() => sendKickoff(bot, { text, from: createdBy }))
|
|
97
107
|
return bot
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// The Bot's Session was already made: archive it instead of leaving an orphan.
|
|
110
|
+
delete state.bots[id]
|
|
111
|
+
state.mainBotIds = state.mainBotIds.filter(other => other !== id)
|
|
112
|
+
rt.chatOwners.delete(id)
|
|
113
|
+
// A save that another change queued meanwhile still carries this Bot; a later
|
|
114
|
+
// one writes the team without it.
|
|
115
|
+
void rt.save().catch(() => {})
|
|
116
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
117
|
+
.catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', cleanName, archiveError))
|
|
118
|
+
throw error
|
|
98
119
|
}
|
|
99
|
-
await rt.save()
|
|
100
|
-
// The greeting runs on the Bot's own model, once its provider is registered.
|
|
101
|
-
void rt.waitForModel(bot)
|
|
102
|
-
.then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
|
|
103
|
-
.then(() => sendKickoff(bot, { text, from: createdBy }))
|
|
104
|
-
return bot
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
function sendKickoff(bot, { text, from }) {
|
|
@@ -114,7 +129,7 @@ export function install(rt) {
|
|
|
114
129
|
const bot = rt.botOf(botId)
|
|
115
130
|
if (bot?.kickoff === undefined) return
|
|
116
131
|
delete bot.kickoff
|
|
117
|
-
void rt.save()
|
|
132
|
+
void rt.save().catch(() => {})
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
async function duplicateBot(id) {
|
|
@@ -140,6 +155,9 @@ export function install(rt) {
|
|
|
140
155
|
const next = String(patch.name).trim().slice(0, 40)
|
|
141
156
|
const clash = rt.findBot(next)
|
|
142
157
|
if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`)
|
|
158
|
+
// A name saved before the rule stays usable until it changes.
|
|
159
|
+
const problem = next === bot.name ? undefined : nameProblem(next)
|
|
160
|
+
if (problem !== undefined) throw new Error(problem)
|
|
143
161
|
bot.name = next
|
|
144
162
|
const agent = await rt.resolveAgent(rt.chatOf(id))
|
|
145
163
|
await ctx.sessionTitle.rename(agent.session, next)
|
|
@@ -242,19 +260,28 @@ export function install(rt) {
|
|
|
242
260
|
if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
243
261
|
const title = custom || autoRoomName(members)
|
|
244
262
|
const id = await createSession(title)
|
|
245
|
-
|
|
246
|
-
id
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
263
|
+
try {
|
|
264
|
+
state.rooms[id] = {
|
|
265
|
+
id,
|
|
266
|
+
name: title,
|
|
267
|
+
...(custom ? { named: true } : {}),
|
|
268
|
+
members,
|
|
269
|
+
admin: adminId,
|
|
270
|
+
mode: mode ?? 'admin',
|
|
271
|
+
notice: cleanNotice(notice),
|
|
272
|
+
createdAt: Date.now(),
|
|
273
|
+
createdBy: createdBy ?? null,
|
|
274
|
+
}
|
|
275
|
+
await rt.save()
|
|
276
|
+
return state.rooms[id]
|
|
277
|
+
} catch (error) {
|
|
278
|
+
// The group's Session was already made: archive it instead of leaving an orphan.
|
|
279
|
+
delete state.rooms[id]
|
|
280
|
+
void rt.save().catch(() => {})
|
|
281
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
282
|
+
.catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
|
|
283
|
+
throw error
|
|
255
284
|
}
|
|
256
|
-
await rt.save()
|
|
257
|
-
return state.rooms[id]
|
|
258
285
|
}
|
|
259
286
|
|
|
260
287
|
// `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
|
|
@@ -328,8 +355,12 @@ export function install(rt) {
|
|
|
328
355
|
for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId)
|
|
329
356
|
delete state.rooms[id]
|
|
330
357
|
rt.roomInbox.delete(id)
|
|
331
|
-
await
|
|
332
|
-
|
|
358
|
+
const failed = await saveForDelete()
|
|
359
|
+
// Armed scheduled tasks refuse a plain archive and would keep delivering into
|
|
360
|
+
// the deleted group; the session-stop that stopActivity runs removes them.
|
|
361
|
+
await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
|
|
362
|
+
.catch(error => rt.warn('ds-bot: group chat %s not archived: %s', room.name, error))
|
|
363
|
+
if (failed !== undefined) throw failed
|
|
333
364
|
}
|
|
334
365
|
|
|
335
366
|
// The first Main Bot is the sidebar tile. `replace` gives a Main Bot's place to
|
|
@@ -364,13 +395,14 @@ export function install(rt) {
|
|
|
364
395
|
}
|
|
365
396
|
|
|
366
397
|
async function deleteBot(id) {
|
|
367
|
-
|
|
398
|
+
const bot = rt.botOf(id)
|
|
399
|
+
if (bot === undefined) throw new Error('Unknown Bot')
|
|
368
400
|
if (rt.isMain(id)) throw new Error('A Main Bot cannot be deleted; remove its Main Bot role first')
|
|
369
401
|
// Before the Bot and its group Sessions leave the state, which the archive reads.
|
|
370
402
|
const archived = rt.archiveBotMemory(id)
|
|
371
403
|
const current = rt.chatOf(id)
|
|
372
404
|
// Questions and exchange turns are kept per Session, so every part of the chat is cleared.
|
|
373
|
-
const parts = new Set([id, current, ...(
|
|
405
|
+
const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)])
|
|
374
406
|
delete state.bots[id]
|
|
375
407
|
for (const part of parts) {
|
|
376
408
|
delete state.questions[part]
|
|
@@ -387,8 +419,29 @@ export function install(rt) {
|
|
|
387
419
|
await followMemberNames(touched)
|
|
388
420
|
await rt.forgetBotSecrets(id)
|
|
389
421
|
await archived
|
|
390
|
-
await
|
|
391
|
-
|
|
422
|
+
const failed = await saveForDelete()
|
|
423
|
+
// Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks.
|
|
424
|
+
// Past parts are archived too: a part move can leave tasks on the old Session,
|
|
425
|
+
// which otherwise stays armed and invisible. Already-archived ids are a no-op.
|
|
426
|
+
for (const part of parts) {
|
|
427
|
+
await ctx.workspaceRegistry.archiveSession(part, { stopActivity: true })
|
|
428
|
+
.catch(error => rt.warn('ds-bot: Bot %s not archived: %s', bot.name, error))
|
|
429
|
+
}
|
|
430
|
+
if (failed !== undefined) throw failed
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// A deletion whose save fails stays in memory, and the next save writes it, so its
|
|
434
|
+
// Sessions are stopped anyway: left armed, their scheduled tasks would keep running
|
|
435
|
+
// with nothing in the team to show or delete them. Only a host that lost the team
|
|
436
|
+
// to another dsh keeps them, since the other host's copy still has them.
|
|
437
|
+
async function saveForDelete() {
|
|
438
|
+
try {
|
|
439
|
+
await rt.save()
|
|
440
|
+
return undefined
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error?.code === 'ELOCKLOST' || rt.readOnly() !== undefined) throw error
|
|
443
|
+
return error
|
|
444
|
+
}
|
|
392
445
|
}
|
|
393
446
|
|
|
394
447
|
async function bootstrap() {
|