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
@@ -0,0 +1,316 @@
1
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
+ import type { Logger } from "../log.ts";
3
+
4
+ /**
5
+ * What became of one `send`: written to the connection, `"queued"` behind a client that reads
6
+ * slower than the route sends, or `"dropped"` because the socket is closed, or because
7
+ * `maxBufferedBytes` were already waiting and the socket was cut for it.
8
+ */
9
+ export type WebSocketSendResult = "sent" | "queued" | "dropped";
10
+
11
+ /** One socket a WebSocket route accepted, with the data its `accept` gave. */
12
+ export interface RouteSocket<Data = unknown> {
13
+ readonly data: Data;
14
+ send(message: string | Uint8Array): WebSocketSendResult;
15
+ close(code?: number, reason?: string): void;
16
+ }
17
+
18
+ /** What `accept` answers: the socket's data to upgrade with, or a response that refuses it. */
19
+ export type WebSocketAccept<Data = unknown> =
20
+ | { data: Data; headers?: Record<string, string> }
21
+ | Response;
22
+
23
+ /**
24
+ * The WebSocket half of an HTTP route. An upgrade request on the route's path passes the Origin
25
+ * check, then `accept`, which authenticates it before any socket opens; other requests still
26
+ * reach the route's `handle`.
27
+ */
28
+ export interface WebSocketRoute<Data = unknown> {
29
+ /**
30
+ * The `Origin` values a browser may connect from, each as `scheme://host[:port]` exactly as the
31
+ * browser sends it, such as `https://chat.example.com` or `chrome-extension://<id>`; a request
32
+ * with another Origin, or none, is refused with 403 before `accept`. `"any"` skips the check,
33
+ * for clients that are not browsers.
34
+ */
35
+ origins: readonly string[] | "any";
36
+ /** Authenticates and authorizes the upgrade; a thrown error answers 500 like a failing `handle`. */
37
+ accept(
38
+ request: Request,
39
+ ): WebSocketAccept<Data> | Promise<WebSocketAccept<Data>>;
40
+ /**
41
+ * The largest message in bytes; a larger one closes the socket with 1009. Default 64 KiB. A
42
+ * message over twice the listener's largest limit drops the connection before it is read.
43
+ */
44
+ maxMessageBytes?: number;
45
+ /** At most `messages` per `perMs` from one socket; one more closes it with 1008. Default 120 a minute. */
46
+ rate?: { messages: number; perMs: number };
47
+ /**
48
+ * The most bytes one socket may have waiting for a client that reads slower than the route
49
+ * sends. It is checked before each `send`: a `send` that finds this many waiting returns
50
+ * `"dropped"` and cuts the socket without a close frame, so the client and the route's `close`
51
+ * both see 1006; one that finds fewer is sent whole, so the bytes waiting can reach this limit
52
+ * plus one message. Default 1 MiB.
53
+ */
54
+ maxBufferedBytes?: number;
55
+ /** The most sockets the route holds open at once; another upgrade gets 503 before `accept`. Default 256. */
56
+ maxConnections?: number;
57
+ /** What a handler returns is ignored; a promise is awaited. */
58
+ open?(socket: RouteSocket<Data>): unknown | Promise<unknown>;
59
+ /** Called per message as it arrives, without waiting for the previous call to settle. */
60
+ message(
61
+ socket: RouteSocket<Data>,
62
+ message: string | Uint8Array,
63
+ ): unknown | Promise<unknown>;
64
+ /** Called once the socket closed, also when the listener stops (code 1001); stop waits for it. */
65
+ close?(
66
+ socket: RouteSocket<Data>,
67
+ code: number,
68
+ reason: string,
69
+ ): unknown | Promise<unknown>;
70
+ }
71
+
72
+ export const DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024;
73
+ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
74
+ const DEFAULT_MAX_CONNECTIONS = 256;
75
+ const DEFAULT_RATE = { messages: 120, perMs: 60_000 };
76
+
77
+ /** The close codes RFC 6455 names for the reasons this module closes a socket. */
78
+ const GOING_AWAY = 1001;
79
+ const POLICY_VIOLATION = 1008;
80
+ const TOO_BIG = 1009;
81
+ const INTERNAL_ERROR = 1011;
82
+
83
+ /** What the server keeps on each socket: its route, the route's socket, and its rate window. */
84
+ export interface Connection {
85
+ route: string;
86
+ websocket: WebSocketRoute;
87
+ data: unknown;
88
+ socket?: RouteSocket;
89
+ windowStart: number;
90
+ count: number;
91
+ }
92
+
93
+ /** Whether the request asks to become a WebSocket: a GET with `Upgrade: websocket`. */
94
+ export const isUpgrade = (request: Request): boolean =>
95
+ request.method === "GET" &&
96
+ request.headers.get("upgrade")?.toLowerCase() === "websocket";
97
+
98
+ /** Whether an upgrade request carries the key and the version 13 that RFC 6455 requires. */
99
+ const completeHandshake = (request: Request): boolean =>
100
+ Boolean(request.headers.get("sec-websocket-key")) &&
101
+ request.headers.get("sec-websocket-version") === "13";
102
+
103
+ const originAllowed = (route: WebSocketRoute, origin: string | null) =>
104
+ route.origins === "any" ||
105
+ (origin !== null && route.origins.includes(origin));
106
+
107
+ const byteLength = (message: string | Uint8Array) =>
108
+ typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
109
+
110
+ /**
111
+ * Sends one message unless the route's `maxBufferedBytes` already wait; then, or when Bun drops
112
+ * the message, the socket is cut (1006) rather than left open with a gap in what it was sent.
113
+ * Bun answers 0 for an empty message it did send, and for a message it dropped.
114
+ */
115
+ function send(
116
+ ws: ServerWebSocket<Connection>,
117
+ message: string | Uint8Array,
118
+ ): WebSocketSendResult {
119
+ if (ws.readyState !== WebSocket.OPEN) return "dropped";
120
+ const limit =
121
+ ws.data.websocket.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
122
+ if (ws.getBufferedAmount() < limit) {
123
+ const status = ws.send(message);
124
+ if (ws.readyState !== WebSocket.OPEN) return "dropped";
125
+ if (status > 0 || (status === 0 && byteLength(message) === 0))
126
+ return "sent";
127
+ if (status === -1) return "queued";
128
+ }
129
+ ws.terminate();
130
+ return "dropped";
131
+ }
132
+
133
+ /** Resolves after `promise` settles or `ms` passes, whichever comes first. */
134
+ async function settledWithin(promise: Promise<unknown>, ms: number) {
135
+ let timer: ReturnType<typeof setTimeout> | undefined;
136
+ await Promise.race([
137
+ promise,
138
+ new Promise<void>((resolve) => {
139
+ timer = setTimeout(resolve, ms);
140
+ }),
141
+ ]);
142
+ clearTimeout(timer);
143
+ }
144
+
145
+ /**
146
+ * The open sockets of one host's listeners and the handler calls still running on them. A route
147
+ * handler that throws or rejects closes its socket with 1011 and logs the route and listener,
148
+ * never the URL.
149
+ */
150
+ export class SocketSet {
151
+ readonly #open = new Set<ServerWebSocket<Connection>>();
152
+ readonly #running = new Set<Promise<void>>();
153
+ /** Each route's open sockets plus the upgrades still in `accept`. */
154
+ readonly #held = new Map<WebSocketRoute, number>();
155
+ readonly #logger: Logger;
156
+
157
+ constructor(logger: Logger) {
158
+ this.#logger = logger;
159
+ }
160
+
161
+ /**
162
+ * Upgrades a request for a WebSocket route. A handshake without a key or version 13 is 400, a
163
+ * refused Origin 403, and a route at `maxConnections` 503, all before `accept`; the response
164
+ * `accept` returns is sent as it is, and a throwing `accept` is passed to the caller like a
165
+ * failing `handle`. Answers nothing once the socket is upgraded.
166
+ */
167
+ async upgrade(
168
+ name: string,
169
+ websocket: WebSocketRoute,
170
+ request: Request,
171
+ server: Server<Connection>,
172
+ ): Promise<Response | undefined> {
173
+ if (!completeHandshake(request))
174
+ return new Response("Bad Request", { status: 400 });
175
+ if (!originAllowed(websocket, request.headers.get("origin")))
176
+ return new Response("Forbidden", { status: 403 });
177
+ const held = this.#held.get(websocket) ?? 0;
178
+ if (held >= (websocket.maxConnections ?? DEFAULT_MAX_CONNECTIONS))
179
+ return new Response("Service Unavailable", { status: 503 });
180
+ // The place is taken while `accept` runs, so concurrent upgrades cannot overshoot it.
181
+ this.#held.set(websocket, held + 1);
182
+ let upgraded = false;
183
+ try {
184
+ const accepted = await websocket.accept(request);
185
+ if (accepted instanceof Response) return accepted;
186
+ const connection: Connection = {
187
+ route: name,
188
+ websocket,
189
+ data: accepted.data,
190
+ windowStart: Date.now(),
191
+ count: 0,
192
+ };
193
+ // Bun runs `open` inside `upgrade`, and `close` releases the place from then on.
194
+ upgraded = server.upgrade(request, {
195
+ data: connection,
196
+ ...(accepted.headers ? { headers: accepted.headers } : {}),
197
+ });
198
+ return upgraded
199
+ ? undefined
200
+ : new Response("Bad Request", { status: 400 });
201
+ } finally {
202
+ if (!upgraded) this.#release(websocket);
203
+ }
204
+ }
205
+
206
+ #release(websocket: WebSocketRoute): void {
207
+ const held = (this.#held.get(websocket) ?? 1) - 1;
208
+ if (held > 0) this.#held.set(websocket, held);
209
+ else this.#held.delete(websocket);
210
+ }
211
+
212
+ /**
213
+ * The Bun handler for one listener's sockets, given its largest route limits. Bun drops a frame
214
+ * over twice the message limit unread; up to there each route's own limit closes with 1009.
215
+ * Bun drops a message only when the largest buffered limit already waits, which each route's
216
+ * `send` checks first at its own limit, so a single message larger than any limit still goes out.
217
+ */
218
+ handler(
219
+ listener: string,
220
+ limits: { maxMessageBytes: number; maxBufferedBytes: number },
221
+ ): WebSocketHandler<Connection> {
222
+ return {
223
+ maxPayloadLength: limits.maxMessageBytes * 2,
224
+ backpressureLimit: limits.maxBufferedBytes,
225
+ open: (ws) => {
226
+ this.#open.add(ws);
227
+ const connection = ws.data;
228
+ connection.socket = {
229
+ data: connection.data,
230
+ send: (message) => send(ws, message),
231
+ close: (code, reason) => ws.close(code, reason),
232
+ };
233
+ const { open } = connection.websocket;
234
+ if (open) this.#run(ws, listener, (socket) => open(socket));
235
+ },
236
+ message: (ws, message) => {
237
+ const connection = ws.data;
238
+ const { websocket } = connection;
239
+ const limit = websocket.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
240
+ if (byteLength(message) > limit) {
241
+ ws.close(TOO_BIG, "message too big");
242
+ return;
243
+ }
244
+ const rate = websocket.rate ?? DEFAULT_RATE;
245
+ const now = Date.now();
246
+ if (now - connection.windowStart >= rate.perMs) {
247
+ connection.windowStart = now;
248
+ connection.count = 0;
249
+ }
250
+ connection.count += 1;
251
+ if (connection.count > rate.messages) {
252
+ ws.close(POLICY_VIOLATION, "too many messages");
253
+ return;
254
+ }
255
+ this.#run(ws, listener, (socket) => websocket.message(socket, message));
256
+ },
257
+ close: (ws, code, reason) => {
258
+ this.#open.delete(ws);
259
+ this.#release(ws.data.websocket);
260
+ const { close } = ws.data.websocket;
261
+ if (close)
262
+ this.#run(ws, listener, (socket) => close(socket, code, reason));
263
+ },
264
+ };
265
+ }
266
+
267
+ /** Runs one route handler, tracked until it settles; a failure closes the socket. */
268
+ #run(
269
+ ws: ServerWebSocket<Connection>,
270
+ listener: string,
271
+ call: (socket: RouteSocket) => unknown | Promise<unknown>,
272
+ ): Promise<void> {
273
+ const connection = ws.data;
274
+ const running = (async () => {
275
+ try {
276
+ if (connection.socket) await call(connection.socket);
277
+ } catch (err) {
278
+ this.#logger.error(
279
+ { err, route: connection.route, listener },
280
+ "websocket handler failed",
281
+ );
282
+ ws.close(INTERNAL_ERROR, "internal error");
283
+ }
284
+ })();
285
+ this.#running.add(running);
286
+ void running.finally(() => this.#running.delete(running));
287
+ return running;
288
+ }
289
+
290
+ /** Closes every open socket with 1001, so each route's `close` runs. */
291
+ closeAll(): void {
292
+ for (const ws of this.#open) ws.close(GOING_AWAY, "server stopping");
293
+ }
294
+
295
+ /**
296
+ * Waits, at most `timeoutMs`, until every socket has closed and every handler call has settled;
297
+ * a close the server reports later, or a call it starts meanwhile, is waited for too.
298
+ */
299
+ async drain(timeoutMs: number): Promise<void> {
300
+ const deadline = Date.now() + timeoutMs;
301
+ while (this.#open.size > 0 || this.#running.size > 0) {
302
+ const left = deadline - Date.now();
303
+ if (left <= 0) {
304
+ this.#logger.warn(
305
+ { open: this.#open.size, running: this.#running.size },
306
+ "websocket handlers still running after the close timeout",
307
+ );
308
+ return;
309
+ }
310
+ await settledWithin(
311
+ Promise.allSettled([...this.#running]),
312
+ Math.min(left, 10),
313
+ );
314
+ }
315
+ }
316
+ }
@@ -11,11 +11,12 @@ export function cardsEn(ctx: CatalogContext) {
11
11
  cardStopped: "⏹️ This round was stopped.",
12
12
  cardExpired: "⌛ Timed out with no answer.",
13
13
  cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
14
- cardApproversNote: (minTier: string) =>
15
- `-# Who can approve: ${minTier} and above`,
16
- cardApproversRefusal: (minTier: string) =>
17
- `This card needs ${minTier} or above to answer.`,
18
- cardAskerNote: (userId: string) => `-# Who can answer: <@${userId}>`,
14
+ cardApproversNote: (...userIds: readonly string[]) =>
15
+ `-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
16
+ cardApproversRefusal:
17
+ "This approval is for the speaker whose turn asked for it.",
18
+ cardAskerNote: (...userIds: readonly string[]) =>
19
+ `-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
19
20
  cardAskerRefusal: "This question is for another speaker.",
20
21
  cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
21
22
  cardApproved: "✅ Approved to run.",
@@ -42,10 +43,11 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
42
43
  cardStopped: "⏹️ 這一輪已停止。",
43
44
  cardExpired: "⌛ 已逾時,沒有回答。",
44
45
  cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
45
- cardApproversNote: (minTier: string) => `-# 可以核准的人:${minTier} 以上`,
46
- cardApproversRefusal: (minTier: string) =>
47
- `這張卡片要 ${minTier} 以上才能回答。`,
48
- cardAskerNote: (userId: string) => `-# 可以回答的人:<@${userId}>`,
46
+ cardApproversNote: (...userIds: readonly string[]) =>
47
+ `-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
48
+ cardApproversRefusal: "這張核准卡是給提出這一輪的發話者的。",
49
+ cardAskerNote: (...userIds: readonly string[]) =>
50
+ `-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
49
51
  cardAskerRefusal: "這張問題是問另一位發話者的。",
50
52
  cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,
51
53
  cardApproved: "✅ 已核准執行。",
@@ -0,0 +1,227 @@
1
+ import type { Pronouns } from "../config/config.ts";
2
+ import { ConfigError } from "../domain/errors.ts";
3
+ import type { ChannelKey } from "../sessions.ts";
4
+ import type {
5
+ SpeakerFacts,
6
+ SpeakerMap,
7
+ SpeakerPolicy,
8
+ Tier,
9
+ TierMembers,
10
+ } from "../speakers.ts";
11
+ import {
12
+ type ActorFacts,
13
+ identityOf,
14
+ parseIdentity,
15
+ surfaceOf,
16
+ } from "./actor-facts.ts";
17
+ import { SYSTEM_PRINCIPAL } from "./principal-store.ts";
18
+
19
+ /** One owner the configuration names: whom they are, and the identities that are theirs. */
20
+ export interface AccessOwner {
21
+ name: string;
22
+ pronouns?: Pronouns;
23
+ /** Their principal's id, such as the 0.8 `owner.id`; without it, a new `p_` principal is made at the first boot and found again by the identities after. */
24
+ principal?: string;
25
+ /** `<provider>:<subject>` strings, such as `discord:<user id>` or `token:remote-mcp`. */
26
+ identities: readonly string[];
27
+ }
28
+
29
+ /**
30
+ * Who holds a tier besides the owners: identities, roles, or everyone. Roles are written
31
+ * `<surface>:role:<name>`. `everyone: true` admits everyone on every surface, a list only on
32
+ * those surfaces.
33
+ */
34
+ export interface AccessTier {
35
+ identities?: readonly string[];
36
+ roles?: readonly string[];
37
+ everyone?: boolean | readonly string[];
38
+ }
39
+
40
+ /** Who the host serves, and at which tier. */
41
+ export interface AccessRules {
42
+ /** The owners, the first of them the primary owner. The owner tier comes only from here or the CLI, never from a role a surface reports. */
43
+ owners: readonly AccessOwner[];
44
+ admins?: AccessTier;
45
+ members?: AccessTier;
46
+ /** `admitted`: someone the rules admit gets a principal at first contact, and a 0.8 speaker claims theirs; `linked`: only identities already linked, such as by the CLI or the configuration, are served. Owners are never admitted. */
47
+ provisioning: "admitted" | "linked";
48
+ /** How many days a principal whose tier came only from the rules on contact may go unseen before their background turns are refused. */
49
+ backgroundStaleDays: number;
50
+ }
51
+
52
+ const holds = (
53
+ tier: AccessTier | undefined,
54
+ facts: ActorFacts,
55
+ surface: string,
56
+ ): boolean => {
57
+ if (!tier) return false;
58
+ const { everyone } = tier;
59
+ if (everyone === true) return true;
60
+ if (Array.isArray(everyone) && everyone.includes(surface)) return true;
61
+ if (tier.identities?.includes(identityOf(facts))) return true;
62
+ return (facts.roles ?? []).some((role) => tier.roles?.includes(role));
63
+ };
64
+
65
+ /**
66
+ * The tier the rules give these facts on this contact: admin, member, or none. Never owner: an
67
+ * owner is one by the configuration's owners or a lasting role, not by what a surface reports.
68
+ */
69
+ export function factsTier(
70
+ rules: Pick<AccessRules, "admins" | "members">,
71
+ facts: ActorFacts,
72
+ conversation?: ChannelKey,
73
+ ): Exclude<Tier, "owner"> | undefined {
74
+ const surface = surfaceOf(facts, conversation);
75
+ if (holds(rules.admins, facts, surface)) return "admin";
76
+ if (holds(rules.members, facts, surface)) return "member";
77
+ return undefined;
78
+ }
79
+
80
+ /** The configured owner whose identities include these facts', if any. */
81
+ export function ownerOfFacts(
82
+ rules: Pick<AccessRules, "owners">,
83
+ facts: ActorFacts,
84
+ ): AccessOwner | undefined {
85
+ const identity = identityOf(facts);
86
+ return rules.owners.find((owner) => owner.identities.includes(identity));
87
+ }
88
+
89
+ const ROLE = /^[^:]+:role:.+$/;
90
+
91
+ function checkTier(tier: AccessTier | undefined, path: string): void {
92
+ if (!tier) return;
93
+ tier.identities?.forEach((identity, i) => {
94
+ if (!parseIdentity(identity))
95
+ throw new ConfigError(
96
+ `config ${path}.identities[${i}]: expected <provider>:<subject>, got ${JSON.stringify(identity)}. Write it like discord:<user id>; roundtable principal list prints them.`,
97
+ );
98
+ });
99
+ tier.roles?.forEach((role, i) => {
100
+ if (!ROLE.test(role))
101
+ throw new ConfigError(
102
+ `config ${path}.roles[${i}]: expected <surface>:role:<name>, got ${JSON.stringify(role)}. Write it like discord:role:<role id> or web:role:<role name>.`,
103
+ );
104
+ });
105
+ }
106
+
107
+ /** The rules, checked: identities and roles well formed, no identity or principal under two owners, no owner as the system principal. */
108
+ export function checkAccessRules(rules: AccessRules): AccessRules {
109
+ const seenIdentity = new Map<string, number>();
110
+ const seenPrincipal = new Map<string, number>();
111
+ rules.owners.forEach((owner, n) => {
112
+ const path = `access.owners[${n}]`;
113
+ if (owner.principal === SYSTEM_PRINCIPAL)
114
+ throw new ConfigError(
115
+ `config ${path}.principal: "${SYSTEM_PRINCIPAL}" is the host's own principal. Give the owner's own id, or leave principal out.`,
116
+ );
117
+ if (owner.principal === undefined && owner.identities.length === 0)
118
+ throw new ConfigError(
119
+ `config ${path}: give the owner a principal id or at least one identity, so each boot finds the same principal.`,
120
+ );
121
+ if (owner.principal !== undefined) {
122
+ const other = seenPrincipal.get(owner.principal);
123
+ if (other !== undefined)
124
+ throw new ConfigError(
125
+ `config ${path}.principal: access.owners[${other}] has the same principal ${owner.principal}. Merge the two owners into one.`,
126
+ );
127
+ seenPrincipal.set(owner.principal, n);
128
+ }
129
+ owner.identities.forEach((identity, i) => {
130
+ if (!parseIdentity(identity))
131
+ throw new ConfigError(
132
+ `config ${path}.identities[${i}]: expected <provider>:<subject>, got ${JSON.stringify(identity)}. Write it like discord:<user id>; roundtable principal list prints them.`,
133
+ );
134
+ const other = seenIdentity.get(identity);
135
+ if (other !== undefined)
136
+ throw new ConfigError(
137
+ `config ${path}.identities[${i}]: ${identity} is also listed under access.owners[${other}]. An identity is one person's; keep it under one owner.`,
138
+ );
139
+ seenIdentity.set(identity, n);
140
+ });
141
+ });
142
+ checkTier(rules.admins, "access.admins");
143
+ checkTier(rules.members, "access.members");
144
+ if (
145
+ !(
146
+ Number.isInteger(rules.backgroundStaleDays) &&
147
+ rules.backgroundStaleDays > 0
148
+ )
149
+ )
150
+ throw new ConfigError(
151
+ `config access.backgroundStaleDays: expected a positive whole number of days, got ${JSON.stringify(rules.backgroundStaleDays)}.`,
152
+ );
153
+ return rules;
154
+ }
155
+
156
+ function tierOfMembers(
157
+ members: TierMembers | undefined,
158
+ provider: string,
159
+ ): AccessTier | undefined {
160
+ if (!members) return undefined;
161
+ return {
162
+ ...(members.users
163
+ ? { identities: members.users.map((id) => `${provider}:${id}`) }
164
+ : {}),
165
+ ...(members.roles
166
+ ? { roles: members.roles.map((role) => `${provider}:role:${role}`) }
167
+ : {}),
168
+ ...(members.everyone === undefined
169
+ ? {}
170
+ : { everyone: members.everyone ? [provider] : false }),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The rules a 0.8 speaker map means on one surface: its owners by id, its users as identities of
176
+ * that surface, its roles as that surface's roles, and `everyone` as everyone there.
177
+ */
178
+ export function rulesOfSpeakerMap(
179
+ map: SpeakerMap,
180
+ provider = "discord",
181
+ ): Pick<AccessRules, "owners" | "admins" | "members"> {
182
+ const admins = tierOfMembers(map.admins, provider);
183
+ const members = tierOfMembers(map.members, provider);
184
+ return {
185
+ owners: map.owners.map((id) => ({
186
+ name: id,
187
+ principal: id,
188
+ identities: [`${provider}:${id}`],
189
+ })),
190
+ ...(admins ? { admins } : {}),
191
+ ...(members ? { members } : {}),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The 0.8 policy over these rules on one surface, for the code that still resolves a speaker
197
+ * from a surface's facts by itself: an author is an owner when one of the owners lists their
198
+ * identity there, and otherwise holds the tier the rules give them. Each speaker's principal is
199
+ * their own id, as for a person carried over from 0.8; the identity service knows the others.
200
+ */
201
+ export function speakerPolicyOf(
202
+ rules: Pick<AccessRules, "owners" | "admins" | "members">,
203
+ provider = "discord",
204
+ ): SpeakerPolicy {
205
+ return {
206
+ resolve(author: SpeakerFacts) {
207
+ const facts: ActorFacts = {
208
+ provider,
209
+ subject: author.id,
210
+ name: author.name,
211
+ surface: provider,
212
+ roles: (author.roleIds ?? []).map((role) => `${provider}:role:${role}`),
213
+ };
214
+ const tier: Tier | undefined = ownerOfFacts(rules, facts)
215
+ ? "owner"
216
+ : factsTier(rules, facts);
217
+ return (
218
+ tier && {
219
+ id: author.id,
220
+ name: author.name,
221
+ tier,
222
+ principalId: author.id,
223
+ }
224
+ );
225
+ },
226
+ };
227
+ }
@@ -0,0 +1,65 @@
1
+ import { parseChannelKey } from "../contract/surface.ts";
2
+ import type { ChannelKey } from "../sessions.ts";
3
+ import type { IdentityRef } from "./principal-store.ts";
4
+
5
+ /**
6
+ * What a surface reports about the person behind a message or a connection; the identity
7
+ * service decides who they are and the tier they hold.
8
+ */
9
+ export interface ActorFacts {
10
+ /** The identity provider: `discord`, `oidc:<base64url issuer>`, `token`, … */
11
+ provider: string;
12
+ /** The person's id at the provider. */
13
+ subject: string;
14
+ /** The name to address them by on this contact. */
15
+ name: string;
16
+ /** The roles they hold, each prefixed with its surface: `discord:role:<id>`, `web:role:<name>`. */
17
+ roles?: readonly string[];
18
+ /**
19
+ * The surface they reached the host through, as `everyone` lists name it, such as `discord` or
20
+ * `web`; default the conversation's surface, then the provider up to its first colon.
21
+ */
22
+ surface?: string;
23
+ /** Where on the surface, such as a guild; informational. */
24
+ space?: string;
25
+ /**
26
+ * The speaker id 0.8 gave this person, which a principal carried over from 0.8 has as its id:
27
+ * the user id on Discord, `oidc:<issuer>:<subject>` on the web. At first contact it claims that
28
+ * principal. It is also the speaker's `id`, the id the surface knows them by.
29
+ */
30
+ legacyId?: string;
31
+ }
32
+
33
+ /** An identity as configuration and the CLI write it: `<provider>:<subject>`. */
34
+ export function identityOf(ref: IdentityRef): string {
35
+ return `${ref.provider}:${ref.subject}`;
36
+ }
37
+
38
+ /**
39
+ * An identity string read back: the provider is the part before the first colon, and for `oidc`
40
+ * the issuer after it too, so `oidc:<issuer>:<subject>` is the 0.8 web speaker id. Undefined
41
+ * when a part is missing.
42
+ */
43
+ export function parseIdentity(identity: string): IdentityRef | undefined {
44
+ const first = identity.indexOf(":");
45
+ if (first <= 0) return undefined;
46
+ let cut = first;
47
+ if (identity.slice(0, first) === "oidc") {
48
+ cut = identity.indexOf(":", first + 1);
49
+ if (cut <= first + 1) return undefined;
50
+ }
51
+ const subject = identity.slice(cut + 1);
52
+ if (subject === "") return undefined;
53
+ return { provider: identity.slice(0, cut), subject };
54
+ }
55
+
56
+ /** The surface the facts came through: their own, the conversation's, or the provider's first part. */
57
+ export function surfaceOf(
58
+ facts: ActorFacts,
59
+ conversation?: ChannelKey,
60
+ ): string {
61
+ if (facts.surface) return facts.surface;
62
+ if (conversation) return parseChannelKey(conversation).surface;
63
+ const colon = facts.provider.indexOf(":");
64
+ return colon < 0 ? facts.provider : facts.provider.slice(0, colon);
65
+ }