@kevin5251984/guild 0.2.18 → 0.2.20

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/handlers.ts CHANGED
@@ -18,27 +18,41 @@ import {
18
18
  } from "./generate.ts";
19
19
  import {
20
20
  liveTrajectoryEvents,
21
+ promoteSpawnEvent,
21
22
  synthesizeTrajectory,
22
23
  turnTrajectoryEvents,
23
24
  userTrajectoryEvent,
24
25
  } from "./trajectory.ts";
25
26
  import { importFromGithub, importFromUrl } from "./skill-import.ts";
26
- import { harvestBotMemory, harvestChannelMemory } from "./memory.ts";
27
+ import {
28
+ harvestBotMemory,
29
+ harvestChannelMemory,
30
+ mergeQuestMemory,
31
+ } from "./memory.ts";
27
32
  import { listHostSkills, type HostSkill } from "./host-skills.ts";
28
- import { GuildStore, StoreError, type LiveStep, type LiveTurn } from "./store.ts";
33
+ import {
34
+ CHANNEL_ROSTER_CAP,
35
+ GuildStore,
36
+ StoreError,
37
+ type LiveStep,
38
+ type LiveTurn,
39
+ } from "./store.ts";
29
40
  import { listSpawnRefs } from "./subagent.ts";
30
41
  import {
31
42
  importHostMcp,
32
43
  listGuildMcp,
33
44
  listHostMcp,
34
45
  listMcpToolRefs,
46
+ publicMcpServer,
35
47
  removeGuildMcp,
36
48
  upsertGuildMcp,
37
49
  } from "./mcp.ts";
38
50
  import {
39
51
  assignmentFor,
40
52
  isBroadcastMention,
41
- summonedHandles,
53
+ messageMentionIds,
54
+ parseMentionIds,
55
+ sanitizeMentionIds,
42
56
  } from "./mention.ts";
43
57
  import { slashNames } from "./slash.ts";
44
58
  import { toHistoryItem, type HistoryItem } from "./compact.ts";
@@ -59,6 +73,8 @@ export type HandlerExtras = {
59
73
  mcpTools?: McpToolRef[] | Promise<McpToolRef[]>;
60
74
  onTurnComplete?: (turn: TurnComplete) => void;
61
75
  turn?: (input: Parameters<typeof chatReply>[0]) => Promise<ChatReply>;
76
+ /** Bot ids the client already resolved from @mentions. */
77
+ mentions?: string[];
62
78
  };
63
79
 
64
80
  export function healthPayload(): HealthResponse {
@@ -77,12 +93,13 @@ export function listLibrary(store: GuildStore, kind: LibraryKind) {
77
93
  return store.listLibrary(kind);
78
94
  }
79
95
 
96
+ /** HTTP surfaces never carry `launch.env` values; see `publicMcpServer`. */
80
97
  export function listMcpServers(store: GuildStore) {
81
- return listGuildMcp(store.dataDir);
98
+ return listGuildMcp(store.dataDir).map(publicMcpServer);
82
99
  }
83
100
 
84
101
  export function listHostMcpServers() {
85
- return listHostMcp();
102
+ return listHostMcp().map(publicMcpServer);
86
103
  }
87
104
 
88
105
  export function createMcpServer(
@@ -97,13 +114,15 @@ export function createMcpServer(
97
114
  },
98
115
  ) {
99
116
  try {
100
- return upsertGuildMcp(store.dataDir, input.name, {
101
- command: input.command || "",
102
- args: input.args || [],
103
- env: input.env,
104
- cwd: input.cwd,
105
- url: input.url,
106
- });
117
+ return publicMcpServer(
118
+ upsertGuildMcp(store.dataDir, input.name, {
119
+ command: input.command || "",
120
+ args: input.args || [],
121
+ env: input.env,
122
+ cwd: input.cwd,
123
+ url: input.url,
124
+ }),
125
+ );
107
126
  } catch (error) {
108
127
  throw new StoreError(
109
128
  400,
@@ -114,7 +133,7 @@ export function createMcpServer(
114
133
 
115
134
  export function importMcpServer(store: GuildStore, hostId: string) {
116
135
  try {
117
- return importHostMcp(store.dataDir, hostId);
136
+ return publicMcpServer(importHostMcp(store.dataDir, hostId));
118
137
  } catch (error) {
119
138
  throw new StoreError(
120
139
  404,
@@ -242,6 +261,7 @@ export function updateBot(
242
261
  name?: string;
243
262
  handle?: string;
244
263
  oneLiner?: string;
264
+ portrait?: string | null;
245
265
  skillIds?: string[];
246
266
  soul?: MarkdownDraft;
247
267
  agent?: MarkdownDraft;
@@ -256,6 +276,162 @@ export function updateBot(
256
276
  return store.updateBot(id, { ...input, skillIds });
257
277
  }
258
278
 
279
+ const LOOK_HAIR = [
280
+ "jet-black short crop",
281
+ "jet-black long straight hair",
282
+ "copper-red bob with blunt bangs",
283
+ "copper-red messy spikes",
284
+ "indigo long waves",
285
+ "indigo pixie cut",
286
+ "honey-blonde high ponytail",
287
+ "honey-blonde bowl cut",
288
+ "hot-pink messy spikes",
289
+ "hot-pink bob",
290
+ "ash-white curly volume",
291
+ "ash-white long hair",
292
+ "teal-tinted undercut",
293
+ "teal high ponytail",
294
+ "deep-burgundy twin braids",
295
+ "deep-burgundy shag",
296
+ ];
297
+ const LOOK_CLOTH = [
298
+ "sunflower-yellow collared shirt",
299
+ "cobalt hooded jacket",
300
+ "rose cardigan over a cream tee",
301
+ "forest-green knit turtleneck",
302
+ "ivory blouse with a coral scarf",
303
+ "charcoal work vest over a rust tee",
304
+ "lilac haori",
305
+ "orange windbreaker",
306
+ "white lab coat over a black tee",
307
+ "crimson bomber jacket",
308
+ "mint sailor collar",
309
+ "navy peacoat",
310
+ "gold-trimmed teal capelet",
311
+ "checkered red-and-black shirt",
312
+ "pale-blue denim jacket",
313
+ "magenta track jacket",
314
+ ];
315
+ const LOOK_EXTRA = [
316
+ "round wire glasses",
317
+ "small gold hoop earrings",
318
+ "over-ear headphones around the neck",
319
+ "a paintbrush tucked behind one ear",
320
+ "a red hair clip",
321
+ "a thin black choker",
322
+ "a knitted ear warmer",
323
+ "no extra accessories",
324
+ ];
325
+ const LOOK_SKIN = [
326
+ "fair peach human skin",
327
+ "warm tan human skin",
328
+ "light brown human skin",
329
+ "deep brown human skin",
330
+ "golden beige human skin",
331
+ ];
332
+ const LOOK_FACE = [
333
+ "round cheerful face with wide-set eyes",
334
+ "sharp jaw and narrow eyes",
335
+ "soft oval face with thick brows",
336
+ "heart-shaped face and a small nose",
337
+ "square face with a bright closed-mouth smile",
338
+ ];
339
+ const LOOK_RACE = [
340
+ {
341
+ id: "human" as const,
342
+ label: "human",
343
+ prompt:
344
+ "human. Ordinary rounded human ears, fully human anatomy, no fantasy ears.",
345
+ },
346
+ {
347
+ id: "dwarf" as const,
348
+ label: "dwarf",
349
+ prompt:
350
+ "young dwarf. Stout neck, broader cheekbones, a slightly larger nose, thick brows, rounded ears, youthful (not elderly, not bald).",
351
+ },
352
+ {
353
+ id: "elf" as const,
354
+ label: "elf",
355
+ prompt:
356
+ "young elf. Long pointed ears clearly visible, fine features, almond eyes, still youthful.",
357
+ },
358
+ {
359
+ id: "demihuman" as const,
360
+ label: "demihuman",
361
+ prompt:
362
+ "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.",
363
+ },
364
+ ];
365
+
366
+ function lookSeed(key: string): number {
367
+ let n = 2166136261;
368
+ for (const ch of key) {
369
+ n ^= ch.charCodeAt(0);
370
+ n = Math.imul(n, 16777619);
371
+ }
372
+ return n >>> 0;
373
+ }
374
+
375
+ export function lookTraits(bot: { name: string; handle: string }): {
376
+ hair: string;
377
+ cloth: string;
378
+ extra: string;
379
+ skin: string;
380
+ face: string;
381
+ race: (typeof LOOK_RACE)[number];
382
+ } {
383
+ const n = lookSeed(bot.handle || bot.name || "bot");
384
+ return {
385
+ hair: LOOK_HAIR[n % LOOK_HAIR.length],
386
+ cloth: LOOK_CLOTH[(n >>> 4) % LOOK_CLOTH.length],
387
+ extra: LOOK_EXTRA[(n >>> 8) % LOOK_EXTRA.length],
388
+ skin: LOOK_SKIN[(n >>> 12) % LOOK_SKIN.length],
389
+ face: LOOK_FACE[(n >>> 16) % LOOK_FACE.length],
390
+ race: LOOK_RACE[(n >>> 20) % LOOK_RACE.length],
391
+ };
392
+ }
393
+
394
+ export function lookPrompt(bot: {
395
+ name: string;
396
+ handle: string;
397
+ oneLiner?: string;
398
+ }): string {
399
+ const role = bot.oneLiner?.trim() || "keeps a seat in the guild tavern";
400
+ const look = lookTraits(bot);
401
+ return [
402
+ `SNES 16-bit pixel-art bust portrait of ${bot.name} (@${bot.handle}), a unique ${look.race.label} guild adventurer.`,
403
+ `Race (mandatory): ${look.race.prompt}`,
404
+ `Mandatory look: ${look.skin}, ${look.face}, ${look.hair}, wearing a ${look.cloth}, ${look.extra}.`,
405
+ `They ${role}.`,
406
+ "Hair color, outfit, and race are locked; do not default to brown hair or a beige coat.",
407
+ "Natural skin tones only, never green, gray, or monster skin.",
408
+ "Head and shoulders only, facing the camera, chunky 16-bit pixels, limited tavern palette, cream pixel outline.",
409
+ "Animal Crossing crossed with Earthbound, youthful SNES NPC.",
410
+ "Opaque cream or tavern-wood background, no black void, no white photo studio, no checkerboard, no transparency.",
411
+ "Close-up character headshot, no full body, no legs, no floor, no scenery, no photorealism, no 3D render, no text, no watermark.",
412
+ ].join(" ");
413
+ }
414
+
415
+ export async function generateBotLook(
416
+ store: GuildStore,
417
+ id: string,
418
+ env: NodeJS.ProcessEnv = process.env,
419
+ ) {
420
+ const bot = store.getBot(id);
421
+ if (!bot) throw new StoreError(404, "bot not found");
422
+ const { generateImage } = await import("./image-gen.ts");
423
+ const result = await generateImage({
424
+ prompt: lookPrompt(bot),
425
+ aspectRatio: "1:1",
426
+ dataDir: store.dataDir,
427
+ env,
428
+ });
429
+ if (result.isError || !result.publicPath) {
430
+ throw new StoreError(502, result.text);
431
+ }
432
+ return store.updateBot(id, { portrait: result.publicPath });
433
+ }
434
+
259
435
  export async function importSkills(
260
436
  store: GuildStore,
261
437
  input: { source: string; url?: string; repo?: string },
@@ -356,10 +532,53 @@ export function createChannel(store: GuildStore, name: string) {
356
532
  return store.createChannel(name);
357
533
  }
358
534
 
535
+ export function createBranch(
536
+ store: GuildStore,
537
+ parentId: string,
538
+ messageId: string,
539
+ name?: string,
540
+ ) {
541
+ return store.createBranch(parentId, messageId, name);
542
+ }
543
+
359
544
  export function deleteChannel(store: GuildStore, id: string) {
545
+ const room = store.getRoom(id);
546
+ if (room?.parentId) {
547
+ throw new StoreError(400, "close the branch instead");
548
+ }
360
549
  return store.deleteChannel(id);
361
550
  }
362
551
 
552
+ export async function closeBranch(
553
+ store: GuildStore,
554
+ id: string,
555
+ merge = false,
556
+ env: NodeJS.ProcessEnv = process.env,
557
+ ) {
558
+ const room = store.getRoom(id);
559
+ if (!room) throw new StoreError(404, "channel not found");
560
+ if (room.kind !== "channel") throw new StoreError(400, "not a channel");
561
+ if (!room.parentId) throw new StoreError(400, "not a branch");
562
+ const parent = store.getRoom(room.parentId);
563
+ if (!parent) throw new StoreError(404, "parent not found");
564
+ if (merge) {
565
+ for (const child of store
566
+ .listChannels()
567
+ .filter((item) => item.parentId === id)) {
568
+ await closeBranch(store, child.id, true, env);
569
+ }
570
+ await mergeQuestMemory({
571
+ store,
572
+ parentId: parent.id,
573
+ childId: id,
574
+ questName: room.name,
575
+ env,
576
+ });
577
+ }
578
+ store.deleteChannel(id);
579
+ return { ok: true as const, id, parentId: parent.id, merged: Boolean(merge) };
580
+ }
581
+
363
582
  export function renameChannel(store: GuildStore, id: string, name: string) {
364
583
  return store.renameChannel(id, name);
365
584
  }
@@ -401,16 +620,23 @@ export function openDm(store: GuildStore, botId: string) {
401
620
  return store.openDm(botId);
402
621
  }
403
622
 
623
+ /** A seat may speak twice in one turn so report-back can resume the assigner. */
624
+ const HANDOFF_SEAT_CAP = 2;
625
+
404
626
  function handoffTargets(
405
627
  store: GuildStore,
406
628
  memberIds: string[],
407
629
  replies: ChatMessage[],
408
630
  asked: string,
409
631
  history: HistoryItem[],
632
+ fresh: ChatMessage[],
410
633
  ): { botId: string; fromHandle: string; asked: string; history: HistoryItem[] }[] {
411
634
  const bots = store.listBots();
412
635
  const handles = bots.map((bot) => bot.handle);
413
- const spoke = new Set(replies.map((row) => row.author));
636
+ const spoke = new Map<string, number>();
637
+ for (const row of replies) {
638
+ spoke.set(row.author, (spoke.get(row.author) || 0) + 1);
639
+ }
414
640
  const hops: {
415
641
  botId: string;
416
642
  fromHandle: string;
@@ -418,10 +644,10 @@ function handoffTargets(
418
644
  history: HistoryItem[];
419
645
  }[] = [];
420
646
  const queued = new Set<string>();
421
- for (const reply of replies) {
647
+ for (const reply of fresh) {
422
648
  if (isBroadcastMention(reply.body)) continue;
423
- const names = summonedHandles(reply.body, handles);
424
- if (!names.length) continue;
649
+ const ids = messageMentionIds(reply, bots);
650
+ if (!ids.length) continue;
425
651
  const from = bots.find((bot) => bot.id === reply.author);
426
652
  const fromHandle = from?.handle || reply.author;
427
653
  const hopHistory = history.concat(
@@ -429,10 +655,10 @@ function handoffTargets(
429
655
  { author: reply.author, body: reply.body },
430
656
  );
431
657
  for (const bot of bots) {
432
- if (!names.includes(bot.handle.toLowerCase())) continue;
658
+ if (!ids.includes(bot.id)) continue;
433
659
  if (!memberIds.includes(bot.id)) continue;
434
660
  if (bot.id === reply.author) continue;
435
- if (spoke.has(bot.id) || queued.has(bot.id)) continue;
661
+ if ((spoke.get(bot.id) || 0) >= HANDOFF_SEAT_CAP || queued.has(bot.id)) continue;
436
662
  queued.add(bot.id);
437
663
  const spec = assignmentFor(reply.body, bot.handle, handles);
438
664
  hops.push({
@@ -449,19 +675,16 @@ function handoffTargets(
449
675
  function replyBots(
450
676
  store: GuildStore,
451
677
  memberIds: string[],
452
- userText: string,
678
+ userMessage: { author?: string; body: string; mentions?: string[] },
453
679
  extraBotId?: string,
454
680
  ): string[] {
455
- if (isBroadcastMention(userText)) return memberIds;
456
- const bots = store.listBots();
457
- const names = summonedHandles(
458
- userText,
459
- bots.map((bot) => bot.handle),
681
+ if (isBroadcastMention(userMessage.body)) return memberIds;
682
+ const mentioned = new Set(
683
+ messageMentionIds(
684
+ { author: userMessage.author || "you", body: userMessage.body, mentions: userMessage.mentions },
685
+ store.listBots(),
686
+ ),
460
687
  );
461
- const mentioned = new Set<string>();
462
- for (const bot of bots) {
463
- if (names.includes(bot.handle.toLowerCase())) mentioned.add(bot.id);
464
- }
465
688
  if (mentioned.size > 0) {
466
689
  return memberIds.filter((id) => mentioned.has(id));
467
690
  }
@@ -480,7 +703,11 @@ function turnUserMessage(
480
703
  store.listBots().find((bot) => bot.id === parent.author)?.handle ||
481
704
  parent.author;
482
705
  const preview = parent.body.replace(/\s+/g, " ").trim().slice(0, 240);
483
- return `(回覆 @${handle}:${preview})\n${body}`;
706
+ return [
707
+ `(回覆 @${handle}。只做下一句;引言裡的交棒已經發生過,不要再叫別人。)`,
708
+ `> ${preview}`,
709
+ body,
710
+ ].join("\n");
484
711
  }
485
712
 
486
713
  const ATTACH_TOKEN = /^\[[A-Za-z]+ #\d+\]$/;
@@ -530,28 +757,14 @@ function askedText(
530
757
  return `附件:\n${legend}\n\n${asked}`;
531
758
  }
532
759
 
533
- /** @handle of a bot not in this channel adds them, then they can reply. */
760
+ /** Roster is staffed in the members panel. @handle never pulls a new seat. */
534
761
  export function inviteMentionedBots(
535
762
  store: GuildStore,
536
763
  roomId: string,
537
- userText: string,
764
+ _userMessage?: { author?: string; body: string; mentions?: string[] },
538
765
  ): string[] {
539
766
  const room = store.getRoom(roomId);
540
- if (!room) return [];
541
- if (room.kind !== "channel") return room.memberIds;
542
- if (isBroadcastMention(userText)) return room.memberIds;
543
- const names = summonedHandles(
544
- userText,
545
- store.listBots().map((bot) => bot.handle),
546
- );
547
- let memberIds = room.memberIds;
548
- for (const bot of store.listBots()) {
549
- if (!names.includes(bot.handle.toLowerCase())) continue;
550
- if (memberIds.includes(bot.id)) continue;
551
- store.addMember(roomId, bot.id);
552
- memberIds = [...memberIds, bot.id];
553
- }
554
- return memberIds;
767
+ return room?.memberIds ?? [];
555
768
  }
556
769
 
557
770
  export function channelMarkdownForRoom(
@@ -734,7 +947,18 @@ function liveDetail(trace: ToolTrace): string {
734
947
  if (trace.name === "skill") return String(args.name || "");
735
948
  if (trace.name === "image_gen") return String(args.prompt || "");
736
949
  if (trace.name === "spawn") {
737
- return String(args.description || args.name || args.prompt || "");
950
+ return String(
951
+ args.title ||
952
+ args.description ||
953
+ args.profile ||
954
+ args.name ||
955
+ args.task ||
956
+ args.prompt ||
957
+ "",
958
+ );
959
+ }
960
+ if (trace.name === "read_spawn") {
961
+ return String(args.agent_id || args.id || "");
738
962
  }
739
963
  if (trace.name.startsWith("mcp__")) {
740
964
  return JSON.stringify(args).slice(0, 120);
@@ -918,20 +1142,20 @@ async function generateReplies(
918
1142
  store: GuildStore,
919
1143
  roomId: string,
920
1144
  memberIds: string[],
921
- userMessage: { body: string; attachments?: ChatAttachment[] },
1145
+ userMessage: { author?: string; body: string; attachments?: ChatAttachment[]; mentions?: string[] },
922
1146
  history: HistoryItem[],
923
1147
  onlyBotId?: string,
924
1148
  env: NodeJS.ProcessEnv = process.env,
925
1149
  parent?: ChatMessage,
926
1150
  extras: HandlerExtras = {},
927
1151
  ) {
928
- const extraBotId = hasExplicitSummon(store, userMessage.body)
1152
+ const extraBotId = hasExplicitSummon(store, userMessage)
929
1153
  ? undefined
930
1154
  : followBotId(store, history, parent);
931
1155
  const asked = askedText(store, parent, userMessage);
932
1156
  const targets = onlyBotId
933
1157
  ? [onlyBotId]
934
- : replyBots(store, memberIds, userMessage.body, extraBotId);
1158
+ : replyBots(store, memberIds, userMessage, extraBotId);
935
1159
  const replies: ChatMessage[] = [];
936
1160
  const harvested: { handle: string; author: string; body: string }[] = [];
937
1161
  const signal = store.beginTurn(roomId, targets);
@@ -942,7 +1166,7 @@ async function generateReplies(
942
1166
  turnAsked: string,
943
1167
  turnHistory: HistoryItem[],
944
1168
  ) => {
945
- if (!memberIds.includes(botId) && !onlyBotId) return;
1169
+ if (!memberIds.includes(botId)) return;
946
1170
  if (signal.aborted) return;
947
1171
  const prev = store.getLiveBotTurn(roomId, botId);
948
1172
  const startedAt = prev?.startedAt || new Date().toISOString();
@@ -996,6 +1220,11 @@ async function generateReplies(
996
1220
  throw err;
997
1221
  }
998
1222
  const usage = { ...(generated.usage || {}), startedAt };
1223
+ const hopMentions = parseMentionIds(
1224
+ generated.body,
1225
+ store.listBots(),
1226
+ "bot",
1227
+ ).filter((id) => id !== botId);
999
1228
  const reply = store.appendMessage(
1000
1229
  roomId,
1001
1230
  botId,
@@ -1004,6 +1233,9 @@ async function generateReplies(
1004
1233
  undefined,
1005
1234
  undefined,
1006
1235
  usage,
1236
+ undefined,
1237
+ undefined,
1238
+ hopMentions,
1007
1239
  );
1008
1240
  store.dropLiveBotTurn(roomId, botId);
1009
1241
  recordTurn(store, roomId, botId, generated, reply);
@@ -1045,8 +1277,12 @@ async function generateReplies(
1045
1277
  return speak(botId, turnAsked, history);
1046
1278
  }),
1047
1279
  );
1048
- const hops = handoffTargets(store, memberIds, replies, asked, history);
1049
- if (hops.length && !signal.aborted) {
1280
+ let seen = 0;
1281
+ for (let wave = 0; wave < CHANNEL_ROSTER_CAP && !signal.aborted; wave++) {
1282
+ const fresh = replies.slice(seen);
1283
+ seen = replies.length;
1284
+ const hops = handoffTargets(store, memberIds, replies, asked, history, fresh);
1285
+ if (!hops.length) break;
1050
1286
  const hopAt = new Date().toISOString();
1051
1287
  for (const hop of hops) {
1052
1288
  store.adoptTurn(roomId, hop.botId, signal);
@@ -1133,7 +1369,7 @@ export function listRoomTrajectory(store: GuildStore, roomId: string) {
1133
1369
  });
1134
1370
  return {
1135
1371
  ...base,
1136
- events: base.events.concat(extra),
1372
+ events: base.events.map(promoteSpawnEvent).concat(extra),
1137
1373
  live: extra.length > 0 || open,
1138
1374
  };
1139
1375
  }
@@ -1168,7 +1404,7 @@ function plantLiveTurns(
1168
1404
  const startedAt = new Date().toISOString();
1169
1405
  for (const botId of botIds) {
1170
1406
  if (!botId) continue;
1171
- if (!memberIds.includes(botId) && !onlyBotId) continue;
1407
+ if (!memberIds.includes(botId)) continue;
1172
1408
  store.dropLastFailedReply(roomId, botId);
1173
1409
  store.setLiveTurn(roomId, {
1174
1410
  botId,
@@ -1200,29 +1436,45 @@ function followBotId(
1200
1436
  return lastBotSpeaker(store, messages);
1201
1437
  }
1202
1438
 
1203
- function hasExplicitSummon(store: GuildStore, userText: string): boolean {
1204
- if (isBroadcastMention(userText)) return true;
1439
+ /** Reply-to-a-bot locks that seat. Client assigneeId cannot steal it unless the new text @mentions someone. */
1440
+ function exclusiveReplyBot(
1441
+ store: GuildStore,
1442
+ userMessage: { author?: string; body: string; mentions?: string[] },
1443
+ parent: ChatMessage | undefined,
1444
+ assignee?: string,
1445
+ ): string | undefined {
1446
+ if (hasExplicitSummon(store, userMessage)) return assignee || undefined;
1447
+ if (parent && parent.author !== "you" && store.getBot(parent.author)) {
1448
+ return parent.author;
1449
+ }
1450
+ return assignee || undefined;
1451
+ }
1452
+
1453
+ function hasExplicitSummon(
1454
+ store: GuildStore,
1455
+ userMessage: { author?: string; body: string; mentions?: string[] },
1456
+ ): boolean {
1457
+ if (isBroadcastMention(userMessage.body)) return true;
1205
1458
  return (
1206
- summonedHandles(
1207
- userText,
1208
- store.listBots().map((bot) => bot.handle),
1459
+ messageMentionIds(
1460
+ {
1461
+ author: userMessage.author || "you",
1462
+ body: userMessage.body,
1463
+ mentions: userMessage.mentions,
1464
+ },
1465
+ store.listBots(),
1209
1466
  ).length > 0
1210
1467
  );
1211
1468
  }
1212
1469
 
1213
1470
  function includeFollowBot(
1214
- store: GuildStore,
1215
- roomId: string,
1471
+ _store: GuildStore,
1472
+ _roomId: string,
1216
1473
  memberIds: string[],
1217
1474
  follow?: string,
1218
1475
  ): string[] {
1219
1476
  if (!follow || memberIds.includes(follow)) return memberIds;
1220
- try {
1221
- store.addMember(roomId, follow);
1222
- return [...memberIds, follow];
1223
- } catch {
1224
- return memberIds;
1225
- }
1477
+ return memberIds;
1226
1478
  }
1227
1479
 
1228
1480
  function inviteAssignee(
@@ -1233,12 +1485,10 @@ function inviteAssignee(
1233
1485
  const room = store.getRoom(roomId);
1234
1486
  if (!room) return [];
1235
1487
  if (room.kind !== "channel") return room.memberIds;
1236
- if (room.memberIds.includes(assigneeId)) return room.memberIds;
1237
1488
  if (!store.getBot(assigneeId)) {
1238
1489
  throw new StoreError(400, "assignee does not exist");
1239
1490
  }
1240
- store.addMember(roomId, assigneeId);
1241
- return [...room.memberIds, assigneeId];
1491
+ return room.memberIds;
1242
1492
  }
1243
1493
 
1244
1494
  export async function postUserMessage(
@@ -1258,6 +1508,11 @@ export async function postUserMessage(
1258
1508
  const packed = parseAttachments(attachments);
1259
1509
  const tokens = packed?.map((att) => att.token).join(" ") || "";
1260
1510
  const text = body.trim() || tokens;
1511
+ const bots = store.listBots();
1512
+ const mentions =
1513
+ extras.mentions !== undefined
1514
+ ? sanitizeMentionIds(extras.mentions, bots)
1515
+ : parseMentionIds(text, bots, "user");
1261
1516
  const message = store.appendMessage(
1262
1517
  roomId,
1263
1518
  "you",
@@ -1265,6 +1520,10 @@ export async function postUserMessage(
1265
1520
  undefined,
1266
1521
  parent ? parent.id : undefined,
1267
1522
  packed,
1523
+ undefined,
1524
+ undefined,
1525
+ undefined,
1526
+ mentions,
1268
1527
  );
1269
1528
  try {
1270
1529
  store.appendTrajectory(roomId, [
@@ -1275,10 +1534,11 @@ export async function postUserMessage(
1275
1534
  }
1276
1535
  const history = previous.map(toHistoryItem);
1277
1536
  const assignee = assigneeId?.trim();
1278
- let memberIds = assignee
1279
- ? inviteAssignee(store, roomId, assignee)
1280
- : inviteMentionedBots(store, roomId, message.body);
1281
- if (!assignee && !hasExplicitSummon(store, message.body)) {
1537
+ const only = exclusiveReplyBot(store, message, parent, assignee);
1538
+ let memberIds = only
1539
+ ? inviteAssignee(store, roomId, only)
1540
+ : inviteMentionedBots(store, roomId, message);
1541
+ if (!only && !hasExplicitSummon(store, message)) {
1282
1542
  memberIds = includeFollowBot(
1283
1543
  store,
1284
1544
  roomId,
@@ -1292,7 +1552,7 @@ export async function postUserMessage(
1292
1552
  memberIds,
1293
1553
  message,
1294
1554
  history,
1295
- assignee || undefined,
1555
+ only || undefined,
1296
1556
  env,
1297
1557
  parent,
1298
1558
  extras,
@@ -1317,19 +1577,25 @@ export async function retryMessage(
1317
1577
  const current = messages[index];
1318
1578
 
1319
1579
  if (current.author === "you") {
1580
+ const bots = store.listBots();
1581
+ const mentions =
1582
+ extras.mentions !== undefined
1583
+ ? sanitizeMentionIds(extras.mentions, bots)
1584
+ : undefined;
1320
1585
  const message =
1321
1586
  typeof body === "string" && body.trim()
1322
- ? store.updateMessage(roomId, messageId, body)
1587
+ ? store.updateMessage(roomId, messageId, body, mentions)
1323
1588
  : current;
1324
1589
  store.truncateAfter(roomId, messageId);
1325
1590
  const kept = store.listMessages(roomId);
1326
1591
  const history = kept.slice(0, -1).map(toHistoryItem);
1327
1592
  const parent = parentMessage(kept.slice(0, -1), message.replyTo);
1328
1593
  const assignee = assigneeId?.trim();
1329
- let memberIds = assignee
1330
- ? inviteAssignee(store, roomId, assignee)
1331
- : inviteMentionedBots(store, roomId, message.body);
1332
- if (!assignee && !hasExplicitSummon(store, message.body)) {
1594
+ const only = exclusiveReplyBot(store, message, parent, assignee);
1595
+ let memberIds = only
1596
+ ? inviteAssignee(store, roomId, only)
1597
+ : inviteMentionedBots(store, roomId, message);
1598
+ if (!only && !hasExplicitSummon(store, message)) {
1333
1599
  memberIds = includeFollowBot(
1334
1600
  store,
1335
1601
  roomId,
@@ -1343,7 +1609,7 @@ export async function retryMessage(
1343
1609
  memberIds,
1344
1610
  message,
1345
1611
  history,
1346
- assignee || undefined,
1612
+ only || undefined,
1347
1613
  env,
1348
1614
  parent,
1349
1615
  extras,
@@ -1443,4 +1709,9 @@ export async function retryMessage(
1443
1709
 
1444
1710
  export { StoreError, localGenerate };
1445
1711
 
1446
- export { publicModels, mergeModelsFile } from "./llm.ts";
1712
+ export {
1713
+ publicModels,
1714
+ mergeModelsFile,
1715
+ refreshOpenCodeFreeCatalog,
1716
+ refreshReasoningCatalog,
1717
+ } from "./llm.ts";