pi-roundtable 0.8.0 → 0.9.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 (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,13 +1,16 @@
1
1
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
  import type { SQL } from "bun";
3
- import { OWNER_TARGET } from "../agents/agent-claim.ts";
4
3
  import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
5
4
  import { discordAdminPlugin } from "../builtin/discord-admin.ts";
6
5
  import { type ModulesOptions, modulesPlugin } from "../builtin/modules.ts";
7
6
  import { skillsPlugin } from "../builtin/skills.ts";
8
7
  import { memoryPlugin } from "../builtin/stores.ts";
8
+ import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
9
9
  import type { ChannelKey } from "../domain/conversation.ts";
10
+ import type { IdentityService } from "../identity/identity-service.ts";
11
+ import type { Principal } from "../identity/principal-store.ts";
10
12
  import { silentLogger } from "../log.ts";
13
+ import { PERSONAL_TARGET } from "../modules/background/personal-target.ts";
11
14
  import type { SkillStore } from "../modules/skills/skill-store.ts";
12
15
  import {
13
16
  type Contribution,
@@ -15,10 +18,22 @@ import {
15
18
  pluginContext,
16
19
  type RoundtablePlugin,
17
20
  } from "../plugin.ts";
21
+ import {
22
+ type DirectChannelProvider,
23
+ directChannelsPort,
24
+ } from "../presence/direct-channels.ts";
18
25
  import { ServiceRegistry } from "../registry/services.ts";
19
26
  import { surfacePort } from "../routing/surface-port.ts";
20
27
  import type { AgentServer, MemoryStore, ScheduleStore } from "../services.ts";
21
- import { AGENTS, MEMORY, SCHEDULES } from "../services.ts";
28
+ import {
29
+ AGENTS,
30
+ CONVERSATIONS,
31
+ IDENTITY,
32
+ MEMORY,
33
+ SCHEDULES,
34
+ } from "../services.ts";
35
+ import type { Tier } from "../speakers.ts";
36
+ import { toolTiers } from "../tool-tiers.ts";
22
37
 
23
38
  /** What the modules' tools did, for a test to read. */
24
39
  export interface ModuleRecord {
@@ -26,14 +41,28 @@ export interface ModuleRecord {
26
41
  threadOrigins: (ChannelKey | undefined)[];
27
42
  /** The channel each delegated report came back to. */
28
43
  reportChannels: ChannelKey[];
29
- /** How often a conversation without a chat channel fell back to the owner's messages. */
44
+ /** How often anything asked the connection for the primary owner's messages, which the modules no longer read. */
30
45
  ownerChannelAsked: number;
46
+ /** Whose direct channel was asked for, in order. */
47
+ directAsked: string[];
48
+ /** The notices sent through a direct channel, by principal. */
49
+ notified: { principalId: string; text: string }[];
31
50
  /** What was posted on the surface, by channel. */
32
51
  posted: { channel: ChannelKey; text: string }[];
33
52
  }
34
53
 
35
54
  export const OWNER_CHANNEL: ChannelKey = "discord:owner-dm";
36
55
 
56
+ /** The primary owner the modules serve: principal "1". */
57
+ export const MODULES_OWNER = {
58
+ id: "1",
59
+ name: "Owner",
60
+ pronouns: { subject: "they", object: "them", possessive: "their" },
61
+ };
62
+
63
+ /** The direct messages the stand-in Discord provider reaches by default: the owner's, principal "1". */
64
+ const DIRECT: Readonly<Record<string, ChannelKey>> = { "1": OWNER_CHANNEL };
65
+
37
66
  /** The modules plugin set up over stand-in stores and surface; returns its contribution and the record. */
38
67
  export async function setUpModules(
39
68
  options: {
@@ -46,7 +75,29 @@ export async function setUpModules(
46
75
  owns?: (channel: ChannelKey) => boolean;
47
76
  /** Whether the claim owning a channel takes background turns; by default every owned channel's does. */
48
77
  takesBackground?: (channel: ChannelKey) => boolean;
78
+ /** Whether the claim owning a channel takes the host's own turns; by default every owned channel's does. */
79
+ takesSystemReports?: (channel: ChannelKey) => boolean;
49
80
  errorReporter?: ModulesOptions["errorReporter"];
81
+ /** The schedules the tools keep; by default a stub that answers nothing. */
82
+ schedules?: ScheduleStore;
83
+ /** The conversations the host records, which say whose a conversation is; by default none is recorded. */
84
+ conversations?: Pick<ConversationRegistry, "get">;
85
+ /**
86
+ * Each principal's direct messages on the stand-in Discord provider, the host's only direct
87
+ * channel; by default the owner's are `OWNER_CHANNEL`, and without Discord there is none.
88
+ */
89
+ direct?: Readonly<Record<string, ChannelKey>>;
90
+ /**
91
+ * Whether the stand-in provider reaches the network, read at each `reaches`, which throws
92
+ * while it does not; `knows` answers from its records either way. Default always.
93
+ */
94
+ online?: () => boolean;
95
+ /** The host's owners, as `IDENTITY` lists them; by default there is no identity service. */
96
+ owners?: readonly Principal[];
97
+ /** The operator's tool tiers; by default none, so notify stays the owners'. */
98
+ toolTiers?: Readonly<Record<string, Tier>>;
99
+ /** The modules' options besides the test's own. */
100
+ modules?: Partial<ModulesOptions>;
50
101
  } = {},
51
102
  ): Promise<{
52
103
  plugin: RoundtablePlugin;
@@ -58,14 +109,31 @@ export async function setUpModules(
58
109
  threadOrigins: [],
59
110
  reportChannels: [],
60
111
  ownerChannelAsked: 0,
112
+ directAsked: [],
113
+ notified: [],
61
114
  posted: [],
62
115
  };
116
+ const direct =
117
+ options.direct ?? (options.discord === false ? undefined : DIRECT);
118
+ const providers: DirectChannelProvider[] = direct
119
+ ? [
120
+ {
121
+ name: "discord",
122
+ label: "a direct message on Discord",
123
+ knows: async (principalId) => direct[principalId] !== undefined,
124
+ reaches: async (principalId) => {
125
+ record.directAsked.push(principalId);
126
+ if (options.online?.() === false)
127
+ throw new Error("Discord is unreachable");
128
+ return direct[principalId];
129
+ },
130
+ deliver: async (principalId, text) =>
131
+ void record.notified.push({ principalId, text }),
132
+ },
133
+ ]
134
+ : [];
63
135
  const plugin = modulesPlugin({
64
- owner: {
65
- id: "1",
66
- name: "Owner",
67
- pronouns: { subject: "they", object: "them", possessive: "their" },
68
- },
136
+ owner: MODULES_OWNER,
69
137
  assistant: "Assistant",
70
138
  // SAFETY: a worker of the test's own replaces the one that would read the runtime.
71
139
  modelRuntime: {} as ModelRuntime,
@@ -77,11 +145,26 @@ export async function setUpModules(
77
145
  worker: { run: async () => "found it" },
78
146
  },
79
147
  ...(options.errorReporter ? { errorReporter: options.errorReporter } : {}),
148
+ ...options.modules,
80
149
  });
81
150
  const services = new ServiceRegistry([plugin]);
82
151
  // SAFETY: the tools under test read no store; each stub is asked for nothing else.
83
152
  services.preset(MEMORY, {} as MemoryStore);
84
- services.preset(SCHEDULES, {} as ScheduleStore);
153
+ services.preset(SCHEDULES, options.schedules ?? ({} as ScheduleStore));
154
+ if (options.conversations)
155
+ // SAFETY: the schedule tools only read a conversation's record.
156
+ services.preset(
157
+ CONVERSATIONS,
158
+ options.conversations as unknown as ConversationRegistry,
159
+ );
160
+ if (options.owners) {
161
+ const owners = options.owners;
162
+ // SAFETY: the modules ask the identity service for the owners and a principal by id only.
163
+ services.preset(IDENTITY, {
164
+ owners: async () => owners,
165
+ principal: async (id: string) => owners.find((owner) => owner.id === id),
166
+ } as unknown as IdentityService);
167
+ }
85
168
  if (options.agentChannelOf)
86
169
  // SAFETY: the schedule tools ask the agent team for a channel and nothing else.
87
170
  services.preset(AGENTS, {
@@ -108,30 +191,34 @@ export async function setUpModules(
108
191
  } as unknown as DiscordServices);
109
192
  const owns =
110
193
  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.
194
+ // Only Discord's channels have a surface; any other conversation has none to report in.
195
+ const surfaces = surfacePort(() => [
196
+ {
197
+ surface: "discord",
198
+ start: async () => undefined,
199
+ sendReply: async (channel, reply) =>
200
+ void record.posted.push({ channel, text: reply.chunks.join("") }),
201
+ },
202
+ ]);
203
+ // SAFETY: setup and the preflight read only the logger, the conversations' background, targets and owners, the surfaces, the direct channels, and the services.
112
204
  const context = {
113
205
  logger: silentLogger(),
114
206
  conversations: {
115
207
  target: (name: string) =>
116
- options.ownerTarget !== false && name === OWNER_TARGET.name
117
- ? OWNER_TARGET
208
+ options.ownerTarget !== false && name === PERSONAL_TARGET.name
209
+ ? PERSONAL_TARGET
118
210
  : undefined,
119
211
  owns,
120
212
  takesBackground: options.takesBackground ?? owns,
213
+ takesSystemReports: options.takesSystemReports ?? owns,
121
214
  background: async (turn: { channel: ChannelKey }) => {
122
215
  record.reportChannels.push(turn.channel);
123
216
  return { status: "ran" };
124
217
  },
125
218
  },
126
- // Only Discord's channels have a surface; any other conversation has none to report in.
127
- surfaces: surfacePort(() => [
128
- {
129
- surface: "discord",
130
- start: async () => undefined,
131
- sendReply: async (channel, reply) =>
132
- void record.posted.push({ channel, text: reply.chunks.join("") }),
133
- },
134
- ]),
219
+ surfaces,
220
+ directChannels: directChannelsPort(() => providers, surfaces),
221
+ toolTiers: toolTiers(options.toolTiers),
135
222
  } as unknown as Omit<PluginContext, "services">;
136
223
  const contribution = await services.setUp(plugin, () =>
137
224
  plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
@@ -1,3 +1,4 @@
1
+ import type { Principal } from "../identity/principal-store.ts";
1
2
  import type { OwnerIdentity } from "../identity.ts";
2
3
  import type { Speaker } from "../speakers.ts";
3
4
 
@@ -7,9 +8,25 @@ export const TEST_OWNER: OwnerIdentity = {
7
8
  pronouns: { subject: "he", object: "him", possessive: "his" },
8
9
  };
9
10
 
11
+ /** The owner as a principal of the tests, its id the owner speaker's, as for an owner carried over from 0.8. */
12
+ export const OWNER_PRINCIPAL: Principal = {
13
+ id: "1",
14
+ displayName: TEST_OWNER.name,
15
+ pronouns: "he",
16
+ disabled: false,
17
+ };
18
+
19
+ /** A member as a principal of the tests. */
20
+ export const MEMBER_PRINCIPAL: Principal = {
21
+ id: "2",
22
+ displayName: "Bo",
23
+ disabled: false,
24
+ };
25
+
10
26
  /** The owner as a speaker of the tests. */
11
27
  export const OWNER_SPEAKER: Speaker = {
12
28
  id: "1",
13
29
  name: TEST_OWNER.name,
14
30
  tier: "owner",
31
+ principalId: OWNER_PRINCIPAL.id,
15
32
  };
@@ -2,8 +2,12 @@ import { describe, test } from "bun:test";
2
2
  import type { InboundMessage } from "../contract/channels.ts";
3
3
  import type { ChatSurface } from "../contract/surface.ts";
4
4
  import { parseChannelKey } from "../contract/surface.ts";
5
- import type { Approval } from "../domain/owner-prompts.ts";
6
5
  import type { TurnProgress } from "../domain/progress.ts";
6
+ import {
7
+ type Approval,
8
+ type PromptScope,
9
+ promptScope,
10
+ } from "../interactions/prompts.ts";
7
11
  import type { ChannelKey } from "../sessions.ts";
8
12
  import type { Speaker } from "../speakers.ts";
9
13
 
@@ -33,23 +37,34 @@ export interface SurfaceContractSubject {
33
37
  /** The person answers an open approval; without it the prompt checks are skipped. */
34
38
  answer?(prompt: string, approved: boolean): Promise<void>;
35
39
  /**
36
- * The person as the speaker of the turns that ask them, given to `surface.prompts`; default an
37
- * owner-tier speaker. Every approval the contract asks for needs exactly the speaker's tier, so
38
- * it is theirs to answer.
40
+ * The person as the speaker of the turns that ask them, given to `surface.prompts` as the
41
+ * scope `promptScope(speaker, visibility)`; default an owner-tier speaker. Every approval the
42
+ * contract asks for needs exactly the speaker's tier, so it is theirs to answer; one above a
43
+ * speaker below the owner tier checks that a private conversation escalates to no one.
39
44
  */
40
45
  speaker?: Speaker;
41
46
  /**
42
- * Someone else on the surface's network, not the owner, who knows the id of the person's open
43
- * prompt and answers it; without it the responder-scope check is skipped.
47
+ * Someone else on the surface's network, another principal and not an owner, who knows the
48
+ * id of the person's open prompt and answers it; without it the responder-scope check is
49
+ * skipped.
44
50
  */
45
- stranger?: {
46
- /** They join the surface, once it has started. */
47
- join?(): Promise<void>;
48
- answer(prompt: string, approved: boolean): Promise<void>;
49
- };
51
+ stranger?: SurfaceContractAnswerer;
52
+ /**
53
+ * An owner on the surface's network, another principal than the person, who knows the id of
54
+ * the person's open prompt in their private conversation and answers it; without it that
55
+ * check is skipped.
56
+ */
57
+ owner?: SurfaceContractAnswerer;
50
58
  close?(): Promise<void>;
51
59
  }
52
60
 
61
+ /** Someone on the network other than the person, who answers a prompt by its id. */
62
+ export interface SurfaceContractAnswerer {
63
+ /** They join the surface, once it has started. */
64
+ join?(): Promise<void>;
65
+ answer(prompt: string, approved: boolean): Promise<void>;
66
+ }
67
+
53
68
  /** One way the subject broke the contract. */
54
69
  export interface SurfaceContractFailure {
55
70
  name: string;
@@ -93,7 +108,18 @@ const seen = (running: Running, kind: SurfaceObservation["kind"]) =>
93
108
  running.subject.observations().filter((o) => o.kind === kind);
94
109
 
95
110
  const speakerOf = (subject: SurfaceContractSubject): Speaker =>
96
- subject.speaker ?? { id: "contract-speaker", name: "Ada", tier: "owner" };
111
+ subject.speaker ?? {
112
+ id: "contract-speaker",
113
+ name: "Ada",
114
+ tier: "owner",
115
+ principalId: "contract-speaker",
116
+ };
117
+
118
+ /** The person's prompt scope in a conversation of this visibility. */
119
+ const scopeOf = (
120
+ subject: SurfaceContractSubject,
121
+ visibility: "private" | "shared" = "shared",
122
+ ): PromptScope => promptScope(speakerOf(subject), visibility);
97
123
 
98
124
  /** The newest prompt the person has seen, once one more than `before` is open. */
99
125
  async function nextPrompt(running: Running, before: number): Promise<string> {
@@ -109,7 +135,7 @@ async function nextPrompt(running: Running, before: number): Promise<string> {
109
135
  /** Asks for one approval and answers it as the person does. */
110
136
  async function approval(running: Running, approved: boolean) {
111
137
  const { surface, channel, answer } = running.subject;
112
- const prompts = surface.prompts?.(channel, speakerOf(running.subject));
138
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
113
139
  if (!prompts || !answer)
114
140
  throw new ContractBroken("the surface shows no prompts");
115
141
  const before = seen(running, "prompt").length;
@@ -264,7 +290,7 @@ const CHECKS: readonly Check[] = [
264
290
  subject.surface.prompts !== undefined && subject.answer !== undefined,
265
291
  run: async (running) => {
266
292
  const { surface, channel } = running.subject;
267
- const prompts = surface.prompts?.(channel, speakerOf(running.subject));
293
+ const prompts = surface.prompts?.(channel, scopeOf(running.subject));
268
294
  if (!prompts) throw new ContractBroken("the surface shows no prompts");
269
295
  const stop = new AbortController();
270
296
  const before = seen(running, "prompt").length;
@@ -293,39 +319,89 @@ const CHECKS: readonly Check[] = [
293
319
  subject.surface.prompts !== undefined &&
294
320
  subject.answer !== undefined &&
295
321
  subject.stranger !== undefined,
322
+ run: (running) =>
323
+ answeredOnlyByThem(running, running.subject.stranger, "shared"),
324
+ },
325
+ {
326
+ name: "another principal cannot answer for the person in their private conversation, even an owner",
327
+ applies: (subject) =>
328
+ subject.surface.prompts !== undefined &&
329
+ subject.answer !== undefined &&
330
+ subject.owner !== undefined,
331
+ run: (running) =>
332
+ answeredOnlyByThem(running, running.subject.owner, "private"),
333
+ },
334
+ {
335
+ name: "a private conversation's approval above the person's tier goes to no one: it expires at once",
336
+ applies: (subject) =>
337
+ subject.surface.prompts !== undefined &&
338
+ speakerOf(subject).tier !== "owner",
296
339
  run: async (running) => {
297
- const { surface, channel, answer, stranger } = running.subject;
298
- const prompts = surface.prompts?.(channel, speakerOf(running.subject));
299
- if (!prompts || !answer || !stranger)
300
- throw new ContractBroken("the surface shows no prompts");
301
- await stranger.join?.();
340
+ const { surface, channel } = running.subject;
341
+ const prompts = surface.prompts?.(
342
+ channel,
343
+ scopeOf(running.subject, "private"),
344
+ );
345
+ if (!prompts) throw new ContractBroken("the surface shows no prompts");
302
346
  const before = seen(running, "prompt").length;
303
- let settled: Approval | undefined;
304
- const result = prompts
305
- .confirm(
306
- "Approve?",
307
- "Send the report.",
308
- undefined,
309
- speakerOf(running.subject).tier,
310
- )
311
- .then((value) => {
312
- settled = value;
313
- return value;
314
- });
315
- const id = await nextPrompt(running, before);
316
- await stranger.answer(id, true);
317
- await Bun.sleep(50);
347
+ const answer = await Promise.race([
348
+ prompts.confirm("Approve?", "Delete the notes.", undefined, "owner"),
349
+ Bun.sleep(1000).then(() => "still open" as const),
350
+ ]);
318
351
  expectThat(
319
- settled === undefined,
320
- `another person's answer settled the approval as ${settled}`,
352
+ answer === "expired",
353
+ `an approval no one may answer came back ${answer}`,
354
+ );
355
+ expectThat(
356
+ seen(running, "prompt").length === before,
357
+ "an approval no one may answer was shown",
321
358
  );
322
- await answer(id, false);
323
- const own = await result;
324
- expectThat(own === "declined", `the person's answer came back ${own}`);
325
359
  },
326
360
  },
327
361
  ];
328
362
 
363
+ /**
364
+ * Opens the person's approval in a conversation of `visibility`, has `other` answer it by its id,
365
+ * and checks that only the person's own answer settles it.
366
+ */
367
+ async function answeredOnlyByThem(
368
+ running: Running,
369
+ other: SurfaceContractAnswerer | undefined,
370
+ visibility: "private" | "shared",
371
+ ): Promise<void> {
372
+ const { surface, channel, answer } = running.subject;
373
+ const prompts = surface.prompts?.(
374
+ channel,
375
+ scopeOf(running.subject, visibility),
376
+ );
377
+ if (!prompts || !answer || !other)
378
+ throw new ContractBroken("the surface shows no prompts");
379
+ await other.join?.();
380
+ const before = seen(running, "prompt").length;
381
+ let settled: Approval | undefined;
382
+ const result = prompts
383
+ .confirm(
384
+ "Approve?",
385
+ "Send the report.",
386
+ undefined,
387
+ speakerOf(running.subject).tier,
388
+ )
389
+ .then((value) => {
390
+ settled = value;
391
+ return value;
392
+ });
393
+ const id = await nextPrompt(running, before);
394
+ await other.answer(id, true);
395
+ await Bun.sleep(50);
396
+ expectThat(
397
+ settled === undefined,
398
+ `another principal's answer settled the approval as ${settled}`,
399
+ );
400
+ await answer(id, false);
401
+ const own = await result;
402
+ expectThat(own === "declined", `the person's answer came back ${own}`);
403
+ }
404
+
329
405
  async function runCheck(
330
406
  check: Check,
331
407
  make: () => Promise<SurfaceContractSubject>,
@@ -366,8 +442,9 @@ export async function checkSurfaceContract(
366
442
  * Registers the chat surface contract as one test per check, under `name`: the surface names a
367
443
  * prefix and owns its channel, delivers what its person writes, shows every chunk of a reply, and,
368
444
  * where it offers them, delivers files, ends typing and stop controls idempotently, shows
369
- * progress, resolves approvals as the person answers or as a stopped turn cancels them, and
370
- * ignores another person's answer to the person's approval. Each
445
+ * progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
446
+ * another person's answer to the person's approval, and, in a private conversation, an owner's
447
+ * too, and lets an approval above the person's tier there expire at once, shown to no one. Each
371
448
  * check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
372
449
  */
373
450
  export function describeSurfaceContract(
@@ -11,27 +11,32 @@ import { type RoundtableConfig, resolveConfig } from "../config/config.ts";
11
11
  import type { ConversationPort } from "../contract/channels.ts";
12
12
  import type { AgentRuntime } from "../contract/runtime.ts";
13
13
  import type { ChatSurface } from "../contract/surface.ts";
14
- import { defineRoundtable } from "../define-roundtable.ts";
14
+ import { defineRoundtable, discordOwnerOf } from "../define-roundtable.ts";
15
15
  import { CommandCollection } from "../discord/command-collection.ts";
16
16
  import type { ComposedCommands } from "../discord/compose-commands.ts";
17
17
  import type { DiscordConnection } from "../discord/connection.ts";
18
+ import { discordDirectChannel } from "../discord/direct-channel.ts";
18
19
  import type {
19
20
  CommandGuard,
20
21
  InteractionContribution,
21
22
  } from "../discord/interaction-module.ts";
22
- import { OwnerGuard, ownerRootCommand } from "../discord/owner-command.ts";
23
+ import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
23
24
  import { Roundtable } from "../host.ts";
24
25
  import { silentLogger } from "../log.ts";
25
26
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
26
27
  import { CompactionTiers } from "../runtime/compaction-tiers.ts";
28
+ import { privateCompaction } from "../runtime/extensions/private-memory.ts";
27
29
  import { sessionExtensions } from "../runtime/runtime-types.ts";
30
+ import { IDENTITY } from "../services.ts";
28
31
  import type { AgentTurnScope, SessionContext } from "../sessions.ts";
32
+ import { THE_SPEAKER } from "../speakers.ts";
29
33
  import { testDatabaseUrl } from "./database.ts";
30
34
 
31
35
  export interface TestHostOptions {
32
36
  /**
33
- * Over a test configuration: an owner, a guild, a temporary `dataDir`, and the test database
34
- * (`ROUNDTABLE_TEST_DATABASE_URL`).
37
+ * Over a test configuration: the owner Ada in `access`, a guild, a temporary `dataDir`, and
38
+ * the test database (`ROUNDTABLE_TEST_DATABASE_URL`). Giving the 0.8 `owner` or `speakers`
39
+ * replaces the default `access`.
35
40
  */
36
41
  config?: Partial<RoundtableConfig>;
37
42
  /** The app's plugins, after the built-in ones, as `defineRoundtable` places them. */
@@ -73,17 +78,28 @@ export interface TestHost {
73
78
  stop(): Promise<void>;
74
79
  }
75
80
 
81
+ /** The test host's owner, Ada, as `access` writes her: her principal is her Discord user id. */
82
+ const TEST_ACCESS: NonNullable<RoundtableConfig["access"]> = {
83
+ owners: [
84
+ {
85
+ name: "Ada",
86
+ principal: "100000000000000001",
87
+ identities: ["discord:100000000000000001"],
88
+ },
89
+ ],
90
+ };
91
+
76
92
  function defaultConfig(withDiscord: boolean): RoundtableConfig {
77
93
  const dataDir = mkdtempSync(join(tmpdir(), "roundtable-test-host-"));
78
94
  if (!withDiscord)
79
95
  return {
80
- owner: { id: "100000000000000001", name: "Ada" },
96
+ access: TEST_ACCESS,
81
97
  database: { url: testDatabaseUrl },
82
98
  dataDir,
83
99
  model: "anthropic/claude-sonnet-5-5",
84
100
  };
85
101
  return {
86
- owner: { id: "100000000000000001", name: "Ada" },
102
+ access: TEST_ACCESS,
87
103
  discord: {
88
104
  token: "token",
89
105
  guild: "900000000000000001",
@@ -122,10 +138,14 @@ function quietRuntime(): AgentRuntime {
122
138
  }
123
139
 
124
140
  /** A Discord that connects to nothing, recording the commands added to it, standing in for the built-in plugin that does. */
125
- function standInDiscord(
141
+ export function standInDiscord(
126
142
  commands: CommandCollection,
127
143
  guard: CommandGuard,
128
144
  given: TestHostOptions["discord"],
145
+ /** The primary owner's Discord user id, whose direct messages are the owner channel. */
146
+ ownerId: string,
147
+ /** What `DISCORD.channelContext` answers; by default no context, as on a host with it off. */
148
+ channelContext: DiscordServices["channelContext"] = async () => undefined,
129
149
  ): RoundtablePlugin {
130
150
  const surface: ChatSurface = {
131
151
  surface: "discord",
@@ -137,6 +157,18 @@ function standInDiscord(
137
157
  provides: [DISCORD],
138
158
  replaces: [DISCORD],
139
159
  setup: ({ services }) => {
160
+ const identity = services.find(IDENTITY);
161
+ const ownerChannel =
162
+ (given ? given.ownerChannel : undefined) ??
163
+ (async () => "discord:owner-dm" as const);
164
+ // Each person's direct messages, as the Discord plugin's provider reaches them; the owner's is the owner channel.
165
+ const direct = discordDirectChannel({
166
+ directChannel: async (userId) =>
167
+ userId === ownerId ? ownerChannel() : `discord:dm-${userId}`,
168
+ sendDirect: async () => undefined,
169
+ ...(identity ? { identity } : {}),
170
+ ownerId,
171
+ });
140
172
  // SAFETY: the plugins after it read only these members while they set up; the rest is read when a tool runs, which a test host does not do.
141
173
  services.provide(DISCORD, {
142
174
  connection: {
@@ -150,8 +182,9 @@ function standInDiscord(
150
182
  commands: commands.registrar,
151
183
  guard,
152
184
  threads: { open: async () => undefined, sweep: async () => undefined },
185
+ channelContext,
153
186
  } as unknown as DiscordServices);
154
- return { surfaces: [surface] };
187
+ return { surfaces: [surface], directChannels: [direct] };
155
188
  },
156
189
  };
157
190
  }
@@ -168,8 +201,8 @@ function ignored(): Ignored {
168
201
  /** The tools a factory registers, by name; every other registration (handlers, commands, events) is accepted and ignored. */
169
202
  async function registeredBy(factory: ExtensionFactory): Promise<string[]> {
170
203
  const names: string[] = [];
171
- // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
172
204
  const registerTool = (tool: { name: string }) => names.push(tool.name);
205
+ // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
173
206
  const api = new Proxy(
174
207
  {},
175
208
  {
@@ -188,6 +221,7 @@ const CORE_EXTENSIONS = {
188
221
  confirmationGate: noop,
189
222
  askUser: noop,
190
223
  selfCompactGuard: noop,
224
+ privateMemory: noop,
191
225
  activeTools: noop,
192
226
  };
193
227
 
@@ -211,17 +245,31 @@ export async function testHost(
211
245
  };
212
246
  const withDiscord = options.discord !== false;
213
247
  const given = options.discord === false ? undefined : options.discord;
214
- const base: RoundtableConfig = {
215
- ...defaultConfig(withDiscord),
216
- ...options.config,
217
- };
248
+ const defaults = defaultConfig(withDiscord);
249
+ // A test that writes the 0.8 owner or speakers gets them in place of the default access.
250
+ if (options.config?.owner || options.config?.speakers) delete defaults.access;
251
+ const base: RoundtableConfig = { ...defaults, ...options.config };
218
252
  const resolved = resolveConfig(base);
219
253
  const rootCommand = resolved.slug;
220
- const guard = new OwnerGuard(resolved.owner.id, silentLogger(), rootCommand);
254
+ // Like the Discord plugin's, the guard asks the host's identity service, once the host is up.
255
+ const identity = () => captured?.services.find(IDENTITY);
256
+ const guard = commandGuard({
257
+ ownerId: discordOwnerOf(resolved).id,
258
+ identity: {
259
+ resolve: async (facts, scope) => identity()?.resolve(facts, scope),
260
+ owners: async () => (await identity()?.owners()) ?? [],
261
+ identities: async (principalId) =>
262
+ (await identity()?.identities(principalId)) ?? [],
263
+ },
264
+ root: rootCommand,
265
+ logger: silentLogger(),
266
+ });
221
267
  const config: RoundtableConfig = {
222
268
  ...base,
223
269
  plugins: [
224
- ...(withDiscord ? [standInDiscord(commands, guard, given)] : []),
270
+ ...(withDiscord
271
+ ? [standInDiscord(commands, guard, given, discordOwnerOf(resolved).id)]
272
+ : []),
225
273
  {
226
274
  name: "test-runtime",
227
275
  providers: { runtime: () => runtime },
@@ -261,9 +309,24 @@ export async function testHost(
261
309
  kind: scope ? "agent" : "owner",
262
310
  homeChannel: scope?.home ?? ownerChannel,
263
311
  turnChannel: turnChannelOf(scope) ?? ownerChannel,
312
+ // Shared, as the runtime makes it, so its compactor summarizes no one's private memory.
264
313
  compaction: {
265
- wrap: (compactor) => tiers.wrapCompactor(compactor, () => undefined),
314
+ wrap: (compactor) =>
315
+ tiers.wrapCompactor(
316
+ compactor,
317
+ () => undefined,
318
+ (event) =>
319
+ privateCompaction(
320
+ event.preparation,
321
+ event.branchEntries.flatMap((entry) =>
322
+ entry.type === "message" ? [entry.message] : [],
323
+ ),
324
+ ),
325
+ ),
266
326
  },
327
+ conversation: { visibility: "shared" },
328
+ addressee: THE_SPEAKER,
329
+ memory: "speaker",
267
330
  speaker: () => undefined,
268
331
  runTask: async () => {
269
332
  throw new Error("a test host session cannot run tasks");