pi-roundtable-web 0.7.18 → 0.8.0

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-mr32cg74.js"></script></head>
8
+ <link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-2cm6wak5.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.18",
3
+ "version": "0.8.0",
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",
@@ -40,14 +40,14 @@
40
40
  "jose": "6.2.12"
41
41
  },
42
42
  "peerDependencies": {
43
- "pi-roundtable": ">=0.7.0 <0.8.0"
43
+ "pi-roundtable": ">=0.8.0 <0.9.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@biomejs/biome": "2.5.15",
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.18",
50
+ "pi-roundtable": "0.8.0",
51
51
  "react": "19.3.0",
52
52
  "react-dom": "19.3.0",
53
53
  "react-markdown": "10.1.0",
package/src/api-types.ts CHANGED
@@ -80,8 +80,16 @@ export interface DashboardView {
80
80
  party: PartyView[];
81
81
  }
82
82
 
83
- /** Who a stored conversation belongs to. */
84
- export type ConversationKind = "agent" | "group" | "owner" | "outside";
83
+ /**
84
+ * Who a stored conversation belongs to; `plugin` is one a plugin runs through `context.turns`,
85
+ * such as a web chat, as the host's registry records it.
86
+ */
87
+ export type ConversationKind =
88
+ | "agent"
89
+ | "group"
90
+ | "owner"
91
+ | "outside"
92
+ | "plugin";
85
93
 
86
94
  export interface ConversationView {
87
95
  /** The channel key: `discord:<id>`, `agentgroup:<channel>.<agent>`, or `mcp:<session>`. */
@@ -97,9 +105,11 @@ export interface ConversationView {
97
105
  liveBytes: number;
98
106
  archives: number;
99
107
  lastActive?: string;
100
- /** Outside-agent conversations only: up to 80 characters of the first message. */
108
+ /** Outside-agent and plugin conversations only: up to 80 characters of the first message. */
101
109
  firstMessage?: string;
102
110
  startedAt?: string;
111
+ /** Plugin conversations only: the name the registry gives it. */
112
+ title?: string;
103
113
  /** Turns running or waiting in the channel. */
104
114
  busy: number;
105
115
  }
@@ -2,12 +2,13 @@ import { join } from "node:path";
2
2
  import type {
3
3
  AgentTeam,
4
4
  ChannelKey,
5
+ ConversationRegistry,
5
6
  Logger,
6
7
  MemoryKind,
7
8
  QueuePort,
8
9
  SpeakerMemory,
9
10
  } from "pi-roundtable";
10
- import { MemoryError } from "pi-roundtable";
11
+ import { MemoryError, parseChannelKey } from "pi-roundtable";
11
12
  import { thinkingLabel } from "pi-roundtable/kit";
12
13
  import type {
13
14
  ApiError,
@@ -28,10 +29,12 @@ import {
28
29
  conversationFiles,
29
30
  parseKey,
30
31
  readTranscript,
32
+ registeredConversations,
33
+ registeredDir,
31
34
  type StoredConversation,
35
+ type StoredKind,
32
36
  storedConversations,
33
37
  } from "./conversations.ts";
34
-
35
38
  import {
36
39
  type ConsoleFeatures,
37
40
  type ConsolePresentation,
@@ -61,6 +64,8 @@ export interface ConsolePorts {
61
64
  >;
62
65
  /** Conversations the console must not list or read. */
63
66
  exclude?: (key: ChannelKey) => boolean;
67
+ /** The host's conversation registry; without it only the conversations found by name are listed. */
68
+ registry?: Pick<ConversationRegistry, "list" | "get">;
64
69
  /** Text a relayed message begins with, which is not the owner's words. */
65
70
  relayNotes: readonly string[];
66
71
  /** Called after a note is written, so every open page refreshes. */
@@ -272,29 +277,56 @@ export class ConsoleApi {
272
277
  // ── Conversations ──────────────────────────────────────────────────────
273
278
 
274
279
  async conversations(): Promise<ConversationsView> {
275
- const { sessionsDir, relayNotes, exclude } = this.#ports;
276
- const stored = storedConversations(sessionsDir, {
277
- excluded: (key) =>
278
- (exclude?.(key) ?? false) ||
279
- (this.#ports.features?.party?.contains(key) ?? false),
280
+ const { sessionsDir, relayNotes, registry } = this.#ports;
281
+ const filter = {
282
+ excluded: (key: ChannelKey) => this.#hidden(key),
280
283
  relayNotes,
281
- });
284
+ };
285
+ // The registry first; the directory scan reads the conversations from before it, by name.
286
+ const recorded = registry
287
+ ? registeredConversations(sessionsDir, await registry.list(), filter)
288
+ : [];
289
+ const stored = [
290
+ ...recorded,
291
+ ...storedConversations(sessionsDir, filter),
292
+ ].sort((a, b) => (b.lastActive ?? "").localeCompare(a.lastActive ?? ""));
282
293
  return {
283
294
  conversations: await Promise.all(stored.map((c) => this.#view(c))),
284
295
  };
285
296
  }
286
297
 
298
+ #hidden(key: ChannelKey): boolean {
299
+ return (
300
+ (this.#ports.exclude?.(key) ?? false) ||
301
+ (this.#ports.features?.party?.contains(key) ?? false)
302
+ );
303
+ }
304
+
305
+ /** The session directory and identity of a conversation the console may read, or undefined. */
306
+ async #located(
307
+ key: string,
308
+ ): Promise<
309
+ { dir: string; kind: StoredKind; id: string; member?: string } | undefined
310
+ > {
311
+ const parsed = parseKey(key);
312
+ if (parsed) return parsed;
313
+ const record = await this.#ports.registry?.get(key as ChannelKey);
314
+ return record
315
+ ? {
316
+ dir: registeredDir(record.key),
317
+ kind: "registered",
318
+ id: parseChannelKey(record.key).id,
319
+ }
320
+ : undefined;
321
+ }
322
+
287
323
  async transcript(
288
324
  key: string,
289
325
  archive: string | undefined,
290
326
  ): Promise<TranscriptView> {
291
- const { sessionsDir, relayNotes, exclude } = this.#ports;
292
- const parsed = parseKey(key);
293
- if (
294
- !parsed ||
295
- exclude?.(key as ChannelKey) ||
296
- this.#ports.features?.party?.contains(key as ChannelKey)
297
- )
327
+ const { sessionsDir, relayNotes } = this.#ports;
328
+ const parsed = await this.#located(key);
329
+ if (!parsed || this.#hidden(key as ChannelKey))
298
330
  throw new HttpError(404, "There is no such conversation.");
299
331
  const dir = join(sessionsDir, parsed.dir);
300
332
  const files = conversationFiles(dir);
@@ -324,17 +356,20 @@ export class ConsoleApi {
324
356
  const kind: ConversationKind =
325
357
  stored.kind === "mcp"
326
358
  ? "outside"
327
- : stored.kind === "group"
328
- ? "group"
329
- : (team?.owns(stored.key) ?? "owner");
359
+ : stored.kind === "registered"
360
+ ? "plugin"
361
+ : stored.kind === "group"
362
+ ? "group"
363
+ : (team?.owns(stored.key) ?? "owner");
330
364
  return {
331
365
  key: stored.key,
332
366
  kind,
333
367
  id: stored.id,
334
368
  ...(stored.member ? { member: stored.member } : {}),
335
- ...(stored.kind === "mcp"
369
+ ...(stored.kind === "mcp" || stored.kind === "registered"
336
370
  ? {}
337
371
  : { channel: await this.#name(stored.id) }),
372
+ ...(stored.title === undefined ? {} : { title: stored.title }),
338
373
  liveBytes: stored.liveBytes,
339
374
  archives: stored.archives,
340
375
  ...(stored.lastActive ? { lastActive: stored.lastActive } : {}),
@@ -9,8 +9,12 @@ import {
9
9
  statSync,
10
10
  } from "node:fs";
11
11
  import { join } from "node:path";
12
- import type { ChannelKey } from "pi-roundtable";
13
- import { textOf } from "pi-roundtable/kit";
12
+ import {
13
+ type ChannelKey,
14
+ type ConversationRecord,
15
+ parseChannelKey,
16
+ } from "pi-roundtable";
17
+ import { channelSegment, textOf } from "pi-roundtable/kit";
14
18
  import type { TranscriptEntry } from "./api-types.ts";
15
19
 
16
20
  const FIRST_MESSAGE_CHARS = 80;
@@ -41,8 +45,11 @@ export interface ConversationFiles {
41
45
  lastActive?: string;
42
46
  }
43
47
 
44
- /** The kinds of session directory the console reads. */
45
- export type StoredKind = "discord" | "group" | "mcp";
48
+ /**
49
+ * The kinds of session directory the console reads: the three it recognises by name, and any
50
+ * other conversation the host's registry records.
51
+ */
52
+ export type StoredKind = "discord" | "group" | "mcp" | "registered";
46
53
 
47
54
  /** An owner, agent, group-member, or outside-agent conversation found on disk. */
48
55
  export interface StoredConversation extends ConversationFiles {
@@ -52,9 +59,11 @@ export interface StoredConversation extends ConversationFiles {
52
59
  id: string;
53
60
  /** Group conversations only: the agent whose conversation inside the group this is. */
54
61
  member?: string;
55
- /** Outside-agent conversations only. */
62
+ /** Outside-agent and registered conversations only. */
56
63
  firstMessage?: string;
57
64
  startedAt?: string;
65
+ /** Registered conversations only: the name the registry gives it. */
66
+ title?: string;
58
67
  }
59
68
 
60
69
  /** The session directory a channel key names, or undefined when the key is not one the console reads. */
@@ -253,6 +262,41 @@ export function storedConversations(
253
262
  );
254
263
  }
255
264
 
265
+ /** The session directory of a conversation the registry records, by its key. */
266
+ export function registeredDir(key: ChannelKey): string {
267
+ return channelSegment(key);
268
+ }
269
+
270
+ /**
271
+ * The conversations the host's registry records and the directory scan does not recognise, with
272
+ * their files; one whose first turn left no session file has nothing to read and is left out.
273
+ */
274
+ export function registeredConversations(
275
+ sessionsDir: string,
276
+ records: readonly ConversationRecord[],
277
+ options: {
278
+ excluded: (key: ChannelKey) => boolean;
279
+ relayNotes: readonly string[];
280
+ },
281
+ ): StoredConversation[] {
282
+ const found: StoredConversation[] = [];
283
+ for (const record of records) {
284
+ if (parseKey(record.key) || options.excluded(record.key)) continue;
285
+ const dir = join(sessionsDir, registeredDir(record.key));
286
+ const files = conversationFiles(dir);
287
+ if (!files) continue;
288
+ found.push({
289
+ key: record.key,
290
+ kind: "registered",
291
+ id: parseChannelKey(record.key).id,
292
+ ...files,
293
+ ...opening(dir, options.relayNotes),
294
+ ...(record.title === undefined ? {} : { title: record.title }),
295
+ });
296
+ }
297
+ return found;
298
+ }
299
+
256
300
  /** The archive folders of a conversation, newest first. */
257
301
  export function archiveNames(dir: string): string[] {
258
302
  const archiveDir = join(dir, "archive");
package/src/web-plugin.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AGENTS,
3
+ CONVERSATIONS,
3
4
  definePlugin,
4
5
  MEMORY,
5
6
  type RoundtablePlugin,
@@ -45,6 +46,8 @@ async function build(settings: ResolvedOptions, context: Context) {
45
46
  }
46
47
  const team = services.find(AGENTS)?.team;
47
48
  const connection = services.find(DISCORD)?.connection;
49
+ // Absent on a host without the registry: the console then lists conversations found by name only.
50
+ const registry = services.find(CONVERSATIONS);
48
51
  const listeners: (() => void)[] = [];
49
52
  const changed = () => {
50
53
  for (const listener of listeners) listener();
@@ -65,6 +68,7 @@ async function build(settings: ResolvedOptions, context: Context) {
65
68
  ? { memory: services.get(MEMORY).forSpeaker(settings.ownerId) }
66
69
  : {}),
67
70
  ...(settings.exclude ? { exclude: settings.exclude } : {}),
71
+ ...(registry ? { registry } : {}),
68
72
  relayNotes: settings.relayNotes,
69
73
  changed,
70
74
  logger,