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,412 @@
1
+ import type { SQL } from "bun";
2
+ import { openPool } from "../core/db/migrations.ts";
3
+ import { IdentityError } from "../core/domain/errors.ts";
4
+ import type { AccessOwner } from "../core/identity/access-policy.ts";
5
+ import { identityOf, parseIdentity } from "../core/identity/actor-facts.ts";
6
+ import type { DeclaredIdentity } from "../core/identity/plugin-identities.ts";
7
+ import {
8
+ type IdentityLink,
9
+ PgPrincipalStore,
10
+ type PrincipalRecord,
11
+ type Pronouns,
12
+ type RoleGrant,
13
+ } from "../core/identity/principal-store.ts";
14
+ import type { Tier } from "../core/speakers.ts";
15
+ import { CONFIG_FILE } from "./project.ts";
16
+
17
+ export const PRINCIPAL_USAGE = ` roundtable principal list every principal, with its roles and its identities written as
18
+ access in roundtable.config.ts takes them
19
+ roundtable principal show <principal>
20
+ roundtable principal create --name <name> [--pronouns he|she|they]
21
+ roundtable principal link <principal> <provider>:<subject>
22
+ roundtable principal unlink <provider>:<subject>
23
+ roundtable principal grant <principal> member|admin|owner
24
+ roundtable principal revoke <principal> member|admin|owner
25
+ roundtable principal disable <principal>
26
+ roundtable principal enable <principal>
27
+ <principal> is a principal id, or an identity linked to it`;
28
+
29
+ /** What every change prints: a running host reads its own copy for up to 30 seconds. */
30
+ export const HOST_DELAY =
31
+ "A running host sees this within 30 seconds; a host that starts sees it at once.";
32
+
33
+ const TIERS: readonly Tier[] = ["member", "admin", "owner"];
34
+ const PRONOUNS: readonly Pronouns[] = ["he", "she", "they"];
35
+
36
+ export interface PrincipalIo {
37
+ out(line: string): void;
38
+ err(line: string): void;
39
+ }
40
+
41
+ /** The owners the project's configuration names, or undefined when it does not load. */
42
+ type ConfiguredOwners = readonly AccessOwner[] | undefined;
43
+
44
+ /** The identities the project's plugins declare, or undefined when the configuration does not load. */
45
+ export type ConfiguredIdentities = readonly DeclaredIdentity[] | undefined;
46
+
47
+ /** What the project's configuration says, read by its schema alone. */
48
+ export interface Configured {
49
+ owners: ConfiguredOwners;
50
+ identities: ConfiguredIdentities;
51
+ }
52
+
53
+ /** A refusal: the message is the whole answer, printed as it is. */
54
+ class Refusal extends Error {}
55
+
56
+ const describe = (principal: PrincipalRecord): string =>
57
+ `${principal.displayName}${principal.pronouns ? ` (${principal.pronouns})` : ""}`;
58
+
59
+ const named = (principal: PrincipalRecord): string =>
60
+ `principal ${principal.id} (${principal.displayName})`;
61
+
62
+ const stamp = (date: Date): string => date.toISOString();
63
+
64
+ /**
65
+ * The principal a reference names: its id, or the principal an identity is linked to. A 0.8 id
66
+ * can be both, such as a web user's `oidc:…` id once that identity is linked to someone else;
67
+ * then it is refused rather than guessed.
68
+ */
69
+ async function find(
70
+ store: PgPrincipalStore,
71
+ reference: string | undefined,
72
+ ): Promise<PrincipalRecord> {
73
+ if (reference === undefined) throw new Refusal("name a principal");
74
+ const byId = await store.get(reference);
75
+ const identity = parseIdentity(reference);
76
+ const link =
77
+ identity && (await store.identity(identity.provider, identity.subject));
78
+ const linked = link ? await store.get(link.principalId) : undefined;
79
+ if (byId && linked && byId.id !== linked.id)
80
+ throw new Refusal(
81
+ `${reference} names two principals: ${named(byId)} by its id, and ${named(linked)} by the identity linked to it. Name ${linked.id} by its id; to reach ${byId.id}, unlink ${reference} first, then link it to ${linked.id} again`,
82
+ );
83
+ const found = byId ?? linked;
84
+ if (found) return found;
85
+ throw new Refusal(
86
+ `there is no principal ${reference}, and no identity ${reference} is linked; roundtable principal list shows them`,
87
+ );
88
+ }
89
+
90
+ function identityArgument(text: string | undefined) {
91
+ const identity = text === undefined ? undefined : parseIdentity(text);
92
+ if (!identity)
93
+ throw new Refusal(
94
+ `expected an identity written <provider>:<subject>, such as discord:<user id>, got ${JSON.stringify(text ?? "")}`,
95
+ );
96
+ return identity;
97
+ }
98
+
99
+ function tierArgument(text: string | undefined): Tier {
100
+ if (!TIERS.includes(text as Tier))
101
+ throw new Refusal(
102
+ `expected a role, one of member, admin, or owner, got ${JSON.stringify(text ?? "")}`,
103
+ );
104
+ return text as Tier;
105
+ }
106
+
107
+ /** The plugin of the configuration that declares the identity; undefined when none does, unknown when the configuration does not load. */
108
+ function declarer(
109
+ identities: ConfiguredIdentities,
110
+ link: IdentityLink,
111
+ ): string | undefined | "unknown" {
112
+ if (identities === undefined) return "unknown";
113
+ const text = identityOf(link);
114
+ return identities.find((declared) => declared.identity === text)?.plugin;
115
+ }
116
+
117
+ /** Where a link came from, and for a plugin's, which plugin declares it. */
118
+ function sourceText(link: IdentityLink, configured: Configured): string {
119
+ if (link.source !== "plugin") return link.source;
120
+ const plugin = declarer(configured.identities, link);
121
+ if (plugin === "unknown") return "plugin";
122
+ return plugin === undefined
123
+ ? "plugin, which no plugin declares any more: the next start unlinks it"
124
+ : `plugin ${plugin}`;
125
+ }
126
+
127
+ const linkLine = (link: IdentityLink, configured: Configured): string =>
128
+ ` ${identityOf(link)} (${sourceText(link, configured)})`;
129
+
130
+ const rolesText = (roles: readonly RoleGrant[]): string =>
131
+ roles.length === 0
132
+ ? "none"
133
+ : roles.map((grant) => `${grant.role} (${grant.source})`).join(", ");
134
+
135
+ /** Whether the person of its 0.8 id still links their own identity at first contact, as the identity service claims it. */
136
+ const claimable = (
137
+ principal: PrincipalRecord,
138
+ roles: readonly RoleGrant[],
139
+ ): boolean =>
140
+ principal.claimable && !roles.some((grant) => grant.role === "owner");
141
+
142
+ async function list(
143
+ store: PgPrincipalStore,
144
+ io: PrincipalIo,
145
+ configured: Configured,
146
+ ): Promise<void> {
147
+ const principals = await store.list();
148
+ for (const principal of principals) {
149
+ const roles = await store.rolesOf(principal.id);
150
+ io.out(
151
+ `${principal.id} ${describe(principal)}${principal.disabled ? " disabled" : ""} roles: ${rolesText(roles)}`,
152
+ );
153
+ const links = await store.identitiesOf(principal.id);
154
+ for (const link of links) io.out(linkLine(link, configured));
155
+ if (links.length === 0)
156
+ io.out(
157
+ claimable(principal, roles)
158
+ ? ` no identity linked yet; carried over from 0.8, so ${principal.id} links its own at its first contact`
159
+ : ` no identity linked yet; link one with roundtable principal link ${principal.id} <provider>:<subject>`,
160
+ );
161
+ }
162
+ io.out(
163
+ `${principals.length} ${principals.length === 1 ? "principal" : "principals"}. Each identity is written <provider>:<subject>, as access in roundtable.config.ts and roundtable principal link take it.`,
164
+ );
165
+ }
166
+
167
+ async function show(
168
+ store: PgPrincipalStore,
169
+ principal: PrincipalRecord,
170
+ io: PrincipalIo,
171
+ configured: Configured,
172
+ ): Promise<void> {
173
+ io.out(`principal ${principal.id}`);
174
+ io.out(` name: ${describe(principal)}`);
175
+ io.out(` status: ${principal.disabled ? "disabled" : "enabled"}`);
176
+ io.out(` created: ${stamp(principal.createdAt)}`);
177
+ io.out(
178
+ ` last seen: ${principal.lastSeenAt ? `${stamp(principal.lastSeenAt)} as ${principal.lastTier ?? "unknown"}` : "never"}`,
179
+ );
180
+ io.out(` roles: ${rolesText(await store.rolesOf(principal.id))}`);
181
+ const links = await store.identitiesOf(principal.id);
182
+ io.out(` identities: ${links.length === 0 ? "none" : ""}`.trimEnd());
183
+ for (const link of links)
184
+ io.out(
185
+ ` ${identityOf(link)} (${sourceText(link, configured)}, linked ${stamp(link.linkedAt)})`,
186
+ );
187
+ }
188
+
189
+ /** The value after `flag`, and the arguments without both. */
190
+ function option(
191
+ args: readonly string[],
192
+ flag: string,
193
+ ): { value: string | undefined; rest: string[] } {
194
+ const at = args.indexOf(flag);
195
+ if (at === -1) return { value: undefined, rest: [...args] };
196
+ return { value: args[at + 1], rest: args.toSpliced(at, 2) };
197
+ }
198
+
199
+ /** Each subcommand and how many arguments it takes. */
200
+ const ARITY: Record<string, number> = {
201
+ list: 0,
202
+ show: 1,
203
+ link: 2,
204
+ unlink: 1,
205
+ grant: 2,
206
+ revoke: 2,
207
+ disable: 1,
208
+ enable: 1,
209
+ };
210
+
211
+ /**
212
+ * Where the configuration lists an identity it linked: under the owner of the principal it is
213
+ * linked to (or an owner found by their identities), under another owner, nowhere any more, or
214
+ * unknown when the configuration does not load.
215
+ */
216
+ function listedUnder(
217
+ owners: ConfiguredOwners,
218
+ link: IdentityLink,
219
+ ): "this principal" | "another owner" | "nowhere" | "unknown" {
220
+ if (owners === undefined) return "unknown";
221
+ const text = identityOf(link);
222
+ const holders = owners.filter((owner) =>
223
+ owner.identities.some((written) => {
224
+ const ref = parseIdentity(written);
225
+ return ref !== undefined && identityOf(ref) === text;
226
+ }),
227
+ );
228
+ if (holders.length === 0) return "nowhere";
229
+ return holders.some(
230
+ (owner) =>
231
+ owner.principal === undefined || owner.principal === link.principalId,
232
+ )
233
+ ? "this principal"
234
+ : "another owner";
235
+ }
236
+
237
+ async function run(
238
+ store: PgPrincipalStore,
239
+ args: readonly string[],
240
+ io: PrincipalIo,
241
+ configured: Configured,
242
+ ): Promise<void> {
243
+ const { owners } = configured;
244
+ const [command, ...rest] = args;
245
+ if (command === "create") {
246
+ const name = option(rest, "--name");
247
+ const pronouns = option(name.rest, "--pronouns");
248
+ if (!name.value?.trim() || name.value.startsWith("--"))
249
+ throw new Refusal(
250
+ "create takes --name <name>, the name to address them by",
251
+ );
252
+ if (pronouns.rest.length > 0)
253
+ throw new Refusal(
254
+ `create takes --name and --pronouns, not ${pronouns.rest.join(" ")}`,
255
+ );
256
+ if (
257
+ pronouns.value !== undefined &&
258
+ !PRONOUNS.includes(pronouns.value as Pronouns)
259
+ )
260
+ throw new Refusal(
261
+ `--pronouns takes he, she, or they, not ${JSON.stringify(pronouns.value)}`,
262
+ );
263
+ const made = await store.create({
264
+ displayName: name.value.trim(),
265
+ ...(pronouns.value ? { pronouns: pronouns.value as Pronouns } : {}),
266
+ });
267
+ io.out(
268
+ `Created ${named(made)}. Link an identity to it with roundtable principal link ${made.id} <provider>:<subject>.`,
269
+ );
270
+ io.out(HOST_DELAY);
271
+ return;
272
+ }
273
+ const arity = command === undefined ? undefined : ARITY[command];
274
+ if (arity === undefined)
275
+ throw new Refusal(`unknown principal command. Usage:\n${PRINCIPAL_USAGE}`);
276
+ if (arity !== rest.length)
277
+ throw new Refusal(
278
+ `${command} takes ${arity} ${arity === 1 ? "argument" : "arguments"}. Usage:\n${PRINCIPAL_USAGE}`,
279
+ );
280
+ if (command === "list") return list(store, io, configured);
281
+ if (command === "unlink") {
282
+ const identity = identityArgument(rest[0]);
283
+ const link = await store.identity(identity.provider, identity.subject);
284
+ // A plugin's identity is linked again by the next start while the plugin declares it.
285
+ const plugin =
286
+ link?.source === "plugin"
287
+ ? declarer(configured.identities, link)
288
+ : undefined;
289
+ if (link && plugin === "unknown")
290
+ throw new Refusal(
291
+ `${identityOf(identity)} is linked to principal ${link.principalId} as an identity a plugin declares, and ${CONFIG_FILE} does not load here to say whether a plugin still declares it; fix it, then try again`,
292
+ );
293
+ if (link && plugin !== undefined)
294
+ throw new Refusal(
295
+ `${identityOf(identity)} is linked to principal ${link.principalId} as an identity plugin ${plugin} declares; change that plugin's options in ${CONFIG_FILE} instead, and the next start moves or unlinks it`,
296
+ );
297
+ // Whoever linked it (the configuration, a first contact, the CLI, or a 0.8 claim), an identity
298
+ // an owner is listed by would be admitted by a running host as someone new once unlinked here,
299
+ // and the next start would fail on it.
300
+ const listed = link ? listedUnder(owners, link) : "nowhere";
301
+ if (link && (listed === "this principal" || listed === "unknown"))
302
+ throw new Refusal(
303
+ `${identityOf(identity)} is linked to principal ${link.principalId}, ${listed === "unknown" ? `and ${CONFIG_FILE} does not load here to say whether it lists it under an owner; fix it, or` : "which the configuration lists under an owner;"} remove it from access.owners[*].identities in ${CONFIG_FILE} instead, ${link.source === "config" ? "and the next start unlinks it" : "then unlink it here once the host has started with that configuration"}`,
304
+ );
305
+ if (!link || !(await store.unlink(identity.provider, identity.subject)))
306
+ throw new Refusal(
307
+ `${identityOf(identity)} is not linked to any principal`,
308
+ );
309
+ io.out(
310
+ `Unlinked ${identityOf(identity)} from principal ${link.principalId}.`,
311
+ );
312
+ if (listed === "another owner")
313
+ io.out(
314
+ "The configuration lists it under another owner, which a running host does not read: stop the host, then start it with this configuration. If a new p_… principal appears with this identity before then, unlink it again.",
315
+ );
316
+ io.out(HOST_DELAY);
317
+ return;
318
+ }
319
+ const principal = await find(store, rest[0]);
320
+ if (command === "show") return show(store, principal, io, configured);
321
+ if (command === "link") {
322
+ const identity = identityArgument(rest[1]);
323
+ const spends = claimable(principal, await store.rolesOf(principal.id));
324
+ await store.link(principal.id, identity, "cli");
325
+ io.out(`Linked ${identityOf(identity)} to ${named(principal)}.`);
326
+ if (spends)
327
+ io.out(
328
+ `This uses up the 0.8 claim of principal ${principal.id}: its 0.8 id no longer links its own identity at first contact, even once this one is unlinked. Link each of their other identities here.`,
329
+ );
330
+ } else if (command === "grant") {
331
+ const role = tierArgument(rest[1]);
332
+ // Granting it again would make it the CLI's, outliving its removal from the configuration.
333
+ const held = (await store.rolesOf(principal.id)).find(
334
+ (grant) => grant.role === role,
335
+ );
336
+ if (held?.source === "config") {
337
+ io.out(
338
+ `${named(principal)} already holds ${role}, granted by the configuration; nothing changed.`,
339
+ );
340
+ return;
341
+ }
342
+ await store.grant(principal.id, role, "cli");
343
+ io.out(`Granted ${role} to ${named(principal)}.`);
344
+ if (role === "owner")
345
+ io.out(
346
+ "The owner role is granted only here and in the configuration; no role a surface reports makes an owner.",
347
+ );
348
+ } else if (command === "revoke") {
349
+ const role = tierArgument(rest[1]);
350
+ const grant = (await store.rolesOf(principal.id)).find(
351
+ (held) => held.role === role,
352
+ );
353
+ if (!grant)
354
+ throw new Refusal(`principal ${principal.id} does not hold ${role}`);
355
+ if (grant.source === "config")
356
+ throw new Refusal(
357
+ `principal ${principal.id} holds ${role} from the configuration, which grants it again at every start; remove them from access.owners in roundtable.config.ts instead`,
358
+ );
359
+ await store.revoke(principal.id, role, "cli");
360
+ io.out(`Revoked ${role} from ${named(principal)}.`);
361
+ } else if (command === "disable") {
362
+ await store.disable(principal.id);
363
+ io.out(
364
+ `Disabled ${named(principal)}: no surface serves them, and their background turns are skipped.`,
365
+ );
366
+ } else {
367
+ await store.enable(principal.id);
368
+ io.out(`Enabled ${named(principal)}.`);
369
+ }
370
+ io.out(HOST_DELAY);
371
+ }
372
+
373
+ /** Whether the host has made the principal tables, which its first start on 0.9 does. */
374
+ async function hasTables(sql: SQL): Promise<boolean> {
375
+ const [row] = await sql`
376
+ SELECT to_regclass('principals') IS NOT NULL
377
+ AND to_regclass('principal_identities') IS NOT NULL
378
+ AND to_regclass('principal_roles') IS NOT NULL AS made`;
379
+ return row?.made === true;
380
+ }
381
+
382
+ /**
383
+ * Runs one `roundtable principal` command on the database at `url`, beside a host that may be
384
+ * running, and returns the exit code. It changes the database directly; a running host's cache
385
+ * catches up within 30 seconds, which every change says.
386
+ */
387
+ export async function principalCommand(
388
+ url: string,
389
+ args: readonly string[],
390
+ io: PrincipalIo,
391
+ configured: Configured,
392
+ ): Promise<number> {
393
+ const sql = openPool(url);
394
+ try {
395
+ if (!(await hasTables(sql))) {
396
+ io.err(
397
+ "This database has no principals yet: start the host on pi-roundtable 0.9 once, and its migrations make them from the people it already knows.",
398
+ );
399
+ return 1;
400
+ }
401
+ await run(await PgPrincipalStore.attach(sql), args, io, configured);
402
+ return 0;
403
+ } catch (error) {
404
+ if (error instanceof Refusal || error instanceof IdentityError) {
405
+ io.err(error.message);
406
+ return 1;
407
+ }
408
+ throw error;
409
+ } finally {
410
+ await sql.close();
411
+ }
412
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import { liftAdapters } from "../core/config/adapters.ts";
4
5
  import {
5
6
  type ResolvedConfig,
6
7
  type RoundtableConfig,
@@ -31,6 +32,11 @@ export async function loadConfigFile(cwd: string): Promise<unknown> {
31
32
  throw new Error(
32
33
  `${CONFIG_FILE} is not in ${cwd}. Run this in the project directory, or create a project with \`roundtable init\`.`,
33
34
  );
35
+ return loadDefault(path);
36
+ }
37
+
38
+ /** The value a configuration file exports by default, imported with Bun. */
39
+ export async function loadDefault(path: string): Promise<unknown> {
34
40
  const module = (await import(pathToFileURL(path).href)) as {
35
41
  default?: unknown;
36
42
  };
@@ -51,6 +57,16 @@ export interface Assembled {
51
57
  const message = (error: unknown): string =>
52
58
  error instanceof Error ? error.message : String(error);
53
59
 
60
+ /** The configuration with its adapters lifted, or as written when they cannot be. */
61
+ // pi-lens-ignore: no-unknown-returns — the configuration as written is untyped until the schema checks it
62
+ function lifted(value: unknown): unknown {
63
+ try {
64
+ return liftAdapters(value);
65
+ } catch {
66
+ return value;
67
+ }
68
+ }
69
+
54
70
  /**
55
71
  * The project's configuration, loaded and resolved at most once and shared by every check, so a
56
72
  * mistake in it is reported by the configuration check and the checks that need it say so
@@ -67,10 +83,14 @@ export class Project {
67
83
  this.#ports = ports;
68
84
  }
69
85
 
70
- /** The exported value as written, before the schema looks at it. */
86
+ /**
87
+ * The exported value as written, before the schema looks at it, with its adapters' settings in
88
+ * the places the host reads them, such as a Discord adapter's as `discord`. Adapters it cannot
89
+ * read stay as written; the configuration check reports them.
90
+ */
71
91
  raw(): Promise<Loaded<unknown>> {
72
92
  this.#raw ??= this.#ports.loadConfig(this.#cwd).then(
73
- (value): Loaded<unknown> => ({ ok: true, value }),
93
+ (value): Loaded<unknown> => ({ ok: true, value: lifted(value) }),
74
94
  (error: unknown): Loaded<unknown> => ({
75
95
  ok: false,
76
96
  failure: fail(
@@ -96,6 +116,17 @@ export class Project {
96
116
  return typeof value === "string" && value.trim() !== "" ? value : undefined;
97
117
  }
98
118
 
119
+ /** Whether the configuration, loaded, leaves the top-level key out; false when it could not be loaded. */
120
+ async leavesOut(key: string): Promise<boolean> {
121
+ const raw = await this.raw();
122
+ return (
123
+ raw.ok &&
124
+ typeof raw.value === "object" &&
125
+ raw.value !== null &&
126
+ (raw.value as Record<string, unknown>)[key] === undefined
127
+ );
128
+ }
129
+
99
130
  /** The configuration checked against its schema and assembled, or the failure that says why not. */
100
131
  assembled(): Promise<Loaded<Assembled>> {
101
132
  this.#assembled ??= this.#assemble();
package/src/cli/report.ts CHANGED
@@ -1,6 +1,10 @@
1
- /** How one check ended: passed, failed with what to do, or not run because it needs something an earlier check reports. */
1
+ /**
2
+ * How one check ended: passed, passed with a warning and what to do about it, failed with what to
3
+ * do, or not run because it needs something an earlier check reports. A warning fails nothing.
4
+ */
2
5
  export type Result =
3
6
  | { status: "ok"; detail?: string }
7
+ | { status: "warn"; problem: string; fix: string }
4
8
  | { status: "fail"; problem: string; fix: string }
5
9
  | { status: "skipped"; reason: string };
6
10
 
@@ -23,6 +27,11 @@ export const fail = (problem: string, fix: string): Result => ({
23
27
  problem,
24
28
  fix,
25
29
  });
30
+ export const warn = (problem: string, fix: string): Result => ({
31
+ status: "warn",
32
+ problem,
33
+ fix,
34
+ });
26
35
  export const skipped = (reason: string): Result => ({
27
36
  status: "skipped",
28
37
  reason,
@@ -53,7 +62,7 @@ export async function runChecks(checks: readonly Check[]): Promise<Outcome[]> {
53
62
  export const failed = (outcomes: readonly Outcome[]): Outcome[] =>
54
63
  outcomes.filter(({ result }) => result.status === "fail");
55
64
 
56
- /** One line per check, and for a failure its problem and fix underneath. */
65
+ /** One line per check, and for a warning or a failure its problem and fix underneath. */
57
66
  export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
58
67
  return outcomes.flatMap(({ name, result }) => {
59
68
  if (result.status === "ok")
@@ -61,7 +70,7 @@ export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
61
70
  if (result.status === "skipped")
62
71
  return [`- ${name}: skipped, ${result.reason}`];
63
72
  return [
64
- `✗ ${name}: ${result.problem}`,
73
+ `${result.status === "warn" ? "!" : "✗"} ${name}: ${result.problem}`,
65
74
  ...result.fix.split("\n").map((line) => ` ${line}`),
66
75
  ];
67
76
  });
@@ -19,6 +19,23 @@ const OFFICIAL_DIR = "official";
19
19
  /** The directory of the plugin `add package` writes around a Pi package. */
20
20
  const PACKAGE_DIR = "package";
21
21
 
22
+ /** The directory of each chat network's files that replace or add to the skeleton's, one directory per adapter. */
23
+ const ADAPTERS_DIR = "adapters";
24
+
25
+ /** The chat networks `init` can set a project up for; Discord's files are the skeleton itself. */
26
+ export const ADAPTERS = ["discord", "web"] as const;
27
+ export type Adapter = (typeof ADAPTERS)[number];
28
+
29
+ /** Whether `name` is an adapter `init` knows. */
30
+ export const isAdapter = (name: string): name is Adapter =>
31
+ (ADAPTERS as readonly string[]).includes(name);
32
+
33
+ /** Skeleton files a project for the adapter leaves out: a web project has no agents and no shared persona. */
34
+ const LEFT_OUT: Readonly<Record<Adapter, readonly string[]>> = {
35
+ discord: [],
36
+ web: ["agents.ts", "persona/shared.md"],
37
+ };
38
+
22
39
  /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
23
40
  export const OFFICIAL_PLUGINS = [
24
41
  "codex-images",
@@ -143,31 +160,46 @@ export function renderPackagePlugin(
143
160
  }));
144
161
  }
145
162
 
146
- /** Every file of a new project: the skeleton and the `hello` plugin, paths relative to the project. */
163
+ /**
164
+ * Every file of a new project for `adapter` (default Discord): the skeleton, without the files the
165
+ * adapter leaves out and with its own files in place of the skeleton's, and the `hello` plugin;
166
+ * paths relative to the project.
167
+ */
147
168
  export function renderProject(
148
169
  substitutions: Substitutions,
149
170
  dir = TEMPLATES_DIR,
171
+ adapter: Adapter = "discord",
150
172
  ): Rendered[] {
151
173
  if (!existsSync(dir)) throw new Error(`templates are missing: ${dir}`);
152
174
  const values = {
153
175
  PROJECT: substitutions.project,
154
176
  VERSION: substitutions.version,
155
177
  };
178
+ const render = (base: string, path: string): Rendered => ({
179
+ path: targetOf(path),
180
+ content: fill(readFileSync(join(base, path), "utf8"), values),
181
+ });
182
+ const overlayDir = join(dir, ADAPTERS_DIR, adapter);
183
+ const overlay = existsSync(overlayDir)
184
+ ? filesUnder(overlayDir).map((file) =>
185
+ render(overlayDir, relative(overlayDir, file)),
186
+ )
187
+ : [];
188
+ const replaced = new Set([
189
+ ...LEFT_OUT[adapter],
190
+ ...overlay.map((file) => file.path),
191
+ ]);
156
192
  const skeleton = filesUnder(dir)
157
193
  .map((file) => relative(dir, file))
158
- .flatMap((path) =>
159
- [PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR].some((sub) =>
160
- path.startsWith(`${sub}/`),
161
- )
162
- ? []
163
- : [
164
- {
165
- path: targetOf(path),
166
- content: fill(readFileSync(join(dir, path), "utf8"), values),
167
- },
168
- ],
169
- );
170
- return [...skeleton, ...renderPlugin(pluginNames("hello"), dir)];
194
+ .filter(
195
+ (path) =>
196
+ ![PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR, ADAPTERS_DIR].some((sub) =>
197
+ path.startsWith(`${sub}/`),
198
+ ),
199
+ )
200
+ .map((path) => render(dir, path))
201
+ .filter((file) => !replaced.has(file.path));
202
+ return [...skeleton, ...overlay, ...renderPlugin(pluginNames("hello"), dir)];
171
203
  }
172
204
 
173
205
  /** Writes rendered files under `root`, creating directories; the caller has checked that none exists. */