pi-roundtable 0.7.19 → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +267 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/config/adapters.ts +57 -0
  24. package/src/core/config/config.ts +174 -77
  25. package/src/core/contract/channels.ts +4 -0
  26. package/src/core/contract/providers.ts +3 -2
  27. package/src/core/contract/runtime.ts +8 -4
  28. package/src/core/contract/surface.ts +13 -0
  29. package/src/core/conversations/conversation-registry.ts +42 -0
  30. package/src/core/conversations/conversation-store.ts +113 -0
  31. package/src/core/conversations/conversations-plugin.ts +29 -0
  32. package/src/core/define-roundtable.ts +124 -54
  33. package/src/core/discord/discord-adapter.ts +9 -0
  34. package/src/core/discord/owner-cards.ts +19 -8
  35. package/src/core/domain/conversation.ts +5 -0
  36. package/src/core/domain/owner-prompts.ts +1 -1
  37. package/src/core/domain/ports.ts +6 -0
  38. package/src/core/domain/progress.ts +10 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host.ts +6 -3
  41. package/src/core/http/listeners.ts +174 -21
  42. package/src/core/http/websocket.ts +316 -0
  43. package/src/core/i18n/cards.ts +5 -7
  44. package/src/core/ops/error-reporter.ts +24 -12
  45. package/src/core/plugin.ts +14 -0
  46. package/src/core/registry/providers.ts +1 -1
  47. package/src/core/routing/channel-router.ts +8 -0
  48. package/src/core/routing/conversation-turns.ts +55 -1
  49. package/src/core/routing/surface-port.ts +3 -0
  50. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  51. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  52. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  53. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  54. package/src/core/runtime/prompt-slot.ts +2 -2
  55. package/src/core/runtime/runtime-plugin.ts +160 -0
  56. package/src/core/runtime/turn-progress.ts +116 -0
  57. package/src/core/services.ts +15 -1
  58. package/src/core/shared/unix-server.ts +35 -5
  59. package/src/core/testing/modules.ts +47 -23
  60. package/src/core/testing/surface-contract.ts +383 -0
  61. package/src/core/testing/test-host.ts +27 -9
  62. package/src/discord/index.ts +2 -0
  63. package/src/index.ts +18 -0
  64. package/src/testing.ts +41 -5
  65. package/templates/adapters/web/.env.example +24 -0
  66. package/templates/adapters/web/README.md +15 -0
  67. package/templates/adapters/web/package.json.tmpl +26 -0
  68. package/templates/adapters/web/persona/assistant.md +2 -0
  69. package/templates/adapters/web/roundtable.config.ts +55 -0
  70. package/templates/package.json.tmpl +1 -0
  71. package/templates/plugins/self-compact.ts +11 -0
  72. package/templates/roundtable.config.ts +2 -1
@@ -0,0 +1,116 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ import type { TurnProgress } from "../domain/progress.ts";
3
+
4
+ /** How long text is joined before it is sent, so a fast stream costs a few events a second. */
5
+ export const PROGRESS_INTERVAL_MS = 250;
6
+ /** The longest preview of a tool's arguments, ellipsis included. */
7
+ export const PROGRESS_PREVIEW_CHARS = 80;
8
+
9
+ /** One turn's progress as the runtime reports it; every method returns at once and never throws. */
10
+ export interface ProgressReporter {
11
+ text(delta: string): void;
12
+ toolStart(id: string, tool: string, args: unknown): void;
13
+ toolEnd(id: string, tool: string, isError: boolean): void;
14
+ /** Reports a session's event: its text deltas and its tools' starts and ends, never the thinking. */
15
+ observe(event: AgentSessionEvent): void;
16
+ /** Sends the text not sent yet; nothing is sent after it. */
17
+ close(): void;
18
+ }
19
+
20
+ export interface ProgressReporterOptions {
21
+ intervalMs?: number;
22
+ /** Runs `run` after `ms`; returns a cancel. Replaceable in tests. */
23
+ setTimer?: (run: () => void, ms: number) => () => void;
24
+ }
25
+
26
+ const realTimer = (run: () => void, ms: number) => {
27
+ const timer = setTimeout(run, ms);
28
+ timer.unref?.();
29
+ return () => clearTimeout(timer);
30
+ };
31
+
32
+ /** One line of a tool's arguments, cut to the preview length; undefined for none. */
33
+ export function toolPreview(args: unknown): string | undefined {
34
+ let text: string | undefined;
35
+ try {
36
+ text = args === undefined ? undefined : JSON.stringify(args);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ if (!text || text === "{}" || text === "[]") return undefined;
41
+ const line = text.replace(/\s+/g, " ");
42
+ return line.length > PROGRESS_PREVIEW_CHARS
43
+ ? `${line.slice(0, PROGRESS_PREVIEW_CHARS - 1)}…`
44
+ : line;
45
+ }
46
+
47
+ /**
48
+ * Turns a session's text deltas and tool events into `TurnProgress`. Text is joined and sent at
49
+ * most once per interval, and always before a tool event, so the order the turn wrote in holds.
50
+ * A sink that throws loses that event only.
51
+ */
52
+ export function progressReporter(
53
+ sink: (event: TurnProgress) => void,
54
+ options: ProgressReporterOptions = {},
55
+ ): ProgressReporter {
56
+ const intervalMs = options.intervalMs ?? PROGRESS_INTERVAL_MS;
57
+ const setTimer = options.setTimer ?? realTimer;
58
+ let buffered = "";
59
+ let cancel: (() => void) | undefined;
60
+ let closed = false;
61
+ const send = (event: TurnProgress) => {
62
+ try {
63
+ sink(event);
64
+ } catch {
65
+ // The surface's failure is its own; the turn goes on without that event.
66
+ }
67
+ };
68
+ const flush = () => {
69
+ cancel?.();
70
+ cancel = undefined;
71
+ if (buffered === "") return;
72
+ const delta = buffered;
73
+ buffered = "";
74
+ send({ type: "text", delta });
75
+ };
76
+ const reporter: ProgressReporter = {
77
+ observe(event) {
78
+ if (
79
+ event.type === "message_update" &&
80
+ event.assistantMessageEvent.type === "text_delta"
81
+ )
82
+ reporter.text(event.assistantMessageEvent.delta);
83
+ else if (event.type === "tool_execution_start")
84
+ reporter.toolStart(event.toolCallId, event.toolName, event.args);
85
+ else if (event.type === "tool_execution_end")
86
+ reporter.toolEnd(event.toolCallId, event.toolName, event.isError);
87
+ },
88
+ text(delta) {
89
+ if (closed || delta === "") return;
90
+ buffered += delta;
91
+ cancel ??= setTimer(flush, intervalMs);
92
+ },
93
+ toolStart(id, tool, args) {
94
+ if (closed) return;
95
+ flush();
96
+ const preview = toolPreview(args);
97
+ send({
98
+ type: "tool_start",
99
+ id,
100
+ tool,
101
+ ...(preview === undefined ? {} : { preview }),
102
+ });
103
+ },
104
+ toolEnd(id, tool, isError) {
105
+ if (closed) return;
106
+ flush();
107
+ send({ type: "tool_end", id, tool, ok: !isError });
108
+ },
109
+ close() {
110
+ if (closed) return;
111
+ flush();
112
+ closed = true;
113
+ },
114
+ };
115
+ return reporter;
116
+ }
@@ -4,6 +4,7 @@ import type { TeamStatus } from "./agents/team-status.ts";
4
4
  import type { ScheduledOutcome } from "./contract/channels.ts";
5
5
  import type { AgentRuntime } from "./contract/runtime.ts";
6
6
  import { type ServiceKey, serviceKey } from "./contract/services.ts";
7
+ import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
7
8
  import type { PendingConfirmation } from "./domain/conversation.ts";
8
9
  import type { HttpRoute } from "./http/listeners.ts";
9
10
  import type { ThinkingSetting } from "./models.ts";
@@ -35,6 +36,19 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
35
36
  // interface, so an object with the same methods satisfies it without being one of the built-in
36
37
  // classes; a plugin replaces a built-in by providing its own under the same key.
37
38
 
39
+ /**
40
+ * The runtime every conversation turn runs on: the agent server's, and each turn run through
41
+ * `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
42
+ * by the `runtime` plugin, which every host registers, with or without the agent server.
43
+ */
44
+ export const RUNTIME: ServiceKey<AgentRuntime> =
45
+ serviceKey<AgentRuntime>("roundtable.runtime");
46
+ /**
47
+ * The conversations run through `context.turns`: who each belongs to, its kind and visibility,
48
+ * and when it was last active, recorded at its first turn. Provided by the `conversations` plugin.
49
+ */
50
+ export const CONVERSATIONS: ServiceKey<ConversationRegistry> =
51
+ serviceKey<ConversationRegistry>("roundtable.conversations");
38
52
  /** The agent server: its team, its directory, the runtime that runs every agent turn. Provided by the agent-server plugin. */
39
53
  export const AGENTS: ServiceKey<AgentServer> =
40
54
  serviceKey<AgentServer>("roundtable.agents");
@@ -84,7 +98,7 @@ export interface AgentServer {
84
98
  team: AgentTeam;
85
99
  /** Read-only lookups of agents and groups; edits go through the team. */
86
100
  directory: AgentDirectory;
87
- /** The runtime every agent-server turn runs on: the `runtime` provider's, or Pi's when no plugin fills the slot. */
101
+ /** The runtime every agent-server turn runs on: the same one `RUNTIME` provides. */
88
102
  runtime: AgentRuntime;
89
103
  /** Decides whether the owner's reply approves the actions the assistant held for them. */
90
104
  approvals: {
@@ -1,5 +1,5 @@
1
1
  import { rmSync } from "node:fs";
2
- import type { Server } from "bun";
2
+ import type { Server, WebSocketHandler } from "bun";
3
3
 
4
4
  /**
5
5
  * Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
@@ -9,18 +9,48 @@ import type { Server } from "bun";
9
9
  */
10
10
  export function serveUnix(
11
11
  socketPath: string,
12
- fetch: (request: Request) => Response | Promise<Response>,
13
- options: { error?: (error: Error) => Response | Promise<Response> } = {},
14
- ): Server<undefined> {
12
+ fetch: (
13
+ request: Request,
14
+ server: Server<undefined>,
15
+ ) => Response | Promise<Response>,
16
+ options?: { error?: (error: Error) => Response | Promise<Response> },
17
+ ): Server<undefined>;
18
+ /**
19
+ * Serves HTTP and WebSockets on a unix socket: `websocket` serves the sockets `fetch` upgrades
20
+ * with `server.upgrade`, and `fetch` answers nothing for a request it upgraded.
21
+ */
22
+ export function serveUnix<WebSocketData>(
23
+ socketPath: string,
24
+ fetch: (
25
+ request: Request,
26
+ server: Server<WebSocketData>,
27
+ ) => Response | undefined | Promise<Response | undefined>,
28
+ options: {
29
+ error?: (error: Error) => Response | Promise<Response>;
30
+ websocket: WebSocketHandler<WebSocketData>;
31
+ },
32
+ ): Server<WebSocketData>;
33
+ export function serveUnix<WebSocketData>(
34
+ socketPath: string,
35
+ fetch: (
36
+ request: Request,
37
+ server: Server<WebSocketData>,
38
+ ) => Response | undefined | Promise<Response | undefined>,
39
+ options: {
40
+ error?: (error: Error) => Response | Promise<Response>;
41
+ websocket?: WebSocketHandler<WebSocketData>;
42
+ } = {},
43
+ ): Server<WebSocketData> {
15
44
  rmSync(socketPath, { force: true });
16
45
  const serveOptions = {
17
46
  unix: socketPath,
18
47
  idleTimeout: 0,
19
48
  fetch,
20
49
  ...(options.error ? { error: options.error } : {}),
50
+ ...(options.websocket ? { websocket: options.websocket } : {}),
21
51
  };
22
52
  // SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
23
53
  return Bun.serve(
24
54
  serveOptions as unknown as Parameters<typeof Bun.serve>[0],
25
- ) as Server<undefined>;
55
+ ) as unknown as Server<WebSocketData>;
26
56
  }
@@ -3,7 +3,7 @@ import type { SQL } from "bun";
3
3
  import { OWNER_TARGET } from "../agents/agent-claim.ts";
4
4
  import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
5
5
  import { discordAdminPlugin } from "../builtin/discord-admin.ts";
6
- import { modulesPlugin } from "../builtin/modules.ts";
6
+ import { type ModulesOptions, modulesPlugin } from "../builtin/modules.ts";
7
7
  import { skillsPlugin } from "../builtin/skills.ts";
8
8
  import { memoryPlugin } from "../builtin/stores.ts";
9
9
  import type { ChannelKey } from "../domain/conversation.ts";
@@ -28,14 +28,28 @@ export interface ModuleRecord {
28
28
  reportChannels: ChannelKey[];
29
29
  /** How often a conversation without a chat channel fell back to the owner's messages. */
30
30
  ownerChannelAsked: number;
31
+ /** What was posted on the surface, by channel. */
32
+ posted: { channel: ChannelKey; text: string }[];
31
33
  }
32
34
 
33
35
  export const OWNER_CHANNEL: ChannelKey = "discord:owner-dm";
34
36
 
35
37
  /** The modules plugin set up over stand-in stores and surface; returns its contribution and the record. */
36
38
  export async function setUpModules(
37
- options: { agentChannelOf?: (name: string) => ChannelKey } = {},
39
+ options: {
40
+ agentChannelOf?: (name: string) => ChannelKey;
41
+ /** Whether Discord is there; default true. A host without it has no owner's messages. */
42
+ discord?: boolean;
43
+ /** Whether a plugin contributes the owner's background target, as the agent server does; default true. */
44
+ ownerTarget?: boolean;
45
+ /** Whether a claim owns a channel; by default the Discord channels are owned and no other. */
46
+ owns?: (channel: ChannelKey) => boolean;
47
+ /** Whether the claim owning a channel takes background turns; by default every owned channel's does. */
48
+ takesBackground?: (channel: ChannelKey) => boolean;
49
+ errorReporter?: ModulesOptions["errorReporter"];
50
+ } = {},
38
51
  ): Promise<{
52
+ plugin: RoundtablePlugin;
39
53
  contribution: Contribution;
40
54
  record: ModuleRecord;
41
55
  services: ServiceRegistry;
@@ -44,6 +58,7 @@ export async function setUpModules(
44
58
  threadOrigins: [],
45
59
  reportChannels: [],
46
60
  ownerChannelAsked: 0,
61
+ posted: [],
47
62
  };
48
63
  const plugin = modulesPlugin({
49
64
  owner: {
@@ -61,40 +76,48 @@ export async function setUpModules(
61
76
  thinking: "low",
62
77
  worker: { run: async () => "found it" },
63
78
  },
79
+ ...(options.errorReporter ? { errorReporter: options.errorReporter } : {}),
64
80
  });
65
81
  const services = new ServiceRegistry([plugin]);
66
82
  // SAFETY: the tools under test read no store; each stub is asked for nothing else.
67
83
  services.preset(MEMORY, {} as MemoryStore);
68
84
  services.preset(SCHEDULES, {} as ScheduleStore);
69
- // SAFETY: the schedule tools ask the agent team for a channel and nothing else.
70
85
  if (options.agentChannelOf)
86
+ // SAFETY: the schedule tools ask the agent team for a channel and nothing else.
71
87
  services.preset(AGENTS, {
72
88
  team: { channelOf: options.agentChannelOf },
73
89
  } as unknown as AgentServer);
74
- // SAFETY: the tools under test use the connection's owner channel and the threads' open only.
75
- services.preset(DISCORD, {
76
- connection: {
77
- ownerChannel: async () => {
78
- record.ownerChannelAsked += 1;
79
- return OWNER_CHANNEL;
90
+ if (options.discord !== false)
91
+ // SAFETY: the tools under test use the connection's owner channel and the threads' open only.
92
+ services.preset(DISCORD, {
93
+ connection: {
94
+ ownerChannel: async () => {
95
+ record.ownerChannelAsked += 1;
96
+ return OWNER_CHANNEL;
97
+ },
98
+ ownerOperations: () => ({}),
80
99
  },
81
- ownerOperations: () => ({}),
82
- },
83
- threads: {
84
- open: async (origin: ChannelKey | undefined) => {
85
- record.threadOrigins.push(origin);
86
- return undefined;
100
+ threads: {
101
+ open: async (origin: ChannelKey | undefined) => {
102
+ record.threadOrigins.push(origin);
103
+ return undefined;
104
+ },
87
105
  },
88
- },
89
- guard: {},
90
- commands: { add: () => undefined },
91
- } as unknown as DiscordServices);
92
- // SAFETY: setup reads only the logger, the conversations' background and targets, the surfaces, and the services.
106
+ guard: {},
107
+ commands: { add: () => undefined },
108
+ } as unknown as DiscordServices);
109
+ const owns =
110
+ options.owns ?? ((channel: ChannelKey) => channel.startsWith("discord:"));
111
+ // SAFETY: setup and the preflight read only the logger, the conversations' background, targets and owners, the surfaces, and the services.
93
112
  const context = {
94
113
  logger: silentLogger(),
95
114
  conversations: {
96
115
  target: (name: string) =>
97
- name === OWNER_TARGET.name ? OWNER_TARGET : undefined,
116
+ options.ownerTarget !== false && name === OWNER_TARGET.name
117
+ ? OWNER_TARGET
118
+ : undefined,
119
+ owns,
120
+ takesBackground: options.takesBackground ?? owns,
98
121
  background: async (turn: { channel: ChannelKey }) => {
99
122
  record.reportChannels.push(turn.channel);
100
123
  return { status: "ran" };
@@ -105,14 +128,15 @@ export async function setUpModules(
105
128
  {
106
129
  surface: "discord",
107
130
  start: async () => undefined,
108
- sendReply: async () => undefined,
131
+ sendReply: async (channel, reply) =>
132
+ void record.posted.push({ channel, text: reply.chunks.join("") }),
109
133
  },
110
134
  ]),
111
135
  } as unknown as Omit<PluginContext, "services">;
112
136
  const contribution = await services.setUp(plugin, () =>
113
137
  plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
114
138
  );
115
- return { contribution, record, services };
139
+ return { plugin, contribution, record, services };
116
140
  }
117
141
 
118
142
  /** What each addon plugin contributes, set up over stand-ins as far as setup reads them. */