pi-roundtable 0.27.1 → 0.29.0

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 (65) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +54 -22
  5. package/package.json +1 -1
  6. package/src/characters/compose.ts +11 -0
  7. package/src/characters/parts-face.ts +23 -2
  8. package/src/core/agents/agent-messages.ts +37 -92
  9. package/src/core/agents/agent-ports.ts +3 -0
  10. package/src/core/agents/agent-prompt.ts +2 -16
  11. package/src/core/agents/agent-rules.ts +27 -0
  12. package/src/core/agents/agent-team.ts +5 -0
  13. package/src/core/agents/agent-tools.ts +18 -10
  14. package/src/core/agents/helper-create.ts +3 -24
  15. package/src/core/agents/helper-modes.ts +64 -0
  16. package/src/core/agents/message-target.ts +20 -18
  17. package/src/core/agents/routes.ts +42 -24
  18. package/src/core/agents/team-editing.ts +5 -5
  19. package/src/core/agents/team-helpers.ts +76 -5
  20. package/src/core/agents/team-keys.ts +14 -0
  21. package/src/core/agents/team-turn-types.ts +7 -13
  22. package/src/core/agents/team-turns.ts +9 -6
  23. package/src/core/builtin/agent-server.ts +3 -4
  24. package/src/core/builtin/discord.ts +35 -8
  25. package/src/core/builtin/modules.ts +15 -15
  26. package/src/core/config/adapters.ts +2 -1
  27. package/src/core/config/limits.ts +35 -2
  28. package/src/core/config/routing.ts +5 -4
  29. package/src/core/discord/agent-discord.ts +12 -1
  30. package/src/core/discord/card-copies.ts +7 -2
  31. package/src/core/discord/direct-channel.ts +1 -0
  32. package/src/core/discord/discord-surface.ts +13 -30
  33. package/src/core/discord/intercom-cards.ts +121 -0
  34. package/src/core/discord/owner-card-asks.ts +9 -0
  35. package/src/core/discord/owner-card-types.ts +2 -0
  36. package/src/core/discord/owner-cards.ts +2 -1
  37. package/src/core/discord/stop-button.ts +35 -0
  38. package/src/core/domain/conversation.ts +18 -0
  39. package/src/core/domain/interim.ts +7 -0
  40. package/src/core/domain/turn-timeout.ts +5 -0
  41. package/src/core/i18n/conversation.ts +16 -4
  42. package/src/core/i18n/turn-timeout.ts +10 -2
  43. package/src/core/interactions/prompts.ts +5 -0
  44. package/src/core/modes/autonomy.ts +22 -1
  45. package/src/core/modes/conversation-modes.ts +135 -23
  46. package/src/core/modes/mode-inheritance.ts +78 -0
  47. package/src/core/modes/modes-plugin.ts +1 -1
  48. package/src/core/modules/notify/notify.ts +3 -2
  49. package/src/core/modules/notify/session-notify.ts +21 -4
  50. package/src/core/modules/skills/skill-contract.ts +13 -0
  51. package/src/core/modules/skills/skill-registry.ts +3 -1
  52. package/src/core/modules/skills/skill-store.ts +4 -3
  53. package/src/core/presence/delivery-router.ts +165 -0
  54. package/src/core/presence/direct-channels.ts +39 -68
  55. package/src/core/presentation/intercom.ts +44 -0
  56. package/src/core/runtime/ask-questions.ts +21 -4
  57. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  58. package/src/core/runtime/extensions/ask-user.ts +21 -1
  59. package/src/core/runtime/interim-text.ts +7 -4
  60. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  61. package/src/core/runtime/prompt-slot.ts +11 -1
  62. package/src/core/runtime/session-factory.ts +11 -6
  63. package/src/core/runtime/turn-budget.ts +34 -3
  64. package/src/core/runtime/turn-limits.ts +13 -3
  65. package/src/index.ts +7 -1
@@ -1,9 +1,9 @@
1
- import type { ChannelKey } from "../domain/conversation.ts";
1
+ import type { ChannelKey, IntercomCard } from "../domain/conversation.ts";
2
2
  import { AgentError, IdentityError } from "../domain/errors.ts";
3
3
  import type { AgentTurnScope } from "../domain/ports.ts";
4
4
  import { HostStoppingError } from "../errors.ts";
5
- import { messages } from "../i18n/index.ts";
6
5
  import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
6
+ import { intercomPlain } from "../presentation/intercom.ts";
7
7
  import { isQuiet } from "../presentation/reply-body.ts";
8
8
  import { splitReply } from "../presentation/reply-splitter.ts";
9
9
  import { addressee, type Speaker } from "../speakers.ts";
@@ -14,14 +14,13 @@ import {
14
14
  agentReplyText,
15
15
  chainLimitReason,
16
16
  deliveredMessagePost,
17
- returnedAnswerPost,
18
17
  } from "./agent-prompt.ts";
18
+ import { answersTo } from "./agent-rules.ts";
19
19
  import type { Agent } from "./agent-store.ts";
20
20
  import { conversationLabel, messageTarget } from "./message-target.ts";
21
21
  import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
22
22
  import {
23
23
  type Chain,
24
- type Exchange,
25
24
  MAX_CHAIN_MESSAGES,
26
25
  type TurnHost,
27
26
  } from "./team-turn-types.ts";
@@ -80,35 +79,25 @@ export class AgentMessages {
80
79
  );
81
80
  // A temp knight talks only to the conversation it was made from (and to the owners), so one
82
81
  // person's helper session is not open to another person's turn.
83
- if (target.parent !== undefined && chain.speaker.tier !== "owner") {
84
- const own = sender.name === target.parent && target.origin === parent;
85
- if (!own)
86
- throw new AgentError(
87
- `"${target.name}" is a temp knight of "${target.parent}" and answers only the conversation that made it; ask "${target.parent}" there.`,
88
- );
89
- }
82
+ if (!answersTo(target, caller.name, parent, chain.speaker))
83
+ throw new AgentError(
84
+ `"${target.name}" is a temp knight of "${target.parent}" and answers only the conversation that made it; ask "${target.parent}" there.`,
85
+ );
90
86
  chain.messages += 1;
91
87
  // A temp knight is working from the moment a message is handed to it until it has answered.
92
88
  this.#host.helpers.begin(target.name);
93
- // The thread opens in the sender's turn channel, on its surface when that hosts threads; a
94
- // surface that hosts none leaves the answer returning to the sender's conversation.
95
- const exchange = this.#openExchange(parent, sender, target, text);
89
+ // The request shows in the sender's turn channel as a small card under the sender's name.
90
+ this.#card(parent, sender, {
91
+ kind: "sent",
92
+ peer: this.#peer(target),
93
+ text,
94
+ });
96
95
  void queue
97
96
  .run(targetChannel, async () =>
98
- this.#deliver(
99
- sender,
100
- target,
101
- text,
102
- chain,
103
- await exchange,
104
- targetChannel,
105
- parent,
106
- ),
97
+ this.#exchange(sender, target, text, chain, targetChannel, parent),
107
98
  )
108
99
  .catch((error: unknown) => {
109
100
  if (error instanceof HostStoppingError) {
110
- // Refused before it started: the thread it opened has nothing more to hold.
111
- void exchange.then((opened) => opened?.thread.close());
112
101
  this.#keep({
113
102
  kind: "message",
114
103
  sender: sender.name,
@@ -139,69 +128,36 @@ export class AgentMessages {
139
128
  return `Sent to "${target.displayName}" in its ${conversationLabel(targetChannel)}. It works there; its answer comes back to you as a new turn.`;
140
129
  }
141
130
 
131
+ /** The other agent of a card, as it is named now. */
132
+ #peer(agent: Agent): IntercomCard["peer"] {
133
+ const current = this.#host.current(agent);
134
+ return { name: current.name, displayName: current.displayName };
135
+ }
136
+
142
137
  /**
143
- * Opens the message's thread in the sender's turn channel, a Discord channel or a room, and
144
- * posts the request there under the sender's name; undefined where no thread opens. Never
145
- * rejects.
138
+ * Posts a card of the exchange in the channel under `as`'s name: the card where the channel's
139
+ * surface shows cards, and its heading and text where it shows none. Never rejects.
146
140
  */
147
- async #openExchange(
148
- parent: ChannelKey,
149
- sender: Agent,
150
- target: Agent,
151
- text: string,
152
- ): Promise<Exchange | undefined> {
153
- const thread = await this.#host.options.threads?.open(
154
- parent,
155
- `→ ${target.displayName}`,
156
- );
157
- if (!thread) return undefined;
158
- await this.#host
159
- .post(parent, this.#host.current(sender), {
160
- chunks: splitReply(
161
- deliveredMessagePost(this.#host.current(target), text),
162
- ),
163
- threadId: thread.id,
141
+ #card(channel: ChannelKey, as: Agent, card: IntercomCard): Promise<void> {
142
+ return this.#host
143
+ .post(channel, this.#host.current(as), {
144
+ chunks: splitReply(intercomPlain(card)),
145
+ intercom: card,
164
146
  })
165
147
  .catch((error: unknown) =>
166
148
  this.#host.options.logger.warn(
167
- { err: error },
168
- "agent request not threaded",
149
+ { channel, kind: card.kind, err: error },
150
+ "agent message card not posted",
169
151
  ),
170
152
  );
171
- return { parent, thread };
172
- }
173
-
174
- /** Runs the target's turn, then posts its answer and wakes the sender; closes the thread either way. */
175
- async #deliver(
176
- sender: Agent,
177
- target: Agent,
178
- text: string,
179
- chain: Chain,
180
- exchange: Exchange | undefined,
181
- targetChannel: ChannelKey,
182
- senderChannel: ChannelKey,
183
- ): Promise<void> {
184
- try {
185
- await this.#exchange(
186
- sender,
187
- target,
188
- text,
189
- chain,
190
- exchange,
191
- targetChannel,
192
- senderChannel,
193
- );
194
- } finally {
195
- await exchange?.thread.close();
196
- }
197
153
  }
198
154
 
155
+ /** Runs the target's turn, then shows its answer in the sender's channel and wakes the sender. */
199
156
  async #exchange(
200
157
  sender: Agent,
201
158
  target: Agent,
202
159
  text: string,
203
160
  chain: Chain,
204
- exchange: Exchange | undefined,
205
161
  targetChannel: ChannelKey,
206
162
  senderChannel: ChannelKey,
207
163
  ): Promise<void> {
@@ -223,9 +179,6 @@ export class AgentMessages {
223
179
  chain,
224
180
  { unattended: true },
225
181
  );
226
- const thread = exchange?.thread;
227
- if (thread && !result.ok)
228
- await thread.close(messages().agentNoReply(result.error.message));
229
182
  // A target with nothing to answer sends nothing back and wakes nobody.
230
183
  if (isQuiet(result)) {
231
184
  logger.info(
@@ -236,29 +189,22 @@ export class AgentMessages {
236
189
  }
237
190
  const current = this.#host.options.store.agent(sender.name);
238
191
  if (current?.status !== "active") return;
239
- // With a thread the answer goes there, leaving the sender's channel its own reply.
240
- if (result.ok)
241
- await this.#host
242
- .post(senderChannel, this.#host.current(target), {
243
- chunks: splitReply(returnedAnswerPost(result.text)),
244
- ...(thread ? { threadId: thread.id } : {}),
245
- })
246
- .catch((error: unknown) =>
247
- logger.error({ err: error }, "agent answer not posted back"),
248
- );
249
- await thread?.close();
192
+ // The answer, or why none came, shows in the sender's channel as a small card.
193
+ await this.#card(senderChannel, target, {
194
+ kind: result.ok ? "answered" : "failed",
195
+ peer: this.#peer(target),
196
+ text: result.ok ? result.text : result.error.message,
197
+ });
250
198
  const followUp = result.ok
251
199
  ? agentReplyText(
252
200
  addressee(chain.speaker, this.#host.options.owner),
253
201
  this.#host.current(target),
254
202
  result.text,
255
- thread?.mention,
256
203
  )
257
204
  : agentFailureText(
258
205
  addressee(chain.speaker, this.#host.options.owner),
259
206
  this.#host.current(target),
260
207
  result.error.message,
261
- thread?.mention,
262
208
  );
263
209
  // The answer is a report back to the sender, so its turn may ask the owner on cards.
264
210
  void queue
@@ -407,7 +353,7 @@ export class AgentMessages {
407
353
  await this.#followUp(sender, entry.channel, entry.text, chain);
408
354
  return;
409
355
  }
410
- // Its thread was closed when the host refused it, so the answer returns to the sender's conversation.
356
+ // Its request card was posted when it was sent; the answer's card follows as it would have.
411
357
  this.#host.helpers.begin(target.name);
412
358
  try {
413
359
  await this.#exchange(
@@ -415,7 +361,6 @@ export class AgentMessages {
415
361
  target,
416
362
  entry.text,
417
363
  chain,
418
- undefined,
419
364
  entry.channel,
420
365
  origin,
421
366
  );
@@ -1,4 +1,5 @@
1
1
  import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
2
+ import type { IntercomCard } from "../domain/conversation.ts";
2
3
  import type { InterimPosts } from "../domain/interim.ts";
3
4
  import type { ThinkingSetting } from "../models.ts";
4
5
 
@@ -24,6 +25,8 @@ export interface AgentPost {
24
25
  files?: { name: string; data: Uint8Array }[];
25
26
  /** A thread of the channel to post in instead, such as a dispatch's. */
26
27
  threadId?: string;
28
+ /** A message between agents, shown as a small card in place of `chunks` where the channel can. */
29
+ intercom?: IntercomCard;
27
30
  }
28
31
 
29
32
  /**
@@ -134,16 +134,8 @@ export function agentReplyText(
134
134
  who: OwnerIdentity,
135
135
  from: Agent,
136
136
  text: string,
137
- thread?: string,
138
137
  ): string {
139
- return `(The agent "${from.displayName}" (\`${from.name}\`) answered your message. Continue with it and tell ${who.name} what matters; your reply is posted in your channel.${threadNote(thread)})\n\n${text}`;
140
- }
141
-
142
- /** Where the exchange was posted, when it had a thread. */
143
- function threadNote(thread: string | undefined): string {
144
- return thread
145
- ? ` The exchange is in the thread ${thread}, now archived; link it when useful.`
146
- : "";
138
+ return `(The agent "${from.displayName}" (\`${from.name}\`) answered your message. Continue with it and tell ${who.name} what matters; your reply is posted in your channel.)\n\n${text}`;
147
139
  }
148
140
 
149
141
  /** What the sender receives when the target could not answer. */
@@ -151,9 +143,8 @@ export function agentFailureText(
151
143
  who: OwnerIdentity,
152
144
  from: Agent,
153
145
  reason: string,
154
- thread?: string,
155
146
  ): string {
156
- return `(The agent "${from.displayName}" (\`${from.name}\`) could not answer your message: ${reason}. Tell ${who.name} briefly, or try again later.${threadNote(thread)})`;
147
+ return `(The agent "${from.displayName}" (\`${from.name}\`) could not answer your message: ${reason}. Tell ${who.name} briefly, or try again later.)`;
157
148
  }
158
149
 
159
150
  /** A new agent's first turn. */
@@ -192,11 +183,6 @@ export function deliveredMessagePost(to: Agent, text: string): string {
192
183
  return messages().messageDelivered(to.displayName, text);
193
184
  }
194
185
 
195
- /** How the target's answer appears in the sender's channel. */
196
- export function returnedAnswerPost(text: string): string {
197
- return messages().answerReturned(text);
198
- }
199
-
200
186
  /** Why a chain of agent messages stops without the owner in between. */
201
187
  export function chainLimitReason(who: OwnerIdentity): string {
202
188
  const o = ownerWords(who);
@@ -42,6 +42,33 @@ export interface Agent {
42
42
  export const isHelper = (agent: Pick<Agent, "parent"> | undefined): boolean =>
43
43
  agent?.parent !== undefined;
44
44
 
45
+ /**
46
+ * Whether `sender`, writing from `from` in a run `speaker` started, may message `target`: a temp
47
+ * knight answers only its knight in the conversation that made it, and the owners.
48
+ */
49
+ export const answersTo = (
50
+ target: Pick<Agent, "parent" | "origin">,
51
+ sender: string,
52
+ from: string,
53
+ speaker: { tier: string },
54
+ ): boolean =>
55
+ target.parent === undefined ||
56
+ speaker.tier === "owner" ||
57
+ (sender === target.parent && target.origin === from);
58
+
59
+ /**
60
+ * The surface a temp knight was made on, such as "discord" or the web app's, read off its origin;
61
+ * undefined for a permanent knight and for a temp knight made before the origin was kept. A temp
62
+ * knight lives on that surface; its place on another is made only when someone uses it there.
63
+ */
64
+ export const helperSurface = (
65
+ agent: Pick<Agent, "parent" | "origin"> | undefined,
66
+ ): string | undefined => {
67
+ const origin = agent?.parent === undefined ? undefined : agent.origin;
68
+ const colon = origin?.indexOf(":") ?? -1;
69
+ return origin && colon > 0 ? origin.slice(0, colon) : undefined;
70
+ };
71
+
45
72
  export interface AgentGroup {
46
73
  name: string;
47
74
  displayName: string;
@@ -19,6 +19,7 @@ import {
19
19
  import type { HelperTeam } from "./helper-ops.ts";
20
20
  import { type Routes, routesOf } from "./routes.ts";
21
21
  import { TeamEditing } from "./team-editing.ts";
22
+ import type { TeamHelpers } from "./team-helpers.ts";
22
23
  import {
23
24
  channelOwner,
24
25
  coordinatorOf,
@@ -321,6 +322,10 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
321
322
  return this.#turns.message(...args);
322
323
  }
323
324
 
325
+ reach(...args: Parameters<TeamHelpers["reach"]>): Promise<void> {
326
+ return this.#lifecycle.helpers.reach(...args);
327
+ }
328
+
324
329
  async read(
325
330
  channel: string,
326
331
  around: string | undefined,
@@ -123,7 +123,7 @@ export interface AgentOps {
123
123
  avatar(name: string, look: CharacterInput): Promise<string>;
124
124
  /** Queues the message and returns at once. */
125
125
  /**
126
- * Hands a request to another agent, in the conversation it used most recently, or in the
126
+ * Hands a request to another agent, in its conversation on the sender's surface, or in the
127
127
  * `conversation` named: "discord", "web" (or a room surface's name), or one of its conversation keys.
128
128
  */
129
129
  message(
@@ -132,6 +132,15 @@ export interface AgentOps {
132
132
  text: string,
133
133
  conversation?: string,
134
134
  ): string;
135
+ /**
136
+ * Readies the place a message is about to reach: a temp knight made in the web app gets its
137
+ * Discord thread when the caller may message it and names "discord". Never rejects.
138
+ */
139
+ reach?(
140
+ caller: AgentTurnScope,
141
+ to: string,
142
+ conversation?: string,
143
+ ): Promise<void>;
135
144
  createGroup(input: {
136
145
  name: string;
137
146
  displayName: string;
@@ -425,24 +434,23 @@ function toolDefs(
425
434
  name: MESSAGE_AGENT_TOOL,
426
435
  label: "Message agent",
427
436
  description:
428
- 'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in the one it used most recently, so work it started in the web app is answered there. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The exchange shows in its conversation, and a thread in yours.',
437
+ 'Send a self-contained request to another agent. It returns at once; the agent works in its own conversation and its answer comes back to you as a new turn, in the conversation you are writing from. An agent has one conversation on each surface (Discord, the web app); by default the request lands in its conversation on the surface you are writing from (its room from a web room, its Discord channel from Discord), so nothing crosses surfaces unless you name it. Give conversation to choose another: "discord", "web", or one of the conversation keys agent_get lists for it. The request shows in its conversation, and the request and the answer show as small cards in yours.',
429
438
  parameters: Type.Object({
430
439
  to: NAME,
431
440
  text: Type.String({ minLength: 1, maxLength: 4000 }),
432
441
  conversation: Type.Optional(
433
442
  Type.String({
434
443
  description:
435
- 'Which of the agent\'s conversations to deliver into: "discord", "web" (or the name of another surface that hosts rooms), or a key from agent_get. Leave it out to reach the one it used most recently.',
444
+ 'Which of the agent\'s conversations to deliver into: "discord", "web" (or the name of another surface that hosts rooms), or a key from agent_get. Leave it out to reach its conversation on the surface you are writing from.',
436
445
  }),
437
446
  ),
438
447
  }),
439
- run: (i) =>
440
- ops.message(
441
- scope,
442
- requiredString(i, "to"),
443
- requiredString(i, "text"),
444
- optionalString(i, "conversation"),
445
- ),
448
+ run: async (i) => {
449
+ const to = requiredString(i, "to");
450
+ const conversation = optionalString(i, "conversation");
451
+ await ops.reach?.(scope, to, conversation);
452
+ return ops.message(scope, to, requiredString(i, "text"), conversation);
453
+ },
446
454
  });
447
455
  return defs;
448
456
  }
@@ -2,12 +2,11 @@ import type { CharacterInput } from "../../characters/index.ts";
2
2
  import { helpersOf } from "../config/helpers.ts";
3
3
  import { AgentError } from "../domain/errors.ts";
4
4
  import type { AgentTurnScope } from "../domain/ports.ts";
5
- import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
6
5
  import type { Speaker } from "../speakers.ts";
7
6
  import { toolTiers } from "../tool-tiers.ts";
8
7
  import { isHelper } from "./agent-rules.ts";
9
8
  import type { Agent } from "./agent-store.ts";
10
- import { keysOf } from "./team-keys.ts";
9
+ import { followOrigin } from "./helper-modes.ts";
11
10
  import type { TeamContext } from "./team-options.ts";
12
11
 
13
12
  /** The team's way of making an agent, which `createHelper` uses to make a temp knight. */
@@ -98,10 +97,10 @@ export async function createHelper(
98
97
  );
99
98
  const helper = made.agent;
100
99
  try {
101
- await inheritLongRunning(ctx, caller, speaker, helper);
100
+ await followOrigin(ctx, caller, speaker, helper);
102
101
  ctx.turns.message(caller, helper.name, task);
103
102
  } catch (error) {
104
- // The long-running mode or the task could not be handed over (a chain at its cap): the temp
103
+ // The modes or the task could not be handed over (a chain at its cap): the temp
105
104
  // knight is not left idle, and its thread is closed.
106
105
  await ctx.archiveHelpers([helper.name]);
107
106
  throw error;
@@ -158,23 +157,3 @@ async function makeRoom(
158
157
  await ctx.archiveHelpers([oldest.name]);
159
158
  return ` To make room, "${oldest.displayName}" (${oldest.name}), your oldest finished temp knight, was archived.`;
160
159
  }
161
-
162
- /**
163
- * Turns long-running on for the temp knight's conversations when the conversation that asked for
164
- * it has it on, so it works as long as its parent may. This adds no permission: an owner turned
165
- * it on there. Only a turn of an owner can carry that over, as for any change of modes.
166
- */
167
- async function inheritLongRunning(
168
- ctx: TeamContext,
169
- caller: AgentTurnScope,
170
- speaker: Speaker,
171
- helper: Agent,
172
- ): Promise<void> {
173
- const { store, surfaces } = ctx.options;
174
- const modes = ctx.options.modes?.();
175
- if (!modes?.peek(ctx.turns.turnChannel(caller)).longRunning) return;
176
- if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL)
177
- return;
178
- for (const key of keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []))
179
- await modes.set(key, { longRunning: true }, speaker);
180
- }
@@ -0,0 +1,64 @@
1
+ import type { AgentTurnScope } from "../domain/ports.ts";
2
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
3
+ import type { ModeOrigins } from "../modes/mode-inheritance.ts";
4
+ import type { AgentDirectory } from "../services.ts";
5
+ import type { Speaker } from "../speakers.ts";
6
+ import { isHelper } from "./agent-rules.ts";
7
+ import type { Agent } from "./agent-store.ts";
8
+ import { agentOfChannel, groupOfChannel, keysOf } from "./team-keys.ts";
9
+ import type { TeamContext } from "./team-options.ts";
10
+
11
+ /**
12
+ * Makes the temp knight's conversations follow the modes of the conversation that asked for it,
13
+ * live, so turning yolo or long-running on or off there applies to it at once. This adds no
14
+ * permission: an owner turns the modes on there. Only a turn of an owner can carry them over, as
15
+ * for any change of modes; any other turn's temp knight starts with its own, both off, whatever
16
+ * an earlier agent of the same name followed.
17
+ */
18
+ export async function followOrigin(
19
+ ctx: TeamContext,
20
+ caller: AgentTurnScope,
21
+ speaker: Speaker,
22
+ helper: Agent,
23
+ ): Promise<void> {
24
+ const { store, surfaces } = ctx.options;
25
+ const modes = ctx.options.modes?.();
26
+ if (!modes) return;
27
+ const keys = keysOf(store, helper.name, surfaces?.roomSurfaces() ?? []);
28
+ if (speaker.tier !== "owner" || speaker.principalId === SYSTEM_PRINCIPAL) {
29
+ for (const key of keys) await modes.forget(key);
30
+ return;
31
+ }
32
+ await modes.follow(keys, ctx.turns.turnChannel(caller), speaker);
33
+ }
34
+
35
+ /**
36
+ * What the agent server says of a conversation that follows another's modes: it still does while
37
+ * it is a temp knight's conversation made from that origin, named after the temp knight's parent;
38
+ * the origin is gone once the agent or group whose conversation it is (else the parent) is
39
+ * archived or deleted.
40
+ */
41
+ export function modeOrigins(
42
+ store: Pick<
43
+ AgentDirectory,
44
+ "agent" | "agentByChannel" | "group" | "groupByChannel"
45
+ >,
46
+ hosts: { roomSurfaces(): readonly string[] },
47
+ ): ModeOrigins {
48
+ return {
49
+ originOf: (channel, origin) => {
50
+ const helper = agentOfChannel(store, hosts, channel);
51
+ if (!helper || !isHelper(helper) || helper.origin !== origin)
52
+ return undefined;
53
+ const parent = store.agent(helper.parent as string);
54
+ const owner =
55
+ agentOfChannel(store, hosts, origin) ??
56
+ groupOfChannel(store, hosts, origin) ??
57
+ parent;
58
+ return {
59
+ name: parent?.displayName ?? (helper.parent as string),
60
+ alive: owner?.status === "active",
61
+ };
62
+ },
63
+ };
64
+ }
@@ -3,19 +3,22 @@ import { AgentError } from "../domain/errors.ts";
3
3
  import type { AgentDirectory } from "../services.ts";
4
4
  import type { Agent } from "./agent-store.ts";
5
5
  import { Routes } from "./routes.ts";
6
- import { agentRoomKey, discordKey, keysOf } from "./team-keys.ts";
6
+ import { agentRoomKey, keysOf } from "./team-keys.ts";
7
7
 
8
8
  /** What the choice of a message's conversation reads. */
9
9
  export interface MessageTargetContext {
10
10
  store: Pick<AgentDirectory, "agent" | "group">;
11
11
  /** The surfaces that host agent rooms. */
12
12
  roomSurfaces: readonly string[];
13
- /** The conversation the target last ran a turn in, if it ran one since the host started. */
13
+ /**
14
+ * The conversation the target last ran a turn in, if it ran one since the host started; read
15
+ * only under the `"latest"` routing rule, and only on the sender's surface.
16
+ */
14
17
  latest: ChannelKey | undefined;
15
- /** The conversation the sender writes from. */
18
+ /** The conversation the sender writes from: the origin whose surface the message stays on. */
16
19
  from: ChannelKey;
17
20
  /** The host's routing table; without it the defaults every host had before `routing`. */
18
- routes?: Pick<Routes, "of">;
21
+ routes?: Pick<Routes, "of" | "placeOn">;
19
22
  }
20
23
 
21
24
  /** The word for the web app's conversation, accepted when exactly one surface hosts rooms. */
@@ -24,9 +27,8 @@ const WEB = "web";
24
27
  /**
25
28
  * The conversation of `target` that a message is delivered into. With `ask` (a surface name such
26
29
  * as "discord", "web", or the surface of a room, or one of the target's conversation keys) that
27
- * one; otherwise the conversation the routing table picks (`Routes.of`): by default the one the
28
- * target used most recently, which a message from a room never follows out to Discord, and, when
29
- * it has run none yet, the sender's own surface, so a message between rooms stays among the rooms.
30
+ * one; otherwise the conversation the routing table picks (`Routes.of`): by default the target's
31
+ * place on the sender's own surface, so a message never crosses surfaces unless named.
30
32
  */
31
33
  export function messageTarget(
32
34
  ctx: MessageTargetContext,
@@ -35,25 +37,25 @@ export function messageTarget(
35
37
  ): ChannelKey {
36
38
  const keys = keysOf(ctx.store, target.name, ctx.roomSurfaces);
37
39
  const has = keys.length > 0 ? keys.join(", ") : "none";
38
- if (ask === undefined || ask.trim() === "") {
39
- const routes =
40
- ctx.routes ??
41
- new Routes({
42
- store: ctx.store,
43
- roomSurfaces: () => ctx.roomSurfaces,
44
- latest: () => ctx.latest,
45
- });
40
+ const routes =
41
+ ctx.routes ??
42
+ new Routes({
43
+ store: ctx.store,
44
+ roomSurfaces: () => ctx.roomSurfaces,
45
+ latest: () => ctx.latest,
46
+ });
47
+ if (ask === undefined || ask.trim() === "")
46
48
  return routes.of(target.name, "agentMessage", ctx.from);
47
- }
48
49
  const wanted = ask.trim();
49
50
  const exact = keys.find((key) => key === wanted);
50
51
  if (exact !== undefined) return exact;
51
52
  if (wanted === "discord") {
52
- if (!target.channelId)
53
+ const place = routes.placeOn(target.name, "discord");
54
+ if (!place)
53
55
  throw new AgentError(
54
56
  `${target.name} has no Discord channel; its conversations: ${has}.`,
55
57
  );
56
- return discordKey(target.channelId);
58
+ return place;
57
59
  }
58
60
  const surface = ctx.roomSurfaces.includes(wanted)
59
61
  ? wanted