@mlx-bun/module-chat 0.0.0-stage → 1.0.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.
@@ -0,0 +1,469 @@
1
+ import type { UiSnapshotElement, UiSnapshot, WizardStep } from "../protocol";
2
+ export type { UiSnapshotElement, UiSnapshot, WizardStep } from "../protocol";
3
+ // Part of the chat panel (`@mlx-bun/module-chat`): the app-aware assistant, bundled into the app's browser build.
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 (a hard constraint): 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
+ const STEP_CONTAINER_BY_ROUTE: Partial<Record<RouteId, string>> = {
30
+ quantize: "q-steps",
31
+ finetune: "f-steps",
32
+ dataset: "d-steps",
33
+ };
34
+
35
+ /** UI controls may live inside a mounted module's open shadow root. */
36
+ export function queryUiElements(selector: string, root: Document | ShadowRoot = document): Element[] {
37
+ const elements = [...root.querySelectorAll(selector)];
38
+ for (const host of root.querySelectorAll("*")) {
39
+ if (host.shadowRoot) elements.push(...queryUiElements(selector, host.shadowRoot));
40
+ }
41
+ return elements;
42
+ }
43
+
44
+ /** Follow the rendered ancestry across a panel's shadow boundary. */
45
+ function uiParent(element: Element): Element | null {
46
+ if (element.parentElement) return element.parentElement;
47
+ const root = element.getRootNode();
48
+ return "host" in root ? (root as ShadowRoot).host : null;
49
+ }
50
+
51
+ export function currentWizardStep(route: string): WizardStep | null {
52
+ const containerId = STEP_CONTAINER_BY_ROUTE[route as RouteId];
53
+ if (!containerId) return null;
54
+ const container = queryUiElements(`[id="${containerId}"]`)[0];
55
+ if (!container) return null;
56
+ const spans = [...container.querySelectorAll(".s")];
57
+ if (spans.length === 0) return null;
58
+ const curIdx = spans.findIndex((s) => s.classList.contains("cur"));
59
+ const cur = spans[curIdx] ?? spans[0]!;
60
+ const label = cur.querySelector(".n")
61
+ ? (cur.textContent || "").replace(/^\d+/, "").trim()
62
+ : (cur.textContent || "").trim();
63
+ return { index: curIdx < 0 ? 0 : curIdx, count: spans.length, label };
64
+ }
65
+
66
+ const INTERACTIVE_SELECTOR = [
67
+ "button:not([disabled])",
68
+ "a[href]",
69
+ 'input:not([type="hidden"]):not([disabled])',
70
+ "select:not([disabled])",
71
+ "textarea:not([disabled])",
72
+ '[role="button"]',
73
+ "[data-spotlight]",
74
+ ].join(", ");
75
+
76
+ const MAX_ELEMENTS = 120;
77
+
78
+ /** Chrome/content the snapshot must never include: toasts, the assistant's
79
+ * own spotlight overlay (`data-ui-chrome="assistant"`), rendered
80
+ * conversation content (`data-ui-chrome="content"` — chat-thread and the
81
+ * memory/adapters/hub panel bodies, see the marker sites in chat.ts/
82
+ * memory-panel.ts/adapters-panel.ts/hub.ts), and any overlay currently
83
+ * closed (display:none via its .open class gate — see isVisible's
84
+ * ancestor walk below).
85
+ *
86
+ * The `data-ui-chrome="content"` exclusion matters beyond tidiness: a
87
+ * rendered assistant/user message can contain a real `<a href>` (a
88
+ * citation link, a markdown link from RAG'd/tool/web content, or just an
89
+ * ordinary reply). INTERACTIVE_SELECTOR matches `a[href]`, and this
90
+ * snapshot's whole point is to be re-served to the model verbatim as
91
+ * trusted "current UI state" via get_current_app_context — without this
92
+ * exclusion, an attacker-influenced link's visible text becomes a second,
93
+ * less-obvious prompt-injection channel alongside the visible transcript
94
+ * the model already sees. Any element inside a `data-ui-chrome="content"`
95
+ * root is excluded regardless of nesting depth (`.closest`), matching how
96
+ * the "assistant" chrome exclusion already works. */
97
+ function isAgentChrome(el: Element): boolean {
98
+ for (let node: Element | null = el; node; node = uiParent(node)) {
99
+ if (node.matches("[data-ui-chrome], #toasts")) return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /** True when `el` itself is hidden by inline style or the `hidden`
105
+ * attribute — deliberately NOT full computed-style resolution (a real
106
+ * browser's CSS-class-driven `display:none` — e.g. `#mem-overlay{display:
107
+ * none} .open{display:flex}` — only resolves through getComputedStyle,
108
+ * which happy-dom's test environment can't lay out or cascade through
109
+ * <style> rules). Real browsers get the accurate signal from
110
+ * offsetParent below; this inline-only check is the fallback that also
111
+ * makes the ancestor walk deterministically testable with plain
112
+ * `style="display:none"` fixtures. */
113
+ function isHiddenSelf(el: Element): boolean {
114
+ const html = el as HTMLElement;
115
+ if (html.hidden) return true;
116
+ const inline = html.style?.display;
117
+ if (inline === "none") return true;
118
+ const inlineVis = html.style?.visibility;
119
+ if (inlineVis === "hidden" || inlineVis === "collapse") return true;
120
+ return false;
121
+ }
122
+
123
+ function isVisible(el: Element): boolean {
124
+ const html = el as HTMLElement;
125
+ // offsetParent is the accurate, layout-derived signal in a real browser
126
+ // (null for display:none, including via a CSS class rule) — the same
127
+ // test shell.ts's trapFocus already uses elsewhere in this codebase. Only
128
+ // trust it when it's null: some engines (and fixed-position elements)
129
+ // report null even when visible, and test environments without real
130
+ // layout (happy-dom) report `undefined` for every element regardless of
131
+ // visibility — in both of those cases fall through to the ancestor walk
132
+ // below rather than either false-negative or false-positive on
133
+ // offsetParent alone.
134
+ if (html.offsetParent === null) {
135
+ const style = (globalThis as { getComputedStyle?: (e: Element) => CSSStyleDeclaration }).getComputedStyle?.(html);
136
+ if (style && style.position === "fixed") return true;
137
+ }
138
+ // Ancestor walk: catches inline/attribute hidden state on `el` itself or
139
+ // any ancestor (a closed overlay's contents, a display:none wrapper),
140
+ // independent of whether offsetParent resolved anything above.
141
+ for (let node: Element | null = el; node; node = uiParent(node)) {
142
+ if (isHiddenSelf(node)) return false;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ function elementLabel(el: Element): string {
148
+ const html = el as HTMLElement;
149
+
150
+ const uiLabel = html.getAttribute("data-ui-label")?.trim();
151
+ if (uiLabel) return uiLabel;
152
+
153
+ const aria = html.getAttribute("aria-label")?.trim();
154
+ if (aria) return aria;
155
+
156
+ const labelledBy = html.getAttribute("aria-labelledby");
157
+ if (labelledBy) {
158
+ const labelEl = (html.getRootNode() as Document | ShadowRoot).getElementById(labelledBy);
159
+ if (labelEl?.textContent?.trim()) return labelEl.textContent.trim();
160
+ }
161
+
162
+ const spotlight = html.getAttribute("data-spotlight")?.trim();
163
+ const text = html.textContent?.replace(/\s+/g, " ").trim() ?? "";
164
+ if (spotlight) return text.length > 0 && text.length <= 100 ? text : spotlight;
165
+
166
+ if (text.length > 0 && text.length <= 80) return text;
167
+
168
+ const placeholder = (html as HTMLInputElement).placeholder?.trim();
169
+ if (placeholder) return placeholder;
170
+
171
+ const title = html.getAttribute("title")?.trim();
172
+ if (title) return title;
173
+
174
+ const name = html.getAttribute("name")?.trim();
175
+ if (name) return name;
176
+
177
+ return "";
178
+ }
179
+
180
+ /** Build the ~120-cap snapshot of the CURRENT DOM. `route` is whatever
181
+ * chat.ts's currentRoute()/overlay-name is at capture time — stamped onto
182
+ * the snapshot and used to derive stable per-route refs. */
183
+ export function captureUiSnapshot(route: string): UiSnapshot {
184
+ const nodes = queryUiElements(INTERACTIVE_SELECTOR);
185
+ const elements: UiSnapshotElement[] = [];
186
+ const seenRefs = new Set<string>();
187
+ let index = 0;
188
+
189
+ for (const el of nodes) {
190
+ if (isAgentChrome(el) || !isVisible(el)) continue;
191
+ const label = elementLabel(el);
192
+ if (!label) continue;
193
+
194
+ const html = el as HTMLElement;
195
+ const spotlightId = html.getAttribute("data-spotlight")?.trim() || undefined;
196
+ const existingRef = html.getAttribute("data-ui-ref");
197
+ const ref = existingRef ?? `ui-${route.replace(/[^a-zA-Z0-9]/g, "_") || "root"}-${index}`;
198
+ if (!existingRef) html.setAttribute("data-ui-ref", ref);
199
+ if (seenRefs.has(ref)) continue;
200
+ seenRefs.add(ref);
201
+
202
+ elements.push({
203
+ ref,
204
+ tag: html.tagName.toLowerCase(),
205
+ label,
206
+ kind: spotlightId ? "region" : "interactive",
207
+ role: html.getAttribute("role") ?? undefined,
208
+ selector: `[data-ui-ref="${ref}"]`,
209
+ spotlightId,
210
+ });
211
+ index += 1;
212
+ if (elements.length >= MAX_ELEMENTS) break;
213
+ }
214
+
215
+ return { route, capturedAt: new Date().toISOString(), elements };
216
+ }
217
+
218
+ /* ────────────────────────────────────────────────────────────────────
219
+ 2. POINT — resolving a spotlight_ui request to a concrete element
220
+ ──────────────────────────────────────────────────────────────────── */
221
+
222
+ export interface SpotlightRequest {
223
+ ref?: string;
224
+ label?: string;
225
+ selector?: string;
226
+ target?: string;
227
+ message?: string;
228
+ route?: string;
229
+ }
230
+
231
+ export interface ResolvedSpotlight {
232
+ selector: string;
233
+ title: string;
234
+ message?: string;
235
+ }
236
+
237
+ function matchScore(haystack: string, query: string): number {
238
+ const h = haystack.toLowerCase();
239
+ const q = query.toLowerCase().trim();
240
+ if (!q) return 0;
241
+ if (h === q) return 100;
242
+ if (h.includes(q)) return 85;
243
+ const tokens = q.split(/\s+/).filter(Boolean);
244
+ if (tokens.length === 0) return 0;
245
+ const matched = tokens.filter((t) => h.includes(t)).length;
246
+ return matched === 0 ? 0 : 50 + (matched / tokens.length) * 35;
247
+ }
248
+
249
+ /** Fuzzy label match against the last-captured snapshot's elements — same
250
+ * scoring PortfolioManager's ui-snapshot.ts findElementByLabel uses. */
251
+ function findByLabel(snapshot: UiSnapshot | null, label: string): UiSnapshotElement | undefined {
252
+ if (!snapshot) return undefined;
253
+ let best: UiSnapshotElement | undefined;
254
+ let bestScore = 0;
255
+ for (const el of snapshot.elements) {
256
+ const hay = [el.label, el.spotlightId].filter(Boolean).join(" ");
257
+ const score = matchScore(hay, label);
258
+ if (score > bestScore) { bestScore = score; best = el; }
259
+ }
260
+ return bestScore >= 50 ? best : undefined;
261
+ }
262
+
263
+ /** Attribute-value escape for selectors built from model-provided (or
264
+ * DOM-read) strings — `[data-ui-ref="…"]` etc. Backslash-escapes `\` and
265
+ * `"` so a quote in the value can't break out of the attribute string
266
+ * and turn the selector into a SyntaxError (or something else entirely). */
267
+ function attrEscape(v: string): string {
268
+ return v.replace(/[\\"]/g, "\\$&");
269
+ }
270
+
271
+ /** querySelector that treats an INVALID selector as a miss, not a throw.
272
+ * request.selector is model-invented; a malformed one must resolve to
273
+ * null so chat.ts's ui_spotlight case reaches the "couldn't find that on
274
+ * screen" toast — a SyntaxError here would escape through handle() and
275
+ * kill the ws frame instead (2026-07-07 review finding). */
276
+ function safeQuery(selector: string): Element | null {
277
+ try { return queryUiElements(selector)[0] ?? null; } catch { return null; }
278
+ }
279
+
280
+ /** Resolve a spotlight request in PortfolioManager's exact precedence: ref
281
+ * from the last snapshot -> live selector -> label fuzzy-match (against
282
+ * the snapshot, falling back to any live data-ui-label/data-spotlight in
283
+ * the DOM) -> curated catalog target. Returns null when nothing resolves
284
+ * (unknown ref/label/selector/target — spotlight_ui's tool handler turns
285
+ * that into an error result, never a silent no-op overlay). Pure —
286
+ * `snapshot` is passed in rather than re-captured, so tests exercise it
287
+ * without a live route. */
288
+ export function resolveSpotlightTarget(
289
+ request: SpotlightRequest,
290
+ snapshot: UiSnapshot | null,
291
+ ): ResolvedSpotlight | null {
292
+ if (request.ref) {
293
+ const el = snapshot?.elements.find((e) => e.ref === request.ref);
294
+ if (el) return { selector: el.selector, title: el.label, message: request.message };
295
+ const selector = `[data-ui-ref="${attrEscape(request.ref)}"]`;
296
+ if (safeQuery(selector)) {
297
+ return { selector, title: request.label ?? request.ref, message: request.message };
298
+ }
299
+ }
300
+
301
+ if (request.selector && safeQuery(request.selector)) {
302
+ return { selector: request.selector, title: request.label ?? "Here", message: request.message };
303
+ }
304
+
305
+ if (request.label) {
306
+ const el = findByLabel(snapshot, request.label);
307
+ if (el) return { selector: el.selector, title: el.label, message: request.message };
308
+
309
+ let bestEl: Element | null = null;
310
+ let bestScore = 0;
311
+ for (const node of queryUiElements("[data-ui-label]")) {
312
+ const score = matchScore(node.getAttribute("data-ui-label") ?? "", request.label);
313
+ if (score > bestScore) { bestScore = score; bestEl = node; }
314
+ }
315
+ if (bestEl && bestScore >= 50) {
316
+ const ref = bestEl.getAttribute("data-ui-ref");
317
+ return {
318
+ selector: ref
319
+ ? `[data-ui-ref="${attrEscape(ref)}"]`
320
+ : `[data-ui-label="${attrEscape(bestEl.getAttribute("data-ui-label") ?? "")}"]`,
321
+ title: bestEl.getAttribute("data-ui-label") ?? request.label,
322
+ message: request.message,
323
+ };
324
+ }
325
+ }
326
+
327
+ if (request.target) {
328
+ const meta = getSpotlightTarget(request.target);
329
+ if (meta && safeQuery(meta.selector)) {
330
+ return { selector: meta.selector, title: meta.label, message: request.message };
331
+ }
332
+ const targetSelector = `[data-spotlight="${attrEscape(request.target)}"]`;
333
+ const bySpotlightAttr = safeQuery(targetSelector);
334
+ if (bySpotlightAttr) {
335
+ const ref = bySpotlightAttr.getAttribute("data-ui-ref");
336
+ return {
337
+ selector: ref ? `[data-ui-ref="${attrEscape(ref)}"]` : targetSelector,
338
+ title: bySpotlightAttr.getAttribute("data-ui-label") ?? request.label ?? request.target,
339
+ message: request.message,
340
+ };
341
+ }
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ /* ────────────────────────────────────────────────────────────────────
348
+ 3. THE OVERLAY — never-hijack
349
+ ──────────────────────────────────────────────────────────────────── */
350
+
351
+ const AUTO_DISMISS_MS = 3000;
352
+ let dismissTimer: ReturnType<typeof setTimeout> | null = null;
353
+ let dismissListeners: (() => void) | null = null;
354
+
355
+ function ensureOverlayEl(): HTMLElement {
356
+ let el = document.getElementById("assistant-spotlight");
357
+ if (!el) {
358
+ el = document.createElement("div");
359
+ el.id = "assistant-spotlight";
360
+ // data-ui-chrome="assistant" — excluded from the DOM snapshot (the
361
+ // assistant must never spotlight or narrate its own spotlight).
362
+ el.setAttribute("data-ui-chrome", "assistant");
363
+ el.setAttribute("aria-hidden", "true");
364
+ el.innerHTML = '<div class="asr-ring"></div><div class="asr-pop"><div class="asr-pop-text"></div></div>';
365
+ document.body.appendChild(el);
366
+ }
367
+ return el;
368
+ }
369
+
370
+ /** Tear down the overlay: clear the timer, remove the dismiss-on-any-input
371
+ * listeners, and hide it. Safe to call when nothing is showing. */
372
+ export function dismissSpotlight(): void {
373
+ if (dismissTimer) { clearTimeout(dismissTimer); dismissTimer = null; }
374
+ if (dismissListeners) { dismissListeners(); dismissListeners = null; }
375
+ const el = document.getElementById("assistant-spotlight");
376
+ if (el) el.classList.remove("show");
377
+ }
378
+
379
+ /** Show the spotlight overlay around `selector` with a short popover
380
+ * message. Returns false (no-op) if the element doesn't exist — the
381
+ * caller (chat.ts's ui_spotlight handler) can toast a quiet failure
382
+ * instead of showing a ring around nothing.
383
+ *
384
+ * NEVER-HIJACK: this overlay traps no focus (never
385
+ * added to shell.ts's trapFocus/closeTopOverlay set), blocks no clicks
386
+ * (pointer-events:none in CSS — the highlighted control stays clickable
387
+ * through it), auto-dismisses after ~3s, and ANY user input — keydown,
388
+ * mousedown, or wheel, anywhere on the page — dismisses it instantly.
389
+ * transform/opacity are the only animated properties, and the CSS
390
+ * respects prefers-reduced-motion (app.html). */
391
+ export function showSpotlight(resolved: ResolvedSpotlight): boolean {
392
+ // safeQuery: resolved.selector can be the raw model-provided passthrough;
393
+ // it was valid at resolve time, but "return false → toast" is the right
394
+ // failure mode here too, never a throw.
395
+ const target = safeQuery(resolved.selector) as HTMLElement | null;
396
+ if (!target) return false;
397
+
398
+ dismissSpotlight();
399
+ const overlay = ensureOverlayEl();
400
+ const rect = target.getBoundingClientRect();
401
+ const ring = overlay.querySelector(".asr-ring") as HTMLElement;
402
+ const pop = overlay.querySelector(".asr-pop") as HTMLElement;
403
+ const popText = overlay.querySelector(".asr-pop-text") as HTMLElement;
404
+
405
+ const pad = 6;
406
+ ring.style.left = `${rect.left - pad}px`;
407
+ ring.style.top = `${rect.top - pad}px`;
408
+ ring.style.width = `${rect.width + pad * 2}px`;
409
+ ring.style.height = `${rect.height + pad * 2}px`;
410
+
411
+ popText.textContent = resolved.message ? `${resolved.title} — ${resolved.message}` : resolved.title;
412
+ const popTop = rect.bottom + 12;
413
+ pop.style.left = `${Math.max(8, rect.left)}px`;
414
+ pop.style.top = `${popTop}px`;
415
+
416
+ overlay.classList.add("show");
417
+
418
+ const onDismiss = () => dismissSpotlight();
419
+ document.addEventListener("keydown", onDismiss, { capture: true });
420
+ document.addEventListener("mousedown", onDismiss, { capture: true });
421
+ document.addEventListener("wheel", onDismiss, { capture: true, passive: true });
422
+ dismissListeners = () => {
423
+ document.removeEventListener("keydown", onDismiss, { capture: true });
424
+ document.removeEventListener("mousedown", onDismiss, { capture: true });
425
+ document.removeEventListener("wheel", onDismiss, { capture: true });
426
+ };
427
+ dismissTimer = setTimeout(dismissSpotlight, AUTO_DISMISS_MS);
428
+ return true;
429
+ }
430
+
431
+ /* ────────────────────────────────────────────────────────────────────
432
+ Ambient context frame builder (chat.ts calls this on route/step change)
433
+ ──────────────────────────────────────────────────────────────────── */
434
+
435
+ export interface AppContext {
436
+ route: string;
437
+ view?: ViewId;
438
+ step?: WizardStep;
439
+ snapshot: UiSnapshot;
440
+ }
441
+
442
+ /** Build the full context payload chat.ts pushes as a `context` frame.
443
+ * `viewOverride` names an open overlay (memory/hub/adapters panel) when one
444
+ * is open on top of the current route — routes and views are otherwise
445
+ * the same "where is the user" concept from the tool's point of view. */
446
+ export function buildAppContext(route: string, viewOverride?: string | null): AppContext {
447
+ const view = viewOverride && isViewId(viewOverride) ? viewOverride : undefined;
448
+ const step = currentWizardStep(route) ?? undefined;
449
+ return {
450
+ route,
451
+ ...(view ? { view } : {}),
452
+ ...(step ? { step } : {}),
453
+ snapshot: captureUiSnapshot(route),
454
+ };
455
+ }
456
+
457
+ /** The compact one-line ambient context PortfolioManager-style "never
458
+ * answer blind" auto-prepend — NOT a snapshot dump. Mirrors the shape in
459
+ * the task brief's example: "[user is on: Quantize · step 2/4]". Pure
460
+ * string builder so packages/module-chat/tests/chat-policy.test.ts can exercise the exact wire text
461
+ * the assistant would see; the browser never calls this directly (the
462
+ * server derives its own copy from the stored context — see
463
+ * packages/module-chat/src/policy.ts's ambientContextLine). Exported for symmetry/documentation even though
464
+ * chat.ts doesn't call it (kept here so the format has one home). */
465
+ export function ambientLine(ctx: { route: string; view?: string; step?: WizardStep }): string {
466
+ const place = ctx.view ? ctx.view : (ROUTE_LABELS as Record<string, string>)[ctx.route] ?? ctx.route;
467
+ const stepPart = ctx.step ? ` · step ${ctx.step.index + 1}/${ctx.step.count}` : "";
468
+ return `[user is on: ${place}${stepPart}]`;
469
+ }