pi-roundtable-webchat 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.2] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - File attachments. A person uploads a file to one of their conversations with `POST <path>/conversations/<id>/files?name=<file name>` (the body is the file, its `Content-Type` the type; answers 201 `{ file, name, contentType, size }`), then names it in a `send` frame's new `attachments` field. The turn runs with the files as the core's attachments: images reach the model, every file is listed under `## Attachments`, and `read_attachment` and a tool's `turn.attachment()` read them. An rrweb recording is an ordinary `application/json` file.
13
+ - `ready` carries `attachments: { maxBytes, perMessage, types }`, so a client shows an upload control only when the server takes files; the subprotocol stays `roundtable.webchat.v1`, and a client that sends no `attachments` sees no change.
14
+ - The error code `unknown_attachment` (a `send` named a file that is not waiting for the person in that conversation; the whole message is refused) and the REST errors `payload_too_large` (413), `unsupported_media_type` (415) and `too_many_uploads` (429).
15
+ - The limits `attachmentBytes` (10 MiB, at most the core's 25 MiB), `attachmentsPerMessage` (8), `uploadsPerHour` (60), `unsentUploadBytesPerPrincipal` (64 MiB), `attachmentTypes` (PNG, JPEG, WebP, GIF, JSON, plain text, PDF; an entry may end in `/*`) and `unsentUploadTtlMs` (24 hours). The first bytes of an image or PDF must match its type. An upload no message used is deleted after its time; a swept file, a file of another conversation or person, or a file a message already used is `unknown_attachment`.
16
+ - Nothing needs configuring. The plugin needs `context.attachments` from pi-roundtable 0.9.2 and refuses to set up without it, and a host that has no `dataDir` fails at startup with a message naming it.
17
+
18
+ ### Fixed
19
+
20
+ - The README named `>=0.8.0 <0.9.0` as the peer range of pi-roundtable; it is `>=0.9.0 <0.10.0`.
21
+
22
+ ## [0.9.1] - 2026-10-10
23
+
24
+ - Release in lockstep with pi-roundtable 0.9.1; no package-specific behavior changes.
25
+
8
26
  ## [0.9.0] - 2026-10-09
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -8,19 +8,20 @@ Source lives in [`packages/webchat`][source] in the pi-roundtable repository and
8
8
  [roundtable]: https://www.npmjs.com/package/pi-roundtable
9
9
  [source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/webchat
10
10
 
11
- The plugin adds four things to a host:
11
+ The plugin adds five things to a host:
12
12
 
13
13
  - a chat surface whose conversations have keys `web:<conversation>`;
14
14
  - the claim that runs each message as a turn of its conversation's persona, through `context.turns` and the host's runtime;
15
15
  - a REST API and a WebSocket under one path of the host's HTTP listener;
16
- - a durable private inbox for `notify`, with live `notice` frames and an authenticated REST inbox.
16
+ - a durable private inbox for `notify`, with live `notice` frames and an authenticated REST inbox;
17
+ - file uploads to a conversation (screenshots, a JSON recording, a PDF), which a message then references; see [Attachments](#attachments).
17
18
 
18
19
  It needs no Discord: a host whose `roundtable.config.ts` has no `discord` key and lists this plugin is a web-only assistant.
19
20
  `roundtable init --adapter web` creates such a project.
20
21
 
21
22
  ## Requirements
22
23
 
23
- - Bun 1.3 or later, and pi-roundtable `>=0.8.0 <0.9.0` as a peer dependency.
24
+ - Bun 1.3 or later, and pi-roundtable `>=0.9.0 <0.10.0` as a peer dependency. Uploading files needs `context.attachments`, which pi-roundtable added in 0.9.2: the plugin refuses to set up on an earlier core and names the version to install.
24
25
  - An HTTP listener on the host (`http` in the configuration), behind a reverse proxy that serves it over HTTPS.
25
26
  - An OpenID Connect provider that issues access tokens for this API, with signing keys published as a JWKS.
26
27
 
@@ -194,7 +195,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
194
195
  | Frame | Meaning |
195
196
  |---|---|
196
197
  | `{ type: "send", id, persona, text }` | Opens a new conversation of `persona` with this message. `id` is your reference, echoed by `accepted` or `error`. |
197
- | `{ type: "send", id, conversation, text }` | A message in one of your conversations. |
198
+ | `{ type: "send", id, conversation, text, attachments? }` | A message in one of your conversations. `attachments` lists files you [uploaded](#attachments) to this conversation, by the `file` each upload returned. |
198
199
  | `{ type: "stop", conversation }` | Stops the conversation's running turn. |
199
200
  | `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
200
201
  | `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
@@ -204,7 +205,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
204
205
 
205
206
  | Frame | Meaning |
206
207
  |---|---|
207
- | `{ type: "ready", protocol, speaker, personas, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), and when the token expires. Open prompts follow it. |
208
+ | `{ type: "ready", protocol, speaker, personas, attachments, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), and when the token expires. Open prompts follow it. |
208
209
  | `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
209
210
  | `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
210
211
  | `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
@@ -217,7 +218,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
217
218
  | `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
218
219
  | `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
219
220
 
220
- Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
221
+ Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
221
222
 
222
223
  Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
223
224
  The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
@@ -232,6 +233,7 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
232
233
  | `POST <path>/tickets` | 201 `{ ticket, expiresAt }`. |
233
234
  | `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
234
235
  | `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
236
+ | `POST <path>/conversations/<conversation>/files?name=<file name>` with the file's bytes as the body and its type as `Content-Type` | 201 `{ file, name, contentType, size }`: the file waits for your next message that names `file`. 400 `bad_request` (no name, or a name over 255 characters), 403 (not your conversation), 404 (unknown conversation), 413 `payload_too_large`, 415 `unsupported_media_type`, 429 `too_many_uploads`. See [Attachments](#attachments). |
235
237
  | `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
236
238
  | `GET <path>/notices?limit=50&before=<id>` | `{ notices: [{ id, text, createdAt, readAt }] }`: only your principal's entries on this surface, newest first, at most 100. Omit `before` for the first page; use its last id to fetch the next. |
237
239
  | `POST <path>/notices/<id>/read` | `{ notice }` with `readAt` set. Idempotent; unknown ids or another principal's notice return 404. |
@@ -243,6 +245,44 @@ List, read acknowledgements, pagination cursors and retention are isolated by su
243
245
  Offline inbox discovery requires a private conversation on this surface or a linked identity whose provider equals this surface; a generic `oidc:` link alone does not establish membership in every webchat inbox.
244
246
  A REST notice page therefore contains at most 100 bounded entries, less than 2.5 MiB at the default text cap.
245
247
 
248
+ ## Attachments
249
+
250
+ A person sends a file in two steps, because the socket carries JSON text only: they upload it over REST to a conversation they own, then name it in a `send` frame.
251
+ Open the conversation first (`POST <path>/conversations`, or an earlier message), since a message that opens a conversation cannot reference files that have no conversation to wait in.
252
+ `ready.attachments` tells the client the limits (`maxBytes`, `perMessage`, `types`) before it shows an upload control.
253
+
254
+ ```js
255
+ const open = await fetch("/chat/conversations", {
256
+ method: "POST",
257
+ headers: { authorization: `Bearer ${token}` },
258
+ body: JSON.stringify({ persona: "helpdesk" }),
259
+ });
260
+ const { conversation } = await open.json();
261
+ const response = await fetch(`/chat/conversations/${conversation}/files?name=${encodeURIComponent(file.name)}`, {
262
+ method: "POST",
263
+ headers: { authorization: `Bearer ${token}`, "content-type": file.type },
264
+ body: file,
265
+ });
266
+ const { file: id } = await response.json();
267
+ socket.send(JSON.stringify({ type: "send", id: "2", conversation, text: "Why did this fail?", attachments: [id] }));
268
+ ```
269
+
270
+ What the server checks, in this order: the conversation is the caller's (403, 404); the file name is present and at most 255 characters, with control characters replaced and quotes made apostrophes so a name cannot add lines to the model's prompt (400); the `Content-Type` is one of `limits.attachmentTypes` (415); the person has not made `uploadsPerHour` uploads in the last hour (429); the file is within `attachmentBytes`, by `Content-Length` and again while the body streams, so a body that lies about its length is cut off (413); the person's uploads waiting for a message stay within `unsentUploadBytesPerPrincipal` (429); and for PNG, JPEG, GIF, WebP and PDF the first bytes match the type (415), whatever the file name says.
271
+ JSON, plain text and other types have no such check, since any bytes may be labelled so.
272
+ The default types are `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain` and `application/pdf`; an entry ending in `/*` admits every type under it.
273
+ An rrweb recording is an ordinary `application/json` file.
274
+
275
+ A `send` that names files runs the turn with them: the core moves each file into the conversation's attachment directory, shows up to four images to the model, lists every file under `## Attachments` in the turn's prompt, and lets the model read text, JSON and PDF files with `read_attachment`.
276
+ A plugin's tool passes a file on with `turn.attachment(file)`, which returns the bytes (see the core's [plugin guide](https://github.com/wayne930242/pi-roundtable/blob/master/docs/plugins.md)).
277
+ A message may name at most `attachmentsPerMessage` files, a file belongs to one message, and a message that names a file that is not waiting for the person is refused whole with `unknown_attachment`.
278
+
279
+ An upload no message used is deleted after `unsentUploadTtlMs` (24 hours): the plugin sweeps every ten minutes, and once when the host starts.
280
+ Files a message used stay with their conversation, in the host's data directory, as long as the conversation does.
281
+ This plugin has no way to delete a conversation, so none of its files go with it.
282
+
283
+ Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
284
+ Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
285
+
246
286
  ## Security model
247
287
 
248
288
  - **Private conversations.** A conversation belongs to the person who opened it.
@@ -258,6 +298,9 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
258
298
  Each person has at most `turnsPerPrincipal` interactive turns running or queued at once, however many conversations or sockets they use, and a conversation at most its running interactive turn and one queued behind it; a browser message over either limit is refused with `busy` and never queued.
259
299
  Personal schedules and delegated reports use the core runtime's conversation queue separately from this interactive admission budget, so a busy browser cannot discard them; runtime stop and shutdown still apply.
260
300
  Each person opens at most `newConversationsPerHour` conversations an hour, over the socket or the REST API alike.
301
+ - **Uploads.** Only a conversation's own person may upload to it, a file waits for that person and conversation alone, and a message names it by an id nobody can guess.
302
+ Type, size, rate and the waiting total are limited per person, and the bytes of an image or PDF are checked against the type.
303
+ See [Attachments](#attachments).
261
304
  - **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
262
305
  A card whose tier the person lacks is never shown: its prompt resolves `expired`, without owner escalation.
263
306
  - **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
@@ -279,10 +322,15 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
279
322
  | `rate` | 60 frames a minute per socket |
280
323
  | `maxBufferedBytes` | 4 MiB waiting for a slow client |
281
324
  | `ticketTtlMs` | 30 seconds |
325
+ | `attachmentBytes` | 10 MiB per uploaded file; never above the core's 25 MiB |
326
+ | `attachmentsPerMessage` | 8 files named by one message |
327
+ | `uploadsPerHour` | 60 uploads per person in any hour |
328
+ | `unsentUploadBytesPerPrincipal` | 64 MiB per person uploaded and not yet used by a message |
329
+ | `attachmentTypes` | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain`, `application/pdf` |
330
+ | `unsentUploadTtlMs` | 24 hours before an unused upload is deleted |
282
331
 
283
332
  ## What it does not do yet
284
333
 
285
- - Attachments: messages carry text only.
286
334
  - System error reports into a person's web conversation: the background claim accepts only `PERSONAL_TARGET` turns checked by core, whose author principal is the private conversation's principal.
287
335
  The claim declares `takesSystemReports: false`: configuring `ops.conversation` on this surface (for example, `web:ops`) fails at startup with core `ConfigError`, rather than silently skipping every report.
288
336
  Use a shared conversation on another surface, such as a Discord channel, or `ops.agent`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-webchat",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "@earendil-works/pi-ai": ">=1.0.0 <2",
44
44
  "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
45
45
  "@types/bun": "1.4.2",
46
- "pi-roundtable": "0.9.0",
46
+ "pi-roundtable": "0.9.2",
47
47
  "typebox": "1.3.34",
48
48
  "typescript": "7.0.2"
49
49
  }
package/src/chat.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  type AgentRuntime,
3
+ type AttachmentPort,
4
+ AttachmentRefusal,
3
5
  type ChannelClaim,
4
6
  type ChannelKey,
5
7
  type ConversationPort,
@@ -16,6 +18,7 @@ import {
16
18
  type Tier,
17
19
  type ToolSelection,
18
20
  type TranscriptEntry,
21
+ type TurnAttachments,
19
22
  type TurnResult,
20
23
  } from "pi-roundtable";
21
24
  import { RateWindow, TurnBudget } from "./budget.ts";
@@ -37,6 +40,7 @@ import {
37
40
  WEBCHAT_PROTOCOL_VERSION,
38
41
  } from "./protocol.ts";
39
42
  import { WebSurface } from "./surface.ts";
43
+ import { type UploadedFile, type UploadLimits, Uploads } from "./uploads.ts";
40
44
 
41
45
  /** A conversation kind a client may open. */
42
46
  export interface WebPersona {
@@ -55,8 +59,8 @@ export interface WebPersona {
55
59
  selection?: ToolSelection;
56
60
  }
57
61
 
58
- /** The limits of one web chat. */
59
- export interface WebChatLimits {
62
+ /** The limits of one web chat, those of uploads and attachments included. */
63
+ export interface WebChatLimits extends UploadLimits {
60
64
  /** Connections one person may hold open at once; default 5. */
61
65
  connectionsPerPrincipal: number;
62
66
  /** New conversations one person may hold before writing in them; default 20. */
@@ -86,6 +90,8 @@ export interface WebChatDeps {
86
90
  /** Read when used, after the host linked every plugin. */
87
91
  registry(): ConversationRegistry;
88
92
  conversations(): ConversationPort;
93
+ /** Where uploaded files wait for the message that uses them. */
94
+ attachments(): AttachmentPort;
89
95
  turns(): ConversationTurns;
90
96
  runtime(): AgentRuntime;
91
97
  /** The clock, in milliseconds; default `Date.now`. */
@@ -108,6 +114,8 @@ interface Pending {
108
114
  speaker: Speaker;
109
115
  persona: WebPersona;
110
116
  title: string;
117
+ /** The files the message references, moved into the conversation when it was accepted. */
118
+ attachments?: TurnAttachments;
111
119
  /** Whether the conversation was new when the message was accepted. */
112
120
  fresh: boolean;
113
121
  /** Frees the message's place in the person's turn budget; called once its turn ends or never runs. */
@@ -181,6 +189,7 @@ export function checkPersonas(
181
189
  */
182
190
  export class WebChat {
183
191
  readonly surface: WebSurface;
192
+ readonly uploads: Uploads;
184
193
  readonly connections: Connections;
185
194
  readonly desk: PromptDesk;
186
195
  readonly #deps: WebChatDeps;
@@ -200,6 +209,11 @@ export class WebChat {
200
209
 
201
210
  constructor(deps: WebChatDeps) {
202
211
  this.#deps = deps;
212
+ this.uploads = new Uploads({
213
+ limits: deps.limits,
214
+ attachments: deps.attachments,
215
+ ...(deps.now ? { now: deps.now } : {}),
216
+ });
203
217
  this.#personas = checkPersonas(deps.personas);
204
218
  this.#turns = new TurnBudget(deps.limits.turnsPerPrincipal);
205
219
  this.#opened = new RateWindow(
@@ -319,6 +333,26 @@ export class WebChat {
319
333
  return { persona, minted };
320
334
  }
321
335
 
336
+ /**
337
+ * Keeps the request's body as a file for one of the speaker's conversations until a message
338
+ * uses it; a Refusal when the conversation is not theirs, an UploadRefusal when the file is not
339
+ * accepted.
340
+ */
341
+ async upload(
342
+ speaker: Speaker,
343
+ conversation: string,
344
+ request: Request,
345
+ name: string | null,
346
+ ): Promise<UploadedFile> {
347
+ await this.own(speaker, conversation);
348
+ return this.uploads.receive(
349
+ request,
350
+ channelKey(this.#deps.surface, conversation),
351
+ speaker.principalId,
352
+ name,
353
+ );
354
+ }
355
+
322
356
  /** The speaker's web conversations, the most recently active first. */
323
357
  async list(speaker: Speaker): Promise<ConversationRecord[]> {
324
358
  const records = await this.#deps
@@ -361,6 +395,7 @@ export class WebChat {
361
395
  protocol: WEBCHAT_PROTOCOL_VERSION,
362
396
  speaker: { ...speaker },
363
397
  personas: this.personasFor(speaker),
398
+ attachments: this.uploads.advertised,
364
399
  expiresAt: identity.expiresAt.toISOString(),
365
400
  });
366
401
  for (const frame of this.desk.openFor(speaker.principalId))
@@ -520,6 +555,9 @@ export class WebChat {
520
555
  const text = frame.text;
521
556
  if (!text.trim() || text.length > this.#deps.limits.messageChars)
522
557
  throw new Refusal("bad_frame");
558
+ const files = frame.attachments ?? [];
559
+ if (files.length > this.#deps.limits.attachmentsPerMessage)
560
+ throw new Refusal("bad_frame");
523
561
  // Checked before a conversation is opened for it, so a refused message opens none.
524
562
  if (!this.#turns.allows(speaker.principalId, frame.conversation))
525
563
  throw new Refusal("busy");
@@ -532,12 +570,33 @@ export class WebChat {
532
570
  if (!frame.conversation) this.#minted.delete(conversation);
533
571
  throw new Refusal("busy");
534
572
  }
573
+ let attachments: TurnAttachments | undefined;
574
+ if (files.length > 0) {
575
+ try {
576
+ attachments = await this.#deps
577
+ .attachments()
578
+ .turnAttachments(
579
+ channelKey(this.#deps.surface, conversation),
580
+ speaker.principalId,
581
+ files,
582
+ );
583
+ } catch (error) {
584
+ // The refused message holds no place and opens no conversation.
585
+ release();
586
+ if (!frame.conversation) this.#minted.delete(conversation);
587
+ if (!(error instanceof AttachmentRefusal)) throw error;
588
+ throw new Refusal(
589
+ error.code === "unknown_file" ? "unknown_attachment" : "forbidden",
590
+ );
591
+ }
592
+ }
535
593
  const messageId = crypto.randomUUID();
536
594
  this.#pending.set(messageId, {
537
595
  speaker,
538
596
  persona,
539
597
  title: minted?.title ?? titleOf(text),
540
598
  fresh: record === undefined,
599
+ ...(attachments ? { attachments } : {}),
541
600
  release,
542
601
  });
543
602
  this.connections.send(connection, {
@@ -740,6 +799,7 @@ export class WebChat {
740
799
  text,
741
800
  speaker,
742
801
  interactive: true,
802
+ ...(pending.attachments ? { attachments: pending.attachments } : {}),
743
803
  ...(persona.selection
744
804
  ? {
745
805
  selection: {
@@ -0,0 +1,50 @@
1
+ /** The content type without parameters, lower case; undefined for a missing or empty header. */
2
+ export function baseType(header: string | null): string | undefined {
3
+ const type = header?.split(";")[0]?.trim().toLowerCase();
4
+ return type ? type : undefined;
5
+ }
6
+
7
+ /** Whether `type` is one of `allowed`; an entry ending in `/*` admits every type under it. */
8
+ export function isAllowedType(
9
+ allowed: readonly string[],
10
+ type: string,
11
+ ): boolean {
12
+ return allowed.some((entry) =>
13
+ entry.endsWith("/*")
14
+ ? type.startsWith(entry.slice(0, -1)) && type.length > entry.length - 1
15
+ : entry === type,
16
+ );
17
+ }
18
+
19
+ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
20
+ signature.every((byte, index) => bytes[at + index] === byte);
21
+
22
+ const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
23
+
24
+ /**
25
+ * Whether the bytes are what the type says, for the types whose first bytes say so: the four
26
+ * image formats the model reads, and PDF. Any other type has nothing to check and passes.
27
+ */
28
+ export function bytesMatchType(type: string, bytes: Uint8Array): boolean {
29
+ switch (type) {
30
+ case "image/png":
31
+ return startsWith(
32
+ bytes,
33
+ [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
34
+ );
35
+ case "image/jpeg":
36
+ return startsWith(bytes, [0xff, 0xd8, 0xff]);
37
+ case "image/gif":
38
+ return (
39
+ startsWith(bytes, ascii("GIF87a")) || startsWith(bytes, ascii("GIF89a"))
40
+ );
41
+ case "image/webp":
42
+ return (
43
+ startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8)
44
+ );
45
+ case "application/pdf":
46
+ return startsWith(bytes, ascii("%PDF-"));
47
+ default:
48
+ return true;
49
+ }
50
+ }
package/src/plugin.ts CHANGED
@@ -24,6 +24,7 @@ import { TokenRefused, type TokenVerifier } from "./oidc.ts";
24
24
  import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
25
25
  import { restHandler } from "./rest.ts";
26
26
  import { TicketBook } from "./tickets.ts";
27
+ import { DEFAULT_ATTACHMENT_TYPES } from "./uploads.ts";
27
28
 
28
29
  /** Limits of the WebSocket route, besides the chat's own. */
29
30
  export interface WebChatRouteLimits {
@@ -68,6 +69,12 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
68
69
  messageChars: 32_000,
69
70
  promptTimeoutMs: 30 * 60_000,
70
71
  reauthLeadMs: 60_000,
72
+ attachmentBytes: 10 * 1024 * 1024,
73
+ attachmentsPerMessage: 8,
74
+ uploadsPerHour: 60,
75
+ unsentUploadBytesPerPrincipal: 64 * 1024 * 1024,
76
+ attachmentTypes: DEFAULT_ATTACHMENT_TYPES,
77
+ unsentUploadTtlMs: 24 * 60 * 60_000,
71
78
  maxConnections: 256,
72
79
  maxMessageBytes: 64 * 1024,
73
80
  rate: { messages: 60, perMs: 60_000 },
@@ -100,6 +107,41 @@ function checkOptions(options: WebChatOptions): {
100
107
  return { path, surface };
101
108
  }
102
109
 
110
+ /** The core keeps no file over this; a web chat's own limit may be lower, never higher. */
111
+ const CORE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
112
+ /** The longest wait between two sweeps of uploads no message used. */
113
+ const SWEEP_EVERY_MS = 10 * 60_000;
114
+
115
+ /** Refuses an attachment limit that is not a positive whole number, or a size the core cannot keep. */
116
+ function checkAttachmentLimits(limits: WebChatLimits): void {
117
+ for (const key of [
118
+ "attachmentBytes",
119
+ "attachmentsPerMessage",
120
+ "uploadsPerHour",
121
+ "unsentUploadBytesPerPrincipal",
122
+ "unsentUploadTtlMs",
123
+ ] as const)
124
+ if (!Number.isInteger(limits[key]) || limits[key] < 1)
125
+ throw new Error(
126
+ `webChat: limits.${key} must be a positive whole number; got ${String(limits[key])}`,
127
+ );
128
+ if (limits.attachmentBytes > CORE_ATTACHMENT_BYTES)
129
+ throw new Error(
130
+ `webChat: limits.attachmentBytes may be at most ${CORE_ATTACHMENT_BYTES} (25 MiB), the most the core keeps; got ${limits.attachmentBytes}`,
131
+ );
132
+ if (
133
+ !Array.isArray(limits.attachmentTypes) ||
134
+ limits.attachmentTypes.some(
135
+ (type) =>
136
+ typeof type !== "string" ||
137
+ !/^[a-z0-9.+-]+\/(\*|[a-z0-9.+-]+)$/.test(type),
138
+ )
139
+ )
140
+ throw new Error(
141
+ 'webChat: limits.attachmentTypes must list content types in lower case, such as "image/png" or "image/*"',
142
+ );
143
+ }
144
+
103
145
  /** The subprotocols a WebSocket upgrade offers. */
104
146
  function offered(request: Request): string[] {
105
147
  return (request.headers.get("sec-websocket-protocol") ?? "")
@@ -122,11 +164,16 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
122
164
  const limits = { ...DEFAULT_LIMITS, ...options.limits };
123
165
  // The personas are checked now, so a broken list stops the configuration rather than the boot.
124
166
  checkPersonas(options.personas);
167
+ checkAttachmentLimits(limits);
125
168
  return definePlugin({
126
169
  name: surface === "web" ? "webchat" : `webchat-${surface}`,
127
170
  requires: [IDENTITY, CONVERSATIONS, RUNTIME],
128
171
  migrations: [PgNotices.migration],
129
172
  setup: (context: PluginContext) => {
173
+ if (!context.attachments)
174
+ throw new Error(
175
+ "webChat: this version keeps uploaded files with context.attachments, which pi-roundtable 0.9.2 added. Update pi-roundtable to 0.9.2 or later.",
176
+ );
130
177
  const chat = new WebChat({
131
178
  surface,
132
179
  verifier: options.verifier,
@@ -136,6 +183,7 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
136
183
  logger: context.logger,
137
184
  registry: () => context.services.get(CONVERSATIONS),
138
185
  conversations: () => context.conversations,
186
+ attachments: () => context.attachments,
139
187
  turns: () => context.turns,
140
188
  runtime: () => context.services.get(RUNTIME),
141
189
  });
@@ -216,7 +264,41 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
216
264
  handle: rest,
217
265
  websocket,
218
266
  };
267
+ // Uploads no message used are deleted once they are older than the limit's time.
268
+ let sweeper: ReturnType<typeof setInterval> | undefined;
269
+ const sweep = async () => {
270
+ try {
271
+ const dropped = await chat.uploads.sweep();
272
+ if (dropped > 0)
273
+ context.logger.info({ dropped }, "unused web chat uploads deleted");
274
+ } catch (error) {
275
+ context.logger.warn(
276
+ { err: error },
277
+ "unused web chat uploads not deleted",
278
+ );
279
+ }
280
+ };
219
281
  return {
282
+ services: [
283
+ {
284
+ name: `${surface}-uploads`,
285
+ start: async () => {
286
+ // Not caught: a host without a `dataDir` stops here, naming it, instead of failing at the first upload.
287
+ await chat.uploads.sweep();
288
+ sweeper = setInterval(
289
+ sweep,
290
+ Math.min(
291
+ SWEEP_EVERY_MS,
292
+ Math.max(limits.unsentUploadTtlMs, 100),
293
+ ),
294
+ );
295
+ },
296
+ stop: () => {
297
+ clearInterval(sweeper);
298
+ sweeper = undefined;
299
+ },
300
+ },
301
+ ],
220
302
  surfaces: [chat.surface],
221
303
  channels: [chat.claim()],
222
304
  directChannels: [
package/src/protocol.ts CHANGED
@@ -65,7 +65,9 @@ export type ErrorCode =
65
65
  | "unknown_persona"
66
66
  | "unknown_prompt"
67
67
  | "too_many_conversations"
68
- | "busy";
68
+ | "busy"
69
+ /** A `send` named a file that is not waiting for this person in this conversation; the whole message is refused. */
70
+ | "unknown_attachment";
69
71
 
70
72
  /** What a client sends: one JSON object per WebSocket text message. */
71
73
  export type ClientFrame =
@@ -81,6 +83,11 @@ export type ClientFrame =
81
83
  conversation?: string;
82
84
  persona?: string;
83
85
  text: string;
86
+ /**
87
+ * Files uploaded to the conversation (`POST <path>/conversations/<id>/files`) for this
88
+ * message, by the `file` each upload returned; at most `ready.attachments.perMessage`.
89
+ */
90
+ attachments?: string[];
84
91
  }
85
92
  /** Stops the conversation's running turn. */
86
93
  | { type: "stop"; conversation: string }
@@ -97,6 +104,16 @@ export type ServerFrame =
97
104
  protocol: typeof WEBCHAT_PROTOCOL_VERSION;
98
105
  speaker: { id: string; name: string; tier: Tier; principalId: string };
99
106
  personas: readonly PersonaSummary[];
107
+ /**
108
+ * What an upload may be: the most bytes in a file, the most files in a message, and the
109
+ * content types accepted, each exactly or as `<type>/*`. Absent from a server before 0.9.2,
110
+ * which takes no attachments: show no upload control then.
111
+ */
112
+ attachments: {
113
+ maxBytes: number;
114
+ perMessage: number;
115
+ types: readonly string[];
116
+ };
100
117
  /** When the token expires, as an ISO time. */
101
118
  expiresAt: string;
102
119
  }
@@ -135,6 +152,10 @@ export type ServerFrame =
135
152
 
136
153
  /** The longest client reference, conversation id, or prompt id accepted. */
137
154
  const ID_CHARS = 128;
155
+ /** The longest uploaded file reference accepted: a 36-character id, a dash, and a name cut to 120. */
156
+ const FILE_ID_CHARS = 200;
157
+ /** The most file references one frame may carry, whatever the server's own limit is. */
158
+ const MAX_FRAME_FILES = 32;
138
159
 
139
160
  const isRecord = (value: unknown): value is Record<string, unknown> =>
140
161
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -142,6 +163,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
142
163
  const isId = (value: unknown): value is string =>
143
164
  typeof value === "string" && value.length > 0 && value.length <= ID_CHARS;
144
165
 
166
+ const isFileList = (value: unknown): value is string[] =>
167
+ Array.isArray(value) &&
168
+ value.length <= MAX_FRAME_FILES &&
169
+ value.every(
170
+ (item) =>
171
+ typeof item === "string" &&
172
+ item.length > 0 &&
173
+ item.length <= FILE_ID_CHARS,
174
+ );
175
+
145
176
  const optional = <T>(value: unknown, check: (v: unknown) => v is T) =>
146
177
  value === undefined || check(value);
147
178
 
@@ -169,12 +200,17 @@ export function parseClientFrame(
169
200
  if (!optional(conversation, isId) || !optional(persona, isId))
170
201
  return undefined;
171
202
  if (conversation === undefined && persona === undefined) return undefined;
203
+ if (!optional(value.attachments, isFileList)) return undefined;
204
+ const { attachments } = value;
172
205
  return {
173
206
  type: "send",
174
207
  id,
175
208
  text,
176
209
  ...(conversation === undefined ? {} : { conversation }),
177
210
  ...(persona === undefined ? {} : { persona }),
211
+ ...(attachments === undefined || attachments.length === 0
212
+ ? {}
213
+ : { attachments }),
178
214
  };
179
215
  }
180
216
  case "stop":
package/src/rest.ts CHANGED
@@ -3,6 +3,7 @@ import { type Admitted, Refusal, type WebChat } from "./chat.ts";
3
3
  import { MAX_NOTICES, type PgNotices } from "./notices.ts";
4
4
  import { TokenRefused } from "./oidc.ts";
5
5
  import type { TicketBook } from "./tickets.ts";
6
+ import { UploadRefusal } from "./uploads.ts";
6
7
 
7
8
  export interface RestOptions {
8
9
  chat: WebChat;
@@ -62,13 +63,17 @@ const STATUS: Record<string, number> = {
62
63
  unknown_conversation: 404,
63
64
  unknown_persona: 404,
64
65
  too_many_conversations: 429,
66
+ unknown_attachment: 400,
65
67
  };
66
68
 
67
69
  /**
68
70
  * The web chat's REST API, under its path, every call with `Authorization: Bearer <token>`:
69
71
  * `POST tickets` (a one-time ticket for the WebSocket), `GET conversations` (the caller's own),
70
72
  * `POST conversations` (`{ persona, title? }` opens one), and `GET conversations/<id>/messages`
71
- * (`?limit=`, its last messages). A browser on another origin gets CORS headers when its origin
73
+ * (`?limit=`, its last messages), and `POST conversations/<id>/files?name=<file name>` (the body is
74
+ * the file's bytes, its `Content-Type` the file's type; answers 201 with `{ file, name, contentType,
75
+ * size }`, 413 over the size limit, 415 for a type that is not accepted, 429 over the person's
76
+ * allowance). A browser on another origin gets CORS headers when its origin
72
77
  * is allowed and 403 otherwise.
73
78
  */
74
79
  export function restHandler(options: RestOptions) {
@@ -143,6 +148,16 @@ export function restHandler(options: RestOptions) {
143
148
  const entries = await chat.transcript(speaker, messages[1], limit);
144
149
  return json({ messages: entries }, 200, headers);
145
150
  }
151
+ const files = /^conversations\/([A-Za-z0-9-]{1,128})\/files$/.exec(rest);
152
+ if (files?.[1] && request.method === "POST") {
153
+ const uploaded = await chat.upload(
154
+ speaker,
155
+ files[1],
156
+ request,
157
+ url.searchParams.get("name"),
158
+ );
159
+ return json(uploaded, 201, headers);
160
+ }
146
161
  return json({ error: "not_found" }, 404, headers);
147
162
  }
148
163
  return async (request: Request): Promise<Response> => {
@@ -179,6 +194,8 @@ export function restHandler(options: RestOptions) {
179
194
  try {
180
195
  return await route(request, url, who, headers);
181
196
  } catch (error) {
197
+ if (error instanceof UploadRefusal)
198
+ return json({ error: error.code }, error.status, headers);
182
199
  if (!(error instanceof Refusal)) throw error;
183
200
  return json({ error: error.code }, STATUS[error.code] ?? 400, headers);
184
201
  }
package/src/uploads.ts ADDED
@@ -0,0 +1,198 @@
1
+ import type { AttachmentPort, ChannelKey } from "pi-roundtable";
2
+ import { RateWindow } from "./budget.ts";
3
+ import { baseType, bytesMatchType, isAllowedType } from "./file-types.ts";
4
+
5
+ /** The types a web chat takes unless its `limits.attachmentTypes` says otherwise. */
6
+ export const DEFAULT_ATTACHMENT_TYPES: readonly string[] = [
7
+ "image/png",
8
+ "image/jpeg",
9
+ "image/webp",
10
+ "image/gif",
11
+ "application/json",
12
+ "text/plain",
13
+ "application/pdf",
14
+ ];
15
+
16
+ /** The limits of uploads and of the attachments a message carries. */
17
+ export interface UploadLimits {
18
+ /** The largest file in bytes; default 10 MiB, and never over the core's 25 MiB. */
19
+ attachmentBytes: number;
20
+ /** The most files one message may reference; default 8. */
21
+ attachmentsPerMessage: number;
22
+ /** Uploads one person may make in any hour; default 60. */
23
+ uploadsPerHour: number;
24
+ /** The bytes one person may have uploaded and not yet sent in a message; default 64 MiB. */
25
+ unsentUploadBytesPerPrincipal: number;
26
+ /** The content types accepted, such as `image/png`; an entry ending in `/*` admits every type under it. */
27
+ attachmentTypes: readonly string[];
28
+ /** How long an upload no message referenced is kept before it is deleted; default 24 hours. */
29
+ unsentUploadTtlMs: number;
30
+ }
31
+
32
+ /** Why an upload was refused, as the HTTP status and error code the client reads. */
33
+ export class UploadRefusal extends Error {
34
+ override name = "UploadRefusal";
35
+ readonly status: number;
36
+ readonly code:
37
+ | "bad_request"
38
+ | "payload_too_large"
39
+ | "unsupported_media_type"
40
+ | "too_many_uploads";
41
+
42
+ constructor(status: number, code: UploadRefusal["code"]) {
43
+ super(code);
44
+ this.status = status;
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ /** What an upload returns: `file` is what a `send` frame's `attachments` names. */
50
+ export interface UploadedFile {
51
+ file: string;
52
+ name: string;
53
+ contentType: string;
54
+ size: number;
55
+ }
56
+
57
+ export interface UploadsDeps {
58
+ limits: UploadLimits;
59
+ /** Read when used, after the host linked every plugin. */
60
+ attachments(): AttachmentPort;
61
+ /** The clock, in milliseconds; default `Date.now`. */
62
+ now?(): number;
63
+ }
64
+
65
+ const HOUR_MS = 60 * 60_000;
66
+ /** The longest file name accepted, in characters. */
67
+ const NAME_CHARS = 255;
68
+
69
+ /**
70
+ * A name the model can read in its prompt: control characters become underscores and quotes
71
+ * become apostrophes, so a name cannot add lines or close the quotes around it.
72
+ */
73
+ function safeName(name: string | null): string {
74
+ const cleaned = (name ?? "")
75
+ .replace(/\p{Cc}/gu, "_")
76
+ .replaceAll('"', "'")
77
+ .trim();
78
+ if (cleaned === "" || cleaned.length > NAME_CHARS)
79
+ throw new UploadRefusal(400, "bad_request");
80
+ return cleaned;
81
+ }
82
+
83
+ /** The request body, or undefined once it passes `max` bytes; the rest of the stream is not read. */
84
+ async function readLimited(
85
+ request: Request,
86
+ max: number,
87
+ ): Promise<Uint8Array | undefined> {
88
+ const reader = request.body?.getReader();
89
+ if (!reader) return new Uint8Array();
90
+ const chunks: Uint8Array[] = [];
91
+ let size = 0;
92
+ try {
93
+ for (;;) {
94
+ const { done, value } = await reader.read();
95
+ if (done) break;
96
+ size += value.byteLength;
97
+ if (size > max) {
98
+ await reader.cancel();
99
+ return undefined;
100
+ }
101
+ chunks.push(value);
102
+ }
103
+ } finally {
104
+ reader.releaseLock();
105
+ }
106
+ const bytes = new Uint8Array(size);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ bytes.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ return bytes;
113
+ }
114
+
115
+ /**
116
+ * The files people upload to their conversations before they send a message: it checks the
117
+ * content type, the bytes, the size and the person's allowance, and keeps the file with the
118
+ * core's attachment port until a message uses it. Who may upload to which conversation is the
119
+ * chat's to decide before it calls `receive`.
120
+ */
121
+ export class Uploads {
122
+ readonly #deps: UploadsDeps;
123
+ readonly #rate: RateWindow;
124
+
125
+ constructor(deps: UploadsDeps) {
126
+ this.#deps = deps;
127
+ this.#rate = new RateWindow(
128
+ deps.limits.uploadsPerHour,
129
+ HOUR_MS,
130
+ deps.now ?? Date.now,
131
+ );
132
+ }
133
+
134
+ /** What `ready` tells a client about attachments. */
135
+ get advertised(): {
136
+ maxBytes: number;
137
+ perMessage: number;
138
+ types: readonly string[];
139
+ } {
140
+ const { limits } = this.#deps;
141
+ return {
142
+ maxBytes: limits.attachmentBytes,
143
+ perMessage: limits.attachmentsPerMessage,
144
+ types: limits.attachmentTypes,
145
+ };
146
+ }
147
+
148
+ /** Takes the request's body as a file for `principalId` in `channel`; throws an UploadRefusal. */
149
+ async receive(
150
+ request: Request,
151
+ channel: ChannelKey,
152
+ principalId: string,
153
+ name: string | null,
154
+ ): Promise<UploadedFile> {
155
+ const { limits } = this.#deps;
156
+ const fileName = safeName(name);
157
+ const type = baseType(request.headers.get("content-type"));
158
+ if (!type || !isAllowedType(limits.attachmentTypes, type))
159
+ throw new UploadRefusal(415, "unsupported_media_type");
160
+ const declared = Number(request.headers.get("content-length"));
161
+ if (Number.isFinite(declared) && declared > limits.attachmentBytes)
162
+ throw new UploadRefusal(413, "payload_too_large");
163
+ if (!this.#rate.take(principalId))
164
+ throw new UploadRefusal(429, "too_many_uploads");
165
+ const attachments = this.#deps.attachments();
166
+ const waiting = await attachments.pendingBytes(principalId);
167
+ const room = limits.unsentUploadBytesPerPrincipal - waiting;
168
+ const bytes = await readLimited(
169
+ request,
170
+ Math.min(limits.attachmentBytes, Math.max(room, 0)),
171
+ );
172
+ if (!bytes)
173
+ throw room < limits.attachmentBytes
174
+ ? new UploadRefusal(429, "too_many_uploads")
175
+ : new UploadRefusal(413, "payload_too_large");
176
+ if (!bytesMatchType(type, bytes))
177
+ throw new UploadRefusal(415, "unsupported_media_type");
178
+ const stored = await attachments.save(channel, principalId, {
179
+ name: fileName,
180
+ contentType: type,
181
+ data: bytes,
182
+ });
183
+ return {
184
+ file: stored.file,
185
+ name: stored.name,
186
+ contentType: stored.contentType,
187
+ size: stored.size,
188
+ };
189
+ }
190
+
191
+ /** Deletes the uploads no message used within the time to live; returns how many. */
192
+ sweep(): Promise<number> {
193
+ const now = (this.#deps.now ?? Date.now)();
194
+ return this.#deps
195
+ .attachments()
196
+ .discardPending(new Date(now - this.#deps.limits.unsentUploadTtlMs));
197
+ }
198
+ }