pi-roundtable 0.8.0 → 0.9.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 (158) hide show
  1. package/CHANGELOG.md +115 -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 +618 -86
  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 +150 -86
  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 +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -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 +42 -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/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -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");
@@ -1,3 +1,5 @@
1
+ import type { ChannelKey } from "../domain/conversation.ts";
2
+ import type { RoundtablePlugin } from "../plugin.ts";
1
3
  import { ASK_USER_TOOL } from "../runtime/extensions/ask-user.ts";
2
4
  import { COMPACT_TOOL } from "../runtime/extensions/self-compact-guard.ts";
3
5
  import { AGENTS } from "../services.ts";
@@ -16,6 +18,36 @@ export const seat: AgentTurnScope = {
16
18
  group: "war-room",
17
19
  };
18
20
 
21
+ /**
22
+ * The conversations the recorded sessions live in, served as a host serves them: the owner's
23
+ * direct messages by an app's claim, such as one that answers the owner there, and the agent's
24
+ * channel by the agent server; both answer background turns, so their sessions schedule and
25
+ * delegate. Give it to the test host the tool set is captured on.
26
+ */
27
+ export function servedConversations(
28
+ ownerChannel: ChannelKey,
29
+ ): RoundtablePlugin {
30
+ const served = new Set<ChannelKey>([ownerChannel, scout.home, seat.home]);
31
+ return {
32
+ name: "served-conversations",
33
+ setup: () => ({
34
+ channels: [
35
+ {
36
+ name: "served-conversations",
37
+ priority: 0,
38
+ owns: (channel) => served.has(channel),
39
+ admit: () => undefined,
40
+ background: async () => ({
41
+ status: "skipped",
42
+ reason: "the tool set's host runs no background turns",
43
+ }),
44
+ startFresh: async () => "owner",
45
+ },
46
+ ],
47
+ }),
48
+ };
49
+ }
50
+
19
51
  /** Tools the core's own extensions and pi-web-access register, which no fake session here loads. */
20
52
  const NAMED_ELSEWHERE = [
21
53
  ASK_USER_TOOL,
@@ -37,7 +37,7 @@
37
37
  "memory_add",
38
38
  "memory_remove",
39
39
  "memory_search",
40
- "notify_owner",
40
+ "notify",
41
41
  "schedule_cancel",
42
42
  "schedule_create",
43
43
  "schedule_list",
@@ -92,7 +92,7 @@
92
92
  "memory_remove",
93
93
  "memory_search",
94
94
  "message_agent",
95
- "notify_owner",
95
+ "notify",
96
96
  "schedule_cancel",
97
97
  "schedule_create",
98
98
  "schedule_list",
@@ -179,7 +179,7 @@
179
179
  "memory_add",
180
180
  "memory_remove",
181
181
  "memory_search",
182
- "notify_owner",
182
+ "notify",
183
183
  "schedule_cancel",
184
184
  "schedule_create",
185
185
  "schedule_list",
@@ -272,7 +272,7 @@
272
272
  "memory_remove": "member",
273
273
  "memory_search": "member",
274
274
  "message_agent": "member",
275
- "notify_owner": "owner",
275
+ "notify": "owner",
276
276
  "read": "owner",
277
277
  "read_attachment": "member",
278
278
  "schedule_cancel": "admin",
@@ -1,4 +1,5 @@
1
1
  import { PluginError } from "./errors.ts";
2
+ import type { Logger } from "./log.ts";
2
3
  import { ASK_USER_TOOL } from "./runtime/extensions/ask-user.ts";
3
4
  import { COMPACT_TOOL } from "./runtime/extensions/self-compact-guard.ts";
4
5
  import { type Tier, tierAtLeast } from "./speakers.ts";
@@ -27,6 +28,40 @@ export const CORE_TOOL_TIERS: Readonly<Record<string, Tier>> = set("member", [
27
28
  "read_attachment",
28
29
  ]);
29
30
 
31
+ /**
32
+ * Tools renamed since 0.8, by their old name: until 1.0 the old name still names the tool in a
33
+ * selection, a task's `exclude`, a plugin's `requiredTools`, and the operator's and plugins'
34
+ * `toolTiers`, where the new name's own setting wins.
35
+ */
36
+ export const RENAMED_TOOLS: Readonly<Record<string, string>> = {
37
+ notify_owner: "notify",
38
+ };
39
+
40
+ const warnedAliases = new WeakMap<Logger, Set<string>>();
41
+
42
+ /** Each logger warns once for an old name used in tiers or selections. */
43
+ function warnAliases(tools: readonly string[], logger: Logger): void {
44
+ const warned = warnedAliases.get(logger) ?? new Set<string>();
45
+ warnedAliases.set(logger, warned);
46
+ for (const tool of tools) {
47
+ const renamed = RENAMED_TOOLS[tool];
48
+ if (!renamed || warned.has(tool)) continue;
49
+ warned.add(tool);
50
+ logger.warn(
51
+ `deprecated: ${tool} is now ${renamed}; update selections and toolTiers before 1.0`,
52
+ );
53
+ }
54
+ }
55
+
56
+ /** The tools named, each by its current name and once, in order. */
57
+ export function currentToolNames(
58
+ tools: readonly string[],
59
+ logger?: Logger,
60
+ ): string[] {
61
+ if (logger) warnAliases(tools, logger);
62
+ return [...new Set(tools.map((tool) => RENAMED_TOOLS[tool] ?? tool))];
63
+ }
64
+
30
65
  /**
31
66
  * What each tool needs: the operator's setting first, then the tier its plugin declared, then
32
67
  * the core's default, and the owner for a tool nobody named. Plugins declare theirs when the
@@ -38,7 +73,15 @@ export class ToolTierTable implements ToolTiers {
38
73
  readonly #core = new Map(Object.entries(CORE_TOOL_TIERS));
39
74
 
40
75
  constructor(operator: Readonly<Record<string, Tier>> = {}) {
41
- this.#operator = new Map(Object.entries(operator));
76
+ const entries = Object.entries(operator);
77
+ // An old name first, so the new name's own setting overrides it.
78
+ this.#operator = new Map([
79
+ ...entries.flatMap(([tool, tier]) => {
80
+ const renamed = RENAMED_TOOLS[tool];
81
+ return renamed ? [[renamed, tier] as const] : [];
82
+ }),
83
+ ...entries,
84
+ ]);
42
85
  }
43
86
 
44
87
  /**
@@ -46,7 +89,16 @@ export class ToolTierTable implements ToolTiers {
46
89
  * plugin declaring its own tool again, as when the host retries its start, replaces it.
47
90
  */
48
91
  declare(plugin: string, tiers: Readonly<Record<string, Tier>>): void {
49
- for (const [tool, tier] of Object.entries(tiers)) {
92
+ const entries = Object.entries(tiers);
93
+ // An old name is the new name's, declared first so the new name's own tier wins.
94
+ const named = [
95
+ ...entries.flatMap(([tool, tier]) => {
96
+ const renamed = RENAMED_TOOLS[tool];
97
+ return renamed ? [[renamed, tier] as const] : [];
98
+ }),
99
+ ...entries.filter(([tool]) => !RENAMED_TOOLS[tool]),
100
+ ];
101
+ for (const [tool, tier] of named) {
50
102
  const other = this.#declared.get(tool);
51
103
  if (other && other.plugin !== plugin)
52
104
  throw new PluginError(
@@ -73,11 +125,13 @@ export class ToolTierTable implements ToolTiers {
73
125
  /** The core's tiers with the operator's on top; plugins add theirs through `declare`. */
74
126
  export function toolTiers(
75
127
  operator: Readonly<Record<string, Tier>> = {},
128
+ logger?: Logger,
76
129
  ): ToolTierTable {
130
+ if (logger) warnAliases(Object.keys(operator), logger);
77
131
  return new ToolTierTable(operator);
78
132
  }
79
133
 
80
- /** The tools a tier may use, in order; a turn nobody spoke in has the owner's tier. */
134
+ /** The tools a tier may use, in order; a turn's tier is its speaker's, and a turn without one is refused. */
81
135
  export function toolsForTier(
82
136
  tools: readonly string[],
83
137
  tier: Tier,
@@ -4,12 +4,19 @@
4
4
  export type { DiscordServices } from "../core/builtin/discord.ts";
5
5
  export { DISCORD } from "../core/builtin/discord.ts";
6
6
  export type { DiscordAdapterConfig } from "../core/config/config.ts";
7
+ export type {
8
+ ChannelContext,
9
+ ChannelContextMessage,
10
+ ChannelContextOptions,
11
+ } from "../core/contract/channel-context.ts";
12
+ export { withChannelContext } from "../core/contract/channel-context.ts";
7
13
  export type { AgentPanelMessage } from "../core/discord/agent-commands.ts";
8
14
  export type {
9
15
  AgentPanel,
10
16
  AgentPanelOptions,
11
17
  } from "../core/discord/agent-panel.ts";
12
18
  export { agentPanel } from "../core/discord/agent-panel.ts";
19
+ export { CHANNEL_CONTEXT_DEFAULTS } from "../core/discord/channel-context.ts";
13
20
  export type { ManagedChannel } from "../core/discord/channel-executor.ts";
14
21
  export {
15
22
  fetchManagedChannel,
@@ -35,6 +42,7 @@ export type {
35
42
  DiscordConnection,
36
43
  } from "../core/discord/connection.ts";
37
44
  export { discord } from "../core/discord/discord-adapter.ts";
45
+ export type { DiscordActor } from "../core/discord/discord-owners.ts";
38
46
  export type {
39
47
  CommandGuard,
40
48
  CommandRegistrar,