pi-roundtable 0.28.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.29.0] - 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - `SkillRegistry.groups()`, `setGroup(name, description, skills)` and `deleteGroup(name)` are part of the skill registry interface (`SkillGroup`: `name`, `description`, `skills`), so a host surface can manage skill groups. A group stays a filter and a list to apply once: an agent carries skills, not groups. `SkillRegistry` has three new required methods, so a hand-built stand-in must set them.
13
+ - `helperSurface` tells the surface a temp knight was made on, read off its stored origin. `AgentOps.reach` readies a message's place before `message_agent` delivers it.
14
+
15
+ ### Changed
16
+
17
+ - Delivery follows the conversation the work came from. One delivery router (`src/core/presence/delivery-router.ts`) decides where a notice or run for a person goes from its origin: a provider on the origin's surface first, another surface only when none there reaches the person, logged with both surfaces. A schedule made in a web room that calls `notify` now notices in the web app (Home notice and push) instead of a Discord direct message. The web app's push mirror of a notice Discord delivered stays, to the same person only.
18
+ - Breaking: `DirectChannels.reach`, `known`, and `notify` take the origin `ChannelKey` first (`reach(origin, principalId)`, `notify(origin, principalId, text)`), and `DirectChannels` no longer extends `Notifier`. `DirectChannelProvider.surface` (optional, default `name`) names the surface a provider reaches; the Discord, web, and webchat providers set it.
19
+ - `message_agent` without `conversation` reaches the target's place on the sender's surface (`routing.agents.<name>.agentMessage` default `origin-surface`), never the conversation it used most recently on another surface; with no place there it lands in the primary place, logged. `latest` now follows only on the sender's surface. `RoutingTable.of(agent, "agentMessage", origin)` requires the origin.
20
+ - The notify tool's description says it goes out on the conversation's own surface.
21
+ - A temp knight lives only on the surface of the conversation that made it. One made in the web app gets no Discord thread at creation; the thread is made once, when a `message_agent` call from a turn that may message it names `conversation: "discord"`, or names no conversation from a Discord turn (where an unnamed message goes), and restoring it reopens the thread it had but never makes a new one. Archiving one without a thread works as before. A temp knight made before its origin was kept is placed as before. See pi-roundtable-web for the rooms of temp knights made on Discord.
22
+
23
+ ### Fixed
24
+
25
+ - A group seat outside its running turn, such as after a start-over, stays in the group's room on its own surface instead of falling out to the group's Discord channel.
26
+
8
27
  ## [0.28.0] - 2026-10-31
9
28
 
10
29
  ### Added
package/docs/plugins.md CHANGED
@@ -182,7 +182,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
182
182
  | `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
183
183
  | `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
184
184
  | `observe(filter, observer)` | Hears inbound messages without owning or admitting them: [`observe`](#observe-hearing-messages-without-owning-them). Returns a function that stops it; may be called during `setup` |
185
- | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)` |
185
+ | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(origin, principalId)`, `known(origin, principalId)`, and `notify(origin, principalId, text)`, each through the [delivery router](#where-things-are-delivered) |
186
186
  | `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
187
187
 
188
188
  `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, and `dashboard()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
@@ -258,7 +258,7 @@ The built-in plugins provide these, from the main entry:
258
258
  | `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
259
259
  | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), `avatars` (`AvatarStudio`), `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`), and `routes` (`RoutingTable`: where work no person started lands; see [Routing](#routing-where-work-no-person-started-lands)) |
260
260
  | `PROMPTS` | `PromptDocuments` | `prompts` | The prompt texts the owner edits in the app, each with a file version, an override and a history: `register`, `text`, `list`, `get`, `set`, `revert`, `history`. Always on; read it with `get`. See [`PROMPTS`: prompt texts the owner edits](#prompts-prompt-texts-the-owner-edits) |
261
- | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory` |
261
+ | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory`, and the groups `groups`, `setGroup`, `deleteGroup` |
262
262
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
263
263
  | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
264
264
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
@@ -2999,6 +2999,7 @@ setup: ({ services }) => ({
2999
2999
  directChannels: [
3000
3000
  {
3001
3001
  name: "inbox",
3002
+ surface: "chat",
3002
3003
  label: "a notice in the web chat's inbox",
3003
3004
  reaches: async (principalId) => inboxOf(principalId),
3004
3005
  deliver: async (principalId, text) => postNotice(principalId, text),
@@ -3010,23 +3011,36 @@ setup: ({ services }) => ({
3010
3011
  | Field | What it is |
3011
3012
  |---|---|
3012
3013
  | `name` | Unique across every plugin; a second provider of one name is a `PluginError` naming both plugins |
3014
+ | `surface` | Optional: the surface whose conversations it reaches, the key prefix of the channels `reaches` names, such as `discord` or the web app's `app`. Work from a conversation on that surface reaches the person here first. Without it, `name` |
3013
3015
  | `label` | How the `notify` tool names a notice sent through it, completing "Send Ada …", such as `a direct message on Discord` |
3014
3016
  | `reaches(principalId)` | The person's own conversation here, as a channel key, or `undefined` when they have none here. It may reach the network and throw |
3015
3017
  | `knows(principalId)` | Optional: whether the person has a conversation here, from the host's own records such as their linked identities, without the network. A session asks it when it decides whether to offer `notify` or the schedule and delegation tools, so an outage then does not take them away; without it the host asks `reaches` |
3016
3018
  | `deliver(principalId, text)` | Optional: sends the notice itself. Without it the host posts the text in the channel `reaches` names, through the surface that serves it |
3017
- | `mirror(principalId, text)` | Optional: echoes a notice another provider delivered, such as a web app pushing to a phone that Discord's direct message already reached. `notify` calls it on every provider but the one that delivered, only after a delivery; a failure is logged and never changes the result |
3019
+ | `mirror(principalId, text)` | Optional: echoes a notice another provider delivered to the same person, such as a web app pushing to a phone that Discord's direct message already reached. `notify` calls it on every provider but the one that delivered, only after a delivery; a failure is logged and never changes the result |
3018
3020
 
3019
- The host reaches a person through the first provider, in contribution order, that reaches them.
3020
- `context.directChannels.reach(principalId)` resolves that provider and channel, or `undefined`; `known(principalId)` resolves the first provider that knows the person without the network; `notify(principalId, text)` sends a notice there and resolves `false` when no provider reaches the person.
3021
+ Every lookup takes the *origin*: the conversation the work came from. The host reaches a person through a provider on the origin's surface, and through another only when none there reaches them, as [Where things are delivered](#where-things-are-delivered) describes.
3022
+ `context.directChannels.reach(origin, principalId)` resolves that provider and channel, or `undefined`; `known(origin, principalId)` resolves the provider that knows the person without the network, in the same order; `notify(origin, principalId, text)` sends a notice there and resolves `false` when no provider reaches the person.
3021
3023
  The Discord plugin contributes one named `discord`: it finds a principal's Discord identities through `IDENTITY` and reaches the first one's direct messages, the primary owner's configured identity first, so a single owner is reached in the same direct messages as in 0.8.
3022
3024
 
3023
3025
  The `notify` tool, 0.8's `notify_owner`, sends such a notice. A session has it only when a provider is contributed, and a private conversation only when a provider knows its person; the channel itself is reached when a notice is sent.
3024
3026
  It notifies the conversation's person in a private conversation and the turn's speaker in a shared one, and refuses when no provider reaches them or when the speaker is not the private conversation's person; the host's own turns, such as a report's, notify the conversation's person in a private conversation and the primary owner elsewhere, as `notify_owner` did.
3025
- Its description names the channels that can reach the person, so a host with only Discord reads `Send Ada a direct message on Discord. …` as 0.8 did.
3027
+ It sends on the conversation's own surface: from a web room the web app's notice and push, from Discord a Discord direct message. Its description names the channel on that surface that can reach the person, and says it goes out on the conversation's own surface, such as `Send Ada a direct message on Discord. It goes out on this conversation's own surface. …`.
3026
3028
  In a shared conversation it names the primary owner while only they can receive a notice (the host has no other owner, and only owners may use it), and "the speaker" otherwise; this is checked again before each turn, so an open session follows an owner granted or revoked meanwhile.
3027
3029
  Until 1.0 the name `notify_owner` still selects it in a selection or a profile and names it in a task's `exclude` and a plugin's `requiredTools`, and an operator's or a plugin's `toolTiers` entry for `notify_owner` applies to `notify` unless `notify` has its own.
3028
3030
  Using the old name logs a deprecation warning once per logger, whether in a selection or in `toolTiers`.
3029
3031
 
3032
+ ### Where things are delivered
3033
+
3034
+ Everything the host sends goes back where the work came from. Every turn knows its *origin*, the conversation it runs in (a `ChannelKey` whose prefix is its surface), and so does everything a turn leaves behind: a schedule keeps the conversation it was made in, a delegated or coding job its channel and origin, a kept agent message its target's conversation and the sender's, a dispatch thread its parent. Nothing chooses a destination from a global default: not the first provider contributed, not the conversation an agent used most recently, not an agent's Discord channel.
3035
+
3036
+ - **Replies, cards, and threads** go through `SurfacePort`, which picks the surface by the channel key's prefix: an `ask_user` card, a timeout card, a message's request and answer cards, a job's thread and report all land in the origin's conversation, on its surface.
3037
+ - **The delivery router** (`src/core/presence/delivery-router.ts`) is the one place that decides where a notice or run for a *person* goes. Given the origin, it tries the direct channels on the origin's surface first: a web conversation reaches the web app's channel (a Home notice and a push), a Discord conversation a Discord direct message. Only when no provider on that surface reaches the person does it try another surface, and it logs that fallback with both surfaces (`from`, `to`). `notify`, `context.directChannels`, and the run channel of a schedule or delegated task made in a conversation no chat surface carries (`runChannelFor`) all go through it.
3038
+ - **Mirroring** is the provider's own declaration: after a delivery, each other provider that implements `mirror` echoes the notice to the *same person*, such as the web app pushing to their phone a notice Discord delivered. It never sends to anyone else and never posts in a conversation.
3039
+ - **`message_agent`** without `conversation` reaches the target's place on the *sender's* surface: from a web room, the target's room on that surface; from Discord, the target's Discord channel. When the target has no place there, it lands in the target's primary place, and the crossing is logged. A routing rule may pin it elsewhere; `latest` follows the conversation the target last used only on the sender's surface.
3040
+ - **A group seat** outside its running turn, such as after a start-over, posts in the group's room on its own surface and never falls out to the group's Discord channel.
3041
+
3042
+ `scripts/delivery-scan.test.ts` fails the build when code outside the router asks a provider to reach someone, walks the providers to pick one, or reads an agent's Discord channel id outside the files listed there; `src/core/presence/cross-surface.test.ts` starts each outbound feature from a web and from a Discord conversation and checks the other surface received nothing.
3043
+
3030
3044
  ### `surfaces`: a chat network of your own
3031
3045
 
3032
3046
  A chat surface connects the host to one chat network: it reports what people write, and it posts what the conversations answer.
@@ -3087,7 +3101,7 @@ An agent has a *place* on each surface that holds one: its Discord channel, and
3087
3101
  | `primary(agent)` | The primary place: the surface the rules name, and with `auto` the agent's Discord channel when it has one, else its room on the first surface that hosts rooms. Undefined for an unknown agent. |
3088
3102
  | `placeOn(agent, surface)` | The agent's place on a surface (`web` for the one surface that hosts rooms); undefined when it has none there. |
3089
3103
  | `of(agent, "integration", origin)` | Where the report turn of a foreign webhook's post made in `origin` runs; `origin` itself by default, and also when the destination has vanished. |
3090
- | `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default the agent's latest, never out of a room into Discord. Throws `AgentError` when the agent has no place. |
3104
+ | `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default (`origin-surface`) the agent's place on the surface `from` is on, else its primary place, logged; it never crosses surfaces by recency. Throws `AgentError` when the agent has no place. |
3091
3105
  | `of(agent, "opening", origin?)` | Where an opening task runs: by default the agent's place on the surface `origin` is on, else its primary place; undefined when it has no place at all. |
3092
3106
  | `place("ops")` | A named place of the host: the conversation, or the primary place of the agent it names while that agent is active. |
3093
3107
 
@@ -3109,13 +3123,13 @@ It follows its knight's routing rules, and its yolo and long-running modes follo
3109
3123
 
3110
3124
  | Rule | What happens |
3111
3125
  |---|---|
3112
- | Place | On Discord a temp knight lives in a thread under its knight's channel, whose posts go through the channel's webhook under the temp knight's own name and picture; a knight with no Discord channel gives it a room on a surface that hosts rooms. Archiving a temp knight archives and locks its thread, and restoring it unarchives the thread. |
3126
+ | Place | A temp knight lives on the surface of the conversation that made it, kept in `Agent.origin`; its place on the other surface is made only when someone uses it there. Made on Discord, it lives in a thread under its knight's channel, whose posts go through the channel's webhook under the temp knight's own name and picture; a surface's room list leaves its room out until the room has a message or someone opens it by its address. Made in a room, it gets no thread: the thread is made, once, when a `message_agent` call from a turn that may message it names `conversation: "discord"`, or names none from a Discord conversation, where an unnamed message goes. A knight with no Discord channel gives it a room on a surface that hosts rooms. Archiving a temp knight archives and locks its thread when it has one; restoring it unarchives the thread, and only one made on Discord gets a new thread when the old one is gone. A temp knight made before its origin was kept is placed as one made on Discord. |
3113
3127
  | Finished | Derived, never set by the knight: nothing runs, nothing is queued, no card waits for the owner and no message from its knight is still unanswered. `Agent.finishedAt` carries the stamp. |
3114
3128
  | Limits | `helpers.perParent` (default 4) and `helpers.perHost` (default 12) count active temp knights; depth is one, so a temp knight cannot make one. At a limit the knight's oldest finished temp knight is archived to make room, and the call is refused only when every one of them is busy. |
3115
3129
  | Cleanup | `helper_end`, the owner's clearing in a surface, a knight's own archive (which archives its temp knights first), and, after `helpers.archiveFinishedAfterHours` (default 24), an hourly sweep. An archived temp knight keeps its history and comes back with `restoreHelpers`. Deleting an archived knight deletes its temp knights. |
3116
3130
 
3117
3131
  `AgentTeam` offers `helpersOf(parent)`, `finishedHelpers(parents?)`, `archiveHelpers(names, by)`, `restoreHelpers(names)` (only a name that is still an archived temp knight of an active knight is restored) and `onHelpersEnded(listener)`, which hears a knight's own `helper_end` and nothing else, so a surface can offer the owner an undo.
3118
- `isHelper(agent)` tells a temp knight, for a surface that lists agents; a group never takes one.
3132
+ `isHelper(agent)` tells a temp knight, for a surface that lists agents, and `helperSurface(agent)` the surface it was made on (undefined for one made before its origin was kept); a group never takes one.
3119
3133
  The `helpers` block of the configuration turns the limits and the sweep; `helpers: false` offers neither tool, and a value out of its range, or a `perParent` above `perHost`, stops the start with a `ConfigError`:
3120
3134
 
3121
3135
  ```ts
@@ -4403,6 +4417,7 @@ Import from the entries listed below; source area files are internal.
4403
4417
  | `AvatarStudio` | `pi-roundtable` | type |
4404
4418
  | `PROMPTS` | `pi-roundtable` | value |
4405
4419
  | `characterOf` | `pi-roundtable` | value |
4420
+ | `helperSurface` | `pi-roundtable` | value |
4406
4421
  | `isHelper` | `pi-roundtable` | value |
4407
4422
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
4408
4423
  | `CONVERSATIONS` | `pi-roundtable` | value |
@@ -4594,6 +4609,7 @@ Import from the entries listed below; source area files are internal.
4594
4609
  | `PromptConflictError` | `pi-roundtable` | value |
4595
4610
  | `SkillConflictError` | `pi-roundtable` | value |
4596
4611
  | `SkillDocument` | `pi-roundtable` | type |
4612
+ | `SkillGroup` | `pi-roundtable` | type |
4597
4613
  | `SKILLS` | `pi-roundtable` | value |
4598
4614
  | `SINGLE_QUESTION` | `pi-roundtable` | value |
4599
4615
  | `Schedule` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@ import {
15
15
  chainLimitReason,
16
16
  deliveredMessagePost,
17
17
  } from "./agent-prompt.ts";
18
+ import { answersTo } from "./agent-rules.ts";
18
19
  import type { Agent } from "./agent-store.ts";
19
20
  import { conversationLabel, messageTarget } from "./message-target.ts";
20
21
  import { discordIdOf, discordKey, scopeOf } from "./team-keys.ts";
@@ -78,13 +79,10 @@ export class AgentMessages {
78
79
  );
79
80
  // A temp knight talks only to the conversation it was made from (and to the owners), so one
80
81
  // person's helper session is not open to another person's turn.
81
- if (target.parent !== undefined && chain.speaker.tier !== "owner") {
82
- const own = sender.name === target.parent && target.origin === parent;
83
- if (!own)
84
- throw new AgentError(
85
- `"${target.name}" is a temp knight of "${target.parent}" and answers only the conversation that made it; ask "${target.parent}" there.`,
86
- );
87
- }
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
+ );
88
86
  chain.messages += 1;
89
87
  // A temp knight is working from the moment a message is handed to it until it has answered.
90
88
  this.#host.helpers.begin(target.name);
@@ -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 request shows in its conversation, and the request and the answer show as small cards 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
  }
@@ -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
@@ -37,7 +37,7 @@ export interface RoutingTable {
37
37
  * has none when the agent has no place at all.
38
38
  */
39
39
  of(agent: string, kind: "integration", origin: ChannelKey): ChannelKey;
40
- of(agent: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
40
+ of(agent: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
41
41
  of(
42
42
  agent: string,
43
43
  kind: RouteKind,
@@ -154,7 +154,7 @@ export class Routes implements RoutingTable {
154
154
  * when an `agentMessage` finds no place; undefined when an `opening` finds none.
155
155
  */
156
156
  of(name: string, kind: "integration", origin: ChannelKey): ChannelKey;
157
- of(name: string, kind: "agentMessage", origin?: ChannelKey): ChannelKey;
157
+ of(name: string, kind: "agentMessage", origin: ChannelKey): ChannelKey;
158
158
  of(
159
159
  name: string,
160
160
  kind: RouteKind,
@@ -201,39 +201,57 @@ export class Routes implements RoutingTable {
201
201
  }
202
202
 
203
203
  /**
204
- * The conversation a message to the agent lands in when the sender names none: with `latest`
205
- * the one it last ran a turn in, except that a message from a room never follows it out to
206
- * Discord, since the room's text may be for the owners alone; with none yet, the sender's own
207
- * surface when it hosts rooms, so a message between rooms stays among them; otherwise its
208
- * primary place.
204
+ * The conversation a message to the agent lands in when the sender names none. By default
205
+ * (`"origin-surface"`) its place on the sender's own surface: its Discord channel for a message
206
+ * from Discord, its room for one from a room. `"latest"` is the conversation it last ran a turn
207
+ * in, but only on the sender's surface. A message never crosses surfaces by recency: when the
208
+ * agent has no place on the sender's surface, or no sender is known, it lands in the agent's
209
+ * primary place, and a crossing is logged with both surfaces.
209
210
  */
210
211
  #agentMessage(name: string, from: ChannelKey | undefined): ChannelKey {
211
- const { store, roomSurfaces, latest } = this.#options;
212
- const to = this.rules(name).agentMessage ?? "latest";
213
- if (to !== "latest") {
212
+ const { latest } = this.#options;
213
+ const to = this.rules(name).agentMessage ?? "origin-surface";
214
+ if (to !== "latest" && to !== "origin-surface") {
214
215
  const key =
215
216
  to === "primary" ? this.primary(name) : this.placeOn(name, to);
216
217
  if (key) return key;
217
218
  this.#warn(
218
219
  `message:${name}`,
219
220
  { agent: name, to },
220
- "the agent has no place for messages to it; the conversation it last ran a turn in is used",
221
+ "the agent has no place for messages to it; its place on the sender's surface is used",
221
222
  );
222
223
  }
223
- const via = from === undefined ? undefined : roomSurfaceOf(from);
224
- const last = latest?.(name);
225
- const keys = keysOf(store, name, roomSurfaces());
226
- const outward =
227
- via !== undefined &&
228
- last !== undefined &&
229
- roomSurfaceOf(last) === undefined;
230
- if (last !== undefined && keys.includes(last) && !outward) return last;
231
- if (via === undefined) {
232
- const primary = this.primary(name);
233
- if (!primary) throw new AgentError(`${name} has no channel yet.`);
234
- return primary;
224
+ const surface =
225
+ from === undefined ? undefined : parseChannelKey(from).surface;
226
+ if (to === "latest" && surface !== undefined) {
227
+ const last = latest?.(name);
228
+ const keys = keysOf(
229
+ this.#options.store,
230
+ name,
231
+ this.#options.roomSurfaces(),
232
+ );
233
+ if (
234
+ last !== undefined &&
235
+ keys.includes(last) &&
236
+ parseChannelKey(last).surface === surface
237
+ )
238
+ return last;
235
239
  }
236
- return agentRoomKey(via, name);
240
+ const own = surface === undefined ? undefined : this.placeOn(name, surface);
241
+ if (own) return own;
242
+ const primary = this.primary(name);
243
+ if (!primary) throw new AgentError(`${name} has no channel yet.`);
244
+ if (surface !== undefined)
245
+ this.#warn(
246
+ `message-cross:${name}:${surface}`,
247
+ {
248
+ agent: name,
249
+ from: surface,
250
+ to: parseChannelKey(primary).surface,
251
+ },
252
+ "the agent has no place on the sender's surface; the message lands in its primary place",
253
+ );
254
+ return primary;
237
255
  }
238
256
 
239
257
  /**
@@ -220,9 +220,9 @@ export class TeamEditing {
220
220
  creator: Agent | undefined;
221
221
  /** The surface whose room runs the opening task. */
222
222
  openIn?: string | undefined;
223
- /** Makes the agent a temp knight of this knight: its place is a thread of the parent's channel, and it settles on the parent's model. */
223
+ /** Makes the agent a temp knight of this knight, which settles on the parent's model. */
224
224
  parent?: Agent | undefined;
225
- /** The conversation a temp knight is made from. */
225
+ /** The conversation a temp knight is made from: made on Discord, it lives in a thread of the parent's channel, made elsewhere in its rooms alone. */
226
226
  origin?: string | undefined;
227
227
  },
228
228
  input: {
@@ -279,10 +279,10 @@ export class TeamEditing {
279
279
  const suggested = suggestCharacter(input, taken);
280
280
  const character = this.#resolve(wanted ?? {}, suggested);
281
281
  const avatarHash = await studio.render(character);
282
- // Without Discord channels the agent lives in its rooms, which exist as soon as it does. A
283
- // temp knight lives in a thread of its parent's channel, or in rooms alone without one.
282
+ // Without Discord channels the agent lives in its rooms, which exist as soon as it does.
283
+ const onDiscord = who.origin?.startsWith("discord:") !== false;
284
284
  const channelId = who.parent
285
- ? who.parent.channelId && channels?.createThread
285
+ ? onDiscord && who.parent.channelId && channels?.createThread
286
286
  ? await channels.createThread(who.parent.channelId, input.displayName)
287
287
  : undefined
288
288
  : await channels?.createChannel(
@@ -1,7 +1,8 @@
1
1
  import { helpersOf } from "../config/helpers.ts";
2
2
  import { AgentError } from "../domain/errors.ts";
3
3
  import type { AgentTurnScope } from "../domain/ports.ts";
4
- import { isHelper } from "./agent-rules.ts";
4
+ import { surfaceOf } from "../presence/delivery-router.ts";
5
+ import { answersTo, helperSurface, isHelper } from "./agent-rules.ts";
5
6
  import type { Agent } from "./agent-store.ts";
6
7
  import type { HelpersEnded } from "./helper-ops.ts";
7
8
  import type { TeamContext } from "./team-options.ts";
@@ -11,8 +12,9 @@ const SWEEP_EVERY_MS = 3_600_000;
11
12
 
12
13
  /**
13
14
  * The team's temp knights after they are made: which a knight has, which are finished, and their
14
- * archive, restore, and sweep. Archiving one closes its Discord thread, since a temp knight's
15
- * place is a thread of its parent's channel; an archived one keeps its history.
15
+ * archive, restore, and sweep. A temp knight made on Discord lives in a thread of its parent's
16
+ * channel; one made in the web app gets that thread only once someone reaches for it on Discord.
17
+ * Archiving one closes its thread, when it has one; an archived one keeps its history.
16
18
  */
17
19
  export class TeamHelpers {
18
20
  readonly #ctx: TeamContext;
@@ -20,6 +22,8 @@ export class TeamHelpers {
20
22
  readonly #archiveAgent: (agent: Agent) => Promise<void>;
21
23
  readonly #ended: ((event: HelpersEnded) => void)[] = [];
22
24
  #sweeper: ReturnType<typeof setInterval> | undefined;
25
+ /** The threads being made for web-born temp knights, by name, so two reaches make one. */
26
+ readonly #placing = new Map<string, Promise<void>>();
23
27
 
24
28
  constructor(ctx: TeamContext, archiveAgent: (agent: Agent) => Promise<void>) {
25
29
  this.#ctx = ctx;
@@ -125,9 +129,74 @@ export class TeamHelpers {
125
129
  return restored;
126
130
  }
127
131
 
132
+ /**
133
+ * A `message_agent` call that names "discord", or names nothing from a Discord conversation, gives
134
+ * a web-born temp knight its thread first.
135
+ */
136
+ async reach(
137
+ caller: AgentTurnScope,
138
+ to: string,
139
+ conversation?: string,
140
+ ): Promise<void> {
141
+ const { store } = this.#ctx.options;
142
+ const { turns } = this.#ctx;
143
+ const target = store.agent(to);
144
+ const speaker = turns.speakerOf(caller);
145
+ const from = turns.turnChannel(caller);
146
+ // Unnamed, a message stays on the sender's surface, so one sent from Discord goes there too.
147
+ const wanted = conversation?.trim() || surfaceOf(from);
148
+ if (wanted !== "discord" || !target || !speaker) return;
149
+ if (answersTo(target, caller.name, from, speaker))
150
+ await this.placeOnDiscord(to);
151
+ }
152
+
153
+ /**
154
+ * Gives an active temp knight made in the web app its thread under the parent's channel, the
155
+ * first time someone reaches for it on Discord. One made on Discord, or before the origin was
156
+ * kept, already has its place, and a host without Discord threads makes none; never rejects.
157
+ */
158
+ placeOnDiscord(name: string): Promise<void> {
159
+ const pending = this.#placing.get(name);
160
+ if (pending) return pending;
161
+ const placing = this.#placeOnDiscord(name).finally(() =>
162
+ this.#placing.delete(name),
163
+ );
164
+ this.#placing.set(name, placing);
165
+ return placing;
166
+ }
167
+
168
+ async #placeOnDiscord(name: string): Promise<void> {
169
+ const { store, channels, logger } = this.#ctx.options;
170
+ const agent = store.agent(name);
171
+ const surface = helperSurface(agent);
172
+ if (agent?.status !== "active" || agent.channelId) return;
173
+ if (surface === undefined || surface === "discord") return;
174
+ const parent = store.agent(agent.parent as string);
175
+ if (!parent?.channelId || !channels?.createThread) return;
176
+ try {
177
+ const channelId = await channels.createThread(
178
+ parent.channelId,
179
+ agent.displayName,
180
+ );
181
+ await store.updateAgent(name, { channelId });
182
+ } catch (error) {
183
+ logger.warn(
184
+ { agent: name, err: error },
185
+ "temp knight's thread not made; it stays in its rooms",
186
+ );
187
+ return;
188
+ }
189
+ logger.info(
190
+ { agent: name, parent: parent.name },
191
+ "temp knight's thread made",
192
+ );
193
+ this.#ctx.changed();
194
+ }
195
+
128
196
  /**
129
197
  * Restores one archived temp knight: its thread opens again, or a new one is made when the old
130
- * is gone. It comes back finished, with nothing running.
198
+ * is gone. One made in the web app never gets a new thread: its old one reopens, if it had one
199
+ * that is still there. It comes back finished, with nothing running.
131
200
  */
132
201
  async restoreOne(agent: Agent): Promise<void> {
133
202
  const { store, channels, logger } = this.#ctx.options;
@@ -137,6 +206,8 @@ export class TeamHelpers {
137
206
  `"${agent.name}" belongs to "${agent.parent}", which is archived; restore it first.`,
138
207
  );
139
208
  let { channelId } = agent;
209
+ const surface = helperSurface(agent);
210
+ const bornOnDiscord = surface === undefined || surface === "discord";
140
211
  if (channels?.createThread && parent.channelId) {
141
212
  if (
142
213
  channelId &&
@@ -144,7 +215,7 @@ export class TeamHelpers {
144
215
  (await channels.exists(channelId))
145
216
  )
146
217
  await channels.openThread(channelId);
147
- else
218
+ else if (bornOnDiscord)
148
219
  channelId = await channels.createThread(
149
220
  parent.channelId,
150
221
  agent.displayName,
@@ -87,6 +87,20 @@ export const roomSurfaceOf = (room: ChannelKey): string | undefined => {
87
87
  return surface === DISCORD_SURFACE ? undefined : surface;
88
88
  };
89
89
 
90
+ /**
91
+ * The channel a group seat runs in outside a running turn: the group's room on the surface of the
92
+ * seat's home for a room seat, so it never falls out to the group's Discord channel; otherwise the
93
+ * group's Discord channel, or the seat's home.
94
+ */
95
+ export function groupSeatChannel(
96
+ group: Pick<AgentGroup, "name" | "channelId">,
97
+ scope: Pick<AgentTurnScope, "home">,
98
+ ): ChannelKey {
99
+ const surface = roomSurfaceOf(scope.home);
100
+ if (surface !== undefined) return groupRoomKey(surface, group.name);
101
+ return group.channelId ? discordKey(group.channelId) : scope.home;
102
+ }
103
+
90
104
  /**
91
105
  * An agent's conversation inside a group, apart from its own. In a Discord group channel, or
92
106
  * without a room, it is keyed by the channel; in a room of another surface, by the surface and the
@@ -31,6 +31,7 @@ import {
31
31
  coordinatorOf,
32
32
  discordIdOf,
33
33
  discordKey,
34
+ groupSeatChannel,
34
35
  scopeOf,
35
36
  } from "./team-keys.ts";
36
37
  import {
@@ -429,10 +430,7 @@ export class TeamTurns {
429
430
  : undefined;
430
431
  if (!group) return scope.home;
431
432
  // A seat of a room's group runs in that room; its running turn knows which.
432
- return (
433
- this.#posting.get(scope.session) ??
434
- (group.channelId ? discordKey(group.channelId) : scope.home)
435
- );
433
+ return this.#posting.get(scope.session) ?? groupSeatChannel(group, scope);
436
434
  }
437
435
 
438
436
  /**
@@ -31,6 +31,7 @@ import { Scheduler } from "../modules/schedules/scheduler.ts";
31
31
  import { schedulesExtension } from "../modules/schedules/schedules.ts";
32
32
  import type { ErrorReporter } from "../ops/error-reporter.ts";
33
33
  import type { RoundtablePlugin } from "../plugin.ts";
34
+ import { runChannelFor } from "../presence/delivery-router.ts";
34
35
  import { splitReply } from "../presentation/reply-splitter.ts";
35
36
  import {
36
37
  AGENTS,
@@ -130,23 +131,22 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
130
131
  services.get(AGENTS).team.roomsOf(agent);
131
132
  // A conversation no chat surface carries keeps its runs in the creator's direct messages, where
132
133
  // a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
133
- const channelFor = async (
134
+ // The delivery router picks those messages with the conversation as the origin.
135
+ const channelFor = (
134
136
  channel: ChannelKey,
135
137
  principalId: string,
136
138
  refused: (message: string) => Error,
137
- ) => {
138
- if (surfaces.of(channel)) return channel;
139
- const reached = await directChannels.reach(principalId);
140
- if (!reached)
141
- throw refused(
142
- "this conversation has no chat surface to post a run in, and you have no direct channel on this host to post it in instead",
143
- );
144
- if (!conversations.takesBackground(reached.channel))
145
- throw refused(
146
- `this conversation has no chat surface to post a run in, and nothing here takes background turns in your ${reached.provider.name} direct messages, where it would go instead`,
147
- );
148
- return reached.channel;
149
- };
139
+ ) =>
140
+ runChannelFor(
141
+ {
142
+ surfaces,
143
+ directChannels,
144
+ takesBackground: (key) => conversations.takesBackground(key),
145
+ },
146
+ channel,
147
+ principalId,
148
+ refused,
149
+ );
150
150
  const scheduleChannelFor = (channel: ChannelKey, principalId: string) =>
151
151
  channelFor(channel, principalId, (m) => new ScheduleError(m));
152
152
  const delegateChannelFor = (channel: ChannelKey, principalId: string) =>
@@ -198,7 +198,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
198
198
  // Only whether a direct channel knows them, without the network; the conversation there,
199
199
  // and whether a claim takes its turns, are checked when a tool runs.
200
200
  try {
201
- if (!(await directChannels.known(own))) return;
201
+ if (!(await directChannels.known(home, own))) return;
202
202
  } catch (error) {
203
203
  logger.warn(
204
204
  { channel: home, err: error },
@@ -31,11 +31,12 @@ export interface AgentRoutes {
31
31
  */
32
32
  integration?: "origin" | "primary" | (string & {});
33
33
  /**
34
- * The conversation a `message_agent` call reaches when it names none. `"latest"` (default) is
35
- * the one the agent last ran a turn in, never carrying a message from a room out to Discord;
34
+ * The conversation a `message_agent` call reaches when it names none. `"origin-surface"`
35
+ * (default) is the agent's place on the sender's surface, else its primary place; `"latest"` the
36
+ * one the agent last ran a turn in on the sender's surface, else as `"origin-surface"`;
36
37
  * `"primary"` the agent's primary place; a surface name the agent's place there.
37
38
  */
38
- agentMessage?: "latest" | "primary" | (string & {});
39
+ agentMessage?: "origin-surface" | "latest" | "primary" | (string & {});
39
40
  /**
40
41
  * Where an agent's opening task (`agent_create task`) runs. `"origin-surface"` (default) is the
41
42
  * agent's place on the surface the creation came from, else its primary place; `"primary"`
@@ -86,7 +87,7 @@ const DECLARED_PLACES: readonly string[] = ["ops"];
86
87
  export const ROUTE_WORDS = {
87
88
  primary: ["auto"],
88
89
  integration: ["origin", "primary"],
89
- agentMessage: ["latest", "primary"],
90
+ agentMessage: ["origin-surface", "latest", "primary"],
90
91
  opening: ["origin-surface", "primary"],
91
92
  } as const;
92
93
 
@@ -36,6 +36,7 @@ export function discordDirectChannel(
36
36
  };
37
37
  return {
38
38
  name: "discord",
39
+ surface: "discord",
39
40
  label: "a direct message on Discord",
40
41
  knows: async (principalId) => (await userOf(principalId)) !== undefined,
41
42
  reaches: async (principalId) => {
@@ -19,7 +19,8 @@ export interface SessionNotify {
19
19
 
20
20
  /**
21
21
  * Registers notify for one session: it sends a notice to the person the conversation serves,
22
- * through the first direct channel that reaches them, and refuses when none does. Its 0.8 name,
22
+ * through the direct channel on the conversation's own surface (another only when that one cannot
23
+ * reach them), and refuses when none does. Its 0.8 name,
23
24
  * `notify_owner`, still names it in a selection and in `toolTiers` until 1.0 (`RENAMED_TOOLS`).
24
25
  */
25
26
  export function notifyExtension(
@@ -37,7 +38,7 @@ export function notifyExtension(
37
38
  return {
38
39
  name: "notify",
39
40
  label: "Notify",
40
- description: `Send ${o.name} ${notify.channels}. Use only when ${o.he} asks to be notified or reminded by DM; your normal reply already reaches ${o.him}.`,
41
+ description: `Send ${o.name} ${notify.channels}. It goes out on this conversation's own surface. Use only when ${o.he} asks to be notified or reminded; your normal reply already reaches ${o.him}.`,
41
42
  parameters: Type.Object({
42
43
  text: Type.String({ description: "The message to send." }),
43
44
  }),
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ChannelKey } from "../../domain/conversation.ts";
2
3
  import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
3
4
  import type { OwnerIdentity } from "../../identity.ts";
4
5
  import type { Logger } from "../../log.ts";
6
+ import { providerSurface, surfaceOf } from "../../presence/delivery-router.ts";
5
7
  import type {
6
8
  DirectChannelProvider,
7
9
  DirectChannels,
@@ -26,10 +28,21 @@ function labels(providers: readonly DirectChannelProvider[]): string {
26
28
  return providers.map((provider) => provider.label).join(" or ");
27
29
  }
28
30
 
31
+ /** The providers on the conversation's own surface, or every one when none is on it. */
32
+ function ownSurface(
33
+ providers: readonly DirectChannelProvider[],
34
+ home: ChannelKey,
35
+ ): readonly DirectChannelProvider[] {
36
+ const surface = surfaceOf(home);
37
+ const own = providers.filter((p) => providerSurface(p) === surface);
38
+ return own.length > 0 ? own : providers;
39
+ }
40
+
29
41
  /**
30
42
  * The notify tool of each session: none on a host without direct channels, and none in a private
31
43
  * conversation whose person no direct channel reaches. A notice goes to the conversation's
32
- * person in a private one, and to the turn's speaker in a shared one.
44
+ * person in a private one, and to the turn's speaker in a shared one, on the conversation's own
45
+ * surface: the delivery router tries another only when that one cannot reach them.
33
46
  */
34
47
  export function sessionNotify(
35
48
  deps: SessionNotifyDeps,
@@ -70,15 +83,16 @@ export function sessionNotify(
70
83
  const providers = directChannels.providers();
71
84
  if (providers.length === 0) return null;
72
85
  const { conversation } = session;
86
+ const origin = session.homeChannel;
73
87
  const own =
74
88
  conversation.visibility === "private"
75
89
  ? conversation.principalId
76
90
  : undefined;
77
91
  return async (pi) => {
78
- let reaching = providers;
92
+ let reaching = ownSurface(providers, origin);
79
93
  if (own !== undefined) {
80
94
  try {
81
- const known = await directChannels.known(own);
95
+ const known = await directChannels.known(origin, own);
82
96
  if (!known) return;
83
97
  reaching = [known];
84
98
  } catch (error) {
@@ -98,7 +112,10 @@ export function sessionNotify(
98
112
  : session.addressee;
99
113
  await notifyExtension(
100
114
  {
101
- notifier: directChannels,
115
+ notifier: {
116
+ notify: (principalId, text) =>
117
+ directChannels.notify(origin, principalId, text),
118
+ },
102
119
  recipient: async () => recipientOf(session, own),
103
120
  channels: labels(reaching),
104
121
  },
@@ -4,6 +4,7 @@ import type {
4
4
  SkillSet,
5
5
  SkillVersion,
6
6
  } from "./skill-rules.ts";
7
+ import type { SkillGroup } from "./skill-store.ts";
7
8
 
8
9
  /** The skills agents carry: built in, linked from repositories, or written by an agent. */
9
10
  export interface SkillRegistry {
@@ -45,6 +46,16 @@ export interface SkillRegistry {
45
46
  delete(name: string): Promise<string>;
46
47
  /** The saved versions of an agent-written skill, newest first; none for any other skill. */
47
48
  documentHistory(name: string): Promise<SkillVersion[]>;
49
+ /** Every skill group, by name: a named list of skills to filter by or apply to an agent once; an agent carries skills, not groups. */
50
+ groups(): SkillGroup[];
51
+ /** Creates or replaces a group; returns what it did. Throws AgentError for a refused name, an empty description or an unknown skill. */
52
+ setGroup(
53
+ name: string,
54
+ description: string,
55
+ skills: readonly string[],
56
+ ): Promise<string>;
57
+ /** Deletes a group and leaves its skills and their carriers as they are; returns what it did. Throws AgentError for an unknown group. */
58
+ deleteGroup(name: string): Promise<string>;
48
59
  /** Adds and removes an agent's registered skills together, or changes nothing; returns what it carries after. */
49
60
  attach(
50
61
  agent: string,
@@ -28,7 +28,7 @@ import {
28
28
  type SkillVersion,
29
29
  skillFileContent,
30
30
  } from "./skill-rules.ts";
31
- import type { SkillEntry } from "./skill-store.ts";
31
+ import type { SkillEntry, SkillGroup } from "./skill-store.ts";
32
32
 
33
33
  export {
34
34
  BUILTIN_SKILLS,
@@ -343,6 +343,8 @@ export class StoredSkillRegistry implements SkillRegistry {
343
343
  return this.#removed(name, detached);
344
344
  }
345
345
 
346
+ readonly groups = (): SkillGroup[] => this.#options.store.groups();
347
+
346
348
  async setGroup(
347
349
  name: string,
348
350
  description: string,
@@ -209,10 +209,11 @@ export class SkillStore {
209
209
  return this.#skills.get(name);
210
210
  }
211
211
 
212
+ /** Copies, by name, so a caller's change reaches no stored group. */
212
213
  groups(): SkillGroup[] {
213
- return [...this.#groups.values()].sort((a, b) =>
214
- a.name.localeCompare(b.name),
215
- );
214
+ return [...this.#groups.values()]
215
+ .map((group) => ({ ...group, skills: [...group.skills] }))
216
+ .sort((a, b) => a.name.localeCompare(b.name));
216
217
  }
217
218
 
218
219
  group(name: string): SkillGroup | undefined {
@@ -0,0 +1,165 @@
1
+ import type { SurfacePort } from "../contract/surface.ts";
2
+ import { parseChannelKey } from "../contract/surface.ts";
3
+ import type { ChannelKey } from "../domain/conversation.ts";
4
+ import type { Logger } from "../log.ts";
5
+ import { splitReply } from "../presentation/reply-splitter.ts";
6
+ import type {
7
+ DirectChannelProvider,
8
+ DirectChannels,
9
+ DirectReach,
10
+ } from "./direct-channels.ts";
11
+
12
+ /**
13
+ * The conversation a piece of work came from: a turn's channel, a schedule's, a job's. Its surface
14
+ * is the key's prefix, and everything the work sends goes there, or to the same person on that
15
+ * surface, before any other.
16
+ */
17
+ export type Origin = ChannelKey;
18
+
19
+ /** The surface an origin is on: the prefix of its key, such as `discord` or `app`. */
20
+ export function surfaceOf(origin: Origin): string {
21
+ return parseChannelKey(origin).surface;
22
+ }
23
+
24
+ /** The surface whose conversations a provider reaches: its `surface`, else its name. */
25
+ export function providerSurface(provider: DirectChannelProvider): string {
26
+ return provider.surface ?? provider.name;
27
+ }
28
+
29
+ /**
30
+ * The one place that decides where a notice or run for a person goes, from the origin of the work.
31
+ * A provider on the origin's surface is tried first; another surface is tried only when none there
32
+ * reaches the person, and that fallback is logged with both surfaces. Mirroring is the provider's
33
+ * own: after a delivery, each other provider that declares `mirror` echoes the notice to the same
34
+ * person. Nothing here picks the "first" provider or the "most recent" conversation.
35
+ */
36
+ export interface DeliveryRouter {
37
+ /** The providers in the order an origin tries them: its own surface's, then the rest. */
38
+ order(origin: Origin): readonly DirectChannelProvider[];
39
+ /** The person's own conversation for work from `origin`; undefined when no provider reaches them. */
40
+ reach(origin: Origin, principalId: string): Promise<DirectReach | undefined>;
41
+ /** The provider that knows the person for `origin`, without the network where it can tell. */
42
+ known(
43
+ origin: Origin,
44
+ principalId: string,
45
+ ): Promise<DirectChannelProvider | undefined>;
46
+ /** Sends the person `text` for work from `origin`; false when no provider reaches them. */
47
+ notify(origin: Origin, principalId: string, text: string): Promise<boolean>;
48
+ }
49
+
50
+ export function deliveryRouter(
51
+ providers: () => readonly DirectChannelProvider[],
52
+ surfaces: Pick<SurfacePort, "sendReply">,
53
+ logger?: Pick<Logger, "warn">,
54
+ ): DeliveryRouter {
55
+ const order = (origin: Origin): readonly DirectChannelProvider[] => {
56
+ const surface = surfaceOf(origin);
57
+ const all = providers();
58
+ return [
59
+ ...all.filter((p) => providerSurface(p) === surface),
60
+ ...all.filter((p) => providerSurface(p) !== surface),
61
+ ];
62
+ };
63
+ const fellBack = (
64
+ origin: Origin,
65
+ provider: DirectChannelProvider,
66
+ principalId: string,
67
+ ): void => {
68
+ const from = surfaceOf(origin);
69
+ const to = providerSurface(provider);
70
+ if (from !== to)
71
+ logger?.warn(
72
+ { origin, from, to, principalId, provider: provider.name },
73
+ "the origin's surface cannot reach the person; delivered on another surface",
74
+ );
75
+ };
76
+ const reach = async (
77
+ origin: Origin,
78
+ principalId: string,
79
+ ): Promise<DirectReach | undefined> => {
80
+ for (const provider of order(origin)) {
81
+ const channel = await provider.reaches(principalId);
82
+ if (channel === undefined) continue;
83
+ fellBack(origin, provider, principalId);
84
+ return { provider, channel };
85
+ }
86
+ return undefined;
87
+ };
88
+ const mirrored = async (
89
+ delivered: DirectChannelProvider,
90
+ principalId: string,
91
+ text: string,
92
+ ): Promise<void> => {
93
+ await Promise.all(
94
+ providers()
95
+ .filter((provider) => provider !== delivered && provider.mirror)
96
+ .map(async (provider) => {
97
+ try {
98
+ await provider.mirror?.(principalId, text);
99
+ } catch (error) {
100
+ logger?.warn(
101
+ { err: error, provider: provider.name },
102
+ "a direct channel did not mirror a notice",
103
+ );
104
+ }
105
+ }),
106
+ );
107
+ };
108
+ return {
109
+ order,
110
+ reach,
111
+ known: async (origin, principalId) => {
112
+ for (const provider of order(origin)) {
113
+ const knows = provider.knows
114
+ ? await provider.knows(principalId)
115
+ : (await provider.reaches(principalId)) !== undefined;
116
+ if (knows) return provider;
117
+ }
118
+ return undefined;
119
+ },
120
+ notify: async (origin, principalId, text) => {
121
+ const reached = await reach(origin, principalId);
122
+ if (!reached) return false;
123
+ if (reached.provider.deliver)
124
+ await reached.provider.deliver(principalId, text);
125
+ else
126
+ await surfaces.sendReply(reached.channel, {
127
+ chunks: splitReply(text),
128
+ });
129
+ await mirrored(reached.provider, principalId, text);
130
+ return true;
131
+ },
132
+ };
133
+ }
134
+
135
+ /** What `runChannelFor` reads: the surfaces, the direct channels, and which claims take background turns. */
136
+ export interface RunChannelDeps {
137
+ surfaces: Pick<SurfacePort, "of">;
138
+ directChannels: Pick<DirectChannels, "reach">;
139
+ takesBackground(channel: ChannelKey): boolean;
140
+ }
141
+
142
+ /**
143
+ * The conversation a schedule's runs or a delegated job's report post in, for work created in
144
+ * `origin`: the origin itself when a chat surface carries it; otherwise the creator's own
145
+ * conversation the router reaches from that origin, which a claim must take background turns in.
146
+ * Throws `refused` with why there is none.
147
+ */
148
+ export async function runChannelFor(
149
+ deps: RunChannelDeps,
150
+ origin: Origin,
151
+ principalId: string,
152
+ refused: (message: string) => Error,
153
+ ): Promise<ChannelKey> {
154
+ if (deps.surfaces.of(origin)) return origin;
155
+ const reached = await deps.directChannels.reach(origin, principalId);
156
+ if (!reached)
157
+ throw refused(
158
+ "this conversation has no chat surface to post a run in, and you have no direct channel on this host to post it in instead",
159
+ );
160
+ if (!deps.takesBackground(reached.channel))
161
+ throw refused(
162
+ `this conversation has no chat surface to post a run in, and nothing here takes background turns in your ${reached.provider.name} direct messages, where it would go instead`,
163
+ );
164
+ return reached.channel;
165
+ }
@@ -1,13 +1,13 @@
1
1
  import type { SurfacePort } from "../contract/surface.ts";
2
2
  import type { ChannelKey } from "../domain/conversation.ts";
3
- import type { Notifier } from "../domain/ports.ts";
4
3
  import type { Logger } from "../log.ts";
5
- import { splitReply } from "../presentation/reply-splitter.ts";
4
+ import { deliveryRouter } from "./delivery-router.ts";
6
5
 
7
6
  /**
8
7
  * A way to reach a person on their own, such as a chat network's direct messages or a web inbox.
9
- * A plugin contributes it as `directChannels`; the host reaches a person through the first one,
10
- * in contribution order, that reaches them.
8
+ * A plugin contributes it as `directChannels`; the host reaches a person for a piece of work
9
+ * through a provider on the surface the work came from, and through another only when none there
10
+ * reaches them (`DeliveryRouter`).
11
11
  */
12
12
  export interface DirectChannelProvider {
13
13
  /** Unique across every plugin, such as `"discord"`. */
@@ -17,6 +17,12 @@ export interface DirectChannelProvider {
17
17
  * `"a direct message on Discord"`.
18
18
  */
19
19
  label: string;
20
+ /**
21
+ * The surface whose conversations it reaches, the key prefix of the channels `reaches` names,
22
+ * such as `"discord"` or the web app's `"app"`; work from a conversation on that surface reaches
23
+ * the person here first. Without it, its `name`.
24
+ */
25
+ surface?: string;
20
26
  /**
21
27
  * The person's own conversation here, where a notice to them is posted and where their
22
28
  * schedules and delegated reports from a conversation no chat surface carries run; undefined
@@ -38,8 +44,8 @@ export interface DirectChannelProvider {
38
44
  /**
39
45
  * Called with the notice after another provider delivered it, so this one can echo it, such as
40
46
  * a web app pushing a prompt to a phone that Discord's direct message already reached. It runs
41
- * only after a delivery, on every provider but the one that delivered; its failure is logged
42
- * and never changes what `notify` returns.
47
+ * only after a delivery, on every provider but the one that delivered, and only ever to the same
48
+ * person; its failure is logged and never changes what `notify` returns.
43
49
  */
44
50
  mirror?(principalId: string, text: string): Promise<void>;
45
51
  }
@@ -51,24 +57,36 @@ export interface DirectReach {
51
57
  }
52
58
 
53
59
  /**
54
- * Every plugin's direct channels, as plugins and the core use them. Each call before the host
55
- * links its plugins throws NotLinkedError, as for `conversations`.
60
+ * Every plugin's direct channels, as plugins and the core use them. Each lookup takes the origin of
61
+ * the work, the conversation it came from, and goes through the delivery router: a provider on the
62
+ * origin's surface first, another surface only when none there reaches the person. Each call before
63
+ * the host links its plugins throws NotLinkedError, as for `conversations`.
56
64
  */
57
- export interface DirectChannels extends Notifier {
65
+ export interface DirectChannels {
58
66
  /** Every contributed provider, in contribution order; empty when no plugin contributes one. */
59
67
  providers(): readonly DirectChannelProvider[];
60
- /** The person's own conversation on the first provider that reaches them; undefined when none does. */
61
- reach(principalId: string): Promise<DirectReach | undefined>;
68
+ /** The person's own conversation for work from `origin`; undefined when no provider reaches them. */
69
+ reach(
70
+ origin: ChannelKey,
71
+ principalId: string,
72
+ ): Promise<DirectReach | undefined>;
62
73
  /**
63
- * The first provider that knows the person, as `DirectChannelProvider.knows` tells without the
64
- * network (or `reaches`, for a provider without it); undefined when none does.
74
+ * The provider that knows the person for work from `origin`, as `DirectChannelProvider.knows`
75
+ * tells without the network (or `reaches`, for a provider without it); undefined when none does.
65
76
  */
66
- known(principalId: string): Promise<DirectChannelProvider | undefined>;
77
+ known(
78
+ origin: ChannelKey,
79
+ principalId: string,
80
+ ): Promise<DirectChannelProvider | undefined>;
67
81
  /**
68
- * Sends the person `text` through the first provider that reaches them; false when none does.
69
- * A provider's failure, such as a network error, rejects.
82
+ * Sends the person `text` for work from `origin`, on its surface when a provider there reaches
83
+ * them; false when none anywhere does. A provider's failure, such as a network error, rejects.
70
84
  */
71
- notify(principalId: string, text: string): Promise<boolean>;
85
+ notify(
86
+ origin: ChannelKey,
87
+ principalId: string,
88
+ text: string,
89
+ ): Promise<boolean>;
72
90
  }
73
91
 
74
92
  /** The direct channels over the providers the host linked, posting through its surfaces where a provider does not deliver itself. */
@@ -77,58 +95,11 @@ export function directChannelsPort(
77
95
  surfaces: Pick<SurfacePort, "sendReply">,
78
96
  logger?: Pick<Logger, "warn">,
79
97
  ): DirectChannels {
80
- const reach = async (
81
- principalId: string,
82
- ): Promise<DirectReach | undefined> => {
83
- for (const provider of providers()) {
84
- const channel = await provider.reaches(principalId);
85
- if (channel !== undefined) return { provider, channel };
86
- }
87
- return undefined;
88
- };
89
- const mirrored = async (
90
- delivered: DirectChannelProvider,
91
- principalId: string,
92
- text: string,
93
- ): Promise<void> => {
94
- await Promise.all(
95
- providers()
96
- .filter((provider) => provider !== delivered && provider.mirror)
97
- .map(async (provider) => {
98
- try {
99
- await provider.mirror?.(principalId, text);
100
- } catch (error) {
101
- logger?.warn(
102
- { err: error, provider: provider.name },
103
- "a direct channel did not mirror a notice",
104
- );
105
- }
106
- }),
107
- );
108
- };
98
+ const router = deliveryRouter(providers, surfaces, logger);
109
99
  return {
110
100
  providers: () => providers(),
111
- reach,
112
- known: async (principalId) => {
113
- for (const provider of providers()) {
114
- const knows = provider.knows
115
- ? await provider.knows(principalId)
116
- : (await provider.reaches(principalId)) !== undefined;
117
- if (knows) return provider;
118
- }
119
- return undefined;
120
- },
121
- notify: async (principalId, text) => {
122
- const reached = await reach(principalId);
123
- if (!reached) return false;
124
- if (reached.provider.deliver)
125
- await reached.provider.deliver(principalId, text);
126
- else
127
- await surfaces.sendReply(reached.channel, {
128
- chunks: splitReply(text),
129
- });
130
- await mirrored(reached.provider, principalId, text);
131
- return true;
132
- },
101
+ reach: router.reach,
102
+ known: router.known,
103
+ notify: router.notify,
133
104
  };
134
105
  }
package/src/index.ts CHANGED
@@ -4,7 +4,11 @@ export {
4
4
  AGENT_SERVER_PRIORITY,
5
5
  OWNER_TARGET,
6
6
  } from "./core/agents/agent-claim.ts";
7
- export { characterOf, isHelper } from "./core/agents/agent-rules.ts";
7
+ export {
8
+ characterOf,
9
+ helperSurface,
10
+ isHelper,
11
+ } from "./core/agents/agent-rules.ts";
8
12
  export type {
9
13
  Agent,
10
14
  AgentGroup,
@@ -333,6 +337,7 @@ export type {
333
337
  SkillVersion,
334
338
  } from "./core/modules/skills/skill-rules.ts";
335
339
  export { SkillConflictError } from "./core/modules/skills/skill-rules.ts";
340
+ export type { SkillGroup } from "./core/modules/skills/skill-store.ts";
336
341
  export type {
337
342
  ActionEvent,
338
343
  AgentTools,