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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|