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,3 +1,4 @@
1
+ import type { Locale } from "../i18n/index.ts";
1
2
  import type { ChannelKey } from "../sessions.ts";
2
3
  import type { Tier } from "../speakers.ts";
3
4
 
@@ -19,15 +20,20 @@ export interface InboundMessage {
19
20
  /** How the author appears in the channel, for example a server nickname. */
20
21
  authorName: string;
21
22
  authorIsBot: boolean;
22
- /** The roles the author holds in the message's server; absent in direct messages. */
23
+ /** The roles the author holds in the message's space; absent in direct messages. */
23
24
  authorRoleIds?: readonly string[];
24
- /** The webhook that posted the message; its name is `authorName`. */
25
- webhookId?: string;
26
- /** The webhook is one the assistant posts through itself, such as an agent's voice. */
27
- ownWebhook?: boolean;
25
+ /**
26
+ * The server or workspace the channel belongs to, in the surface's own ids; absent in a
27
+ * direct conversation.
28
+ */
29
+ space?: string;
28
30
  isDirect: boolean;
29
- /** The server the message was posted in; absent in direct messages. */
30
- guildId?: string;
31
+ /**
32
+ * Set when an integration posted the message, such as a Discord webhook; its name is
33
+ * `authorName`. `own` says the integration is one the assistant posts through itself, such as an
34
+ * agent's voice.
35
+ */
36
+ integration?: { id: string; own: boolean };
31
37
  mentionsBot: boolean;
32
38
  /** The message replies to one of the bot's messages. */
33
39
  repliesToBot: boolean;
@@ -44,8 +50,9 @@ export interface InboundMessage {
44
50
  /** The message the author forwarded with this one; its attachments are in `attachments`. */
45
51
  forwarded?: {
46
52
  text: string;
47
- /** The source channel, as `<#id>`, and a link to the original message. */
48
- channelMention: string;
53
+ /** The channel the message was forwarded from. */
54
+ source: ChannelKey;
55
+ /** A link to the original message. */
49
56
  url: string;
50
57
  };
51
58
  }
@@ -60,11 +67,30 @@ export type ScheduledOutcome =
60
67
  | { status: "failed"; error: string }
61
68
  | { status: "skipped"; reason: string };
62
69
 
70
+ /**
71
+ * Whose turn a schedule or a delegated task asks for, contributed by the plugin whose claim
72
+ * answers it. The target also sets the limits of what may be scheduled or delegated for it, so a
73
+ * channel open to many people can be held tighter than the owner's own.
74
+ */
75
+ export interface BackgroundTarget {
76
+ /** Stored on schedules and delegated jobs; unique across plugins, as a persona's kind is. */
77
+ name: string;
78
+ /** How lists such as `/<root> schedule` name it, in the host's locale. */
79
+ label(locale: Locale): string;
80
+ /** Limits of the schedules made for it; absent, nothing may be scheduled for it. */
81
+ schedules?: { perChannel: number; promptChars: number; aheadDays: number };
82
+ /** Limits of the delegated tasks reporting to it; absent, nothing may be delegated to it. */
83
+ delegation?: { maxRunning: number };
84
+ }
85
+
63
86
  /** A turn nobody wrote in the channel: a schedule's, or a report of work done elsewhere. */
64
87
  export interface BackgroundTurn {
65
88
  channel: ChannelKey;
66
- /** Whose turn a schedule asked for; a claim skips a mode it does not serve. */
67
- mode: "owner" | "party";
89
+ /**
90
+ * The name of the `BackgroundTarget` the turn is for. The router skips a turn whose target no
91
+ * plugin contributes, and a claim skips one it does not serve.
92
+ */
93
+ target: string;
68
94
  author: { id: string; name: string };
69
95
  /**
70
96
  * The tier the turn runs at, which is its creator's when a person set it up; absent for
@@ -73,10 +99,17 @@ export interface BackgroundTurn {
73
99
  tier?: Tier;
74
100
  turnId: string;
75
101
  text: string;
76
- /** It delivers a report the owner is waiting for, so it may ask him on cards. */
102
+ /** It delivers a report the owner is waiting for, so it may ask them on cards. */
77
103
  report?: boolean;
78
104
  }
79
105
 
106
+ /**
107
+ * Whose conversation a channel holds, as the claim that owns it names it when `startFresh` says
108
+ * whose it was: any string, such as "owner" or "study". The host reads none of them, so a plugin
109
+ * narrows the kinds of its own claims itself.
110
+ */
111
+ export type ConversationKind = string;
112
+
80
113
  /** What a claim does with a message in a channel it owns. */
81
114
  export type Admission =
82
115
  | {
@@ -111,14 +144,19 @@ export type Admission =
111
144
  export interface ChannelClaim {
112
145
  name: string;
113
146
  priority: number;
114
- /** Whether the claim owns the channel; `guildId` is the message's server, when routing one. */
115
- owns(channel: ChannelKey, guildId?: string): boolean;
147
+ /** Whether the claim owns the channel; `space` is the message's space, when routing one. */
148
+ owns(channel: ChannelKey, space?: string): boolean;
116
149
  /** What to do with a message in an owned channel; undefined drops it. */
117
150
  admit(message: InboundMessage): Admission | undefined;
118
151
  /** A background turn in an owned channel; a claim without one skips them. */
119
152
  background?(turn: BackgroundTurn): Promise<ScheduledOutcome>;
120
153
  /** Starts the channel's conversation over, saying whose it was. */
121
154
  startFresh(channel: ChannelKey): Promise<string>;
155
+ /**
156
+ * Stops the channel's running turn; true when one was running. The router asks only the claim
157
+ * that owns the channel, and a claim without `stop` has nothing to stop there.
158
+ */
159
+ stop?(channel: ChannelKey): boolean;
122
160
  /** Removes the channel's conversation for good; a claim without one refuses. */
123
161
  deleteConversation?(channel: ChannelKey): Promise<void>;
124
162
  /** Reports of work started in an owned channel stay in it instead of opening a thread. */
@@ -130,6 +168,8 @@ export interface ConversationPort {
130
168
  /** Never rejects; resolves when the message's turn is done or dropped. */
131
169
  handle(message: InboundMessage): Promise<void>;
132
170
  background(turn: BackgroundTurn): Promise<ScheduledOutcome>;
171
+ /** A contributed background target, read when used; undefined when no plugin contributes it. */
172
+ target(name: string): BackgroundTarget | undefined;
133
173
  /** Waits for the channel's running turn, then starts its conversation over; says whose it was. */
134
174
  startFresh(channel: ChannelKey): Promise<string>;
135
175
  /** `busy` while a turn runs or waits in the channel. */
@@ -1,3 +1,5 @@
1
+ import type { RuntimeFactory } from "./runtime.ts";
2
+
1
3
  /** A choice among named options; the answer is one option's name. */
2
4
  export interface ChoiceQuestion {
3
5
  type: "choice";
@@ -64,4 +66,19 @@ export type ImageDrawer = (
64
66
  export interface Providers {
65
67
  judge: Judge;
66
68
  images: ImageDrawer;
69
+ /**
70
+ * Builds the runtime that runs the agent server's conversations and every turn run through
71
+ * `context.turns`, in place of the Pi runtime. The default refuses: the agent server builds the
72
+ * Pi runtime itself when no plugin fills this slot, so check `filled` before calling it.
73
+ */
74
+ runtime: RuntimeFactory;
75
+ }
76
+
77
+ /**
78
+ * Each slot as the host resolved it, and which slots a plugin fills. A slot no plugin fills holds
79
+ * the core's default, and `filled` is how a caller tells that default from a provider without
80
+ * calling it.
81
+ */
82
+ export interface ResolvedProviders extends Providers {
83
+ readonly filled: ReadonlySet<keyof Providers>;
67
84
  }
@@ -0,0 +1,138 @@
1
+ import type { TurnAttachments } from "../domain/attachment.ts";
2
+ import type {
3
+ ChannelKey,
4
+ PendingConfirmation,
5
+ TranscriptEntry,
6
+ TurnResult,
7
+ } from "../domain/conversation.ts";
8
+ import type { OwnerPrompts } from "../domain/owner-prompts.ts";
9
+ import type { TurnRequest } from "../domain/ports.ts";
10
+ import type { Logger } from "../log.ts";
11
+ import type { ThinkingSetting } from "../models.ts";
12
+ import type { HostEnv, LinkedSessions } from "../plugin.ts";
13
+ import type { AgentTurnScope } from "../sessions.ts";
14
+ import type { Speaker } from "../speakers.ts";
15
+ import type { ToolTiers } from "../tool-tiers.ts";
16
+ import type { Judge } from "./providers.ts";
17
+
18
+ /** A conversation's context use after its latest turn. */
19
+ export interface ContextUse {
20
+ /** Null right after a compaction, until the next model response. */
21
+ tokens: number | null;
22
+ contextWindow: number;
23
+ }
24
+
25
+ /**
26
+ * Runs the conversations of the agent server, and of every claim that runs turns through
27
+ * `context.turns`: one persistent conversation per channel key (an agent's conversation has the
28
+ * key of its `AgentTurnScope.session`), its turns, its held actions, and its history. A plugin
29
+ * replaces the whole runtime by filling the `runtime` provider slot; the default runs Pi.
30
+ */
31
+ export interface AgentRuntime {
32
+ /**
33
+ * Runs one turn and returns how it ended. A turn that cannot run is `{ ok: false }`; a throw is
34
+ * settled into a failed result by the caller.
35
+ */
36
+ runTurn(request: TurnRequest): Promise<TurnResult>;
37
+ /**
38
+ * Adds the message to the conversation's running turn when that turn is steerable and holds no
39
+ * actions; false when the message must wait for its own turn.
40
+ */
41
+ steer(
42
+ conversation: ChannelKey,
43
+ text: string,
44
+ attachments: TurnAttachments,
45
+ /** The message's author; a turn another speaker started takes no steering from them. */
46
+ speakerId?: string,
47
+ ): Promise<boolean>;
48
+ /** Aborts the conversation's running turn and drops what was steered into it; false when none runs. */
49
+ stop(conversation: ChannelKey): boolean;
50
+ /** Archives a conversation and drops its held actions; called between turns. */
51
+ startFresh(conversation: ChannelKey): Promise<void>;
52
+ /** Like `startFresh`, but removes the conversation and every archive of it for good; called between turns. */
53
+ deleteConversation(conversation: ChannelKey): Promise<void>;
54
+ /** The conversation's held actions known in memory since startup. */
55
+ pendingConfirmation(
56
+ conversation: ChannelKey,
57
+ ): PendingConfirmation | undefined;
58
+ /** The conversation's held actions, restored from the store after a restart. */
59
+ heldActions(
60
+ conversation: ChannelKey,
61
+ ): Promise<PendingConfirmation | undefined>;
62
+ recentTranscript(
63
+ conversation: ChannelKey,
64
+ limit: number,
65
+ ): Promise<TranscriptEntry[]>;
66
+ /** Undefined when unknown; the team status shows no context bar for the conversation then. */
67
+ contextUsage?(conversation: ChannelKey): ContextUse | undefined;
68
+ /** Runs in the host's preflight, before anything starts; a throw stops the boot. */
69
+ preflight?(): Promise<void>;
70
+ /** Runs when the host stops the agent server's runtime service. */
71
+ dispose?(): Promise<void> | void;
72
+ }
73
+
74
+ /** A skill file a session loads; only its name and description enter the prompt. */
75
+ export interface LoadedSkill {
76
+ name: string;
77
+ description: string;
78
+ file: string;
79
+ }
80
+
81
+ /** The agent server's per-agent settings, which a runtime reads when it runs an agent's turn. */
82
+ export interface AgentSessions {
83
+ /** The shell's working directory, shared by every agent; writes outside it are held. */
84
+ workDir: string;
85
+ /**
86
+ * The skills the agent carries, read at the start of every run; a change rebuilds its
87
+ * sessions, keeping their history.
88
+ */
89
+ skills(name: string): readonly LoadedSkill[];
90
+ /** The agent's model (`<provider>/<id>`) and thinking setting, read at the start of every run. */
91
+ modelOf(name: string): { model: string; thinking: ThinkingSetting };
92
+ /** The channel the scope's turns run in: the group's for a seat in one, else the agent's own. */
93
+ turnChannel(scope: AgentTurnScope): ChannelKey;
94
+ }
95
+
96
+ /** Where a runtime keeps each conversation's held actions, so they survive a restart. */
97
+ export interface HeldActionStore {
98
+ /** The held actions stored for the conversation, if any. */
99
+ load(conversation: ChannelKey): Promise<PendingConfirmation | undefined>;
100
+ /** Stores the conversation's held actions; `undefined` clears them. */
101
+ save(
102
+ conversation: ChannelKey,
103
+ held: PendingConfirmation | undefined,
104
+ ): Promise<void>;
105
+ }
106
+
107
+ /** What the agent server hands a runtime provider when it builds the runtime. */
108
+ export interface RuntimeDeps {
109
+ logger: Logger;
110
+ /** The host's locale and time zone. */
111
+ env: HostEnv;
112
+ /** Who the conversations serve. */
113
+ owner: { id: string; name: string };
114
+ /**
115
+ * The linked session parts: hold rules, packages, session tools, and personas. Read once the
116
+ * host has linked them, from the preflight on; calling it during the factory throws.
117
+ */
118
+ sessions(): LinkedSessions;
119
+ /** What each tool needs; plugin tools are added when the host links, so ask at use time. */
120
+ toolTiers: ToolTiers;
121
+ /**
122
+ * The owner's approval and question prompts in a conversation, from its chat surface; undefined
123
+ * when the surface has none, and the action then waits for the owner's next message.
124
+ */
125
+ prompts(
126
+ conversation: ChannelKey,
127
+ speaker?: Speaker,
128
+ ): OwnerPrompts | undefined;
129
+ /** The agent server's per-agent settings, for agent turns. */
130
+ agents: AgentSessions;
131
+ /** Where held actions persist across restarts. */
132
+ confirmations: HeldActionStore;
133
+ /** The host's judge, resolved from the `judge` slot. */
134
+ judge: Judge;
135
+ }
136
+
137
+ /** Builds the runtime, once, when the agent server sets up. */
138
+ export type RuntimeFactory = (deps: RuntimeDeps) => AgentRuntime;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A typed name for a service one plugin provides and other plugins read. Two keys with one `id`
3
+ * are the same service, so a key survives being imported through two copies of a package.
4
+ */
5
+ export interface ServiceKey<T> {
6
+ /** Unique across plugins, such as `"roundtable.schedules"` or `"my-notes.index"`. */
7
+ readonly id: string;
8
+ /** Type only; never set. Ties the key to the service it names, so keys of different services stay apart. */
9
+ readonly __service?: () => T;
10
+ /**
11
+ * What to tell a plugin that reads the service while no registered plugin provides it, such as
12
+ * how to switch the addon that provides it on. Without one the host gives its generic advice.
13
+ */
14
+ readonly absent?: string;
15
+ }
16
+
17
+ /** Makes a key. The id is what the host matches on; give it a prefix of your own, such as your plugin's name. */
18
+ export function serviceKey<T>(
19
+ id: string,
20
+ options: { absent?: string } = {},
21
+ ): ServiceKey<T> {
22
+ if (typeof id !== "string" || id.trim() === "")
23
+ throw new TypeError(
24
+ `a service key needs an id, a non-empty string such as "my-notes.index"; got ${JSON.stringify(id)}.`,
25
+ );
26
+ return Object.freeze(
27
+ options.absent === undefined ? { id } : { id, absent: options.absent },
28
+ );
29
+ }
30
+
31
+ /** What a plugin provides and reads through `PluginContext.services`. */
32
+ export interface Services {
33
+ /** The service, or throws a PluginError naming the key and the plugin to register first when it is not provided yet. */
34
+ get<T>(key: ServiceKey<T>): T;
35
+ /**
36
+ * The service, or undefined when no registered plugin declares it, such as an addon switched off.
37
+ * Throws like `get` when a plugin declares it but has not been set up yet: that is order, not absence.
38
+ */
39
+ find<T>(key: ServiceKey<T>): T | undefined;
40
+ /**
41
+ * Provides a service the plugin declares in `provides`; only while the plugin's setup runs, and
42
+ * once per key.
43
+ */
44
+ provide<T>(key: ServiceKey<T>, value: T): void;
45
+ }
@@ -0,0 +1,88 @@
1
+ import type { OutboundReply } from "../domain/conversation.ts";
2
+ import type { OwnerPrompts } from "../domain/owner-prompts.ts";
3
+ import { PluginError } from "../errors.ts";
4
+ import type { ChannelKey } from "../sessions.ts";
5
+ import type { Speaker } from "../speakers.ts";
6
+ import type { InboundMessage } from "./channels.ts";
7
+
8
+ /**
9
+ * Splits a channel key, `<surface>:<id>`, at its first colon: the surface names the chat network
10
+ * that owns the channel, such as `discord`, and the id is that network's own, and may contain colons.
11
+ * Throws PluginError for a key without a surface.
12
+ */
13
+ export function parseChannelKey(key: ChannelKey): {
14
+ surface: string;
15
+ id: string;
16
+ } {
17
+ const at = typeof key === "string" ? key.indexOf(":") : -1;
18
+ if (at <= 0)
19
+ throw new PluginError(
20
+ `"${String(key)}" is not a channel key. A key is <surface>:<id>, such as discord:123.`,
21
+ );
22
+ return { surface: key.slice(0, at), id: key.slice(at + 1) };
23
+ }
24
+
25
+ /** The key of a channel on a surface; the inverse of `parseChannelKey`. */
26
+ export function channelKey(surface: string, id: string): ChannelKey {
27
+ if (surface === "" || surface.includes(":"))
28
+ throw new PluginError(
29
+ `"${surface}" is not a surface name. A surface is a non-empty word without a colon, such as discord.`,
30
+ );
31
+ return `${surface}:${id}`;
32
+ }
33
+
34
+ /**
35
+ * One chat network the host talks through. The host ships Discord's; a plugin may contribute
36
+ * another with `surfaces`. A surface serves the channels whose key starts with its `surface`
37
+ * prefix, and only those reach its methods.
38
+ */
39
+ export interface ChatSurface {
40
+ /** The key prefix of this surface's channels, such as "discord"; unique per host. */
41
+ readonly surface: string;
42
+ /**
43
+ * Connects and delivers every incoming message; the host passes its conversation router. A
44
+ * message whose channel key has another prefix is logged and dropped.
45
+ */
46
+ start(deliver: (message: InboundMessage) => void): Promise<void>;
47
+ /** Runs when the host stops, after the services started later have stopped. */
48
+ stop?(): Promise<void>;
49
+ sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void>;
50
+ /** Shows a typing indicator until the returned function is called; absent = none shown. */
51
+ startTyping?(channel: ChannelKey): () => void;
52
+ /**
53
+ * Shows the owner a stop control until the returned function is called; it calls
54
+ * `conversations.stop(channel)` when used. Absent = none shown.
55
+ */
56
+ showStop?(channel: ChannelKey): () => void;
57
+ /** Adds or removes the bot's reaction on a message; failures are logged, never thrown. */
58
+ react?(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
59
+ unreact?(
60
+ channel: ChannelKey,
61
+ messageId: string,
62
+ emoji: string,
63
+ ): Promise<void>;
64
+ /**
65
+ * How the owner approves held actions or answers ask_user in a running turn; undefined, or
66
+ * absent, = the action is held until the owner's next message.
67
+ */
68
+ prompts?(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
69
+ }
70
+
71
+ /**
72
+ * Every contributed surface, chosen by the prefix of a channel's key. Calls during setup throw
73
+ * NotLinkedError, because the surfaces are collected once every plugin is set up.
74
+ */
75
+ export interface SurfacePort {
76
+ /** The surface that serves the channel's prefix; undefined when none does. */
77
+ of(channel: ChannelKey): ChatSurface | undefined;
78
+ /** Throws PluginError naming the prefix when no surface serves it. */
79
+ sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void>;
80
+ /** A no-op when no surface serves the channel or its surface shows none. */
81
+ startTyping(channel: ChannelKey): () => void;
82
+ /** A no-op when no surface serves the channel or its surface shows none. */
83
+ showStop(channel: ChannelKey): () => void;
84
+ react(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
85
+ unreact(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
86
+ /** The owner's prompts in the channel; undefined when its surface has none. */
87
+ prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
88
+ }
@@ -1,12 +1,29 @@
1
- import { SQL } from "bun";
1
+ import { SQL, type TransactionSQL } from "bun";
2
2
  import { MigrationError, PluginError } from "../errors.ts";
3
+ import type { RoundtablePlugin } from "../plugin.ts";
3
4
 
4
- /** Idempotent DDL a plugin's tables need; every boot runs it again over the existing schema. */
5
+ /** A plugin's tables, as DDL its `up` runs. */
5
6
  export interface Migration {
6
7
  name: string;
8
+ /**
9
+ * "once" (the default): the ledger records it, so it runs one time over a database and never
10
+ * again. "every-boot": runs at every boot and is never recorded; it must be idempotent, which
11
+ * suits a migration that converges data an older build may have written since.
12
+ */
13
+ runs?: "once" | "every-boot";
7
14
  up(sql: SQL): Promise<void>;
8
15
  }
9
16
 
17
+ /** What one boot's migrations did, each entry a ledger id (`<plugin>/<migration>`). */
18
+ export interface MigrationReport {
19
+ /** The "once" migrations that ran now and were recorded. */
20
+ applied: string[];
21
+ /** The "once" migrations the ledger already held. */
22
+ skipped: string[];
23
+ /** The "every-boot" migrations that ran. */
24
+ everyBoot: string[];
25
+ }
26
+
10
27
  /**
11
28
  * The host's one connection pool. PostgreSQL's 100 connections are shared with the other services on the same server,
12
29
  * so the pool stays small and closes idle connections; Bun's defaults (10, never closed)
@@ -16,7 +33,23 @@ export function openPool(databaseUrl: string): SQL {
16
33
  return new SQL(databaseUrl, { max: 10, idleTimeout: 60 });
17
34
  }
18
35
 
19
- /** Runs the migrations in order; a repeated name is a plugin bug, a failing one stops the boot. */
36
+ /**
37
+ * The transaction as a migration sees the pool: a migration that opens its own transaction
38
+ * with `begin` gets a savepoint of the running one, which PostgreSQL nests.
39
+ */
40
+ export function nested(transaction: TransactionSQL): SQL {
41
+ return new Proxy(transaction, {
42
+ get(target, property) {
43
+ if (property === "begin")
44
+ return (...args: Parameters<TransactionSQL["savepoint"]>) =>
45
+ target.savepoint(...args);
46
+ const value: unknown = Reflect.get(target, property);
47
+ return typeof value === "function" ? value.bind(target) : value;
48
+ },
49
+ });
50
+ }
51
+
52
+ /** Runs each migration in order, none of them recorded; a repeated name is a plugin bug, a failing one stops. Test setup uses it for tables a test drops and rebuilds. */
20
53
  export async function migrate(
21
54
  sql: SQL,
22
55
  migrations: readonly Migration[],
@@ -35,3 +68,85 @@ export async function migrate(
35
68
  }
36
69
  }
37
70
  }
71
+
72
+ const LEDGER = "roundtable_migrations";
73
+
74
+ /** The lock key for one name; two hosts booting together queue on it instead of racing. */
75
+ const lockOf = (name: string) => `pi-roundtable:migration:${name}`;
76
+
77
+ /**
78
+ * Runs every plugin's migrations in plugin order, then declaration order, the way a boot does. A
79
+ * "once" migration runs inside its own transaction under an advisory lock, and the ledger row is
80
+ * written in that transaction, so a migration that failed leaves no record and two hosts booting at
81
+ * once apply it one time in total. An "every-boot" migration runs each time and is not recorded.
82
+ */
83
+ export async function runMigrations(
84
+ sql: SQL,
85
+ plugins: readonly Pick<RoundtablePlugin, "name" | "migrations">[],
86
+ ): Promise<MigrationReport> {
87
+ const steps = plugins.flatMap((plugin) =>
88
+ (plugin.migrations ?? []).map((migration) => ({
89
+ id: `${plugin.name}/${migration.name}`,
90
+ plugin: plugin.name,
91
+ migration,
92
+ })),
93
+ );
94
+ const ids = new Set<string>();
95
+ for (const { id } of steps) {
96
+ if (ids.has(id)) throw new PluginError(`migration ${id} is declared twice`);
97
+ ids.add(id);
98
+ }
99
+ const report: MigrationReport = { applied: [], skipped: [], everyBoot: [] };
100
+ if (steps.length === 0) return report;
101
+
102
+ await sql.begin(async (tx) => {
103
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${lockOf("ledger")}, 0))`;
104
+ await tx.unsafe(`
105
+ CREATE TABLE IF NOT EXISTS ${LEDGER} (
106
+ id text PRIMARY KEY,
107
+ plugin text NOT NULL,
108
+ name text NOT NULL,
109
+ applied_at timestamptz NOT NULL DEFAULT now()
110
+ )`);
111
+ });
112
+
113
+ for (const { id, plugin, migration } of steps) {
114
+ try {
115
+ if (migration.runs === "every-boot") {
116
+ await migration.up(sql);
117
+ report.everyBoot.push(id);
118
+ continue;
119
+ }
120
+ const ran = await sql.begin(async (tx) => {
121
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${lockOf(id)}, 0))`;
122
+ const [recorded] =
123
+ await tx`SELECT 1 FROM roundtable_migrations WHERE id = ${id}`;
124
+ if (recorded) return false;
125
+ await migration.up(nested(tx));
126
+ await tx`INSERT INTO roundtable_migrations (id, plugin, name) VALUES (${id}, ${plugin}, ${migration.name})`;
127
+ return true;
128
+ });
129
+ (ran ? report.applied : report.skipped).push(id);
130
+ } catch (error) {
131
+ throw new MigrationError(id, error);
132
+ }
133
+ }
134
+ return report;
135
+ }
136
+
137
+ /**
138
+ * Runs these plugins' migrations over the database at `url` with the ledger and the lock a boot
139
+ * uses, then closes its connection. Pass the plugins, with the names, the host runs: the ledger ids
140
+ * come from them, so another name would record the same tables a second time under it.
141
+ */
142
+ export async function migrateDatabase(
143
+ url: string,
144
+ plugins: readonly Pick<RoundtablePlugin, "name" | "migrations">[],
145
+ ): Promise<MigrationReport> {
146
+ const pool = new SQL(url, { max: 2 });
147
+ try {
148
+ return await runMigrations(pool, plugins);
149
+ } finally {
150
+ await pool.close();
151
+ }
152
+ }