pi-roundtable 0.1.0 → 0.2.1

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 (239) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/README.zh-TW.md +144 -0
  4. package/docs/plugins.md +1448 -66
  5. package/examples/echo-runtime.test.ts +122 -0
  6. package/examples/echo-runtime.ts +107 -0
  7. package/examples/events.test.ts +1 -0
  8. package/examples/events.ts +2 -2
  9. package/examples/fake-surface.test.ts +180 -0
  10. package/examples/fake-surface.ts +109 -0
  11. package/examples/interactions.test.ts +12 -3
  12. package/examples/interactions.ts +19 -19
  13. package/examples/shared-services.test.ts +74 -0
  14. package/examples/shared-services.ts +60 -0
  15. package/examples/study-room.test.ts +69 -0
  16. package/examples/study-room.ts +52 -0
  17. package/examples/support-desk.test.ts +34 -0
  18. package/examples/support-desk.ts +40 -0
  19. package/package.json +11 -3
  20. package/src/cli/checks/database.ts +11 -30
  21. package/src/cli/checks/images.ts +19 -0
  22. package/src/cli/doctor.ts +6 -0
  23. package/src/cli/main.ts +2 -1
  24. package/src/core/agents/agent-claim.ts +42 -16
  25. package/src/core/agents/agent-messages.ts +4 -4
  26. package/src/core/agents/agent-ports.ts +8 -21
  27. package/src/core/agents/agent-prompt.ts +3 -1
  28. package/src/core/agents/agent-store.ts +6 -4
  29. package/src/core/agents/agent-team-fixture.ts +389 -0
  30. package/src/core/agents/agent-team.ts +26 -25
  31. package/src/core/agents/agent-tools.ts +34 -15
  32. package/src/core/agents/avatar-studio.ts +40 -6
  33. package/src/core/agents/fallback-avatar.ts +85 -0
  34. package/src/core/agents/team-editing.ts +49 -10
  35. package/src/core/agents/team-keys.ts +22 -9
  36. package/src/core/agents/team-layout.ts +4 -3
  37. package/src/core/agents/team-lifecycle.ts +28 -2
  38. package/src/core/agents/team-options.ts +9 -5
  39. package/src/core/agents/team-status.ts +4 -4
  40. package/src/core/agents/team-text.ts +8 -6
  41. package/src/core/agents/team-turn-types.ts +4 -4
  42. package/src/core/agents/team-turns.ts +6 -5
  43. package/src/core/builtin/agent-server.ts +187 -121
  44. package/src/core/builtin/discord-admin.ts +41 -0
  45. package/src/core/builtin/discord.ts +65 -40
  46. package/src/core/builtin/modules.ts +67 -58
  47. package/src/core/builtin/session-tool.ts +24 -0
  48. package/src/core/builtin/skills.ts +72 -0
  49. package/src/core/builtin/stores.ts +55 -31
  50. package/src/core/config/config.ts +45 -4
  51. package/src/core/config/schema.ts +6 -0
  52. package/src/core/contract/channels.ts +54 -14
  53. package/src/core/contract/providers.ts +17 -0
  54. package/src/core/contract/runtime.ts +138 -0
  55. package/src/core/contract/services.ts +45 -0
  56. package/src/core/contract/surface.ts +88 -0
  57. package/src/core/db/migrations.ts +118 -3
  58. package/src/core/define-roundtable.ts +70 -46
  59. package/src/core/define.ts +2 -1
  60. package/src/core/discord/agent-commands.ts +39 -14
  61. package/src/core/discord/agent-panel.ts +66 -0
  62. package/src/core/discord/channel-executor.ts +6 -3
  63. package/src/core/discord/channel-operations.ts +14 -11
  64. package/src/core/discord/command-collection.ts +46 -0
  65. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  66. package/src/core/discord/connection.ts +35 -0
  67. package/src/core/discord/discord-surface.ts +32 -94
  68. package/src/core/discord/dispatch-threads.ts +2 -2
  69. package/src/core/discord/inbound-message.ts +99 -0
  70. package/src/core/discord/interaction-module.ts +57 -1
  71. package/src/core/discord/owner-cards.ts +4 -4
  72. package/src/core/discord/owner-command.ts +44 -11
  73. package/src/core/discord/owner-discord.ts +2 -2
  74. package/src/core/discord/schedule-commands.ts +21 -18
  75. package/src/core/discord/stop-button.ts +44 -1
  76. package/src/core/domain/attachment.ts +12 -8
  77. package/src/core/domain/conversation.ts +3 -4
  78. package/src/core/domain/owner-prompts.ts +5 -5
  79. package/src/core/domain/ports.ts +9 -53
  80. package/src/core/freeze.ts +9 -0
  81. package/src/core/host.ts +255 -82
  82. package/src/core/http/listeners.ts +55 -20
  83. package/src/core/i18n/agent-panel.ts +4 -0
  84. package/src/core/i18n/index.ts +12 -2
  85. package/src/core/i18n/owner.ts +7 -1
  86. package/src/core/i18n/schedules.ts +12 -6
  87. package/src/core/identity.ts +1 -1
  88. package/src/core/judging/effort-judge.ts +25 -3
  89. package/src/core/log.ts +21 -3
  90. package/src/core/models.ts +2 -2
  91. package/src/core/modules/background/background-turns.ts +6 -4
  92. package/src/core/modules/delegation/delegate.ts +3 -2
  93. package/src/core/modules/delegation/delegator.ts +15 -13
  94. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  95. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  96. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  97. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  98. package/src/core/modules/memory/owner-memory.ts +9 -13
  99. package/src/core/modules/schedules/recurrence.ts +5 -3
  100. package/src/core/modules/schedules/schedule-store.ts +11 -11
  101. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  102. package/src/core/modules/schedules/scheduler.ts +7 -2
  103. package/src/core/modules/schedules/schedules.ts +9 -3
  104. package/src/core/modules/skills/skill-registry.ts +3 -2
  105. package/src/core/modules/skills/skill-store.ts +2 -15
  106. package/src/core/modules/skills/skill-tools.ts +10 -4
  107. package/src/core/ops/error-reporter.ts +1 -1
  108. package/src/core/plugin.ts +232 -28
  109. package/src/core/registry/contributions.ts +173 -14
  110. package/src/core/registry/providers.ts +35 -5
  111. package/src/core/registry/services.ts +229 -0
  112. package/src/core/routing/channel-queue.ts +5 -0
  113. package/src/core/routing/channel-router.ts +21 -7
  114. package/src/core/routing/conversation-turns.ts +139 -0
  115. package/src/core/routing/message-text.ts +9 -2
  116. package/src/core/routing/surface-port.ts +39 -0
  117. package/src/core/runtime/conversation-sessions.ts +4 -3
  118. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  119. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  120. package/src/core/runtime/mcp.ts +1 -1
  121. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  122. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  123. package/src/core/runtime/prompt-slot.ts +8 -3
  124. package/src/core/runtime/runtime-types.ts +9 -35
  125. package/src/core/runtime/session-factory.ts +21 -5
  126. package/src/core/runtime/text-tools.ts +1 -3
  127. package/src/core/services.ts +238 -90
  128. package/src/core/sessions.ts +3 -2
  129. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  130. package/src/core/shared/delegate-tool.ts +4 -3
  131. package/src/core/shared/schedule-tools.ts +22 -13
  132. package/src/core/shared/session-messages.ts +1 -1
  133. package/src/core/speakers.ts +6 -38
  134. package/src/core/testing/database.ts +1 -7
  135. package/src/core/testing/eager-catalog.ts +30 -0
  136. package/src/core/testing/locale.ts +23 -5
  137. package/src/core/testing/modules.ts +105 -41
  138. package/src/core/testing/test-host.ts +279 -0
  139. package/src/core/testing/tool-set.ts +64 -0
  140. package/src/core/time.ts +6 -1
  141. package/src/core/tool-set.snapshot.json +288 -0
  142. package/src/core/tool-tiers.ts +17 -57
  143. package/src/discord/index.ts +64 -0
  144. package/src/index.ts +181 -10
  145. package/src/kit/channels.ts +14 -0
  146. package/src/kit/domain.ts +18 -0
  147. package/src/kit/holds.ts +4 -0
  148. package/src/kit/index.ts +129 -0
  149. package/src/kit/judging.ts +10 -0
  150. package/src/kit/memory.ts +3 -0
  151. package/src/kit/mirror.ts +19 -0
  152. package/src/kit/presentation.ts +7 -0
  153. package/src/kit/shell.ts +7 -0
  154. package/src/kit/skills.ts +8 -0
  155. package/src/kit/support.ts +21 -0
  156. package/src/kit/threads.ts +7 -0
  157. package/src/kit/tools.ts +14 -0
  158. package/src/kit/worker.ts +16 -0
  159. package/src/testing.ts +394 -34
  160. package/examples/guide.test.ts +0 -52
  161. package/src/cli/add-plugin.test.ts +0 -107
  162. package/src/cli/checks/basic.test.ts +0 -276
  163. package/src/cli/checks/database.test.ts +0 -117
  164. package/src/cli/checks/discord.test.ts +0 -200
  165. package/src/cli/cli.test.ts +0 -122
  166. package/src/cli/config-edit.test.ts +0 -130
  167. package/src/cli/doctor.test.ts +0 -184
  168. package/src/cli/init.test.ts +0 -127
  169. package/src/cli/size.test.ts +0 -9
  170. package/src/cli/templates.test.ts +0 -129
  171. package/src/cli/testing/fixtures.ts +0 -106
  172. package/src/core/agents/agent-claim.test.ts +0 -142
  173. package/src/core/agents/agent-dashboard.test.ts +0 -120
  174. package/src/core/agents/agent-guild.test.ts +0 -124
  175. package/src/core/agents/agent-store.test.ts +0 -205
  176. package/src/core/agents/avatar-studio.test.ts +0 -88
  177. package/src/core/agents/group-round.test.ts +0 -143
  178. package/src/core/agents/owner-identity.test.ts +0 -172
  179. package/src/core/agents/team-turns.test.ts +0 -278
  180. package/src/core/attachments/attachments.test.ts +0 -85
  181. package/src/core/boundary.test.ts +0 -45
  182. package/src/core/builtin/modules.test.ts +0 -127
  183. package/src/core/config/config.test.ts +0 -135
  184. package/src/core/contract/discord.ts +0 -33
  185. package/src/core/db/migrations.test.ts +0 -237
  186. package/src/core/define-roundtable.test.ts +0 -134
  187. package/src/core/define.test.ts +0 -144
  188. package/src/core/discord/agent-commands.test.ts +0 -58
  189. package/src/core/discord/agent-discord.test.ts +0 -70
  190. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  191. package/src/core/discord/dispatch-threads.test.ts +0 -94
  192. package/src/core/discord/owner-cards.test.ts +0 -435
  193. package/src/core/discord/owner-discord.test.ts +0 -325
  194. package/src/core/domain/expression.ts +0 -21
  195. package/src/core/domain/profile.ts +0 -32
  196. package/src/core/drain.test.ts +0 -46
  197. package/src/core/events.test.ts +0 -81
  198. package/src/core/holds.test.ts +0 -54
  199. package/src/core/host.test.ts +0 -536
  200. package/src/core/http/listeners.test.ts +0 -164
  201. package/src/core/i18n/i18n.test.ts +0 -139
  202. package/src/core/identity.test.ts +0 -18
  203. package/src/core/judging/effort-judge.test.ts +0 -112
  204. package/src/core/judging/model-judge.test.ts +0 -126
  205. package/src/core/modules/delegation/delegator.test.ts +0 -170
  206. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  207. package/src/core/modules/schedules/schedule.test.ts +0 -358
  208. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  209. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  210. package/src/core/ops/error-reporter.test.ts +0 -283
  211. package/src/core/presentation/card-cadence.ts +0 -41
  212. package/src/core/presentation/presentation.test.ts +0 -166
  213. package/src/core/public-entry.test.ts +0 -59
  214. package/src/core/registry/contributions.test.ts +0 -215
  215. package/src/core/registry/interactions.test.ts +0 -87
  216. package/src/core/registry/providers.test.ts +0 -103
  217. package/src/core/routing/channel-queue.test.ts +0 -31
  218. package/src/core/routing/channel-router.test.ts +0 -326
  219. package/src/core/routing/conversation-kind.ts +0 -14
  220. package/src/core/routing/settle-turn.test.ts +0 -21
  221. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  222. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  223. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  224. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  225. package/src/core/runtime/session-archive.test.ts +0 -20
  226. package/src/core/runtime/steerable-run.test.ts +0 -264
  227. package/src/core/runtime/text-tools.test.ts +0 -110
  228. package/src/core/runtime/turn-answer.test.ts +0 -72
  229. package/src/core/runtime/worker-task.test.ts +0 -82
  230. package/src/core/services.test.ts +0 -28
  231. package/src/core/sessions.test.ts +0 -85
  232. package/src/core/shared/unix-server.ts +0 -18
  233. package/src/core/size.test.ts +0 -9
  234. package/src/core/speakers.test.ts +0 -78
  235. package/src/core/testing/file-size.ts +0 -34
  236. package/src/core/time.test.ts +0 -166
  237. package/src/core/tool-tiers.test.ts +0 -117
  238. package/src/entries.test.ts +0 -112
  239. package/src/testing.test.ts +0 -156
@@ -0,0 +1,60 @@
1
+ import { definePlugin, serviceKey } from "pi-roundtable";
2
+
3
+ /** What the notes plugin offers other plugins: a port, so any object with these methods will do. */
4
+ export interface NoteIndex {
5
+ add(text: string): void;
6
+ all(): readonly string[];
7
+ }
8
+
9
+ /** The key is the service's name. Give its id a prefix of your own; two keys with one id are one service. */
10
+ export const NOTE_INDEX = serviceKey<NoteIndex>("my-notes.index");
11
+
12
+ /** A plugin lists the services it provides, then provides each from `setup`. */
13
+ export function notes() {
14
+ const stored: string[] = [];
15
+ return definePlugin({
16
+ name: "my-notes",
17
+ provides: [NOTE_INDEX],
18
+ setup: ({ services }) => {
19
+ services.provide(NOTE_INDEX, {
20
+ add: (text) => void stored.push(text),
21
+ all: () => stored,
22
+ });
23
+ return { services: [{ name: "notes-ready" }] };
24
+ },
25
+ });
26
+ }
27
+
28
+ /** A plugin registered after it reads the service; `find` is undefined when nobody provides it. */
29
+ export function noteReader(
30
+ onNotes: (notes: readonly string[] | undefined) => void,
31
+ ) {
32
+ return definePlugin({
33
+ name: "note-reader",
34
+ setup: ({ services }) => ({
35
+ services: [
36
+ {
37
+ name: "note-reader",
38
+ start: () => onNotes(services.find(NOTE_INDEX)?.all()),
39
+ },
40
+ ],
41
+ }),
42
+ });
43
+ }
44
+
45
+ /** A plugin that provides the same key and lists it in `replaces` takes the place of the one before it. */
46
+ export function shoutingNotes() {
47
+ const stored: string[] = [];
48
+ return definePlugin({
49
+ name: "shouting-notes",
50
+ provides: [NOTE_INDEX],
51
+ replaces: [NOTE_INDEX],
52
+ setup: ({ services }) => {
53
+ services.provide(NOTE_INDEX, {
54
+ add: (text) => void stored.push(text.toUpperCase()),
55
+ all: () => stored,
56
+ });
57
+ return { services: [{ name: "shouting-notes-ready" }] };
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,69 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { createEchoRuntime } from "./echo-runtime.ts";
4
+ import { FakeSurface } from "./fake-surface.ts";
5
+ import { studyRoom } from "./study-room.ts";
6
+
7
+ /** The plugin over the fake surface and the echo runtime, with nothing else: no Discord, no Pi, no database. */
8
+ async function studying() {
9
+ const surface = new FakeSurface();
10
+ const harness = await testPlugin(studyRoom, {
11
+ surfaces: [surface],
12
+ providers: { runtime: createEchoRuntime },
13
+ });
14
+ return { surface, harness };
15
+ }
16
+
17
+ async function until(done: () => boolean): Promise<void> {
18
+ for (let waited = 0; !done() && waited < 1000; waited += 5)
19
+ await Bun.sleep(5);
20
+ expect(done()).toBe(true);
21
+ }
22
+
23
+ test("a message in a study room runs as a turn of the study kind, with the tutor's persona", async () => {
24
+ const { surface, harness } = await studying();
25
+ surface.say("fake:study-algebra", "What is a group?");
26
+ await until(() => surface.replies.length > 0);
27
+ expect(surface.replies).toEqual([
28
+ {
29
+ channel: "fake:study-algebra",
30
+ reply: {
31
+ chunks: [
32
+ "[You are a patient tutor. Ask one question back before you give the answer.] What is a group?",
33
+ ],
34
+ },
35
+ },
36
+ ]);
37
+ // The plugins' turn events carry the kind and no agent.
38
+ const started = harness.events.find(({ name }) => name === "turnStarted");
39
+ expect(started?.turn).toMatchObject({
40
+ kind: "study",
41
+ channel: "fake:study-algebra",
42
+ });
43
+ expect(started?.turn?.agent).toBeUndefined();
44
+ await harness.stop();
45
+ });
46
+
47
+ test("a channel that is not a study room is not the claim's", async () => {
48
+ const { surface, harness } = await studying();
49
+ surface.say("fake:lounge", "hello");
50
+ await Bun.sleep(30);
51
+ expect(surface.replies).toEqual([]);
52
+ await harness.stop();
53
+ });
54
+
55
+ test("starting a room over says it was a study conversation", async () => {
56
+ const { harness } = await studying();
57
+ expect(await harness.conversations.startFresh("fake:study-algebra")).toBe(
58
+ "study",
59
+ );
60
+ await harness.stop();
61
+ });
62
+
63
+ test("the persona is the plugin's and belongs to the study kind only", async () => {
64
+ const { harness } = await studying();
65
+ const [persona] = harness.contribution.personas ?? [];
66
+ expect(persona?.kind).toBe("study");
67
+ expect(persona?.prompt()).toContain("patient tutor");
68
+ await harness.stop();
69
+ });
@@ -0,0 +1,52 @@
1
+ import { definePlugin, parseChannelKey } from "pi-roundtable";
2
+
3
+ /** The kind of a study room's conversations: the string `startFresh` returns, and the persona's kind. */
4
+ const STUDY = "study";
5
+
6
+ /**
7
+ * A conversation kind of its own. The persona is the system prompt of every `study` conversation,
8
+ * the claim owns the channels of the study rooms, and `context.turns` runs each message as a turn
9
+ * of that kind on whatever runtime the host has, and posts the answer through the channel's
10
+ * surface.
11
+ */
12
+ export const studyRoom = definePlugin({
13
+ name: "study-room",
14
+ setup: ({ turns }) => ({
15
+ personas: [
16
+ {
17
+ kind: STUDY,
18
+ prompt: () =>
19
+ "You are a patient tutor. Ask one question back before you give the answer.",
20
+ },
21
+ ],
22
+ channels: [
23
+ {
24
+ name: "study-rooms",
25
+ priority: 10,
26
+ // The id of a room starts with `study-`, on whichever surface carries it.
27
+ owns: (channel) => parseChannelKey(channel).id.startsWith("study-"),
28
+ admit: (message) =>
29
+ message.authorIsBot
30
+ ? undefined
31
+ : {
32
+ kind: "turn",
33
+ run: async () => {
34
+ await turns.run({
35
+ channel: message.channel,
36
+ kind: STUDY,
37
+ text: message.text,
38
+ speaker: {
39
+ id: message.authorId,
40
+ name: message.authorName,
41
+ tier: "member",
42
+ },
43
+ });
44
+ },
45
+ failure: "a study turn failed",
46
+ },
47
+ // What the conversation was, so a host picks the right persona when it starts over.
48
+ startFresh: async () => STUDY,
49
+ },
50
+ ],
51
+ }),
52
+ });
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { supportDesk } from "./support-desk.ts";
4
+
5
+ const turn = (target: string) => ({
6
+ channel: "support:1" as const,
7
+ target,
8
+ author: { id: "u1", name: "Sam" },
9
+ turnId: "t1",
10
+ text: "check the queue",
11
+ });
12
+
13
+ test("the plugin contributes its target, with the limits its schedules and tasks obey", async () => {
14
+ const { conversations, stop } = await testPlugin(supportDesk);
15
+ const target = conversations.target("support");
16
+ expect(target?.label("en")).toBe("Support desk");
17
+ expect(target?.schedules?.perChannel).toBe(3);
18
+ expect(target?.delegation?.maxRunning).toBe(1);
19
+ expect(conversations.target("owner")).toBeUndefined();
20
+ await stop();
21
+ });
22
+
23
+ test("a turn for its target runs in its channels, and any other target is skipped", async () => {
24
+ const { conversations, stop } = await testPlugin(supportDesk);
25
+ expect(await conversations.background(turn("support"))).toEqual({
26
+ status: "ran",
27
+ });
28
+ // Nobody contributes "retired", so the router skips it before any claim is asked.
29
+ expect(await conversations.background(turn("retired"))).toEqual({
30
+ status: "skipped",
31
+ reason: 'no plugin contributes the background target "retired"',
32
+ });
33
+ await stop();
34
+ });
@@ -0,0 +1,40 @@
1
+ import { type BackgroundTarget, definePlugin } from "pi-roundtable";
2
+
3
+ /**
4
+ * Whose turn a schedule or a delegated task asks for. The target sets what may be scheduled or
5
+ * delegated for it, so a desk open to many people is held tighter than the owner's own agent.
6
+ */
7
+ const SUPPORT: BackgroundTarget = {
8
+ name: "support",
9
+ label: () => "Support desk",
10
+ schedules: { perChannel: 3, promptChars: 500, aheadDays: 30 },
11
+ delegation: { maxRunning: 1 },
12
+ };
13
+
14
+ /**
15
+ * A plugin that answers the turns nobody wrote in its channels. The router skips a turn whose
16
+ * target no plugin contributes, and this claim skips the targets it does not serve, so another
17
+ * target's schedule never runs here.
18
+ */
19
+ export const supportDesk = definePlugin({
20
+ name: "support-desk",
21
+ setup: () => ({
22
+ backgroundTargets: [SUPPORT],
23
+ channels: [
24
+ {
25
+ name: "support-desks",
26
+ priority: 10,
27
+ owns: (channel) => channel.startsWith("support:"),
28
+ admit: () => undefined,
29
+ background: async (turn) =>
30
+ turn.target === SUPPORT.name
31
+ ? { status: "ran" }
32
+ : {
33
+ status: "skipped",
34
+ reason: `the support desk does not serve "${turn.target}"`,
35
+ },
36
+ startFresh: async () => "support",
37
+ },
38
+ ],
39
+ }),
40
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,18 +9,25 @@
9
9
  },
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./testing": "./src/testing.ts"
12
+ "./testing": "./src/testing.ts",
13
+ "./kit": "./src/kit/index.ts",
14
+ "./discord": "./src/discord/index.ts"
13
15
  },
14
16
  "bin": {
15
- "roundtable": "./src/cli/roundtable.mjs"
17
+ "roundtable": "src/cli/roundtable.mjs"
16
18
  },
17
19
  "files": [
18
20
  "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/core/testing/file-size.ts",
23
+ "!src/cli/testing",
19
24
  "templates",
20
25
  "docs",
21
26
  "examples",
27
+ "!examples/guide.test.ts",
22
28
  "LICENSE",
23
29
  "README.md",
30
+ "README.zh-TW.md",
24
31
  "CHANGELOG.md"
25
32
  ],
26
33
  "repository": {
@@ -43,6 +50,7 @@
43
50
  "canvas": "3.2.3",
44
51
  "discord.js": "14.27.0",
45
52
  "pi-mcp-adapter": "2.37.0",
53
+ "pi-web-access": "0.35.0",
46
54
  "pino": "10.3.1",
47
55
  "typebox": "1.3.34",
48
56
  "unpdf": "1.8.1"
@@ -1,7 +1,6 @@
1
- import type { SQL, TransactionSQL } from "bun";
2
- import type { Migration } from "../../core/db/migrations.ts";
3
- import { migrate, openPool } from "../../core/db/migrations.ts";
1
+ import { nested, openPool, runMigrations } from "../../core/db/migrations.ts";
4
2
  import { MigrationError } from "../../core/errors.ts";
3
+ import type { RoundtablePlugin } from "../../core/plugin.ts";
5
4
  import type { Project } from "../project.ts";
6
5
  import { fail, ok, type Result, skipped } from "../report.ts";
7
6
 
@@ -9,42 +8,28 @@ import { fail, ok, type Result, skipped } from "../report.ts";
9
8
  export interface DatabasePort {
10
9
  check(
11
10
  url: string,
12
- migrations: readonly Migration[] | undefined,
11
+ plugins:
12
+ | readonly Pick<RoundtablePlugin, "name" | "migrations">[]
13
+ | undefined,
13
14
  ): Promise<void>;
14
15
  }
15
16
 
16
17
  class Rollback extends Error {}
17
18
 
18
- /**
19
- * The transaction as a migration sees the pool: a migration that opens its own transaction
20
- * with `begin` gets a savepoint of the checking one, which PostgreSQL nests.
21
- */
22
- function nested(transaction: TransactionSQL): SQL {
23
- return new Proxy(transaction, {
24
- get(target, property) {
25
- if (property === "begin")
26
- return (...args: Parameters<TransactionSQL["savepoint"]>) =>
27
- target.savepoint(...args);
28
- const value: unknown = Reflect.get(target, property);
29
- return typeof value === "function" ? value.bind(target) : value;
30
- },
31
- });
32
- }
33
-
34
19
  /**
35
20
  * The real port. The migrations run inside a transaction that is always rolled back: DDL in
36
21
  * PostgreSQL is transactional, so what the check proves it runs is exactly what the host runs,
37
22
  * and the database is left as it was.
38
23
  */
39
24
  export const postgres: DatabasePort = {
40
- async check(url, migrations) {
25
+ async check(url, plugins) {
41
26
  const pool = openPool(url);
42
27
  try {
43
28
  await pool`select 1`;
44
- if (!migrations) return;
29
+ if (!plugins) return;
45
30
  try {
46
31
  await pool.begin(async (transaction) => {
47
- await migrate(nested(transaction), migrations);
32
+ await runMigrations(nested(transaction), plugins);
48
33
  throw new Rollback();
49
34
  });
50
35
  } catch (error) {
@@ -74,13 +59,9 @@ export async function checkDatabase(
74
59
  const url = await project.text("database", "url");
75
60
  if (!url) return skipped("database.url has no value");
76
61
  const assembled = await project.assembled();
77
- const migrations = assembled.ok
78
- ? assembled.value.defined.plugins.flatMap(
79
- (plugin) => plugin.migrations ?? [],
80
- )
81
- : undefined;
62
+ const plugins = assembled.ok ? assembled.value.defined.plugins : undefined;
82
63
  try {
83
- await port.check(url, migrations);
64
+ await port.check(url, plugins);
84
65
  } catch (error) {
85
66
  if (error instanceof MigrationError)
86
67
  return fail(
@@ -93,7 +74,7 @@ export async function checkDatabase(
93
74
  );
94
75
  }
95
76
  return ok(
96
- migrations
77
+ plugins
97
78
  ? `${where(url)} answers and the migrations run`
98
79
  : `${where(url)} answers; migrations are checked once the configuration is valid`,
99
80
  );
@@ -0,0 +1,19 @@
1
+ import { fillsImages } from "../../core/registry/providers.ts";
2
+ import type { Project } from "../project.ts";
3
+ import { ok, type Result } from "../report.ts";
4
+
5
+ /** The guide ships in the package, so the path is readable offline. */
6
+ const GUIDE =
7
+ "node_modules/pi-roundtable/docs/plugins.md#providers-replace-a-part-the-core-runs-on";
8
+
9
+ /** Whether a plugin fills the `images` slot. Not having one is a choice, not a failure: agents get generated avatars. */
10
+ export async function checkImageProvider(project: Project): Promise<Result> {
11
+ const assembled = await project.assembled();
12
+ if (!assembled.ok)
13
+ return { status: "skipped", reason: "the configuration is not valid yet" };
14
+ return fillsImages(assembled.value.defined.plugins)
15
+ ? ok("a plugin fills the images slot; agents' avatars are drawn")
16
+ : ok(
17
+ `none; agents use avatars generated from their display names. To have them drawn, fill the images slot, as the plugin guide shows: ${GUIDE}`,
18
+ );
19
+ }
package/src/cli/doctor.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Discord,
10
10
  } from "./checks/discord.ts";
11
11
  import { checkEnvironment } from "./checks/environment.ts";
12
+ import { checkImageProvider } from "./checks/images.ts";
12
13
  import { checkModelLogin } from "./checks/model.ts";
13
14
  import { checkPublicUrl } from "./checks/public-url.ts";
14
15
  import type { Http } from "./http.ts";
@@ -51,6 +52,11 @@ export function buildChecks(
51
52
  run: () => checkConfiguration(project),
52
53
  },
53
54
  { name: "plugins", offline: true, run: () => checkPlugins(project) },
55
+ {
56
+ name: "image provider",
57
+ offline: true,
58
+ run: () => checkImageProvider(project),
59
+ },
54
60
  {
55
61
  name: "PostgreSQL",
56
62
  offline: false,
package/src/cli/main.ts CHANGED
@@ -8,6 +8,7 @@ runCli(process.argv.slice(2), processEnvironment()).then(
8
8
  },
9
9
  (error: unknown) => {
10
10
  console.error(error instanceof Error ? error.message : String(error));
11
- process.exitCode = 1;
11
+ // A boot that failed has stopped what it started; nothing may keep the process up.
12
+ process.exit(1);
12
13
  },
13
14
  );
@@ -1,32 +1,51 @@
1
1
  import { attachmentsOf } from "../attachments/turn-attachments.ts";
2
2
  import type {
3
3
  Admission,
4
+ BackgroundTarget,
4
5
  ChannelClaim,
5
6
  InboundMessage,
6
7
  } from "../contract/channels.ts";
8
+ import type { AgentRuntime } from "../contract/runtime.ts";
9
+ import type { SurfacePort } from "../contract/surface.ts";
7
10
  import type { ChannelKey } from "../domain/conversation.ts";
8
- import type { AgentRuntime, ChatSurface } from "../domain/ports.ts";
11
+ import { messages } from "../i18n/index.ts";
9
12
  import type { OwnerIdentity } from "../identity.ts";
10
13
  import type { Logger } from "../log.ts";
11
14
  import { withReference } from "../routing/message-text.ts";
12
15
  import { outcome } from "../routing/settle-turn.ts";
13
16
  import { addressee, attributed, type SpeakerPolicy } from "../speakers.ts";
14
- import type { AgentTeam } from "./agent-team.ts";
17
+ import type { DiscordAgentTeam } from "./agent-team.ts";
18
+ import { discordIdOf } from "./team-keys.ts";
15
19
 
16
20
  /**
17
- * The agent server outranks every other claim, and its guild's other channels stay silent even
18
- * when party mode or a mention would answer there. It becomes the core's reserved branch once
19
- * the team moves into the core (pi-roundtable batch 7).
21
+ * The agent server outranks every other claim on the Discord channels it owns, and its guild's
22
+ * other channels stay silent even when another plugin's mode or a mention would answer there; a claim of a
23
+ * plugin must use a higher priority to answer in that guild. It owns no channel of another
24
+ * surface. It becomes the core's reserved branch once the team moves into the core
25
+ * (pi-roundtable batch 7).
20
26
  */
21
27
  export const AGENT_SERVER_PRIORITY = 100;
22
28
 
29
+ /**
30
+ * The host's own background target: the conversations of the owner and of the agent server. The
31
+ * agent server contributes it, so schedules and delegated tasks made in those conversations carry
32
+ * its name and limits. Its label is read when a list is shown, never when this module loads,
33
+ * because the host applies its catalog at startup.
34
+ */
35
+ export const OWNER_TARGET: BackgroundTarget = {
36
+ name: "owner",
37
+ label: () => messages().scheduleModeOwner,
38
+ schedules: { perChannel: 20, promptChars: 8_000, aheadDays: 366 },
39
+ delegation: { maxRunning: 3 },
40
+ };
41
+
23
42
  export interface AgentClaimOptions {
24
43
  /** The owner, as forwarded messages name them. */
25
44
  owner: OwnerIdentity;
26
45
  /** Who may talk to the agents, and at which tier. */
27
46
  speakers: SpeakerPolicy;
28
47
  team: Pick<
29
- AgentTeam,
48
+ DiscordAgentTeam,
30
49
  | "guildId"
31
50
  | "owns"
32
51
  | "answerOwner"
@@ -34,8 +53,8 @@ export interface AgentClaimOptions {
34
53
  | "answerBackground"
35
54
  | "startFresh"
36
55
  >;
37
- runtime: Pick<AgentRuntime, "steer">;
38
- surface: Pick<ChatSurface, "react" | "unreact">;
56
+ runtime: Pick<AgentRuntime, "steer" | "stop">;
57
+ surface: Pick<SurfacePort, "react" | "unreact">;
39
58
  /** Where a channel's attachments are saved. */
40
59
  attachmentDir: (channel: ChannelKey) => string;
41
60
  logger: Logger;
@@ -61,7 +80,7 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
61
80
  kind: "background",
62
81
  turn: {
63
82
  channel: message.channel,
64
- mode: "owner",
83
+ target: OWNER_TARGET.name,
65
84
  author: { id: message.authorId, name: message.authorName },
66
85
  turnId: `webhook-${message.messageId}`,
67
86
  text: `Webhook "${message.authorName}" posted in your channel:\n${text}`,
@@ -71,7 +90,7 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
71
90
  logger.warn(
72
91
  {
73
92
  channel: message.channel,
74
- webhook: message.webhookId,
93
+ webhook: message.integration?.id,
75
94
  outcome: result,
76
95
  },
77
96
  "webhook post not answered",
@@ -82,14 +101,16 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
82
101
  return {
83
102
  name: "agent-server",
84
103
  priority: AGENT_SERVER_PRIORITY,
85
- owns: (channel, guildId) =>
86
- team.owns(channel) !== undefined ||
87
- (guildId !== undefined && guildId === team.guildId),
104
+ // Only Discord keys: another surface's channel is never the agent server's, whatever its id.
105
+ owns: (channel, space) =>
106
+ discordIdOf(channel) !== undefined &&
107
+ (team.owns(channel) !== undefined ||
108
+ (space !== undefined && space === team.guildId)),
88
109
  admit: (message) => {
89
110
  const owned = team.owns(message.channel);
90
111
  // The agent server's other channels are the owner's notes: nothing there is answered.
91
112
  if (!owned) return undefined;
92
- if (owned === "agent" && message.webhookId && !message.ownWebhook)
113
+ if (owned === "agent" && message.integration && !message.integration.own)
93
114
  return webhookReport(message);
94
115
  if (message.authorIsBot) return undefined;
95
116
  const speaker = speakers.resolve({
@@ -144,8 +165,12 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
144
165
  };
145
166
  },
146
167
  background: async (turn) => {
147
- if (turn.mode === "party")
148
- return { status: "skipped", reason: "party mode is off" };
168
+ // Fail closed: a turn for any target but the owner's never reaches the owner's tools.
169
+ if (turn.target !== OWNER_TARGET.name)
170
+ return {
171
+ status: "skipped",
172
+ reason: `the agent server answers only "${OWNER_TARGET.name}" background turns`,
173
+ };
149
174
  const owned = team.owns(turn.channel);
150
175
  if (owned === "group")
151
176
  return { status: "skipped", reason: "groups have no schedules" };
@@ -162,6 +187,7 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
162
187
  ),
163
188
  );
164
189
  },
190
+ stop: (channel) => runtime.stop(channel),
165
191
  startFresh: async (channel) => {
166
192
  await team.startFresh(channel);
167
193
  return "agent";
@@ -12,7 +12,7 @@ import {
12
12
  returnedAnswerPost,
13
13
  } from "./agent-prompt.ts";
14
14
  import type { Agent } from "./agent-store.ts";
15
- import { channelKey, homeScope } from "./team-keys.ts";
15
+ import { discordKey, homeScope } from "./team-keys.ts";
16
16
  import {
17
17
  type Chain,
18
18
  type Exchange,
@@ -47,7 +47,7 @@ export class AgentMessages {
47
47
  chainLimitReason(addressee(chain.speaker, this.#host.options.owner)),
48
48
  );
49
49
  chain.messages += 1;
50
- const targetChannel = channelKey(target.channelId);
50
+ const targetChannel = discordKey(target.channelId);
51
51
  const exchange = this.#openExchange(caller, sender, target, text);
52
52
  void queue
53
53
  .run(targetChannel, async () =>
@@ -121,7 +121,7 @@ export class AgentMessages {
121
121
  exchange: Exchange | undefined,
122
122
  ): Promise<void> {
123
123
  const { queue, logger } = this.#host.options;
124
- const targetChannel = channelKey(target.channelId ?? "");
124
+ const targetChannel = discordKey(target.channelId ?? "");
125
125
  await this.#host.post(targetChannel, this.#host.current(sender), {
126
126
  chunks: splitReply(
127
127
  deliveredMessagePost(this.#host.current(target), text),
@@ -143,7 +143,7 @@ export class AgentMessages {
143
143
  await thread.close(messages().agentNoReply(result.error.message));
144
144
  const current = this.#host.options.store.agent(sender.name);
145
145
  if (current?.status !== "active" || !current.channelId) return;
146
- const senderChannel = channelKey(current.channelId);
146
+ const senderChannel = discordKey(current.channelId);
147
147
  // With a thread the answer goes there, leaving the sender's channel its own reply.
148
148
  if (result.ok)
149
149
  await this.#host
@@ -1,19 +1,13 @@
1
- import type {
2
- ChannelKey,
3
- PendingConfirmation,
4
- TurnResult,
5
- } from "../domain/conversation.ts";
6
- import type { TurnRequest } from "../domain/ports.ts";
1
+ import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
7
2
  import type { ThinkingSetting } from "../models.ts";
8
3
 
9
- /** The part of the Pi runtime the agent team drives. */
10
- export interface AgentTurnRunner {
11
- runTurn(request: TurnRequest): Promise<TurnResult>;
12
- heldActions(session: ChannelKey): Promise<PendingConfirmation | undefined>;
13
- startFresh(session: ChannelKey): Promise<void>;
14
- /** Undefined until the conversation has run a turn since startup. */
15
- contextUsage(session: ChannelKey): ContextUse | undefined;
16
- }
4
+ export type { ContextUse };
5
+
6
+ /** The part of the runtime the agent team drives. */
7
+ export type AgentTurnRunner = Pick<
8
+ AgentRuntime,
9
+ "runTurn" | "heldActions" | "startFresh" | "contextUsage"
10
+ >;
17
11
 
18
12
  /** One agent's message in a channel, posted under its name and avatar. */
19
13
  export interface AgentPost {
@@ -82,13 +76,6 @@ export interface AgentChannels {
82
76
  ): Promise<ChannelMessage[]>;
83
77
  }
84
78
 
85
- /** A conversation's context use after its latest turn, as Pi estimates it. */
86
- export interface ContextUse {
87
- /** Null right after a compaction, until the next model response. */
88
- tokens: number | null;
89
- contextWindow: number;
90
- }
91
-
92
79
  /** The dashboard channel's one pinned message, made when missing and edited in place. */
93
80
  export interface DashboardBoard {
94
81
  show(sections: string[]): Promise<void>;
@@ -21,6 +21,8 @@ export function agentSystemPrompt(input: {
21
21
  group?: { group: AgentGroup; members: readonly Agent[] };
22
22
  /** Who the turn is for; without one, or the owner, the prompt speaks to the owner. */
23
23
  speaker?: Speaker;
24
+ /** Whether agents can redraw avatars; false without an image provider. Default true. */
25
+ avatars?: boolean;
24
26
  }): string {
25
27
  const { agent, coordinator, shared, workDir, owner, shellUser, group } =
26
28
  input;
@@ -32,7 +34,7 @@ export function agentSystemPrompt(input: {
32
34
  (guest && input.guestShared) || shared,
33
35
  `You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
34
36
  roleText(agent, coordinator, who),
35
- `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; agent_avatar redraws an avatar; schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
37
+ `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; ${input.avatars === false ? "" : "agent_avatar redraws an avatar; "}schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
36
38
  `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}. Commands that are destructive or change the system, and writes outside the workspace, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm.`,
37
39
  ];
38
40
  if (group) {