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.
package/src/options.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { join } from "node:path";
2
+ import type { ChannelKey } from "pi-roundtable";
3
+ import { PluginError } from "pi-roundtable";
4
+ import { PANES, type PaneName } from "./api-types.ts";
5
+ import type { RequestVerifier } from "./verifier.ts";
6
+
7
+ /**
8
+ * The note pi-roundtable-mcp puts before a relayed message by default. A conversation opened over
9
+ * remote MCP starts with it, so the console takes it off before showing the owner's words.
10
+ */
11
+ export const DEFAULT_RELAY_NOTE =
12
+ "(The owner wrote this in a personal agent that relays it over MCP, not on Discord. Answer the owner directly, just as you would on Discord; the agent passes your reply back.)";
13
+
14
+ export interface WebConsoleOptions {
15
+ /**
16
+ * Decides whether a request comes from the owner; required. `cloudflareAccess(...)` is the
17
+ * built-in one. Without it the plugin refuses to start rather than serve without authentication.
18
+ */
19
+ verifier: RequestVerifier;
20
+ /**
21
+ * The console's own origin, such as `https://console.example.com`: requests that change data
22
+ * must carry it as their `Origin`, and the dashboard line links to it.
23
+ */
24
+ origin: string;
25
+ /** The id memory notes are kept under: the owner's. Required when the `notes` pane is served. */
26
+ ownerId?: string;
27
+ /** The host's data directory, whose `sessions/` holds the conversations; default `./data`. */
28
+ dataDir?: string;
29
+ /** The path the console is served under; default `/console`. */
30
+ mountPath?: string;
31
+ /** The listener whose address serves it; default `public`. */
32
+ listener?: string;
33
+ /** The panes to serve, in the order the page lists them; default all of `overview`, `conversations`, `notes`. */
34
+ panes?: readonly PaneName[];
35
+ /** The page's title and heading; default `Roundtable`. */
36
+ title?: string;
37
+ /** Text a relayed message begins with, taken off the owner's words; default the remote MCP note. */
38
+ relayNotes?: readonly string[];
39
+ /** Conversations the console neither lists nor reads. */
40
+ exclude?: (key: ChannelKey) => boolean;
41
+ /** The built page's directory; default the `dist/` this package ships. Set it only in tests. */
42
+ assetDir?: string;
43
+ }
44
+
45
+ /** Options checked and filled in. */
46
+ export interface ResolvedOptions {
47
+ verifier: RequestVerifier;
48
+ origin: string;
49
+ ownerId: string | undefined;
50
+ sessionsDir: string;
51
+ mount: string;
52
+ listener: string;
53
+ panes: PaneName[];
54
+ title: string;
55
+ relayNotes: readonly string[];
56
+ exclude: ((key: ChannelKey) => boolean) | undefined;
57
+ assetDir: string | undefined;
58
+ }
59
+
60
+ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
61
+
62
+ function fail(message: string): never {
63
+ throw new PluginError(`pi-roundtable-web: ${message}`);
64
+ }
65
+
66
+ function checkMount(path: string): string {
67
+ const mount = path.replace(/\/+$/, "");
68
+ const segments = mount.split("/").slice(1);
69
+ if (
70
+ !mount.startsWith("/") ||
71
+ segments.length === 0 ||
72
+ !segments.every(
73
+ (segment) => SEGMENT.test(segment) && segment !== "." && segment !== "..",
74
+ )
75
+ )
76
+ fail(
77
+ `mountPath ${JSON.stringify(path)} must be a path of at least one segment, such as /console`,
78
+ );
79
+ return mount;
80
+ }
81
+
82
+ function checkOrigin(origin: string): string {
83
+ let url: URL;
84
+ try {
85
+ url = new URL(origin);
86
+ } catch {
87
+ fail(
88
+ `origin ${JSON.stringify(origin)} is not a URL such as https://console.example.com`,
89
+ );
90
+ }
91
+ if (
92
+ (url.protocol !== "https:" && url.protocol !== "http:") ||
93
+ url.origin !== origin.replace(/\/+$/, "")
94
+ )
95
+ fail(
96
+ `origin ${JSON.stringify(origin)} must be only a scheme and host, such as https://console.example.com`,
97
+ );
98
+ return url.origin;
99
+ }
100
+
101
+ function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
102
+ if (panes === undefined) return [...PANES];
103
+ if (panes.length === 0) fail("panes is empty; serve at least one pane");
104
+ for (const pane of panes)
105
+ if (!PANES.includes(pane))
106
+ fail(
107
+ `unknown pane ${JSON.stringify(pane)}; the panes are ${PANES.join(", ")}`,
108
+ );
109
+ if (new Set(panes).size !== panes.length) fail("panes lists a pane twice");
110
+ return [...panes];
111
+ }
112
+
113
+ /** Checks the options at the point the plugin is created, so a bad setting stops the host before anything starts. */
114
+ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
115
+ if (typeof options.verifier !== "function")
116
+ fail(
117
+ "no verifier. The console serves the owner's conversations and notes, so it will not start without one that authenticates every request; pass `verifier: cloudflareAccess({...})` or your own",
118
+ );
119
+ const panes = checkPanes(options.panes);
120
+ const ownerId = options.ownerId?.trim();
121
+ if (panes.includes("notes") && !ownerId)
122
+ fail(
123
+ "ownerId is required for the notes pane: it is the id the owner's memory is kept under. Set it, or leave `notes` out of panes",
124
+ );
125
+ const title = options.title?.trim() ?? "Roundtable";
126
+ if (!title) fail("title is empty");
127
+ return {
128
+ verifier: options.verifier,
129
+ origin: checkOrigin(options.origin),
130
+ ownerId,
131
+ sessionsDir: join(options.dataDir ?? "data", "sessions"),
132
+ mount: checkMount(options.mountPath ?? "/console"),
133
+ listener: options.listener ?? "public",
134
+ panes,
135
+ title,
136
+ relayNotes: options.relayNotes ?? [DEFAULT_RELAY_NOTE],
137
+ exclude: options.exclude,
138
+ assetDir: options.assetDir,
139
+ };
140
+ }
@@ -0,0 +1,22 @@
1
+ /** The verdict on one request: admitted, or refused with a reason for the log. */
2
+ export type Verdict =
3
+ | { admitted: true }
4
+ | {
5
+ admitted: false;
6
+ /** Why, for the operator's log. It is never sent to the client, and it must not hold a secret. */
7
+ reason: string;
8
+ };
9
+
10
+ /**
11
+ * Decides whether a request comes from the owner. The console asks it for every request under
12
+ * its path, before anything else, and refuses with 403 when it says no, throws, or rejects.
13
+ * A verifier reads the request's headers, which the operator's proxy set after authenticating the
14
+ * owner, and must never trust a header a client can forge: see "Threat model" in the README.
15
+ */
16
+ export type RequestVerifier = (request: Request) => Verdict | Promise<Verdict>;
17
+
18
+ export const admit = (): Verdict => ({ admitted: true });
19
+ export const refuse = (reason: string): Verdict => ({
20
+ admitted: false,
21
+ reason,
22
+ });
@@ -0,0 +1,88 @@
1
+ import {
2
+ AGENTS,
3
+ definePlugin,
4
+ MEMORY,
5
+ type RoundtablePlugin,
6
+ } from "pi-roundtable";
7
+ import { DISCORD } from "pi-roundtable/discord";
8
+ import { loadAssets } from "./assets.ts";
9
+ import { ConsoleApi } from "./console-api.ts";
10
+ import { ConsoleServer } from "./console-server.ts";
11
+ import {
12
+ type ResolvedOptions,
13
+ resolveOptions,
14
+ type WebConsoleOptions,
15
+ } from "./options.ts";
16
+
17
+ /**
18
+ * The owner-only web console: conversations, transcripts, memory notes, and the agent team,
19
+ * served on a route of the host's listener and updated live. The options are checked here, so
20
+ * a missing verifier or a bad setting throws when the config is loaded.
21
+ */
22
+ export function webConsole(options: WebConsoleOptions): RoundtablePlugin {
23
+ const settings = resolveOptions(options);
24
+ return definePlugin({
25
+ name: "web-console",
26
+ requires: [
27
+ ...(settings.panes.includes("overview") ? [AGENTS] : []),
28
+ ...(settings.panes.includes("notes") ? [MEMORY] : []),
29
+ ],
30
+ setup: (context) => build(settings, context),
31
+ });
32
+ }
33
+
34
+ type Context = Parameters<RoundtablePlugin["setup"]>[0];
35
+
36
+ function build(settings: ResolvedOptions, context: Context) {
37
+ const { services, queue, logger, env } = context;
38
+ const team = services.find(AGENTS)?.team;
39
+ const connection = services.find(DISCORD)?.connection;
40
+ const listeners: (() => void)[] = [];
41
+ const changed = () => {
42
+ for (const listener of listeners) listener();
43
+ };
44
+ // A broken page stops startup here, before anything connects.
45
+ const assets = loadAssets(settings.assetDir);
46
+ const api = new ConsoleApi({
47
+ title: settings.title,
48
+ timeZone: env.timeZone,
49
+ panes: settings.panes,
50
+ sessionsDir: settings.sessionsDir,
51
+ ...(team ? { team } : {}),
52
+ queue,
53
+ ...(connection
54
+ ? { channelName: (id: string) => connection.channelInfo(id) }
55
+ : {}),
56
+ ...(settings.ownerId && settings.panes.includes("notes")
57
+ ? { memory: services.get(MEMORY).forSpeaker(settings.ownerId) }
58
+ : {}),
59
+ ...(settings.exclude ? { exclude: settings.exclude } : {}),
60
+ relayNotes: settings.relayNotes,
61
+ changed,
62
+ logger,
63
+ });
64
+ const server = new ConsoleServer({
65
+ mount: settings.mount,
66
+ assets,
67
+ verifier: settings.verifier,
68
+ origin: settings.origin,
69
+ api,
70
+ subscribe: (listener) => {
71
+ listeners.push(listener);
72
+ queue.onChange(listener);
73
+ team?.onChange(listener);
74
+ },
75
+ logger,
76
+ });
77
+ return {
78
+ services: [
79
+ {
80
+ name: "web-console",
81
+ start: () => server.start(),
82
+ stop: () => server.stop(),
83
+ },
84
+ ],
85
+ http: server.routes(settings.listener),
86
+ dashboard: [`Web console: ${settings.origin}${settings.mount}/`],
87
+ };
88
+ }