pi-roundtable 0.7.19 → 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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -0,0 +1,120 @@
1
+ import type { ChannelContextOptions } from "../contract/channel-context.ts";
2
+ import { ConfigError } from "../domain/errors.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
+ });
63
+
64
+ /** The Discord settings, at the top level or in a Discord adapter. */
65
+ export const discordShape = shape({
66
+ token: text,
67
+ guild: text,
68
+ entryChannel: text,
69
+ rootCommand: optional(text),
70
+ admin: optional(bool),
71
+ refusalHint: optional(text),
72
+ agentMemory: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
73
+ channelContext: optional(orOff(channelContextShape)),
74
+ freshMarker: optional(orOff(text)),
75
+ });
76
+
77
+ /** The adapters `adapters` takes, by the name their factory gives them. */
78
+ const ADAPTERS = ["discord"] as const;
79
+
80
+ /**
81
+ * The configuration with its adapters in the places the host reads them: a Discord adapter's
82
+ * settings as the top-level `discord`. Throws a ConfigError naming the adapter by its place.
83
+ */
84
+ // pi-lens-ignore: no-unknown-returns — the configuration as written is untyped until the schema checks it
85
+ export function liftAdapters(input: unknown): unknown {
86
+ if (typeof input !== "object" || input === null || !("adapters" in input))
87
+ return input;
88
+ const { adapters, ...rest } = input as Record<string, unknown>;
89
+ if (adapters === undefined) return rest;
90
+ if (!Array.isArray(adapters))
91
+ throw new ConfigError(
92
+ `config adapters: expected a list of adapters, such as [discord({ ... })] with discord from pi-roundtable/discord, got ${JSON.stringify(adapters)}. Fix the value in roundtable.config.ts.`,
93
+ );
94
+ const lifted: Record<string, unknown> = { ...rest };
95
+ adapters.forEach((entry: unknown, index) => {
96
+ const at = `adapters[${index}]`;
97
+ const name =
98
+ typeof entry === "object" && entry !== null
99
+ ? (entry as { adapter?: unknown }).adapter
100
+ : undefined;
101
+ if (typeof name !== "string")
102
+ throw new ConfigError(
103
+ `config ${at}: expected an adapter, an object its factory makes such as discord({ ... }) from pi-roundtable/discord, got ${JSON.stringify(entry)}. Fix the value in roundtable.config.ts.`,
104
+ );
105
+ if (!(ADAPTERS as readonly string[]).includes(name))
106
+ throw new ConfigError(
107
+ `config ${at}: unknown adapter ${JSON.stringify(name)}. The adapters are ${ADAPTERS.join(", ")}; a chat network that comes as a plugin, such as pi-roundtable-webchat's webChat(), belongs in plugins.`,
108
+ );
109
+ if (lifted.discord !== undefined)
110
+ throw new ConfigError(
111
+ `config ${at}: Discord is configured twice, at the top level or by another adapter. Keep one.`,
112
+ );
113
+ const { adapter: _adapter, ...settings } = entry as Record<string, unknown>;
114
+ lifted.discord = shape({ discord: discordShape }).check(
115
+ settings,
116
+ at,
117
+ ).discord;
118
+ });
119
+ return lifted;
120
+ }
@@ -4,6 +4,7 @@ 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
6
  import { isLocale, type Locale } from "../i18n/index.ts";
7
+ import type { AccessRules } from "../identity/access-policy.ts";
7
8
  import type { OwnerIdentity } from "../identity.ts";
8
9
  import {
9
10
  type ModelRef,
@@ -11,9 +12,13 @@ import {
11
12
  THINKING_LEVELS,
12
13
  type ThinkingLevel,
13
14
  } from "../models.ts";
15
+ import type { PerPrincipalLimits } from "../modules/background/personal-target.ts";
14
16
  import type { RoundtablePlugin } from "../plugin.ts";
15
17
  import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
18
+ import type { ChannelKey } from "../sessions.ts";
16
19
  import type { Tier, TierMembers } from "../speakers.ts";
20
+ import { type AccessConfig, accessOf, accessShape } from "./access.ts";
21
+ import { type DiscordConfig, discordShape, liftAdapters } from "./adapters.ts";
17
22
  import {
18
23
  bool,
19
24
  guarded,
@@ -28,6 +33,8 @@ import {
28
33
  text,
29
34
  } from "./schema.ts";
30
35
 
36
+ export type { DiscordConfig };
37
+
31
38
  /** How prompts refer back to the owner. */
32
39
  export type Pronouns = "he" | "she" | "they";
33
40
 
@@ -41,33 +48,46 @@ export interface TierConfig {
41
48
  everyone?: boolean;
42
49
  }
43
50
 
51
+ /** A chat network the host talks through, as its adapter's factory makes it; `adapter` names the network. */
52
+ export interface AdapterConfig {
53
+ readonly adapter: string;
54
+ }
55
+
56
+ /** What `discord()` from `pi-roundtable/discord` makes: the Discord settings under the adapter's name. */
57
+ export interface DiscordAdapterConfig extends AdapterConfig {
58
+ readonly adapter: "discord";
59
+ readonly discord: DiscordConfig;
60
+ }
61
+
62
+ /** The configuration's `background`: each person's share of the host's background work. */
63
+ export interface BackgroundConfig {
64
+ perPrincipal?: PerPrincipalLimits;
65
+ }
66
+
44
67
  /** What `roundtable.config.ts` gives `defineRoundtable`. */
45
68
  export interface RoundtableConfig {
46
69
  /** The assistant's display name; default "Roundtable". */
47
70
  name?: string;
48
- /** The one owner: the only person who can change everything. */
49
- owner: { id: string; name: string; pronouns?: Pronouns };
50
- discord: {
51
- /** The bot token; keep it in `.env`, not in this file. */
52
- token: string;
53
- /** The agent server's guild id. */
54
- guild: string;
55
- /** The channel the coordinator lives in. */
56
- entryChannel: string;
57
- /** The root slash command, without the slash; default the lowercase assistant name. */
58
- rootCommand?: string;
59
- /**
60
- * Whether the owner's conversations have Discord administration tools; default true. `false`
61
- * leaves the `discord-admin` addon out.
62
- */
63
- admin?: boolean;
64
- /**
65
- * Text appended as it is to the refusal a non-owner gets from the root command, such as a
66
- * pointer to the commands anyone may use; include the space or punctuation your language
67
- * needs before it. Default none.
68
- */
69
- refusalHint?: string;
70
- };
71
+ /**
72
+ * The one owner, as 0.8 wrote it: their id, which is both their principal and their Discord
73
+ * user id, their name, and pronouns. Deprecated, going away in 0.10: write `access` instead,
74
+ * never both; `roundtable upgrade` rewrites it.
75
+ */
76
+ owner?: { id: string; name: string; pronouns?: Pronouns };
77
+ /**
78
+ * The Discord bot, its guild, and the agent server that lives there. Leave it out for a host
79
+ * without Discord: no Discord plugins, no agent server or agents, no skills, and no tool that
80
+ * messages the owner on Discord; conversations come through the plugins' own surfaces. The same
81
+ * settings may come as `adapters: [discord({...})]` instead, never both.
82
+ */
83
+ discord?: DiscordConfig;
84
+ /**
85
+ * The chat networks the host talks through, each made by its adapter's factory, such as
86
+ * `discord()` from `pi-roundtable/discord`. A Discord adapter is the same as the top-level
87
+ * `discord`; a chat network that comes as a plugin, such as pi-roundtable-webchat, stays in
88
+ * `plugins`.
89
+ */
90
+ adapters?: readonly AdapterConfig[];
71
91
  database: { url: string };
72
92
  /** Where the process keeps its files, such as pictures, attachments, and skills. */
73
93
  dataDir: string;
@@ -84,20 +104,33 @@ export interface RoundtableConfig {
84
104
  locale?: Locale;
85
105
  /** The IANA time zone schedules and stamps use; default "UTC". */
86
106
  timeZone?: string;
87
- /** Who may talk to the agents besides the owner, and what each tier may use. */
107
+ /** 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. */
88
108
  speakers?: { admins?: TierConfig; members?: TierConfig };
109
+ /**
110
+ * Who the host serves: the owners, the first the primary one, and the admins and members by
111
+ * identity (`<provider>:<subject>`), role (`<surface>:role:<name>`), or everyone. Required
112
+ * unless the deprecated `owner` is given, and never with it.
113
+ */
114
+ access?: AccessConfig;
115
+ /**
116
+ * Each person's limits across all their conversations: `perPrincipal.schedules` they may keep and
117
+ * `perPrincipal.delegations` running at once. Unset, only each conversation's, as in 0.8.
118
+ */
119
+ background?: BackgroundConfig;
89
120
  toolTiers?: Record<string, Tier>;
90
121
  /** Prompt files, read once at start; `shared` starts every agent's prompt. */
91
122
  prompts?: { shared: string; guest?: string };
92
- /** The first team, created once; an agent already stored is never overwritten. */
123
+ /** The first team, created once; an agent already stored is never overwritten. Needs `discord`. */
93
124
  agents?: AgentSeed[];
94
125
  /**
95
- * Where the agents' avatars are served: the public address that reaches it, and the TCP port
96
- * (default 3000) or unix socket the process listens on. `socketMode` is the socket file's
97
- * permission bits (default `0o660`); widen it only for a proxy that runs as another user.
126
+ * The `public` listener: the public address that reaches it, and the TCP port (default 3000) or
127
+ * unix socket the process listens on. `socketMode` is the socket file's permission bits
128
+ * (default `0o660`); widen it only for a proxy that runs as another user. With `discord` it is
129
+ * required, with `publicUrl`, the address the agents' avatars are served from; without
130
+ * `discord` leave it out when no plugin serves HTTP, and the host opens no listener.
98
131
  */
99
- http: {
100
- publicUrl: string;
132
+ http?: {
133
+ publicUrl?: string;
101
134
  port?: number;
102
135
  hostname?: string;
103
136
  socketPath?: string;
@@ -109,13 +142,14 @@ export interface RoundtableConfig {
109
142
  workDir?: string;
110
143
  /**
111
144
  * The agents' scratch dir: their shell runs with TMPDIR pointing to it, and writes and removals
112
- * inside it run without a hold. Default `<os temp dir>/<discord.rootCommand>-scratch`.
145
+ * inside it run without a hold. Default `<os temp dir>/<slug>-scratch`, where the slug is
146
+ * `discord.rootCommand`, or the lowercase assistant name without Discord.
113
147
  */
114
148
  scratchDir?: string;
115
149
  /**
116
150
  * Where the skill registry reads built-in skills and keeps linked repositories. `false` leaves
117
151
  * the `skills` addon out: agents carry no skills, and no skill tools exist. Stored skills stay
118
- * in their tables.
152
+ * in their tables. Without `discord` there are no agents, so the addon is off.
119
153
  */
120
154
  skills?: false | { builtinDir?: string; reposDir?: string };
121
155
  /**
@@ -123,8 +157,13 @@ export interface RoundtableConfig {
123
157
  * `memory` addon out: no memory tools, no memory prompt block, and the table stays as it is.
124
158
  */
125
159
  memory?: boolean;
126
- /** The agent that investigates the process's own errors, by name. */
127
- ops?: { agent: string };
160
+ /**
161
+ * Where the process's own errors are reported: to an agent, by name, in its channel (needs
162
+ * `discord`), or to a conversation, by its key `<surface>:<id>`, as a visible message and a
163
+ * report turn its claim answers. The host does not start when no chat surface serves the
164
+ * conversation or no plugin's claim owns it; the web chat takes no error reports.
165
+ */
166
+ ops?: { agent: string } | { conversation: string };
128
167
  /**
129
168
  * Whether a turn posts the text it writes before its final answer as it goes: long or
130
169
  * structured text as ordinary messages, short narration and the tools called in one small
@@ -150,19 +189,14 @@ const isPlugin = (value: unknown): value is RoundtablePlugin =>
150
189
 
151
190
  const schema = shape({
152
191
  name: optional(text),
153
- owner: shape({
154
- id: text,
155
- name: text,
156
- pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
157
- }),
158
- discord: shape({
159
- token: text,
160
- guild: text,
161
- entryChannel: text,
162
- rootCommand: optional(text),
163
- admin: optional(bool),
164
- refusalHint: optional(text),
165
- }),
192
+ owner: optional(
193
+ shape({
194
+ id: text,
195
+ name: text,
196
+ pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
197
+ }),
198
+ ),
199
+ discord: optional(discordShape),
166
200
  database: shape({ url: text }),
167
201
  dataDir: text,
168
202
  agentDir: optional(text),
@@ -186,6 +220,17 @@ const schema = shape({
186
220
  speakers: optional(
187
221
  shape({ admins: optional(tierMembers), members: optional(tierMembers) }),
188
222
  ),
223
+ access: optional(accessShape),
224
+ background: optional(
225
+ shape({
226
+ perPrincipal: optional(
227
+ shape({
228
+ schedules: optional(integer(1, 10_000)),
229
+ delegations: optional(integer(1, 1_000)),
230
+ }),
231
+ ),
232
+ }),
233
+ ),
189
234
  toolTiers: optional(record(oneOf<Tier>("owner", "admin", "member"))),
190
235
  prompts: optional(shape({ shared: text, guest: optional(text) })),
191
236
  agents: optional(
@@ -199,13 +244,15 @@ const schema = shape({
199
244
  }),
200
245
  ),
201
246
  ),
202
- http: shape({
203
- publicUrl: text,
204
- port: optional(integer(1, 65535)),
205
- hostname: optional(text),
206
- socketPath: optional(text),
207
- socketMode: optional(integer(0, 0o777)),
208
- }),
247
+ http: optional(
248
+ shape({
249
+ publicUrl: optional(text),
250
+ port: optional(integer(1, 65535)),
251
+ hostname: optional(text),
252
+ socketPath: optional(text),
253
+ socketMode: optional(integer(0, 0o777)),
254
+ }),
255
+ ),
209
256
  avatar: optional(text),
210
257
  workDir: optional(text),
211
258
  scratchDir: optional(text),
@@ -213,7 +260,7 @@ const schema = shape({
213
260
  orOff(shape({ builtinDir: optional(text), reposDir: optional(text) })),
214
261
  ),
215
262
  memory: optional(bool),
216
- ops: optional(shape({ agent: text })),
263
+ ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
217
264
  interimText: optional(oneOf<InterimTextMode>("on", "off")),
218
265
  interimPrimaryChars: optional(integer(1, 100_000)),
219
266
  plugins: optional(
@@ -223,23 +270,37 @@ const schema = shape({
223
270
  ),
224
271
  });
225
272
 
226
- const PRONOUNS: Record<Pronouns, OwnerIdentity["pronouns"]> = {
227
- he: { subject: "he", object: "him", possessive: "his" },
228
- she: { subject: "she", object: "her", possessive: "her" },
229
- they: { subject: "they", object: "them", possessive: "their" },
230
- };
231
-
232
273
  /** The configuration with every default filled in. */
233
274
  export interface ResolvedConfig {
234
275
  name: string;
276
+ /**
277
+ * The process's short name: the logger's and the scratch dir's, and the root slash command's
278
+ * with Discord. `discord.rootCommand` when Discord is on, else the lowercase assistant name.
279
+ */
280
+ slug: string;
281
+ /**
282
+ * The primary owner, `access.owners[0]` (or the 0.8 `owner`): their principal id, name, and
283
+ * pronouns, and the Discord user id the Discord parts act for until 0.10.
284
+ */
285
+ primaryOwner: OwnerIdentity & { id: string; discordId?: string };
286
+ /** The primary owner as 0.8 knew them, `id` their principal id. Deprecated: read `primaryOwner`. */
235
287
  owner: OwnerIdentity & { id: string };
236
- discord: {
288
+ /** Who the host serves, the 0.8 `owner` and `speakers` converted when they are what the configuration gives. */
289
+ access: AccessRules;
290
+ /** What the configuration wrote in a deprecated form, which the host logs once. */
291
+ deprecations: string[];
292
+ /** Undefined on a host without Discord. */
293
+ discord?: {
237
294
  token: string;
238
295
  guild: string;
239
296
  entryChannel: string;
240
297
  rootCommand: string;
241
298
  admin: boolean;
242
299
  refusalHint?: string;
300
+ agentMemory?: "everyone" | "owners";
301
+ /** As configured: `{}` when left out, so the defaults apply, or `false`. */
302
+ channelContext: NonNullable<DiscordConfig["channelContext"]>;
303
+ freshMarker?: string | false;
243
304
  };
244
305
  databaseUrl: string;
245
306
  dataDir: string;
@@ -250,12 +311,17 @@ export interface ResolvedConfig {
250
311
  delegation: { model: ModelRef; thinking: ThinkingLevel };
251
312
  locale: Locale;
252
313
  timeZone: string;
314
+ /** The 0.8 `speakers`; empty when the configuration writes `access`. Deprecated: read `access`. */
253
315
  speakers: { admins?: TierMembers; members?: TierMembers };
316
+ /** Each person's limits across their conversations; a limit left out is unset. */
317
+ background: { perPrincipal: PerPrincipalLimits };
254
318
  toolTiers: Record<string, Tier>;
255
319
  prompts: { shared?: string; guest?: string };
256
320
  agents: AgentSeed[];
257
- http: {
258
- publicUrl: string;
321
+ /** Undefined when no `http` is configured, and the host opens no `public` listener. */
322
+ http?: {
323
+ /** Always set with Discord. */
324
+ publicUrl?: string;
259
325
  port: number;
260
326
  hostname?: string;
261
327
  socketPath?: string;
@@ -267,7 +333,7 @@ export interface ResolvedConfig {
267
333
  /** `false` when the skills addon is off. */
268
334
  skills: false | { builtinDir?: string; reposDir?: string };
269
335
  memory: boolean;
270
- ops?: { agent: string };
336
+ ops?: { agent: string } | { conversation: ChannelKey };
271
337
  interimText: InterimTextMode;
272
338
  interimPrimaryChars: number;
273
339
  plugins: RoundtablePlugin[];
@@ -282,32 +348,94 @@ function modelOf(value: string, path: string): ModelRef {
282
348
  return parsed;
283
349
  }
284
350
 
351
+ /** Where the errors go: one of an agent or a conversation; an agent only where the agents live. */
352
+ function opsOf(
353
+ ops: { agent?: string; conversation?: string } | undefined,
354
+ withDiscord: boolean,
355
+ ): ResolvedConfig["ops"] {
356
+ if (!ops) return undefined;
357
+ const { agent, conversation } = ops;
358
+ if (agent !== undefined && conversation !== undefined)
359
+ throw new ConfigError(
360
+ "config ops: name an agent or a conversation, not both. Keep the one the reports go to.",
361
+ );
362
+ if (agent !== undefined) {
363
+ if (!withDiscord)
364
+ throw new ConfigError(
365
+ 'config ops.agent: the agents live in Discord, which is not configured. Report to a conversation a plugin\'s chat surface serves instead, with ops: { conversation: "<surface>:<id>" }; the web chat takes no error reports.',
366
+ );
367
+ return { agent };
368
+ }
369
+ if (conversation === undefined)
370
+ throw new ConfigError(
371
+ 'config ops: name an agent or a conversation, such as ops: { agent: "infra" } or ops: { conversation: "<surface>:<id>" }.',
372
+ );
373
+ if (!/^[^:]+:.+$/.test(conversation))
374
+ throw new ConfigError(
375
+ `config ops.conversation: expected a conversation key <surface>:<id>, got ${JSON.stringify(conversation)}. Write the surface's prefix, a colon, and the conversation's id.`,
376
+ );
377
+ return { conversation: conversation as ChannelKey };
378
+ }
379
+
380
+ /** Refuses what only the agent server serves on a host without Discord, naming the key. */
381
+ function refuseAgentServerKeys(config: RoundtableConfig): void {
382
+ if (config.agents && config.agents.length > 0)
383
+ throw new ConfigError(
384
+ "config agents: the agents live in Discord, which is not configured. Configure discord, or remove agents.",
385
+ );
386
+ if (config.skills)
387
+ throw new ConfigError(
388
+ "config skills: the skills are the agents', and the agents live in Discord, which is not configured. Configure discord, or remove skills.",
389
+ );
390
+ }
391
+
285
392
  /** The configuration checked against its schema, defaults filled; throws a ConfigError naming the key and the fix. */
286
393
  export function resolveConfig(input: unknown): ResolvedConfig {
287
- const config = schema.check(input, "") as RoundtableConfig;
394
+ const config = schema.check(liftAdapters(input), "") as RoundtableConfig;
288
395
  const name = config.name ?? "Roundtable";
289
396
  const model = modelOf(config.model, "model");
290
397
  const thinking = config.thinking ?? "medium";
291
- const rootCommand =
292
- config.discord.rootCommand ??
293
- name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
398
+ const { discord, http } = config;
399
+ if (discord && !http?.publicUrl)
400
+ throw new ConfigError(
401
+ "config http.publicUrl: required with discord, expected the public address that reaches the bot; Discord fetches the agents' avatars from it. Add it to roundtable.config.ts.",
402
+ );
403
+ if (!discord) refuseAgentServerKeys(config);
404
+ const slug =
405
+ discord?.rootCommand ?? name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
406
+ const ops = opsOf(config.ops, discord !== undefined);
407
+ const access = accessOf(config, discord !== undefined);
408
+ const { primaryOwner } = access;
294
409
  return {
295
410
  name,
411
+ slug,
412
+ ...access,
296
413
  owner: {
297
- id: config.owner.id,
298
- name: config.owner.name,
299
- pronouns: PRONOUNS[config.owner.pronouns ?? "they"],
300
- },
301
- discord: {
302
- token: config.discord.token,
303
- guild: config.discord.guild,
304
- entryChannel: config.discord.entryChannel,
305
- rootCommand,
306
- admin: config.discord.admin ?? true,
307
- ...(config.discord.refusalHint === undefined
308
- ? {}
309
- : { refusalHint: config.discord.refusalHint }),
414
+ id: primaryOwner.id,
415
+ name: primaryOwner.name,
416
+ pronouns: primaryOwner.pronouns,
310
417
  },
418
+ ...(discord
419
+ ? {
420
+ discord: {
421
+ token: discord.token,
422
+ guild: discord.guild,
423
+ entryChannel: discord.entryChannel,
424
+ rootCommand: slug,
425
+ admin: discord.admin ?? true,
426
+ ...(discord.refusalHint === undefined
427
+ ? {}
428
+ : { refusalHint: discord.refusalHint }),
429
+ ...(discord.agentMemory === undefined
430
+ ? {}
431
+ : { agentMemory: discord.agentMemory }),
432
+ channelContext: discord.channelContext ?? {},
433
+ ...(discord.freshMarker === undefined
434
+ ? {}
435
+ : { freshMarker: discord.freshMarker }),
436
+ },
437
+ }
438
+ : {}),
311
439
  databaseUrl: config.database.url,
312
440
  dataDir: config.dataDir,
313
441
  agentDir: config.agentDir ?? `${config.dataDir}/pi`,
@@ -331,24 +459,30 @@ export function resolveConfig(input: unknown): ResolvedConfig {
331
459
  ...(config.speakers?.admins ? { admins: config.speakers.admins } : {}),
332
460
  ...(config.speakers?.members ? { members: config.speakers.members } : {}),
333
461
  },
462
+ background: { perPrincipal: { ...config.background?.perPrincipal } },
334
463
  toolTiers: config.toolTiers ?? {},
335
464
  prompts: config.prompts ?? {},
336
465
  agents: config.agents ?? [],
337
- http: {
338
- publicUrl: config.http.publicUrl,
339
- port: config.http.port ?? 3000,
340
- ...(config.http.hostname ? { hostname: config.http.hostname } : {}),
341
- ...(config.http.socketPath ? { socketPath: config.http.socketPath } : {}),
342
- ...(config.http.socketMode === undefined
343
- ? {}
344
- : { socketMode: config.http.socketMode }),
345
- },
466
+ ...(http
467
+ ? {
468
+ http: {
469
+ ...(http.publicUrl ? { publicUrl: http.publicUrl } : {}),
470
+ port: http.port ?? 3000,
471
+ ...(http.hostname ? { hostname: http.hostname } : {}),
472
+ ...(http.socketPath ? { socketPath: http.socketPath } : {}),
473
+ ...(http.socketMode === undefined
474
+ ? {}
475
+ : { socketMode: http.socketMode }),
476
+ },
477
+ }
478
+ : {}),
346
479
  ...(config.avatar ? { avatar: config.avatar } : {}),
347
480
  workDir: config.workDir ?? `${config.dataDir}/work`,
348
- scratchDir: config.scratchDir ?? join(tmpdir(), `${rootCommand}-scratch`),
349
- skills: config.skills ?? {},
481
+ scratchDir: config.scratchDir ?? join(tmpdir(), `${slug}-scratch`),
482
+ // Without Discord there are no agents to carry skills, so the addon is off.
483
+ skills: config.skills ?? (discord ? {} : false),
350
484
  memory: config.memory ?? true,
351
- ...(config.ops ? { ops: config.ops } : {}),
485
+ ...(ops ? { ops } : {}),
352
486
  interimText: config.interimText ?? "on",
353
487
  interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
354
488
  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
+ }