@kevin5251984/guild 0.2.12

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/bin/guildd.mjs +20 -0
  3. package/cordis.yml +24 -0
  4. package/package.json +52 -0
  5. package/src/agent-file.ts +125 -0
  6. package/src/browser.ts +668 -0
  7. package/src/catalog/default-bots.ts +263 -0
  8. package/src/catalog/skills.ts +128 -0
  9. package/src/catalog/subagents.ts +70 -0
  10. package/src/chat-parts.ts +71 -0
  11. package/src/cli-args.ts +75 -0
  12. package/src/cli.ts +60 -0
  13. package/src/compact.ts +355 -0
  14. package/src/cordis.d.ts +40 -0
  15. package/src/db.ts +653 -0
  16. package/src/generate.ts +673 -0
  17. package/src/handlers.ts +1623 -0
  18. package/src/harness.ts +326 -0
  19. package/src/host-agents.ts +137 -0
  20. package/src/host-browse.ts +199 -0
  21. package/src/host-skills.ts +150 -0
  22. package/src/image-gen.ts +270 -0
  23. package/src/index.ts +12 -0
  24. package/src/llm.ts +993 -0
  25. package/src/mcp.ts +563 -0
  26. package/src/memory.ts +159 -0
  27. package/src/mention.ts +176 -0
  28. package/src/oauth.ts +1474 -0
  29. package/src/plugins/api.ts +8 -0
  30. package/src/plugins/chat.ts +31 -0
  31. package/src/plugins/harness.ts +77 -0
  32. package/src/plugins/llm.ts +50 -0
  33. package/src/plugins/mcp.ts +58 -0
  34. package/src/plugins/memory.ts +42 -0
  35. package/src/plugins/oauth.ts +47 -0
  36. package/src/plugins/server.ts +126 -0
  37. package/src/plugins/store.ts +29 -0
  38. package/src/plugins/tools.ts +79 -0
  39. package/src/public/buddy.js +432 -0
  40. package/src/public/chat.css +3045 -0
  41. package/src/public/chat.html +5834 -0
  42. package/src/public/favicon-16.png +0 -0
  43. package/src/public/favicon-16.svg +10 -0
  44. package/src/public/favicon-32.png +0 -0
  45. package/src/public/favicon.ico +0 -0
  46. package/src/public/favicon.svg +13 -0
  47. package/src/public/i18n.js +663 -0
  48. package/src/public/index.html +143 -0
  49. package/src/public/library.html +678 -0
  50. package/src/public/mcp-add.html +126 -0
  51. package/src/public/md.js +332 -0
  52. package/src/public/rpg/inn-street.jpg +0 -0
  53. package/src/public/settings.html +795 -0
  54. package/src/public/skills-add.html +212 -0
  55. package/src/public/studio.html +1181 -0
  56. package/src/public/style.css +1678 -0
  57. package/src/public/subagents-add.html +152 -0
  58. package/src/router.ts +978 -0
  59. package/src/send-budget.ts +52 -0
  60. package/src/server.ts +1 -0
  61. package/src/skill-import.ts +250 -0
  62. package/src/slash.ts +15 -0
  63. package/src/start.ts +103 -0
  64. package/src/store.ts +1208 -0
  65. package/src/subagent.ts +355 -0
  66. package/src/tools.ts +818 -0
  67. package/src/trajectory.ts +339 -0
  68. package/src/usage.ts +111 -0
  69. package/vendor/protocol/package.json +19 -0
  70. package/vendor/protocol/src/index.ts +159 -0
@@ -0,0 +1,1623 @@
1
+ import type {
2
+ BenchListing,
3
+ ChatAttachment,
4
+ ChatMessage,
5
+ HealthResponse,
6
+ LibraryKind,
7
+ ModelRef,
8
+ } from "@guild/protocol";
9
+ import {
10
+ buildChatSystem,
11
+ chatReply,
12
+ generateMarkdown,
13
+ localGenerate,
14
+ pickSkills,
15
+ type ChatReply,
16
+ type GenerateKind,
17
+ type SkillPickInput,
18
+ } from "./generate.ts";
19
+ import {
20
+ liveTrajectoryEvents,
21
+ promoteSpawnEvent,
22
+ synthesizeTrajectory,
23
+ turnTrajectoryEvents,
24
+ userTrajectoryEvent,
25
+ } from "./trajectory.ts";
26
+ import { importFromGithub, importFromUrl } from "./skill-import.ts";
27
+ import { harvestBotMemory, harvestChannelMemory } from "./memory.ts";
28
+ import { listHostSkills, type HostSkill } from "./host-skills.ts";
29
+ import {
30
+ CHANNEL_ROSTER_CAP,
31
+ GuildStore,
32
+ StoreError,
33
+ type LiveStep,
34
+ type LiveTurn,
35
+ } from "./store.ts";
36
+ import { listSpawnRefs } from "./subagent.ts";
37
+ import {
38
+ importHostMcp,
39
+ listGuildMcp,
40
+ listHostMcp,
41
+ listMcpToolRefs,
42
+ removeGuildMcp,
43
+ upsertGuildMcp,
44
+ } from "./mcp.ts";
45
+ import {
46
+ assignmentFor,
47
+ handoffHandles,
48
+ isBroadcastMention,
49
+ summonedHandles,
50
+ } from "./mention.ts";
51
+ import { slashNames } from "./slash.ts";
52
+ import { toHistoryItem, type HistoryItem } from "./compact.ts";
53
+ import type { SkillRef, SubAgentRef, ToolProgress, ToolTrace } from "./tools.ts";
54
+ import type { McpToolRef } from "./mcp.ts";
55
+
56
+ export type TurnComplete = {
57
+ roomId: string;
58
+ botId: string;
59
+ userText: string;
60
+ reply: string;
61
+ };
62
+
63
+ export type HandlerExtras = {
64
+ mcp?: boolean;
65
+ oauth?: boolean;
66
+ harvest?: boolean;
67
+ mcpTools?: McpToolRef[] | Promise<McpToolRef[]>;
68
+ onTurnComplete?: (turn: TurnComplete) => void;
69
+ turn?: (input: Parameters<typeof chatReply>[0]) => Promise<ChatReply>;
70
+ };
71
+
72
+ export function healthPayload(): HealthResponse {
73
+ return {
74
+ status: "ok",
75
+ ready: true,
76
+ service: "guildd",
77
+ };
78
+ }
79
+
80
+ export function listBench(store: GuildStore): BenchListing {
81
+ return store.listBots();
82
+ }
83
+
84
+ export function listLibrary(store: GuildStore, kind: LibraryKind) {
85
+ return store.listLibrary(kind);
86
+ }
87
+
88
+ export function listMcpServers(store: GuildStore) {
89
+ return listGuildMcp(store.dataDir);
90
+ }
91
+
92
+ export function listHostMcpServers() {
93
+ return listHostMcp();
94
+ }
95
+
96
+ export function createMcpServer(
97
+ store: GuildStore,
98
+ input: {
99
+ name: string;
100
+ command?: string;
101
+ args?: string[];
102
+ env?: Record<string, string>;
103
+ cwd?: string;
104
+ url?: string;
105
+ },
106
+ ) {
107
+ try {
108
+ return upsertGuildMcp(store.dataDir, input.name, {
109
+ command: input.command || "",
110
+ args: input.args || [],
111
+ env: input.env,
112
+ cwd: input.cwd,
113
+ url: input.url,
114
+ });
115
+ } catch (error) {
116
+ throw new StoreError(
117
+ 400,
118
+ error instanceof Error ? error.message : "invalid mcp server",
119
+ );
120
+ }
121
+ }
122
+
123
+ export function importMcpServer(store: GuildStore, hostId: string) {
124
+ try {
125
+ return importHostMcp(store.dataDir, hostId);
126
+ } catch (error) {
127
+ throw new StoreError(
128
+ 404,
129
+ error instanceof Error ? error.message : "host mcp not found",
130
+ );
131
+ }
132
+ }
133
+
134
+ export function deleteMcpServer(store: GuildStore, name: string) {
135
+ try {
136
+ return removeGuildMcp(store.dataDir, name);
137
+ } catch (error) {
138
+ throw new StoreError(
139
+ 404,
140
+ error instanceof Error ? error.message : "mcp server not found",
141
+ );
142
+ }
143
+ }
144
+
145
+ export function createLibraryItem(
146
+ store: GuildStore,
147
+ kind: LibraryKind,
148
+ input: { name: string; body?: string; slug?: string; description?: string },
149
+ ) {
150
+ return store.createLibrary(kind, input);
151
+ }
152
+
153
+ type MarkdownDraft = { name: string; body: string };
154
+
155
+ function existingSkillForHost(store: GuildStore, host: HostSkill) {
156
+ const skills = store.listLibrary("skills");
157
+ const slug = host.slug.toLowerCase();
158
+ const name = host.name.toLowerCase();
159
+ return (
160
+ skills.find((item) => item.slug.toLowerCase() === slug) ||
161
+ skills.find((item) => item.name.toLowerCase() === name) ||
162
+ null
163
+ );
164
+ }
165
+
166
+ /** Guild UUIDs, or `host:codex:…` ids from the bar picker. Host ids import once. */
167
+ export function resolveStaffSkillIds(
168
+ store: GuildStore,
169
+ skillIds: string[],
170
+ hosts?: HostSkill[],
171
+ ): string[] {
172
+ const hostList = hosts ?? listHostSkills();
173
+ const byId = new Map(hostList.map((item) => [item.id, item]));
174
+ const out: string[] = [];
175
+ const seen = new Set<string>();
176
+ for (const raw of skillIds) {
177
+ const id = String(raw || "").trim();
178
+ if (!id) continue;
179
+ let guildId: string;
180
+ if (id.startsWith("host:")) {
181
+ const host = byId.get(id);
182
+ if (!host) throw new StoreError(400, `skillId does not exist: ${id}`);
183
+ const existing = existingSkillForHost(store, host);
184
+ guildId = existing
185
+ ? existing.id
186
+ : store.createLibrary("skills", {
187
+ name: host.name,
188
+ body: host.body,
189
+ description: host.description,
190
+ slug: host.slug,
191
+ tags: host.tags,
192
+ }).id;
193
+ } else {
194
+ if (!store.getLibrary("skills", id)) {
195
+ throw new StoreError(400, `skillId does not exist: ${id}`);
196
+ }
197
+ guildId = id;
198
+ }
199
+ if (seen.has(guildId)) continue;
200
+ seen.add(guildId);
201
+ out.push(guildId);
202
+ }
203
+ return out;
204
+ }
205
+
206
+ export function createBot(
207
+ store: GuildStore,
208
+ input: {
209
+ name: string;
210
+ handle: string;
211
+ oneLiner?: string;
212
+ soulId?: string;
213
+ agentTemplateId?: string;
214
+ defaultPositionId?: string;
215
+ soul?: MarkdownDraft;
216
+ agent?: MarkdownDraft;
217
+ position?: MarkdownDraft;
218
+ skillIds?: string[];
219
+ skillId?: string;
220
+ },
221
+ hosts?: HostSkill[],
222
+ ) {
223
+ const raw = input.skillIds?.length
224
+ ? input.skillIds
225
+ : input.skillId
226
+ ? [input.skillId]
227
+ : [];
228
+ return store.createBot({
229
+ name: input.name,
230
+ handle: input.handle,
231
+ oneLiner: input.oneLiner,
232
+ soulId: input.soulId,
233
+ agentTemplateId: input.agentTemplateId,
234
+ defaultPositionId: input.defaultPositionId,
235
+ soul: input.soul,
236
+ agent: input.agent,
237
+ position: input.position,
238
+ skillIds: resolveStaffSkillIds(store, raw, hosts),
239
+ });
240
+ }
241
+
242
+ export function getBotDetail(store: GuildStore, id: string) {
243
+ return store.botDetail(id);
244
+ }
245
+
246
+ export function updateBot(
247
+ store: GuildStore,
248
+ id: string,
249
+ input: {
250
+ name?: string;
251
+ handle?: string;
252
+ oneLiner?: string;
253
+ portrait?: string | null;
254
+ skillIds?: string[];
255
+ soul?: MarkdownDraft;
256
+ agent?: MarkdownDraft;
257
+ position?: MarkdownDraft;
258
+ model?: ModelRef | null;
259
+ },
260
+ hosts?: HostSkill[],
261
+ ) {
262
+ const skillIds = input.skillIds
263
+ ? resolveStaffSkillIds(store, input.skillIds, hosts)
264
+ : undefined;
265
+ return store.updateBot(id, { ...input, skillIds });
266
+ }
267
+
268
+ const LOOK_HAIR = [
269
+ "jet-black short crop",
270
+ "jet-black long straight hair",
271
+ "copper-red bob with blunt bangs",
272
+ "copper-red messy spikes",
273
+ "indigo long waves",
274
+ "indigo pixie cut",
275
+ "honey-blonde high ponytail",
276
+ "honey-blonde bowl cut",
277
+ "hot-pink messy spikes",
278
+ "hot-pink bob",
279
+ "ash-white curly volume",
280
+ "ash-white long hair",
281
+ "teal-tinted undercut",
282
+ "teal high ponytail",
283
+ "deep-burgundy twin braids",
284
+ "deep-burgundy shag",
285
+ ];
286
+ const LOOK_CLOTH = [
287
+ "sunflower-yellow collared shirt",
288
+ "cobalt hooded jacket",
289
+ "rose cardigan over a cream tee",
290
+ "forest-green knit turtleneck",
291
+ "ivory blouse with a coral scarf",
292
+ "charcoal work vest over a rust tee",
293
+ "lilac haori",
294
+ "orange windbreaker",
295
+ "white lab coat over a black tee",
296
+ "crimson bomber jacket",
297
+ "mint sailor collar",
298
+ "navy peacoat",
299
+ "gold-trimmed teal capelet",
300
+ "checkered red-and-black shirt",
301
+ "pale-blue denim jacket",
302
+ "magenta track jacket",
303
+ ];
304
+ const LOOK_EXTRA = [
305
+ "round wire glasses",
306
+ "small gold hoop earrings",
307
+ "over-ear headphones around the neck",
308
+ "a paintbrush tucked behind one ear",
309
+ "a red hair clip",
310
+ "a thin black choker",
311
+ "a knitted ear warmer",
312
+ "no extra accessories",
313
+ ];
314
+ const LOOK_SKIN = [
315
+ "fair peach human skin",
316
+ "warm tan human skin",
317
+ "light brown human skin",
318
+ "deep brown human skin",
319
+ "golden beige human skin",
320
+ ];
321
+ const LOOK_FACE = [
322
+ "round cheerful face with wide-set eyes",
323
+ "sharp jaw and narrow eyes",
324
+ "soft oval face with thick brows",
325
+ "heart-shaped face and a small nose",
326
+ "square face with a bright closed-mouth smile",
327
+ ];
328
+ const LOOK_RACE = [
329
+ {
330
+ id: "human" as const,
331
+ label: "human",
332
+ prompt:
333
+ "human. Ordinary rounded human ears, fully human anatomy, no fantasy ears.",
334
+ },
335
+ {
336
+ id: "dwarf" as const,
337
+ label: "dwarf",
338
+ prompt:
339
+ "young dwarf. Stout neck, broader cheekbones, a slightly larger nose, thick brows, rounded ears, youthful (not elderly, not bald).",
340
+ },
341
+ {
342
+ id: "elf" as const,
343
+ label: "elf",
344
+ prompt:
345
+ "young elf. Long pointed ears clearly visible, fine features, almond eyes, still youthful.",
346
+ },
347
+ {
348
+ id: "demihuman" as const,
349
+ label: "demihuman",
350
+ prompt:
351
+ "demihuman who is 90% human: a human face and bust with only one subtle tell (tiny pointed ear tips, faint whisker marks, or slightly elongated canines). Not a full animal-person, not a mascot, not extra limbs.",
352
+ },
353
+ ];
354
+
355
+ function lookSeed(key: string): number {
356
+ let n = 2166136261;
357
+ for (const ch of key) {
358
+ n ^= ch.charCodeAt(0);
359
+ n = Math.imul(n, 16777619);
360
+ }
361
+ return n >>> 0;
362
+ }
363
+
364
+ export function lookTraits(bot: { name: string; handle: string }): {
365
+ hair: string;
366
+ cloth: string;
367
+ extra: string;
368
+ skin: string;
369
+ face: string;
370
+ race: (typeof LOOK_RACE)[number];
371
+ } {
372
+ const n = lookSeed(bot.handle || bot.name || "bot");
373
+ return {
374
+ hair: LOOK_HAIR[n % LOOK_HAIR.length],
375
+ cloth: LOOK_CLOTH[(n >>> 4) % LOOK_CLOTH.length],
376
+ extra: LOOK_EXTRA[(n >>> 8) % LOOK_EXTRA.length],
377
+ skin: LOOK_SKIN[(n >>> 12) % LOOK_SKIN.length],
378
+ face: LOOK_FACE[(n >>> 16) % LOOK_FACE.length],
379
+ race: LOOK_RACE[(n >>> 20) % LOOK_RACE.length],
380
+ };
381
+ }
382
+
383
+ export function lookPrompt(bot: {
384
+ name: string;
385
+ handle: string;
386
+ oneLiner?: string;
387
+ }): string {
388
+ const role = bot.oneLiner?.trim() || "keeps a seat in the guild tavern";
389
+ const look = lookTraits(bot);
390
+ return [
391
+ `SNES 16-bit pixel-art bust portrait of ${bot.name} (@${bot.handle}), a unique ${look.race.label} guild adventurer.`,
392
+ `Race (mandatory): ${look.race.prompt}`,
393
+ `Mandatory look: ${look.skin}, ${look.face}, ${look.hair}, wearing a ${look.cloth}, ${look.extra}.`,
394
+ `They ${role}.`,
395
+ "Hair color, outfit, and race are locked; do not default to brown hair or a beige coat.",
396
+ "Natural skin tones only, never green, gray, or monster skin.",
397
+ "Head and shoulders only, facing the camera, chunky 16-bit pixels, limited tavern palette, cream pixel outline.",
398
+ "Animal Crossing crossed with Earthbound, youthful SNES NPC.",
399
+ "Opaque cream or tavern-wood background, no black void, no white photo studio, no checkerboard, no transparency.",
400
+ "Close-up character headshot, no full body, no legs, no floor, no scenery, no photorealism, no 3D render, no text, no watermark.",
401
+ ].join(" ");
402
+ }
403
+
404
+ export async function generateBotLook(
405
+ store: GuildStore,
406
+ id: string,
407
+ env: NodeJS.ProcessEnv = process.env,
408
+ ) {
409
+ const bot = store.getBot(id);
410
+ if (!bot) throw new StoreError(404, "bot not found");
411
+ const { generateImage } = await import("./image-gen.ts");
412
+ const result = await generateImage({
413
+ prompt: lookPrompt(bot),
414
+ aspectRatio: "1:1",
415
+ dataDir: store.dataDir,
416
+ env,
417
+ });
418
+ if (result.isError || !result.publicPath) {
419
+ throw new StoreError(502, result.text);
420
+ }
421
+ return store.updateBot(id, { portrait: result.publicPath });
422
+ }
423
+
424
+ export async function importSkills(
425
+ store: GuildStore,
426
+ input: { source: string; url?: string; repo?: string },
427
+ fetchImpl: typeof fetch = fetch,
428
+ ) {
429
+ const source = input.source.trim();
430
+ const drafts =
431
+ source === "github"
432
+ ? await importFromGithub(input.repo ?? input.url ?? "", fetchImpl)
433
+ : source === "url"
434
+ ? await importFromUrl(input.url ?? "", fetchImpl)
435
+ : (() => {
436
+ throw new StoreError(400, "source must be url or github");
437
+ })();
438
+ return drafts.map((draft) =>
439
+ store.createLibrary("skills", {
440
+ name: draft.name,
441
+ body: draft.body,
442
+ description: draft.description,
443
+ slug: draft.slug,
444
+ }),
445
+ );
446
+ }
447
+
448
+ export async function generateKind(
449
+ store: GuildStore,
450
+ kind: string,
451
+ prompt: string,
452
+ ) {
453
+ if (
454
+ kind !== "soul" &&
455
+ kind !== "agent" &&
456
+ kind !== "position" &&
457
+ kind !== "skill" &&
458
+ kind !== "subagent"
459
+ ) {
460
+ throw new StoreError(400, "kind must be soul, agent, position, skill, or subagent");
461
+ }
462
+ return generateMarkdown(kind as GenerateKind, prompt, process.env, store.dataDir);
463
+ }
464
+
465
+ export async function pickBotSkills(store: GuildStore, input: SkillPickInput) {
466
+ return pickSkills(input, process.env, store.dataDir);
467
+ }
468
+
469
+ function byUpdatedAtDesc<T extends { updatedAt?: string }>(a: T, b: T): number {
470
+ const ta = a.updatedAt ? Date.parse(a.updatedAt) : 0;
471
+ const tb = b.updatedAt ? Date.parse(b.updatedAt) : 0;
472
+ const na = Number.isFinite(ta) ? ta : 0;
473
+ const nb = Number.isFinite(tb) ? tb : 0;
474
+ return nb - na;
475
+ }
476
+
477
+ export function workspace(store: GuildStore) {
478
+ const bots = store.listBots();
479
+ const byId = new Map(bots.map((bot) => [bot.id, bot]));
480
+ const channels = store.listChannels().map((room) => {
481
+ const last = store.lastMessagePreview(room.id);
482
+ return {
483
+ ...room,
484
+ members: room.memberIds
485
+ .map((id) => byId.get(id))
486
+ .filter((bot): bot is NonNullable<typeof bot> => Boolean(bot)),
487
+ updatedAt: last?.createdAt,
488
+ lastMessage: last ?? null,
489
+ };
490
+ });
491
+ const listed = bots.map((bot) => {
492
+ const last = store.lastMessagePreview(`dm-${bot.id}`);
493
+ return {
494
+ ...bot,
495
+ updatedAt: last?.createdAt,
496
+ lastMessage: last ?? null,
497
+ };
498
+ });
499
+ channels.sort(byUpdatedAtDesc);
500
+ listed.sort(byUpdatedAtDesc);
501
+ const live = store.listLiveTurns().flatMap(({ roomId, turn }) => {
502
+ if (!turn.botId) return [];
503
+ const room = store.getRoom(roomId);
504
+ if (!room) return [];
505
+ const id = room.kind === "dm" ? roomId.replace(/^dm-/, "") : roomId;
506
+ return [
507
+ {
508
+ kind: room.kind,
509
+ id,
510
+ botId: turn.botId,
511
+ startedAt: turn.startedAt || "",
512
+ thinking: turn.thinking,
513
+ steps: turn.steps,
514
+ },
515
+ ];
516
+ });
517
+ return { channels, bots: listed, live };
518
+ }
519
+
520
+ export function createChannel(store: GuildStore, name: string) {
521
+ return store.createChannel(name);
522
+ }
523
+
524
+ export function deleteChannel(store: GuildStore, id: string) {
525
+ return store.deleteChannel(id);
526
+ }
527
+
528
+ export function renameChannel(store: GuildStore, id: string, name: string) {
529
+ return store.renameChannel(id, name);
530
+ }
531
+
532
+ export function deleteBot(store: GuildStore, id: string) {
533
+ return store.deleteBot(id);
534
+ }
535
+
536
+ export function addChannelMember(
537
+ store: GuildStore,
538
+ roomId: string,
539
+ botId: string,
540
+ ) {
541
+ return store.addMember(roomId, botId);
542
+ }
543
+
544
+ export function removeChannelMember(
545
+ store: GuildStore,
546
+ roomId: string,
547
+ botId: string,
548
+ ) {
549
+ return store.removeMember(roomId, botId);
550
+ }
551
+
552
+ export function listRoomMessages(store: GuildStore, roomId: string) {
553
+ return store.listMessages(roomId);
554
+ }
555
+
556
+ export function deleteRoomMessage(
557
+ store: GuildStore,
558
+ roomId: string,
559
+ messageId: string,
560
+ ) {
561
+ const removed = store.deleteMessage(roomId, messageId);
562
+ return { ok: true, id: removed.id };
563
+ }
564
+
565
+ export function openDm(store: GuildStore, botId: string) {
566
+ return store.openDm(botId);
567
+ }
568
+
569
+ function handoffTargets(
570
+ store: GuildStore,
571
+ memberIds: string[],
572
+ replies: ChatMessage[],
573
+ asked: string,
574
+ history: HistoryItem[],
575
+ ): { botId: string; fromHandle: string; asked: string; history: HistoryItem[] }[] {
576
+ const bots = store.listBots();
577
+ const handles = bots.map((bot) => bot.handle);
578
+ const spoke = new Set(replies.map((row) => row.author));
579
+ const hops: {
580
+ botId: string;
581
+ fromHandle: string;
582
+ asked: string;
583
+ history: HistoryItem[];
584
+ }[] = [];
585
+ const queued = new Set<string>();
586
+ for (const reply of replies) {
587
+ if (isBroadcastMention(reply.body)) continue;
588
+ const names = handoffHandles(reply.body, handles);
589
+ if (!names.length) continue;
590
+ const from = bots.find((bot) => bot.id === reply.author);
591
+ const fromHandle = from?.handle || reply.author;
592
+ const hopHistory = history.concat(
593
+ { author: "you", body: asked },
594
+ { author: reply.author, body: reply.body },
595
+ );
596
+ for (const bot of bots) {
597
+ if (!names.includes(bot.handle.toLowerCase())) continue;
598
+ if (!memberIds.includes(bot.id)) continue;
599
+ if (bot.id === reply.author) continue;
600
+ if (spoke.has(bot.id) || queued.has(bot.id)) continue;
601
+ queued.add(bot.id);
602
+ const spec = assignmentFor(reply.body, bot.handle, handles);
603
+ hops.push({
604
+ botId: bot.id,
605
+ fromHandle,
606
+ asked: `(@${fromHandle} 交棒給 @${bot.handle})\n${spec}`,
607
+ history: hopHistory,
608
+ });
609
+ }
610
+ }
611
+ return hops;
612
+ }
613
+
614
+ function replyBots(
615
+ store: GuildStore,
616
+ memberIds: string[],
617
+ userText: string,
618
+ extraBotId?: string,
619
+ ): string[] {
620
+ if (isBroadcastMention(userText)) return memberIds;
621
+ const bots = store.listBots();
622
+ const names = summonedHandles(
623
+ userText,
624
+ bots.map((bot) => bot.handle),
625
+ );
626
+ const mentioned = new Set<string>();
627
+ for (const bot of bots) {
628
+ if (names.includes(bot.handle.toLowerCase())) mentioned.add(bot.id);
629
+ }
630
+ if (mentioned.size > 0) {
631
+ return memberIds.filter((id) => mentioned.has(id));
632
+ }
633
+ if (extraBotId) return memberIds.filter((id) => id === extraBotId);
634
+ if (memberIds.length === 1) return memberIds;
635
+ return [];
636
+ }
637
+
638
+ function turnUserMessage(
639
+ store: GuildStore,
640
+ parent: ChatMessage | undefined,
641
+ body: string,
642
+ ): string {
643
+ if (!parent || parent.author === "you") return body;
644
+ const handle =
645
+ store.listBots().find((bot) => bot.id === parent.author)?.handle ||
646
+ parent.author;
647
+ const preview = parent.body.replace(/\s+/g, " ").trim().slice(0, 240);
648
+ return `(回覆 @${handle}:${preview})\n${body}`;
649
+ }
650
+
651
+ const ATTACH_TOKEN = /^\[[A-Za-z]+ #\d+\]$/;
652
+ const PREVIEW_RE = /^data:image\/(png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]+$/i;
653
+ const PREVIEW_CAP = 100_000;
654
+
655
+ function parsePreview(raw: unknown): string | undefined {
656
+ if (typeof raw !== "string") return undefined;
657
+ const value = raw.trim();
658
+ if (!value || value.length > PREVIEW_CAP) return undefined;
659
+ if (!PREVIEW_RE.test(value)) return undefined;
660
+ return value;
661
+ }
662
+
663
+ export function parseAttachments(raw: unknown): ChatAttachment[] | undefined {
664
+ if (!Array.isArray(raw)) return undefined;
665
+ const out: ChatAttachment[] = [];
666
+ for (const item of raw.slice(0, 12)) {
667
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
668
+ const rec = item as Record<string, unknown>;
669
+ const token = typeof rec.token === "string" ? rec.token.trim() : "";
670
+ const title = typeof rec.title === "string" ? rec.title.trim() : "";
671
+ const body = typeof rec.body === "string" ? rec.body : "";
672
+ if (!ATTACH_TOKEN.test(token) || !title) continue;
673
+ const preview = parsePreview(rec.preview);
674
+ out.push({
675
+ token,
676
+ title,
677
+ body: body.slice(0, 48_000),
678
+ ...(preview ? { preview } : {}),
679
+ });
680
+ }
681
+ return out.length ? out : undefined;
682
+ }
683
+
684
+ function askedText(
685
+ store: GuildStore,
686
+ parent: ChatMessage | undefined,
687
+ userMessage: { body: string; attachments?: ChatAttachment[] },
688
+ ): string {
689
+ const asked = turnUserMessage(store, parent, userMessage.body);
690
+ const atts = userMessage.attachments;
691
+ if (!atts?.length) return asked;
692
+ const legend = atts
693
+ .map((att) => `${att.token} ${att.title}\n${att.body}`.trim())
694
+ .join("\n\n");
695
+ return `附件:\n${legend}\n\n${asked}`;
696
+ }
697
+
698
+ /** @handle of a bot not in this channel adds them, then they can reply. */
699
+ export function inviteMentionedBots(
700
+ store: GuildStore,
701
+ roomId: string,
702
+ userText: string,
703
+ ): string[] {
704
+ const room = store.getRoom(roomId);
705
+ if (!room) return [];
706
+ if (room.kind !== "channel") return room.memberIds;
707
+ if (isBroadcastMention(userText)) return room.memberIds;
708
+ const names = summonedHandles(
709
+ userText,
710
+ store.listBots().map((bot) => bot.handle),
711
+ );
712
+ let memberIds = room.memberIds;
713
+ for (const bot of store.listBots()) {
714
+ if (!names.includes(bot.handle.toLowerCase())) continue;
715
+ if (memberIds.includes(bot.id)) continue;
716
+ store.addMember(roomId, bot.id);
717
+ memberIds = [...memberIds, bot.id];
718
+ }
719
+ return memberIds;
720
+ }
721
+
722
+ export function channelMarkdownForRoom(
723
+ store: GuildStore,
724
+ roomId: string,
725
+ ): string {
726
+ const room = store.getRoom(roomId);
727
+ if (!room || room.kind !== "channel") return "";
728
+ return store.readChannelMd(roomId);
729
+ }
730
+
731
+ export function getChannelMd(store: GuildStore, roomId: string) {
732
+ return { body: store.readChannelMd(roomId) };
733
+ }
734
+
735
+ export function getBotMemory(store: GuildStore, botId: string) {
736
+ return { body: store.readBotMemory(botId) };
737
+ }
738
+
739
+ export function setBotMemory(store: GuildStore, botId: string, body: string) {
740
+ return { body: store.writeBotMemory(botId, body) };
741
+ }
742
+
743
+ export function getChannelMemory(store: GuildStore, roomId: string) {
744
+ return { body: store.readChannelMemory(roomId) };
745
+ }
746
+
747
+ export function setChannelMemory(
748
+ store: GuildStore,
749
+ roomId: string,
750
+ body: string,
751
+ ) {
752
+ return { body: store.writeChannelMemory(roomId, body) };
753
+ }
754
+
755
+ export function setChannelMd(
756
+ store: GuildStore,
757
+ roomId: string,
758
+ body: string,
759
+ ) {
760
+ return { body: store.writeChannelMd(roomId, body) };
761
+ }
762
+
763
+ function staffedSkills(store: GuildStore, botId: string): SkillRef[] {
764
+ const detail = store.botDetail(botId);
765
+ return (detail.skillIds ?? [])
766
+ .map((id) => store.getLibrary("skills", id))
767
+ .filter((item): item is NonNullable<typeof item> => Boolean(item))
768
+ .map((item) => ({
769
+ name: item.name,
770
+ slug: item.slug,
771
+ body: item.body,
772
+ description: item.description,
773
+ }));
774
+ }
775
+
776
+ function skillLookupKey(value: string): string {
777
+ return value.trim().replace(/^\/+/, "").toLowerCase();
778
+ }
779
+
780
+ /** Skills named with `/slug` this turn, including host leftover not staffed on the bot. */
781
+ export function extraTurnSkills(store: GuildStore, text: string): SkillRef[] {
782
+ const names = slashNames(text).slice(0, 8);
783
+ if (!names.length) return [];
784
+ const want = new Set(names);
785
+ const out: SkillRef[] = [];
786
+ const seen = new Set<string>();
787
+ const push = (item: {
788
+ name: string;
789
+ slug: string;
790
+ body: string;
791
+ description?: string;
792
+ path?: string;
793
+ }) => {
794
+ const slug = skillLookupKey(item.slug || item.name);
795
+ const name = skillLookupKey(item.name);
796
+ if (!want.has(slug) && !want.has(name)) return;
797
+ if (seen.has(slug) || seen.has(name)) return;
798
+ seen.add(slug);
799
+ if (name) seen.add(name);
800
+ out.push({
801
+ name: item.name,
802
+ slug: item.slug,
803
+ body: item.body,
804
+ description: item.description,
805
+ path: item.path,
806
+ });
807
+ };
808
+ for (const item of store.listLibrary("skills")) push(item);
809
+ for (const item of listHostSkills()) push(item);
810
+ return out;
811
+ }
812
+
813
+ export function extraTurnSubagents(
814
+ text: string,
815
+ agents: SubAgentRef[],
816
+ ): SubAgentRef[] {
817
+ const names = new Set(slashNames(text).map((name) => name.toLowerCase()));
818
+ if (!names.size) return [];
819
+ const out: SubAgentRef[] = [];
820
+ const seen = new Set<string>();
821
+ for (const item of agents) {
822
+ const slug = skillLookupKey(item.slug || item.name);
823
+ const name = skillLookupKey(item.name);
824
+ if (!names.has(slug) && !names.has(name)) continue;
825
+ if (seen.has(slug)) continue;
826
+ seen.add(slug);
827
+ out.push(item);
828
+ }
829
+ return out;
830
+ }
831
+
832
+ function mergeSkillRefs(staffed: SkillRef[], extra: SkillRef[]): SkillRef[] {
833
+ const seen = new Set(
834
+ staffed.map((item) => skillLookupKey(item.slug || item.name)),
835
+ );
836
+ const out = staffed.slice();
837
+ for (const item of extra) {
838
+ const key = skillLookupKey(item.slug || item.name);
839
+ if (!key || seen.has(key)) continue;
840
+ seen.add(key);
841
+ out.push(item);
842
+ }
843
+ return out;
844
+ }
845
+
846
+ /** POST /channels/:id/messages and DM replies share this turn input. */
847
+ export function chatTurnForBot(
848
+ store: GuildStore,
849
+ roomId: string,
850
+ botId: string,
851
+ history: HistoryItem[] = [],
852
+ userMessage = "",
853
+ slashText?: string,
854
+ ) {
855
+ const detail = store.botDetail(botId);
856
+ const room = store.getRoom(roomId);
857
+ const asked = slashText ?? userMessage;
858
+ const subagents = listSpawnRefs(store.listLibrary("subagents"));
859
+ return {
860
+ botName: detail.name,
861
+ handle: detail.handle,
862
+ soul: detail.soul?.body ?? "",
863
+ agent: detail.agent?.body ?? "",
864
+ position: detail.position?.body ?? "",
865
+ history,
866
+ userMessage,
867
+ dataDir: store.dataDir,
868
+ model: detail.model ?? null,
869
+ skills: mergeSkillRefs(
870
+ staffedSkills(store, botId),
871
+ extraTurnSkills(store, asked),
872
+ ),
873
+ subagents,
874
+ wantSpawn: extraTurnSubagents(asked, subagents),
875
+ channelMd: channelMarkdownForRoom(store, roomId),
876
+ botMemory: store.readBotMemory(botId),
877
+ channelMemory:
878
+ room?.kind === "channel" ? store.readChannelMemory(roomId) : "",
879
+ compact: store.readCompact(roomId),
880
+ onCompact: (checkpoint) => store.writeCompact(roomId, checkpoint),
881
+ };
882
+ }
883
+
884
+ export function chatTurnSystem(
885
+ store: GuildStore,
886
+ roomId: string,
887
+ botId: string,
888
+ ): string {
889
+ return buildChatSystem(chatTurnForBot(store, roomId, botId));
890
+ }
891
+
892
+ function liveDetail(trace: ToolTrace): string {
893
+ const args = trace.args || {};
894
+ if (trace.name === "run") {
895
+ return String(args.description || args.command || "")
896
+ .replace(/\s+/g, " ")
897
+ .trim();
898
+ }
899
+ if (trace.name === "skill") return String(args.name || "");
900
+ if (trace.name === "image_gen") return String(args.prompt || "");
901
+ if (trace.name === "spawn") {
902
+ return String(
903
+ args.title ||
904
+ args.description ||
905
+ args.profile ||
906
+ args.name ||
907
+ args.task ||
908
+ args.prompt ||
909
+ "",
910
+ );
911
+ }
912
+ if (trace.name === "read_spawn") {
913
+ return String(args.agent_id || args.id || "");
914
+ }
915
+ if (trace.name.startsWith("mcp__")) {
916
+ return JSON.stringify(args).slice(0, 120);
917
+ }
918
+ return String(args.path || "");
919
+ }
920
+
921
+ const LIVE_TRACE_CAP = 100;
922
+ const LIVE_TRACE_TEXT = 4_000;
923
+ const LIVE_ARGS_CAP = 4_000;
924
+
925
+ function clipLiveArgs(args: Record<string, unknown>): Record<string, unknown> {
926
+ try {
927
+ const raw = JSON.stringify(args);
928
+ if (!raw || raw.length <= LIVE_ARGS_CAP) return args;
929
+ return { preview: raw.slice(0, LIVE_ARGS_CAP) };
930
+ } catch {
931
+ return {};
932
+ }
933
+ }
934
+
935
+ function clipLiveTraces(traces: ToolTrace[] | undefined): LiveTurn["traces"] {
936
+ return (traces || []).slice(-LIVE_TRACE_CAP).map((tr) => ({
937
+ name: tr.name,
938
+ args: clipLiveArgs(tr.args || {}),
939
+ text: String(tr.text || "").slice(0, LIVE_TRACE_TEXT),
940
+ isError: Boolean(tr.isError),
941
+ running: tr.running,
942
+ }));
943
+ }
944
+
945
+ function publicLiveTurn(live: LiveTurn): LiveTurn {
946
+ return {
947
+ botId: live.botId,
948
+ thinking: live.thinking,
949
+ steps: live.steps,
950
+ startedAt: live.startedAt,
951
+ };
952
+ }
953
+
954
+ export function toLiveTurn(botId: string, update: ToolProgress): LiveTurn {
955
+ const thinking = (update.thinking || "").trim();
956
+ const tools: LiveStep[] = (update.traces || []).map((tr) => ({
957
+ name: tr.name,
958
+ detail: liveDetail(tr).slice(0, 120),
959
+ running: tr.running,
960
+ }));
961
+ const steps: LiveStep[] = [];
962
+ if (thinking) {
963
+ steps.push({
964
+ name: "think",
965
+ detail: thinking.split(/\n/)[0].replace(/\s+/g, " ").trim().slice(0, 120),
966
+ });
967
+ }
968
+ steps.push(...tools.slice(thinking ? -4 : -5));
969
+ return { botId, thinking, steps, traces: clipLiveTraces(update.traces) };
970
+ }
971
+
972
+ function liveTrajectoryForRoom(
973
+ store: GuildStore,
974
+ roomId: string,
975
+ logged?: { seq: number; botId?: string; kind: string; ts: string }[],
976
+ ) {
977
+ const liveTurns = store.listLiveRoomTurns(roomId);
978
+ if (!liveTurns.length) return [];
979
+ const events = logged ?? store.listTrajectory(roomId);
980
+ let seq = events.length ? events[events.length - 1].seq + 1 : 0;
981
+ return liveTurns.flatMap((turn) => {
982
+ const started = turn.startedAt ? Date.parse(turn.startedAt) : 0;
983
+ const already = events.some(
984
+ (event) =>
985
+ event.botId === turn.botId &&
986
+ event.kind === "assistant" &&
987
+ (!started || Date.parse(event.ts) >= started),
988
+ );
989
+ if (already) return [];
990
+ return liveTrajectoryEvents({
991
+ botId: turn.botId,
992
+ thinking: turn.thinking,
993
+ traces: turn.traces,
994
+ startedAt: turn.startedAt,
995
+ }).map((draft) => ({ ...draft, seq: seq++, live: true as const }));
996
+ });
997
+ }
998
+
999
+ export function getLiveTurn(store: GuildStore, roomId: string) {
1000
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1001
+ const decorate = (live: LiveTurn): LiveTurn => {
1002
+ const shown = publicLiveTurn(live);
1003
+ const pending = store.peekSteers(roomId, live.botId);
1004
+ if (!pending.length) return shown;
1005
+ const steers: LiveStep[] = pending.map((text) => ({
1006
+ name: "steer",
1007
+ detail: text.replace(/\s+/g, " ").trim().slice(0, 120),
1008
+ running: true,
1009
+ }));
1010
+ const rest = shown.steps.filter((step) => step.name !== "steer");
1011
+ return { ...shown, steps: [...steers, ...rest].slice(0, 5) };
1012
+ };
1013
+ const bots = store.listLiveRoomTurns(roomId).map(decorate);
1014
+ const live = bots[bots.length - 1] ?? {
1015
+ botId: "",
1016
+ thinking: "",
1017
+ steps: store.peekSteers(roomId).map((text) => ({
1018
+ name: "steer" as const,
1019
+ detail: text.replace(/\s+/g, " ").trim().slice(0, 120),
1020
+ running: true,
1021
+ })),
1022
+ };
1023
+ return { ...live, bots, traj: liveTrajectoryForRoom(store, roomId) };
1024
+ }
1025
+
1026
+ export function abortLiveTurn(
1027
+ store: GuildStore,
1028
+ roomId: string,
1029
+ botId?: string,
1030
+ ) {
1031
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1032
+ const live = botId
1033
+ ? store.getLiveBotTurn(roomId, botId)
1034
+ : store.getLiveTurn(roomId);
1035
+ const had = store.abortTurn(roomId, botId);
1036
+ if (!live && !had) throw new StoreError(409, "no live turn");
1037
+ return { ok: true };
1038
+ }
1039
+
1040
+ function isAbortError(err: unknown): boolean {
1041
+ return Boolean(
1042
+ err &&
1043
+ typeof err === "object" &&
1044
+ "name" in err &&
1045
+ (err as { name: string }).name === "AbortError",
1046
+ );
1047
+ }
1048
+
1049
+ export function steerUserMessage(
1050
+ store: GuildStore,
1051
+ roomId: string,
1052
+ body: string,
1053
+ attachments?: ChatAttachment[],
1054
+ replyTo?: string,
1055
+ botId?: string,
1056
+ ) {
1057
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1058
+ const live = store.listLiveRoomTurns(roomId);
1059
+ if (!live.length) {
1060
+ throw new StoreError(409, "no live turn");
1061
+ }
1062
+ const packed = parseAttachments(attachments);
1063
+ const tokens = packed?.map((att) => att.token).join(" ") || "";
1064
+ const text = body.trim() || tokens;
1065
+ const parent = parentMessage(store.listMessages(roomId), replyTo);
1066
+ const message = store.appendMessage(
1067
+ roomId,
1068
+ "you",
1069
+ text,
1070
+ undefined,
1071
+ parent?.id,
1072
+ packed,
1073
+ undefined,
1074
+ true,
1075
+ botId,
1076
+ );
1077
+ try {
1078
+ store.appendTrajectory(roomId, [
1079
+ userTrajectoryEvent(message.id, message.body, message.createdAt),
1080
+ ]);
1081
+ } catch {
1082
+ /* ignore */
1083
+ }
1084
+ const asked = askedText(store, parent, message);
1085
+ if (botId && live.some((turn) => turn.botId === botId)) {
1086
+ store.pushSteer(roomId, asked, botId);
1087
+ } else {
1088
+ for (const turn of live) store.pushSteer(roomId, asked, turn.botId);
1089
+ }
1090
+ return { message };
1091
+ }
1092
+
1093
+ async function generateReplies(
1094
+ store: GuildStore,
1095
+ roomId: string,
1096
+ memberIds: string[],
1097
+ userMessage: { body: string; attachments?: ChatAttachment[] },
1098
+ history: HistoryItem[],
1099
+ onlyBotId?: string,
1100
+ env: NodeJS.ProcessEnv = process.env,
1101
+ parent?: ChatMessage,
1102
+ extras: HandlerExtras = {},
1103
+ ) {
1104
+ const extraBotId = hasExplicitSummon(store, userMessage.body)
1105
+ ? undefined
1106
+ : followBotId(store, history, parent);
1107
+ const asked = askedText(store, parent, userMessage);
1108
+ const targets = onlyBotId
1109
+ ? [onlyBotId]
1110
+ : replyBots(store, memberIds, userMessage.body, extraBotId);
1111
+ const replies: ChatMessage[] = [];
1112
+ const harvested: { handle: string; author: string; body: string }[] = [];
1113
+ const signal = store.beginTurn(roomId, targets);
1114
+ plantLiveTurns(store, roomId, targets, memberIds, onlyBotId);
1115
+ const mcpTools = await resolveMcpTools(store, extras);
1116
+ const speak = async (
1117
+ botId: string,
1118
+ turnAsked: string,
1119
+ turnHistory: HistoryItem[],
1120
+ ) => {
1121
+ if (!memberIds.includes(botId) && !onlyBotId) return;
1122
+ if (signal.aborted) return;
1123
+ const prev = store.getLiveBotTurn(roomId, botId);
1124
+ const startedAt = prev?.startedAt || new Date().toISOString();
1125
+ store.dropLastFailedReply(roomId, botId);
1126
+ store.setLiveTurn(roomId, {
1127
+ botId,
1128
+ thinking: prev?.thinking || "",
1129
+ steps: prev?.steps || [],
1130
+ startedAt,
1131
+ });
1132
+ let generated;
1133
+ try {
1134
+ generated = await (extras.turn ?? chatReply)({
1135
+ ...chatTurnForBot(
1136
+ store,
1137
+ roomId,
1138
+ botId,
1139
+ turnHistory,
1140
+ turnAsked,
1141
+ userMessage.body,
1142
+ ),
1143
+ env,
1144
+ signal,
1145
+ mcpTools,
1146
+ onProgress: (update) => {
1147
+ const prev = store.getLiveBotTurn(roomId, botId);
1148
+ const next = toLiveTurn(botId, update);
1149
+ const handoff = (prev?.steps || []).find((step) => step.name === "handoff");
1150
+ const pendingSteers = store.peekSteers(roomId, botId).map((text) => ({
1151
+ name: "steer" as const,
1152
+ detail: text.replace(/\s+/g, " ").trim().slice(0, 120),
1153
+ running: true,
1154
+ }));
1155
+ const keptSteer =
1156
+ pendingSteers.length > 0
1157
+ ? pendingSteers
1158
+ : (prev?.steps || []).filter((step) => step.name === "steer");
1159
+ const rest = next.steps.filter(
1160
+ (step) => step.name !== "handoff" && step.name !== "steer",
1161
+ );
1162
+ store.setLiveTurn(roomId, {
1163
+ ...next,
1164
+ startedAt: prev?.startedAt || startedAt,
1165
+ steps: [...(handoff ? [handoff] : []), ...keptSteer, ...rest].slice(0, 5),
1166
+ });
1167
+ },
1168
+ pullSteers: () => store.drainSteers(roomId, botId),
1169
+ });
1170
+ } catch (err) {
1171
+ if (isAbortError(err) || signal.aborted) return;
1172
+ throw err;
1173
+ }
1174
+ const usage = { ...(generated.usage || {}), startedAt };
1175
+ const reply = store.appendMessage(
1176
+ roomId,
1177
+ botId,
1178
+ generated.body,
1179
+ generated.parts,
1180
+ undefined,
1181
+ undefined,
1182
+ usage,
1183
+ );
1184
+ store.dropLiveBotTurn(roomId, botId);
1185
+ recordTurn(store, roomId, botId, generated, reply);
1186
+ replies.push(reply);
1187
+ extras.onTurnComplete?.({
1188
+ roomId,
1189
+ botId,
1190
+ userText: turnAsked,
1191
+ reply: generated.body,
1192
+ });
1193
+ if (generated.source === "llm") {
1194
+ harvested.push({
1195
+ handle: store.getBot(botId)?.handle || botId,
1196
+ author: botId,
1197
+ body: generated.body,
1198
+ });
1199
+ if (extras.harvest !== false) {
1200
+ await harvestBotMemory({
1201
+ store,
1202
+ botId,
1203
+ userMessage: turnAsked,
1204
+ reply: generated.body,
1205
+ env,
1206
+ prefer: store.getBot(botId)?.model ?? null,
1207
+ }).catch(() => {});
1208
+ }
1209
+ }
1210
+ };
1211
+ try {
1212
+ const handleList = store.listBots().map((bot) => bot.handle);
1213
+ await Promise.all(
1214
+ targets.map((botId) => {
1215
+ const handle = store.getBot(botId)?.handle || "";
1216
+ const body = assignmentFor(userMessage.body, handle, handleList);
1217
+ const turnAsked = askedText(store, parent, {
1218
+ body,
1219
+ attachments: userMessage.attachments,
1220
+ });
1221
+ return speak(botId, turnAsked, history);
1222
+ }),
1223
+ );
1224
+ for (let wave = 0; wave < CHANNEL_ROSTER_CAP && !signal.aborted; wave++) {
1225
+ const hops = handoffTargets(store, memberIds, replies, asked, history);
1226
+ if (!hops.length) break;
1227
+ const hopAt = new Date().toISOString();
1228
+ for (const hop of hops) {
1229
+ store.adoptTurn(roomId, hop.botId, signal);
1230
+ store.setLiveTurn(roomId, {
1231
+ botId: hop.botId,
1232
+ thinking: "",
1233
+ steps: [
1234
+ {
1235
+ name: "handoff",
1236
+ detail: `@${hop.fromHandle}`,
1237
+ running: true,
1238
+ },
1239
+ ],
1240
+ startedAt: hopAt,
1241
+ });
1242
+ }
1243
+ await Promise.all(
1244
+ hops.map((hop) => speak(hop.botId, hop.asked, hop.history)),
1245
+ );
1246
+ }
1247
+ const room = store.getRoom(roomId);
1248
+ if (room?.kind === "channel" && harvested.length && extras.harvest !== false) {
1249
+ await harvestChannelMemory({
1250
+ store,
1251
+ roomId,
1252
+ userMessage: asked,
1253
+ replies: harvested,
1254
+ env,
1255
+ prefer: store.getBot(harvested[0].author)?.model ?? null,
1256
+ }).catch(() => {});
1257
+ }
1258
+ return replies;
1259
+ } finally {
1260
+ store.endTurn(roomId, signal);
1261
+ }
1262
+ }
1263
+
1264
+ function recordTurn(
1265
+ store: GuildStore,
1266
+ roomId: string,
1267
+ botId: string,
1268
+ generated: ChatReply,
1269
+ reply: ChatMessage,
1270
+ ) {
1271
+ try {
1272
+ store.appendTrajectory(
1273
+ roomId,
1274
+ turnTrajectoryEvents({
1275
+ turnId: reply.id,
1276
+ botId,
1277
+ ts: reply.createdAt,
1278
+ system: generated.system,
1279
+ channelMd: channelMarkdownForRoom(store, roomId),
1280
+ model: generated.model,
1281
+ thinking: generated.thinking,
1282
+ traces: generated.traces,
1283
+ text: generated.body,
1284
+ source: generated.source,
1285
+ }),
1286
+ );
1287
+ } catch {
1288
+ /* trajectory must never break chat */
1289
+ }
1290
+ }
1291
+
1292
+ export function listRoomTrajectory(store: GuildStore, roomId: string) {
1293
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1294
+ const logged = store.listTrajectory(roomId);
1295
+ const base = logged.length
1296
+ ? { source: "log" as const, events: logged }
1297
+ : {
1298
+ source: "derived" as const,
1299
+ events: synthesizeTrajectory(store.listMessages(roomId)),
1300
+ };
1301
+ const extra = liveTrajectoryForRoom(store, roomId, base.events);
1302
+ const open = store.listLiveRoomTurns(roomId).some((turn) => {
1303
+ const started = turn.startedAt ? Date.parse(turn.startedAt) : 0;
1304
+ return !base.events.some(
1305
+ (event) =>
1306
+ event.botId === turn.botId &&
1307
+ event.kind === "assistant" &&
1308
+ (!started || Date.parse(event.ts) >= started),
1309
+ );
1310
+ });
1311
+ return {
1312
+ ...base,
1313
+ events: base.events.map(promoteSpawnEvent).concat(extra),
1314
+ live: extra.length > 0 || open,
1315
+ };
1316
+ }
1317
+
1318
+ function parentMessage(
1319
+ messages: ChatMessage[],
1320
+ replyTo?: string,
1321
+ ): ChatMessage | undefined {
1322
+ const id = replyTo?.trim();
1323
+ if (!id) return undefined;
1324
+ return messages.find((item) => item.id === id);
1325
+ }
1326
+
1327
+ function lastBotSpeaker(
1328
+ store: GuildStore,
1329
+ messages: { author: string }[],
1330
+ ): string | undefined {
1331
+ for (let i = messages.length - 1; i >= 0; i--) {
1332
+ const author = messages[i].author;
1333
+ if (author && author !== "you" && store.getBot(author)) return author;
1334
+ }
1335
+ return undefined;
1336
+ }
1337
+
1338
+ function plantLiveTurns(
1339
+ store: GuildStore,
1340
+ roomId: string,
1341
+ botIds: string[],
1342
+ memberIds: string[],
1343
+ onlyBotId?: string,
1344
+ ): string {
1345
+ const startedAt = new Date().toISOString();
1346
+ for (const botId of botIds) {
1347
+ if (!botId) continue;
1348
+ if (!memberIds.includes(botId) && !onlyBotId) continue;
1349
+ store.dropLastFailedReply(roomId, botId);
1350
+ store.setLiveTurn(roomId, {
1351
+ botId,
1352
+ thinking: "",
1353
+ steps: [],
1354
+ startedAt,
1355
+ });
1356
+ }
1357
+ return startedAt;
1358
+ }
1359
+
1360
+ async function resolveMcpTools(
1361
+ store: GuildStore,
1362
+ extras: HandlerExtras,
1363
+ ): Promise<McpToolRef[]> {
1364
+ if (extras.mcp === false) return [];
1365
+ if (extras.mcpTools !== undefined) return extras.mcpTools;
1366
+ return listMcpToolRefs(store.dataDir);
1367
+ }
1368
+
1369
+ function followBotId(
1370
+ store: GuildStore,
1371
+ messages: { author: string }[],
1372
+ parent?: ChatMessage,
1373
+ ): string | undefined {
1374
+ if (parent && parent.author !== "you" && store.getBot(parent.author)) {
1375
+ return parent.author;
1376
+ }
1377
+ return lastBotSpeaker(store, messages);
1378
+ }
1379
+
1380
+ function hasExplicitSummon(store: GuildStore, userText: string): boolean {
1381
+ if (isBroadcastMention(userText)) return true;
1382
+ return (
1383
+ summonedHandles(
1384
+ userText,
1385
+ store.listBots().map((bot) => bot.handle),
1386
+ ).length > 0
1387
+ );
1388
+ }
1389
+
1390
+ function includeFollowBot(
1391
+ store: GuildStore,
1392
+ roomId: string,
1393
+ memberIds: string[],
1394
+ follow?: string,
1395
+ ): string[] {
1396
+ if (!follow || memberIds.includes(follow)) return memberIds;
1397
+ try {
1398
+ store.addMember(roomId, follow);
1399
+ return [...memberIds, follow];
1400
+ } catch {
1401
+ return memberIds;
1402
+ }
1403
+ }
1404
+
1405
+ function inviteAssignee(
1406
+ store: GuildStore,
1407
+ roomId: string,
1408
+ assigneeId: string,
1409
+ ): string[] {
1410
+ const room = store.getRoom(roomId);
1411
+ if (!room) return [];
1412
+ if (room.kind !== "channel") return room.memberIds;
1413
+ if (room.memberIds.includes(assigneeId)) return room.memberIds;
1414
+ if (!store.getBot(assigneeId)) {
1415
+ throw new StoreError(400, "assignee does not exist");
1416
+ }
1417
+ store.addMember(roomId, assigneeId);
1418
+ return [...room.memberIds, assigneeId];
1419
+ }
1420
+
1421
+ export async function postUserMessage(
1422
+ store: GuildStore,
1423
+ roomId: string,
1424
+ body: string,
1425
+ env: NodeJS.ProcessEnv = process.env,
1426
+ replyTo?: string,
1427
+ attachments?: ChatAttachment[],
1428
+ assigneeId?: string,
1429
+ extras: HandlerExtras = {},
1430
+ ) {
1431
+ const room = store.getRoom(roomId);
1432
+ if (!room) throw new StoreError(404, "room not found");
1433
+ const previous = store.listMessages(roomId);
1434
+ const parent = parentMessage(previous, replyTo);
1435
+ const packed = parseAttachments(attachments);
1436
+ const tokens = packed?.map((att) => att.token).join(" ") || "";
1437
+ const text = body.trim() || tokens;
1438
+ const message = store.appendMessage(
1439
+ roomId,
1440
+ "you",
1441
+ text,
1442
+ undefined,
1443
+ parent ? parent.id : undefined,
1444
+ packed,
1445
+ );
1446
+ try {
1447
+ store.appendTrajectory(roomId, [
1448
+ userTrajectoryEvent(message.id, message.body, message.createdAt),
1449
+ ]);
1450
+ } catch {
1451
+ /* ignore */
1452
+ }
1453
+ const history = previous.map(toHistoryItem);
1454
+ const assignee = assigneeId?.trim();
1455
+ let memberIds = assignee
1456
+ ? inviteAssignee(store, roomId, assignee)
1457
+ : inviteMentionedBots(store, roomId, message.body);
1458
+ if (!assignee && !hasExplicitSummon(store, message.body)) {
1459
+ memberIds = includeFollowBot(
1460
+ store,
1461
+ roomId,
1462
+ memberIds,
1463
+ followBotId(store, previous, parent),
1464
+ );
1465
+ }
1466
+ const replies = await generateReplies(
1467
+ store,
1468
+ roomId,
1469
+ memberIds,
1470
+ message,
1471
+ history,
1472
+ assignee || undefined,
1473
+ env,
1474
+ parent,
1475
+ extras,
1476
+ );
1477
+ return { message, replies };
1478
+ }
1479
+
1480
+ export async function retryMessage(
1481
+ store: GuildStore,
1482
+ roomId: string,
1483
+ messageId: string,
1484
+ body?: string,
1485
+ env: NodeJS.ProcessEnv = process.env,
1486
+ assigneeId?: string,
1487
+ extras: HandlerExtras = {},
1488
+ ) {
1489
+ const room = store.getRoom(roomId);
1490
+ if (!room) throw new StoreError(404, "room not found");
1491
+ const messages = store.listMessages(roomId);
1492
+ const index = messages.findIndex((item) => item.id === messageId);
1493
+ if (index < 0) throw new StoreError(404, "message not found");
1494
+ const current = messages[index];
1495
+
1496
+ if (current.author === "you") {
1497
+ const message =
1498
+ typeof body === "string" && body.trim()
1499
+ ? store.updateMessage(roomId, messageId, body)
1500
+ : current;
1501
+ store.truncateAfter(roomId, messageId);
1502
+ const kept = store.listMessages(roomId);
1503
+ const history = kept.slice(0, -1).map(toHistoryItem);
1504
+ const parent = parentMessage(kept.slice(0, -1), message.replyTo);
1505
+ const assignee = assigneeId?.trim();
1506
+ let memberIds = assignee
1507
+ ? inviteAssignee(store, roomId, assignee)
1508
+ : inviteMentionedBots(store, roomId, message.body);
1509
+ if (!assignee && !hasExplicitSummon(store, message.body)) {
1510
+ memberIds = includeFollowBot(
1511
+ store,
1512
+ roomId,
1513
+ memberIds,
1514
+ followBotId(store, kept.slice(0, -1), parent),
1515
+ );
1516
+ }
1517
+ const replies = await generateReplies(
1518
+ store,
1519
+ roomId,
1520
+ memberIds,
1521
+ message,
1522
+ history,
1523
+ assignee || undefined,
1524
+ env,
1525
+ parent,
1526
+ extras,
1527
+ );
1528
+ return { message, replies };
1529
+ }
1530
+
1531
+ let userIndex = index - 1;
1532
+ while (userIndex >= 0 && messages[userIndex].author !== "you") userIndex -= 1;
1533
+ if (userIndex < 0) throw new StoreError(400, "no user message to retry");
1534
+ const userMessage = messages[userIndex];
1535
+ const history = messages.slice(0, userIndex).map(toHistoryItem);
1536
+ const startedAt = new Date().toISOString();
1537
+ const signal = store.beginTurn(roomId, [current.author]);
1538
+ store.setLiveTurn(roomId, {
1539
+ botId: current.author,
1540
+ thinking: "",
1541
+ steps: [],
1542
+ startedAt,
1543
+ });
1544
+ const mcpTools = await resolveMcpTools(store, extras);
1545
+ let generated;
1546
+ try {
1547
+ generated = await (extras.turn ?? chatReply)({
1548
+ ...chatTurnForBot(
1549
+ store,
1550
+ roomId,
1551
+ current.author,
1552
+ history,
1553
+ userMessage.body,
1554
+ userMessage.body,
1555
+ ),
1556
+ env,
1557
+ signal,
1558
+ mcpTools,
1559
+ onProgress: (update) => {
1560
+ const prev = store.getLiveBotTurn(roomId, current.author);
1561
+ store.setLiveTurn(roomId, {
1562
+ ...toLiveTurn(current.author, update),
1563
+ startedAt: prev?.startedAt || startedAt,
1564
+ });
1565
+ },
1566
+ pullSteers: () => store.drainSteers(roomId, current.author),
1567
+ });
1568
+ } catch (err) {
1569
+ if (isAbortError(err) || signal.aborted) {
1570
+ return { message: userMessage, replies: [] };
1571
+ }
1572
+ throw err;
1573
+ } finally {
1574
+ store.endTurn(roomId, signal);
1575
+ }
1576
+ const usage = { ...(generated.usage || {}), startedAt };
1577
+ const reply = store.replaceMessage(
1578
+ roomId,
1579
+ messageId,
1580
+ generated.body,
1581
+ generated.parts,
1582
+ usage,
1583
+ );
1584
+ recordTurn(store, roomId, current.author, generated, reply);
1585
+ extras.onTurnComplete?.({
1586
+ roomId,
1587
+ botId: current.author,
1588
+ userText: userMessage.body,
1589
+ reply: generated.body,
1590
+ });
1591
+ if (generated.source === "llm" && extras.harvest !== false) {
1592
+ await harvestBotMemory({
1593
+ store,
1594
+ botId: current.author,
1595
+ userMessage: userMessage.body,
1596
+ reply: generated.body,
1597
+ env,
1598
+ prefer: store.getBot(current.author)?.model ?? null,
1599
+ }).catch(() => {});
1600
+ const roomAfter = store.getRoom(roomId);
1601
+ if (roomAfter?.kind === "channel") {
1602
+ await harvestChannelMemory({
1603
+ store,
1604
+ roomId,
1605
+ userMessage: userMessage.body,
1606
+ replies: [
1607
+ {
1608
+ handle: store.getBot(current.author)?.handle,
1609
+ author: current.author,
1610
+ body: generated.body,
1611
+ },
1612
+ ],
1613
+ env,
1614
+ prefer: store.getBot(current.author)?.model ?? null,
1615
+ }).catch(() => {});
1616
+ }
1617
+ }
1618
+ return { message: userMessage, replies: [reply] };
1619
+ }
1620
+
1621
+ export { StoreError, localGenerate };
1622
+
1623
+ export { publicModels, mergeModelsFile } from "./llm.ts";