pi-roundtable 0.7.19 → 0.9.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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,6 +1,7 @@
1
1
  import type { Locale } from "../i18n/index.ts";
2
+ import type { ActorFacts } from "../identity/actor-facts.ts";
2
3
  import type { ChannelKey } from "../sessions.ts";
3
- import type { Tier } from "../speakers.ts";
4
+ import type { Speaker, Tier } from "../speakers.ts";
4
5
 
5
6
  /** A file attached to a chat message, as the surface reports it; nothing is downloaded yet. */
6
7
  export interface AttachmentRef {
@@ -16,6 +17,21 @@ export interface InboundMessage {
16
17
  channel: ChannelKey;
17
18
  /** Unique per surface; keeps the files of different messages apart. */
18
19
  messageId: string;
20
+ /**
21
+ * Who wrote it, as the surface reports them: the router resolves them to `speaker` through the
22
+ * identity service. A surface that leaves it out has it read from `authorId`, `authorName`, and
23
+ * `authorRoleIds` under its own name as the provider, with a `deprecated` warning; that goes
24
+ * away in 1.0.
25
+ */
26
+ actor?: ActorFacts;
27
+ /**
28
+ * Who the author is, with their principal and tier, as the router resolved them before any
29
+ * claim admits the message: undefined when the access rules serve no one by them, and for a bot
30
+ * or an integration, which are not resolved. Only the router sets it; a surface's own value is
31
+ * dropped. A claim that serves only people the host serves answers only when it is set.
32
+ */
33
+ speaker?: Speaker;
34
+ /** The id the surface knows the author by; who they are to the host is `speaker`. */
19
35
  authorId: string;
20
36
  /** How the author appears in the channel, for example a server nickname. */
21
37
  authorName: string;
@@ -77,10 +93,22 @@ export interface BackgroundTarget {
77
93
  name: string;
78
94
  /** How lists such as `/<root> schedule` name it, in the host's locale. */
79
95
  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 };
96
+ /**
97
+ * Limits of the schedules made for it; absent, nothing may be scheduled for it. `perPrincipal`,
98
+ * when set, holds each person to that many of its schedules across all their conversations.
99
+ */
100
+ schedules?: {
101
+ perChannel: number;
102
+ perPrincipal?: number;
103
+ promptChars: number;
104
+ aheadDays: number;
105
+ };
106
+ /**
107
+ * Limits of the delegated tasks reporting to it; absent, nothing may be delegated to it.
108
+ * `maxRunningPerPrincipal`, when set, holds each person to that many running at once across
109
+ * all their conversations.
110
+ */
111
+ delegation?: { maxRunning: number; maxRunningPerPrincipal?: number };
84
112
  }
85
113
 
86
114
  /** A turn nobody wrote in the channel: a schedule's, or a report of work done elsewhere. */
@@ -91,18 +119,31 @@ export interface BackgroundTurn {
91
119
  * plugin contributes, and a claim skips one it does not serve.
92
120
  */
93
121
  target: string;
94
- author: { id: string; name: string };
95
122
  /**
96
- * The tier the turn runs at, which is its creator's when a person set it up; absent for
97
- * the operator's own automation, which runs at the owner's.
123
+ * Whom the turn is for: the principal who set up the work, and the id and name they are shown
124
+ * by, such as the identity they spoke as. The system principal is the host's alone.
125
+ */
126
+ author: { principalId: string; id: string; name: string };
127
+ /**
128
+ * The tier the turn asks to run at, its creator's when they set it up. It runs at that tier or
129
+ * the principal's own now, whichever is lower.
98
130
  */
99
- tier?: Tier;
131
+ tier: Tier;
100
132
  turnId: string;
101
133
  text: string;
102
134
  /** It delivers a report the owner is waiting for, so it may ask them on cards. */
103
135
  report?: boolean;
136
+ /**
137
+ * Who the turn runs as, set by the router alone once it checked the author: their principal
138
+ * exists and is not disabled, and the tier is capped at theirs. A claim runs the turn as this
139
+ * speaker; one the caller sets is replaced.
140
+ */
141
+ speaker?: Speaker;
104
142
  }
105
143
 
144
+ /** Who a background turn runs as, once checked; or why it may not run now. */
145
+ export type BackgroundRunsAs = { speaker: Speaker } | { skipped: string };
146
+
106
147
  /**
107
148
  * Whose conversation a channel holds, as the claim that owns it names it when `startFresh` says
108
149
  * whose it was: any string, such as "owner" or "study". The host reads none of them, so a plugin
@@ -125,6 +166,12 @@ export type Admission =
125
166
  };
126
167
  /** The turn, run in the channel's queue. */
127
168
  run(): Promise<void>;
169
+ /**
170
+ * Called instead of `run` when the router drops the message the claim admitted, because
171
+ * its author, once recorded, was not who they were when admitted; frees what the claim
172
+ * holds for it.
173
+ */
174
+ dropped?(): void;
128
175
  /** The log line when the turn throws. */
129
176
  failure: string;
130
177
  }
@@ -161,13 +208,32 @@ export interface ChannelClaim {
161
208
  deleteConversation?(channel: ChannelKey): Promise<void>;
162
209
  /** Reports of work started in an owned channel stay in it instead of opening a thread. */
163
210
  postsInPlace?: boolean;
211
+ /**
212
+ * Whether the claim's `background` answers the host's own turns (`SYSTEM_PRINCIPAL`), such as
213
+ * `ops.conversation`'s error reports, in its conversations; true when left out. A claim whose
214
+ * conversations are each private to the person who opened them, such as the web chat's, says
215
+ * false, and a host whose `ops.conversation` names one of them does not start.
216
+ */
217
+ takesSystemReports?: boolean;
164
218
  }
165
219
 
166
220
  /** The conversations of every claimed channel, through the channel queue. */
167
221
  export interface ConversationPort {
168
222
  /** Never rejects; resolves when the message's turn is done or dropped. */
169
223
  handle(message: InboundMessage): Promise<void>;
224
+ /**
225
+ * Runs a turn nobody wrote, as its author's principal at the lower of its tier and theirs; it
226
+ * is skipped, with the reason, when the principal is unknown, disabled, holds no tier, or was
227
+ * last seen too long ago (`access.backgroundStaleDays`), or when the turn names no principal or
228
+ * tier. Never rejects.
229
+ */
170
230
  background(turn: BackgroundTurn): Promise<ScheduledOutcome>;
231
+ /**
232
+ * Who a background turn would run as now, checked as `background` checks it, without running
233
+ * it: for work that must not happen for an author who may not run the turn, such as a
234
+ * schedule's precheck. Throws when the identity service cannot tell.
235
+ */
236
+ runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs>;
171
237
  /** A contributed background target, read when used; undefined when no plugin contributes it. */
172
238
  target(name: string): BackgroundTarget | undefined;
173
239
  /** Waits for the channel's running turn, then starts its conversation over; says whose it was. */
@@ -178,6 +244,12 @@ export interface ConversationPort {
178
244
  stop(channel: ChannelKey): boolean;
179
245
  /** Whether reports of work started in the channel stay in it, by its owner's policy. */
180
246
  postsInPlace(channel: ChannelKey): boolean;
247
+ /** Whether a claim owns the channel, so its messages and background turns reach a conversation. */
248
+ owns(channel: ChannelKey): boolean;
249
+ /** Whether the claim that owns the channel runs background turns there; false when none owns it. */
250
+ takesBackground(channel: ChannelKey): boolean;
251
+ /** Whether the claim that owns the channel takes the host's own turns; false when none owns it. */
252
+ takesSystemReports(channel: ChannelKey): boolean;
181
253
  }
182
254
 
183
255
  /** The host's channel queue, as plugins use it. */
@@ -68,8 +68,9 @@ export interface Providers {
68
68
  images: ImageDrawer;
69
69
  /**
70
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.
71
+ * `context.turns`, in place of the Pi runtime. The default refuses: the host's runtime plugin
72
+ * builds the Pi runtime itself when no plugin fills this slot. Read the running runtime from
73
+ * `services.get(RUNTIME)` rather than calling this.
73
74
  */
74
75
  runtime: RuntimeFactory;
75
76
  }
@@ -5,8 +5,8 @@ import type {
5
5
  TranscriptEntry,
6
6
  TurnResult,
7
7
  } from "../domain/conversation.ts";
8
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
9
8
  import type { TurnRequest } from "../domain/ports.ts";
9
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
10
10
  import type { Logger } from "../log.ts";
11
11
  import type { ThinkingSetting } from "../models.ts";
12
12
  import type { HostEnv, LinkedSessions } from "../plugin.ts";
@@ -51,11 +51,18 @@ export interface AgentRuntime {
51
51
  startFresh(conversation: ChannelKey): Promise<void>;
52
52
  /** Like `startFresh`, but removes the conversation and every archive of it for good; called between turns. */
53
53
  deleteConversation(conversation: ChannelKey): Promise<void>;
54
- /** The conversation's held actions known in memory since startup. */
54
+ /**
55
+ * The conversation's held actions known in memory: those of a turn run since startup, and those
56
+ * `heldActions` or `recentTranscript` restored from the store for it. After a restart, ask one of
57
+ * those first.
58
+ */
55
59
  pendingConfirmation(
56
60
  conversation: ChannelKey,
57
61
  ): PendingConfirmation | undefined;
58
- /** The conversation's held actions, restored from the store after a restart. */
62
+ /**
63
+ * The conversation's held actions, restored from the store after a restart, so that
64
+ * `pendingConfirmation` knows them too, as `recentTranscript` does. Expired ones count as none.
65
+ */
59
66
  heldActions(
60
67
  conversation: ChannelKey,
61
68
  ): Promise<PendingConfirmation | undefined>;
@@ -96,6 +103,13 @@ export interface AgentSessions {
96
103
  modelOf(name: string): { model: string; thinking: ThinkingSetting };
97
104
  /** The channel the scope's turns run in: the group's for a seat in one, else the agent's own. */
98
105
  turnChannel(scope: AgentTurnScope): ChannelKey;
106
+ /**
107
+ * Whose turns in an agent's conversation load memory: `"everyone"` (the default) lets each
108
+ * speaker read their own, `"owners"` only the owners, so a speaker below the owner tier gets no
109
+ * memory in their prompt and no memory tools, and their turn never carries private memory into
110
+ * the conversation's history.
111
+ */
112
+ memory?: "everyone" | "owners";
99
113
  }
100
114
 
101
115
  /** Where a runtime keeps each conversation's held actions, so they survive a restart. */
@@ -109,7 +123,7 @@ export interface HeldActionStore {
109
123
  ): Promise<void>;
110
124
  }
111
125
 
112
- /** What the agent server hands a runtime provider when it builds the runtime. */
126
+ /** What the host's runtime plugin hands a runtime provider when it builds the runtime. */
113
127
  export interface RuntimeDeps {
114
128
  logger: Logger;
115
129
  /** The host's locale and time zone. */
@@ -124,20 +138,26 @@ export interface RuntimeDeps {
124
138
  /** What each tool needs; plugin tools are added when the host links, so ask at use time. */
125
139
  toolTiers: ToolTiers;
126
140
  /**
127
- * The owner's approval and question prompts in a conversation, from its chat surface; undefined
128
- * when the surface has none, and the action then waits for the owner's next message.
141
+ * The approval and question prompts in a conversation for a turn's scope, made with
142
+ * `promptScope(speaker, visibility)`, from its chat surface; undefined when the surface has
143
+ * none, and the action then waits for a message that approves it. A `Speaker`, the 0.8 form,
144
+ * is read as theirs in a shared conversation, with a deprecation warning, until 1.0.
129
145
  */
130
146
  prompts(
131
147
  conversation: ChannelKey,
132
- speaker?: Speaker,
133
- ): OwnerPrompts | undefined;
134
- /** The agent server's per-agent settings, for agent turns. */
135
- agents: AgentSessions;
148
+ scope?: PromptScope | Speaker,
149
+ ): Prompts | undefined;
150
+ /**
151
+ * The agent server's per-agent settings, for agent turns. The runtime is built before the agent
152
+ * server, so read this when a turn runs, not in the factory; it stays undefined on a host without
153
+ * the agent server, where no agent turn runs.
154
+ */
155
+ readonly agents?: AgentSessions;
136
156
  /** Where held actions persist across restarts. */
137
157
  confirmations: HeldActionStore;
138
158
  /** The host's judge, resolved from the `judge` slot. */
139
159
  judge: Judge;
140
160
  }
141
161
 
142
- /** Builds the runtime, once, when the agent server sets up. */
162
+ /** Builds the runtime, once, when the host's runtime plugin sets up. */
143
163
  export type RuntimeFactory = (deps: RuntimeDeps) => AgentRuntime;
@@ -1,7 +1,8 @@
1
1
  import type { OutboundReply } from "../domain/conversation.ts";
2
2
  import type { InterimPosts } from "../domain/interim.ts";
3
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
3
+ import type { TurnProgress } from "../domain/progress.ts";
4
4
  import { PluginError } from "../errors.ts";
5
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
5
6
  import type { ChannelKey } from "../sessions.ts";
6
7
  import type { Speaker } from "../speakers.ts";
7
8
  import type { InboundMessage } from "./channels.ts";
@@ -57,6 +58,13 @@ export interface ChatSurface {
57
58
  * `conversations.stop(channel)` when used. Absent = none shown.
58
59
  */
59
60
  showStop?(channel: ChannelKey): () => void;
61
+ /**
62
+ * Marks in the channel, publicly, that its conversation just started over, so what a reader of
63
+ * the channel's history would take for the conversation's context ends there; called after the
64
+ * claim's `startFresh` succeeded. A rejection is logged and the restart still counts. Absent =
65
+ * nothing is posted.
66
+ */
67
+ markFresh?(channel: ChannelKey): Promise<void>;
60
68
  /** Adds or removes the bot's reaction on a message; failures are logged, never thrown. */
61
69
  react?(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
62
70
  unreact?(
@@ -65,15 +73,23 @@ export interface ChatSurface {
65
73
  emoji: string,
66
74
  ): Promise<void>;
67
75
  /**
68
- * How the owner approves held actions or answers ask_user in a running turn; undefined, or
69
- * absent, = the action is held until the owner's next message.
76
+ * How those the scope names approve held actions or answer ask_user in a running turn: the
77
+ * scope's speaker, and in a shared conversation the owners; see `Prompts` and `PromptScope`.
78
+ * Without a scope the prompts are the owners'. Undefined, or absent, = the action is held
79
+ * until a message approves it.
70
80
  */
71
- prompts?(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
81
+ prompts?(channel: ChannelKey, scope?: PromptScope): Prompts | undefined;
72
82
  /**
73
83
  * Where a running turn posts the text it writes before its final answer: ordinary messages it
74
84
  * may edit in place. Absent, or undefined, = only the final reply is posted.
75
85
  */
76
86
  interim?(channel: ChannelKey): InterimPosts | undefined;
87
+ /**
88
+ * Shows what a running turn writes and which tools it runs, as it goes, such as a live preview
89
+ * in a web chat. Called between the turn's start and its reply; a rejection is logged and the
90
+ * turn goes on. Absent = the surface shows only the final reply.
91
+ */
92
+ progress?(channel: ChannelKey, event: TurnProgress): Promise<void> | void;
77
93
  }
78
94
 
79
95
  /**
@@ -91,8 +107,21 @@ export interface SurfacePort {
91
107
  showStop(channel: ChannelKey): () => void;
92
108
  react(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
93
109
  unreact(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
94
- /** The owner's prompts in the channel; undefined when its surface has none. */
95
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
110
+ /**
111
+ * The prompts in the channel for the scope's turn; undefined when its surface has none. A
112
+ * `Speaker`, the 0.8 form, is read as theirs in a shared conversation, with a deprecation
113
+ * warning, until 1.0.
114
+ */
115
+ prompts(
116
+ channel: ChannelKey,
117
+ scope?: PromptScope | Speaker,
118
+ ): Prompts | undefined;
96
119
  /** The channel's interim posts; undefined when its surface has none. */
97
120
  interim(channel: ChannelKey): InterimPosts | undefined;
121
+ /**
122
+ * Shows a running turn's progress; a no-op when no surface serves the channel or its surface
123
+ * shows none. The host's port always has it; it is optional so a port written before 0.8 still
124
+ * type-checks, and a turn run over one without it shows no progress.
125
+ */
126
+ progress?(channel: ChannelKey, event: TurnProgress): Promise<void>;
98
127
  }
@@ -0,0 +1,56 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+
3
+ /** Who may take part: `private` belongs to one principal, `shared` to whoever its claim admits. */
4
+ export type ConversationVisibility = "private" | "shared";
5
+
6
+ /** What the first turn of a conversation records about it. */
7
+ export interface ConversationRegistration {
8
+ key: ChannelKey;
9
+ /** The conversation's kind, such as "study"; the persona its session runs. */
10
+ kind: string;
11
+ visibility: ConversationVisibility;
12
+ /** Whose conversation it is: the speaker's id, for a private one. */
13
+ principalId?: string;
14
+ /** A name to list it by. */
15
+ title?: string;
16
+ }
17
+
18
+ /** A conversation the host knows: who it belongs to and when it was last active. */
19
+ export interface ConversationRecord extends ConversationRegistration {
20
+ /** The prefix of its key, such as "web". */
21
+ surface: string;
22
+ createdAt: Date;
23
+ lastActiveAt: Date;
24
+ }
25
+
26
+ /**
27
+ * The conversations run through `context.turns`, recorded at their first turn. Its kind,
28
+ * visibility, and principal are fixed then; each later turn only marks it active. Who may speak
29
+ * in it stays the claim's decision: the registry records, it does not refuse.
30
+ */
31
+ export interface ConversationRegistry {
32
+ /** Records the conversation at its first turn, or marks a known one active; returns what is stored. */
33
+ register(entry: ConversationRegistration): Promise<ConversationRecord>;
34
+ get(key: ChannelKey): Promise<ConversationRecord | undefined>;
35
+ /** One principal's conversations, or every one; the most recently active first. */
36
+ list(filter?: { principal?: string }): Promise<ConversationRecord[]>;
37
+ /**
38
+ * Only for migrating a conversation recorded before 0.9, when every turn run without a
39
+ * visibility was recorded `shared` with no principal: makes such a conversation private to
40
+ * `principalId`, as the plugin that hands over what 0.8 left behind decides, such as remote-mcp
41
+ * giving 0.8's sessions to the primary owner. A conversation that is private or names a
42
+ * principal never changes, so adopting is safe to repeat and to race: one principal wins.
43
+ * Returns what is stored, or undefined when the key is unknown. Adopting a conversation 0.9 or
44
+ * later recorded is outside this contract: today it changes one recorded shared like any other,
45
+ * and a later version may refuse it.
46
+ */
47
+ adopt(
48
+ key: ChannelKey,
49
+ principalId: string,
50
+ ): Promise<ConversationRecord | undefined>;
51
+ /** Names a conversation, or clears its name; undefined when the key is unknown. */
52
+ setTitle(
53
+ key: ChannelKey,
54
+ title: string | undefined,
55
+ ): Promise<ConversationRecord | undefined>;
56
+ }
@@ -0,0 +1,126 @@
1
+ import type { SQL } from "bun";
2
+ import { parseChannelKey } from "../contract/surface.ts";
3
+ import type { Migration } from "../db/migrations.ts";
4
+ import type { ChannelKey } from "../sessions.ts";
5
+ import type {
6
+ ConversationRecord,
7
+ ConversationRegistration,
8
+ ConversationRegistry,
9
+ ConversationVisibility,
10
+ } from "./conversation-registry.ts";
11
+
12
+ interface Row {
13
+ key: string;
14
+ surface: string;
15
+ kind: string;
16
+ principal_id: string | null;
17
+ visibility: ConversationVisibility;
18
+ title: string | null;
19
+ created_at: Date;
20
+ last_active_at: Date;
21
+ }
22
+
23
+ function recordOf(row: Row): ConversationRecord {
24
+ return {
25
+ key: row.key as ChannelKey,
26
+ surface: row.surface,
27
+ kind: row.kind,
28
+ visibility: row.visibility,
29
+ ...(row.principal_id === null ? {} : { principalId: row.principal_id }),
30
+ ...(row.title === null ? {} : { title: row.title }),
31
+ createdAt: row.created_at,
32
+ lastActiveAt: row.last_active_at,
33
+ };
34
+ }
35
+
36
+ /** The conversation registry in the `conversations` table. */
37
+ export class PgConversationRegistry implements ConversationRegistry {
38
+ readonly #sql: SQL;
39
+
40
+ private constructor(sql: SQL) {
41
+ this.#sql = sql;
42
+ }
43
+
44
+ /** The registry's table; the host runs this before the registry attaches. */
45
+ static readonly migration: Migration = {
46
+ name: "conversations",
47
+ up: async (sql) => {
48
+ await sql`
49
+ CREATE TABLE IF NOT EXISTS conversations (
50
+ key text PRIMARY KEY,
51
+ surface text NOT NULL,
52
+ kind text NOT NULL,
53
+ principal_id text,
54
+ visibility text NOT NULL CHECK (visibility IN ('private', 'shared')),
55
+ title text,
56
+ created_at timestamptz NOT NULL DEFAULT now(),
57
+ last_active_at timestamptz NOT NULL DEFAULT now()
58
+ )`;
59
+ await sql`
60
+ CREATE INDEX IF NOT EXISTS conversations_principal
61
+ ON conversations (principal_id, last_active_at DESC)`;
62
+ },
63
+ };
64
+
65
+ /** The registry over the host's migrated pool. */
66
+ static async attach(sql: SQL): Promise<PgConversationRegistry> {
67
+ return new PgConversationRegistry(sql);
68
+ }
69
+
70
+ async register(entry: ConversationRegistration): Promise<ConversationRecord> {
71
+ const { surface } = parseChannelKey(entry.key);
72
+ const rows: Row[] = await this.#sql`
73
+ INSERT INTO conversations (key, surface, kind, principal_id, visibility, title)
74
+ VALUES (${entry.key}, ${surface}, ${entry.kind}, ${entry.principalId ?? null},
75
+ ${entry.visibility}, ${entry.title ?? null})
76
+ ON CONFLICT (key) DO UPDATE SET last_active_at = now()
77
+ RETURNING *`;
78
+ const [row] = rows;
79
+ if (!row) throw new Error(`conversation ${entry.key} was not recorded`);
80
+ return recordOf(row);
81
+ }
82
+
83
+ async get(key: ChannelKey): Promise<ConversationRecord | undefined> {
84
+ const rows: Row[] = await this.#sql`
85
+ SELECT * FROM conversations WHERE key = ${key}`;
86
+ const [row] = rows;
87
+ return row ? recordOf(row) : undefined;
88
+ }
89
+
90
+ async list(
91
+ filter: { principal?: string } = {},
92
+ ): Promise<ConversationRecord[]> {
93
+ const rows: Row[] =
94
+ filter.principal === undefined
95
+ ? await this.#sql`
96
+ SELECT * FROM conversations ORDER BY last_active_at DESC, key`
97
+ : await this.#sql`
98
+ SELECT * FROM conversations WHERE principal_id = ${filter.principal}
99
+ ORDER BY last_active_at DESC, key`;
100
+ return rows.map(recordOf);
101
+ }
102
+
103
+ async adopt(
104
+ key: ChannelKey,
105
+ principalId: string,
106
+ ): Promise<ConversationRecord | undefined> {
107
+ // The row lock makes a racing adopt wait and then find the conversation no longer no one's.
108
+ const rows: Row[] = await this.#sql`
109
+ UPDATE conversations SET visibility = 'private', principal_id = ${principalId}
110
+ WHERE key = ${key} AND visibility = 'shared' AND principal_id IS NULL
111
+ RETURNING *`;
112
+ const [row] = rows;
113
+ return row ? recordOf(row) : this.get(key);
114
+ }
115
+
116
+ async setTitle(
117
+ key: ChannelKey,
118
+ title: string | undefined,
119
+ ): Promise<ConversationRecord | undefined> {
120
+ const rows: Row[] = await this.#sql`
121
+ UPDATE conversations SET title = ${title ?? null} WHERE key = ${key}
122
+ RETURNING *`;
123
+ const [row] = rows;
124
+ return row ? recordOf(row) : undefined;
125
+ }
126
+ }
@@ -0,0 +1,29 @@
1
+ import type { SQL } from "bun";
2
+ import type { RoundtablePlugin } from "../plugin.ts";
3
+ import { CONVERSATIONS } from "../services.ts";
4
+ import type { ConversationRegistry } from "./conversation-registry.ts";
5
+ import { PgConversationRegistry } from "./conversation-store.ts";
6
+
7
+ /** The name of the conversation registry's plugin, as the migration ledger names it. */
8
+ export const CONVERSATIONS_PLUGIN = "conversations";
9
+
10
+ /**
11
+ * The conversation registry, provided as `CONVERSATIONS`: every conversation run through
12
+ * `context.turns`, recorded at its first turn. No session file moves; a conversation from before
13
+ * the registry is recorded at its next turn.
14
+ */
15
+ export function conversationsPlugin(
16
+ /** Opens the registry; a test hands a stand-in, so the plugin sets up without a database. */
17
+ open: (sql: SQL) => Promise<ConversationRegistry> = (sql) =>
18
+ PgConversationRegistry.attach(sql),
19
+ ): RoundtablePlugin {
20
+ return {
21
+ name: CONVERSATIONS_PLUGIN,
22
+ migrations: [PgConversationRegistry.migration],
23
+ provides: [CONVERSATIONS],
24
+ setup: async ({ database, services }) => {
25
+ services.provide(CONVERSATIONS, await open(database()));
26
+ return {};
27
+ },
28
+ };
29
+ }