pi-roundtable 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,7 +1,8 @@
1
1
  import { join } from "node:path";
2
- import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
- import { OWNER_TARGET } from "../agents/agent-claim.ts";
4
- import type { SurfacePort } from "../contract/surface.ts";
2
+ import type {
3
+ ExtensionFactory,
4
+ ModelRuntime,
5
+ } from "@earendil-works/pi-coding-agent";
5
6
  import { scheduleCommands } from "../discord/schedule-commands.ts";
6
7
  import type { ChannelKey } from "../domain/conversation.ts";
7
8
  import {
@@ -10,16 +11,21 @@ import {
10
11
  ScheduleError,
11
12
  } from "../domain/errors.ts";
12
13
  import { messages } from "../i18n/index.ts";
14
+ import { legacyPrincipalsOf } from "../identity/identity-view.ts";
13
15
  import type { OwnerIdentity } from "../identity.ts";
14
16
  import type { ModelRef, ThinkingLevel } from "../models.ts";
15
17
  import { ConversationBackgroundTurns } from "../modules/background/background-turns.ts";
18
+ import {
19
+ type PerPrincipalLimits,
20
+ personalTarget,
21
+ } from "../modules/background/personal-target.ts";
16
22
  import { delegateExtension } from "../modules/delegation/delegate.ts";
17
23
  import {
18
24
  DefaultDelegator,
19
25
  type DelegationWorker,
20
26
  } from "../modules/delegation/delegator.ts";
21
27
  import { WebResearchWorker } from "../modules/delegation/web-research-worker.ts";
22
- import { notifyExtension } from "../modules/notify/notify.ts";
28
+ import { sessionNotify } from "../modules/notify/session-notify.ts";
23
29
  import { precheckScriptHoldRule } from "../modules/schedules/precheck-tools.ts";
24
30
  import { Scheduler } from "../modules/schedules/scheduler.ts";
25
31
  import { schedulesExtension } from "../modules/schedules/schedules.ts";
@@ -30,13 +36,14 @@ import {
30
36
  AGENTS,
31
37
  BACKGROUND_TURNS,
32
38
  DELEGATION,
39
+ IDENTITY,
33
40
  PRECHECKS,
34
41
  SCHEDULES,
35
42
  } from "../services.ts";
36
43
  import type { SessionContext } from "../sessions.ts";
37
44
  import { DELEGATE_TOOL } from "../shared/delegate-tool.ts";
38
45
  import { SCHEDULE_TOOLS } from "../shared/schedule-tools.ts";
39
- import { THE_SPEAKER, type Tier } from "../speakers.ts";
46
+ import type { Tier } from "../speakers.ts";
40
47
  import { DISCORD } from "./discord.ts";
41
48
  import { fixed } from "./session-tool.ts";
42
49
 
@@ -54,6 +61,11 @@ export interface ModulesOptions {
54
61
  modelRuntime: ModelRuntime;
55
62
  agentDir: string;
56
63
  dataDir: string;
64
+ /**
65
+ * Each person's limits across their conversations, which the personal target holds them to;
66
+ * unset, only each conversation's, as in 0.8.
67
+ */
68
+ perPrincipal?: PerPrincipalLimits;
57
69
  /** The model that runs delegated tasks, or a worker of your own that replaces it. */
58
70
  delegation: {
59
71
  model: ModelRef;
@@ -77,27 +89,18 @@ const MODULE_TIERS: Readonly<Record<string, Tier>> = {
77
89
  };
78
90
 
79
91
  /**
80
- * The owner's modules: notifications, schedules, and delegated tasks, each a session tool, and
81
- * the turns nobody wrote. The delegator's running jobs join the shutdown drain. Without Discord
82
- * there are no owner's messages: no `notify_owner`, and a conversation no chat surface carries
83
- * can neither schedule nor delegate. Without the owner's background target no session gets the
84
- * schedule or delegation tools.
92
+ * The modules: notifications, schedules, and delegated tasks, each a session tool, and the turns
93
+ * nobody wrote. The delegator's running jobs join the shutdown drain. `notify` sends through the
94
+ * plugins' direct channels, so with none there is no notify. A conversation no chat surface carries
95
+ * schedules and delegates only when recorded private, into its creator's reachable,
96
+ * background-capable direct channel. It
97
+ * contributes `PERSONAL_TARGET`, with the per-person limits given, whose turns the schedules and
98
+ * delegated reports are, and a session whose conversation's claim takes no background turns gets
99
+ * neither tool.
85
100
  */
86
101
  export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
87
102
  const { owner } = options;
88
- // A conversation no chat surface carries posts its runs in the owner's messages, when there are any.
89
- const channelFor = async (
90
- channel: ChannelKey,
91
- surfaces: SurfacePort,
92
- ownerChannel: (() => Promise<ChannelKey>) | undefined,
93
- refused: (message: string) => Error,
94
- ) => {
95
- if (surfaces.of(channel)) return channel;
96
- if (ownerChannel) return ownerChannel();
97
- throw refused(
98
- "this conversation has no chat surface to post a run in, and the host has no owner's messages to post it in instead",
99
- );
100
- };
103
+ const personal = personalTarget(options.perPrincipal);
101
104
  // Set up once the modules set up; the preflight reads it after every plugin linked.
102
105
  let reportsReach: (() => void) | undefined;
103
106
  return {
@@ -111,36 +114,44 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
111
114
  surfaces,
112
115
  sessions,
113
116
  toolTiers,
117
+ directChannels,
114
118
  }) => {
115
119
  const schedules = services.get(SCHEDULES);
116
120
  // Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
117
121
  const prechecks = services.find(PRECHECKS);
118
- // Absent on a host without Discord: then there are no owner's messages and no threads.
122
+ // Absent on a host without Discord: then there are no threads.
119
123
  const discord = services.find(DISCORD);
120
- const connection = discord?.connection;
121
- const ownerChannel = connection
122
- ? () => connection.ownerChannel()
123
- : undefined;
124
124
  // Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
125
125
  const agentChannelOf = (agent: string) =>
126
126
  services.get(AGENTS).team.channelOf(agent);
127
- const scheduleChannelFor = (channel: ChannelKey) =>
128
- channelFor(
129
- channel,
130
- surfaces,
131
- ownerChannel,
132
- (message) => new ScheduleError(message),
133
- );
134
- const delegateChannelFor = (channel: ChannelKey) =>
135
- channelFor(
136
- channel,
137
- surfaces,
138
- ownerChannel,
139
- (message) => new DelegationError(message),
140
- );
127
+ // A conversation no chat surface carries keeps its runs in the creator's direct messages, where
128
+ // a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
129
+ const channelFor = async (
130
+ channel: ChannelKey,
131
+ principalId: string,
132
+ refused: (message: string) => Error,
133
+ ) => {
134
+ if (surfaces.of(channel)) return channel;
135
+ const reached = await directChannels.reach(principalId);
136
+ if (!reached)
137
+ throw refused(
138
+ "this conversation has no chat surface to post a run in, and you have no direct channel on this host to post it in instead",
139
+ );
140
+ if (!conversations.takesBackground(reached.channel))
141
+ throw refused(
142
+ `this conversation has no chat surface to post a run in, and nothing here takes background turns in your ${reached.provider.name} direct messages, where it would go instead`,
143
+ );
144
+ return reached.channel;
145
+ };
146
+ const scheduleChannelFor = (channel: ChannelKey, principalId: string) =>
147
+ channelFor(channel, principalId, (m) => new ScheduleError(m));
148
+ const delegateChannelFor = (channel: ChannelKey, principalId: string) =>
149
+ channelFor(channel, principalId, (m) => new DelegationError(m));
150
+ const identity = services.find(IDENTITY);
141
151
  const background = new ConversationBackgroundTurns({
142
152
  conversations,
143
153
  system: { id: "assistant", name: options.assistant },
154
+ ...(identity ? { principalOf: legacyPrincipalsOf(identity) } : {}),
144
155
  logger,
145
156
  });
146
157
  const delegator = new DefaultDelegator({
@@ -160,14 +171,51 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
160
171
  });
161
172
  services.provide(BACKGROUND_TURNS, background);
162
173
  services.provide(DELEGATION, delegator);
163
- // An agent session serves every speaker, so its tools name none.
164
- const served = (session: SessionContext) =>
165
- session.agent ? THE_SPEAKER : owner;
166
- // Schedules and delegated reports run as the owner's background turns; without a plugin
167
- // contributing that target, as on a host without the agent server, none could ever start,
168
- // so no session gets the tools. Read when a session is made, after every plugin set up.
169
- const ownerTurns = () =>
170
- conversations.target(OWNER_TARGET.name) !== undefined;
174
+ // Schedules and delegated reports run as background turns in the conversation, or, for one
175
+ // no chat surface carries, in the creator's direct messages; where no claim would take them,
176
+ // none could ever start, so the session gets no tools. Read when a session is made, after
177
+ // every plugin set up: a conversation without a chat surface has them only when it is
178
+ // recorded private to someone a direct channel knows. Creation checks the speaker's own
179
+ // destination again.
180
+ const offered = (
181
+ session: SessionContext,
182
+ extension: () => ExtensionFactory,
183
+ ): ExtensionFactory | null => {
184
+ const home = session.homeChannel;
185
+ if (surfaces.of(home))
186
+ return conversations.takesBackground(home) ? extension() : null;
187
+ if (directChannels.providers().length === 0) return null;
188
+ // Only a conversation recorded private has a person to run its work for: a session
189
+ // outlives the turn it is built in, so whoever speaks then decides nothing.
190
+ const { conversation } = session;
191
+ if (session.agent || conversation.visibility !== "private") return null;
192
+ const own = conversation.principalId;
193
+ return async (pi) => {
194
+ // Only whether a direct channel knows them, without the network; the conversation there,
195
+ // and whether a claim takes its turns, are checked when a tool runs.
196
+ try {
197
+ if (!(await directChannels.known(own))) return;
198
+ } catch (error) {
199
+ logger.warn(
200
+ { channel: home, err: error },
201
+ "could not tell whether the conversation's person has a direct channel",
202
+ );
203
+ return;
204
+ }
205
+ await extension()(pi);
206
+ };
207
+ };
208
+ // Whose a conversation is, as its session was made: an agent's serves everyone; another's
209
+ // schedules are its person's in a private one, and the speaker's own in one no chat surface
210
+ // carries, where its schedules are kept in a conversation it is not.
211
+ const visibility =
212
+ (session: SessionContext) =>
213
+ async (): Promise<"private" | "shared"> => {
214
+ if (session.agent) return "shared";
215
+ if (session.conversation.visibility === "private") return "private";
216
+ return surfaces.of(session.homeChannel) ? "shared" : "private";
217
+ };
218
+ const principalOf = identity ? legacyPrincipalsOf(identity) : undefined;
171
219
  // A reporter for an ops agent is the agent server's to connect.
172
220
  const reporter =
173
221
  options.errorReporter &&
@@ -187,14 +235,15 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
187
235
  throw new ConfigError(
188
236
  `config ops.conversation: no plugin's conversations own ${JSON.stringify(channel)}, so nothing answers its error reports. Name a conversation a plugin's claim owns, or leave ops out.`,
189
237
  );
190
- if (!ownerTurns())
191
- throw new ConfigError(
192
- `config ops.conversation: no plugin contributes the "${OWNER_TARGET.name}" background target, so the report turns of ${JSON.stringify(channel)} could never run. Configure discord, whose agent server contributes it, add a plugin that contributes it, or leave ops out.`,
193
- );
194
238
  if (!conversations.takesBackground(channel))
195
239
  throw new ConfigError(
196
240
  `config ops.conversation: the claim that owns ${JSON.stringify(channel)} takes no background turns, so nothing answers its error reports. Name a conversation whose claim takes background turns, or leave ops out.`,
197
241
  );
242
+ // A report turn is the system principal's, which a conversation private to its person refuses.
243
+ if (!conversations.takesSystemReports(channel))
244
+ throw new ConfigError(
245
+ `config ops.conversation: the claim that owns ${JSON.stringify(channel)} keeps its conversations private to their people, so it takes no system error reports and each would only be logged. Name a conversation of a surface whose conversations are shared, such as a Discord channel, or report to an agent with ops.agent.`,
246
+ );
198
247
  };
199
248
  }
200
249
  return {
@@ -227,43 +276,49 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
227
276
  }),
228
277
  ],
229
278
  sessionTools: [
230
- ...(connection
231
- ? [fixed("notify", () => notifyExtension(connection, owner))]
232
- : []),
279
+ fixed(
280
+ "notify",
281
+ sessionNotify({
282
+ directChannels,
283
+ owner,
284
+ logger,
285
+ onlyOwnerNotified: async () =>
286
+ toolTiers.minTier("notify") === "owner" &&
287
+ (identity ? (await identity.owners()).length <= 1 : true),
288
+ }),
289
+ ),
233
290
  fixed("schedules", (session) =>
234
- ownerTurns()
235
- ? schedulesExtension(
236
- {
237
- store: schedules,
238
- owner: { id: owner.id, name: owner.name },
239
- channelFor: scheduleChannelFor,
240
- ...(prechecks ? { prechecks } : {}),
241
- holds: () => sessions().holds,
242
- },
243
- session.homeChannel,
244
- served(session),
245
- session.agent ? agentChannelOf : undefined,
246
- session.speaker,
247
- )
248
- : null,
291
+ offered(session, () =>
292
+ schedulesExtension(
293
+ {
294
+ store: schedules,
295
+ channelFor: scheduleChannelFor,
296
+ ...(prechecks ? { prechecks } : {}),
297
+ holds: () => sessions().holds,
298
+ visibility: visibility(session),
299
+ ...(principalOf ? { principalOf } : {}),
300
+ target: personal,
301
+ },
302
+ session.homeChannel,
303
+ session.agent ? agentChannelOf : undefined,
304
+ session.speaker,
305
+ ),
306
+ ),
249
307
  ),
250
308
  fixed("delegate", (session) =>
251
- ownerTurns()
252
- ? delegateExtension(
253
- {
254
- delegator,
255
- owner: { id: owner.id, name: owner.name },
256
- channelFor: delegateChannelFor,
257
- },
258
- session.homeChannel,
259
- served(session),
260
- session.turnChannel,
261
- session.speaker,
262
- )
263
- : null,
309
+ offered(session, () =>
310
+ delegateExtension(
311
+ { delegator, channelFor: delegateChannelFor },
312
+ session.homeChannel,
313
+ session.turnChannel,
314
+ session.speaker,
315
+ ),
316
+ ),
264
317
  ),
265
318
  ],
266
319
  toolTiers: MODULE_TIERS,
320
+ // Every person's conversations where a claim takes background turns are its own.
321
+ backgroundTargets: [personal],
267
322
  };
268
323
  },
269
324
  };
@@ -4,9 +4,10 @@ import { PgMemoryStore } from "../modules/memory/owner-memory-store.ts";
4
4
  import { memoryPrecheckRegistry } from "../modules/schedules/prechecks.ts";
5
5
  import { PgScheduleStore } from "../modules/schedules/schedule-store.ts";
6
6
  import type { RoundtablePlugin } from "../plugin.ts";
7
+ import { memoryReader } from "../runtime/session-conversation.ts";
7
8
  import { MEMORY, type MemoryStore, PRECHECKS, SCHEDULES } from "../services.ts";
8
- import type { SessionTool } from "../sessions.ts";
9
- import { THE_SPEAKER, type Tier } from "../speakers.ts";
9
+ import type { SessionContext, SessionTool } from "../sessions.ts";
10
+ import type { Tier } from "../speakers.ts";
10
11
  import { fixed } from "./session-tool.ts";
11
12
 
12
13
  export interface MemoryOptions {
@@ -21,29 +22,47 @@ export const MEMORY_TIERS: Readonly<Record<string, Tier>> = {
21
22
  memory_remove: "member",
22
23
  };
23
24
 
24
- /** The memory tools and prompt block of every conversation, over the given store. */
25
+ /**
26
+ * Whose memory a session's running turn reads: a private conversation's person's, whoever speaks
27
+ * in it, and in a shared one each turn's speaker's; never by tier, so a second owner reads their
28
+ * own. The host's own turns read no one's in a shared conversation.
29
+ */
30
+ function memoryOwner(
31
+ session: SessionContext,
32
+ ): { principalId: string; name: string } | undefined {
33
+ if (session.memory === "none") return undefined;
34
+ const speaker = session.speaker();
35
+ const { conversation } = session;
36
+ const principalId = memoryReader(conversation, speaker);
37
+ if (principalId === undefined) return undefined;
38
+ // A principal 0.8 left is named by their id until someone names them; their speaker has a name.
39
+ const principal =
40
+ conversation.visibility === "private" ? conversation.principal : undefined;
41
+ const named =
42
+ principal && principal.displayName !== principal.id
43
+ ? principal.displayName
44
+ : undefined;
45
+ const name =
46
+ named ??
47
+ (speaker?.principalId === principalId ? speaker.name : principalId);
48
+ return { principalId, name };
49
+ }
50
+
51
+ /** The memory tools and prompt block of every conversation, over the given store; none for a persona without memory. */
25
52
  export function memorySessionTool(
26
53
  memory: MemoryStore,
27
54
  owner: MemoryOptions["owner"],
28
55
  ): SessionTool {
29
- return fixed("owner-memory", (session) => {
30
- // An agent session serves every speaker, so its tools name none.
31
- if (session.agent)
32
- return ownerMemoryExtension(
33
- memory,
34
- owner.id,
35
- THE_SPEAKER,
36
- session.speaker,
37
- );
38
- // Any other conversation may admit a member or an admin, who reads and changes their own
39
- // memory; an owner-tier speaker, such as remote MCP, speaks as the owner.
40
- const speaker = () => {
41
- const current = session.speaker();
42
- return current?.tier === "owner" ? undefined : current;
43
- };
44
- // Its tools keep the owner's words, so the owner's own chats read as before.
45
- return ownerMemoryExtension(memory, owner.id, owner, speaker, false);
46
- });
56
+ return fixed("owner-memory", (session) =>
57
+ session.memory === "none"
58
+ ? null
59
+ : ownerMemoryExtension(memory, {
60
+ ownerId: owner.id,
61
+ addressee: session.addressee,
62
+ describesSpeakers: session.conversation.visibility === "shared",
63
+ whose: () => memoryOwner(session),
64
+ }),
65
+ );
47
66
  }
48
67
 
49
68
  /**
@@ -0,0 +1,229 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import {
3
+ type AccessRules,
4
+ type AccessTier,
5
+ checkAccessRules,
6
+ rulesOfSpeakerMap,
7
+ } from "../identity/access-policy.ts";
8
+ import { parseIdentity } from "../identity/actor-facts.ts";
9
+ import { PRONOUNS } from "../identity.ts";
10
+ import type { Logger } from "../log.ts";
11
+ import type { TierMembers } from "../speakers.ts";
12
+ import type { Pronouns, ResolvedConfig, RoundtableConfig } from "./config.ts";
13
+ import {
14
+ type Field,
15
+ guarded,
16
+ integer,
17
+ list,
18
+ oneOf,
19
+ optional,
20
+ shape,
21
+ text,
22
+ } from "./schema.ts";
23
+
24
+ /** Who holds a tier besides the owners: identities, roles written `<surface>:role:<name>`, or everyone, on every surface or on those listed. */
25
+ export interface AccessTierConfig {
26
+ identities?: readonly string[];
27
+ roles?: readonly string[];
28
+ everyone?: boolean | readonly string[];
29
+ }
30
+
31
+ /** One owner: their name and pronouns, their principal, and the identities that are theirs. */
32
+ export interface AccessOwnerConfig {
33
+ name: string;
34
+ pronouns?: Pronouns;
35
+ /**
36
+ * Their principal's id. Required for the first owner, the primary one: give the old
37
+ * `owner.id`, which `roundtable upgrade` writes, or an id of your choosing, such as their
38
+ * Discord user id. Another owner without one gets a new `p_` principal at the first boot.
39
+ */
40
+ principal?: string;
41
+ /** `<provider>:<subject>` strings, such as `discord:<user id>` or `token:remote-mcp`; `roundtable principal list` prints them. */
42
+ identities?: readonly string[];
43
+ }
44
+
45
+ /** Who the host serves, and at which tier: the owners, then admins and members by identity, role, or everyone. */
46
+ export interface AccessConfig {
47
+ /** The owners, the first of them the primary owner, whose Discord identity the Discord parts act for until 0.10. */
48
+ owners: readonly AccessOwnerConfig[];
49
+ admins?: AccessTierConfig;
50
+ members?: AccessTierConfig;
51
+ /** `admitted` (default): someone the rules admit gets a principal at first contact, and a 0.8 speaker claims theirs; `linked`: only identities linked with the CLI, or the owners' configured ones, are served, and no one claims a 0.8 principal. */
52
+ provisioning?: "admitted" | "linked";
53
+ /** How many days someone whose tier comes only from the rules may go unseen before their background turns are refused; default 30. */
54
+ backgroundStaleDays?: number;
55
+ }
56
+
57
+ const everyone: Field<boolean | string[]> = guarded(
58
+ "true, false, or a list of surfaces",
59
+ (value): value is boolean | string[] =>
60
+ typeof value === "boolean" ||
61
+ (Array.isArray(value) &&
62
+ value.every((item) => typeof item === "string" && item.trim() !== "")),
63
+ );
64
+
65
+ const tierShape = shape({
66
+ identities: optional(list(text)),
67
+ roles: optional(list(text)),
68
+ everyone: optional(everyone),
69
+ });
70
+
71
+ /** The `access` key's shape. */
72
+ export const accessShape = shape({
73
+ owners: list(
74
+ shape({
75
+ name: text,
76
+ pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
77
+ principal: optional(text),
78
+ identities: optional(list(text)),
79
+ }),
80
+ ),
81
+ admins: optional(tierShape),
82
+ members: optional(tierShape),
83
+ provisioning: optional(oneOf<"admitted" | "linked">("admitted", "linked")),
84
+ backgroundStaleDays: optional(integer(1, 36_500)),
85
+ });
86
+
87
+ /** The days an unseen principal's background turns keep their last tier, by default. */
88
+ export const BACKGROUND_STALE_DAYS = 30;
89
+
90
+ /** The deprecation the 0.8 `owner` and `speakers` print, once per host logger. */
91
+ export const LEGACY_ACCESS =
92
+ "config owner and speakers are deprecated and go away in 0.10: write access: { owners, admins, members } instead; roundtable upgrade rewrites them";
93
+
94
+ function tierOf(config: AccessTierConfig | undefined): AccessTier | undefined {
95
+ if (!config) return undefined;
96
+ return {
97
+ ...(config.identities ? { identities: config.identities } : {}),
98
+ ...(config.roles ? { roles: config.roles } : {}),
99
+ ...(config.everyone === undefined ? {} : { everyone: config.everyone }),
100
+ };
101
+ }
102
+
103
+ /** The rules an `access` configuration means, checked. */
104
+ export function rulesOfAccess(config: AccessConfig): AccessRules {
105
+ if (config.owners.length === 0)
106
+ throw new ConfigError(
107
+ "config access.owners: expected at least one owner. Name the person who runs this host.",
108
+ );
109
+ const [primary] = config.owners;
110
+ if (primary?.principal === undefined)
111
+ throw new ConfigError(
112
+ "config access.owners[0].principal: required for the primary owner in 0.9, expected their principal id. Give the old owner.id (roundtable upgrade writes it), or an id of your choosing, such as their Discord user id.",
113
+ );
114
+ const admins = tierOf(config.admins);
115
+ const members = tierOf(config.members);
116
+ return checkAccessRules({
117
+ owners: config.owners.map((owner) => ({
118
+ name: owner.name,
119
+ ...(owner.pronouns ? { pronouns: owner.pronouns } : {}),
120
+ ...(owner.principal === undefined ? {} : { principal: owner.principal }),
121
+ identities: owner.identities ?? [],
122
+ })),
123
+ ...(admins ? { admins } : {}),
124
+ ...(members ? { members } : {}),
125
+ provisioning: config.provisioning ?? "admitted",
126
+ backgroundStaleDays: config.backgroundStaleDays ?? BACKGROUND_STALE_DAYS,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * The rules the 0.8 `owner` and `speakers` mean: the owner as the primary owner, their principal
132
+ * the old `owner.id`, which is also their Discord identity on a host with Discord, and the tiers
133
+ * by Discord user and role ids, with `everyone` meaning everyone on Discord, the one surface it
134
+ * reached in 0.8.
135
+ */
136
+ export function rulesOfLegacy(
137
+ owner: { id: string; name: string; pronouns?: Pronouns },
138
+ speakers: { admins?: TierMembers; members?: TierMembers } = {},
139
+ withDiscord = true,
140
+ ): AccessRules {
141
+ const map = rulesOfSpeakerMap({
142
+ owners: [owner.id],
143
+ ...(speakers.admins ? { admins: speakers.admins } : {}),
144
+ ...(speakers.members ? { members: speakers.members } : {}),
145
+ });
146
+ return checkAccessRules({
147
+ ...map,
148
+ owners: [
149
+ {
150
+ name: owner.name,
151
+ ...(owner.pronouns ? { pronouns: owner.pronouns } : {}),
152
+ principal: owner.id,
153
+ identities: withDiscord ? [`discord:${owner.id}`] : [],
154
+ },
155
+ ],
156
+ provisioning: "admitted",
157
+ backgroundStaleDays: BACKGROUND_STALE_DAYS,
158
+ });
159
+ }
160
+
161
+ /** The Discord user id among an owner's identities, if they list one. */
162
+ export function discordIdOf(identities: readonly string[]): string | undefined {
163
+ for (const identity of identities) {
164
+ const ref = parseIdentity(identity);
165
+ if (ref?.provider === "discord") return ref.subject;
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ const warned = new WeakMap<Logger, Set<string>>();
171
+
172
+ /** Logs each deprecation once per logger, so a host warns once however often it is defined. */
173
+ export function warnDeprecations(
174
+ logger: Logger,
175
+ deprecations: readonly string[],
176
+ ): void {
177
+ let seen = warned.get(logger);
178
+ if (!seen) {
179
+ seen = new Set();
180
+ warned.set(logger, seen);
181
+ }
182
+ for (const message of deprecations) {
183
+ if (seen.has(message)) continue;
184
+ seen.add(message);
185
+ logger.warn(`deprecated: ${message}`);
186
+ }
187
+ }
188
+
189
+ /** The access rules and the primary owner, from `access` or the deprecated `owner` and `speakers`. */
190
+ export function accessOf(
191
+ config: Pick<RoundtableConfig, "owner" | "speakers" | "access">,
192
+ withDiscord: boolean,
193
+ ): Pick<ResolvedConfig, "access" | "primaryOwner" | "deprecations"> {
194
+ const { owner, speakers, access } = config;
195
+ if (access && (owner || speakers))
196
+ throw new ConfigError(
197
+ "config access: write the owners in access or in owner and speakers, not both. Remove owner and speakers; roundtable upgrade rewrites them as access.",
198
+ );
199
+ if (!access && !owner)
200
+ throw new ConfigError(
201
+ 'config access: required, expected who runs this host, such as access: { owners: [{ name: "Ada", principal: "<id>", identities: ["discord:<user id>"] }] }. Add it to roundtable.config.ts.',
202
+ );
203
+ const rules = access
204
+ ? rulesOfAccess(access)
205
+ : rulesOfLegacy(
206
+ // The schema checked that one of the two is here.
207
+ owner as NonNullable<typeof owner>,
208
+ speakers,
209
+ withDiscord,
210
+ );
211
+ const [primary] = rules.owners;
212
+ if (primary?.principal === undefined)
213
+ throw new ConfigError("config access.owners[0].principal: required");
214
+ const discordId = discordIdOf(primary.identities);
215
+ if (withDiscord && discordId === undefined)
216
+ throw new ConfigError(
217
+ 'config access.owners[0].identities: the primary owner needs a discord:<user id> identity while Discord is configured; the Discord parts act for them until 0.10. Add it, such as identities: ["discord:<your user id>"].',
218
+ );
219
+ return {
220
+ access: rules,
221
+ primaryOwner: {
222
+ id: primary.principal,
223
+ name: primary.name,
224
+ pronouns: PRONOUNS[primary.pronouns ?? "they"],
225
+ ...(discordId === undefined ? {} : { discordId }),
226
+ },
227
+ deprecations: access ? [] : [LEGACY_ACCESS],
228
+ };
229
+ }