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
@@ -1,5 +1,65 @@
1
+ import type { ChannelContextOptions } from "../contract/channel-context.ts";
1
2
  import { ConfigError } from "../domain/errors.ts";
2
- import { bool, optional, shape, text } from "./schema.ts";
3
+ import {
4
+ bool,
5
+ integer,
6
+ number,
7
+ oneOf,
8
+ optional,
9
+ orOff,
10
+ shape,
11
+ text,
12
+ } from "./schema.ts";
13
+
14
+ /** The Discord bot and its guild, as the top-level `discord` or `discord()` from `pi-roundtable/discord` gives them. */
15
+ export interface DiscordConfig {
16
+ /** The bot token; keep it in `.env`, not in this file. */
17
+ token: string;
18
+ /** The agent server's guild id. */
19
+ guild: string;
20
+ /** The channel the coordinator lives in. */
21
+ entryChannel: string;
22
+ /** The root slash command, without the slash; default the lowercase assistant name. */
23
+ rootCommand?: string;
24
+ /**
25
+ * Whether the owner's conversations have Discord administration tools; default true. `false`
26
+ * leaves the `discord-admin` addon out.
27
+ */
28
+ admin?: boolean;
29
+ /**
30
+ * Text appended as it is to the refusal a non-owner gets from the root command, such as a
31
+ * pointer to the commands anyone may use; include the space or punctuation your language
32
+ * needs before it. Default none.
33
+ */
34
+ refusalHint?: string;
35
+ /**
36
+ * Whose turns in the agents' channels load memory: `"everyone"` (default) lets each speaker
37
+ * read their own, `"owners"` only the owners, so a guest's turn has no memory in its prompt and
38
+ * no memory tools, and never makes claude-bridge refuse the owner afterwards.
39
+ */
40
+ agentMemory?: "everyone" | "owners";
41
+ /**
42
+ * What a turn addressed to the assistant in a server channel reads of the messages posted there
43
+ * since its last post, mentions or not; on with the defaults when left out. `false` turns it off
44
+ * for the agent server and every plugin's claim.
45
+ */
46
+ channelContext?: ChannelContextOptions | false;
47
+ /**
48
+ * The divider the assistant posts in a server channel or thread when its conversation starts
49
+ * over (a new conversation by an owner's command), which ends the window channel context reads;
50
+ * never in a direct message. Default `─── new conversation ───`; `false` posts none.
51
+ */
52
+ freshMarker?: string | false;
53
+ }
54
+
55
+ /** What `discord.channelContext` takes besides `false`. */
56
+ const channelContextShape = shape({
57
+ fetch: optional(integer(1, 100)),
58
+ keep: optional(integer(1, 100)),
59
+ similarity: optional(number(0, 1)),
60
+ messageChars: optional(integer(1, 4000)),
61
+ botMessageChars: optional(integer(1, 4000)),
62
+ });
3
63
 
4
64
  /** The Discord settings, at the top level or in a Discord adapter. */
5
65
  export const discordShape = shape({
@@ -9,6 +69,9 @@ export const discordShape = shape({
9
69
  rootCommand: optional(text),
10
70
  admin: optional(bool),
11
71
  refusalHint: optional(text),
72
+ agentMemory: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
73
+ channelContext: optional(orOff(channelContextShape)),
74
+ freshMarker: optional(orOff(text)),
12
75
  });
13
76
 
14
77
  /** The adapters `adapters` takes, by the name their factory gives them. */
@@ -3,7 +3,9 @@ import { join } from "node:path";
3
3
  import type { AgentSeed } from "../agents/agent-rules.ts";
4
4
  import { ConfigError } from "../domain/errors.ts";
5
5
  import type { InterimTextMode } from "../domain/interim.ts";
6
+ import { DRAIN_LIMIT_MS } from "../drain.ts";
6
7
  import { isLocale, type Locale } from "../i18n/index.ts";
8
+ import type { AccessRules } from "../identity/access-policy.ts";
7
9
  import type { OwnerIdentity } from "../identity.ts";
8
10
  import {
9
11
  type ModelRef,
@@ -11,11 +13,13 @@ import {
11
13
  THINKING_LEVELS,
12
14
  type ThinkingLevel,
13
15
  } from "../models.ts";
16
+ import type { PerPrincipalLimits } from "../modules/background/personal-target.ts";
14
17
  import type { RoundtablePlugin } from "../plugin.ts";
15
18
  import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
16
19
  import type { ChannelKey } from "../sessions.ts";
17
20
  import type { Tier, TierMembers } from "../speakers.ts";
18
- import { discordShape, liftAdapters } from "./adapters.ts";
21
+ import { type AccessConfig, accessOf, accessShape } from "./access.ts";
22
+ import { type DiscordConfig, discordShape, liftAdapters } from "./adapters.ts";
19
23
  import {
20
24
  bool,
21
25
  guarded,
@@ -30,6 +34,8 @@ import {
30
34
  text,
31
35
  } from "./schema.ts";
32
36
 
37
+ export type { DiscordConfig };
38
+
33
39
  /** How prompts refer back to the owner. */
34
40
  export type Pronouns = "he" | "she" | "they";
35
41
 
@@ -43,29 +49,6 @@ export interface TierConfig {
43
49
  everyone?: boolean;
44
50
  }
45
51
 
46
- /** The Discord bot and its guild, as the top-level `discord` or `discord()` from `pi-roundtable/discord` gives them. */
47
- export interface DiscordConfig {
48
- /** The bot token; keep it in `.env`, not in this file. */
49
- token: string;
50
- /** The agent server's guild id. */
51
- guild: string;
52
- /** The channel the coordinator lives in. */
53
- entryChannel: string;
54
- /** The root slash command, without the slash; default the lowercase assistant name. */
55
- rootCommand?: string;
56
- /**
57
- * Whether the owner's conversations have Discord administration tools; default true. `false`
58
- * leaves the `discord-admin` addon out.
59
- */
60
- admin?: boolean;
61
- /**
62
- * Text appended as it is to the refusal a non-owner gets from the root command, such as a
63
- * pointer to the commands anyone may use; include the space or punctuation your language
64
- * needs before it. Default none.
65
- */
66
- refusalHint?: string;
67
- }
68
-
69
52
  /** A chat network the host talks through, as its adapter's factory makes it; `adapter` names the network. */
70
53
  export interface AdapterConfig {
71
54
  readonly adapter: string;
@@ -77,12 +60,21 @@ export interface DiscordAdapterConfig extends AdapterConfig {
77
60
  readonly discord: DiscordConfig;
78
61
  }
79
62
 
63
+ /** The configuration's `background`: each person's share of the host's background work. */
64
+ export interface BackgroundConfig {
65
+ perPrincipal?: PerPrincipalLimits;
66
+ }
67
+
80
68
  /** What `roundtable.config.ts` gives `defineRoundtable`. */
81
69
  export interface RoundtableConfig {
82
70
  /** The assistant's display name; default "Roundtable". */
83
71
  name?: string;
84
- /** The one owner: the only person who can change everything. */
85
- owner: { id: string; name: string; pronouns?: Pronouns };
72
+ /**
73
+ * The one owner, as 0.8 wrote it: their id, which is both their principal and their Discord
74
+ * user id, their name, and pronouns. Deprecated, going away in 0.10: write `access` instead,
75
+ * never both; `roundtable upgrade` rewrites it.
76
+ */
77
+ owner?: { id: string; name: string; pronouns?: Pronouns };
86
78
  /**
87
79
  * The Discord bot, its guild, and the agent server that lives there. Leave it out for a host
88
80
  * without Discord: no Discord plugins, no agent server or agents, no skills, and no tool that
@@ -113,8 +105,19 @@ export interface RoundtableConfig {
113
105
  locale?: Locale;
114
106
  /** The IANA time zone schedules and stamps use; default "UTC". */
115
107
  timeZone?: string;
116
- /** Who may talk to the agents besides the owner, and what each tier may use. */
108
+ /** Who may talk to the agents besides the owner, by Discord user and role ids, as 0.8 wrote it. Deprecated with `owner`: write `access` instead. */
117
109
  speakers?: { admins?: TierConfig; members?: TierConfig };
110
+ /**
111
+ * Who the host serves: the owners, the first the primary one, and the admins and members by
112
+ * identity (`<provider>:<subject>`), role (`<surface>:role:<name>`), or everyone. Required
113
+ * unless the deprecated `owner` is given, and never with it.
114
+ */
115
+ access?: AccessConfig;
116
+ /**
117
+ * Each person's limits across all their conversations: `perPrincipal.schedules` they may keep and
118
+ * `perPrincipal.delegations` running at once. Unset, only each conversation's, as in 0.8.
119
+ */
120
+ background?: BackgroundConfig;
118
121
  toolTiers?: Record<string, Tier>;
119
122
  /** Prompt files, read once at start; `shared` starts every agent's prompt. */
120
123
  prompts?: { shared: string; guest?: string };
@@ -162,6 +165,11 @@ export interface RoundtableConfig {
162
165
  * conversation or no plugin's claim owns it; the web chat takes no error reports.
163
166
  */
164
167
  ops?: { agent: string } | { conversation: string };
168
+ /**
169
+ * Seconds a shutdown waits for running turns before stopping them; default 180. Give the
170
+ * service manager a stop timeout a minute longer, such as systemd's `TimeoutStopSec`.
171
+ */
172
+ drainSeconds?: number;
165
173
  /**
166
174
  * Whether a turn posts the text it writes before its final answer as it goes: long or
167
175
  * structured text as ordinary messages, short narration and the tools called in one small
@@ -187,11 +195,13 @@ const isPlugin = (value: unknown): value is RoundtablePlugin =>
187
195
 
188
196
  const schema = shape({
189
197
  name: optional(text),
190
- owner: shape({
191
- id: text,
192
- name: text,
193
- pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
194
- }),
198
+ owner: optional(
199
+ shape({
200
+ id: text,
201
+ name: text,
202
+ pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
203
+ }),
204
+ ),
195
205
  discord: optional(discordShape),
196
206
  database: shape({ url: text }),
197
207
  dataDir: text,
@@ -216,6 +226,17 @@ const schema = shape({
216
226
  speakers: optional(
217
227
  shape({ admins: optional(tierMembers), members: optional(tierMembers) }),
218
228
  ),
229
+ access: optional(accessShape),
230
+ background: optional(
231
+ shape({
232
+ perPrincipal: optional(
233
+ shape({
234
+ schedules: optional(integer(1, 10_000)),
235
+ delegations: optional(integer(1, 1_000)),
236
+ }),
237
+ ),
238
+ }),
239
+ ),
219
240
  toolTiers: optional(record(oneOf<Tier>("owner", "admin", "member"))),
220
241
  prompts: optional(shape({ shared: text, guest: optional(text) })),
221
242
  agents: optional(
@@ -246,6 +267,7 @@ const schema = shape({
246
267
  ),
247
268
  memory: optional(bool),
248
269
  ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
270
+ drainSeconds: optional(integer(1, 3_600)),
249
271
  interimText: optional(oneOf<InterimTextMode>("on", "off")),
250
272
  interimPrimaryChars: optional(integer(1, 100_000)),
251
273
  plugins: optional(
@@ -255,12 +277,6 @@ const schema = shape({
255
277
  ),
256
278
  });
257
279
 
258
- const PRONOUNS: Record<Pronouns, OwnerIdentity["pronouns"]> = {
259
- he: { subject: "he", object: "him", possessive: "his" },
260
- she: { subject: "she", object: "her", possessive: "her" },
261
- they: { subject: "they", object: "them", possessive: "their" },
262
- };
263
-
264
280
  /** The configuration with every default filled in. */
265
281
  export interface ResolvedConfig {
266
282
  name: string;
@@ -269,7 +285,17 @@ export interface ResolvedConfig {
269
285
  * with Discord. `discord.rootCommand` when Discord is on, else the lowercase assistant name.
270
286
  */
271
287
  slug: string;
288
+ /**
289
+ * The primary owner, `access.owners[0]` (or the 0.8 `owner`): their principal id, name, and
290
+ * pronouns, and the Discord user id the Discord parts act for until 0.10.
291
+ */
292
+ primaryOwner: OwnerIdentity & { id: string; discordId?: string };
293
+ /** The primary owner as 0.8 knew them, `id` their principal id. Deprecated: read `primaryOwner`. */
272
294
  owner: OwnerIdentity & { id: string };
295
+ /** Who the host serves, the 0.8 `owner` and `speakers` converted when they are what the configuration gives. */
296
+ access: AccessRules;
297
+ /** What the configuration wrote in a deprecated form, which the host logs once. */
298
+ deprecations: string[];
273
299
  /** Undefined on a host without Discord. */
274
300
  discord?: {
275
301
  token: string;
@@ -278,6 +304,10 @@ export interface ResolvedConfig {
278
304
  rootCommand: string;
279
305
  admin: boolean;
280
306
  refusalHint?: string;
307
+ agentMemory?: "everyone" | "owners";
308
+ /** As configured: `{}` when left out, so the defaults apply, or `false`. */
309
+ channelContext: NonNullable<DiscordConfig["channelContext"]>;
310
+ freshMarker?: string | false;
281
311
  };
282
312
  databaseUrl: string;
283
313
  dataDir: string;
@@ -288,7 +318,10 @@ export interface ResolvedConfig {
288
318
  delegation: { model: ModelRef; thinking: ThinkingLevel };
289
319
  locale: Locale;
290
320
  timeZone: string;
321
+ /** The 0.8 `speakers`; empty when the configuration writes `access`. Deprecated: read `access`. */
291
322
  speakers: { admins?: TierMembers; members?: TierMembers };
323
+ /** Each person's limits across their conversations; a limit left out is unset. */
324
+ background: { perPrincipal: PerPrincipalLimits };
292
325
  toolTiers: Record<string, Tier>;
293
326
  prompts: { shared?: string; guest?: string };
294
327
  agents: AgentSeed[];
@@ -308,6 +341,7 @@ export interface ResolvedConfig {
308
341
  skills: false | { builtinDir?: string; reposDir?: string };
309
342
  memory: boolean;
310
343
  ops?: { agent: string } | { conversation: ChannelKey };
344
+ drainMs: number;
311
345
  interimText: InterimTextMode;
312
346
  interimPrimaryChars: number;
313
347
  plugins: RoundtablePlugin[];
@@ -378,13 +412,16 @@ export function resolveConfig(input: unknown): ResolvedConfig {
378
412
  const slug =
379
413
  discord?.rootCommand ?? name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
380
414
  const ops = opsOf(config.ops, discord !== undefined);
415
+ const access = accessOf(config, discord !== undefined);
416
+ const { primaryOwner } = access;
381
417
  return {
382
418
  name,
383
419
  slug,
420
+ ...access,
384
421
  owner: {
385
- id: config.owner.id,
386
- name: config.owner.name,
387
- pronouns: PRONOUNS[config.owner.pronouns ?? "they"],
422
+ id: primaryOwner.id,
423
+ name: primaryOwner.name,
424
+ pronouns: primaryOwner.pronouns,
388
425
  },
389
426
  ...(discord
390
427
  ? {
@@ -397,6 +434,13 @@ export function resolveConfig(input: unknown): ResolvedConfig {
397
434
  ...(discord.refusalHint === undefined
398
435
  ? {}
399
436
  : { refusalHint: discord.refusalHint }),
437
+ ...(discord.agentMemory === undefined
438
+ ? {}
439
+ : { agentMemory: discord.agentMemory }),
440
+ channelContext: discord.channelContext ?? {},
441
+ ...(discord.freshMarker === undefined
442
+ ? {}
443
+ : { freshMarker: discord.freshMarker }),
400
444
  },
401
445
  }
402
446
  : {}),
@@ -423,6 +467,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
423
467
  ...(config.speakers?.admins ? { admins: config.speakers.admins } : {}),
424
468
  ...(config.speakers?.members ? { members: config.speakers.members } : {}),
425
469
  },
470
+ background: { perPrincipal: { ...config.background?.perPrincipal } },
426
471
  toolTiers: config.toolTiers ?? {},
427
472
  prompts: config.prompts ?? {},
428
473
  agents: config.agents ?? [],
@@ -446,6 +491,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
446
491
  skills: config.skills ?? (discord ? {} : false),
447
492
  memory: config.memory ?? true,
448
493
  ...(ops ? { ops } : {}),
494
+ drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
449
495
  interimText: config.interimText ?? "on",
450
496
  interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
451
497
  plugins: config.plugins ?? [],
@@ -0,0 +1,53 @@
1
+ // What a surface tells a turn about the channel around the message it answers. Discord fills it
2
+ // (`DISCORD.channelContext`); the types and the way a turn carries it are any surface's.
3
+
4
+ /** How much of a channel a turn reads as its channel context; every member has a default. */
5
+ export interface ChannelContextOptions {
6
+ /** How many messages before the addressed one are fetched; default 50, at most 100 (Discord's page). */
7
+ fetch?: number;
8
+ /** How many of the window's messages are kept, the newest; default 15. */
9
+ keep?: number;
10
+ /**
11
+ * How alike (0 to 1) two consecutive messages of one author must be to count as one, the later
12
+ * kept, such as a message and its corrected repost; default 0.8.
13
+ */
14
+ similarity?: number;
15
+ /** Where a message's text is cut; default 500 characters. */
16
+ messageChars?: number;
17
+ /** Where another bot's message is cut; default 80 characters. */
18
+ botMessageChars?: number;
19
+ }
20
+
21
+ /** One channel message a turn reads around the message that addressed the assistant. */
22
+ export interface ChannelContextMessage {
23
+ id: string;
24
+ authorId: string;
25
+ /** How the author appears in the channel. */
26
+ authorName: string;
27
+ /** Another bot, or an integration such as a webhook that is not the assistant's. */
28
+ bot: boolean;
29
+ /** One of the host's owners. */
30
+ owner: boolean;
31
+ /** The text, with mentions by name and stickers and files as text, cut where the options say. */
32
+ text: string;
33
+ at: Date;
34
+ }
35
+
36
+ /**
37
+ * What was said in a server channel since the assistant last posted there, before the message that
38
+ * addressed it: public channel text, not anyone's private memory, and not from the turn's speaker.
39
+ */
40
+ export interface ChannelContext {
41
+ /** Oldest first; never empty. */
42
+ readonly messages: readonly ChannelContextMessage[];
43
+ /** The delimited block `withChannelContext` appends to a turn's text. */
44
+ readonly text: string;
45
+ }
46
+
47
+ /** The turn's text, then its channel context when there is one, as `withReference` adds a replied-to message. */
48
+ export function withChannelContext(
49
+ text: string,
50
+ context: ChannelContext | undefined,
51
+ ): string {
52
+ return context ? `${text}\n\n${context.text}` : text;
53
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Locale } from "../i18n/index.ts";
2
+ import type { ActorFacts } from "../identity/actor-facts.ts";
2
3
  import type { ChannelKey } from "../sessions.ts";
3
- import type { Tier } from "../speakers.ts";
4
+ import type { Speaker, Tier } from "../speakers.ts";
4
5
 
5
6
  /** A file attached to a chat message, as the surface reports it; nothing is downloaded yet. */
6
7
  export interface AttachmentRef {
@@ -16,6 +17,21 @@ export interface InboundMessage {
16
17
  channel: ChannelKey;
17
18
  /** Unique per surface; keeps the files of different messages apart. */
18
19
  messageId: string;
20
+ /**
21
+ * Who wrote it, as the surface reports them: the router resolves them to `speaker` through the
22
+ * identity service. A surface that leaves it out has it read from `authorId`, `authorName`, and
23
+ * `authorRoleIds` under its own name as the provider, with a `deprecated` warning; that goes
24
+ * away in 1.0.
25
+ */
26
+ actor?: ActorFacts;
27
+ /**
28
+ * Who the author is, with their principal and tier, as the router resolved them before any
29
+ * claim admits the message: undefined when the access rules serve no one by them, and for a bot
30
+ * or an integration, which are not resolved. Only the router sets it; a surface's own value is
31
+ * dropped. A claim that serves only people the host serves answers only when it is set.
32
+ */
33
+ speaker?: Speaker;
34
+ /** The id the surface knows the author by; who they are to the host is `speaker`. */
19
35
  authorId: string;
20
36
  /** How the author appears in the channel, for example a server nickname. */
21
37
  authorName: string;
@@ -77,10 +93,22 @@ export interface BackgroundTarget {
77
93
  name: string;
78
94
  /** How lists such as `/<root> schedule` name it, in the host's locale. */
79
95
  label(locale: Locale): string;
80
- /** Limits of the schedules made for it; absent, nothing may be scheduled for it. */
81
- schedules?: { perChannel: number; promptChars: number; aheadDays: number };
82
- /** Limits of the delegated tasks reporting to it; absent, nothing may be delegated to it. */
83
- delegation?: { maxRunning: number };
96
+ /**
97
+ * Limits of the schedules made for it; absent, nothing may be scheduled for it. `perPrincipal`,
98
+ * when set, holds each person to that many of its schedules across all their conversations.
99
+ */
100
+ schedules?: {
101
+ perChannel: number;
102
+ perPrincipal?: number;
103
+ promptChars: number;
104
+ aheadDays: number;
105
+ };
106
+ /**
107
+ * Limits of the delegated tasks reporting to it; absent, nothing may be delegated to it.
108
+ * `maxRunningPerPrincipal`, when set, holds each person to that many running at once across
109
+ * all their conversations.
110
+ */
111
+ delegation?: { maxRunning: number; maxRunningPerPrincipal?: number };
84
112
  }
85
113
 
86
114
  /** A turn nobody wrote in the channel: a schedule's, or a report of work done elsewhere. */
@@ -91,18 +119,31 @@ export interface BackgroundTurn {
91
119
  * plugin contributes, and a claim skips one it does not serve.
92
120
  */
93
121
  target: string;
94
- author: { id: string; name: string };
95
122
  /**
96
- * The tier the turn runs at, which is its creator's when a person set it up; absent for
97
- * the operator's own automation, which runs at the owner's.
123
+ * Whom the turn is for: the principal who set up the work, and the id and name they are shown
124
+ * by, such as the identity they spoke as. The system principal is the host's alone.
98
125
  */
99
- tier?: Tier;
126
+ author: { principalId: string; id: string; name: string };
127
+ /**
128
+ * The tier the turn asks to run at, its creator's when they set it up. It runs at that tier or
129
+ * the principal's own now, whichever is lower.
130
+ */
131
+ tier: Tier;
100
132
  turnId: string;
101
133
  text: string;
102
134
  /** It delivers a report the owner is waiting for, so it may ask them on cards. */
103
135
  report?: boolean;
136
+ /**
137
+ * Who the turn runs as, set by the router alone once it checked the author: their principal
138
+ * exists and is not disabled, and the tier is capped at theirs. A claim runs the turn as this
139
+ * speaker; one the caller sets is replaced.
140
+ */
141
+ speaker?: Speaker;
104
142
  }
105
143
 
144
+ /** Who a background turn runs as, once checked; or why it may not run now. */
145
+ export type BackgroundRunsAs = { speaker: Speaker } | { skipped: string };
146
+
106
147
  /**
107
148
  * Whose conversation a channel holds, as the claim that owns it names it when `startFresh` says
108
149
  * whose it was: any string, such as "owner" or "study". The host reads none of them, so a plugin
@@ -125,6 +166,12 @@ export type Admission =
125
166
  };
126
167
  /** The turn, run in the channel's queue. */
127
168
  run(): Promise<void>;
169
+ /**
170
+ * Called instead of `run` when the router drops the message the claim admitted, because
171
+ * its author, once recorded, was not who they were when admitted; frees what the claim
172
+ * holds for it.
173
+ */
174
+ dropped?(): void;
128
175
  /** The log line when the turn throws. */
129
176
  failure: string;
130
177
  }
@@ -161,13 +208,32 @@ export interface ChannelClaim {
161
208
  deleteConversation?(channel: ChannelKey): Promise<void>;
162
209
  /** Reports of work started in an owned channel stay in it instead of opening a thread. */
163
210
  postsInPlace?: boolean;
211
+ /**
212
+ * Whether the claim's `background` answers the host's own turns (`SYSTEM_PRINCIPAL`), such as
213
+ * `ops.conversation`'s error reports, in its conversations; true when left out. A claim whose
214
+ * conversations are each private to the person who opened them, such as the web chat's, says
215
+ * false, and a host whose `ops.conversation` names one of them does not start.
216
+ */
217
+ takesSystemReports?: boolean;
164
218
  }
165
219
 
166
220
  /** The conversations of every claimed channel, through the channel queue. */
167
221
  export interface ConversationPort {
168
222
  /** Never rejects; resolves when the message's turn is done or dropped. */
169
223
  handle(message: InboundMessage): Promise<void>;
224
+ /**
225
+ * Runs a turn nobody wrote, as its author's principal at the lower of its tier and theirs; it
226
+ * is skipped, with the reason, when the principal is unknown, disabled, holds no tier, or was
227
+ * last seen too long ago (`access.backgroundStaleDays`), or when the turn names no principal or
228
+ * tier. Never rejects.
229
+ */
170
230
  background(turn: BackgroundTurn): Promise<ScheduledOutcome>;
231
+ /**
232
+ * Who a background turn would run as now, checked as `background` checks it, without running
233
+ * it: for work that must not happen for an author who may not run the turn, such as a
234
+ * schedule's precheck. Throws when the identity service cannot tell.
235
+ */
236
+ runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs>;
171
237
  /** A contributed background target, read when used; undefined when no plugin contributes it. */
172
238
  target(name: string): BackgroundTarget | undefined;
173
239
  /** Waits for the channel's running turn, then starts its conversation over; says whose it was. */
@@ -182,6 +248,8 @@ export interface ConversationPort {
182
248
  owns(channel: ChannelKey): boolean;
183
249
  /** Whether the claim that owns the channel runs background turns there; false when none owns it. */
184
250
  takesBackground(channel: ChannelKey): boolean;
251
+ /** Whether the claim that owns the channel takes the host's own turns; false when none owns it. */
252
+ takesSystemReports(channel: ChannelKey): boolean;
185
253
  }
186
254
 
187
255
  /** The host's channel queue, as plugins use it. */
@@ -191,6 +259,11 @@ export interface QueuePort {
191
259
  size(channel: ChannelKey): number;
192
260
  /** Channels with a task running or waiting. */
193
261
  busy(): ChannelKey[];
262
+ /**
263
+ * Whether the host is shutting down: a task that has not started is then refused with
264
+ * HostStoppingError, and a plugin that starts work on its own, such as a timer, starts none.
265
+ */
266
+ readonly closed: boolean;
194
267
  /** Called whenever a channel's count of running and waiting tasks changes. */
195
268
  onChange(listener: (channel: ChannelKey) => void): void;
196
269
  }
@@ -5,8 +5,8 @@ import type {
5
5
  TranscriptEntry,
6
6
  TurnResult,
7
7
  } from "../domain/conversation.ts";
8
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
9
8
  import type { TurnRequest } from "../domain/ports.ts";
9
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
10
10
  import type { Logger } from "../log.ts";
11
11
  import type { ThinkingSetting } from "../models.ts";
12
12
  import type { HostEnv, LinkedSessions } from "../plugin.ts";
@@ -51,11 +51,18 @@ export interface AgentRuntime {
51
51
  startFresh(conversation: ChannelKey): Promise<void>;
52
52
  /** Like `startFresh`, but removes the conversation and every archive of it for good; called between turns. */
53
53
  deleteConversation(conversation: ChannelKey): Promise<void>;
54
- /** The conversation's held actions known in memory since startup. */
54
+ /**
55
+ * The conversation's held actions known in memory: those of a turn run since startup, and those
56
+ * `heldActions` or `recentTranscript` restored from the store for it. After a restart, ask one of
57
+ * those first.
58
+ */
55
59
  pendingConfirmation(
56
60
  conversation: ChannelKey,
57
61
  ): PendingConfirmation | undefined;
58
- /** The conversation's held actions, restored from the store after a restart. */
62
+ /**
63
+ * The conversation's held actions, restored from the store after a restart, so that
64
+ * `pendingConfirmation` knows them too, as `recentTranscript` does. Expired ones count as none.
65
+ */
59
66
  heldActions(
60
67
  conversation: ChannelKey,
61
68
  ): Promise<PendingConfirmation | undefined>;
@@ -96,6 +103,13 @@ export interface AgentSessions {
96
103
  modelOf(name: string): { model: string; thinking: ThinkingSetting };
97
104
  /** The channel the scope's turns run in: the group's for a seat in one, else the agent's own. */
98
105
  turnChannel(scope: AgentTurnScope): ChannelKey;
106
+ /**
107
+ * Whose turns in an agent's conversation load memory: `"everyone"` (the default) lets each
108
+ * speaker read their own, `"owners"` only the owners, so a speaker below the owner tier gets no
109
+ * memory in their prompt and no memory tools, and their turn never carries private memory into
110
+ * the conversation's history.
111
+ */
112
+ memory?: "everyone" | "owners";
99
113
  }
100
114
 
101
115
  /** Where a runtime keeps each conversation's held actions, so they survive a restart. */
@@ -124,13 +138,15 @@ export interface RuntimeDeps {
124
138
  /** What each tool needs; plugin tools are added when the host links, so ask at use time. */
125
139
  toolTiers: ToolTiers;
126
140
  /**
127
- * The owner's approval and question prompts in a conversation, from its chat surface; undefined
128
- * when the surface has none, and the action then waits for the owner's next message.
141
+ * The approval and question prompts in a conversation for a turn's scope, made with
142
+ * `promptScope(speaker, visibility)`, from its chat surface; undefined when the surface has
143
+ * none, and the action then waits for a message that approves it. A `Speaker`, the 0.8 form,
144
+ * is read as theirs in a shared conversation, with a deprecation warning, until 1.0.
129
145
  */
130
146
  prompts(
131
147
  conversation: ChannelKey,
132
- speaker?: Speaker,
133
- ): OwnerPrompts | undefined;
148
+ scope?: PromptScope | Speaker,
149
+ ): Prompts | undefined;
134
150
  /**
135
151
  * The agent server's per-agent settings, for agent turns. The runtime is built before the agent
136
152
  * server, so read this when a turn runs, not in the factory; it stays undefined on a host without