@dev.fast/whiteboard 0.0.0-stage → 0.2.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,509 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import {
8
+ type JsonObject,
9
+ type JsonValue,
10
+ isJsonObject,
11
+ isStringValue,
12
+ parseJsonText,
13
+ } from "@dev.fast/json";
14
+ import {
15
+ type ReviewDiffrConfig,
16
+ type ReviewDiffrProvider,
17
+ type ReviewDiffrSummarizerInput,
18
+ decodeStructuralDiffEvent,
19
+ reviewDiffrSummarizerInputSchema,
20
+ } from "@dev.fast/review-protocol";
21
+ import { stringify } from "smol-toml";
22
+
23
+ import { invalidateStructuralComparisons } from "./structural-comparisons.js";
24
+ import { diffrExecutable, diffrMissingError } from "./structural-diff";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ let writes: Promise<unknown> = Promise.resolve();
29
+
30
+ let testing = false;
31
+
32
+ function serialized<T>(operation: () => Promise<T>): Promise<T> {
33
+ const next = writes.then(operation, operation);
34
+ writes = next.catch(() => {});
35
+
36
+ return next;
37
+ }
38
+
39
+ async function diffr(
40
+ args: string[],
41
+ rootPath?: string,
42
+ options: { signal?: AbortSignal; env?: NodeJS.ProcessEnv } = {},
43
+ ): Promise<string> {
44
+ try {
45
+ const { stdout } = await execFileAsync(diffrExecutable(), args, {
46
+ cwd: rootPath,
47
+ maxBuffer: 16 * 1024 * 1024,
48
+ signal: options.signal ?? AbortSignal.timeout(30_000),
49
+ env: options.env ?? process.env,
50
+ });
51
+
52
+ return stdout;
53
+ } catch (error) {
54
+ // SAFETY: execFile rejects with an Error carrying its exit or spawn code.
55
+ // Do not forward its message: it includes command arguments.
56
+ const failure = error as NodeJS.ErrnoException;
57
+
58
+ if (failure.code === "ENOENT") throw diffrMissingError();
59
+
60
+ if (failure.name === "AbortError")
61
+ throw new Error("diffr timed out or was cancelled.");
62
+ throw new Error(
63
+ "diffr could not complete the operation. Check its configuration and credentials.",
64
+ );
65
+ }
66
+ }
67
+
68
+ async function values(
69
+ rootPath?: string,
70
+ reveal = false,
71
+ signal?: AbortSignal,
72
+ ): Promise<JsonObject> {
73
+ return json(
74
+ await diffr(
75
+ ["config", "show", "--json", ...(reveal ? ["--reveal"] : [])],
76
+ rootPath,
77
+ { signal },
78
+ ),
79
+ );
80
+ }
81
+
82
+ function json(output: string): JsonObject {
83
+ try {
84
+ const parsed = parseJsonText(output);
85
+
86
+ if (isJsonObject(parsed)) return parsed;
87
+ } catch {
88
+ /* Do not echo config contents in parse errors. */
89
+ }
90
+
91
+ throw new Error("diffr configuration response is malformed.");
92
+ }
93
+
94
+ function valueAt(object: JsonObject, key: string): JsonValue | undefined {
95
+ let value: JsonValue | undefined = object;
96
+
97
+ for (const segment of key.split("."))
98
+ value = isJsonObject(value) ? value[segment] : undefined;
99
+
100
+ return value;
101
+ }
102
+
103
+ const prefix = "plugins.bundled.summarize";
104
+
105
+ function savedProvider(config: JsonObject): string {
106
+ const value = valueAt(config, `${prefix}.provider`);
107
+
108
+ return isStringValue(value) ? value : "";
109
+ }
110
+
111
+ function savedEndpoint(config: JsonObject): string {
112
+ const value = valueAt(config, `${prefix}.endpoint`);
113
+
114
+ return isStringValue(value) ? value : "";
115
+ }
116
+
117
+ /** Whether a saved key belongs to a different destination than the draft's. */
118
+ function movesKey(
119
+ config: JsonObject,
120
+ draft: Pick<ReviewDiffrSummarizerInput, "provider" | "endpoint">,
121
+ ): boolean {
122
+ return (
123
+ draft.provider !== savedProvider(config) ||
124
+ draft.endpoint !== savedEndpoint(config)
125
+ );
126
+ }
127
+
128
+ function environmentKey(
129
+ providers: ReviewDiffrProvider[],
130
+ id: string,
131
+ ): string | undefined {
132
+ return providers
133
+ .find((provider) => provider.id === id)
134
+ ?.keyVariables.map((name) => process.env[name])
135
+ .find((key) => !!key);
136
+ }
137
+
138
+ function keyOptional(
139
+ providers: ReviewDiffrProvider[],
140
+ id: string,
141
+ endpoint: string,
142
+ ): boolean {
143
+ return (
144
+ endpoint !== "" &&
145
+ !!providers.find((provider) => provider.id === id)?.keylessCustomEndpoint
146
+ );
147
+ }
148
+
149
+ function strings(value: JsonValue | undefined): string[] {
150
+ return Array.isArray(value) ? value.filter(isStringValue) : [];
151
+ }
152
+
153
+ /**
154
+ * What diffr's schema says about summaries: each provider's defaults, and
155
+ * the default prompt with the link its description gives. Nothing when
156
+ * diffr cannot describe its schema: the rest of Settings still works.
157
+ */
158
+ async function summaryDefaults(
159
+ rootPath?: string,
160
+ ): Promise<Pick<ReviewDiffrConfig, "providers" | "defaultPrompt">> {
161
+ let schema: JsonObject;
162
+
163
+ try {
164
+ schema = json(await diffr(["config", "schema"], rootPath));
165
+ } catch {
166
+ return {};
167
+ }
168
+
169
+ const options = valueAt(
170
+ schema,
171
+ "properties.plugins.properties.bundled.properties.summarize.properties",
172
+ );
173
+
174
+ if (!isJsonObject(options)) return {};
175
+
176
+ const option = (name: string) =>
177
+ isJsonObject(options[name]) ? options[name] : {};
178
+
179
+ const provider = option("provider");
180
+ const titles = strings(provider["x-enum-titles"]);
181
+ const models = valueAt(option("model"), "x-default-by.values");
182
+ const details = valueAt(option("provider_details"), "x-default-by.values");
183
+
184
+ const providers = strings(provider.enum).map((id, index) => {
185
+ const detail = isJsonObject(details) ? details[id] : undefined;
186
+ const model = isJsonObject(models) ? models[id] : undefined;
187
+ const endpoint = isJsonObject(detail) ? detail.endpoint : undefined;
188
+
189
+ return {
190
+ id,
191
+ title: titles[index] ?? id,
192
+ model: isStringValue(model) ? model : "",
193
+ endpoint: isStringValue(endpoint) ? endpoint : "",
194
+ keyVariables: isJsonObject(detail) ? strings(detail.key_variables) : [],
195
+ keylessCustomEndpoint:
196
+ isJsonObject(detail) && detail.keyless_custom_endpoint === true,
197
+ };
198
+ });
199
+
200
+ const prompt = option("system_prompt");
201
+
202
+ return {
203
+ providers,
204
+ defaultPrompt: isStringValue(prompt.default) ? prompt.default : undefined,
205
+ };
206
+ }
207
+
208
+ async function read(rootPath?: string): Promise<ReviewDiffrConfig> {
209
+ const config = await values(rootPath);
210
+ const defaults = await summaryDefaults(rootPath);
211
+ const saved = valueAt(config, `${prefix}.api_key`);
212
+
213
+ const credentialSource =
214
+ isStringValue(saved) && saved.length > 0
215
+ ? "config"
216
+ : environmentKey(defaults.providers ?? [], savedProvider(config))
217
+ ? "environment"
218
+ : "missing";
219
+
220
+ const plugins = config.plugins;
221
+
222
+ if (isJsonObject(plugins)) {
223
+ for (const namespace of Object.values(plugins)) {
224
+ if (!isJsonObject(namespace)) continue;
225
+
226
+ for (const plugin of Object.values(namespace)) {
227
+ if (isJsonObject(plugin)) delete plugin.api_key;
228
+ }
229
+ }
230
+ }
231
+
232
+ return { values: config, credentialSource, ...defaults };
233
+ }
234
+
235
+ export function readDiffrConfig(rootPath?: string): Promise<ReviewDiffrConfig> {
236
+ return serialized(() => read(rootPath));
237
+ }
238
+
239
+ export function diffrConfigValueText(value: JsonValue): string {
240
+ return isStringValue(value) ? value : JSON.stringify(value);
241
+ }
242
+
243
+ async function writeSettings(
244
+ entries: [string, JsonValue][],
245
+ rootPath?: string,
246
+ ): Promise<ReviewDiffrConfig> {
247
+ let changed = false;
248
+ let error: string | undefined;
249
+ let current = await values(rootPath);
250
+
251
+ try {
252
+ for (const [key, value] of entries) {
253
+ if (valueAt(current, key) === value) continue;
254
+ await diffr(
255
+ ["config", "set", key, diffrConfigValueText(value)],
256
+ rootPath,
257
+ );
258
+ changed = true;
259
+ // A write can clear others, such as a provider's endpoint and key.
260
+ current = await values(rootPath);
261
+ }
262
+ } catch {
263
+ error = changed
264
+ ? "Some settings were saved before diffr rejected a change. Check the current values and try again."
265
+ : "diffr could not save the setting. Check its configuration and the entered value.";
266
+ } finally {
267
+ if (changed) invalidateStructuralComparisons();
268
+ }
269
+
270
+ return { ...(await read(rootPath)), changed, error };
271
+ }
272
+
273
+ export function setDiffrConfigValue(
274
+ key: string,
275
+ value: JsonValue,
276
+ rootPath?: string,
277
+ ): Promise<ReviewDiffrConfig> {
278
+ if (
279
+ !/^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_][A-Za-z0-9_-]*)*$/.test(key)
280
+ ) {
281
+ throw new Error("Invalid diffr config key.");
282
+ }
283
+
284
+ return serialized(() => writeSettings([[key, value]], rootPath));
285
+ }
286
+
287
+ export function saveDiffrSummarizer(
288
+ input: ReviewDiffrSummarizerInput,
289
+ rootPath?: string,
290
+ ): Promise<ReviewDiffrConfig> {
291
+ const parsed = reviewDiffrSummarizerInputSchema.safeParse(input);
292
+
293
+ if (!parsed.success) throw new Error("Invalid summary settings.");
294
+ const draft = parsed.data;
295
+
296
+ return serialized(async () => {
297
+ const current = await read(rootPath);
298
+ const providers = current.providers ?? [];
299
+ const moving = movesKey(current.values, draft);
300
+ const savedKey = current.credentialSource === "config";
301
+
302
+ if (
303
+ draft.enabled &&
304
+ !draft.apiKey &&
305
+ !(savedKey && !moving) &&
306
+ !environmentKey(providers, draft.provider) &&
307
+ !keyOptional(providers, draft.provider, draft.endpoint)
308
+ ) {
309
+ return {
310
+ ...current,
311
+ changed: false,
312
+ error: "Add an API key before enabling summaries.",
313
+ };
314
+ }
315
+
316
+ const entries: [string, JsonValue][] = [];
317
+
318
+ if (!draft.enabled) entries.push([`${prefix}.enabled`, false]);
319
+
320
+ // Clear a key before its destination changes and write a new one only
321
+ // after, so a failure part way never pairs a key with another vendor.
322
+ if (moving && savedKey) entries.push([`${prefix}.api_key`, ""]);
323
+ entries.push([`${prefix}.provider`, draft.provider]);
324
+
325
+ // diffr clears a provider's endpoint when the provider changes, so a
326
+ // custom one kept across a switch is written again.
327
+ if (
328
+ draft.endpoint !== savedEndpoint(current.values) ||
329
+ (draft.endpoint && draft.provider !== savedProvider(current.values))
330
+ )
331
+ entries.push([`${prefix}.endpoint`, draft.endpoint]);
332
+
333
+ if (draft.apiKey) entries.push([`${prefix}.api_key`, draft.apiKey]);
334
+
335
+ // A blank prompt leaves diffr's own in place.
336
+ if (
337
+ draft.systemPrompt.trim() &&
338
+ draft.systemPrompt !== valueAt(current.values, `${prefix}.system_prompt`)
339
+ )
340
+ entries.push([`${prefix}.system_prompt`, draft.systemPrompt]);
341
+
342
+ entries.push(
343
+ [`${prefix}.model`, draft.model],
344
+ [`${prefix}.tests`, draft.tests],
345
+ );
346
+
347
+ if (draft.enabled) entries.push([`${prefix}.enabled`, true]);
348
+
349
+ return writeSettings(entries, rootPath);
350
+ });
351
+ }
352
+
353
+ export async function testDiffrSummarizer(
354
+ input: ReviewDiffrSummarizerInput,
355
+ rootPath?: string,
356
+ signal?: AbortSignal,
357
+ ): Promise<string> {
358
+ const parsed = reviewDiffrSummarizerInputSchema.safeParse(input);
359
+
360
+ if (!parsed.success) throw new Error("Invalid summary settings.");
361
+
362
+ if (testing) throw new Error("A summary test is already running.");
363
+ testing = true;
364
+ const timeout = AbortSignal.timeout(60_000);
365
+ const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout;
366
+ let directory: string | undefined;
367
+
368
+ try {
369
+ const config = await serialized(() => values(rootPath, true, deadline));
370
+ const saved = valueAt(config, prefix);
371
+
372
+ if (!isJsonObject(saved))
373
+ throw new Error("The summarizer is not available in this configuration.");
374
+
375
+ const { provider, endpoint, model, systemPrompt } = parsed.data;
376
+ const providers = (await summaryDefaults(rootPath)).providers ?? [];
377
+
378
+ const variables =
379
+ providers.find((known) => known.id === provider)?.keyVariables ?? [];
380
+
381
+ const apiKey =
382
+ parsed.data.apiKey ||
383
+ (!movesKey(config, parsed.data) && isStringValue(saved.api_key)
384
+ ? saved.api_key
385
+ : "") ||
386
+ environmentKey(providers, provider);
387
+
388
+ if (!apiKey && !keyOptional(providers, provider, endpoint))
389
+ throw new Error("Add an API key to test summaries.");
390
+ directory = await mkdtemp(join(tmpdir(), "review-summary-"));
391
+
392
+ const options: JsonObject = {
393
+ ...saved,
394
+ enabled: true,
395
+ provider,
396
+ model,
397
+ ...(systemPrompt.trim() && { system_prompt: systemPrompt }),
398
+ min_lines: 1,
399
+ retries: 0,
400
+ request_timeout_ms: 45_000,
401
+ };
402
+
403
+ delete options.api_key;
404
+ // Resolved for the saved provider; diffr derives the draft provider's.
405
+ delete options.provider_details;
406
+
407
+ if (endpoint) options.endpoint = endpoint;
408
+ else delete options.endpoint;
409
+ await mkdir(join(directory, "diffr"));
410
+ await writeFile(
411
+ join(directory, "diffr", "config.toml"),
412
+ stringify({
413
+ version: 1,
414
+ plugins: {
415
+ order: ["bundled.summarize"],
416
+ bundled: { summarize: options },
417
+ },
418
+ }),
419
+ { mode: 0o600 },
420
+ );
421
+
422
+ const before = join(directory, "before.rs"),
423
+ after = join(directory, "after.rs");
424
+
425
+ await writeFile(before, "");
426
+ await writeFile(after, SUMMARY_SAMPLE);
427
+
428
+ const output = await diffr(
429
+ [
430
+ "--no-index",
431
+ "--format",
432
+ "ndjson",
433
+ "--stream-annotations",
434
+ "--",
435
+ before,
436
+ after,
437
+ ],
438
+ directory,
439
+ {
440
+ env: {
441
+ ...process.env,
442
+ ...Object.fromEntries(
443
+ providers
444
+ .flatMap((known) => known.keyVariables)
445
+ .map((name) => [name, ""]),
446
+ ),
447
+ ...(apiKey && variables[0] && { [variables[0]]: apiKey }),
448
+ XDG_CONFIG_HOME: directory,
449
+ },
450
+ signal: deadline,
451
+ },
452
+ );
453
+
454
+ try {
455
+ const events = output.trim().split("\n").map(decodeStructuralDiffEvent);
456
+ const complete = events.at(-1);
457
+
458
+ const annotation = events
459
+ .flatMap((event) =>
460
+ event.type === "annotations" ? event.annotations : [],
461
+ )
462
+ .find((item) => item.label.trim());
463
+
464
+ if (
465
+ complete?.type === "complete" &&
466
+ complete.failed === 0 &&
467
+ !complete.aborted &&
468
+ !events.some(
469
+ (event) =>
470
+ (event.type === "annotations" || event.type === "file") &&
471
+ event.error,
472
+ ) &&
473
+ annotation
474
+ ) {
475
+ return apiKey
476
+ ? annotation.label.split(apiKey).join("[redacted]")
477
+ : annotation.label;
478
+ }
479
+ } catch {
480
+ /* Never return provider output or parser causes. */
481
+ }
482
+
483
+ throw new Error(
484
+ "No summary was produced. Check the API key, model, and network connection.",
485
+ );
486
+ } finally {
487
+ testing = false;
488
+
489
+ if (directory) await rm(directory, { recursive: true, force: true });
490
+ }
491
+ }
492
+
493
+ const SUMMARY_SAMPLE = `pub fn count_values(values: &[i32]) -> (i32, i32) {
494
+ let mut total = 0;
495
+ let mut count = 0;
496
+ for value in values {
497
+ if *value > 0 {
498
+ total += value;
499
+ count += 1;
500
+ }
501
+ }
502
+ let average = if count > 0 {
503
+ total / count
504
+ } else {
505
+ 0
506
+ };
507
+ (count, average)
508
+ }
509
+ `;
@@ -0,0 +1,97 @@
1
+ import { execFile } from "node:child_process";
2
+ import { lstatSync, readFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import { findReviewPackageRoot } from "@review/package-paths";
8
+ import { z } from "zod";
9
+
10
+ const exec = promisify(execFile);
11
+
12
+ const versionSchema = z.object({ version: z.string() });
13
+
14
+ const stampSchema = z.object({ version: z.string(), full: z.boolean() });
15
+
16
+ const pending = new Map<string, Promise<void>>();
17
+
18
+ function configuration(root: string) {
19
+ const installer = path.join(root, "bin/diffr-package/bin/fetch.mjs");
20
+
21
+ const { version } = versionSchema.parse(
22
+ JSON.parse(
23
+ readFileSync(path.join(root, "bin/diffr-package/package.json"), "utf8"),
24
+ ),
25
+ );
26
+
27
+ const directory = path.join(
28
+ process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"),
29
+ "diffr",
30
+ version,
31
+ `${process.platform}-${process.arch}`,
32
+ "full",
33
+ );
34
+
35
+ return {
36
+ installer,
37
+ version,
38
+ directory,
39
+ binary: path.join(
40
+ directory,
41
+ process.platform === "win32" ? "diffr.exe" : "diffr",
42
+ ),
43
+ };
44
+ }
45
+
46
+ export function installedFullDiffr(root: string): string | undefined {
47
+ try {
48
+ const { binary, directory, version } = configuration(root);
49
+ const stat = lstatSync(binary);
50
+
51
+ const stamp = stampSchema.parse(
52
+ JSON.parse(
53
+ readFileSync(path.join(directory, "diffr.stamp.json"), "utf8"),
54
+ ),
55
+ );
56
+
57
+ if (
58
+ stat.isFile() &&
59
+ (process.platform === "win32" || stat.mode & 0o111) &&
60
+ stamp.full &&
61
+ stamp.version === version
62
+ )
63
+ return binary;
64
+ } catch {}
65
+
66
+ return undefined;
67
+ }
68
+
69
+ export function installFullDiffr(
70
+ root = findReviewPackageRoot(import.meta.url),
71
+ ): Promise<void> {
72
+ if (process.env.REVIEW_DIFFR_BINARY)
73
+ return Promise.reject(
74
+ new Error(
75
+ "An explicitly selected diffr executable is in use. Install its full edition separately.",
76
+ ),
77
+ );
78
+ const { installer, directory } = configuration(root);
79
+ const current = pending.get(directory);
80
+
81
+ if (current) return current;
82
+
83
+ const task = exec(
84
+ process.execPath,
85
+ [installer, "--full", "--required", "--into", directory],
86
+ {
87
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
88
+ timeout: 180_000,
89
+ },
90
+ )
91
+ .then(() => {})
92
+ .finally(() => pending.delete(directory));
93
+
94
+ pending.set(directory, task);
95
+
96
+ return task;
97
+ }