pi-roundtable 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +6 -4
  3. package/docs/plugins.md +1448 -66
  4. package/examples/echo-runtime.test.ts +122 -0
  5. package/examples/echo-runtime.ts +107 -0
  6. package/examples/events.test.ts +1 -0
  7. package/examples/events.ts +2 -2
  8. package/examples/fake-surface.test.ts +180 -0
  9. package/examples/fake-surface.ts +109 -0
  10. package/examples/interactions.test.ts +12 -3
  11. package/examples/interactions.ts +19 -19
  12. package/examples/shared-services.test.ts +74 -0
  13. package/examples/shared-services.ts +60 -0
  14. package/examples/study-room.test.ts +69 -0
  15. package/examples/study-room.ts +52 -0
  16. package/examples/support-desk.test.ts +34 -0
  17. package/examples/support-desk.ts +40 -0
  18. package/package.json +9 -2
  19. package/src/cli/checks/database.ts +11 -30
  20. package/src/cli/checks/images.ts +19 -0
  21. package/src/cli/doctor.ts +6 -0
  22. package/src/cli/main.ts +2 -1
  23. package/src/core/agents/agent-claim.ts +42 -16
  24. package/src/core/agents/agent-messages.ts +4 -4
  25. package/src/core/agents/agent-ports.ts +8 -21
  26. package/src/core/agents/agent-prompt.ts +3 -1
  27. package/src/core/agents/agent-store.ts +6 -4
  28. package/src/core/agents/agent-team-fixture.ts +389 -0
  29. package/src/core/agents/agent-team.ts +26 -25
  30. package/src/core/agents/agent-tools.ts +34 -15
  31. package/src/core/agents/avatar-studio.ts +40 -6
  32. package/src/core/agents/fallback-avatar.ts +85 -0
  33. package/src/core/agents/team-editing.ts +49 -10
  34. package/src/core/agents/team-keys.ts +22 -9
  35. package/src/core/agents/team-layout.ts +4 -3
  36. package/src/core/agents/team-lifecycle.ts +28 -2
  37. package/src/core/agents/team-options.ts +9 -5
  38. package/src/core/agents/team-status.ts +4 -4
  39. package/src/core/agents/team-text.ts +8 -6
  40. package/src/core/agents/team-turn-types.ts +4 -4
  41. package/src/core/agents/team-turns.ts +6 -5
  42. package/src/core/builtin/agent-server.ts +187 -121
  43. package/src/core/builtin/discord-admin.ts +41 -0
  44. package/src/core/builtin/discord.ts +65 -40
  45. package/src/core/builtin/modules.ts +67 -58
  46. package/src/core/builtin/session-tool.ts +24 -0
  47. package/src/core/builtin/skills.ts +72 -0
  48. package/src/core/builtin/stores.ts +55 -31
  49. package/src/core/config/config.ts +45 -4
  50. package/src/core/config/schema.ts +6 -0
  51. package/src/core/contract/channels.ts +54 -14
  52. package/src/core/contract/providers.ts +17 -0
  53. package/src/core/contract/runtime.ts +138 -0
  54. package/src/core/contract/services.ts +45 -0
  55. package/src/core/contract/surface.ts +88 -0
  56. package/src/core/db/migrations.ts +118 -3
  57. package/src/core/define-roundtable.ts +70 -46
  58. package/src/core/define.ts +2 -1
  59. package/src/core/discord/agent-commands.ts +39 -14
  60. package/src/core/discord/agent-panel.ts +66 -0
  61. package/src/core/discord/channel-executor.ts +6 -3
  62. package/src/core/discord/channel-operations.ts +14 -11
  63. package/src/core/discord/command-collection.ts +46 -0
  64. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  65. package/src/core/discord/connection.ts +35 -0
  66. package/src/core/discord/discord-surface.ts +32 -94
  67. package/src/core/discord/dispatch-threads.ts +2 -2
  68. package/src/core/discord/inbound-message.ts +99 -0
  69. package/src/core/discord/interaction-module.ts +57 -1
  70. package/src/core/discord/owner-cards.ts +4 -4
  71. package/src/core/discord/owner-command.ts +44 -11
  72. package/src/core/discord/owner-discord.ts +2 -2
  73. package/src/core/discord/schedule-commands.ts +21 -18
  74. package/src/core/discord/stop-button.ts +44 -1
  75. package/src/core/domain/attachment.ts +12 -8
  76. package/src/core/domain/conversation.ts +3 -4
  77. package/src/core/domain/owner-prompts.ts +5 -5
  78. package/src/core/domain/ports.ts +9 -53
  79. package/src/core/freeze.ts +9 -0
  80. package/src/core/host.ts +255 -82
  81. package/src/core/http/listeners.ts +55 -20
  82. package/src/core/i18n/agent-panel.ts +4 -0
  83. package/src/core/i18n/index.ts +12 -2
  84. package/src/core/i18n/owner.ts +7 -1
  85. package/src/core/i18n/schedules.ts +12 -6
  86. package/src/core/identity.ts +1 -1
  87. package/src/core/judging/effort-judge.ts +25 -3
  88. package/src/core/log.ts +21 -3
  89. package/src/core/models.ts +2 -2
  90. package/src/core/modules/background/background-turns.ts +6 -4
  91. package/src/core/modules/delegation/delegate.ts +3 -2
  92. package/src/core/modules/delegation/delegator.ts +15 -13
  93. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  94. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  95. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  96. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  97. package/src/core/modules/memory/owner-memory.ts +9 -13
  98. package/src/core/modules/schedules/recurrence.ts +5 -3
  99. package/src/core/modules/schedules/schedule-store.ts +11 -11
  100. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  101. package/src/core/modules/schedules/scheduler.ts +7 -2
  102. package/src/core/modules/schedules/schedules.ts +9 -3
  103. package/src/core/modules/skills/skill-registry.ts +3 -2
  104. package/src/core/modules/skills/skill-store.ts +2 -15
  105. package/src/core/modules/skills/skill-tools.ts +10 -4
  106. package/src/core/ops/error-reporter.ts +1 -1
  107. package/src/core/plugin.ts +232 -28
  108. package/src/core/registry/contributions.ts +173 -14
  109. package/src/core/registry/providers.ts +35 -5
  110. package/src/core/registry/services.ts +229 -0
  111. package/src/core/routing/channel-queue.ts +5 -0
  112. package/src/core/routing/channel-router.ts +21 -7
  113. package/src/core/routing/conversation-turns.ts +139 -0
  114. package/src/core/routing/message-text.ts +9 -2
  115. package/src/core/routing/surface-port.ts +39 -0
  116. package/src/core/runtime/conversation-sessions.ts +4 -3
  117. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  118. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  119. package/src/core/runtime/mcp.ts +1 -1
  120. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  121. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  122. package/src/core/runtime/prompt-slot.ts +8 -3
  123. package/src/core/runtime/runtime-types.ts +9 -35
  124. package/src/core/runtime/session-factory.ts +21 -5
  125. package/src/core/runtime/text-tools.ts +1 -3
  126. package/src/core/services.ts +238 -90
  127. package/src/core/sessions.ts +3 -2
  128. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  129. package/src/core/shared/delegate-tool.ts +4 -3
  130. package/src/core/shared/schedule-tools.ts +22 -13
  131. package/src/core/shared/session-messages.ts +1 -1
  132. package/src/core/speakers.ts +6 -38
  133. package/src/core/testing/database.ts +1 -7
  134. package/src/core/testing/eager-catalog.ts +30 -0
  135. package/src/core/testing/locale.ts +23 -5
  136. package/src/core/testing/modules.ts +105 -41
  137. package/src/core/testing/test-host.ts +279 -0
  138. package/src/core/testing/tool-set.ts +64 -0
  139. package/src/core/time.ts +6 -1
  140. package/src/core/tool-set.snapshot.json +288 -0
  141. package/src/core/tool-tiers.ts +17 -57
  142. package/src/discord/index.ts +64 -0
  143. package/src/index.ts +181 -10
  144. package/src/kit/channels.ts +14 -0
  145. package/src/kit/domain.ts +18 -0
  146. package/src/kit/holds.ts +4 -0
  147. package/src/kit/index.ts +129 -0
  148. package/src/kit/judging.ts +10 -0
  149. package/src/kit/memory.ts +3 -0
  150. package/src/kit/mirror.ts +19 -0
  151. package/src/kit/presentation.ts +7 -0
  152. package/src/kit/shell.ts +7 -0
  153. package/src/kit/skills.ts +8 -0
  154. package/src/kit/support.ts +21 -0
  155. package/src/kit/threads.ts +7 -0
  156. package/src/kit/tools.ts +14 -0
  157. package/src/kit/worker.ts +16 -0
  158. package/src/testing.ts +394 -34
  159. package/examples/guide.test.ts +0 -52
  160. package/src/cli/add-plugin.test.ts +0 -107
  161. package/src/cli/checks/basic.test.ts +0 -276
  162. package/src/cli/checks/database.test.ts +0 -117
  163. package/src/cli/checks/discord.test.ts +0 -200
  164. package/src/cli/cli.test.ts +0 -122
  165. package/src/cli/config-edit.test.ts +0 -130
  166. package/src/cli/doctor.test.ts +0 -184
  167. package/src/cli/init.test.ts +0 -127
  168. package/src/cli/size.test.ts +0 -9
  169. package/src/cli/templates.test.ts +0 -129
  170. package/src/cli/testing/fixtures.ts +0 -106
  171. package/src/core/agents/agent-claim.test.ts +0 -142
  172. package/src/core/agents/agent-dashboard.test.ts +0 -120
  173. package/src/core/agents/agent-guild.test.ts +0 -124
  174. package/src/core/agents/agent-store.test.ts +0 -205
  175. package/src/core/agents/avatar-studio.test.ts +0 -88
  176. package/src/core/agents/group-round.test.ts +0 -143
  177. package/src/core/agents/owner-identity.test.ts +0 -172
  178. package/src/core/agents/team-turns.test.ts +0 -278
  179. package/src/core/attachments/attachments.test.ts +0 -85
  180. package/src/core/boundary.test.ts +0 -45
  181. package/src/core/builtin/modules.test.ts +0 -127
  182. package/src/core/config/config.test.ts +0 -135
  183. package/src/core/contract/discord.ts +0 -33
  184. package/src/core/db/migrations.test.ts +0 -237
  185. package/src/core/define-roundtable.test.ts +0 -134
  186. package/src/core/define.test.ts +0 -144
  187. package/src/core/discord/agent-commands.test.ts +0 -58
  188. package/src/core/discord/agent-discord.test.ts +0 -70
  189. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  190. package/src/core/discord/dispatch-threads.test.ts +0 -94
  191. package/src/core/discord/owner-cards.test.ts +0 -435
  192. package/src/core/discord/owner-discord.test.ts +0 -325
  193. package/src/core/domain/expression.ts +0 -21
  194. package/src/core/domain/profile.ts +0 -32
  195. package/src/core/drain.test.ts +0 -46
  196. package/src/core/events.test.ts +0 -81
  197. package/src/core/holds.test.ts +0 -54
  198. package/src/core/host.test.ts +0 -536
  199. package/src/core/http/listeners.test.ts +0 -164
  200. package/src/core/i18n/i18n.test.ts +0 -139
  201. package/src/core/identity.test.ts +0 -18
  202. package/src/core/judging/effort-judge.test.ts +0 -112
  203. package/src/core/judging/model-judge.test.ts +0 -126
  204. package/src/core/modules/delegation/delegator.test.ts +0 -170
  205. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  206. package/src/core/modules/schedules/schedule.test.ts +0 -358
  207. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  208. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  209. package/src/core/ops/error-reporter.test.ts +0 -283
  210. package/src/core/presentation/card-cadence.ts +0 -41
  211. package/src/core/presentation/presentation.test.ts +0 -166
  212. package/src/core/public-entry.test.ts +0 -59
  213. package/src/core/registry/contributions.test.ts +0 -215
  214. package/src/core/registry/interactions.test.ts +0 -87
  215. package/src/core/registry/providers.test.ts +0 -103
  216. package/src/core/routing/channel-queue.test.ts +0 -31
  217. package/src/core/routing/channel-router.test.ts +0 -326
  218. package/src/core/routing/conversation-kind.ts +0 -14
  219. package/src/core/routing/settle-turn.test.ts +0 -21
  220. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  221. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  222. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  223. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  224. package/src/core/runtime/session-archive.test.ts +0 -20
  225. package/src/core/runtime/steerable-run.test.ts +0 -264
  226. package/src/core/runtime/text-tools.test.ts +0 -110
  227. package/src/core/runtime/turn-answer.test.ts +0 -72
  228. package/src/core/runtime/worker-task.test.ts +0 -82
  229. package/src/core/services.test.ts +0 -28
  230. package/src/core/sessions.test.ts +0 -85
  231. package/src/core/shared/unix-server.ts +0 -18
  232. package/src/core/size.test.ts +0 -9
  233. package/src/core/speakers.test.ts +0 -78
  234. package/src/core/testing/file-size.ts +0 -34
  235. package/src/core/time.test.ts +0 -166
  236. package/src/core/tool-tiers.test.ts +0 -117
  237. package/src/entries.test.ts +0 -112
  238. package/src/testing.test.ts +0 -156
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type BackgroundTarget,
2
3
  type BackgroundTurn,
3
4
  type ChannelClaim,
4
5
  type ConversationPort,
@@ -17,8 +18,8 @@ export interface ChannelRouterOptions {
17
18
  /** Claims in registration order; the router orders them by priority. */
18
19
  claims: readonly ChannelClaim[];
19
20
  queue: ChannelQueue;
20
- /** Stops the channel's running turn; false when none runs. */
21
- stop(channel: ChannelKey): boolean;
21
+ /** The contributed background target of a name, read at use time. */
22
+ targets(name: string): BackgroundTarget | undefined;
22
23
  logger: Logger;
23
24
  /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
24
25
  forwardJoinMs?: number;
@@ -53,8 +54,8 @@ export class ChannelRouter implements ConversationPort {
53
54
  this.#forwards = new ForwardJoin(options.forwardJoinMs);
54
55
  }
55
56
 
56
- #owner(channel: ChannelKey, guildId?: string): ChannelClaim | undefined {
57
- return this.#claims.find((claim) => claim.owns(channel, guildId));
57
+ #owner(channel: ChannelKey, space?: string): ChannelClaim | undefined {
58
+ return this.#claims.find((claim) => claim.owns(channel, space));
58
59
  }
59
60
 
60
61
  /**
@@ -74,7 +75,7 @@ export class ChannelRouter implements ConversationPort {
74
75
  !message.forwarded?.text.trim()
75
76
  )
76
77
  return;
77
- const admission = this.#owner(message.channel, message.guildId)?.admit(
78
+ const admission = this.#owner(message.channel, message.space)?.admit(
78
79
  message,
79
80
  );
80
81
  if (!admission) return;
@@ -108,8 +109,20 @@ export class ChannelRouter implements ConversationPort {
108
109
  );
109
110
  }
110
111
 
111
- /** A turn nobody wrote, run by the channel's claim inside its queue; never rejects. */
112
+ target(name: string): BackgroundTarget | undefined {
113
+ return this.#options.targets(name);
114
+ }
115
+
116
+ /**
117
+ * A turn nobody wrote, run by the channel's claim inside its queue; never rejects. A turn whose
118
+ * target no plugin contributes is skipped, never handed to another target's claim.
119
+ */
112
120
  async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
121
+ if (!this.#options.targets(turn.target))
122
+ return {
123
+ status: "skipped",
124
+ reason: `no plugin contributes the background target "${turn.target}"`,
125
+ };
113
126
  try {
114
127
  return await this.#options.queue.run(turn.channel, async () => {
115
128
  const claim = this.#owner(turn.channel);
@@ -147,8 +160,9 @@ export class ChannelRouter implements ConversationPort {
147
160
  });
148
161
  }
149
162
 
163
+ /** Asks only the claim that owns the channel; false when none does or it has no `stop`. */
150
164
  stop(channel: ChannelKey): boolean {
151
- return this.#options.stop(channel);
165
+ return this.#owner(channel)?.stop?.(channel) ?? false;
152
166
  }
153
167
 
154
168
  postsInPlace(channel: ChannelKey): boolean {
@@ -0,0 +1,139 @@
1
+ import type { AgentRuntime } from "../contract/runtime.ts";
2
+ import type { SurfacePort } from "../contract/surface.ts";
3
+ import type { TurnAttachments } from "../domain/attachment.ts";
4
+ import type { TurnResult } from "../domain/conversation.ts";
5
+ import { messages } from "../i18n/index.ts";
6
+ import type { Logger } from "../log.ts";
7
+ import type { EventSink } from "../plugin.ts";
8
+ import { splitReply } from "../presentation/reply-splitter.ts";
9
+ import { thinkingLine } from "../presentation/thinking-line.ts";
10
+ import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
11
+ import type { Speaker } from "../speakers.ts";
12
+ import { settleTurn } from "./settle-turn.ts";
13
+
14
+ /** One turn of a conversation, as a claim asks for it. */
15
+ export interface ConversationTurnInput {
16
+ /** The conversation's channel; its surface shows typing and the stop control, and posts the reply. */
17
+ channel: ChannelKey;
18
+ /** The conversation's kind: picks the persona of its session, fixed when the session is made. */
19
+ kind: string;
20
+ text: string;
21
+ /** Who the turn is for; its tier limits the tools. */
22
+ speaker: Speaker;
23
+ /** Files and images the message carries. */
24
+ attachments?: TurnAttachments;
25
+ /** The turn's tools; by default the plugins' `agentSelection`, read at the turn. */
26
+ selection?: TurnSelection;
27
+ /** The owner's own chat turn, which their next messages may steer. */
28
+ steerable?: boolean;
29
+ /** The turn may ask the owner on the surface's prompts, such as approvals of held actions. */
30
+ interactive?: boolean;
31
+ /** The owner confirmed the channel's held actions, so they may run in this turn. */
32
+ confirmed?: boolean;
33
+ /**
34
+ * Posts the turn's outcome in place of the default reply (the text, or a failure or stopped
35
+ * notice); the claim formats its own. A throw is logged, never rethrown.
36
+ */
37
+ reply?(result: TurnResult): Promise<void>;
38
+ }
39
+
40
+ /**
41
+ * Runs the turns of conversations a claim owns, through the runtime and the surfaces, so a claim
42
+ * writes no turn pipeline of its own.
43
+ */
44
+ export interface ConversationTurns {
45
+ /**
46
+ * Runs one turn in a conversation the caller's claim owns; call it inside the claim's queue
47
+ * task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
48
+ * `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
49
+ * posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
50
+ * It rejects during setup (NotLinkedError) and when the host has no runtime to run the turn on.
51
+ */
52
+ run(input: ConversationTurnInput): Promise<TurnResult>;
53
+ }
54
+
55
+ export interface ConversationTurnsOptions {
56
+ /** Throws NotLinkedError until every plugin is set up and linked. */
57
+ linked: () => void;
58
+ /** The runtime that runs the turn; throws PluginError when the host has none yet. */
59
+ runtime: () => AgentRuntime;
60
+ surfaces: SurfacePort;
61
+ events: EventSink;
62
+ /** The plugins' agent selection, read at each turn that gives none of its own. */
63
+ selection: () => ToolSelection;
64
+ logger: Logger;
65
+ }
66
+
67
+ /** The selection of a turn that gives none: the plugins' `agentSelection`. */
68
+ const DEFAULT_SELECTION = "turns";
69
+
70
+ export function conversationTurns(
71
+ options: ConversationTurnsOptions,
72
+ ): ConversationTurns {
73
+ const { surfaces, events, logger } = options;
74
+ return {
75
+ async run(input) {
76
+ const { channel, kind, speaker } = input;
77
+ options.linked();
78
+ // A host without a runtime is a setup mistake, so it is refused before anything is shown.
79
+ const runtime = options.runtime();
80
+ const stopTyping = surfaces.startTyping(channel);
81
+ const hideStop = surfaces.showStop(channel);
82
+ const turn = { kind, channel, speaker };
83
+ events.turnStarted(turn);
84
+ let result: TurnResult;
85
+ try {
86
+ result = await settleTurn(
87
+ () =>
88
+ runtime.runTurn({
89
+ channel,
90
+ kind,
91
+ selection: input.selection ?? {
92
+ id: DEFAULT_SELECTION,
93
+ ...options.selection(),
94
+ },
95
+ text: input.text,
96
+ speaker,
97
+ ...(input.attachments ? { attachments: input.attachments } : {}),
98
+ ...(input.confirmed ? { confirmed: true } : {}),
99
+ ...(input.steerable ? { steerable: true } : {}),
100
+ ...(input.interactive ? { interactive: true } : {}),
101
+ }),
102
+ "conversation turn",
103
+ );
104
+ } finally {
105
+ hideStop();
106
+ }
107
+ events.turnEnded({
108
+ ...turn,
109
+ result: result.ok ? "ok" : result.stopped ? "stopped" : "failed",
110
+ });
111
+ try {
112
+ if (!result.ok && !result.stopped)
113
+ logger.error({ channel, kind, err: result.error }, "turn failed");
114
+ if (input.reply) await input.reply(result);
115
+ else await surfaces.sendReply(channel, replyOf(result));
116
+ } catch (error) {
117
+ logger.error({ channel, kind, err: error }, "reply not posted");
118
+ } finally {
119
+ stopTyping();
120
+ }
121
+ return result;
122
+ },
123
+ };
124
+ }
125
+
126
+ /** The default post: the answer with its thinking line, or the failure or stopped notice. */
127
+ function replyOf(result: TurnResult) {
128
+ if (!result.ok)
129
+ return {
130
+ chunks: [
131
+ result.stopped ? messages().stoppedNotice : messages().failureNotice,
132
+ ],
133
+ };
134
+ const thinking = result.thinking ? thinkingLine(result.thinking) : undefined;
135
+ return {
136
+ ...(thinking ? { thinking } : {}),
137
+ chunks: splitReply(result.text),
138
+ };
139
+ }
@@ -1,6 +1,13 @@
1
- import type { InboundMessage } from "../domain/conversation.ts";
1
+ import { parseChannelKey } from "../contract/surface.ts";
2
+ import type { ChannelKey, InboundMessage } from "../domain/conversation.ts";
2
3
  import type { OwnerIdentity } from "../identity.ts";
3
4
 
5
+ /** How the model is told where a forward came from: a Discord channel as its mention, another surface's as its key. */
6
+ function sourceLabel(source: ChannelKey): string {
7
+ const { surface, id } = parseChannelKey(source);
8
+ return surface === "discord" ? `<#${id}>` : source;
9
+ }
10
+
4
11
  /** The message text, then the message it replies to and the message it forwards, when there are. */
5
12
  export function withReference(
6
13
  message: InboundMessage,
@@ -12,5 +19,5 @@ export function withReference(
12
19
  : message.text;
13
20
  const forwarded = message.forwarded;
14
21
  if (!forwarded) return replied;
15
- return `${replied}\n\n## Forwarded by ${owner.name} from ${forwarded.channelMention} (${forwarded.url})\n${forwarded.text.trim() || "(no text; see the attachments)"}`.trim();
22
+ return `${replied}\n\n## Forwarded by ${owner.name} from ${sourceLabel(forwarded.source)} (${forwarded.url})\n${forwarded.text.trim() || "(no text; see the attachments)"}`.trim();
16
23
  }
@@ -0,0 +1,39 @@
1
+ import type { ChatSurface, SurfacePort } from "../contract/surface.ts";
2
+ import { parseChannelKey } from "../contract/surface.ts";
3
+ import { PluginError } from "../errors.ts";
4
+ import type { ChannelKey } from "../sessions.ts";
5
+
6
+ /**
7
+ * The port over the contributed surfaces, each chosen by the prefix of a channel's key. `linked`
8
+ * gives the surfaces once every plugin is set up and throws NotLinkedError before.
9
+ */
10
+ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
11
+ const of = (channel: ChannelKey): ChatSurface | undefined => {
12
+ const at = channel.indexOf(":");
13
+ if (at <= 0) return undefined;
14
+ const prefix = channel.slice(0, at);
15
+ return linked().find((surface) => surface.surface === prefix);
16
+ };
17
+ const served = (channel: ChannelKey): ChatSurface => {
18
+ const surface = of(channel);
19
+ if (!surface)
20
+ throw new PluginError(
21
+ `no chat surface serves "${parseChannelKey(channel).surface}" channels, such as ${channel}. A plugin contributes one with \`surfaces\`.`,
22
+ );
23
+ return surface;
24
+ };
25
+ return {
26
+ of,
27
+ sendReply: async (channel, reply) =>
28
+ served(channel).sendReply(channel, reply),
29
+ startTyping: (channel) =>
30
+ of(channel)?.startTyping?.(channel) ?? (() => undefined),
31
+ showStop: (channel) =>
32
+ of(channel)?.showStop?.(channel) ?? (() => undefined),
33
+ react: async (channel, messageId, emoji) =>
34
+ of(channel)?.react?.(channel, messageId, emoji),
35
+ unreact: async (channel, messageId, emoji) =>
36
+ of(channel)?.unreact?.(channel, messageId, emoji),
37
+ prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
38
+ };
39
+ }
@@ -91,7 +91,7 @@ export class ConversationSessions {
91
91
  */
92
92
  session(
93
93
  key: ChannelKey,
94
- request?: Pick<TurnRequest, "channel" | "agent">,
94
+ request?: Pick<TurnRequest, "channel" | "agent" | "kind">,
95
95
  ): Promise<ChannelSession> {
96
96
  let pending = this.#sessions.get(key);
97
97
  if (!pending) {
@@ -107,6 +107,7 @@ export class ConversationSessions {
107
107
  this.slot(key),
108
108
  ownerAttachmentDir(this.#options.dataDir, channel),
109
109
  agent,
110
+ request?.kind ?? "owner",
110
111
  ))();
111
112
  pending.catch(() => this.#sessions.delete(key));
112
113
  this.#sessions.set(key, pending);
@@ -114,7 +115,7 @@ export class ConversationSessions {
114
115
  return pending;
115
116
  }
116
117
 
117
- /** The conversation's session, rebuilt with its history when its connectors or skills changed. */
118
+ /** The conversation's session, rebuilt with its history when its session tools or skills changed. */
118
119
  async freshSession(
119
120
  key: ChannelKey,
120
121
  request: TurnRequest,
@@ -137,7 +138,7 @@ export class ConversationSessions {
137
138
  request: TurnRequest,
138
139
  ): string | undefined {
139
140
  if (channelSession.revisions !== revisionsKey(this.#factory.plan))
140
- return "connectors changed";
141
+ return "session tools changed";
141
142
  if (
142
143
  channelSession.skills !== skillsKey(this.#factory.skillsOf(request.agent))
143
144
  )
@@ -0,0 +1,11 @@
1
+ import type { HoldRule } from "../../holds.ts";
2
+
3
+ /** A tool that waits for the owner's confirmation in the gate's tests. */
4
+ export const MAIL = "send-mail";
5
+
6
+ /** The one neutral hold rule the gate's tests use: sending mail waits for the owner. */
7
+ export const MAIL_RULE: HoldRule = {
8
+ name: "mail",
9
+ describe: (tool, input) =>
10
+ tool === MAIL ? `send an email to ${String(input.to)}` : undefined,
11
+ };
@@ -4,7 +4,6 @@ import type {
4
4
  PendingConfirmation,
5
5
  } from "../../domain/conversation.ts";
6
6
  import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
7
- import type { ProfileId } from "../../domain/profile.ts";
8
7
  import type { HoldCheck } from "../../holds.ts";
9
8
  import { messages } from "../../i18n/index.ts";
10
9
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
@@ -46,15 +45,15 @@ function byCodeUnit(a: string, b: string): number {
46
45
  }
47
46
 
48
47
  /**
49
- * Per-channel state of held actions. With the owner's prompts bound, a call that needs his
50
- * confirmation waits for his answer on a card and runs or is refused in the same turn. Otherwise,
48
+ * Per-channel state of held actions. With the owner's prompts bound, a call that needs their
49
+ * confirmation waits for their answer on a card and runs or is refused in the same turn. Otherwise,
51
50
  * or when the card expires, the call is held: it records its tool and exact input, and the
52
51
  * owner's next message either confirms them, which lets each identical call run once in that
53
52
  * turn, or drops them.
54
53
  */
55
54
  // pi-lens-ignore: large-class — one gate per conversation; its state is the held calls and their approvals
56
55
  export class ConfirmationGate {
57
- #profile: ProfileId = "general";
56
+ #selectionId: string | undefined;
58
57
  #pending: PendingConfirmation | undefined;
59
58
  #approved: HeldCall[] = [];
60
59
  readonly #holds: HoldCheck;
@@ -94,11 +93,11 @@ export class ConfirmationGate {
94
93
 
95
94
  /** `addressee` is who the turn is for; without one, the owner. */
96
95
  beginTurn(
97
- profile: ProfileId,
96
+ selectionId: string,
98
97
  confirmed: boolean,
99
98
  addressee?: OwnerIdentity,
100
99
  ): void {
101
- this.#profile = profile;
100
+ this.#selectionId = selectionId;
102
101
  this.#addressee = addressee ?? this.#owner;
103
102
  this.#approved = confirmed ? [...(this.pending()?.calls ?? [])] : [];
104
103
  this.#pending = undefined;
@@ -115,8 +114,8 @@ export class ConfirmationGate {
115
114
  }
116
115
 
117
116
  /**
118
- * Like `hold`, but asks the owner on a card first when `prompts` is given: his approval runs
119
- * the call, his refusal blocks it, and only an expired card holds it.
117
+ * Like `hold`, but asks the owner on a card first when `prompts` is given: their approval runs
118
+ * the call, their refusal blocks it, and only an expired card holds it.
120
119
  */
121
120
  async review(
122
121
  tool: string,
@@ -175,8 +174,11 @@ export class ConfirmationGate {
175
174
  }
176
175
 
177
176
  #record(call: HeldCall): string {
177
+ const selectionId = this.#selectionId;
178
+ if (selectionId === undefined)
179
+ throw new Error("a call was held outside a turn");
178
180
  const pending = this.#pending ?? {
179
- profile: this.#profile,
181
+ selectionId,
180
182
  heldAt: new Date(),
181
183
  calls: [],
182
184
  };
@@ -8,7 +8,7 @@ export interface VirtualServer {
8
8
  tools: string[];
9
9
  }
10
10
 
11
- /** The owner's MCP gateway virtual servers, reached with the process's admin token. */
11
+ /** MCP endpoints supplied by a session plugin, reached with its bearer token. */
12
12
  export function mcpExtension(
13
13
  servers: readonly VirtualServer[],
14
14
  token: string,
@@ -5,10 +5,9 @@ import type {
5
5
  HeldCall,
6
6
  PendingConfirmation,
7
7
  } from "../domain/conversation.ts";
8
- import type { ProfileId } from "../domain/profile.ts";
9
8
 
10
9
  interface Row {
11
- profile: ProfileId;
10
+ selection_id: string;
12
11
  held_at: Date;
13
12
  calls: string;
14
13
  }
@@ -23,12 +22,12 @@ export class PendingConfirmationStore {
23
22
 
24
23
  /** The store's tables; the host runs this before any store attaches. */
25
24
  static readonly migration: Migration = {
26
- name: "pending-confirmations",
25
+ name: "held-actions",
27
26
  up: async (sql) => {
28
27
  await sql`
29
- CREATE TABLE IF NOT EXISTS pending_confirmations (
28
+ CREATE TABLE IF NOT EXISTS held_actions (
30
29
  channel_key text PRIMARY KEY,
31
- profile text NOT NULL,
30
+ selection_id text NOT NULL,
32
31
  held_at timestamptz NOT NULL,
33
32
  calls text NOT NULL
34
33
  )`;
@@ -42,12 +41,12 @@ export class PendingConfirmationStore {
42
41
 
43
42
  async load(channel: ChannelKey): Promise<PendingConfirmation | undefined> {
44
43
  const rows: Row[] = await this.#sql`
45
- SELECT profile, held_at, calls FROM pending_confirmations
44
+ SELECT selection_id, held_at, calls FROM held_actions
46
45
  WHERE channel_key = ${channel}`;
47
46
  const row = rows[0];
48
47
  return row
49
48
  ? {
50
- profile: row.profile,
49
+ selectionId: row.selection_id,
51
50
  heldAt: row.held_at,
52
51
  // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
53
52
  calls: JSON.parse(row.calls) as HeldCall[],
@@ -61,15 +60,14 @@ export class PendingConfirmationStore {
61
60
  pending: PendingConfirmation | undefined,
62
61
  ): Promise<void> {
63
62
  if (!pending) {
64
- await this
65
- .#sql`DELETE FROM pending_confirmations WHERE channel_key = ${channel}`;
63
+ await this.#sql`DELETE FROM held_actions WHERE channel_key = ${channel}`;
66
64
  return;
67
65
  }
68
66
  await this.#sql`
69
- INSERT INTO pending_confirmations (channel_key, profile, held_at, calls)
70
- VALUES (${channel}, ${pending.profile}, ${pending.heldAt},
67
+ INSERT INTO held_actions (channel_key, selection_id, held_at, calls)
68
+ VALUES (${channel}, ${pending.selectionId}, ${pending.heldAt},
71
69
  ${JSON.stringify(pending.calls)})
72
- ON CONFLICT (channel_key) DO UPDATE SET profile = EXCLUDED.profile,
70
+ ON CONFLICT (channel_key) DO UPDATE SET selection_id = EXCLUDED.selection_id,
73
71
  held_at = EXCLUDED.held_at, calls = EXCLUDED.calls`;
74
72
  }
75
73
  }
@@ -7,6 +7,7 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
9
9
  import { withAttachmentsBlock } from "../attachments/prompt-block.ts";
10
+ import type { AgentRuntime } from "../contract/runtime.ts";
10
11
  import { NO_ATTACHMENTS, type TurnAttachments } from "../domain/attachment.ts";
11
12
  import type {
12
13
  ChannelKey,
@@ -15,7 +16,7 @@ import type {
15
16
  TurnResult,
16
17
  } from "../domain/conversation.ts";
17
18
  import { AgentRunError, ConfigError } from "../domain/errors.ts";
18
- import type { AgentRuntime, TurnRequest } from "../domain/ports.ts";
19
+ import type { TurnRequest } from "../domain/ports.ts";
19
20
  import { assistantName } from "../i18n/index.ts";
20
21
  import {
21
22
  AUTO_THINKING,
@@ -99,13 +100,14 @@ export class PiAgentRuntime implements AgentRuntime {
99
100
  new PromptSlot(),
100
101
  join(this.#factory.workDir(), "probe-attachments"),
101
102
  undefined,
103
+ "owner",
102
104
  );
103
105
  const registered = new Set(
104
106
  probe.session.getAllTools().map((tool) => tool.name),
105
107
  );
106
108
  probe.session.dispose();
107
109
  const expected = [
108
- ...this.#options.requiredTools,
110
+ ...this.#factory.link().requiredTools,
109
111
  COMPACT_TOOL,
110
112
  ASK_USER_TOOL,
111
113
  ...planOrder(this.#factory.plan).flatMap(
@@ -115,7 +117,7 @@ export class PiAgentRuntime implements AgentRuntime {
115
117
  const missing = expected.filter((name) => !registered.has(name));
116
118
  if (missing.length > 0) {
117
119
  throw new ConfigError(
118
- `profile tools are not registered: ${missing.join(", ")}`,
120
+ `required tools are not registered: ${missing.join(", ")}`,
119
121
  );
120
122
  }
121
123
  }
@@ -152,10 +154,10 @@ export class PiAgentRuntime implements AgentRuntime {
152
154
  logger.warn(
153
155
  {
154
156
  channel: request.channel,
155
- profile: request.selection.id,
157
+ selection: request.selection.id,
156
158
  missing,
157
159
  },
158
- "profile tools missing; running without them",
160
+ "selected tools missing; running without them",
159
161
  );
160
162
  }
161
163
  channelSession.tools = wanted.filter((name) => registered.has(name));
@@ -256,7 +258,7 @@ export class PiAgentRuntime implements AgentRuntime {
256
258
  logger.info(
257
259
  {
258
260
  channel: key,
259
- profile: request.selection.id,
261
+ selection: request.selection.id,
260
262
  toolCalls,
261
263
  thinking: session.thinkingLevel,
262
264
  // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
@@ -326,6 +328,7 @@ export class PiAgentRuntime implements AgentRuntime {
326
328
  new PromptSlot(),
327
329
  ownerAttachmentDir(dataDir, channel),
328
330
  undefined,
331
+ "owner",
329
332
  );
330
333
  const { session } = worker;
331
334
  const toolCalls: string[] = [];
@@ -2,10 +2,10 @@ import type { OwnerPrompts, OwnerQuestion } from "../domain/owner-prompts.ts";
2
2
  import { assistantName } from "../i18n/index.ts";
3
3
 
4
4
  /**
5
- * A run's way to ask the owner while it works. A turn he started or a report turn binds the
5
+ * A run's way to ask the owner while it works. A turn they started or a report turn binds the
6
6
  * cards of its channel, and a coding worker those of its dispatch thread; a schedule's turn, or
7
- * a session with no Discord channel, leaves the slot empty and held actions wait for his next
8
- * message instead. The slot also counts how long the run waited on him, which its time limit
7
+ * a session with no Discord channel, leaves the slot empty and held actions wait for their next
8
+ * message instead. The slot also counts how long the run waited on them, which its time limit
9
9
  * leaves out.
10
10
  */
11
11
  export class PromptSlot {
@@ -58,6 +58,11 @@ export class PromptSlot {
58
58
  }
59
59
  }
60
60
 
61
+ /** A fresh slot, for a run that asks the owner while it works. */
62
+ export function promptSlot(): PromptSlot {
63
+ return new PromptSlot();
64
+ }
65
+
61
66
  /**
62
67
  * Calls `expire` once the run has worked `ms`, not counting time the slot spent waiting on the
63
68
  * owner's cards; returns a function that cancels it.
@@ -4,16 +4,12 @@ import type {
4
4
  ExtensionFactory,
5
5
  ModelRuntime,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
7
8
  import type { ChannelKey } from "../domain/conversation.ts";
8
9
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
9
- import type { AgentTurnScope } from "../domain/ports.ts";
10
10
  import type { OwnerIdentity } from "../identity.ts";
11
11
  import type { Logger } from "../log.ts";
12
- import type {
13
- ThinkingLevel,
14
- ThinkingPicker,
15
- ThinkingSetting,
16
- } from "../models.ts";
12
+ import type { ThinkingLevel, ThinkingPicker } from "../models.ts";
17
13
  import type { LinkedSessions } from "../plugin.ts";
18
14
  import {
19
15
  planOrder,
@@ -39,7 +35,7 @@ export interface PiAgentRuntimeOptions {
39
35
  /** Who the conversations serve, as prompts and tool results name them. */
40
36
  owner: OwnerIdentity;
41
37
  agentDir: string;
42
- /** Shared with the party broker, so logins refresh in one place. */
38
+ /** Shared by the host's sessions, so logins refresh in one place. */
43
39
  modelRuntime: ModelRuntime;
44
40
  dataDir: string;
45
41
  model: { provider: string; id: string };
@@ -47,7 +43,6 @@ export interface PiAgentRuntimeOptions {
47
43
  thinking: ThinkingLevel;
48
44
  /** Picks the level of the assistant's turns and of agents whose thinking is `auto`. */
49
45
  effort: ThinkingPicker;
50
- persona: string;
51
46
  /** Held actions survive a restart here, so a confirmation after one still runs. */
52
47
  confirmations: Pick<PendingConfirmationStore, "load" | "save">;
53
48
  logger: Logger;
@@ -57,13 +52,11 @@ export interface PiAgentRuntimeOptions {
57
52
  * A changed snapshot revision rebuilds each session on its next turn, keeping its history.
58
53
  */
59
54
  sessions: () => LinkedSessions;
60
- /** Tools startup refuses to run without, besides those the session tools require. */
61
- requiredTools: readonly string[];
62
55
  /** The agent server's sessions: their shared workspace, model, and skills. */
63
56
  agents?: AgentSessions;
64
57
  /**
65
58
  * Cards the owner answers in a channel, for interactive turns; undefined for a channel that
66
- * cannot show them. Without it every held action waits for his next message.
59
+ * cannot show them. Without it every held action waits for their next message.
67
60
  */
68
61
  prompts?: (
69
62
  channel: ChannelKey,
@@ -77,26 +70,7 @@ export interface PiAgentRuntimeOptions {
77
70
  mcpConnectTimeoutMs?: number;
78
71
  }
79
72
 
80
- export interface AgentSessions {
81
- /** The shell's working directory, shared by every agent; writes outside it are held. */
82
- workDir: string;
83
- /**
84
- * The skills the agent carries, read at the start of every run; a change rebuilds its
85
- * sessions, keeping their history (repos-and-skills spec behavior 21).
86
- */
87
- skills(name: string): readonly LoadedSkill[];
88
- /** The agent's model (`<provider>/<id>`) and thinking setting, read at the start of every run. */
89
- modelOf(name: string): { model: string; thinking: ThinkingSetting };
90
- /** The channel the scope's turns run in: the group's for a seat in one, else the agent's own. */
91
- turnChannel(scope: AgentTurnScope): ChannelKey;
92
- }
93
-
94
- /** A skill file a session loads; only its name and description enter the prompt. */
95
- export interface LoadedSkill {
96
- name: string;
97
- description: string;
98
- file: string;
99
- }
73
+ export type { AgentSessions, LoadedSkill };
100
74
 
101
75
  export const skillsKey = (skills: readonly LoadedSkill[]) =>
102
76
  JSON.stringify(skills);
@@ -109,12 +83,12 @@ export interface CoreExtensions {
109
83
  confirmationGate: ExtensionFactory;
110
84
  askUser: ExtensionFactory;
111
85
  selfCompactGuard: ExtensionFactory;
112
- profileTools: ExtensionFactory;
86
+ activeTools: ExtensionFactory;
113
87
  }
114
88
 
115
89
  /**
116
90
  * A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
117
- * and compact guard, the compactor, the MCP phase, and profile-tools last, so its
91
+ * and compact guard, the compactor, the MCP phase, and active-tools last, so its
118
92
  * before_agent_start handler runs after every other extension's.
119
93
  */
120
94
  export function sessionExtensions(
@@ -135,13 +109,13 @@ export function sessionExtensions(
135
109
  { name: "self-compact-guard", factory: core.selfCompactGuard },
136
110
  ...contributed(plan.compaction ? [plan.compaction] : []),
137
111
  ...contributed(plan.mcp),
138
- { name: "profile-tools", factory: core.profileTools },
112
+ { name: "active-tools", factory: core.activeTools },
139
113
  ];
140
114
  }
141
115
 
142
116
  export interface ChannelSession {
143
117
  session: AgentSession;
144
- /** Read by the profile-tools extension at the start of every run. */
118
+ /** Read by the active-tools extension at the start of every run. */
145
119
  tools: readonly string[];
146
120
  /** The session tools' revisions the session was built with, as revisionsKey. */
147
121
  revisions: string;