pi-roundtable-web 0.22.5 → 0.23.1
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 +53 -1
- package/README.md +444 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-660d2t6p.js +1 -0
- package/dist/chunk-connectors-d19v8cjc.js +1 -0
- package/dist/chunk-conversations-4cmexmqq.js +1 -0
- package/dist/chunk-entry-8xpx35ef.js +1 -0
- package/dist/chunk-index-19g8jmr8.js +2 -0
- package/dist/chunk-index-362dfedj.js +1 -0
- package/dist/chunk-index-6g69tqt0.js +47 -0
- package/dist/chunk-index-89q2qdkj.css +1 -0
- package/dist/chunk-index-9tvf6es7.js +41 -0
- package/dist/chunk-index-p8h0e861.js +2 -0
- package/dist/chunk-index-xw5hhmfq.js +2 -0
- package/dist/chunk-notes-fefwds14.js +1 -0
- package/dist/chunk-overview-bxpwpmn6.js +1 -0
- package/dist/chunk-skills-pce3zgxy.js +1 -0
- package/dist/chunk-table-zmpbrfvz.js +1 -0
- package/dist/chunk-table2d-ac2gez71.js +1 -0
- package/dist/chunk-table3d-g258jemr.js +4229 -0
- package/dist/chunk-transcript-yhtjp3pd.js +1 -0
- package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/features.ts +6 -1
- package/src/http.ts +8 -0
- package/src/i18n/index.ts +13 -0
- package/src/i18n/locale.ts +11 -0
- package/src/i18n/zh-tw.ts +696 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- package/dist/chunk-e8z9q554.css +0 -1
package/src/app/files.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { open } from "node:fs/promises";
|
|
13
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
14
|
+
import type { RoomFile } from "../api-types.ts";
|
|
15
|
+
|
|
16
|
+
/** A stored file's id: the uuid the store made. */
|
|
17
|
+
const FILE_ID =
|
|
18
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
19
|
+
|
|
20
|
+
/** The image types shown in the page itself; every other file is a download. */
|
|
21
|
+
const INLINE_TYPES = new Set([
|
|
22
|
+
"image/png",
|
|
23
|
+
"image/jpeg",
|
|
24
|
+
"image/webp",
|
|
25
|
+
"image/gif",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** How long a reply's or a sent message's file stays: 30 days. */
|
|
29
|
+
export const FILE_RETENTION_MS = 30 * 24 * 3_600_000;
|
|
30
|
+
|
|
31
|
+
/** The types a file name suggests, for files an agent's turn made. */
|
|
32
|
+
const BY_EXTENSION: Record<string, string> = {
|
|
33
|
+
".png": "image/png",
|
|
34
|
+
".jpg": "image/jpeg",
|
|
35
|
+
".jpeg": "image/jpeg",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
".gif": "image/gif",
|
|
38
|
+
".json": "application/json",
|
|
39
|
+
".txt": "text/plain",
|
|
40
|
+
".md": "text/markdown",
|
|
41
|
+
".csv": "text/csv",
|
|
42
|
+
".pdf": "application/pdf",
|
|
43
|
+
".html": "text/html",
|
|
44
|
+
".svg": "image/svg+xml",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The type a file name suggests; unknown names are plain bytes. */
|
|
48
|
+
export const typeByName = (name: string): string =>
|
|
49
|
+
BY_EXTENSION[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
50
|
+
|
|
51
|
+
/** A file name safe to keep and to show: no path, no control characters, at most 120 characters. */
|
|
52
|
+
export function safeName(name: string): string {
|
|
53
|
+
const cleaned = Array.from(basename(name.replaceAll("\\", "/")))
|
|
54
|
+
.filter((char) => {
|
|
55
|
+
const code = char.codePointAt(0) ?? 0;
|
|
56
|
+
// Controls, and the bidirectional overrides that make a name read as another one.
|
|
57
|
+
return (
|
|
58
|
+
code > 0x1f &&
|
|
59
|
+
code !== 0x7f &&
|
|
60
|
+
!(code >= 0x202a && code <= 0x202e) &&
|
|
61
|
+
!(code >= 0x2066 && code <= 0x2069)
|
|
62
|
+
);
|
|
63
|
+
})
|
|
64
|
+
.join("")
|
|
65
|
+
.trim();
|
|
66
|
+
const cut = Array.from(cleaned).slice(0, 120).join("");
|
|
67
|
+
return cut === "" || cut === "." || cut === ".." ? "file" : cut;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const isFileId = (id: string): boolean => FILE_ID.test(id);
|
|
71
|
+
|
|
72
|
+
interface Meta {
|
|
73
|
+
name: string;
|
|
74
|
+
type: string;
|
|
75
|
+
size: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A file being written as its bytes arrive; `finish` makes it a stored file, `abort` leaves nothing. */
|
|
79
|
+
export interface FileWriter {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
/** Bytes written so far. */
|
|
82
|
+
readonly size: number;
|
|
83
|
+
write(chunk: Uint8Array): Promise<void>;
|
|
84
|
+
finish(name: string, type: string): Promise<RoomFile>;
|
|
85
|
+
abort(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A directory of files kept by id, each with a small record of its name and type beside it.
|
|
90
|
+
* Both stores of the app, the files of replies and sent messages and the uploads waiting for a
|
|
91
|
+
* message, are one of these.
|
|
92
|
+
*/
|
|
93
|
+
export class FileDir {
|
|
94
|
+
readonly dir: string;
|
|
95
|
+
|
|
96
|
+
constructor(dir: string) {
|
|
97
|
+
this.dir = dir;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#path(id: string): string {
|
|
101
|
+
return join(this.dir, id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Keeps the bytes under a new id. */
|
|
105
|
+
put(
|
|
106
|
+
name: string,
|
|
107
|
+
type: string,
|
|
108
|
+
bytes: Uint8Array,
|
|
109
|
+
id = crypto.randomUUID(),
|
|
110
|
+
): RoomFile {
|
|
111
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
112
|
+
const meta: Meta = { name: safeName(name), type, size: bytes.byteLength };
|
|
113
|
+
writeFileSync(this.#path(id), bytes, { mode: 0o600 });
|
|
114
|
+
writeFileSync(`${this.#path(id)}.json`, JSON.stringify(meta), {
|
|
115
|
+
mode: 0o600,
|
|
116
|
+
});
|
|
117
|
+
return { id, ...meta };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Starts a file under a new id whose bytes arrive in pieces, so it is never held whole. */
|
|
121
|
+
async begin(): Promise<FileWriter> {
|
|
122
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
123
|
+
const id = crypto.randomUUID();
|
|
124
|
+
const path = this.#path(id);
|
|
125
|
+
const handle = await open(path, "wx", 0o600);
|
|
126
|
+
let size = 0;
|
|
127
|
+
let ended = false;
|
|
128
|
+
const close = async () => {
|
|
129
|
+
if (ended) return false;
|
|
130
|
+
ended = true;
|
|
131
|
+
await handle.close();
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
id,
|
|
136
|
+
get size() {
|
|
137
|
+
return size;
|
|
138
|
+
},
|
|
139
|
+
write: async (chunk) => {
|
|
140
|
+
await handle.write(chunk);
|
|
141
|
+
size += chunk.byteLength;
|
|
142
|
+
},
|
|
143
|
+
finish: async (name, type) => {
|
|
144
|
+
await close();
|
|
145
|
+
const meta: Meta = { name: safeName(name), type, size };
|
|
146
|
+
writeFileSync(`${path}.json`, JSON.stringify(meta), { mode: 0o600 });
|
|
147
|
+
return { id, ...meta };
|
|
148
|
+
},
|
|
149
|
+
abort: async () => {
|
|
150
|
+
await close().catch(() => undefined);
|
|
151
|
+
rmSync(path, { force: true });
|
|
152
|
+
rmSync(`${path}.json`, { force: true });
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The file as a blob that is read as it is sent, or undefined when there is none. */
|
|
158
|
+
blob(id: string): Blob | undefined {
|
|
159
|
+
if (!isFileId(id) || !existsSync(this.#path(id))) return undefined;
|
|
160
|
+
return Bun.file(this.#path(id));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
meta(id: string): RoomFile | undefined {
|
|
164
|
+
if (!isFileId(id)) return undefined;
|
|
165
|
+
try {
|
|
166
|
+
const meta = JSON.parse(
|
|
167
|
+
readFileSync(`${this.#path(id)}.json`, "utf8"),
|
|
168
|
+
) as Meta;
|
|
169
|
+
return { id, name: meta.name, type: meta.type, size: meta.size };
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The absolute path of the file, or undefined when there is no regular file under the id. */
|
|
176
|
+
pathOf(id: string): string | undefined {
|
|
177
|
+
if (!isFileId(id)) return undefined;
|
|
178
|
+
const path = resolve(this.#path(id));
|
|
179
|
+
try {
|
|
180
|
+
return lstatSync(path).isFile() ? path : undefined;
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
bytes(id: string): Uint8Array | undefined {
|
|
187
|
+
if (!isFileId(id) || !existsSync(this.#path(id))) return undefined;
|
|
188
|
+
try {
|
|
189
|
+
return readFileSync(this.#path(id));
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Moves a file, with its record, into another directory under the same id. */
|
|
196
|
+
moveTo(id: string, other: FileDir): boolean {
|
|
197
|
+
if (!isFileId(id) || !existsSync(this.#path(id))) return false;
|
|
198
|
+
mkdirSync(other.dir, { recursive: true, mode: 0o700 });
|
|
199
|
+
renameSync(this.#path(id), other.#path(id));
|
|
200
|
+
renameSync(`${this.#path(id)}.json`, `${other.#path(id)}.json`);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
remove(id: string): void {
|
|
205
|
+
if (!isFileId(id)) return;
|
|
206
|
+
rmSync(this.#path(id), { force: true });
|
|
207
|
+
rmSync(`${this.#path(id)}.json`, { force: true });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Ids of the files older than `maxAgeMs`, by their modification time. */
|
|
211
|
+
older(maxAgeMs: number, now: number): string[] {
|
|
212
|
+
let names: string[];
|
|
213
|
+
try {
|
|
214
|
+
names = readdirSync(this.dir);
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
return names
|
|
219
|
+
.filter((name) => isFileId(name))
|
|
220
|
+
.filter((name) => {
|
|
221
|
+
try {
|
|
222
|
+
return now - statSync(this.#path(name)).mtimeMs > maxAgeMs;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Every file's id. */
|
|
230
|
+
ids(): string[] {
|
|
231
|
+
try {
|
|
232
|
+
return readdirSync(this.dir).filter((name) => isFileId(name));
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The response for a stored file: a download, except for the four image types the page shows,
|
|
241
|
+
* never interpreted as a page (`sandbox`, `nosniff`).
|
|
242
|
+
*/
|
|
243
|
+
export function fileResponse(meta: RoomFile, body: Blob): Response {
|
|
244
|
+
const inline = INLINE_TYPES.has(meta.type);
|
|
245
|
+
// RFC 5987 leaves `'`, `(`, `)` and `*` out of a value's characters; encodeURIComponent keeps them.
|
|
246
|
+
const encoded = encodeURIComponent(meta.name).replace(
|
|
247
|
+
/['()*]/g,
|
|
248
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
249
|
+
);
|
|
250
|
+
return new Response(body, {
|
|
251
|
+
headers: {
|
|
252
|
+
"content-type": inline ? meta.type : "application/octet-stream",
|
|
253
|
+
"content-disposition": inline
|
|
254
|
+
? "inline"
|
|
255
|
+
: `attachment; filename*=UTF-8''${encoded}`,
|
|
256
|
+
"content-security-policy": "sandbox",
|
|
257
|
+
"x-content-type-options": "nosniff",
|
|
258
|
+
"cache-control": "private, max-age=3600",
|
|
259
|
+
"content-length": String(body.size),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApprovalDetails,
|
|
3
|
+
ApprovalWording,
|
|
4
|
+
OwnerQuestion,
|
|
5
|
+
Questionnaire,
|
|
6
|
+
} from "pi-roundtable";
|
|
7
|
+
import {
|
|
8
|
+
APPROVAL_MAX_BYTES,
|
|
9
|
+
type PromptApproval,
|
|
10
|
+
type PromptFrame,
|
|
11
|
+
} from "../api-types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The approval as the frame carries it: whole while its JSON is within `APPROVAL_MAX_BYTES`.
|
|
15
|
+
* Over that, each string value of the input is cut to the most code points that fit, marked
|
|
16
|
+
* `… [N chars]`, and `truncated` is set; an input that does not fit even then is sent as `{}`
|
|
17
|
+
* with no files. The copy text is informational, so it is carried after the input is settled
|
|
18
|
+
* and only if it fits then, never at the input's cost. The card's `message` always accompanies it.
|
|
19
|
+
*/
|
|
20
|
+
export function boundedApproval(details: ApprovalDetails): PromptApproval {
|
|
21
|
+
const size = (value: unknown) =>
|
|
22
|
+
new TextEncoder().encode(JSON.stringify(value)).length;
|
|
23
|
+
if (size(details) <= APPROVAL_MAX_BYTES) return details;
|
|
24
|
+
const { copy, ...withoutCopy } = details;
|
|
25
|
+
const bounded = boundedWithoutCopy(withoutCopy, size);
|
|
26
|
+
if (!copy) return bounded;
|
|
27
|
+
const candidate: PromptApproval = { ...bounded, copy };
|
|
28
|
+
return size(candidate) <= APPROVAL_MAX_BYTES ? candidate : bounded;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function boundedWithoutCopy(
|
|
32
|
+
details: Omit<ApprovalDetails, "copy">,
|
|
33
|
+
size: (value: unknown) => number,
|
|
34
|
+
): PromptApproval {
|
|
35
|
+
if (size(details) <= APPROVAL_MAX_BYTES) return details;
|
|
36
|
+
const { files: _files, ...base } = details;
|
|
37
|
+
const fits = (limit: number): PromptApproval | undefined => {
|
|
38
|
+
const candidate: PromptApproval = {
|
|
39
|
+
...base,
|
|
40
|
+
input: cutStrings(details.input, limit),
|
|
41
|
+
truncated: true,
|
|
42
|
+
};
|
|
43
|
+
return size(candidate) <= APPROVAL_MAX_BYTES ? candidate : undefined;
|
|
44
|
+
};
|
|
45
|
+
let low = 0;
|
|
46
|
+
let high = longestString(details.input);
|
|
47
|
+
let best = fits(0);
|
|
48
|
+
if (!best) return { ...base, input: {}, truncated: true };
|
|
49
|
+
while (low < high) {
|
|
50
|
+
const mid = Math.ceil((low + high) / 2);
|
|
51
|
+
const candidate = fits(mid);
|
|
52
|
+
if (candidate) {
|
|
53
|
+
best = candidate;
|
|
54
|
+
low = mid;
|
|
55
|
+
} else high = mid - 1;
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pointsOf = (text: string) => Array.from(text).length;
|
|
61
|
+
|
|
62
|
+
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
63
|
+
function longestString(value: unknown): number {
|
|
64
|
+
if (typeof value === "string") return pointsOf(value);
|
|
65
|
+
if (Array.isArray(value))
|
|
66
|
+
return Math.max(0, ...value.map((v: unknown) => longestString(v)));
|
|
67
|
+
if (value && typeof value === "object")
|
|
68
|
+
return Math.max(
|
|
69
|
+
0,
|
|
70
|
+
...Object.values(value).map((v: unknown) => longestString(v)),
|
|
71
|
+
);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `value` with every string longer than `limit` code points cut to its start plus `… [N chars]`. */
|
|
76
|
+
function cutStrings<T>(value: T, limit: number): T {
|
|
77
|
+
if (typeof value === "string") {
|
|
78
|
+
if (value.length <= limit) return value;
|
|
79
|
+
const points = Array.from(value);
|
|
80
|
+
if (points.length <= limit) return value;
|
|
81
|
+
return `${points.slice(0, limit).join("")}… [${points.length} chars]` as T;
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(value))
|
|
84
|
+
return value.map((v: unknown) => cutStrings(v, limit)) as T;
|
|
85
|
+
if (value && typeof value === "object")
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
Object.entries(value).map(([k, v]) => [k, cutStrings(v, limit)]),
|
|
88
|
+
) as T;
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An `ask` card's frame, from the core's question. */
|
|
93
|
+
export function askFrame(
|
|
94
|
+
id: string,
|
|
95
|
+
title: string,
|
|
96
|
+
question: OwnerQuestion,
|
|
97
|
+
): Extract<PromptFrame, { kind: "ask" }> {
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
kind: "ask",
|
|
101
|
+
title,
|
|
102
|
+
question: question.question,
|
|
103
|
+
options: question.options.map((option) => ({ ...option })),
|
|
104
|
+
multi: question.multi,
|
|
105
|
+
allowOther: question.allowOther,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A `questionnaire` card's frame, the core's questions copied verbatim. */
|
|
110
|
+
export function questionnaireFrame(
|
|
111
|
+
id: string,
|
|
112
|
+
title: string,
|
|
113
|
+
questionnaire: Questionnaire,
|
|
114
|
+
): Extract<PromptFrame, { kind: "questionnaire" }> {
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
kind: "questionnaire",
|
|
118
|
+
title,
|
|
119
|
+
questions: questionnaire.questions.map((q) => ({
|
|
120
|
+
...q,
|
|
121
|
+
options: q.options.map((option) => ({ ...option })),
|
|
122
|
+
})),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The card's own words, copied so a later change of the caller's object cannot reach the frame. */
|
|
127
|
+
export function wordingFrame(
|
|
128
|
+
wording: ApprovalWording,
|
|
129
|
+
): NonNullable<Extract<PromptFrame, { kind: "approval" }>["wording"]> {
|
|
130
|
+
const { approve, decline, footer, approved, declined } = wording;
|
|
131
|
+
return { approve, decline, footer, approved, declined };
|
|
132
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** A body that is not the multipart form it says it is. */
|
|
2
|
+
export class MultipartError extends Error {
|
|
3
|
+
override name = "MultipartError";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** What a part of the form says about itself. */
|
|
7
|
+
export interface PartHeaders {
|
|
8
|
+
/** The form field's name. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** The file's name, for a file part. */
|
|
11
|
+
filename: string | undefined;
|
|
12
|
+
/** The part's content type, lower-cased, without parameters. */
|
|
13
|
+
contentType: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Takes the part's bytes as they arrive; `end` runs once its last byte did, `abort` when the body ended early or failed. */
|
|
17
|
+
export interface PartSink {
|
|
18
|
+
write(chunk: Uint8Array): void | Promise<void>;
|
|
19
|
+
end(): void | Promise<void>;
|
|
20
|
+
abort(): void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The longest a part's header block may be. */
|
|
24
|
+
const MAX_HEADER_BYTES = 16 * 1024;
|
|
25
|
+
/** The most bytes of text before the first boundary that are skipped; a form has none. */
|
|
26
|
+
const MAX_PREAMBLE_BYTES = 8 * 1024;
|
|
27
|
+
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
|
|
30
|
+
/** The boundary a `multipart/form-data` content type names; undefined when it names none. */
|
|
31
|
+
export function boundaryOf(contentType: string | null): string | undefined {
|
|
32
|
+
if (!contentType) return undefined;
|
|
33
|
+
const match =
|
|
34
|
+
/^multipart\/form-data\s*;(?:.*;)?\s*boundary=(?:"([^"]{1,200})"|([^\s;"]{1,200}))/i.exec(
|
|
35
|
+
contentType,
|
|
36
|
+
);
|
|
37
|
+
return match?.[1] ?? match?.[2];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function headersOf(block: string): PartHeaders {
|
|
41
|
+
const map = new Map<string, string>();
|
|
42
|
+
for (const line of block.split("\r\n")) {
|
|
43
|
+
const colon = line.indexOf(":");
|
|
44
|
+
if (colon > 0)
|
|
45
|
+
map.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1));
|
|
46
|
+
}
|
|
47
|
+
const disposition = map.get("content-disposition") ?? "";
|
|
48
|
+
const param = (key: string) => {
|
|
49
|
+
const match = new RegExp(`;\\s*${key}="((?:[^"\\\\]|\\\\.)*)"`, "i").exec(
|
|
50
|
+
disposition,
|
|
51
|
+
);
|
|
52
|
+
return match?.[1]
|
|
53
|
+
?.replaceAll("%22", '"')
|
|
54
|
+
.replaceAll("%0D", "\r")
|
|
55
|
+
.replaceAll("%0A", "\n")
|
|
56
|
+
.replace(/\\(.)/g, "$1");
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
name: param("name") ?? "",
|
|
60
|
+
filename: param("filename"),
|
|
61
|
+
contentType:
|
|
62
|
+
map.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const find = (haystack: Uint8Array, needle: Uint8Array, from = 0): number =>
|
|
67
|
+
Buffer.from(
|
|
68
|
+
haystack.buffer,
|
|
69
|
+
haystack.byteOffset,
|
|
70
|
+
haystack.byteLength,
|
|
71
|
+
).indexOf(needle, from);
|
|
72
|
+
|
|
73
|
+
const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
|
|
74
|
+
if (a.byteLength === 0) return b;
|
|
75
|
+
const both = new Uint8Array(a.byteLength + b.byteLength);
|
|
76
|
+
both.set(a, 0);
|
|
77
|
+
both.set(b, a.byteLength);
|
|
78
|
+
return both;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reads a `multipart/form-data` body as a stream, handing each part's bytes to the sink that
|
|
83
|
+
* `onPart` returns, so no part is ever held whole: memory stays at a chunk and a boundary's
|
|
84
|
+
* length. A part `onPart` gives no sink for is read past and dropped. Throws `MultipartError`
|
|
85
|
+
* for a body that is not well formed, and whatever a sink or `onPart` throws; the part being
|
|
86
|
+
* written is aborted first.
|
|
87
|
+
*/
|
|
88
|
+
export async function readMultipart(
|
|
89
|
+
body: ReadableStream<Uint8Array>,
|
|
90
|
+
boundary: string,
|
|
91
|
+
onPart: (
|
|
92
|
+
headers: PartHeaders,
|
|
93
|
+
) => PartSink | undefined | Promise<PartSink | undefined>,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const delimiter = encoder.encode(`\r\n--${boundary}`);
|
|
96
|
+
const headerEnd = encoder.encode("\r\n\r\n");
|
|
97
|
+
// The first boundary is not preceded by a line break; pretending one came keeps one search for all.
|
|
98
|
+
let buffer: Uint8Array = encoder.encode("\r\n");
|
|
99
|
+
let state: "preamble" | "after-delimiter" | "headers" | "body" | "done" =
|
|
100
|
+
"preamble";
|
|
101
|
+
let sink: PartSink | undefined;
|
|
102
|
+
let dropped = false;
|
|
103
|
+
const reader = body.getReader();
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
// After the closing boundary what follows (the epilogue) is read and dropped, never gathered.
|
|
108
|
+
if (value && state !== "done") buffer = join(buffer, value);
|
|
109
|
+
// Everything the buffer can settle is settled before the next chunk is read.
|
|
110
|
+
for (let progressed = true; progressed && state !== "done"; ) {
|
|
111
|
+
progressed = false;
|
|
112
|
+
if (state === "preamble") {
|
|
113
|
+
const at = find(buffer, delimiter);
|
|
114
|
+
if (at >= 0) {
|
|
115
|
+
buffer = buffer.subarray(at + delimiter.byteLength);
|
|
116
|
+
state = "after-delimiter";
|
|
117
|
+
progressed = true;
|
|
118
|
+
} else if (buffer.byteLength > MAX_PREAMBLE_BYTES) {
|
|
119
|
+
buffer = buffer.subarray(buffer.byteLength - delimiter.byteLength);
|
|
120
|
+
}
|
|
121
|
+
} else if (state === "after-delimiter") {
|
|
122
|
+
if (buffer.byteLength >= 2) {
|
|
123
|
+
const head = String.fromCharCode(buffer[0] ?? 0, buffer[1] ?? 0);
|
|
124
|
+
if (head === "--") {
|
|
125
|
+
state = "done";
|
|
126
|
+
buffer = new Uint8Array(0);
|
|
127
|
+
} else if (head === "\r\n") {
|
|
128
|
+
buffer = buffer.subarray(2);
|
|
129
|
+
state = "headers";
|
|
130
|
+
progressed = true;
|
|
131
|
+
} else throw new MultipartError("A boundary is not well formed.");
|
|
132
|
+
}
|
|
133
|
+
} else if (state === "headers") {
|
|
134
|
+
const at = find(buffer, headerEnd);
|
|
135
|
+
if (at >= 0) {
|
|
136
|
+
const block = new TextDecoder().decode(buffer.subarray(0, at));
|
|
137
|
+
buffer = buffer.subarray(at + headerEnd.byteLength);
|
|
138
|
+
sink = await onPart(headersOf(block));
|
|
139
|
+
dropped = sink === undefined;
|
|
140
|
+
state = "body";
|
|
141
|
+
progressed = true;
|
|
142
|
+
} else if (buffer.byteLength > MAX_HEADER_BYTES)
|
|
143
|
+
throw new MultipartError("A part's headers are too long.");
|
|
144
|
+
} else if (state === "body") {
|
|
145
|
+
const at = find(buffer, delimiter);
|
|
146
|
+
if (at >= 0) {
|
|
147
|
+
if (!dropped && at > 0) await sink?.write(buffer.subarray(0, at));
|
|
148
|
+
await sink?.end();
|
|
149
|
+
sink = undefined;
|
|
150
|
+
buffer = buffer.subarray(at + delimiter.byteLength);
|
|
151
|
+
state = "after-delimiter";
|
|
152
|
+
progressed = true;
|
|
153
|
+
} else {
|
|
154
|
+
// Hold back what could be the start of the delimiter.
|
|
155
|
+
const safe = buffer.byteLength - (delimiter.byteLength - 1);
|
|
156
|
+
if (safe > 0) {
|
|
157
|
+
if (!dropped) await sink?.write(buffer.subarray(0, safe));
|
|
158
|
+
buffer = buffer.subarray(safe);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (done) break;
|
|
164
|
+
}
|
|
165
|
+
if (state !== "done") throw new MultipartError("The form is cut short.");
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await sink?.abort();
|
|
168
|
+
throw error;
|
|
169
|
+
} finally {
|
|
170
|
+
reader.releaseLock();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentTeam, Logger } from "pi-roundtable";
|
|
2
|
+
import type { ServerFrame } from "../api-types.ts";
|
|
3
|
+
import type { Sockets } from "./sockets.ts";
|
|
4
|
+
|
|
5
|
+
/** Changes closer together than this reach the sockets as one `presence` frame. */
|
|
6
|
+
const PRESENCE_COALESCE_MS = 250;
|
|
7
|
+
|
|
8
|
+
type PresenceFrame = Extract<ServerFrame, { type: "presence" }>;
|
|
9
|
+
|
|
10
|
+
export interface PresenceOptions {
|
|
11
|
+
team: Pick<AgentTeam, "status" | "onChange">;
|
|
12
|
+
sockets: Sockets;
|
|
13
|
+
/** The agents working in the app's own rooms now, which the team's status does not list. */
|
|
14
|
+
working(): ReadonlySet<string>;
|
|
15
|
+
logger: Logger;
|
|
16
|
+
/** How long changes are gathered; default 250 ms. */
|
|
17
|
+
coalesceMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Which agents are busy, anywhere: a turn in a Discord channel shows in the team's status, and a
|
|
22
|
+
* turn in an app room shows in the surface. A frame goes out only when the table differs from the
|
|
23
|
+
* one last sent, so a turn's churn costs the sockets nothing.
|
|
24
|
+
*/
|
|
25
|
+
export class Presence {
|
|
26
|
+
readonly #options: PresenceOptions;
|
|
27
|
+
#pending: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
#last = "";
|
|
29
|
+
#stopped = false;
|
|
30
|
+
|
|
31
|
+
constructor(options: PresenceOptions) {
|
|
32
|
+
this.#options = options;
|
|
33
|
+
options.team.onChange(() => this.schedule());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The table now, as a frame. */
|
|
37
|
+
async frame(): Promise<PresenceFrame> {
|
|
38
|
+
const { team, working } = this.#options;
|
|
39
|
+
const status = await team.status();
|
|
40
|
+
const here = working();
|
|
41
|
+
const agents: PresenceFrame["agents"] = {};
|
|
42
|
+
for (const agent of status.agents)
|
|
43
|
+
agents[agent.name] = {
|
|
44
|
+
busy: agent.workingIn !== undefined || here.has(agent.name),
|
|
45
|
+
};
|
|
46
|
+
return { type: "presence", agents };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sends the table to every socket once changes settle, if it changed. */
|
|
50
|
+
schedule(): void {
|
|
51
|
+
if (this.#pending || this.#stopped) return;
|
|
52
|
+
this.#pending = setTimeout(() => {
|
|
53
|
+
this.#pending = undefined;
|
|
54
|
+
this.#send().catch((error: unknown) =>
|
|
55
|
+
this.#options.logger.warn({ err: error }, "presence was not sent"),
|
|
56
|
+
);
|
|
57
|
+
}, this.#options.coalesceMs ?? PRESENCE_COALESCE_MS);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #send(): Promise<void> {
|
|
61
|
+
const frame = await this.frame();
|
|
62
|
+
const text = JSON.stringify(frame.agents);
|
|
63
|
+
if (text === this.#last || this.#stopped) return;
|
|
64
|
+
this.#last = text;
|
|
65
|
+
this.#options.sockets.broadcast(frame);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stop(): void {
|
|
69
|
+
this.#stopped = true;
|
|
70
|
+
clearTimeout(this.#pending);
|
|
71
|
+
this.#pending = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|