mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -0,0 +1,485 @@
1
+ // GENERATED-ADJACENT source module — part of the src/web/src/* split (plan
2
+ // §6.6 "the app-aware assistant", §9 Phase 3, beat matrix Axis 12). Built
3
+ // into src/web/app.js by scripts/build-web.ts.
4
+ //
5
+ // Three pieces, mirroring the proven PortfolioManager reference
6
+ // (client/src/lib/{ui-snapshot,spotlight,resolve-spotlight}.ts, read in
7
+ // full before writing this file):
8
+ //
9
+ // 1. captureUiSnapshot() — a structured DOM snapshot (visible interactive
10
+ // elements + labeled regions), capped at ~120, agent chrome excluded.
11
+ // Sent to the server as a `context` ClientMessage on route/step change
12
+ // (see chat.ts) and returned by the `get_current_app_context` tool.
13
+ // 2. resolveSpotlightTarget() — turns a spotlight_ui tool's loose
14
+ // {ref|label|selector|target} request into a concrete element, in
15
+ // exactly PortfolioManager's resolution order (ref from the last
16
+ // snapshot -> live selector -> label fuzzy-match -> catalog target).
17
+ // 3. showSpotlight()/dismissSpotlight() — the hand-rolled, NEVER-HIJACK
18
+ // overlay (§6.6 hard constraint, verbatim): traps no focus, blocks no
19
+ // clicks (pointer-events: none on the whole overlay — the highlighted
20
+ // control stays clickable THROUGH it), auto-dismisses ~3s, and ANY user
21
+ // input (keydown/mousedown/wheel) dismisses it instantly. transform/
22
+ // opacity animations only, prefers-reduced-motion honored. This is
23
+ // deliberately NOT wired into shell.ts's closeTopOverlay/trapFocus
24
+ // machinery — those are for real modals; this overlay must never behave
25
+ // like one.
26
+
27
+ import { getSpotlightTarget, isViewId, ROUTE_LABELS, type RouteId, type ViewId } from "./ui-catalog";
28
+
29
+ /* ────────────────────────────────────────────────────────────────────
30
+ 1. SEE — the structured DOM snapshot
31
+ ──────────────────────────────────────────────────────────────────── */
32
+
33
+ export interface UiSnapshotElement {
34
+ ref: string;
35
+ tag: string;
36
+ label: string;
37
+ kind: "interactive" | "region";
38
+ role?: string;
39
+ selector: string;
40
+ spotlightId?: string;
41
+ }
42
+
43
+ export interface UiSnapshot {
44
+ route: string;
45
+ capturedAt: string;
46
+ elements: UiSnapshotElement[];
47
+ }
48
+
49
+ /** Process-state fields layered onto the snapshot (§6.6 "the snapshot
50
+ * carries process state, not just elements") — wizard step for the
51
+ * quantize/finetune/dataset wizards, when the current route has one.
52
+ * Derived by reading the DOM the step indicator already renders
53
+ * (markdown.ts's renderSteps into #q-steps/#f-steps/#d-steps + the
54
+ * [data-qstep]/[data-fstep]/[data-dstep] visibility toggle quantize.ts/
55
+ * finetune.ts/dataset.ts already do) rather than adding a callback hook to
56
+ * those three controllers — zero behavior change to files outside this
57
+ * wave's scope, and the DOM IS the source of truth those controllers
58
+ * already write to. */
59
+ export interface WizardStep {
60
+ index: number;
61
+ count: number;
62
+ label: string;
63
+ }
64
+
65
+ const STEP_CONTAINER_BY_ROUTE: Partial<Record<RouteId, string>> = {
66
+ quantize: "q-steps",
67
+ finetune: "f-steps",
68
+ dataset: "d-steps",
69
+ };
70
+
71
+ export function currentWizardStep(route: string): WizardStep | null {
72
+ const containerId = STEP_CONTAINER_BY_ROUTE[route as RouteId];
73
+ if (!containerId) return null;
74
+ const container = document.getElementById(containerId);
75
+ if (!container) return null;
76
+ const spans = [...container.querySelectorAll(".s")];
77
+ if (spans.length === 0) return null;
78
+ const curIdx = spans.findIndex((s) => s.classList.contains("cur"));
79
+ const cur = spans[curIdx] ?? spans[0]!;
80
+ const label = cur.querySelector(".n")
81
+ ? (cur.textContent || "").replace(/^\d+/, "").trim()
82
+ : (cur.textContent || "").trim();
83
+ return { index: curIdx < 0 ? 0 : curIdx, count: spans.length, label };
84
+ }
85
+
86
+ const INTERACTIVE_SELECTOR = [
87
+ "button:not([disabled])",
88
+ "a[href]",
89
+ 'input:not([type="hidden"]):not([disabled])',
90
+ "select:not([disabled])",
91
+ "textarea:not([disabled])",
92
+ '[role="button"]',
93
+ "[data-spotlight]",
94
+ ].join(", ");
95
+
96
+ const MAX_ELEMENTS = 120;
97
+
98
+ /** Chrome/content the snapshot must never include: toasts, the assistant's
99
+ * own spotlight overlay (`data-ui-chrome="assistant"`), rendered
100
+ * conversation content (`data-ui-chrome="content"` — chat-thread and the
101
+ * memory/adapters/hub panel bodies, see the marker sites in chat.ts/
102
+ * memory-panel.ts/adapters-panel.ts/hub.ts), and any overlay currently
103
+ * closed (display:none via its .open class gate — see isVisible's
104
+ * ancestor walk below).
105
+ *
106
+ * The `data-ui-chrome="content"` exclusion matters beyond tidiness: a
107
+ * rendered assistant/user message can contain a real `<a href>` (a
108
+ * citation link, a markdown link from RAG'd/tool/web content, or just an
109
+ * ordinary reply). INTERACTIVE_SELECTOR matches `a[href]`, and this
110
+ * snapshot's whole point is to be re-served to the model verbatim as
111
+ * trusted "current UI state" via get_current_app_context — without this
112
+ * exclusion, an attacker-influenced link's visible text becomes a second,
113
+ * less-obvious prompt-injection channel alongside the visible transcript
114
+ * the model already sees. Any element inside a `data-ui-chrome="content"`
115
+ * root is excluded regardless of nesting depth (`.closest`), matching how
116
+ * the "assistant" chrome exclusion already works. */
117
+ function isAgentChrome(el: Element): boolean {
118
+ return !!el.closest("[data-ui-chrome]") || !!el.closest("#toasts");
119
+ }
120
+
121
+ /** True when `el` itself is hidden by inline style or the `hidden`
122
+ * attribute — deliberately NOT full computed-style resolution (a real
123
+ * browser's CSS-class-driven `display:none` — e.g. `#mem-overlay{display:
124
+ * none} .open{display:flex}` — only resolves through getComputedStyle,
125
+ * which happy-dom's test environment can't lay out or cascade through
126
+ * <style> rules). Real browsers get the accurate signal from
127
+ * offsetParent below; this inline-only check is the fallback that also
128
+ * makes the ancestor walk deterministically testable with plain
129
+ * `style="display:none"` fixtures. */
130
+ function isHiddenSelf(el: Element): boolean {
131
+ const html = el as HTMLElement;
132
+ if (html.hidden) return true;
133
+ const inline = html.style?.display;
134
+ if (inline === "none") return true;
135
+ const inlineVis = html.style?.visibility;
136
+ if (inlineVis === "hidden" || inlineVis === "collapse") return true;
137
+ return false;
138
+ }
139
+
140
+ function isVisible(el: Element): boolean {
141
+ const html = el as HTMLElement;
142
+ // offsetParent is the accurate, layout-derived signal in a real browser
143
+ // (null for display:none, including via a CSS class rule) — the same
144
+ // test shell.ts's trapFocus already uses elsewhere in this codebase. Only
145
+ // trust it when it's null: some engines (and fixed-position elements)
146
+ // report null even when visible, and test environments without real
147
+ // layout (happy-dom) report `undefined` for every element regardless of
148
+ // visibility — in both of those cases fall through to the ancestor walk
149
+ // below rather than either false-negative or false-positive on
150
+ // offsetParent alone.
151
+ if (html.offsetParent === null) {
152
+ const style = (globalThis as { getComputedStyle?: (e: Element) => CSSStyleDeclaration }).getComputedStyle?.(html);
153
+ if (style && style.position === "fixed") return true;
154
+ }
155
+ // Ancestor walk: catches inline/attribute hidden state on `el` itself or
156
+ // any ancestor (a closed overlay's contents, a display:none wrapper),
157
+ // independent of whether offsetParent resolved anything above.
158
+ for (let node: Element | null = el; node; node = node.parentElement) {
159
+ if (isHiddenSelf(node)) return false;
160
+ }
161
+ return true;
162
+ }
163
+
164
+ function elementLabel(el: Element): string {
165
+ const html = el as HTMLElement;
166
+
167
+ const uiLabel = html.getAttribute("data-ui-label")?.trim();
168
+ if (uiLabel) return uiLabel;
169
+
170
+ const aria = html.getAttribute("aria-label")?.trim();
171
+ if (aria) return aria;
172
+
173
+ const labelledBy = html.getAttribute("aria-labelledby");
174
+ if (labelledBy) {
175
+ const labelEl = document.getElementById(labelledBy);
176
+ if (labelEl?.textContent?.trim()) return labelEl.textContent.trim();
177
+ }
178
+
179
+ const spotlight = html.getAttribute("data-spotlight")?.trim();
180
+ const text = html.textContent?.replace(/\s+/g, " ").trim() ?? "";
181
+ if (spotlight) return text.length > 0 && text.length <= 100 ? text : spotlight;
182
+
183
+ if (text.length > 0 && text.length <= 80) return text;
184
+
185
+ const placeholder = (html as HTMLInputElement).placeholder?.trim();
186
+ if (placeholder) return placeholder;
187
+
188
+ const title = html.getAttribute("title")?.trim();
189
+ if (title) return title;
190
+
191
+ const name = html.getAttribute("name")?.trim();
192
+ if (name) return name;
193
+
194
+ return "";
195
+ }
196
+
197
+ /** Build the ~120-cap snapshot of the CURRENT DOM. `route` is whatever
198
+ * chat.ts's currentRoute()/overlay-name is at capture time — stamped onto
199
+ * the snapshot and used to derive stable per-route refs. */
200
+ export function captureUiSnapshot(route: string): UiSnapshot {
201
+ const nodes = [...document.querySelectorAll(INTERACTIVE_SELECTOR)];
202
+ const elements: UiSnapshotElement[] = [];
203
+ const seenRefs = new Set<string>();
204
+ let index = 0;
205
+
206
+ for (const el of nodes) {
207
+ if (isAgentChrome(el) || !isVisible(el)) continue;
208
+ const label = elementLabel(el);
209
+ if (!label) continue;
210
+
211
+ const html = el as HTMLElement;
212
+ const spotlightId = html.getAttribute("data-spotlight")?.trim() || undefined;
213
+ const existingRef = html.getAttribute("data-ui-ref");
214
+ const ref = existingRef ?? `ui-${route.replace(/[^a-zA-Z0-9]/g, "_") || "root"}-${index}`;
215
+ if (!existingRef) html.setAttribute("data-ui-ref", ref);
216
+ if (seenRefs.has(ref)) continue;
217
+ seenRefs.add(ref);
218
+
219
+ elements.push({
220
+ ref,
221
+ tag: html.tagName.toLowerCase(),
222
+ label,
223
+ kind: spotlightId ? "region" : "interactive",
224
+ role: html.getAttribute("role") ?? undefined,
225
+ selector: `[data-ui-ref="${ref}"]`,
226
+ spotlightId,
227
+ });
228
+ index += 1;
229
+ if (elements.length >= MAX_ELEMENTS) break;
230
+ }
231
+
232
+ return { route, capturedAt: new Date().toISOString(), elements };
233
+ }
234
+
235
+ /* ────────────────────────────────────────────────────────────────────
236
+ 2. POINT — resolving a spotlight_ui request to a concrete element
237
+ ──────────────────────────────────────────────────────────────────── */
238
+
239
+ export interface SpotlightRequest {
240
+ ref?: string;
241
+ label?: string;
242
+ selector?: string;
243
+ target?: string;
244
+ message?: string;
245
+ route?: string;
246
+ }
247
+
248
+ export interface ResolvedSpotlight {
249
+ selector: string;
250
+ title: string;
251
+ message?: string;
252
+ }
253
+
254
+ function matchScore(haystack: string, query: string): number {
255
+ const h = haystack.toLowerCase();
256
+ const q = query.toLowerCase().trim();
257
+ if (!q) return 0;
258
+ if (h === q) return 100;
259
+ if (h.includes(q)) return 85;
260
+ const tokens = q.split(/\s+/).filter(Boolean);
261
+ if (tokens.length === 0) return 0;
262
+ const matched = tokens.filter((t) => h.includes(t)).length;
263
+ return matched === 0 ? 0 : 50 + (matched / tokens.length) * 35;
264
+ }
265
+
266
+ /** Fuzzy label match against the last-captured snapshot's elements — same
267
+ * scoring PortfolioManager's ui-snapshot.ts findElementByLabel uses. */
268
+ function findByLabel(snapshot: UiSnapshot | null, label: string): UiSnapshotElement | undefined {
269
+ if (!snapshot) return undefined;
270
+ let best: UiSnapshotElement | undefined;
271
+ let bestScore = 0;
272
+ for (const el of snapshot.elements) {
273
+ const hay = [el.label, el.spotlightId].filter(Boolean).join(" ");
274
+ const score = matchScore(hay, label);
275
+ if (score > bestScore) { bestScore = score; best = el; }
276
+ }
277
+ return bestScore >= 50 ? best : undefined;
278
+ }
279
+
280
+ /** Attribute-value escape for selectors built from model-provided (or
281
+ * DOM-read) strings — `[data-ui-ref="…"]` etc. Backslash-escapes `\` and
282
+ * `"` so a quote in the value can't break out of the attribute string
283
+ * and turn the selector into a SyntaxError (or something else entirely). */
284
+ function attrEscape(v: string): string {
285
+ return v.replace(/[\\"]/g, "\\$&");
286
+ }
287
+
288
+ /** querySelector that treats an INVALID selector as a miss, not a throw.
289
+ * request.selector is model-invented; a malformed one must resolve to
290
+ * null so chat.ts's ui_spotlight case reaches the "couldn't find that on
291
+ * screen" toast — a SyntaxError here would escape through handle() and
292
+ * kill the ws frame instead (2026-07-07 review finding). */
293
+ function safeQuery(selector: string): Element | null {
294
+ try { return document.querySelector(selector); } catch { return null; }
295
+ }
296
+
297
+ /** Resolve a spotlight request in PortfolioManager's exact precedence: ref
298
+ * from the last snapshot -> live selector -> label fuzzy-match (against
299
+ * the snapshot, falling back to any live data-ui-label/data-spotlight in
300
+ * the DOM) -> curated catalog target. Returns null when nothing resolves
301
+ * (unknown ref/label/selector/target — spotlight_ui's tool handler turns
302
+ * that into an error result, never a silent no-op overlay). Pure —
303
+ * `snapshot` is passed in rather than re-captured, so tests exercise it
304
+ * without a live route. */
305
+ export function resolveSpotlightTarget(
306
+ request: SpotlightRequest,
307
+ snapshot: UiSnapshot | null,
308
+ ): ResolvedSpotlight | null {
309
+ if (request.ref) {
310
+ const el = snapshot?.elements.find((e) => e.ref === request.ref);
311
+ if (el) return { selector: el.selector, title: el.label, message: request.message };
312
+ const selector = `[data-ui-ref="${attrEscape(request.ref)}"]`;
313
+ if (safeQuery(selector)) {
314
+ return { selector, title: request.label ?? request.ref, message: request.message };
315
+ }
316
+ }
317
+
318
+ if (request.selector && safeQuery(request.selector)) {
319
+ return { selector: request.selector, title: request.label ?? "Here", message: request.message };
320
+ }
321
+
322
+ if (request.label) {
323
+ const el = findByLabel(snapshot, request.label);
324
+ if (el) return { selector: el.selector, title: el.label, message: request.message };
325
+
326
+ let bestEl: Element | null = null;
327
+ let bestScore = 0;
328
+ for (const node of document.querySelectorAll("[data-ui-label]")) {
329
+ const score = matchScore(node.getAttribute("data-ui-label") ?? "", request.label);
330
+ if (score > bestScore) { bestScore = score; bestEl = node; }
331
+ }
332
+ if (bestEl && bestScore >= 50) {
333
+ const ref = bestEl.getAttribute("data-ui-ref");
334
+ return {
335
+ selector: ref
336
+ ? `[data-ui-ref="${attrEscape(ref)}"]`
337
+ : `[data-ui-label="${attrEscape(bestEl.getAttribute("data-ui-label") ?? "")}"]`,
338
+ title: bestEl.getAttribute("data-ui-label") ?? request.label,
339
+ message: request.message,
340
+ };
341
+ }
342
+ }
343
+
344
+ if (request.target) {
345
+ const meta = getSpotlightTarget(request.target);
346
+ if (meta && safeQuery(meta.selector)) {
347
+ return { selector: meta.selector, title: meta.label, message: request.message };
348
+ }
349
+ const targetSelector = `[data-spotlight="${attrEscape(request.target)}"]`;
350
+ const bySpotlightAttr = safeQuery(targetSelector);
351
+ if (bySpotlightAttr) {
352
+ const ref = bySpotlightAttr.getAttribute("data-ui-ref");
353
+ return {
354
+ selector: ref ? `[data-ui-ref="${attrEscape(ref)}"]` : targetSelector,
355
+ title: bySpotlightAttr.getAttribute("data-ui-label") ?? request.label ?? request.target,
356
+ message: request.message,
357
+ };
358
+ }
359
+ }
360
+
361
+ return null;
362
+ }
363
+
364
+ /* ────────────────────────────────────────────────────────────────────
365
+ 3. THE OVERLAY — never-hijack, verbatim from §6.6
366
+ ──────────────────────────────────────────────────────────────────── */
367
+
368
+ const AUTO_DISMISS_MS = 3000;
369
+ let dismissTimer: ReturnType<typeof setTimeout> | null = null;
370
+ let dismissListeners: (() => void) | null = null;
371
+
372
+ function ensureOverlayEl(): HTMLElement {
373
+ let el = document.getElementById("assistant-spotlight");
374
+ if (!el) {
375
+ el = document.createElement("div");
376
+ el.id = "assistant-spotlight";
377
+ // data-ui-chrome="assistant" — excluded from the DOM snapshot (the
378
+ // assistant must never spotlight or narrate its own spotlight).
379
+ el.setAttribute("data-ui-chrome", "assistant");
380
+ el.innerHTML = '<div class="asr-ring"></div><div class="asr-pop"><div class="asr-pop-text"></div></div>';
381
+ document.body.appendChild(el);
382
+ }
383
+ return el;
384
+ }
385
+
386
+ /** Tear down the overlay: clear the timer, remove the dismiss-on-any-input
387
+ * listeners, and hide it. Safe to call when nothing is showing. */
388
+ export function dismissSpotlight(): void {
389
+ if (dismissTimer) { clearTimeout(dismissTimer); dismissTimer = null; }
390
+ if (dismissListeners) { dismissListeners(); dismissListeners = null; }
391
+ const el = document.getElementById("assistant-spotlight");
392
+ if (el) el.classList.remove("show");
393
+ }
394
+
395
+ /** Show the spotlight overlay around `selector` with a short popover
396
+ * message. Returns false (no-op) if the element doesn't exist — the
397
+ * caller (chat.ts's ui_spotlight handler) can toast a quiet failure
398
+ * instead of showing a ring around nothing.
399
+ *
400
+ * NEVER-HIJACK, verbatim from §6.6: this overlay traps no focus (never
401
+ * added to shell.ts's trapFocus/closeTopOverlay set), blocks no clicks
402
+ * (pointer-events:none in CSS — the highlighted control stays clickable
403
+ * through it), auto-dismisses after ~3s, and ANY user input — keydown,
404
+ * mousedown, or wheel, anywhere on the page — dismisses it instantly.
405
+ * transform/opacity are the only animated properties, and the CSS
406
+ * respects prefers-reduced-motion (app.html). */
407
+ export function showSpotlight(resolved: ResolvedSpotlight): boolean {
408
+ // safeQuery: resolved.selector can be the raw model-provided passthrough;
409
+ // it was valid at resolve time, but "return false → toast" is the right
410
+ // failure mode here too, never a throw.
411
+ const target = safeQuery(resolved.selector) as HTMLElement | null;
412
+ if (!target) return false;
413
+
414
+ dismissSpotlight();
415
+ const overlay = ensureOverlayEl();
416
+ const rect = target.getBoundingClientRect();
417
+ const ring = overlay.querySelector(".asr-ring") as HTMLElement;
418
+ const pop = overlay.querySelector(".asr-pop") as HTMLElement;
419
+ const popText = overlay.querySelector(".asr-pop-text") as HTMLElement;
420
+
421
+ const pad = 6;
422
+ ring.style.left = `${rect.left - pad}px`;
423
+ ring.style.top = `${rect.top - pad}px`;
424
+ ring.style.width = `${rect.width + pad * 2}px`;
425
+ ring.style.height = `${rect.height + pad * 2}px`;
426
+
427
+ popText.textContent = resolved.message ? `${resolved.title} — ${resolved.message}` : resolved.title;
428
+ const popTop = rect.bottom + 12;
429
+ pop.style.left = `${Math.max(8, rect.left)}px`;
430
+ pop.style.top = `${popTop}px`;
431
+
432
+ overlay.classList.add("show");
433
+
434
+ const onDismiss = () => dismissSpotlight();
435
+ document.addEventListener("keydown", onDismiss, { capture: true });
436
+ document.addEventListener("mousedown", onDismiss, { capture: true });
437
+ document.addEventListener("wheel", onDismiss, { capture: true, passive: true });
438
+ dismissListeners = () => {
439
+ document.removeEventListener("keydown", onDismiss, { capture: true });
440
+ document.removeEventListener("mousedown", onDismiss, { capture: true });
441
+ document.removeEventListener("wheel", onDismiss, { capture: true });
442
+ };
443
+ dismissTimer = setTimeout(dismissSpotlight, AUTO_DISMISS_MS);
444
+ return true;
445
+ }
446
+
447
+ /* ────────────────────────────────────────────────────────────────────
448
+ Ambient context frame builder (chat.ts calls this on route/step change)
449
+ ──────────────────────────────────────────────────────────────────── */
450
+
451
+ export interface AppContext {
452
+ route: string;
453
+ view?: ViewId;
454
+ step?: WizardStep;
455
+ snapshot: UiSnapshot;
456
+ }
457
+
458
+ /** Build the full context payload chat.ts pushes as a `context` frame.
459
+ * `viewOverride` names an open overlay (memory/hub/adapters panel) when one
460
+ * is open on top of the current route — routes and views are otherwise
461
+ * the same "where is the user" concept from the tool's point of view. */
462
+ export function buildAppContext(route: string, viewOverride?: string | null): AppContext {
463
+ const view = viewOverride && isViewId(viewOverride) ? viewOverride : undefined;
464
+ const step = currentWizardStep(route) ?? undefined;
465
+ return {
466
+ route,
467
+ ...(view ? { view } : {}),
468
+ ...(step ? { step } : {}),
469
+ snapshot: captureUiSnapshot(route),
470
+ };
471
+ }
472
+
473
+ /** The compact one-line ambient context PortfolioManager-style "never
474
+ * answer blind" auto-prepend — NOT a snapshot dump. Mirrors the shape in
475
+ * the task brief's example: "[user is on: Quantize · step 2/4]". Pure
476
+ * string builder so pi-web.ts's tests can exercise the exact wire text
477
+ * the assistant would see; the browser never calls this directly (the
478
+ * server derives its own copy from the stored context — see pi-web.ts's
479
+ * ambientContextLine). Exported for symmetry/documentation even though
480
+ * chat.ts doesn't call it (kept here so the format has one home). */
481
+ export function ambientLine(ctx: { route: string; view?: string; step?: WizardStep }): string {
482
+ const place = ctx.view ? ctx.view : (ROUTE_LABELS as Record<string, string>)[ctx.route] ?? ctx.route;
483
+ const stepPart = ctx.step ? ` · step ${ctx.step.index + 1}/${ctx.step.count}` : "";
484
+ return `[user is on: ${place}${stepPart}]`;
485
+ }