@jxsuite/studio 3.0.0 → 4.1.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 (197) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  3. package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
  4. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  5. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  6. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  7. package/dist/chunks/content-source-48xg86wj.js +49 -0
  8. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
  9. package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
  10. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
  11. package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
  12. package/dist/chunks/files-3kfkwhk4.js +93 -0
  13. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  14. package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
  15. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  16. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
  17. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  18. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
  19. package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
  20. package/dist/chunks/layers-q75tj2mf.js +45 -0
  21. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  22. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  23. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
  24. package/dist/chunks/notify-yjm79n4s.js +30 -0
  25. package/dist/chunks/parse-24d0gay3.js +19 -0
  26. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  27. package/dist/chunks/publish-panel-v9st96ja.js +29 -0
  28. package/dist/chunks/quick-search-zccjp6gv.js +66 -0
  29. package/dist/chunks/references-3qx8fkyp.js +29 -0
  30. package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
  31. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
  32. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  33. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  34. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
  35. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  36. package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
  37. package/dist/chunks/studio-1ngbejft.js +181 -0
  38. package/dist/chunks/studio-1wagb550.js +118 -0
  39. package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
  40. package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
  41. package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
  42. package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
  43. package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
  44. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  45. package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
  46. package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
  47. package/dist/chunks/studio-6yw4y3my.js +91 -0
  48. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  49. package/dist/chunks/studio-92ab2jqe.js +282 -0
  50. package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
  51. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  52. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  53. package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
  54. package/dist/chunks/studio-b55y6ewx.js +498 -0
  55. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  56. package/dist/chunks/studio-cft9r4r7.js +119 -0
  57. package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
  58. package/dist/chunks/studio-dm19q43r.js +195 -0
  59. package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
  60. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  61. package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
  62. package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
  63. package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
  64. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  65. package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
  66. package/dist/chunks/studio-jc994dnr.js +1066 -0
  67. package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
  68. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  69. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  70. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  71. package/dist/chunks/studio-msd847zn.js +1315 -0
  72. package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
  73. package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
  74. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  75. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  76. package/dist/chunks/studio-q43a5y9h.js +394 -0
  77. package/dist/chunks/studio-q7v10cg2.js +106 -0
  78. package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
  79. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  80. package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
  81. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  82. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  83. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  84. package/dist/chunks/studio-t7qb9db8.js +665 -0
  85. package/dist/chunks/studio-tmc5emrw.js +8 -0
  86. package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
  87. package/dist/chunks/studio-wcy8trfx.js +263 -0
  88. package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
  89. package/dist/chunks/studio-x813zp48.js +195 -0
  90. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  91. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  92. package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
  93. package/dist/chunks/workspace-fjx52ryv.js +89 -0
  94. package/dist/iframe-entry.js +141 -56
  95. package/dist/manifest.json +7 -0
  96. package/dist/studio.js +2605 -1950
  97. package/favicon.ico +0 -0
  98. package/index.html +1 -0
  99. package/package.json +20 -18
  100. package/src/about/about-modal.ts +4 -3
  101. package/src/browse/library-pane.ts +57 -3
  102. package/src/canvas/canvas-render.ts +23 -1
  103. package/src/canvas/iframe-patch.ts +47 -21
  104. package/src/canvas/iframe-render.ts +22 -6
  105. package/src/commands/app-commands.ts +4 -0
  106. package/src/commands/command-args.ts +29 -0
  107. package/src/commands/context.ts +15 -1
  108. package/src/commands/defaults.ts +29 -1
  109. package/src/commands/levels.ts +16 -0
  110. package/src/commands/live-context.ts +3 -0
  111. package/src/content/collection-match.ts +267 -0
  112. package/src/content/entry-commands.ts +38 -29
  113. package/src/content/entry-fields.ts +6 -0
  114. package/src/editor/shortcuts.ts +5 -0
  115. package/src/files/file-ops.ts +10 -42
  116. package/src/files/files.ts +487 -59
  117. package/src/files/fs-events.ts +9 -0
  118. package/src/files/gitignore.ts +432 -0
  119. package/src/files/media-paths.ts +13 -42
  120. package/src/files/media-usage.ts +41 -131
  121. package/src/files/serialize-document.ts +60 -0
  122. package/src/format/convert-file.ts +511 -0
  123. package/src/format/format-choices.ts +199 -0
  124. package/src/grid/grid-view.ts +16 -2
  125. package/src/hosting/document.ts +2 -0
  126. package/src/hosting/layout.ts +8 -0
  127. package/src/media/media-open.ts +58 -0
  128. package/src/media/media-pane.ts +310 -0
  129. package/src/new-project/import-tab.ts +357 -163
  130. package/src/new-project/location-fields.ts +8 -5
  131. package/src/new-project/new-project-modal.ts +27 -34
  132. package/src/packages/ensure-deps.ts +14 -7
  133. package/src/packages/jxsuite-update.ts +11 -11
  134. package/src/page-params.ts +10 -46
  135. package/src/panels/activity-bar.ts +45 -16
  136. package/src/panels/ai-chat/attached-context.ts +6 -1
  137. package/src/panels/ai-chat/chat-view.ts +255 -13
  138. package/src/panels/ai-chat/composer.ts +31 -97
  139. package/src/panels/ai-panel.ts +116 -2
  140. package/src/panels/navigator-panels.ts +7 -6
  141. package/src/panels/settings-menu.ts +799 -0
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +261 -70
  144. package/src/platforms/devserver.ts +67 -33
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/publish/publish-panel.ts +62 -2
  147. package/src/services/ai-ask.ts +247 -0
  148. package/src/services/ai-import-tools.ts +532 -0
  149. package/src/services/ai-models.ts +108 -1
  150. package/src/services/ai-project-tools.ts +20 -26
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/connection.ts +74 -0
  154. package/src/services/context-manager.ts +101 -1
  155. package/src/services/document-assistant.ts +45 -4
  156. package/src/services/import-client.ts +33 -6
  157. package/src/services/import-run.ts +158 -0
  158. package/src/services/import-seed.ts +120 -0
  159. package/src/services/jx-validate.ts +58 -6
  160. package/src/services/project-adoption.ts +71 -0
  161. package/src/services/references.ts +0 -0
  162. package/src/services/settings/definitions.ts +17 -0
  163. package/src/services/tool-executor.ts +45 -2
  164. package/src/settings/contexts-section.ts +45 -3
  165. package/src/settings/dependencies-editor.ts +43 -23
  166. package/src/settings/general-settings.ts +30 -12
  167. package/src/settings/preferences-accounts.ts +193 -12
  168. package/src/settings/preferences-dialog.ts +93 -59
  169. package/src/settings/preferences-sections.ts +57 -0
  170. package/src/settings/settings-document.ts +65 -9
  171. package/src/studio.ts +33 -6
  172. package/src/tabs/transact.ts +42 -19
  173. package/src/types.ts +129 -7
  174. package/src/ui/ai-managed-connect.ts +62 -13
  175. package/src/ui/ai-model-picker.ts +189 -0
  176. package/src/ui/cf-account-picker.ts +200 -0
  177. package/src/ui/choice-field.ts +72 -0
  178. package/src/ui/layers.ts +83 -5
  179. package/src/utils/studio-utils.ts +22 -52
  180. package/styles/forced-colors.css +7 -0
  181. package/styles/overlays.css +256 -0
  182. package/styles/panels.css +13 -1
  183. package/styles/shell.css +147 -0
  184. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  185. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  186. package/dist/chunks/files-xcybanxe.js +0 -70
  187. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  188. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  189. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  190. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  191. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  192. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  193. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  194. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  195. package/dist/chunks/studio-bw5dz6km.js +0 -608
  196. package/dist/chunks/studio-c31yb4t6.js +0 -55
  197. package/src/canvas/site-style-css.ts +0 -117
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Ai-import-tools.ts — `import_site`, the assistant's other way to bootstrap a project.
3
+ *
4
+ * A sibling of `create_project` (`ai-project-tools.ts`) with a different backend: it drives whatever
5
+ * import pipeline the platform's `importSite` reaches — a local headless Chrome, or a hosted one —
6
+ * streams its progress into a record the transcript draws, and then adopts the result exactly as a
7
+ * scaffold is adopted. Its own module
8
+ * rather than an eighth tool in `ai-project-tools.ts`, which is already 650 lines under a per-file
9
+ * coverage bar.
10
+ *
11
+ * Two things it does NOT do are the point:
12
+ *
13
+ * - **It does not invent a destination.** `create_project` requires the model to supply `location`
14
+ * and refuses without one — correct when a bare chat bootstraps a project, a regression on the
15
+ * wizard path where the Location field already has a folder picker, validation and a live preview.
16
+ * The destination comes from the pending brief; a model-supplied one must match it.
17
+ * - **It does not decide what the crawl found.** The pipeline is one-way: it reports and finishes.
18
+ * Everything worth a human's opinion — which skipped pages matter, whether the components it
19
+ * extracted are really components — is in the summary for the model to raise with `ask_user`
20
+ * AFTERWARDS, against real numbers rather than a guess made before the browser launched.
21
+ *
22
+ * @license MIT
23
+ */
24
+
25
+ import { createToolDefinition, toolError, toolSuccess } from "@jxsuite/ai/tools";
26
+ import { getPlatform } from "../platform";
27
+ import { workspace } from "../workspace/workspace";
28
+ import { getBaseUrl, getOpenAiKey } from "./ai-settings";
29
+ import { preferredModel } from "./ai-models";
30
+ import { clearPendingImportBrief, pendingImportBrief } from "./import-seed";
31
+ import {
32
+ abortImportRun,
33
+ beginImportRun,
34
+ finishImportRun,
35
+ importRun,
36
+ recordImportProgress,
37
+ } from "./import-run";
38
+ import { adoptCreatedProject } from "./project-adoption";
39
+ import type { AdoptOutcome } from "./project-adoption";
40
+ import { currentToolCallId, turnSignal } from "./ai-turn-signal";
41
+
42
+ import type { ToolRegistry } from "@jxsuite/ai/tools";
43
+ import type { Tab } from "../tabs/tab";
44
+ import type { ImportBreakpointPolicy, ImportReadyEvent, ImportSiteSummary } from "../types";
45
+
46
+ /** The same bounds `packages/server/src/import-api.ts` clamps to. */
47
+ const MAX_DEPTH = 5;
48
+ const MAX_PAGES = 100;
49
+
50
+ /** Log lines quoted back on a hard failure — enough to see WHICH phase died. */
51
+ const FAILURE_TAIL = 5;
52
+
53
+ /** Low-fidelity pages named in the summary. A list of twenty is a wall, not a finding. */
54
+ const WEAKEST_PAGES = 3;
55
+
56
+ /** Below this, a page is worth naming. Above it, "close enough" is the honest reading. */
57
+ const WEAK_FIDELITY = 90;
58
+
59
+ /**
60
+ * The default bar an import is measured against, matching `jx-import --min-fidelity`.
61
+ *
62
+ * A floor for "this is not a clone of anything" rather than a quality target: a faithful import of
63
+ * a complicated site lands well under 100 for reasons no importer can fix.
64
+ */
65
+ const DEFAULT_MIN_FIDELITY = 25;
66
+ const MAX_FIDELITY = 100;
67
+
68
+ export interface ImportToolsCtx {
69
+ /** The active tab, read AFTER adoption to re-anchor the agent loop's undo batch. */
70
+ getTab: () => Tab | null;
71
+ /** Open the imported project in this window. Defaults to the late-bound adopter. */
72
+ adoptProject?: (root: string) => Promise<void>;
73
+ /** Fired once adoption is VERIFIED — the session store re-keys the live chat here. */
74
+ onProjectAdopted?: (root: string) => void;
75
+ }
76
+
77
+ function clamp(value: number, min: number, max: number): number {
78
+ return Math.min(max, Math.max(min, Math.trunc(value)));
79
+ }
80
+
81
+ /**
82
+ * The breakpoint policy for this run: what the model asked for, else what the wizard collected.
83
+ *
84
+ * The model may narrow this — it is the one option a person is likely to have an opinion about
85
+ * after seeing the crawl — but it never invents an override the user did not ask for, because the
86
+ * wizard's control is already the answer to "how many breakpoints should this project have".
87
+ *
88
+ * @param {object} args - The model's `maxBreakpoints` / `breakpointWidths` / `breakpointRounding`
89
+ * @param {ImportBreakpointPolicy | undefined} fromBrief - What the wizard collected, if anything
90
+ * @returns {ImportBreakpointPolicy | undefined}
91
+ */
92
+ function breakpointPolicyFor(
93
+ args: { maxBreakpoints?: unknown; widths?: unknown; rounding?: unknown },
94
+ fromBrief: ImportBreakpointPolicy | undefined,
95
+ ): ImportBreakpointPolicy | undefined {
96
+ const rounding =
97
+ args.rounding === "down" || args.rounding === "up" || args.rounding === "nearest"
98
+ ? args.rounding
99
+ : (fromBrief?.rounding ?? "nearest");
100
+
101
+ if (Array.isArray(args.widths) && args.widths.length > 0) {
102
+ return { mode: "explicit", rounding, widths: args.widths.map(Number) };
103
+ }
104
+ if (typeof args.maxBreakpoints === "number" && Number.isFinite(args.maxBreakpoints)) {
105
+ // Zero is the only spelling a count has for "keep all", and it is worth accepting: it is what
106
+ // A model reaches for when a person says "keep them all".
107
+ return args.maxBreakpoints <= 0
108
+ ? { mode: "all" }
109
+ : { count: Math.trunc(args.maxBreakpoints), mode: "limit", rounding };
110
+ }
111
+ return fromBrief;
112
+ }
113
+
114
+ /** Absolute on POSIX or Windows — the test `create_project` already applies to its `location`. */
115
+ function isAbsolutePath(path: string): boolean {
116
+ return /^(?:[a-zA-Z]:[/\\]|\/)/.test(path);
117
+ }
118
+
119
+ /**
120
+ * `owner/repo`, conservatively — the destination a `createDestination: "repo"` platform writes to.
121
+ *
122
+ * Narrower than GitHub's own rules on purpose: this guards a string the MODEL may supply, and the
123
+ * cost of refusing an exotic-but-legal name is one clarifying question, while the cost of accepting
124
+ * a path-shaped or query-shaped one is a request the backend has to defend against.
125
+ */
126
+ const REPO_DESTINATION = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
127
+
128
+ /**
129
+ * Whether `destination` is a shape THIS platform can write to, or the sentence saying why not.
130
+ *
131
+ * The platform's `createDestination` decides, not the string's own look: a hosted backend creates a
132
+ * GitHub repository and has no filesystem to hold an absolute path, and a desktop backend has no
133
+ * repository to create. Answering by inspecting the string would accept `owner/repo` on desktop,
134
+ * where it silently means a relative directory nobody named.
135
+ *
136
+ * @param {string} destination - The resolved destination, from the model or the wizard's brief
137
+ * @returns {string | null} The refusal, or null when it is usable
138
+ */
139
+ function destinationProblem(destination: string): string | null {
140
+ if (getPlatform().createDestination === "repo") {
141
+ if (!REPO_DESTINATION.test(destination)) {
142
+ return `"directory" must name a repository as "owner/repo": ${destination}`;
143
+ }
144
+ } else if (!isAbsolutePath(destination)) {
145
+ return `"directory" must be an absolute path: ${destination}`;
146
+ }
147
+ if (destination.split(/[/\\]/).includes("..")) {
148
+ return `"directory" must not contain "..": ${destination}`;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * One run has already succeeded in this session.
155
+ *
156
+ * Not the same question as `workspace.projectRoot`, and that is the whole reason it exists: on
157
+ * desktop, adoption may open the project in ANOTHER window, leaving this one's root empty — so the
158
+ * `no-project` tier would happily advertise a second import over the top of the first.
159
+ * `create_project` has the identical hazard today.
160
+ */
161
+ let imported = false;
162
+
163
+ /** Forget that an import ran. New Chat, and tests. */
164
+ export function resetImportGuard(): void {
165
+ imported = false;
166
+ }
167
+
168
+ /**
169
+ * A compact, factual account of the run for the model to reason about — and to ask about.
170
+ *
171
+ * Counts only where they are real. `streamImport` returns `{ root, config }` and nothing else, so
172
+ * the page and file totals are not available here; inventing them from log prose is exactly how a
173
+ * confidently wrong number gets into a summary. It says what it knows and points at the tool that
174
+ * knows the rest.
175
+ */
176
+ function describeRun(id: string, root: string, summary?: ImportSiteSummary): string {
177
+ const record = importRun(id);
178
+ const warnings = [...(record?.warnings ?? []), ...(summary?.warnings ?? [])];
179
+ const lines = [`Imported ${record?.url ?? "the site"} into ${root} and opened it.`];
180
+
181
+ const pages = summary?.pages ?? [];
182
+ if (pages.length > 0) {
183
+ lines.push(
184
+ `${pages.length} page${pages.length === 1 ? "" : "s"}` +
185
+ `${summary?.fileCount ? `, ${summary.fileCount} files` : ""}: ` +
186
+ `${pages.map((p) => p.route).join(", ")}.`,
187
+ );
188
+ }
189
+
190
+ /* Per page, because the average cannot name one. "84% average" is a fact nobody can act on;
191
+ "the pricing page renders at 61%" is a decision — and it is the one finding here worth
192
+ interrupting a person for. */
193
+ if (summary?.verify) {
194
+ const weakest = (summary.verify.pages ?? [])
195
+ .filter((p) => p.fidelity < WEAK_FIDELITY)
196
+ .toSorted((a, b) => a.fidelity - b.fidelity)
197
+ .slice(0, WEAKEST_PAGES);
198
+ const detail =
199
+ weakest.length > 0
200
+ ? `; weakest: ${weakest.map((p) => `${p.route} at ${p.fidelity}%`).join(", ")}.`
201
+ : " on every page.";
202
+ lines.push(
203
+ `Fidelity against the original averaged ${summary.verify.averageFidelity}%${detail}`,
204
+ );
205
+ /*
206
+ * The bar the user set, and whether this run cleared it. Stated FIRST among the details,
207
+ * because it is the difference between a number to note and a result to act on: an import at
208
+ * 8% used to report exactly like one at 95%, and the only way to tell them apart was to open
209
+ * the project and look.
210
+ */
211
+ if (summary.verify.passed === false && (summary.verify.buildErrors ?? []).length === 0) {
212
+ lines.push(
213
+ `That is below the ${summary.verify.minFidelity ?? 0}% minimum this import was asked ` +
214
+ "for, so the clone does not match the original closely enough. Say so plainly, and " +
215
+ "offer to look at what went wrong rather than moving on.",
216
+ );
217
+ }
218
+ /*
219
+ * The score says a page looks wrong; this says why. A page that 404s on fifteen asset
220
+ * references scores badly for one fixable reason, and reading a percentage on its own sends
221
+ * the reader looking for the wrong thing entirely (jxsuite/jx issue 232).
222
+ */
223
+ const missing = (summary.verify.pages ?? []).reduce(
224
+ (sum, page) => sum + (page.failedRequests ?? 0),
225
+ 0,
226
+ );
227
+ if (missing > 0) {
228
+ lines.push(
229
+ `${missing} request${missing === 1 ? "" : "s"} failed or 404'd in the rendered pages — ` +
230
+ "check the emitted asset paths before treating a low score as a layout problem.",
231
+ );
232
+ }
233
+ for (const error of summary.verify.buildErrors ?? []) {
234
+ lines.push(`The emitted project did not build cleanly: ${error}`);
235
+ }
236
+ }
237
+
238
+ if (warnings.length > 0) {
239
+ lines.push(
240
+ `${warnings.length} warning${warnings.length === 1 ? "" : "s"} during the run: ` +
241
+ `${warnings.join("; ")}.`,
242
+ );
243
+ }
244
+ lines.push(
245
+ "The file and document tools are available now. Read the emitted pages, layouts and " +
246
+ "components before proposing changes — then ask the user about anything the import had to " +
247
+ "guess at (pages it skipped, components it may have split wrongly, a layout it did not find, " +
248
+ "a page that did not render faithfully).",
249
+ );
250
+ return lines.join(" ");
251
+ }
252
+
253
+ /**
254
+ * Register `import_site`.
255
+ *
256
+ * @param {Pick<ToolRegistry, "register">} registry
257
+ * @param {ImportToolsCtx} ctx
258
+ */
259
+ export function registerImportTools(
260
+ registry: Pick<ToolRegistry, "register">,
261
+ { getTab, adoptProject, onProjectAdopted }: ImportToolsCtx,
262
+ ): void {
263
+ registry.register(
264
+ createToolDefinition({
265
+ name: "import_site",
266
+ description:
267
+ "Clone a live website into a new Jx project and open it in the studio: pages, styles, " +
268
+ "assets, a shared layout and recurring components. Only available while no project is " +
269
+ "open. Progress streams into the conversation as it runs. When the user reached you " +
270
+ "through the New Project Import form, the URL, crawl options and destination are already " +
271
+ "settled — call this with the url alone and the rest is filled in. After it succeeds the " +
272
+ "file and document tools become available in the next round.",
273
+ parameters: {
274
+ type: "object",
275
+ properties: {
276
+ url: { type: "string", description: "The site to clone. Must be http(s)." },
277
+ directory: {
278
+ type: "string",
279
+ description:
280
+ "Where the project goes, in whichever form this studio's backend uses: an absolute " +
281
+ 'folder on a filesystem host, or "owner/repo" on a host where a project IS a ' +
282
+ "repository. Omit it when an import was started from the New Project form — that " +
283
+ "destination is the one the user chose, and it wins.",
284
+ },
285
+ depth: {
286
+ type: "number",
287
+ description:
288
+ "Crawl depth. 0 imports the single page and runs an entirely different, much faster " +
289
+ "pipeline — no crawl, no shared-layout detection. Default 1.",
290
+ },
291
+ maxPages: { type: "number", description: "Most pages to capture (1–100). Default 20." },
292
+ verify: {
293
+ type: "boolean",
294
+ description:
295
+ "Build the imported project and screenshot-diff every page against the original, " +
296
+ "reporting a per-page fidelity score. Roughly doubles the run, and is the only " +
297
+ "finding that says how WELL the clone came out rather than what was skipped.",
298
+ },
299
+ minFidelity: {
300
+ type: "number",
301
+ description:
302
+ "With verify on, the average fidelity (0-100) the clone must reach to count as " +
303
+ "matching the original. A floor rather than a target: a faithful import of a " +
304
+ "complicated site lands well under 100, so raise it only when the user asks for a " +
305
+ "stricter bar. 0 reports the score without judging it. Default 25.",
306
+ },
307
+ aiComponents: {
308
+ type: "boolean",
309
+ description:
310
+ "Refine component and prop names with the model. Costs one model call per extracted " +
311
+ "component, so it is worth asking about on a wide crawl. Default true.",
312
+ },
313
+ maxBreakpoints: {
314
+ type: "number",
315
+ description:
316
+ "How many of the site's declared breakpoints to keep, evenly spaced across its " +
317
+ "range (1-12). A real site declares as many as it has accumulated frameworks, and " +
318
+ "each one is a canvas size and a column in every style editor. Default 3; pass 0 to " +
319
+ "keep every one. Ignored when breakpointWidths is given.",
320
+ },
321
+ breakpointWidths: {
322
+ type: "array",
323
+ items: { type: "number" },
324
+ description:
325
+ "Keep these widths instead of a count, e.g. [640, 1024, 1440]. Each is backed by " +
326
+ "the declared width nearest it, because the styles flip where the site says they " +
327
+ "do. Ask the user before choosing widths they did not name.",
328
+ },
329
+ breakpointRounding: {
330
+ type: "string",
331
+ enum: ["nearest", "down", "up"],
332
+ description: "How a kept width matches a declared one. Default nearest.",
333
+ },
334
+ },
335
+ required: ["url"],
336
+ },
337
+ async execute(args) {
338
+ const {
339
+ url,
340
+ directory,
341
+ depth,
342
+ maxPages,
343
+ aiComponents,
344
+ verify,
345
+ minFidelity,
346
+ maxBreakpoints,
347
+ breakpointWidths,
348
+ breakpointRounding,
349
+ } = args as {
350
+ url?: unknown;
351
+ directory?: unknown;
352
+ depth?: unknown;
353
+ maxPages?: unknown;
354
+ aiComponents?: unknown;
355
+ verify?: unknown;
356
+ minFidelity?: unknown;
357
+ maxBreakpoints?: unknown;
358
+ breakpointWidths?: unknown;
359
+ breakpointRounding?: unknown;
360
+ };
361
+
362
+ if (workspace.projectRoot) {
363
+ return toolError(
364
+ "A project is already open in this window — import_site is only for bootstrapping.",
365
+ );
366
+ }
367
+ if (imported) {
368
+ return toolError(
369
+ "A site has already been imported in this conversation. Start a new chat to import " +
370
+ "another, or use the file tools on the project that was created.",
371
+ );
372
+ }
373
+ const platform = getPlatform();
374
+ if (!platform.importSite) {
375
+ return toolError("This Studio platform has no site-import backend.");
376
+ }
377
+
378
+ let target: URL;
379
+ try {
380
+ target = new URL(String(url ?? "").trim());
381
+ } catch {
382
+ return toolError('Pass "url" — an absolute http(s) address, e.g. https://example.com.');
383
+ }
384
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
385
+ return toolError("The url must start with http:// or https://.");
386
+ }
387
+
388
+ const brief = pendingImportBrief();
389
+ const requested = typeof directory === "string" ? directory.trim() : "";
390
+ if (requested && brief && requested !== brief.directory) {
391
+ /* The wizard's Location field is the user's answer to "where does this go". A model that
392
+ disagrees with it is guessing at a decision that was already made in front of them. */
393
+ return toolError(
394
+ `The destination is already set to ${brief.directory} — call import_site without ` +
395
+ '"directory", or ask the user before changing it.',
396
+ );
397
+ }
398
+ const destination = requested || brief?.directory || "";
399
+ if (!destination) {
400
+ return toolError(
401
+ platform.createDestination === "repo"
402
+ ? 'Pass "directory" — the "owner/repo" to create the project as. Ask the user which ' +
403
+ "account and repository name rather than guessing."
404
+ : 'Pass "directory" — the absolute folder to create the project in. Ask the user ' +
405
+ "where the project should live rather than guessing.",
406
+ );
407
+ }
408
+ const problem = destinationProblem(destination);
409
+ if (problem) {
410
+ return toolError(problem);
411
+ }
412
+
413
+ const id = currentToolCallId();
414
+ const signal = beginImportRun(id, { directory: destination, url: target.href });
415
+ /* The turn's own signal too. `assistant.stop` aborts the request through `abortImportRun`,
416
+ but that is not the only way a turn ends: the loop's signal can be aborted directly, and
417
+ a run left going after its turn died would keep a headless browser open for minutes with
418
+ nothing left to receive the result. */
419
+ turnSignal()?.addEventListener("abort", () => {
420
+ abortImportRun();
421
+ finishImportRun(id, { status: "stopped" });
422
+ });
423
+
424
+ const apiKey = getOpenAiKey();
425
+ const baseUrl = getBaseUrl();
426
+ const model = brief?.model || preferredModel();
427
+
428
+ const breakpoints = breakpointPolicyFor(
429
+ { maxBreakpoints, rounding: breakpointRounding, widths: breakpointWidths },
430
+ brief?.breakpoints,
431
+ );
432
+
433
+ /*
434
+ * Adoption happens the moment the destination is a project, not when the run ends.
435
+ *
436
+ * A crawl takes minutes, and it used to spend all of them with the author on the welcome
437
+ * screen — the tool opened the project once `importSite` resolved, so the one view of what
438
+ * the import was doing was a log in the sidebar. Opening at `ready` puts them IN the project
439
+ * while it fills: the Files tree gets each page, component and asset as the pipeline writes
440
+ * it, through the watcher that is already running.
441
+ *
442
+ * It runs at most once (a backend may send the line once, and only one can be first), and a
443
+ * failure here is not the run's failure: the import continues either way, and the terminal
444
+ * path below reports honestly on whichever state the window ended up in.
445
+ */
446
+ let adoption: Promise<AdoptOutcome> | null = null;
447
+ const onReady = ({ root }: ImportReadyEvent) => {
448
+ adoption ??= adoptCreatedProject(root, {
449
+ getTab,
450
+ ...(adoptProject ? { adopt: adoptProject } : {}),
451
+ });
452
+ };
453
+
454
+ let result: { root: string; result?: ImportSiteSummary };
455
+ try {
456
+ result = await platform.importSite(
457
+ {
458
+ aiComponents:
459
+ typeof aiComponents === "boolean" ? aiComponents : (brief?.aiComponents ?? true),
460
+ depth: clamp(Number(depth ?? brief?.depth ?? 1) || 0, 0, MAX_DEPTH),
461
+ directory: destination,
462
+ maxPages: clamp(Number(maxPages ?? brief?.maxPages ?? 20) || 1, 1, MAX_PAGES),
463
+ name: brief?.name || target.hostname.replace(/^www\./, ""),
464
+ url: target.href,
465
+ ...(breakpoints === undefined ? {} : { breakpoints }),
466
+ ...(typeof verify === "boolean"
467
+ ? { verify }
468
+ : brief?.verify === undefined
469
+ ? {}
470
+ : { verify: brief.verify }),
471
+ /* The wizard's number is the user's own answer to "how close is close enough"; the
472
+ model may only override it when it was told to. */
473
+ verifyMinFidelity: clamp(
474
+ Number(minFidelity ?? brief?.minFidelity ?? DEFAULT_MIN_FIDELITY) || 0,
475
+ 0,
476
+ MAX_FIDELITY,
477
+ ),
478
+ ...(apiKey ? { apiKey } : {}),
479
+ ...(baseUrl ? { baseUrl } : {}),
480
+ ...(model ? { model } : {}),
481
+ },
482
+ (evt) => recordImportProgress(id, evt),
483
+ signal,
484
+ onReady,
485
+ );
486
+ } catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ if (signal.aborted) {
489
+ finishImportRun(id, { error: message, status: "stopped" });
490
+ return toolError("The import was stopped.");
491
+ }
492
+ finishImportRun(id, { error: message, status: "failed" });
493
+ /* The tail, not just the message: the commonest recovery is retrying at depth 0, which is
494
+ an entirely different code path, and the phase that died is what says whether that
495
+ would help. */
496
+ const tail = (importRun(id)?.log ?? [])
497
+ .slice(-FAILURE_TAIL)
498
+ .map((e) => `[${e.phase}] ${e.message}`)
499
+ .join("\n");
500
+ return toolError(
501
+ `The import failed: ${message}${tail ? `\n\nLast steps:\n${tail}` : ""}`,
502
+ );
503
+ }
504
+
505
+ finishImportRun(id, { status: "done" });
506
+ imported = true;
507
+ clearPendingImportBrief();
508
+
509
+ /* The early adoption if there was one, otherwise adopt now: a backend that sends no `ready`
510
+ line is not broken, it is older, and the project must still open. */
511
+ const { adopted, error: adoptionError } = await (adoption ??
512
+ adoptCreatedProject(result.root, {
513
+ getTab,
514
+ ...(adoptProject ? { adopt: adoptProject } : {}),
515
+ }));
516
+ if (adopted) {
517
+ onProjectAdopted?.(result.root);
518
+ return toolSuccess(
519
+ { root: result.root, ...(result.result ? { summary: result.result } : {}) },
520
+ describeRun(id, result.root, result.result),
521
+ );
522
+ }
523
+ return toolSuccess(
524
+ { root: result.root },
525
+ `Imported the site into ${result.root}, but it was not opened in this window` +
526
+ `${adoptionError ? ` (${adoptionError})` : " (it may have opened in another window)"}. ` +
527
+ "Ask the user to open it from the welcome screen or recent projects.",
528
+ );
529
+ },
530
+ }),
531
+ );
532
+ }
@@ -20,6 +20,16 @@ import { onSettingsChanged } from "./settings/kernel";
20
20
  export interface AiModel {
21
21
  id: string;
22
22
  name: string;
23
+ /**
24
+ * Whether the backend says the model can call tools.
25
+ *
26
+ * Absent when it did not say, which is the ordinary case for a BYOK provider — only a managed
27
+ * catalogue knows. `false` is therefore a statement and `undefined` is silence, and no gate may
28
+ * collapse the two: a Workers AI chat-only model must be labelled, an OpenAI model must not.
29
+ */
30
+ toolSupport?: boolean;
31
+ /** The backend's declared context window in tokens, when it declares one. */
32
+ contextWindow?: number;
23
33
  }
24
34
 
25
35
  /**
@@ -74,6 +84,23 @@ let proxyCode: AiModelsResponse["code"];
74
84
  let proxyProbe: Promise<void> | null = null;
75
85
  const probeListeners = new Set<() => void>();
76
86
 
87
+ /**
88
+ * How long a settled probe is trusted before a window refocus re-runs it.
89
+ *
90
+ * The probe is one-shot by construction (`proxyProbe ??=`) and the only thing that re-arms it is
91
+ * the settings subscription below — which never fires for a HOSTED grant, because nothing about it
92
+ * is stored in this browser. So a Cloudflare grant that lapsed mid-session left every gate showing
93
+ * the reading it took at boot: the assistant reported itself configured, and every send failed. Ten
94
+ * minutes is well inside the hour a Cloudflare access token lives.
95
+ */
96
+ export const PROBE_STALE_MS = 600_000;
97
+
98
+ /** When the probe last settled (0 = never). The age {@link PROBE_STALE_MS} is measured against. */
99
+ let probeSettledAt = 0;
100
+
101
+ /** Whether {@link installProbeRefresh} has already registered its listener. */
102
+ let probeRefreshInstalled = false;
103
+
77
104
  /**
78
105
  * Drop the cached list and the capability reading.
79
106
  *
@@ -93,6 +120,7 @@ export function resetModelCache() {
93
120
  would strand every gate on a permanent "unconfigured, unmanaged" reading — ensureProxyProbe
94
121
  would no-op forever and the managed option would vanish until a full reload. */
95
122
  proxyProbe = null;
123
+ probeSettledAt = 0;
96
124
  }
97
125
 
98
126
  /** The keys whose value changes what a provider would answer with. */
@@ -117,6 +145,33 @@ export function cachedModels(credentials: AiCredentials): AiModel[] | null {
117
145
  return cache && cacheKey === fingerprint(credentials) ? cache : null;
118
146
  }
119
147
 
148
+ /**
149
+ * A model's entry in whatever list is cached, by id.
150
+ *
151
+ * Deliberately NOT keyed by credentials, unlike {@link cachedModels}. That key exists because
152
+ * SHOWING one provider's catalogue under another's credentials is a lie about what is available;
153
+ * asking "what did the backend say about this id" is not — the answer is either recorded or it is
154
+ * not, and a mismatched key just yields `undefined`, which every caller already has to handle.
155
+ */
156
+ function cachedModel(id: string): AiModel | undefined {
157
+ return cache?.find((model) => model.id === id);
158
+ }
159
+
160
+ /**
161
+ * Whether the backend said this model can call tools — `undefined` when it said nothing.
162
+ *
163
+ * The distinction is the whole point: a chat-only model is a legitimate choice, so this labels and
164
+ * warns rather than gating, and silence must read as "no opinion" rather than as "no tools".
165
+ */
166
+ export function modelToolSupport(id: string): boolean | undefined {
167
+ return cachedModel(id)?.toolSupport;
168
+ }
169
+
170
+ /** The backend's declared context window for a model, in tokens — `undefined` when it declared none. */
171
+ export function modelContextWindow(id: string): number | undefined {
172
+ return cachedModel(id)?.contextWindow;
173
+ }
174
+
120
175
  /**
121
176
  * Run the capability probe once, repainting `onSettled` when it lands. Idempotent: concurrent and
122
177
  * repeated callers share the single in-flight fetch, and every registered host is notified. Probe
@@ -133,12 +188,50 @@ export function ensureProxyProbe(onSettled?: () => void) {
133
188
  // Unreachable proxy — the key gate stays up.
134
189
  })
135
190
  .then(() => {
191
+ probeSettledAt = Date.now();
136
192
  for (const notify of probeListeners) {
137
193
  notify();
138
194
  }
139
195
  });
140
196
  }
141
197
 
198
+ /**
199
+ * Re-run the capability probe when it is older than {@link PROBE_STALE_MS} and the backend holds
200
+ * the credentials. Returns whether it re-armed, so a caller (and a test) can see that it did.
201
+ *
202
+ * Managed-only on purpose. A BYOK reading changes only when the stored key does, and the settings
203
+ * subscription already covers that; a hosted grant expires on a clock this browser cannot see.
204
+ */
205
+ export function refreshStaleProbe(): boolean {
206
+ if (!proxyManaged || probeSettledAt === 0 || Date.now() - probeSettledAt < PROBE_STALE_MS) {
207
+ return false;
208
+ }
209
+ proxyProbe = null;
210
+ probeSettledAt = 0;
211
+ ensureProxyProbe();
212
+ return true;
213
+ }
214
+
215
+ /**
216
+ * Re-check the probe whenever the window regains focus, which is where a lapsed grant is noticed.
217
+ *
218
+ * `focus` rather than a timer: the reading only matters when somebody is looking at Studio, and the
219
+ * OAuth reconnect the stale reading would have hidden happens in another tab — so coming back IS
220
+ * the event. Idempotent, and a no-op outside a browser so the module stays importable under a
221
+ * bare-`bun` runner.
222
+ */
223
+ export function installProbeRefresh(): void {
224
+ if (typeof window === "undefined" || probeRefreshInstalled) {
225
+ return;
226
+ }
227
+ probeRefreshInstalled = true;
228
+ window.addEventListener("focus", () => {
229
+ refreshStaleProbe();
230
+ });
231
+ }
232
+
233
+ installProbeRefresh();
234
+
142
235
  /**
143
236
  * Whether the assistant can run at all: a key stored in this browser, or a backend that holds
144
237
  * credentials itself (managed cloud platforms, env-keyed dev servers). Every credentials gate must
@@ -263,7 +356,21 @@ export async function fetchAvailableModels(
263
356
  proxyManaged = data.managed === true;
264
357
  proxyDefaultModel = data.defaultModel ?? "";
265
358
  proxyCode = data.code;
266
- cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
359
+ /* Capabilities are kept, not dropped. The backend has reported `toolSupport` all along and the
360
+ ingest mapped `{id, name}` only, so a Workers AI model that cannot call tools looked exactly
361
+ like one that can — the agent loop ran, called nothing, and answered as if that were normal.
362
+ The keys are OMITTED rather than set to undefined: absent means "the backend said nothing",
363
+ which is a third state both consumers depend on. */
364
+ cache = (data.models || []).map((m) => {
365
+ const model: AiModel = { id: m.id, name: m.name || m.id };
366
+ if (typeof m.contextWindow === "number") {
367
+ model.contextWindow = m.contextWindow;
368
+ }
369
+ if (typeof m.toolSupport === "boolean") {
370
+ model.toolSupport = m.toolSupport;
371
+ }
372
+ return model;
373
+ });
267
374
  cacheKey = key;
268
375
  return cache;
269
376
  }