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,3 +1,4 @@
1
+ import type { SQL } from "bun";
1
2
  import { nested, openPool, runMigrations } from "../../core/db/migrations.ts";
2
3
  import { MigrationError } from "../../core/errors.ts";
3
4
  import type { RoundtablePlugin } from "../../core/plugin.ts";
@@ -12,6 +13,8 @@ export interface DatabasePort {
12
13
  | readonly Pick<RoundtablePlugin, "name" | "migrations">[]
13
14
  | undefined,
14
15
  ): Promise<void>;
16
+ /** Runs `use` on a pool of the database at `url`, closed after; `use` only reads. */
17
+ read<T>(url: string, use: (sql: SQL) => Promise<T>): Promise<T>;
15
18
  }
16
19
 
17
20
  class Rollback extends Error {}
@@ -39,6 +42,14 @@ export const postgres: DatabasePort = {
39
42
  await pool.close();
40
43
  }
41
44
  },
45
+ async read(url, use) {
46
+ const pool = openPool(url);
47
+ try {
48
+ return await use(pool);
49
+ } finally {
50
+ await pool.close();
51
+ }
52
+ },
42
53
  };
43
54
 
44
55
  /** The address without its password, safe to print. */
@@ -0,0 +1,225 @@
1
+ import type { SQL } from "bun";
2
+ import { LEGACY_ACCESS } from "../../core/config/access.ts";
3
+ import type { ResolvedConfig } from "../../core/config/config.ts";
4
+ import type { AccessRules } from "../../core/identity/access-policy.ts";
5
+ import { identityOf, parseIdentity } from "../../core/identity/actor-facts.ts";
6
+ import { declaredConflict } from "../../core/identity/config-owner.ts";
7
+ import { backfillLine } from "../../core/identity/identity-plugin.ts";
8
+ import {
9
+ type BackfillSummary,
10
+ backfillPrincipals,
11
+ } from "../../core/identity/identity-schema.ts";
12
+ import {
13
+ type DeclaredIdentity,
14
+ declaredIdentities,
15
+ } from "../../core/identity/plugin-identities.ts";
16
+ import { PgPrincipalStore } from "../../core/identity/principal-store.ts";
17
+ import type { Project } from "../project.ts";
18
+ import { fail, ok, type Result, skipped, warn } from "../report.ts";
19
+ import type { DatabasePort } from "./database.ts";
20
+
21
+ const list = (items: readonly string[]): string =>
22
+ new Intl.ListFormat("en").format(items);
23
+
24
+ const message = (error: unknown): string =>
25
+ error instanceof Error ? error.message : String(error);
26
+
27
+ /**
28
+ * The chat surfaces the host talks through, as `everyone` names them: Discord when it is
29
+ * configured, and each web chat plugin's surface, read from its name (`webchat` is `web`,
30
+ * `webchat-<surface>` is `<surface>`). A surface another plugin adds at setup is not seen here.
31
+ */
32
+ export function surfacesOf(
33
+ config: Pick<ResolvedConfig, "discord">,
34
+ plugins: readonly { name: string }[],
35
+ ): string[] {
36
+ const surfaces = config.discord ? ["discord"] : [];
37
+ for (const { name } of plugins) {
38
+ if (name === "webchat") surfaces.push("web");
39
+ else if (name.startsWith("webchat-"))
40
+ surfaces.push(name.slice("webchat-".length));
41
+ }
42
+ return surfaces;
43
+ }
44
+
45
+ /**
46
+ * Whom the configuration serves: the owners by name, a warning when `everyone: true` admits
47
+ * everyone on every surface of a host with more than one, and a note when it is written in the
48
+ * deprecated 0.8 form. It reads no database, so `start` runs it too.
49
+ */
50
+ export async function checkAccess(project: Project): Promise<Result> {
51
+ const assembled = await project.assembled();
52
+ if (!assembled.ok) return skipped("the configuration is not valid yet");
53
+ const { config, defined } = assembled.value;
54
+ const surfaces = surfacesOf(config, defined.plugins);
55
+ const broad = (["admins", "members"] as const).filter(
56
+ (tier) => config.access[tier]?.everyone === true,
57
+ );
58
+ if (broad.length > 0 && surfaces.length > 1)
59
+ return warn(
60
+ `${list(broad.map((tier) => `access.${tier}.everyone`))} ${broad.length === 1 ? "is" : "are"} true, which admits everyone on every surface: ${list(surfaces)}.`,
61
+ `Name the surfaces where everyone may talk, such as everyone: ["discord"], and admit the others by identity or by role, such as roles: ["web:role:<name>"].`,
62
+ );
63
+ const owners = config.access.owners;
64
+ const legacy = config.deprecations.includes(LEGACY_ACCESS)
65
+ ? "; written as the 0.8 owner and speakers, which roundtable upgrade rewrites as access"
66
+ : "";
67
+ return ok(
68
+ `${owners.length} ${owners.length === 1 ? "owner" : "owners"}, ${list(owners.map((owner) => owner.name))}${legacy}`,
69
+ );
70
+ }
71
+
72
+ /** What the database says about the configured owners, and what the next start's backfill would make. */
73
+ export interface PrincipalFindings {
74
+ summary: BackfillSummary;
75
+ /** A configured owner's identity linked to another principal, which stops the start, and how to fix it. */
76
+ conflicts: { problem: string; identity: string; fix: string }[];
77
+ /** Configured owners whose principal is disabled. */
78
+ disabled: { index: number; id: string; name: string }[];
79
+ /** Whether any owner, configured or granted by the CLI, can reach the host. */
80
+ reachable: boolean;
81
+ }
82
+
83
+ /**
84
+ * Reads, without writing, what the identity plugin would do at the next start: the backfill as a
85
+ * dry run, and the configured owners' identity links as its sync checks them. `declaredBy` holds
86
+ * each identity the plugins declare, with the plugin and the principal it names.
87
+ */
88
+ export async function principalFindings(
89
+ sql: SQL,
90
+ rules: AccessRules,
91
+ declaredBy: ReadonlyMap<string, DeclaredIdentity> = new Map(),
92
+ ): Promise<PrincipalFindings> {
93
+ const summary = await backfillPrincipals(sql, rules, { dryRun: true });
94
+ const [tables] = await sql`
95
+ SELECT to_regclass('principal_identities') IS NOT NULL
96
+ AND to_regclass('principal_roles') IS NOT NULL AS made`;
97
+ // Before the first start on 0.9, the start makes every configured owner, enabled.
98
+ if (tables?.made !== true)
99
+ return { summary, conflicts: [], disabled: [], reachable: true };
100
+ const store = await PgPrincipalStore.attach(sql);
101
+ const conflicts: PrincipalFindings["conflicts"] = [];
102
+ // The primary owner's principal, undefined when the start makes it.
103
+ let primary: string | undefined;
104
+ const disabled: PrincipalFindings["disabled"] = [];
105
+ let reachable = false;
106
+ for (const [index, owner] of rules.owners.entries()) {
107
+ let id = owner.principal;
108
+ const links = await Promise.all(
109
+ owner.identities.map(async (identity) => {
110
+ const ref = parseIdentity(identity);
111
+ const link = ref && (await store.identity(ref.provider, ref.subject));
112
+ // A plugin's link no plugin declares any more is unlinked by the start before the owners are synced.
113
+ return link?.source === "plugin" && !declaredBy.has(identityOf(link))
114
+ ? undefined
115
+ : link;
116
+ }),
117
+ );
118
+ const unlink = (identity: string) =>
119
+ `Unlink it with roundtable principal unlink ${identity}, or fix access.owners in roundtable.config.ts`;
120
+ // As at the start, a plugin's link never tells who the owner is.
121
+ for (const [i, link] of links.entries()) {
122
+ if (!link || link.source === "plugin") continue;
123
+ id ??= link.principalId;
124
+ const identity = owner.identities[i] ?? "";
125
+ if (link.principalId !== id)
126
+ conflicts.push({
127
+ problem: `access.owners[${index}].identities[${i}]: ${identity} is linked to principal ${link.principalId}, not to this owner's ${id}`,
128
+ identity,
129
+ fix: unlink(identity),
130
+ });
131
+ }
132
+ // As at the start, an identity a plugin declares is bound to this owner on this start, or it stops it.
133
+ for (const [i, identity] of owner.identities.entries()) {
134
+ const ref = parseIdentity(identity);
135
+ const declared = ref && declaredBy.get(identityOf(ref));
136
+ if (!declared) continue;
137
+ const conflict = declaredConflict({
138
+ index,
139
+ i,
140
+ identity,
141
+ id,
142
+ link: links[i],
143
+ declared,
144
+ primary,
145
+ first: index === 0,
146
+ });
147
+ if (conflict) conflicts.push({ ...conflict, identity });
148
+ }
149
+ if (index === 0) primary = id;
150
+ const principal = id === undefined ? undefined : await store.get(id);
151
+ if (principal?.disabled)
152
+ disabled.push({ index, id: principal.id, name: owner.name });
153
+ else reachable = true;
154
+ }
155
+ for (const holder of await store.holders("owner"))
156
+ if (
157
+ holder.source === "cli" &&
158
+ (await store.get(holder.principalId))?.disabled === false
159
+ )
160
+ reachable = true;
161
+ return { summary, conflicts, disabled, reachable };
162
+ }
163
+
164
+ /**
165
+ * The principals the next start would make from the people 0.8 stored, as a dry run printed as the
166
+ * start logs it; a configured owner's identity linked to another principal, which stops the
167
+ * start; and a disabled configured owner, or no owner left who can reach the host.
168
+ */
169
+ export async function checkPrincipals(
170
+ project: Project,
171
+ database: DatabasePort,
172
+ ): Promise<Result> {
173
+ const url = await project.text("database", "url");
174
+ if (!url) return skipped("database.url has no value");
175
+ const assembled = await project.assembled();
176
+ if (!assembled.ok) return skipped("the configuration is not valid yet");
177
+ const rules = assembled.value.config.access;
178
+ let declaredBy: Map<string, DeclaredIdentity>;
179
+ try {
180
+ declaredBy = new Map(
181
+ declaredIdentities(assembled.value.defined.plugins).map((declared) => [
182
+ declared.identity,
183
+ declared,
184
+ ]),
185
+ );
186
+ } catch (error) {
187
+ return skipped(
188
+ `the plugins' identities are not valid yet (${message(error)})`,
189
+ );
190
+ }
191
+ let findings: PrincipalFindings;
192
+ try {
193
+ findings = await database.read(url, (sql) =>
194
+ principalFindings(sql, rules, declaredBy),
195
+ );
196
+ } catch (error) {
197
+ return skipped(`PostgreSQL did not answer (${message(error)})`);
198
+ }
199
+ const dryRun = `dry run, nothing written: ${backfillLine(findings.summary)}`;
200
+ if (findings.conflicts.length > 0)
201
+ return fail(
202
+ `${findings.conflicts.map((conflict) => conflict.problem).join("; ")}; the host does not start until this is fixed.`,
203
+ findings.conflicts.map((conflict) => conflict.fix).join("\n"),
204
+ );
205
+ if (findings.disabled.length > 0) {
206
+ const who = list(
207
+ findings.disabled.map(
208
+ (owner) =>
209
+ `access.owners[${owner.index}] ${owner.name} (principal ${owner.id})`,
210
+ ),
211
+ );
212
+ return warn(
213
+ findings.reachable
214
+ ? `${who} ${findings.disabled.length === 1 ? "is" : "are"} disabled, so the host does not serve them.`
215
+ : `no owner can reach the host: ${who} ${findings.disabled.length === 1 ? "is" : "are"} disabled.`,
216
+ [
217
+ ...findings.disabled.map(
218
+ (owner) => `Enable with roundtable principal enable ${owner.id}`,
219
+ ),
220
+ dryRun,
221
+ ].join("\n"),
222
+ );
223
+ }
224
+ return ok(dryRun);
225
+ }
@@ -1,8 +1,17 @@
1
1
  import { parseModelRef } from "../../core/models.ts";
2
+ import {
3
+ BRIDGE_MEMORY_FIX,
4
+ bridgeMemoryProblem,
5
+ configuredCrowd,
6
+ onClaudeBridge,
7
+ } from "../../core/runtime/bridge-guard.ts";
2
8
  import type { Ports, Project } from "../project.ts";
3
- import { fail, ok, type Result, skipped } from "../report.ts";
9
+ import { fail, ok, type Result, skipped, warn } from "../report.ts";
4
10
 
5
- /** A login exists for the provider of the configured model: an API key variable or a stored login. */
11
+ /**
12
+ * A login exists for the provider of the configured model: an API key variable or a stored login;
13
+ * with a non-failing warning for bridge's shared private-memory replay risk.
14
+ */
6
15
  export async function checkModelLogin(
7
16
  project: Project,
8
17
  ports: Pick<Ports, "login">,
@@ -15,12 +24,21 @@ export async function checkModelLogin(
15
24
  `the model ${JSON.stringify(model)} is not written <provider>/<id>.`,
16
25
  "Write it like anthropic/claude-sonnet-5-5 in MODEL in .env.",
17
26
  );
27
+ // Admission warns of risk; each shared turn checks actual private history.
28
+ let risk: Result | undefined;
29
+ const assembled = await project.assembled();
30
+ if (assembled.ok) {
31
+ const { config } = assembled.value;
32
+ const crowd = configuredCrowd(config.access, config.plugins);
33
+ if (config.memory && crowd && onClaudeBridge(ref))
34
+ risk = warn(bridgeMemoryProblem(model, crowd), BRIDGE_MEMORY_FIX);
35
+ }
18
36
  const dir = await project.text("agentDir");
19
37
  const dataDir = await project.text("dataDir");
20
38
  const agentDir = dir ?? (dataDir ? `${dataDir}/pi` : undefined);
21
39
  if (!agentDir) return skipped("dataDir has no value");
22
40
  const source = await ports.login(agentDir, ref.provider);
23
- if (source) return ok(`${ref.provider} login from ${source}`);
41
+ if (source) return risk ?? ok(`${ref.provider} login from ${source}`);
24
42
  return fail(
25
43
  `no login for ${ref.provider}, the provider of ${model}.`,
26
44
  `Set the provider's API key in .env (ANTHROPIC_API_KEY for anthropic, OPENAI_API_KEY for openai), or sign in with Pi so ${agentDir}/auth.json holds it.`,
package/src/cli/cli.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { resolveConfig } from "../core/config/config.ts";
3
4
  import type { DefinedRoundtable } from "../core/define-roundtable.ts";
4
5
  import { Roundtable } from "../core/host.ts";
6
+ import { declaredIdentities } from "../core/identity/plugin-identities.ts";
5
7
  import { addPackage } from "./add-package.ts";
6
8
  import { addPlugin } from "./add-plugin.ts";
7
9
  import type { BunFacts } from "./checks/bun.ts";
@@ -10,11 +12,24 @@ import { doctor } from "./doctor.ts";
10
12
  import { fetchHttp } from "./http.ts";
11
13
  import { init } from "./init.ts";
12
14
  import { type PackagePorts, piPackagePorts } from "./pi-packages.ts";
13
- import { loadConfigFile, type Ports } from "./project.ts";
15
+ import {
16
+ type Configured,
17
+ type ConfiguredIdentities,
18
+ PRINCIPAL_USAGE,
19
+ principalCommand,
20
+ } from "./principal.ts";
21
+ import {
22
+ CONFIG_FILE,
23
+ loadConfigFile,
24
+ loadDefault,
25
+ type Ports,
26
+ Project,
27
+ } from "./project.ts";
14
28
  import { formatOutcomes } from "./report.ts";
15
29
  import { assemble, providerLogin } from "./runtime.ts";
16
30
  import { start } from "./start.ts";
17
31
  import { ADAPTERS, isAdapter, OFFICIAL_PLUGINS } from "./templates.ts";
32
+ import { upgrade } from "./upgrade.ts";
18
33
 
19
34
  /** What the command line reads from its surroundings; tests replace every part. */
20
35
  export interface CliEnvironment {
@@ -42,6 +57,12 @@ const USAGE = `roundtable: a Discord agent server on Pi
42
57
  which are copied in ready to run instead of the template
43
58
  roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
44
59
  loads it and gives its tools to every agent turn
60
+ roundtable upgrade [--write [--unchecked]]
61
+ show roundtable.config.ts rewritten in the 0.9 form (owner and
62
+ speakers as access, discord as an adapter); --write writes it once
63
+ it is checked to serve the same people, which needs the
64
+ environment the host runs with; --unchecked writes it unchecked
65
+ ${PRINCIPAL_USAGE}
45
66
  `;
46
67
 
47
68
  /** The package's own version and the Bun range it needs, from the package.json beside the source. */
@@ -201,9 +222,102 @@ export async function runCli(
201
222
  }
202
223
  return 0;
203
224
  }
225
+ if (command === "upgrade") {
226
+ const write = rest.includes("--write");
227
+ const unchecked = rest.includes("--unchecked");
228
+ if (rest.some((arg) => arg !== "--write" && arg !== "--unchecked"))
229
+ return usage(io, "upgrade takes only --write and --unchecked");
230
+ if (unchecked && !write) return usage(io, "--unchecked goes with --write");
231
+ return runUpgrade(io, write, unchecked);
232
+ }
233
+ if (command === "principal") {
234
+ const project = new Project(resolve(io.cwd), io.ports);
235
+ const raw = await project.raw();
236
+ if (!raw.ok) {
237
+ for (const line of formatOutcomes([
238
+ { name: "configuration", result: raw.failure },
239
+ ]))
240
+ io.err(line);
241
+ return 1;
242
+ }
243
+ const url = await project.text("database", "url");
244
+ if (!url) {
245
+ io.err(
246
+ `database.url has no value in ${CONFIG_FILE}; the principals live in the host's database. Set DATABASE_URL in .env.`,
247
+ );
248
+ return 1;
249
+ }
250
+ return principalCommand(url, rest, io, configured(raw.value));
251
+ }
204
252
  return usage(io, `unknown command ${JSON.stringify(command)}`);
205
253
  }
206
254
 
255
+ /**
256
+ * The owners the configuration lists and the identities its plugins declare, read by its schema
257
+ * alone: its plugins and model runtime are not assembled, so their failures do not pass for a
258
+ * configuration that does not load. Identities the start would refuse are unknown.
259
+ */
260
+ function configured(value: unknown): Configured {
261
+ let config: ReturnType<typeof resolveConfig>;
262
+ try {
263
+ config = resolveConfig(value);
264
+ } catch {
265
+ return { owners: undefined, identities: undefined };
266
+ }
267
+ let identities: ConfiguredIdentities;
268
+ try {
269
+ identities = declaredIdentities(config.plugins);
270
+ } catch {
271
+ identities = undefined;
272
+ }
273
+ return { owners: config.access.owners, identities };
274
+ }
275
+
276
+ async function runUpgrade(
277
+ io: CliEnvironment,
278
+ write: boolean,
279
+ unchecked: boolean,
280
+ ): Promise<number> {
281
+ const report = await upgrade({
282
+ cwd: resolve(io.cwd),
283
+ write,
284
+ unchecked,
285
+ load: loadDefault,
286
+ });
287
+ if (!report.ok) {
288
+ for (const line of report.problems) io.err(line);
289
+ io.err(`${CONFIG_FILE} was not changed.`);
290
+ return 1;
291
+ }
292
+ if (report.changes.length === 0) {
293
+ io.out(
294
+ `${CONFIG_FILE} is already in the 0.9 form; there is nothing to change.`,
295
+ );
296
+ return 0;
297
+ }
298
+ io.out(`${CONFIG_FILE} in the 0.9 form:`);
299
+ for (const change of report.changes) io.out(` - ${change}`);
300
+ io.out("");
301
+ for (const line of report.diff) io.out(line);
302
+ io.out("");
303
+ for (const note of report.notes) io.out(`Note: ${note}.`);
304
+ io.out(
305
+ "same" in report.verified
306
+ ? "Checked: the rewrite serves the same owners, admins, and members, with the same Discord settings."
307
+ : `Not checked: ${report.verified.skipped}.`,
308
+ );
309
+ if (report.written && "skipped" in report.verified)
310
+ io.err(
311
+ `Warning: written without the check, as --unchecked asked. Nothing confirmed that the host serves the same owners, admins, and members as before; read the diff above, and run roundtable doctor with the environment the host runs with.`,
312
+ );
313
+ io.out(
314
+ report.written
315
+ ? `Wrote ${CONFIG_FILE}; no other file was changed.`
316
+ : `Nothing was written. Run roundtable upgrade --write to write it.`,
317
+ );
318
+ return 0;
319
+ }
320
+
207
321
  function usage(io: CliEnvironment, message: string): number {
208
322
  io.err(message);
209
323
  io.err(USAGE);
@@ -1,7 +1,7 @@
1
1
  import { parse } from "@babel/parser";
2
2
 
3
3
  /** The parts of a syntax node this edit reads; the parser's own types are not needed beyond these. */
4
- interface Node {
4
+ export interface Node {
5
5
  type: string;
6
6
  start: number;
7
7
  end: number;
@@ -20,7 +20,7 @@ const WRAPPERS = new Set([
20
20
  "ParenthesizedExpression",
21
21
  ]);
22
22
 
23
- const child = (node: Node, key: string): Node | undefined => {
23
+ export const child = (node: Node, key: string): Node | undefined => {
24
24
  const value = node[key];
25
25
  return typeof value === "object" && value !== null
26
26
  ? (value as Node)
@@ -28,35 +28,53 @@ const child = (node: Node, key: string): Node | undefined => {
28
28
  };
29
29
 
30
30
  /** The expression under `satisfies`, `as`, `!`, and parentheses. */
31
- function unwrap(node: Node | undefined): Node | undefined {
31
+ export function unwrap(node: Node | undefined): Node | undefined {
32
32
  let current = node;
33
33
  while (current && WRAPPERS.has(current.type))
34
34
  current = child(current, "expression");
35
35
  return current;
36
36
  }
37
37
 
38
- function parseModule(source: string, file: string): Node[] {
38
+ /** A parsed module: its top-level statements, and every comment in it. */
39
+ export interface ParsedModule {
40
+ body: Node[];
41
+ comments: Node[];
42
+ }
43
+
44
+ /** The module parsed, or a ConfigEditError saying it does not parse and what to do by hand, `instead`. */
45
+ export function parseConfig(
46
+ source: string,
47
+ file: string,
48
+ instead: string,
49
+ ): ParsedModule {
39
50
  try {
40
- // SAFETY: every node the parser builds has a type, start, and end, which is all Node states.
41
- return parse(source, {
51
+ const parsed = parse(source, {
42
52
  sourceType: "module",
43
53
  plugins: ["typescript"],
44
- }).program.body as unknown as Node[];
54
+ });
55
+ // SAFETY: every node the parser builds has a type, start, and end, which is all Node states.
56
+ return {
57
+ body: parsed.program.body as unknown as Node[],
58
+ comments: (parsed.comments ?? []) as unknown as Node[],
59
+ };
45
60
  } catch (error) {
46
61
  throw new ConfigEditError(
47
- `${file} does not parse (${error instanceof Error ? error.message : String(error)}). Fix it, or add the plugin to its list by hand.`,
62
+ `${file} does not parse (${error instanceof Error ? error.message : String(error)}). Fix it, or ${instead}.`,
48
63
  );
49
64
  }
50
65
  }
51
66
 
52
- const nameOf = (node: Node | undefined): string | undefined => {
67
+ const parseModule = (source: string, file: string): Node[] =>
68
+ parseConfig(source, file, "add the plugin to its list by hand").body;
69
+
70
+ export const nameOf = (node: Node | undefined): string | undefined => {
53
71
  if (node?.type === "Identifier") return node.name as string;
54
72
  if (node?.type === "StringLiteral") return node.value as string;
55
73
  return undefined;
56
74
  };
57
75
 
58
76
  /** Every name the module already binds at its top level, so an import cannot shadow one. */
59
- function boundNames(body: readonly Node[]): Set<string> {
77
+ export function boundNames(body: readonly Node[]): Set<string> {
60
78
  const names = new Set<string>();
61
79
  for (const statement of body) {
62
80
  const declaration =
@@ -80,7 +98,7 @@ function boundNames(body: readonly Node[]): Set<string> {
80
98
  }
81
99
 
82
100
  /** The object the config exports: the default export itself, or the top-level constant it names. */
83
- function exportedObject(body: readonly Node[]): Node | undefined {
101
+ export function exportedObject(body: readonly Node[]): Node | undefined {
84
102
  const exported = body.find(
85
103
  (node) => node.type === "ExportDefaultDeclaration",
86
104
  );
@@ -112,7 +130,7 @@ function pluginList(object: Node): Node | undefined {
112
130
  return undefined;
113
131
  }
114
132
 
115
- interface Insertion {
133
+ export interface Insertion {
116
134
  at: number;
117
135
  text: string;
118
136
  /** How many characters from `at` the text replaces; none when it only inserts. */
@@ -162,7 +180,11 @@ function expandedList(
162
180
  }
163
181
 
164
182
  /** The text to add so `ident` joins the list, in the list's own layout. */
165
- function listInsertion(source: string, list: Node, ident: string): Insertion {
183
+ export function listInsertion(
184
+ source: string,
185
+ list: Node,
186
+ ident: string,
187
+ ): Insertion {
166
188
  const elements = (list.elements as (Node | null)[]).filter(
167
189
  (element): element is Node => element !== null,
168
190
  );
@@ -205,6 +227,54 @@ function importInsertion(
205
227
  : { at: 0, text: `${line}\n\n` };
206
228
  }
207
229
 
230
+ /** Where an import of a package goes: after the package imports that sort before it, else first. */
231
+ export function packageImport(
232
+ body: readonly Node[],
233
+ specifier: string,
234
+ line: string,
235
+ ): Insertion {
236
+ const imports = body.filter((node) => node.type === "ImportDeclaration");
237
+ const from = (node: Node) => String(child(node, "source")?.value ?? "");
238
+ const before = imports
239
+ .filter((node) => !from(node).startsWith(".") && from(node) < specifier)
240
+ .at(-1);
241
+ if (before) return { at: before.end, text: `\n${line}` };
242
+ const first = imports[0];
243
+ return first
244
+ ? { at: first.start, text: `${line}\n` }
245
+ : { at: 0, text: `${line}\n\n` };
246
+ }
247
+
248
+ /** Whether the module imports `name` from `specifier` under that same name. */
249
+ export function importsName(
250
+ body: readonly Node[],
251
+ specifier: string,
252
+ name: string,
253
+ ): boolean {
254
+ return body.some(
255
+ (node) =>
256
+ node.type === "ImportDeclaration" &&
257
+ child(node, "source")?.value === specifier &&
258
+ (node.specifiers as Node[]).some(
259
+ (imported) =>
260
+ imported.type === "ImportSpecifier" &&
261
+ nameOf(child(imported, "imported")) === name &&
262
+ nameOf(child(imported, "local")) === name,
263
+ ),
264
+ );
265
+ }
266
+
267
+ /** Whether the file imports anything from `specifier`. */
268
+ export const importsFrom = (
269
+ body: readonly Node[],
270
+ specifier: string,
271
+ ): boolean =>
272
+ body.some(
273
+ (node) =>
274
+ node.type === "ImportDeclaration" &&
275
+ child(node, "source")?.value === specifier,
276
+ );
277
+
208
278
  /**
209
279
  * Adds the plugin's import line and lists it in the `plugins` array of the default export, in the
210
280
  * file's own layout; a one-line list that would pass 80 columns is put one element a line. It
package/src/cli/doctor.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Discord,
10
10
  } from "./checks/discord.ts";
11
11
  import { checkEnvironment } from "./checks/environment.ts";
12
+ import { checkAccess, checkPrincipals } from "./checks/identity.ts";
12
13
  import { checkImageProvider } from "./checks/images.ts";
13
14
  import { checkModelLogin } from "./checks/model.ts";
14
15
  import { checkPublicUrl } from "./checks/public-url.ts";
@@ -64,6 +65,7 @@ export function buildChecks(
64
65
  run: () => checkConfiguration(project),
65
66
  },
66
67
  { name: "plugins", offline: true, run: () => checkPlugins(project) },
68
+ { name: "access", offline: true, run: () => checkAccess(project) },
67
69
  {
68
70
  name: "image provider",
69
71
  offline: true,
@@ -74,6 +76,11 @@ export function buildChecks(
74
76
  offline: false,
75
77
  run: () => checkDatabase(project, database),
76
78
  },
79
+ {
80
+ name: "principals",
81
+ offline: false,
82
+ run: () => checkPrincipals(project, database),
83
+ },
77
84
  {
78
85
  name: "Discord token",
79
86
  offline: false,