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,102 @@
1
+ import { ConfigEditError, type Node } from "./config-edit.ts";
2
+
3
+ /** One replacement of the source text: [start, end) becomes `text`. */
4
+ export interface Edit {
5
+ start: number;
6
+ end: number;
7
+ text: string;
8
+ }
9
+
10
+ /**
11
+ * Rewrites one configuration's text. Each place it cannot read with confidence, such as an owner
12
+ * built elsewhere or spread into the object, is a ConfigEditError naming the file, line, and column.
13
+ */
14
+ export class Rewrite {
15
+ readonly source: string;
16
+ readonly #file: string;
17
+ readonly #comments: readonly Node[];
18
+ readonly edits: Edit[] = [];
19
+ readonly moved: Node[] = [];
20
+
21
+ constructor(source: string, file: string, comments: readonly Node[]) {
22
+ this.source = source;
23
+ this.#file = file;
24
+ this.#comments = comments;
25
+ }
26
+
27
+ text(node: Node): string {
28
+ return this.source.slice(node.start, node.end);
29
+ }
30
+
31
+ /** A ConfigEditError at the node, as `file:line:column: message`. */
32
+ at(node: Node, message: string): ConfigEditError {
33
+ const before = this.source.slice(0, node.start);
34
+ const line = before.split("\n").length;
35
+ const column = node.start - (before.lastIndexOf("\n") + 1) + 1;
36
+ return new ConfigEditError(`${this.#file}:${line}:${column}: ${message}`);
37
+ }
38
+
39
+ /** The indentation of the node's line, when the node starts it; undefined when it shares the line. */
40
+ indentOf(node: Node): string | undefined {
41
+ const lineStart = this.source.lastIndexOf("\n", node.start - 1) + 1;
42
+ const before = this.source.slice(lineStart, node.start);
43
+ return /^[ \t]*$/.test(before) ? before : undefined;
44
+ }
45
+
46
+ /** The comments inside [start, end). */
47
+ commentsIn(start: number, end: number): Node[] {
48
+ return this.#comments.filter(
49
+ (comment) => comment.start >= start && comment.end <= end,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Where the property and what belongs to it end when it is removed: the comment lines right
55
+ * above it, its comma, and a comment after it on its line, through the line's end when it has
56
+ * the line to itself.
57
+ */
58
+ removal(property: Node): { start: number; end: number } {
59
+ const source = this.source;
60
+ let end = property.end;
61
+ const after = /^[ \t]*,?[ \t]*(\/\/[^\n]*|\/\*[^\n]*?\*\/)?[ \t]*/.exec(
62
+ source.slice(end),
63
+ );
64
+ end += after?.[0].length ?? 0;
65
+ const indent = this.indentOf(property);
66
+ if (indent === undefined || (source[end] !== "\n" && end < source.length))
67
+ return { start: property.start, end };
68
+ let start = property.start - indent.length;
69
+ for (;;) {
70
+ const previousEnd = start - 1;
71
+ if (previousEnd < 0) break;
72
+ const previousStart = source.lastIndexOf("\n", previousEnd - 1) + 1;
73
+ const line = source.slice(previousStart, previousEnd).trim();
74
+ if (
75
+ !(
76
+ line.startsWith("//") ||
77
+ (line.startsWith("/*") && line.endsWith("*/"))
78
+ )
79
+ )
80
+ break;
81
+ start = previousStart;
82
+ }
83
+ return { start, end: end + (source[end] === "\n" ? 1 : 0) };
84
+ }
85
+
86
+ /** The comment lines to put back above a rewritten property, at its indentation. */
87
+ commentLines(comments: readonly Node[], indent: string | undefined): string {
88
+ if (comments.length === 0) return "";
89
+ if (indent === undefined) {
90
+ const line = comments.find((comment) => comment.type === "CommentLine");
91
+ if (line)
92
+ throw this.at(
93
+ line,
94
+ "this comment is inside what the upgrade rewrites, on a line it shares with other keys; move it out, or rewrite the key by hand",
95
+ );
96
+ return `${comments.map((comment) => this.text(comment)).join(" ")} `;
97
+ }
98
+ return comments
99
+ .map((comment) => `${this.text(comment)}\n${indent}`)
100
+ .join("");
101
+ }
102
+ }
@@ -0,0 +1,430 @@
1
+ import {
2
+ boundNames,
3
+ ConfigEditError,
4
+ child,
5
+ exportedObject,
6
+ importsFrom,
7
+ importsName,
8
+ listInsertion,
9
+ type Node,
10
+ nameOf,
11
+ packageImport,
12
+ parseConfig,
13
+ unwrap,
14
+ } from "./config-edit.ts";
15
+ import { CONFIG_FILE } from "./project.ts";
16
+ import { Rewrite } from "./upgrade-rewrite.ts";
17
+
18
+ /** A configuration rewritten in the 0.9 form: the new text, what changed, and what to look over. */
19
+ export interface Upgraded {
20
+ source: string;
21
+ /** One line per rewrite made; none when the configuration is already in the 0.9 form. */
22
+ changes: string[];
23
+ /** What the rewrite did that a person should look at, such as comments it moved. */
24
+ notes: string[];
25
+ }
26
+
27
+ const DISCORD_ENTRY = "pi-roundtable/discord";
28
+ const TAB = "\t";
29
+ /** The width the formatter gives a line: a tab counts as its default indent width of 2. */
30
+ const LINE_WIDTH = 80;
31
+ const width = (line: string): number => line.replaceAll("\t", " ").length;
32
+ /** Expressions a method can be called on as they are written, without parentheses. */
33
+ const CALLABLE = new Set([
34
+ "Identifier",
35
+ "MemberExpression",
36
+ "OptionalMemberExpression",
37
+ "CallExpression",
38
+ "OptionalCallExpression",
39
+ "ArrayExpression",
40
+ ]);
41
+
42
+ /** The properties of an object literal by name, refusing a spread or a key it cannot name. */
43
+ function propertiesOf(
44
+ rewrite: Rewrite,
45
+ object: Node,
46
+ path: string,
47
+ known: readonly string[],
48
+ ): Map<string, Node> {
49
+ const found = new Map<string, Node>();
50
+ for (const property of object.properties as Node[]) {
51
+ if (property.type !== "ObjectProperty" || property.computed === true)
52
+ throw rewrite.at(
53
+ property,
54
+ `${path} has a key the upgrade cannot read; write it as plain keys (${known.join(", ")}), or rewrite it by hand`,
55
+ );
56
+ const key = nameOf(child(property, "key")) ?? "";
57
+ if (!known.includes(key))
58
+ throw rewrite.at(
59
+ property,
60
+ `${path}.${key} is not a key of ${path} (${known.join(", ")}); remove it, or rewrite it by hand`,
61
+ );
62
+ found.set(key, property);
63
+ }
64
+ return found;
65
+ }
66
+
67
+ /** The property's value as an object literal, or a ConfigEditError saying it is not one. */
68
+ function objectValue(rewrite: Rewrite, property: Node, path: string): Node {
69
+ const value = unwrap(child(property, "value"));
70
+ if (property.shorthand === true || value?.type !== "ObjectExpression")
71
+ throw rewrite.at(
72
+ property,
73
+ `${path} is not an object written here, so the upgrade cannot read it; write it out as { ... } in this file, or rewrite it as access by hand`,
74
+ );
75
+ return value;
76
+ }
77
+
78
+ /** An identity of a Discord id: a string for a string, a template around anything else. */
79
+ function prefixed(rewrite: Rewrite, prefix: string, node: Node): string {
80
+ if (node.type === "StringLiteral")
81
+ return JSON.stringify(`${prefix}${String(node.value)}`);
82
+ return `\`${prefix}\${${rewrite.text(node)}}\``;
83
+ }
84
+
85
+ /** A list of Discord ids as identities or roles, item by item, or mapped when it is built elsewhere. */
86
+ function prefixedList(
87
+ rewrite: Rewrite,
88
+ prefix: string,
89
+ value: Node,
90
+ path: string,
91
+ variable: string,
92
+ ): string[] | string {
93
+ const list = unwrap(value);
94
+ if (list?.type !== "ArrayExpression") {
95
+ const text = rewrite.text(value);
96
+ const callee = CALLABLE.has(value.type) ? text : `(${text})`;
97
+ return `${callee}.map((${variable}) => \`${prefix}\${${variable}}\`)`;
98
+ }
99
+ return (list.elements as (Node | null)[]).map((element, i) => {
100
+ if (element === null || element.type === "SpreadElement")
101
+ throw rewrite.at(
102
+ element ?? list,
103
+ `${path}[${i}] is spread from another list, which the upgrade cannot read; list the ids here, or rewrite it by hand`,
104
+ );
105
+ return prefixed(rewrite, prefix, element);
106
+ });
107
+ }
108
+
109
+ /** A list as the formatter prints it: on its line when it fits, else one item a line. */
110
+ function listText(
111
+ items: readonly string[],
112
+ indent: string,
113
+ lead: string,
114
+ ): string {
115
+ const line = `[${items.join(", ")}]`;
116
+ if (width(`${indent}${lead}${line},`) <= LINE_WIDTH) return line;
117
+ return `[\n${items.map((item) => `${indent}${TAB}${item},\n`).join("")}${indent}]`;
118
+ }
119
+
120
+ type Field = [key: string, value: string[] | string];
121
+
122
+ function objectText(fields: readonly Field[], indent: string): string {
123
+ const inner = `${indent}${TAB}`;
124
+ const lines = fields.map(([key, value]) => {
125
+ const lead = `${key}: `;
126
+ const text =
127
+ typeof value === "string" ? value : listText(value, inner, lead);
128
+ return `${inner}${lead}${text},\n`;
129
+ });
130
+ return `{\n${lines.join("")}${indent}}`;
131
+ }
132
+
133
+ /**
134
+ * 0.8's `everyone`, which reached Discord alone, as everyone on Discord. An expression is the
135
+ * condition of a new conditional, in parentheses unless it is one operand already, so a
136
+ * conditional or `||` written there keeps its meaning.
137
+ */
138
+ function everyoneText(rewrite: Rewrite, value: Node): string {
139
+ if (value.type !== "BooleanLiteral") {
140
+ const text = rewrite.text(value);
141
+ const condition = CALLABLE.has(value.type) ? text : `(${text})`;
142
+ return `${condition} ? ["discord"] : false`;
143
+ }
144
+ return value.value === true ? '["discord"]' : "false";
145
+ }
146
+
147
+ /** One 0.8 tier, `{ users, roles, everyone }`, as an access tier. */
148
+ function tierFields(rewrite: Rewrite, property: Node, path: string): Field[] {
149
+ const tier = propertiesOf(
150
+ rewrite,
151
+ objectValue(rewrite, property, path),
152
+ path,
153
+ ["users", "roles", "everyone"],
154
+ );
155
+ const fields: Field[] = [];
156
+ const users = tier.get("users");
157
+ if (users)
158
+ fields.push([
159
+ "identities",
160
+ prefixedList(
161
+ rewrite,
162
+ "discord:",
163
+ child(users, "value") as Node,
164
+ `${path}.users`,
165
+ "id",
166
+ ),
167
+ ]);
168
+ const roles = tier.get("roles");
169
+ if (roles)
170
+ fields.push([
171
+ "roles",
172
+ prefixedList(
173
+ rewrite,
174
+ "discord:role:",
175
+ child(roles, "value") as Node,
176
+ `${path}.roles`,
177
+ "role",
178
+ ),
179
+ ]);
180
+ const everyone = tier.get("everyone");
181
+ if (everyone) {
182
+ const value = child(everyone, "value") as Node;
183
+ fields.push(["everyone", everyoneText(rewrite, value)]);
184
+ }
185
+ return fields;
186
+ }
187
+
188
+ /** The `access` text that means what `owner` and `speakers` meant in 0.8. */
189
+ function accessText(
190
+ rewrite: Rewrite,
191
+ owner: Node,
192
+ speakers: Node | undefined,
193
+ withDiscord: boolean,
194
+ indent = "",
195
+ ): string {
196
+ const fields = propertiesOf(
197
+ rewrite,
198
+ objectValue(rewrite, owner, "owner"),
199
+ "owner",
200
+ ["id", "name", "pronouns"],
201
+ );
202
+ const fieldValue = (key: string): Node | undefined => {
203
+ const property = fields.get(key);
204
+ return property && (child(property, "value") as Node);
205
+ };
206
+ const id = fieldValue("id");
207
+ const name = fieldValue("name");
208
+ if (!id || !name)
209
+ throw rewrite.at(
210
+ owner,
211
+ "owner needs an id and a name; add the missing one, or write access by hand",
212
+ );
213
+ const pronouns = fieldValue("pronouns");
214
+ const ownerFields: Field[] = [
215
+ ["name", rewrite.text(name)],
216
+ ...(pronouns ? [["pronouns", rewrite.text(pronouns)] as Field] : []),
217
+ ["principal", rewrite.text(id)],
218
+ ...(withDiscord
219
+ ? [["identities", [prefixed(rewrite, "discord:", id)]] as Field]
220
+ : []),
221
+ ];
222
+ const tiers: Field[] = [];
223
+ if (speakers) {
224
+ const map = propertiesOf(
225
+ rewrite,
226
+ objectValue(rewrite, speakers, "speakers"),
227
+ "speakers",
228
+ ["admins", "members"],
229
+ );
230
+ const inner = `${indent}${TAB}`;
231
+ for (const tier of ["admins", "members"]) {
232
+ const property = map.get(tier);
233
+ if (property)
234
+ tiers.push([
235
+ tier,
236
+ objectText(tierFields(rewrite, property, `speakers.${tier}`), inner),
237
+ ]);
238
+ }
239
+ }
240
+ const inner = `${indent}${TAB}`;
241
+ const owners = `[\n${inner}${TAB}${objectText(ownerFields, `${inner}${TAB}`)},\n${inner}]`;
242
+ return `access: ${objectText([["owners", owners], ...tiers], indent)}`;
243
+ }
244
+
245
+ /** The value's text with each line after the first indented one tab more, unless a template literal spans lines. */
246
+ function indented(text: string): string {
247
+ if (/`[^`]*\n[^`]*`/.test(text)) return text;
248
+ return text.replaceAll(/\n(?=[^\n])/g, `\n${TAB}`);
249
+ }
250
+
251
+ /**
252
+ * The configuration in the 0.9 form: `owner` and `speakers` as the `access` they mean (the owner's
253
+ * principal their old id, their identity `discord:<id>` on a host with Discord, roles `discord:role:<id>`, `everyone`
254
+ * as everyone on Discord), and the top-level `discord` as `adapters: [discord({...})]` with its
255
+ * import. It keeps every comment, and notes the ones it moved. A configuration already in the
256
+ * 0.9 form comes back as it is. Throws a ConfigEditError, changing nothing, for what it cannot
257
+ * rewrite with confidence.
258
+ */
259
+ export function upgradeSource(source: string, file = CONFIG_FILE): Upgraded {
260
+ const { body, comments } = parseConfig(
261
+ source,
262
+ file,
263
+ "rewrite owner and speakers as access by hand",
264
+ );
265
+ const object = exportedObject(body);
266
+ if (!object)
267
+ throw new ConfigEditError(
268
+ `${file}: cannot find the configuration object. Expected \`export default { ... }\`, or a constant of this file it names; rewrite owner and speakers as access by hand.`,
269
+ );
270
+ const rewrite = new Rewrite(source, file, comments);
271
+ const named = new Map<string, Node>();
272
+ let spread: Node | undefined;
273
+ for (const property of object.properties as Node[]) {
274
+ if (property.type === "SpreadElement") spread ??= property;
275
+ const key =
276
+ property.computed === true ? undefined : nameOf(child(property, "key"));
277
+ if (key) named.set(key, property);
278
+ }
279
+ const owner = named.get("owner");
280
+ const speakers = named.get("speakers");
281
+ const discord = named.get("discord");
282
+ if (!owner && !speakers && !discord)
283
+ return { source, changes: [], notes: [] };
284
+ if (spread)
285
+ throw rewrite.at(
286
+ spread,
287
+ "the configuration spreads another object, which may hold owner, speakers, or access; write the keys in this object, or rewrite them by hand",
288
+ );
289
+ const access = named.get("access");
290
+ if (access && (owner || speakers))
291
+ throw rewrite.at(
292
+ owner ?? access,
293
+ "the configuration writes access beside owner and speakers; write the owners in one of them, not both. Remove owner and speakers if access says the same.",
294
+ );
295
+ if (speakers && !owner)
296
+ throw rewrite.at(
297
+ speakers,
298
+ "speakers without owner: 0.8 needed both; add the owner, or write access by hand",
299
+ );
300
+
301
+ const changes: string[] = [];
302
+ if (owner) {
303
+ const indent = rewrite.indentOf(owner);
304
+ const spans = [{ start: owner.start, end: owner.end }];
305
+ if (speakers) {
306
+ const removal = rewrite.removal(speakers);
307
+ spans.push(removal);
308
+ rewrite.edits.push({ ...removal, text: "" });
309
+ }
310
+ const comments = spans.flatMap((span) =>
311
+ rewrite.commentsIn(span.start, span.end),
312
+ );
313
+ rewrite.moved.push(...comments);
314
+ // The host gives the 0.8 owner a Discord identity only with Discord configured.
315
+ const withDiscord = Boolean(discord) || importsFrom(body, DISCORD_ENTRY);
316
+ const text = accessText(rewrite, owner, speakers, withDiscord, indent);
317
+ rewrite.edits.push({
318
+ start: owner.start,
319
+ end: owner.end,
320
+ text: `${rewrite.commentLines(comments, indent)}${text}`,
321
+ });
322
+ const ownerChange = withDiscord
323
+ ? "the owner's principal is their old id and their identity discord:<id>"
324
+ : "the owner's principal is their old id, with no identity, as a host without Discord has none";
325
+ changes.push(
326
+ speakers
327
+ ? `owner and speakers → access: ${ownerChange}; users are discord:<id>, roles discord:role:<id>, and everyone means everyone on Discord`
328
+ : `owner → access: ${ownerChange}`,
329
+ );
330
+ }
331
+ if (discord) {
332
+ const value = child(discord, "value") as Node;
333
+ const bound = boundNames(body);
334
+ const imported = importsName(body, DISCORD_ENTRY, "discord");
335
+ if (bound.has("discord") && !imported)
336
+ throw rewrite.at(
337
+ discord,
338
+ `the name discord is already used in this file; rename it, or write adapters: [discord({ ... })] with discord from ${DISCORD_ENTRY} by hand`,
339
+ );
340
+ const indent = rewrite.indentOf(discord);
341
+ const between = [
342
+ ...rewrite.commentsIn(discord.start, value.start),
343
+ ...rewrite.commentsIn(value.end, discord.end),
344
+ ];
345
+ const call = `discord(${indent === undefined ? rewrite.text(value) : indented(rewrite.text(value))})`;
346
+ const adapters = named.get("adapters");
347
+ if (adapters) {
348
+ const list = unwrap(child(adapters, "value"));
349
+ if (list?.type !== "ArrayExpression")
350
+ throw rewrite.at(
351
+ adapters,
352
+ "adapters is not a list written here, so the upgrade cannot add Discord to it; add discord({ ... }) to it by hand",
353
+ );
354
+ const removal = rewrite.removal(discord);
355
+ // Those inside the settings go with them into discord(...); the rest lead the list.
356
+ const comments = rewrite
357
+ .commentsIn(removal.start, removal.end)
358
+ .filter(
359
+ (comment) => comment.end <= value.start || comment.start >= value.end,
360
+ );
361
+ // A line comment put before a key that shares its line would swallow the rest of that line.
362
+ if (
363
+ rewrite.indentOf(adapters) === undefined &&
364
+ comments.some((comment) => comment.type === "CommentLine")
365
+ )
366
+ throw rewrite.at(
367
+ adapters,
368
+ "adapters shares a line with other keys, so the upgrade cannot put the comments of discord above it; put adapters on its own line",
369
+ );
370
+ rewrite.moved.push(...comments);
371
+ rewrite.edits.push({ ...removal, text: "" });
372
+ if (comments.length > 0)
373
+ rewrite.edits.push({
374
+ start: adapters.start,
375
+ end: adapters.start,
376
+ text: rewrite.commentLines(comments, rewrite.indentOf(adapters)),
377
+ });
378
+ const added = listInsertion(rewrite.source, list, call);
379
+ rewrite.edits.push({
380
+ start: added.at,
381
+ end: added.at + (added.replaces ?? 0),
382
+ text: added.text,
383
+ });
384
+ } else {
385
+ rewrite.moved.push(...between);
386
+ const text =
387
+ indent === undefined
388
+ ? `adapters: [${call}]`
389
+ : `adapters: [\n${indent}${TAB}${call},\n${indent}]`;
390
+ rewrite.edits.push({
391
+ start: discord.start,
392
+ end: discord.end,
393
+ text: `${rewrite.commentLines(between, indent)}${text}`,
394
+ });
395
+ }
396
+ if (!imported) {
397
+ const added = packageImport(
398
+ body,
399
+ DISCORD_ENTRY,
400
+ `import { discord } from "${DISCORD_ENTRY}";`,
401
+ );
402
+ rewrite.edits.push({ start: added.at, end: added.at, text: added.text });
403
+ }
404
+ changes.push(
405
+ `discord → adapters: [discord({ ... })], with discord imported from ${DISCORD_ENTRY}`,
406
+ );
407
+ }
408
+
409
+ const edits = rewrite.edits.toSorted((a, b) => b.start - a.start);
410
+ edits.forEach((edit, i) => {
411
+ const later = edits[i - 1];
412
+ if (later && edit.end > later.start)
413
+ throw new ConfigEditError(
414
+ `${file}: the keys the upgrade rewrites overlap; rewrite owner, speakers, and discord by hand.`,
415
+ );
416
+ });
417
+ let upgraded = source;
418
+ for (const edit of edits)
419
+ upgraded =
420
+ upgraded.slice(0, edit.start) + edit.text + upgraded.slice(edit.end);
421
+ // A result that does not parse is a bug here; never hand it back.
422
+ parseConfig(upgraded, file, "report this as a bug in roundtable upgrade");
423
+ const notes =
424
+ rewrite.moved.length === 0
425
+ ? []
426
+ : [
427
+ `moved ${rewrite.moved.length} ${rewrite.moved.length === 1 ? "comment" : "comments"} from the rewritten keys to above the key that now holds what they were about; check they still read right`,
428
+ ];
429
+ return { source: upgraded, changes, notes };
430
+ }
@@ -0,0 +1,176 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { isDeepStrictEqual } from "node:util";
13
+ import { resolveConfig } from "../core/config/config.ts";
14
+ import { ConfigEditError } from "./config-edit.ts";
15
+ import { lineDiff } from "./line-diff.ts";
16
+ import { CONFIG_FILE } from "./project.ts";
17
+ import { type Upgraded, upgradeSource } from "./upgrade-source.ts";
18
+
19
+ export interface UpgradeInputs {
20
+ /** The project directory, holding roundtable.config.ts. */
21
+ cwd: string;
22
+ /** Write the rewrite; without it the upgrade only shows it. */
23
+ write: boolean;
24
+ /**
25
+ * Write the rewrite even when the file as it is does not load, so the rewrite could not be
26
+ * checked to serve the same people; without it such a write is refused.
27
+ */
28
+ unchecked: boolean;
29
+ /** The value a configuration file exports by default, loaded as the host loads it. */
30
+ load(path: string): Promise<unknown>;
31
+ }
32
+
33
+ export type UpgradeReport =
34
+ | {
35
+ ok: true;
36
+ /** The unified diff of the rewrite; empty when there is nothing to change. */
37
+ diff: string[];
38
+ changes: string[];
39
+ notes: string[];
40
+ /** Whether the rewrite was checked to serve the same people, or why it could not be. */
41
+ verified: { same: true } | { skipped: string };
42
+ written: boolean;
43
+ }
44
+ | { ok: false; problems: string[] };
45
+
46
+ const message = (error: unknown): string =>
47
+ error instanceof Error ? error.message : String(error);
48
+
49
+ /** What the host makes of a configuration that decides whom it serves. */
50
+ function servedBy(config: unknown) {
51
+ const resolved = resolveConfig(config);
52
+ return {
53
+ access: resolved.access,
54
+ primaryOwner: resolved.primaryOwner,
55
+ discord: resolved.discord,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Whether the rewrite serves the same people as the file as it is, loading it beside the file so
61
+ * its imports resolve the same; skipped when the file as it is does not load, a problem when the
62
+ * rewrite does not load or differs.
63
+ */
64
+ async function compare(
65
+ inputs: UpgradeInputs,
66
+ path: string,
67
+ rewritten: string,
68
+ ): Promise<{ same: true } | { skipped: string } | { problem: string }> {
69
+ // Written before either is loaded: Bun reads a directory's files once, at its first import from it.
70
+ const candidate = join(
71
+ inputs.cwd,
72
+ `${CONFIG_FILE.replace(/\.ts$/, "")}.upgrade-${crypto.randomUUID()}.ts`,
73
+ );
74
+ writeFileSync(candidate, rewritten);
75
+ let before: ReturnType<typeof servedBy>;
76
+ try {
77
+ before = servedBy(await inputs.load(path));
78
+ } catch (error) {
79
+ rmSync(candidate, { force: true });
80
+ return {
81
+ skipped: `${CONFIG_FILE} does not load as it is (${message(error)}), so the rewrite could not be compared with it`,
82
+ };
83
+ }
84
+ try {
85
+ const after = servedBy(await inputs.load(candidate));
86
+ if (isDeepStrictEqual(before, after)) return { same: true };
87
+ return {
88
+ problem: `the rewrite would change whom the host serves, so it was not written: ${CONFIG_FILE} as it is gives ${JSON.stringify(before.access)}, the rewrite ${JSON.stringify(after.access)}. Rewrite owner and speakers as access by hand, and report this as a bug in roundtable upgrade.`,
89
+ };
90
+ } catch (error) {
91
+ return {
92
+ problem: `the rewrite does not load (${message(error)}), so it was not written. Rewrite owner and speakers as access by hand, and report this as a bug in roundtable upgrade.`,
93
+ };
94
+ } finally {
95
+ rmSync(candidate, { force: true });
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Replaces the file at `path` with `text`, written beside it and renamed over it so it is never
101
+ * half written. A symlink is written through to the file it names, which keeps its mode; the
102
+ * staged file is removed when the rename fails.
103
+ */
104
+ export function writeAtomically(
105
+ path: string,
106
+ text: string,
107
+ rename: (from: string, to: string) => void = renameSync,
108
+ ): void {
109
+ const target = realpathSync(path);
110
+ const mode = statSync(target).mode & 0o7777;
111
+ const staged = `${target}.upgrade-${crypto.randomUUID()}`;
112
+ try {
113
+ writeFileSync(staged, text, { mode });
114
+ // The mode given to writeFileSync passes through the umask; set it as the file had it.
115
+ chmodSync(staged, mode);
116
+ rename(staged, target);
117
+ } catch (error) {
118
+ rmSync(staged, { force: true });
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Rewrites the project's roundtable.config.ts in the 0.9 form, after showing it. Before it writes,
125
+ * it loads the rewrite beside the original and refuses it unless the host would serve the same
126
+ * people, with the same owners and Discord settings. When the original does not load, such as
127
+ * without the environment the host runs with, the check is skipped: the preview says so, and a
128
+ * write is refused unless `unchecked` asks for it. It touches no other file.
129
+ */
130
+ export async function upgrade(inputs: UpgradeInputs): Promise<UpgradeReport> {
131
+ const path = join(inputs.cwd, CONFIG_FILE);
132
+ if (!existsSync(path))
133
+ return {
134
+ ok: false,
135
+ problems: [
136
+ `${CONFIG_FILE} is not in ${inputs.cwd}. Run this in the project directory.`,
137
+ ],
138
+ };
139
+ const source = readFileSync(path, "utf8");
140
+ let upgraded: Upgraded;
141
+ try {
142
+ upgraded = upgradeSource(source);
143
+ } catch (error) {
144
+ if (error instanceof ConfigEditError)
145
+ return { ok: false, problems: [error.message] };
146
+ throw error;
147
+ }
148
+ const { changes, notes } = upgraded;
149
+ if (changes.length === 0)
150
+ return {
151
+ ok: true,
152
+ diff: [],
153
+ changes,
154
+ notes,
155
+ verified: { skipped: "there is nothing to change" },
156
+ written: false,
157
+ };
158
+ const verified = await compare(inputs, path, upgraded.source);
159
+ if ("problem" in verified) return { ok: false, problems: [verified.problem] };
160
+ if (inputs.write && "skipped" in verified && !inputs.unchecked)
161
+ return {
162
+ ok: false,
163
+ problems: [
164
+ `${verified.skipped}; it was not written. Set the environment the host runs with and retry; to write it without the check, run roundtable upgrade --write --unchecked.`,
165
+ ],
166
+ };
167
+ if (inputs.write) writeAtomically(path, upgraded.source);
168
+ return {
169
+ ok: true,
170
+ diff: lineDiff(source, upgraded.source, CONFIG_FILE),
171
+ changes,
172
+ notes,
173
+ verified,
174
+ written: inputs.write,
175
+ };
176
+ }