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
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
- import { OFFICIAL_PLUGINS } from "./templates.ts";
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 {
@@ -32,7 +47,9 @@ export interface CliEnvironment {
32
47
 
33
48
  const USAGE = `roundtable: a Discord agent server on Pi
34
49
 
35
- roundtable init [dir] create a project (default: the current directory)
50
+ roundtable init [dir] [--adapter discord|web]
51
+ create a project (default: the current directory) that talks
52
+ through Discord (the default) or through the web chat
36
53
  roundtable doctor [--reachable] check the setup and say how to fix what is wrong
37
54
  roundtable start run the checks that need no network, then the bot
38
55
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
@@ -40,6 +57,12 @@ const USAGE = `roundtable: a Discord agent server on Pi
40
57
  which are copied in ready to run instead of the template
41
58
  roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
42
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}
43
66
  `;
44
67
 
45
68
  /** The package's own version and the Bun range it needs, from the package.json beside the source. */
@@ -108,13 +131,22 @@ export async function runCli(
108
131
  return 0;
109
132
  }
110
133
  if (command === "init") {
111
- const [dir, ...extra] = rest;
112
- if (extra.length > 0) return usage(io, "init takes at most one directory");
134
+ const at = rest.indexOf("--adapter");
135
+ const adapter = at === -1 ? "discord" : rest[at + 1];
136
+ if (adapter === undefined || !isAdapter(adapter))
137
+ return usage(
138
+ io,
139
+ `--adapter takes one of ${ADAPTERS.join(", ")}, not ${JSON.stringify(adapter ?? "")}`,
140
+ );
141
+ const [dir, ...extra] = at === -1 ? rest : rest.toSpliced(at, 2);
142
+ if (extra.length > 0 || dir?.startsWith("-"))
143
+ return usage(io, "init takes at most one directory");
113
144
  const report = init({
114
145
  cwd: io.cwd,
115
146
  ...(dir === undefined ? {} : { dir }),
116
147
  bun: io.bun(),
117
148
  version: io.version(),
149
+ adapter,
118
150
  });
119
151
  if (!report.ok) {
120
152
  for (const line of report.problems) io.err(line);
@@ -190,9 +222,102 @@ export async function runCli(
190
222
  }
191
223
  return 0;
192
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
+ }
193
252
  return usage(io, `unknown command ${JSON.stringify(command)}`);
194
253
  }
195
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
+
196
321
  function usage(io: CliEnvironment, message: string): number {
197
322
  io.err(message);
198
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,12 +9,20 @@ 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";
15
16
  import type { Http } from "./http.ts";
16
17
  import { type Ports, Project } from "./project.ts";
17
- import { type Check, failed, type Outcome, runChecks } from "./report.ts";
18
+ import {
19
+ type Check,
20
+ failed,
21
+ type Outcome,
22
+ type Result,
23
+ runChecks,
24
+ skipped,
25
+ } from "./report.ts";
18
26
 
19
27
  export interface DoctorInputs {
20
28
  cwd: string;
@@ -39,6 +47,11 @@ export function buildChecks(
39
47
  ): Check[] {
40
48
  const { cwd, env, bun, ports, database, http, reachable } = inputs;
41
49
  const discord = new Discord(project, http);
50
+ // A project without Discord asks Discord nothing.
51
+ const ifDiscord = (run: () => Promise<Result>) => async (): Promise<Result> =>
52
+ (await project.leavesOut("discord"))
53
+ ? skipped("no Discord is configured")
54
+ : run();
42
55
  return [
43
56
  { name: "Bun", offline: true, run: async () => checkBun(bun) },
44
57
  {
@@ -52,6 +65,7 @@ export function buildChecks(
52
65
  run: () => checkConfiguration(project),
53
66
  },
54
67
  { name: "plugins", offline: true, run: () => checkPlugins(project) },
68
+ { name: "access", offline: true, run: () => checkAccess(project) },
55
69
  {
56
70
  name: "image provider",
57
71
  offline: true,
@@ -62,21 +76,30 @@ export function buildChecks(
62
76
  offline: false,
63
77
  run: () => checkDatabase(project, database),
64
78
  },
65
- { name: "Discord token", offline: false, run: () => checkToken(discord) },
79
+ {
80
+ name: "principals",
81
+ offline: false,
82
+ run: () => checkPrincipals(project, database),
83
+ },
84
+ {
85
+ name: "Discord token",
86
+ offline: false,
87
+ run: ifDiscord(() => checkToken(discord)),
88
+ },
66
89
  {
67
90
  name: "Discord guild",
68
91
  offline: false,
69
- run: () => checkGuild(project, discord),
92
+ run: ifDiscord(() => checkGuild(project, discord)),
70
93
  },
71
94
  {
72
95
  name: "Discord intents",
73
96
  offline: false,
74
- run: () => checkIntents(discord),
97
+ run: ifDiscord(() => checkIntents(discord)),
75
98
  },
76
99
  {
77
100
  name: "Discord channel",
78
101
  offline: false,
79
- run: () => checkChannel(project, discord),
102
+ run: ifDiscord(() => checkChannel(project, discord)),
80
103
  },
81
104
  {
82
105
  name: "model login",
package/src/cli/init.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { basename, join, relative, resolve } from "node:path";
3
3
  import { type BunFacts, checkBun } from "./checks/bun.ts";
4
- import { type Rendered, renderProject, writeRendered } from "./templates.ts";
4
+ import {
5
+ type Adapter,
6
+ type Rendered,
7
+ renderProject,
8
+ writeRendered,
9
+ } from "./templates.ts";
5
10
 
6
11
  export interface InitInputs {
7
12
  cwd: string;
@@ -12,6 +17,8 @@ export interface InitInputs {
12
17
  version: string;
13
18
  /** Where the templates are; default the package's own. */
14
19
  templates?: string;
20
+ /** The chat network the project talks through; default Discord. */
21
+ adapter?: Adapter;
15
22
  }
16
23
 
17
24
  export interface InitReport {
@@ -61,6 +68,7 @@ export function init(inputs: InitInputs): InitReport {
61
68
  const files: Rendered[] = renderProject(
62
69
  { project: projectName(root), version: inputs.version },
63
70
  inputs.templates,
71
+ inputs.adapter,
64
72
  );
65
73
  const existing = files
66
74
  .map((file) => file.path)
@@ -0,0 +1,72 @@
1
+ /** Every line of `before` and `after` in order: kept, removed, or added. */
2
+ function lineOps(
3
+ before: readonly string[],
4
+ after: readonly string[],
5
+ ): { kind: " " | "-" | "+"; line: string }[] {
6
+ // The longest common subsequence, from the end, so the walk below reads forward.
7
+ const rows = before.length + 1;
8
+ const columns = after.length + 1;
9
+ const common = new Uint32Array(rows * columns);
10
+ for (let i = before.length - 1; i >= 0; i--)
11
+ for (let j = after.length - 1; j >= 0; j--)
12
+ common[i * columns + j] =
13
+ before[i] === after[j]
14
+ ? (common[(i + 1) * columns + j + 1] ?? 0) + 1
15
+ : Math.max(
16
+ common[(i + 1) * columns + j] ?? 0,
17
+ common[i * columns + j + 1] ?? 0,
18
+ );
19
+ const ops: { kind: " " | "-" | "+"; line: string }[] = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ while (i < before.length || j < after.length) {
23
+ if (i < before.length && j < after.length && before[i] === after[j]) {
24
+ ops.push({ kind: " ", line: before[i++] ?? "" });
25
+ j++;
26
+ } else if (
27
+ j < after.length &&
28
+ (i === before.length ||
29
+ (common[i * columns + j + 1] ?? 0) >
30
+ (common[(i + 1) * columns + j] ?? 0))
31
+ )
32
+ ops.push({ kind: "+", line: after[j++] ?? "" });
33
+ else ops.push({ kind: "-", line: before[i++] ?? "" });
34
+ }
35
+ return ops;
36
+ }
37
+
38
+ const CONTEXT = 3;
39
+
40
+ /** The change from `before` to `after` as a unified diff with three lines of context; empty when they are the same. */
41
+ export function lineDiff(
42
+ before: string,
43
+ after: string,
44
+ file: string,
45
+ ): string[] {
46
+ const ops = lineOps(before.split("\n"), after.split("\n"));
47
+ const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index]));
48
+ if (changed.length === 0) return [];
49
+ // Changes closer than twice the context share a hunk.
50
+ const hunks: [number, number][] = [];
51
+ for (const index of changed) {
52
+ const last = hunks.at(-1);
53
+ if (last && index - last[1] <= 2 * CONTEXT) last[1] = index;
54
+ else hunks.push([index, index]);
55
+ }
56
+ const lines = [`--- ${file}`, `+++ ${file} (upgraded)`];
57
+ for (const [first, last] of hunks) {
58
+ const start = Math.max(0, first - CONTEXT);
59
+ const end = Math.min(ops.length - 1, last + CONTEXT);
60
+ const before = ops.slice(0, start);
61
+ const span = ops.slice(start, end + 1);
62
+ const count = (list: typeof ops, kind: "-" | "+") =>
63
+ list.filter((op) => op.kind !== (kind === "-" ? "+" : "-")).length;
64
+ const oldLength = count(span, "-");
65
+ const newLength = count(span, "+");
66
+ lines.push(
67
+ `@@ -${count(before, "-") + (oldLength > 0 ? 1 : 0)},${oldLength} +${count(before, "+") + (newLength > 0 ? 1 : 0)},${newLength} @@`,
68
+ ...span.map((op) => `${op.kind}${op.line}`),
69
+ );
70
+ }
71
+ return lines;
72
+ }