pi-roundtable-web 0.7.0 → 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/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Console</title>
7
7
 
8
- <link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-gkh6x5ag.js"></script></head>
8
+ <link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-32sbjm56.js"></script></head>
9
9
  <body>
10
10
  <div id="root"></div>
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-web",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "An owner-only web console for pi-roundtable: conversations, transcripts, and memory notes, with live updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,9 +47,11 @@
47
47
  "@types/bun": "1.4.2",
48
48
  "@types/react": "19.3.0",
49
49
  "@types/react-dom": "19.3.0",
50
- "pi-roundtable": "0.7.0",
50
+ "pi-roundtable": "0.7.3",
51
51
  "react": "19.3.0",
52
52
  "react-dom": "19.3.0",
53
+ "react-markdown": "10.1.0",
54
+ "remark-gfm": "4.0.1",
53
55
  "typescript": "7.0.2"
54
56
  }
55
57
  }
package/src/api-types.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  // The console's API shapes, shared by the server (`src/`) and the page (`web/`). Times are ISO strings.
2
2
 
3
3
  /** The panes the console can show; the options choose which ones a host serves. */
4
- export const PANES = ["overview", "conversations", "notes"] as const;
4
+ export const PANES = [
5
+ "overview",
6
+ "conversations",
7
+ "notes",
8
+ "skills",
9
+ "connectors",
10
+ ] as const;
5
11
  export type PaneName = (typeof PANES)[number];
6
12
 
7
13
  /** What the page needs before it can draw anything. */
@@ -10,6 +16,12 @@ export interface ConfigView {
10
16
  panes: PaneName[];
11
17
  /** The IANA zone the page formats times and event dates in: the host's `env.timeZone`. */
12
18
  timeZone: string;
19
+ locale?: string;
20
+ messages?: Readonly<Record<string, string>>;
21
+ cleanup?: boolean;
22
+ connectorAdminUrl?: string;
23
+ /** Only supplied for path-based routing; also the base for page/API URLs. */
24
+ mountPath?: string;
13
25
  }
14
26
 
15
27
  export type ChannelName =
@@ -53,6 +65,19 @@ export interface OverviewView {
53
65
  guildId: string;
54
66
  agents: AgentView[];
55
67
  groups: GroupView[];
68
+ workspaces?: (ConversationView & { schedules: number })[];
69
+ outside?: ConversationView[];
70
+ party?: PartyView[];
71
+ }
72
+
73
+ /** Compatibility dashboard shape for hosts migrating an existing console endpoint. */
74
+ export interface DashboardView {
75
+ agentGuildId: string;
76
+ agents: (AgentView & { key: string })[];
77
+ groups: (GroupView & { key: string })[];
78
+ workspaces: (ConversationView & { channelId: string; schedules: number })[];
79
+ outside: (ConversationView & { sessionId: string })[];
80
+ party: PartyView[];
56
81
  }
57
82
 
58
83
  /** Who a stored conversation belongs to. */
@@ -122,6 +147,46 @@ export interface NoteInput {
122
147
  eventDate?: string;
123
148
  }
124
149
 
150
+ export interface PartyView {
151
+ key: string;
152
+ channelId: string;
153
+ channel: ChannelName;
154
+ profile: string;
155
+ enabledBy: string;
156
+ enabledAt: string;
157
+ container: "missing" | "running" | "stopped" | "unknown";
158
+ busy: number;
159
+ lastActive?: string;
160
+ }
161
+
162
+ export interface SkillView {
163
+ name: string;
164
+ source:
165
+ | { kind: "builtin" }
166
+ | { kind: "linked"; repo: string; path: string }
167
+ | { kind: "written" };
168
+ description?: string;
169
+ missing?: string;
170
+ groups: string[];
171
+ carriers: string[];
172
+ }
173
+
174
+ export interface SkillDetailView {
175
+ name: string;
176
+ metadata: { key: string; value: string }[];
177
+ body: string;
178
+ }
179
+
180
+ export interface ConnectorsView {
181
+ gateways: {
182
+ name: string;
183
+ enabled: boolean;
184
+ reachable: boolean;
185
+ tools: number;
186
+ }[];
187
+ servers: { name: string; tools: string[]; usedBy: string[] }[];
188
+ }
189
+
125
190
  /** Every error body the API returns. */
126
191
  export interface ApiError {
127
192
  error: string;
@@ -0,0 +1,54 @@
1
+ import { join } from "node:path";
2
+ import type { AgentTeam, ChannelKey } from "pi-roundtable";
3
+ import { conversationFiles, parseKey } from "./conversations.ts";
4
+ import type { ConsoleFeatures } from "./features.ts";
5
+ import { ConsoleHttpError as HttpError, json } from "./http.ts";
6
+
7
+ interface CleanupPorts {
8
+ features?: ConsoleFeatures;
9
+ exclude?: (key: ChannelKey) => boolean;
10
+ team?: Pick<AgentTeam, "owns" | "status">;
11
+ sessionsDir: string;
12
+ changed(): void;
13
+ }
14
+
15
+ /** The console validates ownership before invoking destructive host operations. */
16
+ export async function cleanupConversation(
17
+ ports: CleanupPorts,
18
+ raw: string,
19
+ action: "start-over" | "delete",
20
+ ): Promise<Response> {
21
+ const { features, exclude, team, sessionsDir } = ports;
22
+ const cleanup = features?.cleanup;
23
+ const key = raw as ChannelKey;
24
+ const parsed = parseKey(raw);
25
+ if (!cleanup || !parsed || parsed.kind === "group" || exclude?.(key))
26
+ throw new HttpError(404, "There is no such conversation.");
27
+ const owned =
28
+ Boolean(team?.owns(key)) || Boolean(features?.party?.contains(key));
29
+ const group =
30
+ (await team?.status())?.groups.some(
31
+ (g) => `discord:${g.channelId}` === key,
32
+ ) ?? false;
33
+ const stored =
34
+ !owned &&
35
+ !group &&
36
+ Boolean(conversationFiles(join(sessionsDir, parsed.dir)));
37
+ if (action === "delete") {
38
+ if (!stored)
39
+ throw new HttpError(
40
+ 404,
41
+ "Only workspace and outside conversations can be deleted.",
42
+ );
43
+ const result = await cleanup.deleteConversation(key);
44
+ if (result === "busy")
45
+ throw new HttpError(409, "The conversation is busy. Try again later.");
46
+ ports.changed();
47
+ return json({ result });
48
+ }
49
+ if (!owned && !group && !stored)
50
+ throw new HttpError(404, "There is no such conversation.");
51
+ const kind = await cleanup.startFresh(key);
52
+ ports.changed();
53
+ return json({ kind });
54
+ }
@@ -0,0 +1,26 @@
1
+ import type { ConnectorsView } from "./api-types.ts";
2
+ import type { ConsoleFeatures } from "./features.ts";
3
+ import { ConsoleHttpError as HttpError } from "./http.ts";
4
+
5
+ export async function connectorsView(
6
+ connectors: NonNullable<ConsoleFeatures["connectors"]>,
7
+ ): Promise<ConnectorsView> {
8
+ try {
9
+ const [gateways, servers] = await Promise.all([
10
+ connectors.gateways(),
11
+ connectors.servers(),
12
+ ]);
13
+ return {
14
+ gateways,
15
+ servers: servers.map((server) => ({
16
+ ...server,
17
+ usedBy: connectors.usedBy(server.name),
18
+ })),
19
+ };
20
+ } catch {
21
+ throw new HttpError(
22
+ 502,
23
+ "The connector service did not respond. Try again later.",
24
+ );
25
+ }
26
+ }
@@ -16,6 +16,7 @@ import type {
16
16
  ConversationKind,
17
17
  ConversationsView,
18
18
  ConversationView,
19
+ DashboardView,
19
20
  NoteInput,
20
21
  NoteView,
21
22
  OverviewView,
@@ -31,6 +32,13 @@ import {
31
32
  storedConversations,
32
33
  } from "./conversations.ts";
33
34
 
35
+ import {
36
+ type ConsoleFeatures,
37
+ type ConsolePresentation,
38
+ message,
39
+ safeAdminUrl,
40
+ } from "./features.ts";
41
+
34
42
  const NOTE_SEARCH_LIMIT = 500;
35
43
  /** The largest note body the console reads. */
36
44
  const MAX_BODY_BYTES = 64 * 1024;
@@ -58,22 +66,15 @@ export interface ConsolePorts {
58
66
  /** Called after a note is written, so every open page refreshes. */
59
67
  changed(): void;
60
68
  logger: Logger;
69
+ features?: ConsoleFeatures;
70
+ presentation?: ConsolePresentation;
71
+ mountPath?: string;
61
72
  }
62
73
 
63
- class HttpError extends Error {
64
- constructor(
65
- readonly status: number,
66
- message: string,
67
- ) {
68
- super(message);
69
- }
70
- }
71
-
72
- const json = (body: unknown, status = 200) =>
73
- Response.json(body, {
74
- status,
75
- headers: { "cache-control": "no-store" },
76
- });
74
+ import { cleanupConversation } from "./cleanup-api.ts";
75
+ import { connectorsView } from "./connectors-api.ts";
76
+ import { ConsoleHttpError as HttpError, json } from "./http.ts";
77
+ import { skillCatalog, skillDetail } from "./skills-api.ts";
77
78
 
78
79
  /** Memory refusals as the Notes pane shows them. */
79
80
  const MEMORY_REASONS: Record<string, string> = {
@@ -105,14 +106,27 @@ export class ConsoleApi {
105
106
  return await this.#route(request, path);
106
107
  } catch (error) {
107
108
  if (error instanceof HttpError)
108
- return json({ error: error.message } satisfies ApiError, error.status);
109
+ return json(
110
+ {
111
+ error: message(this.#ports.presentation, error.message),
112
+ } satisfies ApiError,
113
+ error.status,
114
+ );
109
115
  if (error instanceof MemoryError)
110
- return json({ error: memoryReason(error) } satisfies ApiError, 400);
116
+ return json(
117
+ {
118
+ error: message(this.#ports.presentation, memoryReason(error)),
119
+ } satisfies ApiError,
120
+ 400,
121
+ );
111
122
  // The URL stays out of the log: a path may hold something private.
112
123
  this.#ports.logger.error({ err: error }, "console api failed");
113
124
  return json(
114
125
  {
115
- error: "The console could not complete the request.",
126
+ error: message(
127
+ this.#ports.presentation,
128
+ "The console could not complete the request.",
129
+ ),
116
130
  } satisfies ApiError,
117
131
  500,
118
132
  );
@@ -135,9 +149,31 @@ export class ConsoleApi {
135
149
  const [head, id] = parts;
136
150
  if (method === "GET" && head === "config" && !id)
137
151
  return json(this.config());
138
- if (method === "GET" && head === "overview" && !id) {
152
+ if (
153
+ method === "GET" &&
154
+ (head === "overview" || head === "dashboard") &&
155
+ !id
156
+ ) {
139
157
  this.#pane("overview");
140
- return json(await this.overview());
158
+ const view = await this.overview();
159
+ if (head === "overview") return json(view);
160
+ return json({
161
+ agentGuildId: view.guildId,
162
+ agents: view.agents.map((agent) => ({
163
+ ...agent,
164
+ key: agent.channelId ? `discord:${agent.channelId}` : "",
165
+ })),
166
+ groups: view.groups.map((group) => ({
167
+ ...group,
168
+ key: `discord:${group.channelId}`,
169
+ })),
170
+ workspaces: (view.workspaces ?? []).map((c) => ({
171
+ ...c,
172
+ channelId: c.id,
173
+ })),
174
+ outside: (view.outside ?? []).map((c) => ({ ...c, sessionId: c.id })),
175
+ party: view.party ?? [],
176
+ } satisfies DashboardView);
141
177
  }
142
178
  if (method === "GET" && head === "conversations" && parts.length <= 2) {
143
179
  this.#pane("conversations");
@@ -145,6 +181,23 @@ export class ConsoleApi {
145
181
  const archive = new URL(request.url).searchParams.get("archive");
146
182
  return json(await this.transcript(id, archive ?? undefined));
147
183
  }
184
+ if (method === "POST" && head === "channels" && id && parts.length === 3) {
185
+ this.#pane("overview");
186
+ if (parts[2] === "start-over" || parts[2] === "delete")
187
+ return cleanupConversation(this.#ports, id, parts[2]);
188
+ }
189
+ if (method === "GET" && head === "skills" && parts.length <= 2) {
190
+ this.#pane("skills");
191
+ const skills = this.#ports.features?.skills;
192
+ if (!skills) throw new HttpError(404, "This pane is not served.");
193
+ return json(id ? await skillDetail(skills, id) : skillCatalog(skills));
194
+ }
195
+ if (method === "GET" && head === "connectors" && parts.length === 1) {
196
+ this.#pane("connectors");
197
+ const connectors = this.#ports.features?.connectors;
198
+ if (!connectors) throw new HttpError(404, "This pane is not served.");
199
+ return json(await connectorsView(connectors));
200
+ }
148
201
  if (head === "notes" && parts.length <= 2) {
149
202
  this.#pane("notes");
150
203
  return this.#notes(request, id);
@@ -154,7 +207,18 @@ export class ConsoleApi {
154
207
 
155
208
  config(): ConfigView {
156
209
  const { title, panes, timeZone } = this.#ports;
157
- return { title, panes: [...panes], timeZone };
210
+ const { presentation, features } = this.#ports;
211
+ const connectorAdminUrl = safeAdminUrl(features?.connectors?.adminUrl);
212
+ return {
213
+ title,
214
+ panes: [...panes],
215
+ timeZone,
216
+ ...(presentation?.locale ? { locale: presentation.locale } : {}),
217
+ ...(presentation?.messages ? { messages: presentation.messages } : {}),
218
+ ...(features?.cleanup ? { cleanup: true } : {}),
219
+ ...(connectorAdminUrl ? { connectorAdminUrl } : {}),
220
+ ...(this.#ports.mountPath ? { mountPath: this.#ports.mountPath } : {}),
221
+ };
158
222
  }
159
223
 
160
224
  // ── Overview ───────────────────────────────────────────────────────────
@@ -163,7 +227,23 @@ export class ConsoleApi {
163
227
  const { team } = this.#ports;
164
228
  if (!team) throw new HttpError(404, "This pane is not served.");
165
229
  const status = await team.status();
230
+ const { features } = this.#ports;
231
+ const stored = (await this.conversations()).conversations.filter(
232
+ (c) => c.kind === "owner" || c.kind === "outside",
233
+ );
234
+ const workspaces = await Promise.all(
235
+ stored
236
+ .filter((c) => c.kind === "owner")
237
+ .map(async (c) => ({
238
+ ...c,
239
+ schedules:
240
+ (await features?.schedules?.count(c.key as ChannelKey)) ?? 0,
241
+ })),
242
+ );
166
243
  return {
244
+ workspaces,
245
+ outside: stored.filter((c) => c.kind === "outside"),
246
+ party: (await features?.party?.list()) ?? [],
167
247
  guildId: team.guildId,
168
248
  agents: status.agents.map((agent) => ({
169
249
  name: agent.name,
@@ -194,7 +274,9 @@ export class ConsoleApi {
194
274
  async conversations(): Promise<ConversationsView> {
195
275
  const { sessionsDir, relayNotes, exclude } = this.#ports;
196
276
  const stored = storedConversations(sessionsDir, {
197
- excluded: (key) => exclude?.(key) ?? false,
277
+ excluded: (key) =>
278
+ (exclude?.(key) ?? false) ||
279
+ (this.#ports.features?.party?.contains(key) ?? false),
198
280
  relayNotes,
199
281
  });
200
282
  return {
@@ -208,7 +290,11 @@ export class ConsoleApi {
208
290
  ): Promise<TranscriptView> {
209
291
  const { sessionsDir, relayNotes, exclude } = this.#ports;
210
292
  const parsed = parseKey(key);
211
- if (!parsed || exclude?.(key as ChannelKey))
293
+ if (
294
+ !parsed ||
295
+ exclude?.(key as ChannelKey) ||
296
+ this.#ports.features?.party?.contains(key as ChannelKey)
297
+ )
212
298
  throw new HttpError(404, "There is no such conversation.");
213
299
  const dir = join(sessionsDir, parsed.dir);
214
300
  const files = conversationFiles(dir);
@@ -1,6 +1,8 @@
1
1
  import type { HttpRoute, Logger } from "pi-roundtable";
2
2
  import type { AssetBundle } from "./assets.ts";
3
3
  import type { ConsoleApi } from "./console-api.ts";
4
+ import type { ConsolePresentation } from "./features.ts";
5
+ import { presentPage } from "./page-presentation.ts";
4
6
  import type { RequestVerifier } from "./verifier.ts";
5
7
 
6
8
  const encoder = new TextEncoder();
@@ -39,6 +41,9 @@ export interface ConsoleServerOptions {
39
41
  logger: Logger;
40
42
  /** How long changes are gathered into one event; default 250 ms. Tests set it lower. */
41
43
  coalesceMs?: number;
44
+ /** Serve client page paths as the same authenticated document. */
45
+ pathRouting?: boolean;
46
+ presentation?: ConsolePresentation;
42
47
  }
43
48
 
44
49
  /**
@@ -106,12 +111,7 @@ export class ConsoleServer {
106
111
  async #respond(request: Request): Promise<Response> {
107
112
  // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
108
113
  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))
114
+ if (path !== this.#options.mount && !path.startsWith(this.#base))
115
115
  return new Response("Not found", { status: 404 });
116
116
  const refusal = await this.#refusal(request);
117
117
  if (refusal !== undefined) {
@@ -121,6 +121,11 @@ export class ConsoleServer {
121
121
  headers: { "cache-control": "no-store" },
122
122
  });
123
123
  }
124
+ if (path === this.#options.mount)
125
+ return new Response(null, {
126
+ status: 302,
127
+ headers: { location: this.#base },
128
+ });
124
129
  const rest = path.slice(this.#base.length);
125
130
  if (rest === "api/events" && request.method === "GET")
126
131
  return this.#events();
@@ -128,18 +133,37 @@ export class ConsoleServer {
128
133
  return this.#options.api.handle(request, rest.slice("api/".length));
129
134
  if (!READ_METHODS.has(request.method))
130
135
  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
136
  const asset = this.#options.assets.get(rest);
136
137
  if (asset && rest !== "index.html")
137
- return new Response(asset.body, {
138
+ return new Response(new Uint8Array(asset.body), {
138
139
  headers: {
139
140
  "content-type": asset.type,
140
141
  "cache-control": "private, max-age=31536000, immutable",
141
142
  },
142
143
  });
144
+ if (rest === "" || rest === "index.html" || this.#options.pathRouting) {
145
+ const page = this.#options.assets.get("index.html");
146
+ if (page)
147
+ return new Response(
148
+ presentPage(
149
+ page.body,
150
+ this.#options.pathRouting ? this.#options.mount : undefined,
151
+ this.#options.presentation,
152
+ ),
153
+ {
154
+ headers: {
155
+ ...PAGE_HEADERS,
156
+ ...(this.#options.pathRouting
157
+ ? {
158
+ "content-security-policy": PAGE_HEADERS[
159
+ "content-security-policy"
160
+ ].replace("base-uri 'none'", "base-uri 'self'"),
161
+ }
162
+ : {}),
163
+ },
164
+ },
165
+ );
166
+ }
143
167
  return new Response("Not found", { status: 404 });
144
168
  }
145
169
 
@@ -2,9 +2,9 @@ import {
2
2
  closeSync,
3
3
  existsSync,
4
4
  fstatSync,
5
+ lstatSync,
5
6
  openSync,
6
7
  readdirSync,
7
- readFileSync,
8
8
  readSync,
9
9
  statSync,
10
10
  } from "node:fs";
@@ -82,21 +82,22 @@ export function parseKey(
82
82
  * folders, as `archiveSessions` leaves them. Undefined when the directory does not exist.
83
83
  */
84
84
  export function conversationFiles(dir: string): ConversationFiles | undefined {
85
- if (!existsSync(dir)) return undefined;
85
+ if (!existsSync(dir) || !lstatSync(dir).isDirectory()) return undefined;
86
86
  let liveBytes = 0;
87
87
  let newest = 0;
88
- for (const file of readdirSync(dir)) {
89
- if (!file.endsWith(".jsonl")) continue;
90
- const stat = statSync(join(dir, file));
88
+ for (const file of readdirSync(dir, { withFileTypes: true })) {
89
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
90
+ const stat = statSync(join(dir, file.name));
91
91
  liveBytes += stat.size;
92
92
  newest = Math.max(newest, stat.mtimeMs);
93
93
  }
94
94
  const archiveDir = join(dir, "archive");
95
- const archives = existsSync(archiveDir)
96
- ? readdirSync(archiveDir, { withFileTypes: true }).filter((entry) =>
97
- entry.isDirectory(),
98
- )
99
- : [];
95
+ const archives =
96
+ existsSync(archiveDir) && lstatSync(archiveDir).isDirectory()
97
+ ? readdirSync(archiveDir, { withFileTypes: true }).filter((entry) =>
98
+ entry.isDirectory(),
99
+ )
100
+ : [];
100
101
  if (newest === 0)
101
102
  for (const archive of archives)
102
103
  newest = Math.max(
@@ -133,8 +134,9 @@ function withoutRelayNote(text: string, notes: readonly string[]): string {
133
134
 
134
135
  /** The `.jsonl` file names directly in a directory, oldest first; names start with their creation time. */
135
136
  function sessionFiles(dir: string): string[] {
136
- return readdirSync(dir)
137
- .filter((file) => file.endsWith(".jsonl"))
137
+ return readdirSync(dir, { withFileTypes: true })
138
+ .filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
139
+ .map((file) => file.name)
138
140
  .sort();
139
141
  }
140
142
 
@@ -164,8 +166,11 @@ function opening(
164
166
  const archiveDir = join(dir, "archive");
165
167
  const first = sessionFiles(dir)[0];
166
168
  let file = first ? join(dir, first) : undefined;
167
- if (!file && existsSync(archiveDir))
168
- for (const archive of readdirSync(archiveDir).sort()) {
169
+ if (!file && existsSync(archiveDir) && lstatSync(archiveDir).isDirectory())
170
+ for (const archive of readdirSync(archiveDir, { withFileTypes: true })
171
+ .filter((entry) => entry.isDirectory())
172
+ .map((entry) => entry.name)
173
+ .sort()) {
169
174
  const name = sessionFiles(join(archiveDir, archive))[0];
170
175
  if (name) {
171
176
  file = join(archiveDir, archive, name);
@@ -251,7 +256,8 @@ export function storedConversations(
251
256
  /** The archive folders of a conversation, newest first. */
252
257
  export function archiveNames(dir: string): string[] {
253
258
  const archiveDir = join(dir, "archive");
254
- if (!existsSync(archiveDir)) return [];
259
+ if (!existsSync(archiveDir) || !lstatSync(archiveDir).isDirectory())
260
+ return [];
255
261
  return readdirSync(archiveDir, { withFileTypes: true })
256
262
  .filter((entry) => entry.isDirectory())
257
263
  .map((entry) => entry.name)
@@ -270,12 +276,12 @@ function readTail(
270
276
  const fd = openSync(path, "r");
271
277
  try {
272
278
  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
+ const length = Math.min(size, limit);
280
+ const buffer = Buffer.alloc(length);
281
+ const read = readSync(fd, buffer, 0, length, Math.max(0, size - limit));
282
+ const text = buffer.subarray(0, read).toString("utf8");
283
+ const cut = size > limit;
284
+ return { text: cut ? text.slice(text.indexOf("\n") + 1) : text, cut, read };
279
285
  } finally {
280
286
  closeSync(fd);
281
287
  }
@@ -0,0 +1,59 @@
1
+ import type { ChannelKey } from "pi-roundtable";
2
+ import type { ConnectorsView, PartyView, SkillView } from "./api-types.ts";
3
+
4
+ /** Trusted host integrations. They receive validated keys, never request-selected file paths. */
5
+ export interface ConsoleFeatures {
6
+ party?: {
7
+ contains(key: ChannelKey): boolean;
8
+ /** The host binds its channel store, runtime status and existing session paths here. */
9
+ list(): Promise<PartyView[]>;
10
+ };
11
+ schedules?: { count(key: ChannelKey): Promise<number> };
12
+ cleanup?: {
13
+ /** Archives the conversation, preserving memory; the host waits for active turns. */
14
+ startFresh(key: ChannelKey): Promise<string>;
15
+ /** Only stored owner/outside conversations are admitted by the console. */
16
+ deleteConversation(key: ChannelKey): Promise<"deleted" | "busy">;
17
+ };
18
+ skills?: {
19
+ catalog(): SkillView[];
20
+ /** Read only the selected catalog entry, with a host-enforced byte limit. */
21
+ read(
22
+ name: string,
23
+ ): Promise<{ frontmatter: Record<string, unknown>; body: string }>;
24
+ };
25
+ connectors?: {
26
+ gateways(): Promise<ConnectorsView["gateways"]>;
27
+ servers(): Promise<{ name: string; tools: string[] }[]>;
28
+ usedBy(server: string): string[];
29
+ /** Optional administrator link, limited to HTTP(S) or an absolute local path. */
30
+ adminUrl?: string;
31
+ };
32
+ }
33
+
34
+ /** English source strings are keys; a host supplies its own wording without a page fork. */
35
+ export interface ConsolePresentation {
36
+ locale?: string;
37
+ messages?: Readonly<Record<string, string>>;
38
+ }
39
+
40
+ export function message(
41
+ presentation: ConsolePresentation | undefined,
42
+ source: string,
43
+ ): string {
44
+ return presentation?.messages?.[source] ?? source;
45
+ }
46
+
47
+ export function safeAdminUrl(url: string | undefined): string | undefined {
48
+ if (!url) return undefined;
49
+ if (url.startsWith("/") && !url.startsWith("//") && !url.includes("\\"))
50
+ return url;
51
+ try {
52
+ const parsed = new URL(url);
53
+ return parsed.protocol === "https:" || parsed.protocol === "http:"
54
+ ? parsed.href
55
+ : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
package/src/http.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Fixed errors shared by console API adapters; request paths and upstream details stay private. */
2
+ export class ConsoleHttpError extends Error {
3
+ constructor(
4
+ readonly status: number,
5
+ message: string,
6
+ ) {
7
+ super(message);
8
+ }
9
+ }
10
+ export const json = (body: unknown, status = 200): Response =>
11
+ Response.json(body, { status, headers: { "cache-control": "no-store" } });