pi-roundtable 0.28.0 → 0.29.1

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,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.29.1] - 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - Queued messages can be listed, edited and cancelled. A message sent while a turn runs that the turn did not take waits in the channel's queue (`QUEUED_MARK`); until its own turn starts, `ConversationPort.queued(channel)` lists it (`QueuedMessage`: `messageId`, `principalId`, `text`, `queuedAt`), `cancelQueued(channel, messageId, by)` makes its turn never run (the claim's `Admission.dropped` is called and the mark comes off), and `editQueued(channel, messageId, by, text)` admits it again with the new text, so the turn runs on that. `by` is the author's principal (`QueuedBy`); anyone else gets `forbidden`, and a message that started, was steered or was cancelled is `unknown` (`QueuedChange`). The three are optional on the port type, as `messageDeleted` is. A surface that reports a deleted queued message cancels it, and one that reports an edited one changes its text, through `SurfaceMessageEvents` as before: on Discord, deleting a queued message cancels it and editing it changes what its turn uses. `QUEUED_MARK` and `STEERED_MARK` are exported from the main entry.
13
+
14
+ ## [0.29.0] - 2026-10-31
15
+
16
+ ### Added
17
+
18
+ - `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.
19
+ - `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.
20
+
21
+ ### Changed
22
+
23
+ - 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.
24
+ - 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.
25
+ - `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.
26
+ - The notify tool's description says it goes out on the conversation's own surface.
27
+ - 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.
28
+
29
+ ### Fixed
30
+
31
+ - 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.
32
+
8
33
  ## [0.28.0] - 2026-10-31
9
34
 
10
35
  ### 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` |
@@ -2436,6 +2436,8 @@ When that record finds the author someone else than the claim admitted, such as
2436
2436
 
2437
2437
  A claim may also hear that a message it owns changed, through `onMessageDeleted(channel, messageId)` and `onMessageEdited(message)`, which a surface reports with the second argument of its `start` (`{ deleted, edited }`, a `SurfaceMessageEvents`). Discord reports both, including for a message the bot had not cached; a surface that cannot tell never calls them. The router delivers each as it comes, to the claim that owns the channel and no other, outside the channel's queue, so a running turn does not hold it up; a throw or rejection is logged and never fails the router. A delete carries only ids, and Discord's delete event does not say who wrote the message, so the claim hears the deletion of the bot's own messages too and ignores an id it does not know. An edit carries the message as it reads now, as an `InboundMessage`, without `speaker` or `peerBot` because nobody is resolved or admitted for it; the bot's own edits are not reported, nor is a change that leaves the text, the embeds and the attachments as they were, such as a pin. A bulk delete is reported as one delete per message.
2438
2438
 
2439
+ A message sent while a turn runs is first offered to that turn (`busy.steer`); one the turn does not take waits in the channel's queue with `QUEUED_MARK`, and until its own turn starts the conversation port can list, cancel and edit it: `queued(channel)` returns its waiting messages oldest first (`messageId`, the author's `principalId`, the `text` its turn will use, `queuedAt`); `cancelQueued(channel, messageId, { principalId })` makes its turn never run, calling the admission's `dropped` and removing the mark; `editQueued(channel, messageId, { principalId }, text)` admits the message again with the new text through the claim's `admit`, drops the old admission, and the turn runs on the new one. Only the message's author may (a different principal gets `forbidden`, and a message with no resolved author can be changed by no one). A message whose turn has started, was steered into the running turn, or was cancelled is `unknown`; an edit the claim would no longer admit is `refused` and the old text stays. The decision is made in one synchronous step, so a cancel or an edit and the start of the turn never both win. The surface's own copy of the message is the caller's to change. When a surface reports a queued message deleted or edited (`onMessageDeleted`, `onMessageEdited` above), the router cancels it or changes its text itself, without the author check since the surface says it happened: that is how deleting a queued Discord message cancels it. The three methods are optional on `ConversationPort`, as `messageDeleted` is.
2440
+
2439
2441
  A claim may have `stop(channel)`, which stops the channel's running turn and returns whether one was running; the Stop button, `conversations.stop`, and every other stop go through it.
2440
2442
  The router calls only the owning claim's `stop`, returning `false` when that method is absent.
2441
2443
  Give `stop` to a claim whose conversations run turns that can be interrupted.
@@ -2999,6 +3001,7 @@ setup: ({ services }) => ({
2999
3001
  directChannels: [
3000
3002
  {
3001
3003
  name: "inbox",
3004
+ surface: "chat",
3002
3005
  label: "a notice in the web chat's inbox",
3003
3006
  reaches: async (principalId) => inboxOf(principalId),
3004
3007
  deliver: async (principalId, text) => postNotice(principalId, text),
@@ -3010,23 +3013,36 @@ setup: ({ services }) => ({
3010
3013
  | Field | What it is |
3011
3014
  |---|---|
3012
3015
  | `name` | Unique across every plugin; a second provider of one name is a `PluginError` naming both plugins |
3016
+ | `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
3017
  | `label` | How the `notify` tool names a notice sent through it, completing "Send Ada …", such as `a direct message on Discord` |
3014
3018
  | `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
3019
  | `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
3020
  | `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 |
3021
+ | `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
3022
 
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.
3023
+ 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.
3024
+ `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
3025
  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
3026
 
3023
3027
  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
3028
  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.
3029
+ 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
3030
  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
3031
  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
3032
  Using the old name logs a deprecation warning once per logger, whether in a selection or in `toolTiers`.
3029
3033
 
3034
+ ### Where things are delivered
3035
+
3036
+ 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.
3037
+
3038
+ - **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.
3039
+ - **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.
3040
+ - **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.
3041
+ - **`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.
3042
+ - **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.
3043
+
3044
+ `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.
3045
+
3030
3046
  ### `surfaces`: a chat network of your own
3031
3047
 
3032
3048
  A chat surface connects the host to one chat network: it reports what people write, and it posts what the conversations answer.
@@ -3087,7 +3103,7 @@ An agent has a *place* on each surface that holds one: its Discord channel, and
3087
3103
  | `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
3104
  | `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
3105
  | `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. |
3106
+ | `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
3107
  | `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
3108
  | `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
3109
 
@@ -3109,13 +3125,13 @@ It follows its knight's routing rules, and its yolo and long-running modes follo
3109
3125
 
3110
3126
  | Rule | What happens |
3111
3127
  |---|---|
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. |
3128
+ | 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
3129
  | 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
3130
  | 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
3131
  | 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
3132
 
3117
3133
  `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.
3134
+ `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
3135
  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
3136
 
3121
3137
  ```ts
@@ -4403,6 +4419,7 @@ Import from the entries listed below; source area files are internal.
4403
4419
  | `AvatarStudio` | `pi-roundtable` | type |
4404
4420
  | `PROMPTS` | `pi-roundtable` | value |
4405
4421
  | `characterOf` | `pi-roundtable` | value |
4422
+ | `helperSurface` | `pi-roundtable` | value |
4406
4423
  | `isHelper` | `pi-roundtable` | value |
4407
4424
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
4408
4425
  | `CONVERSATIONS` | `pi-roundtable` | value |
@@ -4551,6 +4568,11 @@ Import from the entries listed below; source area files are internal.
4551
4568
  | `Provisioning` | `pi-roundtable` | type |
4552
4569
  | `ProvisioningRule` | `pi-roundtable` | type |
4553
4570
  | `QueuePort` | `pi-roundtable` | type |
4571
+ | `QueuedBy` | `pi-roundtable` | type |
4572
+ | `QueuedChange` | `pi-roundtable` | type |
4573
+ | `QueuedMessage` | `pi-roundtable` | type |
4574
+ | `QUEUED_MARK` | `pi-roundtable` | value |
4575
+ | `STEERED_MARK` | `pi-roundtable` | value |
4554
4576
  | `QUESTIONNAIRE_LIMITS` | `pi-roundtable` | value |
4555
4577
  | `Questionnaire` | `pi-roundtable` | type |
4556
4578
  | `QuestionnaireAnswer` | `pi-roundtable` | type |
@@ -4594,6 +4616,7 @@ Import from the entries listed below; source area files are internal.
4594
4616
  | `PromptConflictError` | `pi-roundtable` | value |
4595
4617
  | `SkillConflictError` | `pi-roundtable` | value |
4596
4618
  | `SkillDocument` | `pi-roundtable` | type |
4619
+ | `SkillGroup` | `pi-roundtable` | type |
4597
4620
  | `SKILLS` | `pi-roundtable` | value |
4598
4621
  | `SINGLE_QUESTION` | `pi-roundtable` | value |
4599
4622
  | `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.1",
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(