pi-roundtable-web 0.0.0-stage → 0.7.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.
@@ -0,0 +1,214 @@
1
+ import type { HttpRoute, Logger } from "pi-roundtable";
2
+ import type { AssetBundle } from "./assets.ts";
3
+ import type { ConsoleApi } from "./console-api.ts";
4
+ import type { RequestVerifier } from "./verifier.ts";
5
+
6
+ const encoder = new TextEncoder();
7
+ /** Changes closer together than this reach the page as one event. */
8
+ const COALESCE_MS = 250;
9
+ /** Events a stream may hold unread before its client is cut off; it reconnects and refetches. */
10
+ const STREAM_BACKLOG = 16;
11
+ /** Keeps the stream open through proxies that close streams idle for about a minute or two. */
12
+ const PING_MS = 30_000;
13
+ /** Open event streams; one more is refused, so a client cannot hold every connection. */
14
+ const MAX_STREAMS = 32;
15
+ const READ_METHODS = new Set(["GET", "HEAD"]);
16
+
17
+ const SECURITY_HEADERS = {
18
+ "x-content-type-options": "nosniff",
19
+ "referrer-policy": "same-origin",
20
+ };
21
+
22
+ const PAGE_HEADERS = {
23
+ "content-type": "text/html;charset=utf-8",
24
+ "cache-control": "no-cache",
25
+ "content-security-policy":
26
+ "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
27
+ };
28
+
29
+ export interface ConsoleServerOptions {
30
+ /** The path the console is served under, without a trailing slash: `/console`. */
31
+ mount: string;
32
+ assets: AssetBundle;
33
+ verifier: RequestVerifier;
34
+ /** The console's own origin; requests that change data must come from it. */
35
+ origin: string;
36
+ api: Pick<ConsoleApi, "handle">;
37
+ /** Registers the listener with every source whose change the console shows. */
38
+ subscribe(listener: () => void): void;
39
+ logger: Logger;
40
+ /** How long changes are gathered into one event; default 250 ms. Tests set it lower. */
41
+ coalesceMs?: number;
42
+ }
43
+
44
+ /**
45
+ * The console on the host's listener: `<mount>` redirects to `<mount>/`, and everything under
46
+ * it needs the verifier to vouch for the owner. A request that changes data must also carry the
47
+ * console's own `Origin`.
48
+ */
49
+ export class ConsoleServer {
50
+ readonly #options: ConsoleServerOptions;
51
+ readonly #base: string;
52
+ readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
53
+ #pending: ReturnType<typeof setTimeout> | undefined;
54
+ #ping: ReturnType<typeof setInterval> | undefined;
55
+
56
+ constructor(options: ConsoleServerOptions) {
57
+ this.#options = options;
58
+ this.#base = `${options.mount}/`;
59
+ options.subscribe(() => this.#changed());
60
+ }
61
+
62
+ routes(listener: string): HttpRoute[] {
63
+ const handle = (request: Request) => this.handle(request);
64
+ return [
65
+ {
66
+ name: "web-console-redirect",
67
+ listener,
68
+ path: { exact: this.#options.mount },
69
+ handle,
70
+ },
71
+ {
72
+ name: "web-console",
73
+ listener,
74
+ path: { prefix: this.#base },
75
+ handle,
76
+ },
77
+ ];
78
+ }
79
+
80
+ start(): void {
81
+ this.#ping = setInterval(() => this.#send(": ping\n\n"), PING_MS);
82
+ }
83
+
84
+ /** Ends the event streams; the listener may already have closed their connections. */
85
+ stop(): void {
86
+ clearInterval(this.#ping);
87
+ clearTimeout(this.#pending);
88
+ this.#pending = undefined;
89
+ for (const stream of this.#streams) {
90
+ try {
91
+ stream.close();
92
+ } catch {
93
+ // Its connection is gone, and the stream with it.
94
+ }
95
+ }
96
+ this.#streams.clear();
97
+ }
98
+
99
+ async handle(request: Request): Promise<Response> {
100
+ const response = await this.#respond(request);
101
+ for (const [name, value] of Object.entries(SECURITY_HEADERS))
102
+ response.headers.set(name, value);
103
+ return response;
104
+ }
105
+
106
+ async #respond(request: Request): Promise<Response> {
107
+ // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
108
+ const path = new URL(request.url).pathname;
109
+ if (path === this.#options.mount)
110
+ return new Response(null, {
111
+ status: 302,
112
+ headers: { location: this.#base },
113
+ });
114
+ if (!path.startsWith(this.#base))
115
+ return new Response("Not found", { status: 404 });
116
+ const refusal = await this.#refusal(request);
117
+ if (refusal !== undefined) {
118
+ this.#options.logger.warn({ refusal }, "console request refused");
119
+ return new Response("Forbidden", {
120
+ status: 403,
121
+ headers: { "cache-control": "no-store" },
122
+ });
123
+ }
124
+ const rest = path.slice(this.#base.length);
125
+ if (rest === "api/events" && request.method === "GET")
126
+ return this.#events();
127
+ if (rest.startsWith("api/"))
128
+ return this.#options.api.handle(request, rest.slice("api/".length));
129
+ if (!READ_METHODS.has(request.method))
130
+ return new Response("Method not allowed", { status: 405 });
131
+ if (rest === "" || rest === "index.html") {
132
+ const page = this.#options.assets.get("index.html");
133
+ if (page) return new Response(page.body, { headers: PAGE_HEADERS });
134
+ }
135
+ const asset = this.#options.assets.get(rest);
136
+ if (asset && rest !== "index.html")
137
+ return new Response(asset.body, {
138
+ headers: {
139
+ "content-type": asset.type,
140
+ "cache-control": "private, max-age=31536000, immutable",
141
+ },
142
+ });
143
+ return new Response("Not found", { status: 404 });
144
+ }
145
+
146
+ /** Undefined when the request may pass, otherwise why not; never throws. */
147
+ async #refusal(request: Request): Promise<string | undefined> {
148
+ try {
149
+ const verdict = await this.#options.verifier(request);
150
+ if (!verdict.admitted) return String(verdict.reason);
151
+ } catch {
152
+ // A verifier that fails admits no one.
153
+ return "verifier failed";
154
+ }
155
+ if (
156
+ !READ_METHODS.has(request.method) &&
157
+ request.headers.get("origin") !== this.#options.origin
158
+ )
159
+ return "foreign origin";
160
+ return undefined;
161
+ }
162
+
163
+ /** A server-sent event stream that says `changed` whenever the console may differ. */
164
+ #events(): Response {
165
+ if (this.#streams.size >= MAX_STREAMS)
166
+ return new Response("Too many event streams", { status: 503 });
167
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
168
+ const body = new ReadableStream<Uint8Array>(
169
+ {
170
+ start: (c) => {
171
+ controller = c;
172
+ this.#streams.add(c);
173
+ c.enqueue(encoder.encode("retry: 3000\n\n"));
174
+ },
175
+ cancel: () => {
176
+ if (controller) this.#streams.delete(controller);
177
+ },
178
+ },
179
+ new CountQueuingStrategy({ highWaterMark: STREAM_BACKLOG }),
180
+ );
181
+ return new Response(body, {
182
+ headers: {
183
+ "content-type": "text/event-stream",
184
+ "cache-control": "no-store",
185
+ "x-accel-buffering": "no",
186
+ },
187
+ });
188
+ }
189
+
190
+ #changed(): void {
191
+ if (this.#pending || this.#streams.size === 0) return;
192
+ this.#pending = setTimeout(() => {
193
+ this.#pending = undefined;
194
+ this.#send("event: changed\ndata: {}\n\n");
195
+ }, this.#options.coalesceMs ?? COALESCE_MS);
196
+ }
197
+
198
+ #send(text: string): void {
199
+ const chunk = encoder.encode(text);
200
+ for (const stream of this.#streams) {
201
+ try {
202
+ // An event only says "refetch", so a client that has not read its backlog is not worth
203
+ // buffering for: it is closed, and its page reconnects after the retry delay.
204
+ if ((stream.desiredSize ?? 0) <= 0) {
205
+ this.#streams.delete(stream);
206
+ stream.close();
207
+ } else stream.enqueue(chunk);
208
+ } catch {
209
+ // The page went away; its stream is closed.
210
+ this.#streams.delete(stream);
211
+ }
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,383 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fstatSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ readSync,
9
+ statSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type { ChannelKey } from "pi-roundtable";
13
+ import { textOf } from "pi-roundtable/kit";
14
+ import type { TranscriptEntry } from "./api-types.ts";
15
+
16
+ const FIRST_MESSAGE_CHARS = 80;
17
+ /** The most a transcript request reads from disk, newest files first. */
18
+ const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
19
+ /** How much of a conversation's first file the list reads to find its first message. */
20
+ const OPENING_BYTES = 256 * 1024;
21
+ /** The most entries a transcript returns; the oldest are dropped. */
22
+ const MAX_ENTRIES = 1000;
23
+ const MAX_TEXT_CHARS = 20_000;
24
+ const MAX_TOOL_TEXT_CHARS = 2000;
25
+ const MAX_PREVIEW_CHARS = 200;
26
+
27
+ const DISCORD_DIR = /^discord_(\d{17,20})$/;
28
+ const MCP_DIR =
29
+ /^mcp_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
30
+ const GROUP_DIR = /^agentgroup_(\d{17,20})_([A-Za-z0-9_-]+)$/;
31
+ const DISCORD_KEY = /^discord:(\d{17,20})$/;
32
+ const GROUP_KEY = /^agentgroup:(\d{17,20})\.([A-Za-z0-9_-]+)$/;
33
+ const MCP_KEY =
34
+ /^mcp:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
35
+
36
+ /** Facts about a conversation's files. */
37
+ export interface ConversationFiles {
38
+ /** Bytes of the live conversation; 0 when only archives are left. */
39
+ liveBytes: number;
40
+ archives: number;
41
+ lastActive?: string;
42
+ }
43
+
44
+ /** The kinds of session directory the console reads. */
45
+ export type StoredKind = "discord" | "group" | "mcp";
46
+
47
+ /** An owner, agent, group-member, or outside-agent conversation found on disk. */
48
+ export interface StoredConversation extends ConversationFiles {
49
+ key: ChannelKey;
50
+ kind: StoredKind;
51
+ /** The Discord channel id, or the outside agent's session id. */
52
+ id: string;
53
+ /** Group conversations only: the agent whose conversation inside the group this is. */
54
+ member?: string;
55
+ /** Outside-agent conversations only. */
56
+ firstMessage?: string;
57
+ startedAt?: string;
58
+ }
59
+
60
+ /** The session directory a channel key names, or undefined when the key is not one the console reads. */
61
+ export function parseKey(
62
+ key: string,
63
+ ): { dir: string; kind: StoredKind; id: string; member?: string } | undefined {
64
+ const discord = DISCORD_KEY.exec(key)?.[1];
65
+ if (discord)
66
+ return { dir: `discord_${discord}`, kind: "discord", id: discord };
67
+ const mcp = MCP_KEY.exec(key)?.[1];
68
+ if (mcp) return { dir: `mcp_${mcp}`, kind: "mcp", id: mcp };
69
+ const group = GROUP_KEY.exec(key);
70
+ if (group?.[1] && group[2])
71
+ return {
72
+ dir: `agentgroup_${group[1]}_${group[2]}`,
73
+ kind: "group",
74
+ id: group[1],
75
+ member: group[2],
76
+ };
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * The live `.jsonl` files at a session directory's top level and its `archive/<time>/`
82
+ * folders, as `archiveSessions` leaves them. Undefined when the directory does not exist.
83
+ */
84
+ export function conversationFiles(dir: string): ConversationFiles | undefined {
85
+ if (!existsSync(dir)) return undefined;
86
+ let liveBytes = 0;
87
+ let newest = 0;
88
+ for (const file of readdirSync(dir)) {
89
+ if (!file.endsWith(".jsonl")) continue;
90
+ const stat = statSync(join(dir, file));
91
+ liveBytes += stat.size;
92
+ newest = Math.max(newest, stat.mtimeMs);
93
+ }
94
+ const archiveDir = join(dir, "archive");
95
+ const archives = existsSync(archiveDir)
96
+ ? readdirSync(archiveDir, { withFileTypes: true }).filter((entry) =>
97
+ entry.isDirectory(),
98
+ )
99
+ : [];
100
+ if (newest === 0)
101
+ for (const archive of archives)
102
+ newest = Math.max(
103
+ newest,
104
+ statSync(join(archiveDir, archive.name)).mtimeMs,
105
+ );
106
+ return {
107
+ liveBytes,
108
+ archives: archives.length,
109
+ ...(newest > 0 ? { lastActive: new Date(newest).toISOString() } : {}),
110
+ };
111
+ }
112
+
113
+ interface SessionLine {
114
+ type?: unknown;
115
+ timestamp?: unknown;
116
+ summary?: unknown;
117
+ message?: {
118
+ role?: unknown;
119
+ content?: unknown;
120
+ toolName?: unknown;
121
+ isError?: unknown;
122
+ };
123
+ }
124
+
125
+ const clip = (text: string, limit: number): string =>
126
+ text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
127
+
128
+ /** The text after a relay note, when the message starts with one of them. */
129
+ function withoutRelayNote(text: string, notes: readonly string[]): string {
130
+ const note = notes.find((prefix) => prefix && text.startsWith(prefix));
131
+ return (note ? text.slice(note.length) : text).trim();
132
+ }
133
+
134
+ /** The `.jsonl` file names directly in a directory, oldest first; names start with their creation time. */
135
+ function sessionFiles(dir: string): string[] {
136
+ return readdirSync(dir)
137
+ .filter((file) => file.endsWith(".jsonl"))
138
+ .sort();
139
+ }
140
+
141
+ /** The whole lines in the first `limit` bytes of a file; a line the limit cuts is left out. */
142
+ function readHead(path: string, limit: number): string[] {
143
+ const fd = openSync(path, "r");
144
+ try {
145
+ const { size } = fstatSync(fd);
146
+ const buffer = Buffer.alloc(Math.min(size, limit));
147
+ readSync(fd, buffer, 0, buffer.length, 0);
148
+ const lines = buffer.toString("utf8").split("\n");
149
+ if (size > limit) lines.pop();
150
+ return lines;
151
+ } finally {
152
+ closeSync(fd);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * When the conversation started and its first user message, relay notes removed: from the
158
+ * live conversation, or from the oldest archive once it has been started over.
159
+ */
160
+ function opening(
161
+ dir: string,
162
+ relayNotes: readonly string[],
163
+ ): { firstMessage?: string; startedAt?: string } {
164
+ const archiveDir = join(dir, "archive");
165
+ const first = sessionFiles(dir)[0];
166
+ let file = first ? join(dir, first) : undefined;
167
+ if (!file && existsSync(archiveDir))
168
+ for (const archive of readdirSync(archiveDir).sort()) {
169
+ const name = sessionFiles(join(archiveDir, archive))[0];
170
+ if (name) {
171
+ file = join(archiveDir, archive, name);
172
+ break;
173
+ }
174
+ }
175
+ if (!file) return {};
176
+ const result: { firstMessage?: string; startedAt?: string } = {};
177
+ for (const line of readHead(file, OPENING_BYTES)) {
178
+ if (!line.trim()) continue;
179
+ let entry: SessionLine;
180
+ try {
181
+ entry = JSON.parse(line);
182
+ } catch {
183
+ // A line cut off mid-write ends what can be read.
184
+ break;
185
+ }
186
+ if (entry.type === "session" && typeof entry.timestamp === "string")
187
+ result.startedAt = entry.timestamp;
188
+ if (entry.type === "message" && entry.message?.role === "user") {
189
+ result.firstMessage = clip(
190
+ withoutRelayNote(textOf(entry.message.content), relayNotes),
191
+ FIRST_MESSAGE_CHARS,
192
+ );
193
+ break;
194
+ }
195
+ }
196
+ return result;
197
+ }
198
+
199
+ /** Who a session directory's name says it belongs to; undefined for any other directory. */
200
+ function parseDirectory(
201
+ name: string,
202
+ ): Pick<StoredConversation, "key" | "kind" | "id" | "member"> | undefined {
203
+ const discord = DISCORD_DIR.exec(name)?.[1];
204
+ if (discord)
205
+ return { key: `discord:${discord}`, kind: "discord", id: discord };
206
+ const mcp = MCP_DIR.exec(name)?.[1];
207
+ if (mcp) return { key: `mcp:${mcp}`, kind: "mcp", id: mcp };
208
+ const group = GROUP_DIR.exec(name);
209
+ if (group?.[1] && group[2])
210
+ return {
211
+ key: `agentgroup:${group[1]}.${group[2]}`,
212
+ kind: "group",
213
+ id: group[1],
214
+ member: group[2],
215
+ };
216
+ return undefined;
217
+ }
218
+
219
+ /**
220
+ * Agent, owner, group-member, and outside-agent conversations (`discord_<channel>`,
221
+ * `agentgroup_<channel>_<agent>`, `mcp_<session>`) under the host's `sessions/` directory, minus
222
+ * the channels `excluded` claims.
223
+ */
224
+ export function storedConversations(
225
+ sessionsDir: string,
226
+ options: {
227
+ excluded: (key: ChannelKey) => boolean;
228
+ relayNotes: readonly string[];
229
+ },
230
+ ): StoredConversation[] {
231
+ if (!existsSync(sessionsDir)) return [];
232
+ const found: StoredConversation[] = [];
233
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
234
+ if (!entry.isDirectory()) continue;
235
+ const identity = parseDirectory(entry.name);
236
+ if (!identity || options.excluded(identity.key)) continue;
237
+ const dir = join(sessionsDir, entry.name);
238
+ const files = conversationFiles(dir);
239
+ if (!files) continue;
240
+ found.push({
241
+ ...identity,
242
+ ...files,
243
+ ...(identity.kind === "mcp" ? opening(dir, options.relayNotes) : {}),
244
+ });
245
+ }
246
+ return found.sort((a, b) =>
247
+ (b.lastActive ?? "").localeCompare(a.lastActive ?? ""),
248
+ );
249
+ }
250
+
251
+ /** The archive folders of a conversation, newest first. */
252
+ export function archiveNames(dir: string): string[] {
253
+ const archiveDir = join(dir, "archive");
254
+ if (!existsSync(archiveDir)) return [];
255
+ return readdirSync(archiveDir, { withFileTypes: true })
256
+ .filter((entry) => entry.isDirectory())
257
+ .map((entry) => entry.name)
258
+ .sort()
259
+ .reverse();
260
+ }
261
+
262
+ /**
263
+ * The last `limit` bytes of a file as text, from the first whole line when the file is longer,
264
+ * and how many bytes were read from disk, which is what the caller's budget pays for.
265
+ */
266
+ function readTail(
267
+ path: string,
268
+ limit: number,
269
+ ): { text: string; cut: boolean; read: number } {
270
+ const fd = openSync(path, "r");
271
+ try {
272
+ const { size } = fstatSync(fd);
273
+ if (size <= limit)
274
+ return { text: readFileSync(fd, "utf8"), cut: false, read: size };
275
+ const buffer = Buffer.alloc(limit);
276
+ readSync(fd, buffer, 0, limit, size - limit);
277
+ const text = buffer.toString("utf8");
278
+ return { text: text.slice(text.indexOf("\n") + 1), cut: true, read: limit };
279
+ } finally {
280
+ closeSync(fd);
281
+ }
282
+ }
283
+
284
+ function callPreview(args: unknown): string {
285
+ try {
286
+ return clip(JSON.stringify(args) ?? "", MAX_PREVIEW_CHARS);
287
+ } catch {
288
+ return "";
289
+ }
290
+ }
291
+
292
+ /** One session line as a transcript entry; undefined for lines the console does not show. */
293
+ function entryOf(
294
+ line: SessionLine,
295
+ relayNotes: readonly string[],
296
+ ): TranscriptEntry | undefined {
297
+ const at = typeof line.timestamp === "string" ? line.timestamp : undefined;
298
+ const base = at ? { at } : {};
299
+ if (line.type === "compaction" && typeof line.summary === "string")
300
+ return {
301
+ role: "compaction",
302
+ ...base,
303
+ text: clip(line.summary, MAX_TOOL_TEXT_CHARS),
304
+ };
305
+ if (line.type !== "message" || !line.message) return undefined;
306
+ const { role, content } = line.message;
307
+ if (role === "user") {
308
+ const text = withoutRelayNote(textOf(content), relayNotes);
309
+ return text
310
+ ? { role: "user", ...base, text: clip(text, MAX_TEXT_CHARS) }
311
+ : undefined;
312
+ }
313
+ if (role === "assistant") {
314
+ const text = textOf(content).trim();
315
+ const calls = Array.isArray(content)
316
+ ? content.flatMap((part) =>
317
+ part?.type === "toolCall" && typeof part.name === "string"
318
+ ? [{ name: part.name, preview: callPreview(part.arguments) }]
319
+ : [],
320
+ )
321
+ : [];
322
+ if (!text && calls.length === 0) return undefined;
323
+ return {
324
+ role: "assistant",
325
+ ...base,
326
+ text: clip(text, MAX_TEXT_CHARS),
327
+ ...(calls.length > 0 ? { calls } : {}),
328
+ };
329
+ }
330
+ if (role === "toolResult")
331
+ return {
332
+ role: "tool",
333
+ ...base,
334
+ text: clip(textOf(content), MAX_TOOL_TEXT_CHARS),
335
+ ...(typeof line.message.toolName === "string"
336
+ ? { tool: line.message.toolName }
337
+ : {}),
338
+ ...(line.message.isError === true ? { failed: true } : {}),
339
+ };
340
+ return undefined;
341
+ }
342
+
343
+ /**
344
+ * The entries of a conversation's live files, or of one archive, oldest first. Reads newest
345
+ * files first until the byte budget is spent, so a very long conversation shows its end.
346
+ */
347
+ export function readTranscript(
348
+ dir: string,
349
+ archive: string | undefined,
350
+ relayNotes: readonly string[],
351
+ ): { entries: TranscriptEntry[]; truncated: boolean } {
352
+ const folder = archive ? join(dir, "archive", archive) : dir;
353
+ const files = sessionFiles(folder).reverse();
354
+ let budget = MAX_TRANSCRIPT_BYTES;
355
+ let truncated = false;
356
+ const chunks: TranscriptEntry[][] = [];
357
+ for (const file of files) {
358
+ if (budget <= 0) {
359
+ truncated = true;
360
+ break;
361
+ }
362
+ const { text, cut, read } = readTail(join(folder, file), budget);
363
+ budget -= read;
364
+ if (cut) truncated = true;
365
+ const entries: TranscriptEntry[] = [];
366
+ for (const raw of text.split("\n")) {
367
+ if (!raw.trim()) continue;
368
+ let line: SessionLine;
369
+ try {
370
+ line = JSON.parse(raw);
371
+ } catch {
372
+ // A line cut off mid-write has no entry.
373
+ continue;
374
+ }
375
+ const entry = entryOf(line, relayNotes);
376
+ if (entry) entries.push(entry);
377
+ }
378
+ chunks.push(entries);
379
+ }
380
+ const all = chunks.reverse().flat();
381
+ if (all.length > MAX_ENTRIES) truncated = true;
382
+ return { entries: all.slice(-MAX_ENTRIES), truncated };
383
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ // The package's one entry: the plugin, its options, and the built-in verifier.
2
+
3
+ export type {
4
+ AgentView,
5
+ ApiError,
6
+ ChannelName,
7
+ ConfigView,
8
+ ContextView,
9
+ ConversationKind,
10
+ ConversationsView,
11
+ ConversationView,
12
+ GroupView,
13
+ NoteInput,
14
+ NoteKind,
15
+ NoteView,
16
+ OverviewView,
17
+ PaneName,
18
+ TranscriptEntry,
19
+ TranscriptRole,
20
+ TranscriptView,
21
+ } from "./api-types.ts";
22
+ export { PANES } from "./api-types.ts";
23
+ export type { CloudflareAccessOptions } from "./cloudflare-access.ts";
24
+ export { cloudflareAccess } from "./cloudflare-access.ts";
25
+ export type { WebConsoleOptions } from "./options.ts";
26
+ export { DEFAULT_RELAY_NOTE } from "./options.ts";
27
+ export type { RequestVerifier, Verdict } from "./verifier.ts";
28
+ export { admit, refuse } from "./verifier.ts";
29
+ export { webConsole } from "./web-plugin.ts";