pi-roundtable-web 0.7.2 → 0.7.3

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/index.ts CHANGED
@@ -5,16 +5,21 @@ export type {
5
5
  ApiError,
6
6
  ChannelName,
7
7
  ConfigView,
8
+ ConnectorsView,
8
9
  ContextView,
9
10
  ConversationKind,
10
11
  ConversationsView,
11
12
  ConversationView,
13
+ DashboardView,
12
14
  GroupView,
13
15
  NoteInput,
14
16
  NoteKind,
15
17
  NoteView,
16
18
  OverviewView,
17
19
  PaneName,
20
+ PartyView,
21
+ SkillDetailView,
22
+ SkillView,
18
23
  TranscriptEntry,
19
24
  TranscriptRole,
20
25
  TranscriptView,
@@ -22,6 +27,9 @@ export type {
22
27
  export { PANES } from "./api-types.ts";
23
28
  export type { CloudflareAccessOptions } from "./cloudflare-access.ts";
24
29
  export { cloudflareAccess } from "./cloudflare-access.ts";
30
+ /** Summary for a trusted host's separately stored party sessions. */
31
+ export { conversationFiles as sessionSummary } from "./conversations.ts";
32
+ export type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
25
33
  export type { WebConsoleOptions } from "./options.ts";
26
34
  export { DEFAULT_RELAY_NOTE } from "./options.ts";
27
35
  export type { RequestVerifier, Verdict } from "./verifier.ts";
package/src/options.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { join } from "node:path";
2
- import type { ChannelKey } from "pi-roundtable";
2
+ import type { ChannelKey, RoundtablePlugin } from "pi-roundtable";
3
3
  import { PluginError } from "pi-roundtable";
4
4
  import { PANES, type PaneName } from "./api-types.ts";
5
+ import type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
5
6
  import type { RequestVerifier } from "./verifier.ts";
6
7
 
7
8
  /**
@@ -17,6 +18,14 @@ export interface WebConsoleOptions {
17
18
  * built-in one. Without it the plugin refuses to start rather than serve without authentication.
18
19
  */
19
20
  verifier: RequestVerifier;
21
+ /** Host-specific data and actions, resolved against public plugin setup context. */
22
+ features?:
23
+ | ConsoleFeatures
24
+ | ((
25
+ context: Parameters<RoundtablePlugin["setup"]>[0],
26
+ ) => ConsoleFeatures | Promise<ConsoleFeatures>);
27
+ /** Localized labels and error text; English source strings are dictionary keys. */
28
+ presentation?: ConsolePresentation;
20
29
  /**
21
30
  * The console's own origin, such as `https://console.example.com`: requests that change data
22
31
  * must carry it as their `Origin`, and the dashboard line links to it.
@@ -30,6 +39,8 @@ export interface WebConsoleOptions {
30
39
  mountPath?: string;
31
40
  /** The listener whose address serves it; default `public`. */
32
41
  listener?: string;
42
+ /** Hash routes by default; path routes retain links such as /console/skills. */
43
+ routing?: "hash" | "path";
33
44
  /** The panes to serve, in the order the page lists them; default all of `overview`, `conversations`, `notes`. */
34
45
  panes?: readonly PaneName[];
35
46
  /** The page's title and heading; default `Roundtable`. */
@@ -50,11 +61,14 @@ export interface ResolvedOptions {
50
61
  sessionsDir: string;
51
62
  mount: string;
52
63
  listener: string;
64
+ routing: "hash" | "path";
53
65
  panes: PaneName[];
54
66
  title: string;
55
67
  relayNotes: readonly string[];
56
68
  exclude: ((key: ChannelKey) => boolean) | undefined;
57
69
  assetDir: string | undefined;
70
+ features: WebConsoleOptions["features"];
71
+ presentation: ConsolePresentation | undefined;
58
72
  }
59
73
 
60
74
  const SEGMENT = /^[A-Za-z0-9._~-]+$/;
@@ -99,7 +113,7 @@ function checkOrigin(origin: string): string {
99
113
  }
100
114
 
101
115
  function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
102
- if (panes === undefined) return [...PANES];
116
+ if (panes === undefined) return ["overview", "conversations", "notes"];
103
117
  if (panes.length === 0) fail("panes is empty; serve at least one pane");
104
118
  for (const pane of panes)
105
119
  if (!PANES.includes(pane))
@@ -116,6 +130,19 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
116
130
  fail(
117
131
  "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
132
  );
133
+ if (
134
+ options.routing !== undefined &&
135
+ options.routing !== "path" &&
136
+ options.routing !== "hash"
137
+ )
138
+ fail("routing must be hash or path");
139
+ if (options.presentation?.locale !== undefined) {
140
+ try {
141
+ new Intl.DateTimeFormat(options.presentation.locale);
142
+ } catch {
143
+ fail("presentation.locale is not a valid locale");
144
+ }
145
+ }
119
146
  const panes = checkPanes(options.panes);
120
147
  const ownerId = options.ownerId?.trim();
121
148
  if (panes.includes("notes") && !ownerId)
@@ -131,10 +158,13 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
131
158
  sessionsDir: join(options.dataDir ?? "data", "sessions"),
132
159
  mount: checkMount(options.mountPath ?? "/console"),
133
160
  listener: options.listener ?? "public",
161
+ routing: options.routing ?? "hash",
134
162
  panes,
135
163
  title,
136
164
  relayNotes: options.relayNotes ?? [DEFAULT_RELAY_NOTE],
137
165
  exclude: options.exclude,
138
166
  assetDir: options.assetDir,
167
+ features: options.features,
168
+ presentation: options.presentation,
139
169
  };
140
170
  }
@@ -0,0 +1,30 @@
1
+ import type { ConsolePresentation } from "./features.ts";
2
+
3
+ const attribute = (value: string): string =>
4
+ value
5
+ .replaceAll("&", "&amp;")
6
+ .replaceAll('"', "&quot;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll("'", "&#39;");
10
+
11
+ /** Authenticated, inert bootstrap data localizes even a failed first API request without inline scripts. */
12
+ export function presentPage(
13
+ body: Uint8Array,
14
+ mount: string | undefined,
15
+ presentation: ConsolePresentation | undefined,
16
+ ): Uint8Array<ArrayBuffer> | string {
17
+ if (!mount && !presentation) return new Uint8Array(body);
18
+ let head = "<head>";
19
+ if (mount) head += `<base href="${attribute(`${mount}/`)}">`;
20
+ if (presentation)
21
+ head += `<meta name="roundtable-console-presentation" content="${attribute(JSON.stringify(presentation))}">`;
22
+ let html = new TextDecoder().decode(body).replace("<head>", () => head);
23
+ if (presentation?.locale)
24
+ html = html.replace(
25
+ /(<html[^>]*lang=")[^"]*(")/,
26
+ (_all, prefix: string, suffix: string) =>
27
+ `${prefix}${attribute(presentation.locale ?? "en")}${suffix}`,
28
+ );
29
+ return html;
30
+ }
@@ -0,0 +1,77 @@
1
+ import type { SkillDetailView, SkillView } from "./api-types.ts";
2
+ import type { ConsoleFeatures } from "./features.ts";
3
+ import { ConsoleHttpError as HttpError } from "./http.ts";
4
+
5
+ /** Strip private file paths from a host catalog explicitly. */
6
+ export function skillCatalog(
7
+ skills: NonNullable<ConsoleFeatures["skills"]>,
8
+ ): SkillView[] {
9
+ return skills
10
+ .catalog()
11
+ .map(({ name, source, description, missing, groups, carriers }) => ({
12
+ name,
13
+ source,
14
+ ...(description ? { description } : {}),
15
+ ...(missing ? { missing } : {}),
16
+ groups,
17
+ carriers,
18
+ }));
19
+ }
20
+
21
+ /** Read a catalog-selected skill, bound its document, and return ordered frontmatter. */
22
+ export async function skillDetail(
23
+ skills: NonNullable<ConsoleFeatures["skills"]>,
24
+ name: string,
25
+ ): Promise<SkillDetailView> {
26
+ const entry = skills.catalog().find((s) => s.name === name);
27
+ if (!entry) throw new HttpError(404, "There is no such skill.");
28
+ if (entry.missing) throw new HttpError(404, "The skill file is missing.");
29
+ try {
30
+ const parsed = await skills.read(name);
31
+ if (
32
+ Buffer.byteLength(parsed.body) > 1024 * 1024 ||
33
+ Buffer.byteLength(JSON.stringify(parsed.frontmatter)) > 64 * 1024
34
+ )
35
+ throw new HttpError(413, "The skill file is too large.");
36
+ return {
37
+ name,
38
+ body: parsed.body,
39
+ metadata: metadataEntries(parsed.frontmatter),
40
+ };
41
+ } catch (error) {
42
+ if (error instanceof HttpError) throw error;
43
+ throw new HttpError(422, "The skill frontmatter could not be read.");
44
+ }
45
+ }
46
+
47
+ /** Flatten nested frontmatter in source order, keeping scalar/array values readable. */
48
+ function metadataEntries(
49
+ record: Record<string, unknown>,
50
+ prefix = "",
51
+ depth = 0,
52
+ ): SkillDetailView["metadata"] {
53
+ return Object.entries(record).flatMap(([key, value]) => {
54
+ const path = prefix ? `${prefix}.${key}` : key;
55
+ if (
56
+ depth < 20 &&
57
+ value !== null &&
58
+ typeof value === "object" &&
59
+ !Array.isArray(value)
60
+ )
61
+ return metadataEntries(value as Record<string, unknown>, path, depth + 1);
62
+ return [
63
+ {
64
+ key: path,
65
+ value: Array.isArray(value)
66
+ ? value
67
+ .map((v) =>
68
+ typeof v === "object" ? JSON.stringify(v) : String(v ?? ""),
69
+ )
70
+ .join(", ")
71
+ : typeof value === "object" && value !== null
72
+ ? JSON.stringify(value)
73
+ : String(value ?? ""),
74
+ },
75
+ ];
76
+ });
77
+ }
package/src/web-plugin.ts CHANGED
@@ -33,8 +33,16 @@ export function webConsole(options: WebConsoleOptions): RoundtablePlugin {
33
33
 
34
34
  type Context = Parameters<RoundtablePlugin["setup"]>[0];
35
35
 
36
- function build(settings: ResolvedOptions, context: Context) {
36
+ async function build(settings: ResolvedOptions, context: Context) {
37
37
  const { services, queue, logger, env } = context;
38
+ const features =
39
+ typeof settings.features === "function"
40
+ ? await settings.features(context)
41
+ : settings.features;
42
+ for (const pane of ["skills", "connectors"] as const) {
43
+ if (settings.panes.includes(pane) && !features?.[pane])
44
+ throw new Error(`web-console: the ${pane} pane needs its feature port`);
45
+ }
38
46
  const team = services.find(AGENTS)?.team;
39
47
  const connection = services.find(DISCORD)?.connection;
40
48
  const listeners: (() => void)[] = [];
@@ -60,12 +68,17 @@ function build(settings: ResolvedOptions, context: Context) {
60
68
  relayNotes: settings.relayNotes,
61
69
  changed,
62
70
  logger,
71
+ ...(features ? { features } : {}),
72
+ ...(settings.routing === "path" ? { mountPath: settings.mount } : {}),
73
+ ...(settings.presentation ? { presentation: settings.presentation } : {}),
63
74
  });
64
75
  const server = new ConsoleServer({
65
76
  mount: settings.mount,
66
77
  assets,
67
78
  verifier: settings.verifier,
68
79
  origin: settings.origin,
80
+ pathRouting: settings.routing === "path",
81
+ ...(settings.presentation ? { presentation: settings.presentation } : {}),
69
82
  api,
70
83
  subscribe: (listener) => {
71
84
  listeners.push(listener);