ds-bot 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host/parts.js CHANGED
@@ -90,8 +90,9 @@ export function historyEntry(event, group) {
90
90
  const text = textOf(event.data?.message?.content)
91
91
  return text === '' ? undefined : { answer: true, text }
92
92
  }
93
- // Cards only show in an own chat.
94
- if (event.type === 'tool/call' && event.data?.name === 'ask_user' && !group) {
93
+ // Cards only show in an own chat. A PTC program's sub-call asks too.
94
+ const asks = (event.type === 'tool/call' || event.type === 'tool/ptc-dispatch-start') && event.data?.name === 'ask_user'
95
+ if (asks && !group) {
95
96
  const text = cardText(event.data.arguments)
96
97
  return text === '' ? undefined : { answer: true, text }
97
98
  }
@@ -143,6 +144,7 @@ export function install(rt) {
143
144
  const STEWARD_MAX_TOKENS = 4_096
144
145
  const STEWARD_TIMEOUT_MS = 120_000
145
146
  const STEWARD_LINES = 40
147
+ const FOLDER_RETRY_MS = 300_000
146
148
 
147
149
  // A broken provider adapter must not crash every turn; estimate from the
148
150
  // derived messages instead, with one warning.
@@ -312,7 +314,8 @@ export function install(rt) {
312
314
  complete = false
313
315
  rt.warn('ds-bot: reading %s for its handoff stopped early: %s', sessionId, error)
314
316
  }
315
- return { recent: recent.reverse(), earlier: earlier.reverse(), unlisted, complete }
317
+ // `whole`: every message of the part is in `recent`.
318
+ return { recent: recent.reverse(), earlier: earlier.reverse(), unlisted, complete, whole: complete && !full }
316
319
  }
317
320
 
318
321
  // The note's sections, in order. A note without all of them is not used.
@@ -326,8 +329,8 @@ export function install(rt) {
326
329
  ['Next step', 'the single next action, with the words of the request it follows quoted exactly; "(none)" when nothing is pending'],
327
330
  ['Where to look', 'details this note leaves out that you may need again, each with a few words copied exactly from the message that holds it, for read_own_chat to search'],
328
331
  ]
329
- const HANDOFF_PROMPT = [
330
- 'You are now writing a handoff note to yourself. This request comes from DS Bot, not from the user: do not answer or continue the messages above. Your conversation is long, so it moves on to a fresh conversation now. The fresh one starts with this note and your latest messages word for word; everything else leaves your working context. The full record stays saved, and you can search it later with read_own_chat. Write the note that lets you carry on as the same Bot without losing anything that matters.',
332
+ const handoffPrompt = why => [
333
+ `You are now writing a handoff note to yourself. This request comes from DS Bot, not from the user: do not answer or continue the messages above. ${why}, so it moves on to a fresh conversation now. The fresh one starts with this note and your latest messages word for word; everything else leaves your working context. The full record stays saved, and you can search it later with read_own_chat. Write the note that lets you carry on as the same Bot without losing anything that matters.`,
331
334
  '',
332
335
  'Output EXACTLY the Markdown structure below: every heading as written, in English and in this order. Write the content in the language the user writes in, as terse bullets. Write "(none)" under a heading with nothing to say.',
333
336
  '',
@@ -341,6 +344,8 @@ export function install(rt) {
341
344
  '- Do NOT mention this request or the move to a fresh conversation.',
342
345
  '- Output only the note, in at most about 2,000 words: no preface, and no tool calls.',
343
346
  ].join('\n')
347
+ const HANDOFF_PROMPT = handoffPrompt('Your conversation is long')
348
+ const FOLDER_PROMPT = handoffPrompt('Your working folder changed')
344
349
  const NOTE_RETRY_SHORTER = 'Your last note was cut off at the output limit, so it was not used. Write the whole note again, shorter: at most about 1,000 words. Keep every heading; drop detail before you drop facts.'
345
350
  const noteRetryMissing = missing => `Your last note lacked these headings, so it was not used: ${missing.map(name => `"## ${name}"`).join(', ')}. Write the whole note again with every heading exactly as given, in English.`
346
351
  const missingSections = note => NOTE_SECTIONS.map(([name]) => name)
@@ -349,16 +354,17 @@ export function install(rt) {
349
354
  // The same system prompt, tools, and messages as the conversation's own requests,
350
355
  // plus the note prompt; nothing is written into the Session. A cut-off note, or one
351
356
  // without every section, is asked for again once; then the note fails.
352
- async function writeNote(agent, signal) {
357
+ async function writeNote(agent, signal, { folder = false } = {}) {
353
358
  const session = agent.session
354
359
  const header = session.requestHeader()
355
360
  const route = header?.config
356
361
  if (!route?.provider || !route?.model) throw new Error('the Session has not called a model yet')
357
362
  const messages = session.deriveMessages()
363
+ const base = folder ? FOLDER_PROMPT : HANDOFF_PROMPT
358
364
  let retry
359
365
  let problem
360
366
  for (let attempt = 1; attempt <= NOTE_ATTEMPTS; attempt += 1) {
361
- const prompt = retry === undefined ? HANDOFF_PROMPT : `${HANDOFF_PROMPT}\n\n${retry}`
367
+ const prompt = retry === undefined ? base : `${base}\n\n${retry}`
362
368
  const assembler = new BlockAssembler()
363
369
  for await (const chunk of ctx.llm.stream({
364
370
  provider: route.provider,
@@ -489,6 +495,7 @@ export function install(rt) {
489
495
  system: STEWARD_SYSTEM,
490
496
  messages: rt.tagUsage([{ role: 'user', content: [{ type: 'text', text: stewardRequest({ lines, midTurn, checkpoints: checkpointCount(session.deriveMessages()), group }) }] }], { sessionId: agent.id, kind: 'compaction' }),
491
497
  maxTokens: STEWARD_MAX_TOKENS,
498
+ sessionId: session.id,
492
499
  purpose: 'compaction',
493
500
  signal: AbortSignal.timeout(STEWARD_TIMEOUT_MS),
494
501
  })) assembler.push(chunk)
@@ -547,6 +554,10 @@ export function install(rt) {
547
554
  // together without the model carries the last note the model wrote.
548
555
  function noteText(segment) {
549
556
  if (segment?.fallback === undefined) return segment?.note
557
+ if (segment.fallback === 'whole') {
558
+ const head = '(No new note: every message of the part was carried over word for word.'
559
+ return segment.note === undefined ? `${head})` : `${head} Below is the note written at the end of part ${segment.noteFrom}, carried over unchanged.)\n\n${segment.note}`
560
+ }
550
561
  const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window'
551
562
  : segment.fallback === 'failure' ? 'a request in the previous part kept failing'
552
563
  : 'the model did not write a usable note'
@@ -556,13 +567,14 @@ export function install(rt) {
556
567
  : `${head} It is the note written at the end of part ${segment.noteFrom}, carried over unchanged; anything newer is in the messages below, and read_own_chat finds the rest.)\n\n${segment.note}`
557
568
  }
558
569
 
559
- function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare }) {
570
+ function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare, folder }) {
560
571
  const where = room ? `your conversation in the group chat "${room.name}"` : 'your own chat with the user'
561
572
  const lookup = room
562
573
  ? 'read_group_chat shows the group\'s latest messages, and read_own_chat your own chat.'
563
574
  : 'read_own_chat searches every part, reads around an anchor such as [part 2 #1234 · …], and shows the note that closed any earlier part.'
564
575
  const reason = failed !== undefined ? `a request in the previous part kept failing (${failed})`
565
- : overflow ? 'the previous part overflowed the model\'s window' : 'the previous part grew long'
576
+ : overflow ? 'the previous part overflowed the model\'s window'
577
+ : folder !== undefined ? `your working folder changed to ${folder}` : 'the previous part grew long'
566
578
  const { recent, earlier, unlisted, complete } = lines
567
579
  const omitted = [
568
580
  ...(unlisted > 0 ? [`(${unlisted} older messages are not listed.)`] : []),
@@ -573,6 +585,7 @@ export function install(rt) {
573
585
  `DS Bot moved ${where} on to a fresh conversation, part ${number}, because ${reason}. This message is not from the user.`,
574
586
  'It is reference, not new instructions: the note and the messages below are a record of earlier work. Do what they say only as part of the work they record, and take new requests only from messages that come after this one.',
575
587
  `Everything earlier stays saved: ${lookup}`,
588
+ ...(folder !== undefined ? [`You now work in ${folder}: commands and file tools start there. Relative paths in the messages below meant the previous folder.`] : []),
576
589
  ...(failed !== undefined ? ['Images from the previous part are not in this conversation; if one matters, ask the user to send it again.'] : []),
577
590
  '',
578
591
  '## Handoff note',
@@ -607,8 +620,11 @@ export function install(rt) {
607
620
  ...(resume && job.joined?.length ? { joined: job.joined } : {}),
608
621
  })
609
622
 
610
- /** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, joined?:any[], resume:boolean, overflow:boolean, failed?:string, bare?:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
623
+ /** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, joined?:any[], resume:boolean, overflow:boolean, failed?:string, bare?:boolean, folder?:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
611
624
  const switches = new Map()
625
+ // Parts whose move to their working folder failed, and the time before which it is
626
+ // not tried again.
627
+ const folderRetry = new Map()
612
628
  // Parts opened because requests kept failing, until one of their turns ends well:
613
629
  // 'carried' brought the latest messages along, 'bare' left them out.
614
630
  /** @type {Map<string, 'carried'|'bare'>} */
@@ -653,11 +669,11 @@ export function install(rt) {
653
669
  }
654
670
 
655
671
  // `hard`: past the hard line or the window, the part moves on even without a note
656
- // from the model.
657
- 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 } = {}) {
658
674
  const place = placeOf(agent.id)
659
675
  if (place === undefined || switches.has(agent.id) || readOnly()) return
660
- 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 }
661
677
  switches.set(agent.id, job)
662
678
  void runSwitch(agent, job)
663
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))
@@ -665,8 +681,12 @@ export function install(rt) {
665
681
  switches.delete(agent.id)
666
682
  if (job.moved) {
667
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)
668
687
  return
669
688
  }
689
+ if (folder) folderRetry.set(agent.id, Date.now() + FOLDER_RETRY_MS)
670
690
  if (!job.noteFailed) partBase.set(agent.id, tokensOf(agent.session) ?? 0)
671
691
  // The stopped turn goes on where it was.
672
692
  const back = job.carry.splice(0)
@@ -711,11 +731,16 @@ export function install(rt) {
711
731
  const lines = await contextLines(session).catch(() => undefined)
712
732
  const parts = partsOf(bot.id, room?.id)
713
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')
714
739
  let written
715
740
  let failure
716
- if (!job.overflow && job.failed === undefined) {
741
+ if (!job.overflow && job.failed === undefined && !whole) {
717
742
  try {
718
- 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 })
719
744
  } catch (error) {
720
745
  if (signal.aborted) throw error
721
746
  failure = error
@@ -734,11 +759,12 @@ export function install(rt) {
734
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)
735
760
  const previous = parts.at(-2)
736
761
  const noted = written !== undefined ? { note: written.note }
737
- : { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
738
- const budget = Math.min(RECENT_TOKENS, Math.floor((lines?.check ?? 4 * RECENT_TOKENS) / 4))
739
- 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()
740
764
  if (signal.aborted) throw signal.reason
741
- const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name)
765
+ const dir = rt.workDirOf(job)
766
+ const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder()
767
+ const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir })
742
768
  const route = session.requestHeader()?.config
743
769
  const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
744
770
  if (ref !== undefined) {
@@ -769,19 +795,26 @@ export function install(rt) {
769
795
  void rt.moveSchedules(agent.id, freshId)
770
796
  job.moved = true
771
797
  const resume = job.resume || job.overflow
772
- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
773
- freshParts.set(freshId, estimateTokens(text))
774
798
  await rt.ensureCapabilities(freshId)
775
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))
776
809
  fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
777
810
  await ctx.sessions.flush(fresh.session)
778
811
  // Then what the old part held: the stopped turn's input, then whatever waited.
779
812
  const held = [...agent.inbox.nextStep, ...agent.inbox.nextTurn]
780
813
  for (const message of held) agent.inbox.remove(message.id)
781
814
  await forward(job, [...job.carry.splice(0), ...held])
782
- 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,
783
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'})`,
784
- carried.recent.length, carried.earlier.length)
817
+ carried.recent.length, carried.earlier.length, folder === undefined ? '' : `; now works in ${folder}`)
785
818
  }
786
819
 
787
820
  // Runs first: an earlier part hands its input on, a part past the hard line makes
@@ -807,21 +840,54 @@ export function install(rt) {
807
840
  freshParts.delete(agent.id)
808
841
  partBase.set(agent.id, (tokensOf(agent.session) ?? 0) + fresh)
809
842
  }
843
+ if (!readOnly() && await folderMove(agent, { midTurn: payload.step > 1 }) !== undefined) return switchBeforeStep(agent, payload, { folder: true })
810
844
  const lines = await contextLines(agent.session).catch(() => undefined)
811
845
  const tokens = tokensOf(agent.session)
812
846
  if (lines === undefined || tokens === undefined || tokens < lines.hard || !grown(agent.id, tokens, lines) || readOnly()) return next()
813
- const midTurn = payload.step > 1
814
- if (await decide(agent, { midTurn }) === 'compact' && await condense(agent, lines, { inTurn: true, signal: payload.signal })) return next()
815
- if (switches.has(agent.id)) {
816
- switches.get(agent.id).carry.push(...payload.messages)
847
+ if (await decide(agent, { midTurn: payload.step > 1 }) === 'compact' && await condense(agent, lines, { inTurn: true, signal: payload.signal })) return next()
848
+ return switchBeforeStep(agent, payload, { hard: true })
849
+ }, { prepend: true })
850
+
851
+ // `messages` left the inbox for this step, so the switch carries them along.
852
+ function switchBeforeStep(agent, payload, options) {
853
+ // A switch may have started while the checks above awaited.
854
+ const running = switches.get(agent.id)
855
+ if (running !== undefined) {
856
+ running.carry.push(...payload.messages)
817
857
  return { kind: 'reject' }
818
858
  }
819
- // `messages` left the inbox for this step, so the switch carries them along.
859
+ const midTurn = payload.step > 1
820
860
  const entry = rt.liveOf(agent.id)
821
861
  entry.switched = true
822
- startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, hard: true })
862
+ startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, ...options })
823
863
  return { kind: 'reject' }
824
- }, { prepend: true })
864
+ }
865
+
866
+ // The folder a part should move to, when it is not the one the part was made in. A
867
+ // working folder that is gone counts as the team workspace, where a fresh part would
868
+ // start. A group reply ends where it began; the group conversation moves at its next
869
+ // turn there.
870
+ async function folderMove(agent, { midTurn = false } = {}) {
871
+ const place = placeOf(agent.id)
872
+ const cwd = agent.session.header?.cwd
873
+ if (place === undefined || cwd === undefined || (midTurn && place.roomId !== undefined) || Date.now() < (folderRetry.get(agent.id) ?? 0)) return undefined
874
+ const wanted = rt.workDirOf(place)
875
+ const target = wanted !== undefined && await rt.isFolder(wanted) ? wanted : rt.teamFolder()
876
+ return target === undefined || target === cwd ? undefined : target
877
+ }
878
+
879
+ // A Bot's own chat moves to its new folder now when it is idle; a running turn
880
+ // moves at its next step, and each group conversation at its next turn there.
881
+ async function followFolder(botId) {
882
+ const sessionId = rt.chatOf(botId)
883
+ if (sessionId === undefined || switches.has(sessionId) || readOnly()) return
884
+ try {
885
+ const agent = await rt.resolveAgent(sessionId)
886
+ if (agent.status === 'idle' && await folderMove(agent) !== undefined) startSwitch(agent, { folder: true })
887
+ } catch (error) {
888
+ rt.warn('ds-bot: %s did not move to its working folder: %s', rt.botOf(botId)?.name ?? botId, error)
889
+ }
890
+ }
825
891
 
826
892
  ctx.on('agent/request-error', async (payload, next) => {
827
893
  const failure = payload.failure
@@ -902,7 +968,7 @@ export function install(rt) {
902
968
  // in the part's history. When nothing is left to do, that turn's reply is a fixed
903
969
  // line instead of a model call.
904
970
  ctx.on('llm/stream', (options, next) => {
905
- if (options.sessionId === undefined || options.purpose !== undefined) return next()
971
+ if (options.sessionId === undefined || options.purpose !== undefined || rt.ownRequest(options)) return next()
906
972
  const messages = options.messages ?? []
907
973
  const since = messages.findLastIndex(message => message.role === 'assistant' || message.role === 'tool') + 1
908
974
  const opened = messages.slice(since).filter(message => message.role === 'user' && (message.source?.kind === 'user' || message.source?.kind === SOURCE_KIND))
@@ -971,5 +1037,5 @@ export function install(rt) {
971
1037
  }
972
1038
  }
973
1039
 
974
- 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() })
975
1041
  }
@@ -13,8 +13,8 @@ const TEAM_RULES = [
13
13
  '- Chat like a teammate texting: short sentences, often two or three short messages instead of one long one, no headings. When the user asks for a deliverable (a plan, schedule, draft, or list), write it out in full in the chat; never claim you gave something you did not write. Mention other Bots by their exact name.',
14
14
  '- Only for a direct user request in your own chat that needs several steps: write one short sentence of normal reply text before your first tool call, and make that tool call in the same response. Never start such a task with tools alone or end it with a promise. On long tasks, give brief factual updates only at meaningful milestones, then keep working.',
15
15
  '- For "[Group chat …]", "[Message from …]", or "[Reply from …]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.',
16
- '- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message. Never use ask_user_question: it blocks you, and messages from other Bots would queue behind it.',
17
- '- update_bot changes your own settings: name, role label, color, model, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up. Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.',
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
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.',
@@ -30,8 +30,8 @@ const MAIN_RULES = [
30
30
  '- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
31
31
  '- If a Bot does not heed your message and must not finish what it is doing, stop_bot ends its turn at once and tells it why; the reply comes back to you.',
32
32
  '- Every Bot runs on one model of its own (list_bots shows it). Pass model to create_bot or update_bot only when the user names one; list_models gives the provider/model refs.',
33
- '- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
34
- '- You run the group chats too. create_group makes a group (you join it as its admin unless you name another); update_group changes any group\'s name, notice, members, admin, or reply mode; post_to_group posts into any group as you, which starts a round of replies the user reads there; delete_group archives a group, only when the user asks. list_bots shows every group.',
33
+ '- Use update_bot to rename a Bot, change its role, color, model, working folder, or instructions when the user asks.',
34
+ '- You run the group chats too. create_group makes a group (you join it as its admin unless you name another); update_group changes any group\'s name, notice, members, admin, reply mode, or the folder every member works in there; post_to_group posts into any group as you, which starts a round of replies the user reads there; delete_group archives a group, only when the user asks. list_bots shows every group.',
35
35
  '- The team can have more than one Main Bot; list_bots marks them. They share these powers, so coordinate with them through message_bot.',
36
36
  ].join('\n')
37
37
 
@@ -21,13 +21,16 @@ export function install(rt) {
21
21
  return workspace.id
22
22
  }
23
23
 
24
- async function createSession(title) {
24
+ // `dir` is a working folder outside the team workspace. Such a Session is made by cwd
25
+ // alone, so it joins no DSH workspace and the user's own project lists stay as they are.
26
+ async function createSession(title, { dir } = {}) {
25
27
  // A Bot's tools run here too; while another dsh holds the team, a new Session would
26
28
  // belong to no saved Bot.
27
29
  const readOnly = rt.readOnly()
28
30
  if (readOnly !== undefined) throw new Error(readOnly)
29
- const workspaceId = await ensureWorkspace()
30
- const { sessionId } = await ctx.sessionController.create({ workspaceId })
31
+ const usable = dir !== undefined && await rt.isFolder(dir)
32
+ if (dir !== undefined && !usable) rt.warn('ds-bot: the working folder %s is gone; %s starts in the team workspace', dir, title)
33
+ const { sessionId } = await ctx.sessionController.create(usable ? { cwd: dir } : { workspaceId: await ensureWorkspace() })
31
34
  const agent = await rt.resolveAgent(sessionId)
32
35
  await ctx.sessionTitle.rename(agent.session, title)
33
36
  return sessionId
@@ -55,7 +58,7 @@ export function install(rt) {
55
58
 
56
59
  // `pickModel` leaves the model to the user: the chat opens on a model card, and the
57
60
  // greeting waits until a model is chosen. So does a Bot made when no model is served.
58
- async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
61
+ async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, copyOf, pickModel = false }) {
59
62
  const cleanName = String(botName ?? '').trim().slice(0, 40)
60
63
  const problem = nameProblem(cleanName)
61
64
  if (problem !== undefined) throw new Error(problem)
@@ -64,7 +67,8 @@ export function install(rt) {
64
67
  // The model is fixed at creation, so a later change of the DSH default leaves it alone.
65
68
  const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
66
69
  const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
67
- const id = await createSession(cleanName)
70
+ const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
71
+ const id = await createSession(cleanName, { dir })
68
72
  try {
69
73
  state.bots[id] = {
70
74
  id,
@@ -75,6 +79,7 @@ export function install(rt) {
75
79
  ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
76
80
  ...(modelRef ? { model: modelRef } : {}),
77
81
  ...(effortId ? { effort: effortId } : {}),
82
+ ...(dir ? { workDir: dir } : {}),
78
83
  instructions: String(instructions).trim(),
79
84
  createdAt: Date.now(),
80
85
  createdBy: createdBy ?? null,
@@ -84,16 +89,19 @@ export function install(rt) {
84
89
  const creator = rt.botOf(createdBy)
85
90
  const original = rt.botOf(copyOf)
86
91
  const language = rt.uiLanguage?.()
92
+ // The kickoff arrives as a user message, so it outranks the Bot's instructions
93
+ // unless it gives way to them, and it names no tool those instructions may rule out.
87
94
  const kickoff = main
88
- ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
95
+ ? `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 ask in one short question what they want the team to help with first. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
89
96
  : original
90
- ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
97
+ ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.`
91
98
  : creator
92
- ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
99
+ ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then ask in one short question where to start. Keep it brief.`
93
100
  : brief
94
101
  ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
95
- : 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
96
- const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
102
+ : 'The user just created you. Greet the user in one short message, then ask in one short question what you should focus on.'
103
+ const opener = `${kickoff}\nWhere your instructions say otherwise, follow them.`
104
+ const text = !main && language ? `${opener}\nWrite in ${language}.` : opener
97
105
  const bot = state.bots[id]
98
106
  if (modelRef === undefined) {
99
107
  bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
@@ -145,6 +153,8 @@ export function install(rt) {
145
153
  avatar: bot.avatar,
146
154
  model: served ? bot.model : undefined,
147
155
  effort: served ? bot.effort : undefined,
156
+ // A folder that went away since leaves the copy in the team workspace.
157
+ workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined,
148
158
  copyOf: bot.id,
149
159
  })
150
160
  }
@@ -152,6 +162,9 @@ export function install(rt) {
152
162
  async function updateBot(id, patch) {
153
163
  const bot = rt.botOf(id)
154
164
  if (bot === undefined) throw new Error('Unknown Bot')
165
+ // Checked first, so a folder that does not pass leaves the rest unchanged too.
166
+ const moving = patch.workDir !== undefined
167
+ const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
155
168
  if (patch.name !== undefined) {
156
169
  const next = String(patch.name).trim().slice(0, 40)
157
170
  const clash = rt.findBot(next)
@@ -191,8 +204,14 @@ export function install(rt) {
191
204
  modelChanged = true
192
205
  }
193
206
  if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
207
+ const moved = moving && workDir !== bot.workDir
208
+ if (moving) {
209
+ if (workDir === undefined) delete bot.workDir
210
+ else bot.workDir = workDir
211
+ }
194
212
  await rt.save()
195
213
  if (modelChanged) await rt.applyModel(bot, { force: true })
214
+ if (moved) void rt.followFolder(bot.id)
196
215
  if (bot.model && bot.kickoff !== undefined) {
197
216
  const held = bot.kickoff
198
217
  delete bot.kickoff
@@ -260,6 +279,8 @@ export function install(rt) {
260
279
  if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
261
280
  if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
262
281
  const title = custom || autoRoomName(members)
282
+ // The group's own Session only relays, so it stays in the team workspace; the
283
+ // group's folder is for its members' Sessions.
263
284
  const id = await createSession(title)
264
285
  try {
265
286
  state.rooms[id] = {
@@ -309,6 +330,9 @@ export function install(rt) {
309
330
  if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
310
331
  const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
311
332
  const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
333
+ const moving = patch.workDir !== undefined
334
+ if (moving && byAdmin) throw new Error(`Only the user or a Main Bot can set the folder of "${room.name}"`)
335
+ const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
312
336
 
313
337
  const changes = []
314
338
  const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
@@ -332,6 +356,12 @@ export function install(rt) {
332
356
  room.notice = notice
333
357
  changes.push(notice ? 'updated the notice' : 'cleared the notice')
334
358
  }
359
+ // Members' group conversations move on to the folder at their next turn there.
360
+ if (moving && workDir !== room.workDir) {
361
+ if (workDir === undefined) delete room.workDir
362
+ else room.workDir = workDir
363
+ changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
364
+ }
335
365
  const before = room.name
336
366
  if (name !== undefined) {
337
367
  if (name !== '') {