mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,448 @@
1
+ 'use strict';
2
+
3
+ // Native `webpreview` tool — capture a small screenshot of a URL.
4
+ //
5
+ // Implements docs/features/webpreview.md.
6
+ //
7
+ // The tool reuses the same Chrome DevTools Protocol bridge as the
8
+ // Inspector tab ([src/inspector.js](../../src/inspector.js)): it opens
9
+ // the URL in a debug-managed tab, waits for the page to settle, and
10
+ // then captures a single small JPEG screenshot via `Page.captureScreenshot`.
11
+ // The result is returned to the chat as { url, title, thumbnail, ... }
12
+ // so the UI can render a small dock between the transcript and composer and
13
+ // re-use the same image in the full viewer that opens on a user tap.
14
+ //
15
+ // Per-call lifecycle:
16
+ // 1. Open the URL in a fresh tab via inspector.openInspectorTarget.
17
+ // 2. Wait for `Page.loadEventFired` (timeout 10s).
18
+ // 3. Apply the requested viewport (default the Inspector's phone preset,
19
+ // 375 × 667) and give responsive layout / paint a tiny grace window.
20
+ // 4. Call `Page.captureScreenshot` for that viewport so the reduced chat
21
+ // image has the same proportions. The screenshot bytes are not
22
+ // appended to the model conversation.
23
+ // 5. Close the tab (`Target.closeTarget`) so server restarts don't leak
24
+ // preview tabs. A failed capture still closes the tab. Calling the tool
25
+ // again for the same URL opens a fresh tab and acts as an agent-triggered
26
+ // reload of the user-facing preview.
27
+ //
28
+ // The tool is project-scope-free: there is no working-directory check
29
+ // (the inspector handles its own auth shape). Authorization gates the
30
+ // runner; the off/ask/allowlist modes follow the same rules as
31
+ // `shell`, with the URL allowlist matching by hostname (or full URL,
32
+ // when wildcards are present).
33
+ //
34
+ // Public surface:
35
+ // SPEC — the OpenAI-compatible tool spec (used by
36
+ // the AI client when collecting tool specs).
37
+ // runWebpreview({ url, viewport, signal })
38
+ // -> Promise<{ ok, content, result }>
39
+ // where `content` is the string fed to the
40
+ // model as the `tool` message and `result`
41
+ // is the richer object surfaced to the UI
42
+ // in the tool_result SSE event.
43
+ // `viewport` is a preset id ('phone',
44
+ // 'phone+', 'tablet', 'laptop') or a
45
+ // 'WIDTHxHEIGHT' string; it overrides the
46
+ // default 375 × 667 phone capture.
47
+ // resolveViewport(raw) — normalize a viewport arg.
48
+ // VIEWPORTS / DEFAULT_VIEWPORT_ID — the preset table + default.
49
+
50
+ const { openInspectorTarget, sendTargetCommand, closeInspectorTarget, fetchInspectorTargets } = require('../inspector.js');
51
+
52
+ // Capture viewports. A preset is the default (and what the dock miniature
53
+ // is tuned for); the agent or user can pick a different one to preview a
54
+ // page at a tablet/laptop size, or pass a custom `WIDTHxHEIGHT`. The
55
+ // `mobile` flag matches the Inspector's preset semantics: phone entries set
56
+ // `mobile: true` so the viewport meta / DPR behaviour flips, tablet and
57
+ // laptop stay desktop-style so media queries behave like a real browser
58
+ // window.
59
+ const THUMB_WIDTH_MAX = 375;
60
+ const THUMB_HEIGHT_MAX = 667;
61
+ const VIEWPORTS = Object.freeze({
62
+ phone: { id: 'phone', label: 'Phone', width: THUMB_WIDTH_MAX, height: THUMB_HEIGHT_MAX, mobile: true },
63
+ 'phone+': { id: 'phone+', label: 'Phone+', width: 414, height: 896, mobile: true },
64
+ tablet: { id: 'tablet', label: 'Tablet', width: 768, height: 1024, mobile: false },
65
+ laptop: { id: 'laptop', label: 'Laptop', width: 1280, height: 800, mobile: false }
66
+ });
67
+ const DEFAULT_VIEWPORT_ID = 'phone';
68
+ const DEFAULT_VIEWPORT = VIEWPORTS[DEFAULT_VIEWPORT_ID];
69
+ // Guard the custom-size path. A runaway width/height would make a
70
+ // capture exceed MAX_IMAGE_BYTES (or time out); clamp into a sane range.
71
+ const MIN_VIEWPORT_DIM = 64;
72
+ const MAX_VIEWPORT_DIM = 2048;
73
+
74
+ // Largest image we accept from CDP. CDP returns either base64-encoded
75
+ // data or a binary stream; either way we re-validate against this cap
76
+ // before returning the image to the chat. 2 MiB is ample for a 375 × 667
77
+ // JPEG at quality 70 and well under any model-feedback rail.
78
+ const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
79
+
80
+ // Default polling cadence while waiting for the load event. The CDP
81
+ // `Page.loadEventFired` event is the clean signal so we subscribe, but
82
+ // Chrome can drop the subscription if our session is rebuilt; the
83
+ // timeout is the fallback so the tool always settles.
84
+ const LOAD_TIMEOUT_MS = 10_000;
85
+ const POST_LOAD_GRACE_MS = 250;
86
+
87
+ // The capture uses the same small-phone dimensions as Inspector's Phone
88
+ // preset. This is also the fallback when a Chrome build omits layout metrics.
89
+ const err = (code, message, extra) => Object.assign(new Error(message), { code, ...(extra || {}) });
90
+
91
+ // Validate a URL the model supplied. Accept http(s) only — ftp, file,
92
+ // chrome-extension, view-source, javascript:, data: are all rejected
93
+ // because they are either not real web pages (`file:`, `data:`) or
94
+ // they would surprise the user (`javascript:`) or are obviously out of
95
+ // scope (`view-source:`, `chrome-extension:`).
96
+ function parseUrl(raw) {
97
+ if (typeof raw !== 'string' || !raw.trim()) {
98
+ throw err('EBADINPUT', 'url is required');
99
+ }
100
+ let url;
101
+ try { url = new URL(raw.trim()); }
102
+ catch { throw err('EBADINPUT', 'url is not a valid URL'); }
103
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
104
+ throw err('EBADINPUT', 'url must use http or https (got ' + url.protocol + ')');
105
+ }
106
+ return url;
107
+ }
108
+
109
+ // Wait for `Page.loadEventFired` over the target WebSocket (or until
110
+ // the timeout). Implemented as a one-shot Promise that resolves on
111
+ // the first loadEventFired frame, so the caller doesn't need to
112
+ // manage a CDP subscription of its own. Soft-fails on any non-load
113
+ // outcome so the caller can still attempt a screenshot.
114
+ function waitForLoad(wsUrl, signal) {
115
+ const { WebSocket } = require('ws');
116
+ return new Promise((resolve) => {
117
+ let ws;
118
+ try { ws = new WebSocket(wsUrl, { perMessageDeflate: false }); }
119
+ catch (e) { resolve({ ok: false, error: e.message || String(e) }); return; }
120
+
121
+ let settled = false;
122
+ const timer = setTimeout(() => {
123
+ if (settled) return;
124
+ settled = true;
125
+ try { ws.close(); } catch { /* ignore */ }
126
+ resolve({ ok: false, error: 'timeout waiting for page load (' + LOAD_TIMEOUT_MS + 'ms)' });
127
+ }, LOAD_TIMEOUT_MS);
128
+ if (timer && timer.unref) timer.unref();
129
+
130
+ function finish(value) {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ try { ws.close(); } catch { /* ignore */ }
135
+ resolve(value);
136
+ }
137
+
138
+ ws.on('open', () => {
139
+ // Enable Page domain so loadEventFired frames reach us. The
140
+ // detector is opened bare; no Page.captureScreenshot call is
141
+ // made here because the target endpoint (used for the
142
+ // capture) might have vanished — the runner does a separate
143
+ // sendTargetCommand below.
144
+ try {
145
+ ws.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }));
146
+ } catch (e) { finish({ ok: false, error: e.message || String(e) }); }
147
+ });
148
+ ws.on('message', (data) => {
149
+ let msg;
150
+ try { msg = JSON.parse(data.toString()); } catch { return; }
151
+ // Skip our `Page.enable` response (which has msg.id === 1 and
152
+ // no method); only Page.loadEventFired events resolve the
153
+ // load gate.
154
+ if (msg && msg.method === 'Page.loadEventFired' && !msg.id) {
155
+ finish({ ok: true, params: msg.params || {} });
156
+ }
157
+ });
158
+ ws.on('error', (e) => {
159
+ // Connection failures are common (Chrome restarts mid-call).
160
+ // Surface as a soft fail so the caller falls through to the
161
+ // capture phase; a partial page is better than nothing for a
162
+ // preview thumbnail.
163
+ finish({ ok: false, error: (e && e.message) || 'ws error' });
164
+ });
165
+ ws.on('close', () => { finish({ ok: false, error: 'ws closed before load' }); });
166
+
167
+ if (signal) {
168
+ if (signal.aborted) finish({ ok: false, error: 'aborted' });
169
+ else signal.addEventListener('abort', () => finish({ ok: false, error: 'aborted' }), { once: true });
170
+ }
171
+ });
172
+ }
173
+
174
+ // A short, blocking sleep used for the post-load grace window.
175
+ function sleep(ms) {
176
+ return new Promise((resolve) => {
177
+ const t = setTimeout(resolve, ms);
178
+ if (t && t.unref) t.unref();
179
+ });
180
+ }
181
+
182
+ // Compose a positive integer ≤ max from any value (NaN/negative -> max).
183
+ function clampPositive(value, max) {
184
+ const n = Number(value);
185
+ if (!isFinite(n) || n <= 0) return max;
186
+ return Math.min(Math.round(n), max);
187
+ }
188
+ // Clamp a custom WIDTH/HEIGHT into the supported capture range. Below the
189
+ // minimum a capture is useless (and a 1x1 thumbnail reads as broken), and
190
+ // above MAX_VIEWPORT_DIM it blows past the image byte cap.
191
+ function clampDim(value) {
192
+ const n = Math.round(Number(value));
193
+ if (!isFinite(n) || n <= 0) return MIN_VIEWPORT_DIM;
194
+ return Math.min(MAX_VIEWPORT_DIM, Math.max(MIN_VIEWPORT_DIM, n));
195
+ }
196
+ // Normalize a viewport argument into a concrete capture rectangle.
197
+ //
198
+ // Accepts either a known preset id (its `mobile` semantics are kept) or a
199
+ // `WIDTHxHEIGHT` string (e.g. '1280x800' / '1280×800'), which defaults to a
200
+ // desktop-style (non-mobile) capture. Unknown or malformed values fall back
201
+ // to the default phone preset so a bad model arg never breaks the tool.
202
+ function resolveViewport(raw) {
203
+ const input = raw || DEFAULT_VIEWPORT_ID;
204
+ if (typeof input === 'string') {
205
+ const key = String(input).trim().toLowerCase();
206
+ if (VIEWPORTS[key]) return VIEWPORTS[key];
207
+ // Allow '1280x800' and '1280×800' and lowercase / spaced forms.
208
+ const m = String(input).match(/^\s*(\d+)\s*[x×]\s*(\d+)\s*$/i);
209
+ if (m) {
210
+ const width = clampDim(Number(m[1]));
211
+ const height = clampDim(Number(m[2]));
212
+ return { id: 'custom', label: width + '×' + height, width, height, mobile: false };
213
+ }
214
+ }
215
+ return DEFAULT_VIEWPORT;
216
+ }
217
+ // Safe presentational label for a viewport, used in the tool summary.
218
+ function viewportLabel(vp) {
219
+ if (!vp) return DEFAULT_VIEWPORT.label;
220
+ return (vp.label || (vp.width + '×' + vp.height));
221
+ }
222
+
223
+ // runWebpreview — open URL, wait for load, screenshot, close tab.
224
+ //
225
+ // `opts.signal` is the parent AbortSignal (the AI client's running
226
+ // chat). A signal abort unwinds the WS-less waits before the next
227
+ // CDP round-trip; we still close the opened tab in `finally` so the
228
+ // session never leaks Chrome tabs on a cancelled run.
229
+ async function runWebpreview(opts) {
230
+ const url = parseUrl(opts && opts.url);
231
+ const finalUrl = url.href;
232
+ // Resolve the capture size up front so both the emulation step and the
233
+ // result meta report the same rectangle the user (or model) asked for.
234
+ const captureVp = resolveViewport(opts && opts.viewport);
235
+
236
+ let target;
237
+ try {
238
+ target = await openInspectorTarget(null, finalUrl);
239
+ } catch (e) {
240
+ return {
241
+ ok: false,
242
+ content: JSON.stringify({ error: { code: e.code || 'EUPSTREAM', message: 'Could not open ' + finalUrl + ': ' + (e.message || String(e)) } }),
243
+ result: { error: (e.message || String(e)), code: e.code || 'EUPSTREAM', url: finalUrl }
244
+ };
245
+ }
246
+ const targetId = target && target.id;
247
+ const wsUrl = target && target.webSocketDebuggerUrl;
248
+ if (!targetId || !wsUrl) {
249
+ return {
250
+ ok: false,
251
+ content: JSON.stringify({ error: { code: 'EUPSTREAM', message: 'Chrome target missing webSocketDebuggerUrl' } }),
252
+ result: { error: 'missing webSocketDebuggerUrl', url: finalUrl, targetId: targetId || null }
253
+ };
254
+ }
255
+
256
+ try {
257
+ const loadResult = await waitForLoad(wsUrl, opts && opts.signal);
258
+ // Soft-fail: a timeout or ws error here does not abort the
259
+ // capture. The page may have rendered before we subscribed to
260
+ // Page events (Chrome refreshed /json/version mid-call, or we
261
+ // attached just after the load event fired). A thumbnail of the
262
+ // current viewport is the right fallback.
263
+ if (!loadResult.ok) {
264
+ // no-op; documented above.
265
+ }
266
+ // Emulate the chosen viewport. Applying a metrics override triggers
267
+ // responsive media-query reflow immediately, so a reload is unnecessary
268
+ // and would add another load-timeout cycle.
269
+ try {
270
+ await sendTargetCommand(null, targetId, 'Emulation.setDeviceMetricsOverride', {
271
+ width: captureVp.width,
272
+ height: captureVp.height,
273
+ deviceScaleFactor: 1,
274
+ mobile: !!captureVp.mobile,
275
+ screenWidth: captureVp.width,
276
+ screenHeight: captureVp.height
277
+ });
278
+ } catch { /* capture still works at the native viewport */ }
279
+ // Post-emulation grace. Tiny enough to feel instant, enough that
280
+ // responsive layout, fonts, and one line of async content have settled.
281
+ await sleep(POST_LOAD_GRACE_MS);
282
+
283
+ // Re-read the real layout after the override so the capture clip matches
284
+ // what the page actually laid out to (the override width/height are the
285
+ // target, but a page can report a smaller clientWidth when the emulation
286
+ // is not honoured). Fall back to the requested rectangle when metrics are
287
+ // unavailable.
288
+ let viewport = { width: captureVp.width, height: captureVp.height, dpr: 1 };
289
+ try {
290
+ const metrics = await sendTargetCommand(null, targetId, 'Page.getLayoutMetrics');
291
+ const layout = (metrics && metrics.layoutViewport) || {};
292
+ const width = clampPositive(layout.clientWidth, captureVp.width) || captureVp.width;
293
+ const height = clampPositive(layout.clientHeight, captureVp.height) || captureVp.height;
294
+ // devicePixelRatio is absent from some Chrome builds (e.g. the
295
+ // headless shell used for the Inspector). Default to 1 so the
296
+ // clip scale below stays 1:1 instead of shrinking to a fraction.
297
+ const dpr = (metrics && metrics.devicePixelRatio > 0)
298
+ ? clampPositive(metrics.devicePixelRatio, 4)
299
+ : 1;
300
+ viewport = { width, height, dpr };
301
+ } catch { /* keep the requested size if getLayoutMetrics isn't supported */ }
302
+
303
+ // Capture a viewport-sized JPEG. The clip rectangle is in CSS
304
+ // pixels, scaled by 1/devicePixelRatio so a HiDPI page is
305
+ // captured at its CSS size, not its raw pixel size (which would
306
+ // blow past the requested width for a 2x DPR display).
307
+ const capW = Math.min(captureVp.width, viewport.width);
308
+ const capH = Math.min(captureVp.height, viewport.height);
309
+ const capResult = await sendTargetCommand(null, targetId, 'Page.captureScreenshot', {
310
+ format: 'jpeg',
311
+ quality: 70,
312
+ clip: {
313
+ x: 0, y: 0, width: capW, height: capH,
314
+ scale: 1 / Math.max(1, viewport.dpr)
315
+ }
316
+ }).catch((e) => ({ __capError: e }));
317
+
318
+ if (!capResult || capResult.__capError) {
319
+ const msg = (capResult && capResult.__capError && capResult.__capError.message) || 'capture failed';
320
+ return {
321
+ ok: false,
322
+ content: JSON.stringify({ error: { code: 'EUPSTREAM', message: 'screenshot failed: ' + msg } }),
323
+ result: { error: 'screenshot failed: ' + msg, url: finalUrl, targetId }
324
+ };
325
+ }
326
+
327
+ const data = capResult && capResult.data;
328
+ if (!data || typeof data !== 'string') {
329
+ return {
330
+ ok: false,
331
+ content: JSON.stringify({ error: { code: 'EUPSTREAM', message: 'CDP returned no screenshot data' } }),
332
+ result: { error: 'no screenshot data', url: finalUrl, targetId }
333
+ };
334
+ }
335
+
336
+ // Decode the base64 payload to enforce a size cap before
337
+ // round-tripping it back through the model. We never echo raw
338
+ // CDP output; if the cap is exceeded the user-facing error is
339
+ // explicit so they can retry with a smaller window.
340
+ let decoded;
341
+ try { decoded = Buffer.from(data, 'base64'); }
342
+ catch { /* malformed base64 — fall through to typed error */ }
343
+ if (!decoded || !decoded.length) {
344
+ return {
345
+ ok: false,
346
+ content: JSON.stringify({ error: { code: 'EPARSE', message: 'screenshot data was not base64' } }),
347
+ result: { error: 'invalid base64', url: finalUrl, targetId }
348
+ };
349
+ }
350
+ if (decoded.length > MAX_IMAGE_BYTES) {
351
+ return {
352
+ ok: false,
353
+ content: JSON.stringify({ error: { code: 'ETOOLARGE', message: 'screenshot exceeded ' + MAX_IMAGE_BYTES + ' bytes', size: decoded.length } }),
354
+ result: { error: 'screenshot too large', size: decoded.length, url: finalUrl, targetId }
355
+ };
356
+ }
357
+
358
+ // Pick a friendly title from the captured page. The open-time
359
+ // target record often reports '' for a fresh tab, so re-fetch the
360
+ // target list after load — Chrome refreshes the record's title once
361
+ // the page has navigated. Fall back to the hostname only when no
362
+ // title is discoverable so the chat card never shows an empty label.
363
+ let title = (target && target.title) || '';
364
+ if (!title) {
365
+ try {
366
+ const list = await fetchInspectorTargets(null);
367
+ const live = Array.isArray(list) && list.find((x) => x && x.id === targetId);
368
+ if (live && typeof live.title === 'string' && live.title.trim()) title = live.title;
369
+ } catch { /* title is cosmetic; keep the hostname fallback */ }
370
+ }
371
+ if (!title) title = url.hostname;
372
+ const dataUrl = 'data:image/jpeg;base64,' + data;
373
+ // The viewport the capture was taken at. `viewportLabel` uses the friendly
374
+ // preset label when the size came from a preset; a custom size shows the
375
+ // `WxH` string so the user knows exactly what they picked.
376
+ const viewportMeta = {
377
+ id: captureVp.id,
378
+ label: viewportLabel(captureVp),
379
+ width: capW,
380
+ height: capH
381
+ };
382
+ // Result shape the chat UI renders: thumbnail + meta. The screenshot is
383
+ // user-facing only: src/ai-stream.js emits the rich result to the UI but does
384
+ // not append its image bytes to the model conversation. The model receives
385
+ // the compact summary below and can call webpreview again to refresh it.
386
+ const result = {
387
+ ok: true,
388
+ url: finalUrl,
389
+ title,
390
+ sizeBytes: decoded.length,
391
+ width: capW,
392
+ height: capH,
393
+ viewport: viewportMeta,
394
+ capturedAt: new Date().toISOString(),
395
+ thumbnail: dataUrl,
396
+ targetId
397
+ };
398
+ const summary = { ok: true, url: finalUrl, title, sizeBytes: decoded.length, width: capW, height: capH, viewport: viewportMeta, targetId };
399
+ return { ok: true, content: JSON.stringify(summary), result };
400
+ } finally {
401
+ // Don't leak Chrome tabs. Closing is best-effort; a failure
402
+ // (Chrome already restarted the target) does not change the
403
+ // result we return.
404
+ try { await closeInspectorTarget(null, targetId); }
405
+ catch { /* tab might have been closed already */ }
406
+ }
407
+ }
408
+
409
+ const SPEC = {
410
+ type: 'function',
411
+ function: {
412
+ name: 'webpreview',
413
+ // Description lists the contract the model sees: which URL
414
+ // shapes are accepted, where the screenshot comes from, and
415
+ // how the user is going to see it. Keep the wording sharp —
416
+ // it is part of the model's tool-pick decision tree.
417
+ description: 'Refresh the user-facing preview of a web URL in the debug Chrome used by the Inspector tab. ' +
418
+ 'The screenshot is shown only to the user in a small dock between the chat scroll and textbox; tapping it opens the full image. ' +
419
+ 'Call this tool again with the URL whenever the user preview should reload. The screenshot is not returned to you for visual analysis. ' +
420
+ 'Only http and https URLs are accepted. Optionally set `viewport` to capture at a different size: ' +
421
+ 'a preset id ("phone", "phone+", "tablet", "laptop") or a "WIDTHxHEIGHT" string (e.g. "1280x800").',
422
+ parameters: {
423
+ type: 'object',
424
+ properties: {
425
+ url: { type: 'string', description: 'HTTP or HTTPS URL to load. Required.' },
426
+ viewport: {
427
+ type: 'string',
428
+ description: 'Capture size. One of "phone" (375x667), "phone+" (414x896), "tablet" (768x1024), "laptop" (1280x800), or a "WIDTHxHEIGHT" string. Defaults to "phone".'
429
+ }
430
+ },
431
+ required: ['url'],
432
+ additionalProperties: false
433
+ }
434
+ }
435
+ };
436
+
437
+ module.exports = {
438
+ SPEC,
439
+ runWebpreview,
440
+ // exported for tests
441
+ parseUrl,
442
+ resolveViewport,
443
+ VIEWPORTS,
444
+ DEFAULT_VIEWPORT_ID,
445
+ THUMB_WIDTH_MAX,
446
+ THUMB_HEIGHT_MAX,
447
+ MAX_IMAGE_BYTES
448
+ };
package/src/trace.js ADDED
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // Per-chat trace writer — the per-chat NDJSON export described in
4
+ // docs/decisions.md section 5. Path: <projectDir>/.mouaif/traces/<chatId>.ndjson.
5
+ // Format: NDJSON, one event per line, append-only. No rotation, no
6
+ // auto-cleanup (decision section 5). If the chat's `trace` flag is
7
+ // false, the writer is a no-op. Each line is one event:
8
+ //
9
+ // { ts: '2026-07-14T12:34:00.000Z', type: 'message' | 'done' | 'error' | ...,
10
+ // ...payload }
11
+ //
12
+ // The filename identifies the chat, so the chatId is not duplicated on
13
+ // every line.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { assertChatId } = require('./messages.js');
18
+
19
+ function traceFilePath(projectDir, chatId) {
20
+ if (!projectDir || typeof projectDir !== 'string') {
21
+ throw new TypeError('projectDir must be a non-empty string');
22
+ }
23
+ assertChatId(chatId);
24
+ return path.join(projectDir, '.mouaif', 'traces', chatId + '.ndjson');
25
+ }
26
+
27
+ function open(projectDir, chatId) {
28
+ // Opens the trace file in append mode and returns a writer. Returns
29
+ // null if traceDir cannot be created (the project may be on a
30
+ // read-only filesystem; the writer becomes a no-op rather than
31
+ // throwing on every event).
32
+ const file = traceFilePath(projectDir, chatId);
33
+ try {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ } catch (e) {
36
+ return null;
37
+ }
38
+ try {
39
+ return fs.createWriteStream(file, { flags: 'a', encoding: 'utf8' });
40
+ } catch (e) {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function write(stream, type, payload) {
46
+ if (!stream) return; // no-op writer
47
+ if (stream.destroyed || stream.writableEnded) return;
48
+ const line = JSON.stringify(Object.assign({ ts: new Date().toISOString(), type }, payload || {})) + '\n';
49
+ try { stream.write(line); } catch { /* swallow */ }
50
+ }
51
+
52
+ function close(stream) {
53
+ if (!stream) return;
54
+ try { stream.end(); } catch { /* swallow */ }
55
+ }
56
+
57
+ function eventForMessage(message) {
58
+ const role = message && message.role;
59
+ if (role === 'user') return {
60
+ type: 'user_message',
61
+ payload: { role, content: message.content, attachments: message.attachments, ts: message.ts }
62
+ };
63
+ if (role === 'assistant') return {
64
+ type: 'assistant_message',
65
+ payload: {
66
+ role,
67
+ content: message.content,
68
+ ts: message.ts,
69
+ usage: message.usage,
70
+ cost: message.cost,
71
+ streamingMs: message.streamingMs,
72
+ modelId: message.modelId
73
+ }
74
+ };
75
+ if (role === 'tool') return {
76
+ type: message.phase === 'call' ? 'tool_call' : 'tool_result',
77
+ payload: Object.assign({}, message, { type: undefined })
78
+ };
79
+ return { type: 'system', payload: { role: role || 'system', content: message && message.content, ts: message && message.ts } };
80
+ }
81
+
82
+ function exportMessages(projectDir, chatId, list) {
83
+ const file = traceFilePath(projectDir, chatId);
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ const lines = [];
86
+ for (const message of list || []) {
87
+ const event = eventForMessage(message);
88
+ const payload = Object.assign({}, event.payload);
89
+ delete payload.type;
90
+ lines.push(JSON.stringify(Object.assign({ ts: payload.ts || new Date().toISOString(), type: event.type }, payload)));
91
+ }
92
+ fs.writeFileSync(file, lines.length ? lines.join('\n') + '\n' : '', 'utf8');
93
+ return file;
94
+ }
95
+
96
+ module.exports = {
97
+ traceFilePath,
98
+ open,
99
+ write,
100
+ close,
101
+ eventForMessage,
102
+ exportMessages
103
+ };