claude-code-session-manager 0.35.1 → 0.35.3
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/dist/assets/{TiptapBody-4v5sETwF.js → TiptapBody-DmCekkUQ.js} +1 -1
- package/dist/assets/index-CHKMzzCM.js +3558 -0
- package/dist/assets/index-DVqmrWP3.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
- package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +10 -6
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +3 -2
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +12 -7
- package/src/main/__tests__/adminServer.test.cjs +175 -0
- package/src/main/__tests__/runVerify.test.cjs +74 -4
- package/src/main/adminServer.cjs +157 -0
- package/src/main/browserCapture.cjs +714 -0
- package/src/main/browserView.cjs +613 -0
- package/src/main/config.cjs +37 -1
- package/src/main/historyAggregator.cjs +92 -6
- package/src/main/index.cjs +32 -3
- package/src/main/ipcSchemas.cjs +106 -0
- package/src/main/lib/schedulerConfig.cjs +6 -2
- package/src/main/runVerify.cjs +14 -1
- package/src/main/scheduler.cjs +6 -1
- package/src/main/sessionsStore.cjs +4 -3
- package/src/preload/api.d.ts +129 -5
- package/src/preload/browserViewPreload.cjs +67 -0
- package/src/preload/index.cjs +55 -5
- package/dist/assets/index-1DFsiqCu.js +0 -3534
- package/dist/assets/index-CPTin6qz.css +0 -32
- package/src/main/docEditor.cjs +0 -92
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM Capture element picker (PRD 403). Foundation for the capture panel
|
|
3
|
+
* (PRD 406): while capture mode is active, injects a hover-highlight overlay
|
|
4
|
+
* into the embedded page and lets the user hover/click to build a selection,
|
|
5
|
+
* then reports a robust selector back to the renderer.
|
|
6
|
+
*
|
|
7
|
+
* The view is a sandboxed WebContentsView with no reachable app globals, so
|
|
8
|
+
* the picker itself has to be a self-contained IIFE delivered via
|
|
9
|
+
* `webContents.executeJavaScript(PICKER_SCRIPT, true)` — it cannot assume
|
|
10
|
+
* any preload bridge. Getting picks back out of that unprivileged context:
|
|
11
|
+
* the injected script buffers events on `window.__smPicker` and this module
|
|
12
|
+
* polls `window.__smPicker.drain()` on an interval while picking is active
|
|
13
|
+
* (the "polling drain" option called out in the PRD notes — simpler and
|
|
14
|
+
* more robust than scraping `console-message` for a sentinel prefix, and
|
|
15
|
+
* needs no wiring beyond executeJavaScript, which this module already uses
|
|
16
|
+
* for start/stop/drain).
|
|
17
|
+
*
|
|
18
|
+
* SELECTOR_CHAIN_SOURCE is the single source of truth for the selector
|
|
19
|
+
* fallback chain ([data-testid] -> [id] -> role+accessible-name -> minimal
|
|
20
|
+
* unique CSS path -> nth-of-type XPath). PRD 408's recorder injection should
|
|
21
|
+
* reuse this exact constant rather than growing a second selector generator.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
25
|
+
|
|
26
|
+
let win = null;
|
|
27
|
+
|
|
28
|
+
function attachWindow(mainWindow) {
|
|
29
|
+
win = mainWindow;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Selector chain (single source of truth — see file header) ─────────
|
|
33
|
+
const SELECTOR_CHAIN_SOURCE = `
|
|
34
|
+
function __smCssPath(el) {
|
|
35
|
+
var parts = [];
|
|
36
|
+
var node = el;
|
|
37
|
+
while (node && node.nodeType === 1 && node !== document.documentElement) {
|
|
38
|
+
var tag = node.tagName.toLowerCase();
|
|
39
|
+
var parent = node.parentElement;
|
|
40
|
+
var seg = tag;
|
|
41
|
+
if (parent) {
|
|
42
|
+
var siblings = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
|
|
43
|
+
if (siblings.length > 1) seg += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
44
|
+
}
|
|
45
|
+
parts.unshift(seg);
|
|
46
|
+
var candidate = parts.join(' > ');
|
|
47
|
+
try {
|
|
48
|
+
if (document.querySelectorAll(candidate).length === 1) return candidate;
|
|
49
|
+
} catch (e) {}
|
|
50
|
+
node = parent;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function __smXPath(el) {
|
|
55
|
+
var xparts = [];
|
|
56
|
+
var node = el;
|
|
57
|
+
while (node && node.nodeType === 1) {
|
|
58
|
+
var tag = node.tagName.toLowerCase();
|
|
59
|
+
var parent = node.parentElement;
|
|
60
|
+
var index = 1;
|
|
61
|
+
if (parent) {
|
|
62
|
+
var siblings = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
|
|
63
|
+
index = siblings.indexOf(node) + 1;
|
|
64
|
+
}
|
|
65
|
+
xparts.unshift(tag + '[' + index + ']');
|
|
66
|
+
node = parent;
|
|
67
|
+
}
|
|
68
|
+
return '/' + xparts.join('/');
|
|
69
|
+
}
|
|
70
|
+
function __smSelectorFor(el) {
|
|
71
|
+
if (!el || el.nodeType !== 1) return '';
|
|
72
|
+
try {
|
|
73
|
+
var testId = el.getAttribute && el.getAttribute('data-testid');
|
|
74
|
+
if (testId) return '[data-testid=' + JSON.stringify(testId) + ']';
|
|
75
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
76
|
+
var role = el.getAttribute && el.getAttribute('role');
|
|
77
|
+
var ariaLabel = el.getAttribute && el.getAttribute('aria-label');
|
|
78
|
+
if (role && ariaLabel) return '[role=' + JSON.stringify(role) + '][aria-label=' + JSON.stringify(ariaLabel) + ']';
|
|
79
|
+
var css = __smCssPath(el);
|
|
80
|
+
if (css) return css;
|
|
81
|
+
return __smXPath(el);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return el.tagName ? el.tagName.toLowerCase() : '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
// ── Injected picker overlay ─────────────────────────────────────────
|
|
89
|
+
// Neutral high-contrast accent (matches docs/design/browser-tab.design.jsx
|
|
90
|
+
// terracotta) since the highlight renders inside third-party page content.
|
|
91
|
+
const PICKER_SCRIPT = `
|
|
92
|
+
(function () {
|
|
93
|
+
if (window.__smPicker) return;
|
|
94
|
+
${SELECTOR_CHAIN_SOURCE}
|
|
95
|
+
|
|
96
|
+
var ACCENT = '#b85c34';
|
|
97
|
+
var events = [];
|
|
98
|
+
var hovered = null;
|
|
99
|
+
var selected = new Map();
|
|
100
|
+
|
|
101
|
+
function labelFor(el) {
|
|
102
|
+
var text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
103
|
+
return text.slice(0, 60);
|
|
104
|
+
}
|
|
105
|
+
function rectOf(el) {
|
|
106
|
+
var r = el.getBoundingClientRect();
|
|
107
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
108
|
+
}
|
|
109
|
+
function baseStyle(el, extra) {
|
|
110
|
+
el.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;box-sizing:border-box;' + extra;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
var hoverBox = document.createElement('div');
|
|
114
|
+
baseStyle(hoverBox, 'border:2px dashed ' + ACCENT + ';display:none;');
|
|
115
|
+
var selectedLayer = document.createElement('div');
|
|
116
|
+
baseStyle(selectedLayer, '');
|
|
117
|
+
var labelEl = document.createElement('div');
|
|
118
|
+
baseStyle(labelEl, 'background:' + ACCENT + ';color:#fff;font:11px/1.4 monospace;padding:2px 6px;border-radius:3px;display:none;white-space:nowrap;');
|
|
119
|
+
document.documentElement.appendChild(hoverBox);
|
|
120
|
+
document.documentElement.appendChild(selectedLayer);
|
|
121
|
+
document.documentElement.appendChild(labelEl);
|
|
122
|
+
|
|
123
|
+
function isOverlay(el) {
|
|
124
|
+
return el === hoverBox || el === selectedLayer || el === labelEl || selectedLayer.contains(el);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function drawSelected() {
|
|
128
|
+
selectedLayer.innerHTML = '';
|
|
129
|
+
selected.forEach(function (_sel, el) {
|
|
130
|
+
if (!el.isConnected) return;
|
|
131
|
+
var r = el.getBoundingClientRect();
|
|
132
|
+
var box = document.createElement('div');
|
|
133
|
+
box.style.cssText = 'position:fixed;left:' + r.x + 'px;top:' + r.y + 'px;width:' + r.width + 'px;height:' + r.height + 'px;border:2px solid ' + ACCENT + ';box-sizing:border-box;pointer-events:none;';
|
|
134
|
+
selectedLayer.appendChild(box);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function positionHover(el) {
|
|
139
|
+
var r = el.getBoundingClientRect();
|
|
140
|
+
hoverBox.style.display = 'block';
|
|
141
|
+
hoverBox.style.left = r.x + 'px';
|
|
142
|
+
hoverBox.style.top = r.y + 'px';
|
|
143
|
+
hoverBox.style.width = r.width + 'px';
|
|
144
|
+
hoverBox.style.height = r.height + 'px';
|
|
145
|
+
labelEl.style.display = 'block';
|
|
146
|
+
labelEl.style.left = r.x + 'px';
|
|
147
|
+
labelEl.style.top = Math.max(0, r.y - 20) + 'px';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function onMove(e) {
|
|
151
|
+
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
152
|
+
if (!el || isOverlay(el)) {
|
|
153
|
+
hoverBox.style.display = 'none';
|
|
154
|
+
labelEl.style.display = 'none';
|
|
155
|
+
hovered = null;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (el === hovered) {
|
|
159
|
+
positionHover(el);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
hovered = el;
|
|
163
|
+
var sel = __smSelectorFor(el);
|
|
164
|
+
positionHover(el);
|
|
165
|
+
labelEl.textContent = sel;
|
|
166
|
+
events.push({ type: 'hover', selector: sel, label: labelFor(el), tag: el.tagName.toLowerCase(), rect: rectOf(el) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onClick(e) {
|
|
170
|
+
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
171
|
+
if (!el || isOverlay(el)) return;
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
e.stopPropagation();
|
|
174
|
+
var sel = __smSelectorFor(el);
|
|
175
|
+
var payload = { selector: sel, label: labelFor(el), tag: el.tagName.toLowerCase(), rect: rectOf(el) };
|
|
176
|
+
if (e.metaKey || e.ctrlKey) {
|
|
177
|
+
if (selected.has(el)) {
|
|
178
|
+
selected.delete(el);
|
|
179
|
+
events.push(Object.assign({ type: 'unpick' }, payload));
|
|
180
|
+
} else {
|
|
181
|
+
selected.set(el, sel);
|
|
182
|
+
events.push(Object.assign({ type: 'pick' }, payload));
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
selected.forEach(function (prevSel, prevEl) {
|
|
186
|
+
if (prevEl === el) return;
|
|
187
|
+
events.push({ type: 'unpick', selector: prevSel, label: labelFor(prevEl), tag: prevEl.tagName.toLowerCase(), rect: rectOf(prevEl) });
|
|
188
|
+
});
|
|
189
|
+
selected.clear();
|
|
190
|
+
selected.set(el, sel);
|
|
191
|
+
events.push(Object.assign({ type: 'pick', replace: true }, payload));
|
|
192
|
+
}
|
|
193
|
+
drawSelected();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function onKeyDown(e) {
|
|
197
|
+
if (e.key === 'Escape') {
|
|
198
|
+
events.push({ type: 'exit' });
|
|
199
|
+
teardown();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function teardown() {
|
|
204
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
205
|
+
document.removeEventListener('click', onClick, true);
|
|
206
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
207
|
+
hoverBox.remove();
|
|
208
|
+
selectedLayer.remove();
|
|
209
|
+
labelEl.remove();
|
|
210
|
+
delete window.__smPicker;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
document.addEventListener('mousemove', onMove, true);
|
|
214
|
+
document.addEventListener('click', onClick, true);
|
|
215
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
216
|
+
|
|
217
|
+
window.__smPicker = {
|
|
218
|
+
drain: function () {
|
|
219
|
+
var out = events;
|
|
220
|
+
events = [];
|
|
221
|
+
return out;
|
|
222
|
+
},
|
|
223
|
+
stop: teardown,
|
|
224
|
+
};
|
|
225
|
+
})();
|
|
226
|
+
`;
|
|
227
|
+
|
|
228
|
+
const POLL_MS = 120;
|
|
229
|
+
|
|
230
|
+
/** @type {Map<string, { interval: NodeJS.Timeout, reinject: () => void, onDestroyed: () => void, wc: Electron.WebContents }>} */
|
|
231
|
+
const pickingState = new Map();
|
|
232
|
+
|
|
233
|
+
function cleanup(viewId) {
|
|
234
|
+
const state = pickingState.get(viewId);
|
|
235
|
+
if (!state) return;
|
|
236
|
+
clearInterval(state.interval);
|
|
237
|
+
try {
|
|
238
|
+
state.wc.removeListener('did-finish-load', state.reinject);
|
|
239
|
+
state.wc.removeListener('destroyed', state.onDestroyed);
|
|
240
|
+
} catch {
|
|
241
|
+
// webContents already gone
|
|
242
|
+
}
|
|
243
|
+
pickingState.delete(viewId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const EVENT_TYPES = new Set(['hover', 'pick', 'unpick', 'exit']);
|
|
247
|
+
const STR_MAX = 300;
|
|
248
|
+
|
|
249
|
+
// The drained payload comes out of the embedded page's own JS context — a
|
|
250
|
+
// hostile or compromised site could shadow `window.__smPicker` and feed us
|
|
251
|
+
// arbitrary data instead of a real pick. Bound and type-check every field
|
|
252
|
+
// before it reaches the renderer, mirroring handleRecordEvent's treatment of
|
|
253
|
+
// the (similarly page-controlled) recorder event stream in browserView.cjs.
|
|
254
|
+
function sanitizeEvent(raw) {
|
|
255
|
+
if (!raw || typeof raw !== 'object' || !EVENT_TYPES.has(raw.type)) return null;
|
|
256
|
+
const ev = { type: raw.type };
|
|
257
|
+
if (typeof raw.selector === 'string') ev.selector = raw.selector.slice(0, STR_MAX);
|
|
258
|
+
if (typeof raw.label === 'string') ev.label = raw.label.slice(0, STR_MAX);
|
|
259
|
+
if (typeof raw.tag === 'string') ev.tag = raw.tag.slice(0, 32);
|
|
260
|
+
if (raw.replace === true) ev.replace = true;
|
|
261
|
+
if (raw.rect && typeof raw.rect === 'object') {
|
|
262
|
+
const { x, y, width, height } = raw.rect;
|
|
263
|
+
if ([x, y, width, height].every((n) => typeof n === 'number' && Number.isFinite(n))) {
|
|
264
|
+
ev.rect = { x, y, width, height };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return ev;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function pollOnce(viewId, wc) {
|
|
271
|
+
if (wc.isDestroyed()) {
|
|
272
|
+
cleanup(viewId);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
let events;
|
|
276
|
+
try {
|
|
277
|
+
events = await wc.executeJavaScript('window.__smPicker ? window.__smPicker.drain() : null');
|
|
278
|
+
} catch {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(events) || !events.length) return;
|
|
282
|
+
for (const raw of events) {
|
|
283
|
+
const ev = sanitizeEvent(raw);
|
|
284
|
+
if (!ev) continue;
|
|
285
|
+
sendIfAlive(win, `browser:picker-event:${viewId}`, ev);
|
|
286
|
+
if (ev.type === 'exit') {
|
|
287
|
+
cleanup(viewId);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function pickerStart({ viewId }, getView) {
|
|
294
|
+
const view = getView(viewId);
|
|
295
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
296
|
+
if (pickingState.has(viewId)) return { ok: true };
|
|
297
|
+
const wc = view.webContents;
|
|
298
|
+
try {
|
|
299
|
+
await wc.executeJavaScript(PICKER_SCRIPT, true);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
302
|
+
}
|
|
303
|
+
// Page JS context is torn down on every navigation — re-inject so a
|
|
304
|
+
// picking session survives the user navigating while picking.
|
|
305
|
+
const reinject = () => {
|
|
306
|
+
if (!pickingState.has(viewId) || wc.isDestroyed()) return;
|
|
307
|
+
wc.executeJavaScript(PICKER_SCRIPT, true).catch(() => {});
|
|
308
|
+
};
|
|
309
|
+
wc.on('did-finish-load', reinject);
|
|
310
|
+
const onDestroyed = () => cleanup(viewId);
|
|
311
|
+
wc.once('destroyed', onDestroyed);
|
|
312
|
+
const interval = setInterval(() => pollOnce(viewId, wc), POLL_MS);
|
|
313
|
+
pickingState.set(viewId, { interval, reinject, onDestroyed, wc });
|
|
314
|
+
return { ok: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function pickerStop({ viewId }, getView) {
|
|
318
|
+
const state = pickingState.get(viewId);
|
|
319
|
+
cleanup(viewId);
|
|
320
|
+
const view = getView(viewId);
|
|
321
|
+
if (view && !view.webContents.isDestroyed()) {
|
|
322
|
+
try {
|
|
323
|
+
await view.webContents.executeJavaScript('window.__smPicker && window.__smPicker.stop()');
|
|
324
|
+
} catch {
|
|
325
|
+
// view may have navigated/closed mid-teardown — nothing further to do
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, wasPicking: Boolean(state) };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Capture: filter -> prune -> summarize -> chunk (PRD 404) ──────────
|
|
332
|
+
//
|
|
333
|
+
// The four capture modes ('html' | 'a11y' | 'selector' | 'agent') share one
|
|
334
|
+
// shape: an unprivileged executeJavaScript pass serializes the selected
|
|
335
|
+
// subtree (or queries outerHTML / resolves a CDP node) into a plain JSON
|
|
336
|
+
// value, and everything downstream of that is pure and unit-testable
|
|
337
|
+
// without a live page — see tests/unit/browserCapture.spec.ts.
|
|
338
|
+
|
|
339
|
+
const NOISE_TAGS = new Set(['script', 'style', 'svg', 'noscript', 'template', 'link', 'meta']);
|
|
340
|
+
const KEEP_ATTRS = new Set(['role', 'name', 'data-testid', 'href', 'type', 'value', 'placeholder']);
|
|
341
|
+
|
|
342
|
+
function isHiddenNode(node) {
|
|
343
|
+
const style = node.style || {};
|
|
344
|
+
if (style.display === 'none' || style.visibility === 'hidden') return true;
|
|
345
|
+
const attrs = node.attrs || {};
|
|
346
|
+
if ('hidden' in attrs) return true;
|
|
347
|
+
if (attrs['aria-hidden'] === 'true') return true;
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function pruneAttrs(attrs) {
|
|
352
|
+
const out = {};
|
|
353
|
+
for (const key of Object.keys(attrs || {})) {
|
|
354
|
+
if (KEEP_ATTRS.has(key) || key.startsWith('aria-')) out[key] = attrs[key];
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// filter: drop noise/hidden nodes, keep interactive + semantic elements and
|
|
360
|
+
// non-empty text. O(n) over the subtree, n = node count in the selection.
|
|
361
|
+
function filterTree(node) {
|
|
362
|
+
if (!node || typeof node !== 'object') return null;
|
|
363
|
+
if (node.type === 'text') {
|
|
364
|
+
const text = (node.text || '').trim();
|
|
365
|
+
return text ? { type: 'text', text } : null;
|
|
366
|
+
}
|
|
367
|
+
const tag = (node.tag || '').toLowerCase();
|
|
368
|
+
if (NOISE_TAGS.has(tag)) return null;
|
|
369
|
+
if (isHiddenNode(node)) return null;
|
|
370
|
+
const children = (node.children || []).map(filterTree).filter(Boolean);
|
|
371
|
+
return { tag, attrs: pruneAttrs(node.attrs), children };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function textContent(node) {
|
|
375
|
+
if (!node) return '';
|
|
376
|
+
if (node.type === 'text') return node.text;
|
|
377
|
+
return (node.children || []).map(textContent).join('').trim();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
381
|
+
const LANDMARK_ROLE_BY_TAG = { header: 'banner', footer: 'contentinfo', nav: 'navigation', main: 'main', section: 'region', article: 'article', aside: 'complementary', form: 'form' };
|
|
382
|
+
|
|
383
|
+
// summarize: flatten the filtered/pruned tree into compact pseudo-markup
|
|
384
|
+
// grouped by landmark/role, matching docs/design/browser-tab.design.jsx's
|
|
385
|
+
// CaptureOutput `agent` sample. O(n) over the (already filtered) subtree.
|
|
386
|
+
function summarizeTree(node, depth = 0) {
|
|
387
|
+
if (!node || node.type === 'text') return '';
|
|
388
|
+
const pad = ' '.repeat(depth);
|
|
389
|
+
const tag = node.tag;
|
|
390
|
+
const attrs = node.attrs || {};
|
|
391
|
+
|
|
392
|
+
if (HEADING_TAGS.has(tag)) {
|
|
393
|
+
return `${pad}<heading level=${tag.slice(1)}>${textContent(node)}</heading>`;
|
|
394
|
+
}
|
|
395
|
+
if (tag === 'button' || tag === 'a') {
|
|
396
|
+
const name = attrs.name || textContent(node);
|
|
397
|
+
const parts = [`name="${name}"`];
|
|
398
|
+
if (attrs['data-testid']) parts.push(`data-testid="${attrs['data-testid']}"`);
|
|
399
|
+
if (tag === 'a' && attrs.href) parts.push(`href="${attrs.href}"`);
|
|
400
|
+
const el = tag === 'a' ? 'link' : 'button';
|
|
401
|
+
return `${pad}<${el} ${parts.join(' ')}>${textContent(node)}</${el}>`;
|
|
402
|
+
}
|
|
403
|
+
if (tag === 'ul' || tag === 'ol') {
|
|
404
|
+
const items = (node.children || []).map((c) => summarizeTree(c, depth + 1)).filter(Boolean).join('\n');
|
|
405
|
+
return `${pad}<list>\n${items}\n${pad}</list>`;
|
|
406
|
+
}
|
|
407
|
+
if (tag === 'li') {
|
|
408
|
+
return `${pad}<item>${textContent(node)}</item>`;
|
|
409
|
+
}
|
|
410
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
411
|
+
const parts = [];
|
|
412
|
+
if (attrs.type) parts.push(`type="${attrs.type}"`);
|
|
413
|
+
if (attrs.placeholder) parts.push(`placeholder="${attrs.placeholder}"`);
|
|
414
|
+
if (attrs.value) parts.push(`value="${attrs.value}"`);
|
|
415
|
+
if (attrs['data-testid']) parts.push(`data-testid="${attrs['data-testid']}"`);
|
|
416
|
+
const attrStr = parts.length ? ' ' + parts.join(' ') : '';
|
|
417
|
+
return `${pad}<field${attrStr}/>`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const role = attrs.role || LANDMARK_ROLE_BY_TAG[tag];
|
|
421
|
+
const children = node.children || [];
|
|
422
|
+
const childText = children.map((c) => summarizeTree(c, depth + 1)).filter(Boolean).join('\n');
|
|
423
|
+
if (role) {
|
|
424
|
+
const label = attrs['aria-label'];
|
|
425
|
+
const openAttr = label ? ` aria-label="${label}"` : '';
|
|
426
|
+
if (!childText) return '';
|
|
427
|
+
return `${pad}<region${openAttr}>\n${childText}\n${pad}</region>`;
|
|
428
|
+
}
|
|
429
|
+
// Plain wrapper (div/span/p/etc): if it's a single-text leaf, emit <text>;
|
|
430
|
+
// otherwise splice children in at this depth without adding a wrapper tag.
|
|
431
|
+
if (children.length === 1 && children[0].type === 'text') {
|
|
432
|
+
return `${pad}<text>${children[0].text}</text>`;
|
|
433
|
+
}
|
|
434
|
+
return childText;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function estimateTokens(text) {
|
|
438
|
+
return Math.ceil(text.length / 4);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// chunk: split on line boundaries so a chunk never exceeds budgetBytes.
|
|
442
|
+
// O(n) over the text, n = byte length.
|
|
443
|
+
function chunkText(text, budgetBytes) {
|
|
444
|
+
if (Buffer.byteLength(text, 'utf8') <= budgetBytes) return [text];
|
|
445
|
+
const lines = text.split(/(?<=\n)/);
|
|
446
|
+
const chunks = [];
|
|
447
|
+
let current = '';
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
if (current && Buffer.byteLength(current + line, 'utf8') > budgetBytes) {
|
|
450
|
+
chunks.push(current);
|
|
451
|
+
current = line;
|
|
452
|
+
} else {
|
|
453
|
+
current += line;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (current) chunks.push(current);
|
|
457
|
+
return chunks;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const DEFAULT_CHUNK_BUDGET_BYTES = 8192;
|
|
461
|
+
|
|
462
|
+
function buildAgentCapture(rawTree, budgetBytes = DEFAULT_CHUNK_BUDGET_BYTES) {
|
|
463
|
+
const filtered = filterTree(rawTree);
|
|
464
|
+
const summary = filtered ? summarizeTree(filtered) : '';
|
|
465
|
+
const chunks = chunkText(summary, budgetBytes);
|
|
466
|
+
const text = chunks[0] || '';
|
|
467
|
+
return { text, meta: { chunks: chunks.length, tokens: estimateTokens(text) } };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function formatHtmlCapture(nodes) {
|
|
471
|
+
return nodes
|
|
472
|
+
.map((n, i) => `<!-- node ${i + 1}: ${n.selector} -->\n${n.outerHTML}`)
|
|
473
|
+
.join('\n\n');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// selector: reuse the SELECTOR_CHAIN_SOURCE precedence (testId > id > role+name
|
|
477
|
+
// > css > xpath) to pick the preferred entry among the fallback chain.
|
|
478
|
+
function formatSelectorChain({ css, xpath, role, name, id, testId } = {}) {
|
|
479
|
+
const lines = [];
|
|
480
|
+
if (css) lines.push({ key: 'css', text: `css ${css}` });
|
|
481
|
+
if (xpath) lines.push({ key: 'xpath', text: `xpath ${xpath}` });
|
|
482
|
+
if (role && name) lines.push({ key: 'aria', text: `aria role=${role} name="${name}"` });
|
|
483
|
+
if (testId) lines.push({ key: 'test', text: `test [data-testid="${testId}"]` });
|
|
484
|
+
|
|
485
|
+
const precedence = testId ? 'test' : id ? 'css' : role && name ? 'aria' : css ? 'css' : 'xpath';
|
|
486
|
+
return lines
|
|
487
|
+
.map((l) => (l.key === precedence ? `${l.text} ← preferred` : l.text))
|
|
488
|
+
.join('\n');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// a11y: format a normalized CDP AX-tree node (role/name/level/states/testId
|
|
492
|
+
// + children) as the indented tree in CaptureOutput's `a11y` sample.
|
|
493
|
+
function formatA11yTree(node, depth = 0) {
|
|
494
|
+
if (!node) return '';
|
|
495
|
+
const pad = ' '.repeat(depth);
|
|
496
|
+
const head = node.name ? `${pad}${node.role} "${node.name}"` : `${pad}${node.role}`;
|
|
497
|
+
const extras = [];
|
|
498
|
+
if (typeof node.level === 'number') extras.push(`level ${node.level}`);
|
|
499
|
+
for (const state of node.states || []) extras.push(state);
|
|
500
|
+
const lines = [extras.length ? `${head} ${extras.join(' ')}` : head];
|
|
501
|
+
if (node.testId) lines.push(`${pad} data-testid=${node.testId}`);
|
|
502
|
+
for (const child of node.children || []) {
|
|
503
|
+
const childText = formatA11yTree(child, depth + 1);
|
|
504
|
+
if (childText) lines.push(childText);
|
|
505
|
+
}
|
|
506
|
+
return lines.join('\n');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── CDP accessibility-tree capture ─────────────────────────────────────
|
|
510
|
+
async function captureA11y(wc, selector) {
|
|
511
|
+
const dbg = wc.debugger;
|
|
512
|
+
await dbg.attach('1.3');
|
|
513
|
+
try {
|
|
514
|
+
await dbg.sendCommand('DOM.enable');
|
|
515
|
+
await dbg.sendCommand('Accessibility.enable');
|
|
516
|
+
const { root } = await dbg.sendCommand('DOM.getDocument');
|
|
517
|
+
const { nodeId } = await dbg.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector });
|
|
518
|
+
if (!nodeId) return null;
|
|
519
|
+
const { nodes } = await dbg.sendCommand('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: true });
|
|
520
|
+
return axNodesToTree(nodes);
|
|
521
|
+
} finally {
|
|
522
|
+
try {
|
|
523
|
+
await dbg.sendCommand('Accessibility.disable');
|
|
524
|
+
await dbg.sendCommand('DOM.disable');
|
|
525
|
+
} catch {
|
|
526
|
+
// best-effort; detach regardless
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
dbg.detach();
|
|
530
|
+
} catch {
|
|
531
|
+
// already detached
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function axPropValue(ax, name) {
|
|
537
|
+
const prop = (ax.properties || []).find((p) => p.name === name);
|
|
538
|
+
return prop ? prop.value.value : undefined;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Accessibility.getPartialAXTree returns a flat array (root first, then
|
|
542
|
+
// relatives referenced by childIds) — rebuild the nested shape formatA11yTree
|
|
543
|
+
// expects. O(n) over the returned node count.
|
|
544
|
+
// AX node names/roles can carry attacker-influenced content (aria-label);
|
|
545
|
+
// bound string length, and cap total nodes visited to guard against a
|
|
546
|
+
// malformed/cyclic childIds graph causing unbounded recursion.
|
|
547
|
+
const AX_NODE_VISIT_CAP = 2_000;
|
|
548
|
+
const AX_STRING_CAP = 1_000;
|
|
549
|
+
|
|
550
|
+
function axNodesToTree(axNodes) {
|
|
551
|
+
if (!Array.isArray(axNodes) || !axNodes.length) return null;
|
|
552
|
+
const byId = new Map(axNodes.map((n) => [n.nodeId, n]));
|
|
553
|
+
const visited = new Set();
|
|
554
|
+
let count = 0;
|
|
555
|
+
function build(ax) {
|
|
556
|
+
if (!ax || visited.has(ax.nodeId) || count >= AX_NODE_VISIT_CAP) return null;
|
|
557
|
+
visited.add(ax.nodeId);
|
|
558
|
+
count += 1;
|
|
559
|
+
const states = [];
|
|
560
|
+
if (axPropValue(ax, 'focusable')) states.push('focusable');
|
|
561
|
+
if (axPropValue(ax, 'checked')) states.push('checked');
|
|
562
|
+
if (axPropValue(ax, 'expanded')) states.push('expanded');
|
|
563
|
+
if (axPropValue(ax, 'disabled')) states.push('disabled');
|
|
564
|
+
const children = (ax.childIds || []).map((id) => build(byId.get(id))).filter(Boolean);
|
|
565
|
+
return {
|
|
566
|
+
role: capString(ax.role && ax.role.value, AX_STRING_CAP),
|
|
567
|
+
name: capString(ax.name && ax.name.value, AX_STRING_CAP),
|
|
568
|
+
level: axPropValue(ax, 'level'),
|
|
569
|
+
states,
|
|
570
|
+
children,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
return build(axNodes[0]);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Everything below comes back from executeJavaScript running inside the
|
|
577
|
+
// embedded (possibly untrusted third-party) page — same threat model as
|
|
578
|
+
// sanitizeEvent above: a compromised page could return an oversized or
|
|
579
|
+
// malformed payload instead of real capture data. Bound every string field
|
|
580
|
+
// before it reaches formatting/IPC.
|
|
581
|
+
const CAPTURE_HTML_CAP = 200_000;
|
|
582
|
+
const CAPTURE_FIELD_CAP = 4_000;
|
|
583
|
+
const CAPTURE_TEXT_CAP = 20_000;
|
|
584
|
+
const CAPTURE_NODE_CAP = 5_000;
|
|
585
|
+
|
|
586
|
+
function capString(s, max) {
|
|
587
|
+
return typeof s === 'string' ? s.slice(0, max) : s;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function capRawTree(node, budget = { count: 0 }) {
|
|
591
|
+
if (!node || typeof node !== 'object' || budget.count >= CAPTURE_NODE_CAP) return null;
|
|
592
|
+
budget.count += 1;
|
|
593
|
+
if (node.type === 'text') return { type: 'text', text: capString(node.text, CAPTURE_TEXT_CAP) };
|
|
594
|
+
const attrs = {};
|
|
595
|
+
for (const key of Object.keys(node.attrs || {})) {
|
|
596
|
+
attrs[capString(key, 256)] = capString(node.attrs[key], CAPTURE_FIELD_CAP);
|
|
597
|
+
}
|
|
598
|
+
const children = (node.children || [])
|
|
599
|
+
.map((c) => capRawTree(c, budget))
|
|
600
|
+
.filter(Boolean);
|
|
601
|
+
return { tag: capString(node.tag, 64), attrs, style: node.style, children };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Top-level capture dispatch ─────────────────────────────────────────
|
|
605
|
+
async function captureSelection({ viewId, selectors, mode }, getView) {
|
|
606
|
+
const view = getView(viewId);
|
|
607
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
608
|
+
const wc = view.webContents;
|
|
609
|
+
|
|
610
|
+
if (mode === 'html') {
|
|
611
|
+
const nodes = await wc.executeJavaScript(
|
|
612
|
+
`(function () {
|
|
613
|
+
${SELECTOR_CHAIN_SOURCE}
|
|
614
|
+
return (${JSON.stringify(selectors)}).map(function (sel) {
|
|
615
|
+
var el = document.querySelector(sel);
|
|
616
|
+
return el ? { selector: sel, outerHTML: el.outerHTML } : null;
|
|
617
|
+
}).filter(Boolean);
|
|
618
|
+
})()`,
|
|
619
|
+
);
|
|
620
|
+
const capped = (Array.isArray(nodes) ? nodes : []).slice(0, selectors.length).map((n) => ({
|
|
621
|
+
selector: capString(n && n.selector, CAPTURE_FIELD_CAP),
|
|
622
|
+
outerHTML: capString(n && n.outerHTML, CAPTURE_HTML_CAP),
|
|
623
|
+
}));
|
|
624
|
+
const text = formatHtmlCapture(capped);
|
|
625
|
+
return { ok: true, mode, text, meta: {} };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (mode === 'selector') {
|
|
629
|
+
const infos = await wc.executeJavaScript(
|
|
630
|
+
`(function () {
|
|
631
|
+
${SELECTOR_CHAIN_SOURCE}
|
|
632
|
+
return (${JSON.stringify(selectors)}).map(function (sel) {
|
|
633
|
+
var el = document.querySelector(sel);
|
|
634
|
+
if (!el) return null;
|
|
635
|
+
return {
|
|
636
|
+
css: __smCssPath(el) || sel,
|
|
637
|
+
xpath: __smXPath(el),
|
|
638
|
+
role: el.getAttribute('role') || undefined,
|
|
639
|
+
name: (el.getAttribute('aria-label') || el.innerText || '').trim().slice(0, 60) || undefined,
|
|
640
|
+
id: el.id || undefined,
|
|
641
|
+
testId: el.getAttribute('data-testid') || undefined,
|
|
642
|
+
};
|
|
643
|
+
}).filter(Boolean);
|
|
644
|
+
})()`,
|
|
645
|
+
);
|
|
646
|
+
const capped = (Array.isArray(infos) ? infos : []).slice(0, selectors.length).map((info) => ({
|
|
647
|
+
css: capString(info && info.css, CAPTURE_FIELD_CAP),
|
|
648
|
+
xpath: capString(info && info.xpath, CAPTURE_FIELD_CAP),
|
|
649
|
+
role: capString(info && info.role, 256),
|
|
650
|
+
name: capString(info && info.name, 256),
|
|
651
|
+
id: capString(info && info.id, 256),
|
|
652
|
+
testId: capString(info && info.testId, 256),
|
|
653
|
+
}));
|
|
654
|
+
const text = capped.map(formatSelectorChain).join('\n\n');
|
|
655
|
+
return { ok: true, mode, text, meta: {} };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (mode === 'a11y') {
|
|
659
|
+
const selector = selectors[0];
|
|
660
|
+
const tree = await captureA11y(wc, selector);
|
|
661
|
+
const text = tree ? formatA11yTree(tree) : '';
|
|
662
|
+
return { ok: true, mode, text, meta: {} };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// mode === 'agent'
|
|
666
|
+
const rawTree = await wc.executeJavaScript(
|
|
667
|
+
`(function () {
|
|
668
|
+
function serialize(el) {
|
|
669
|
+
if (el.nodeType === 3) {
|
|
670
|
+
var t = el.textContent || '';
|
|
671
|
+
return t.trim() ? { type: 'text', text: t } : null;
|
|
672
|
+
}
|
|
673
|
+
if (el.nodeType !== 1) return null;
|
|
674
|
+
var attrs = {};
|
|
675
|
+
for (var i = 0; i < el.attributes.length; i++) {
|
|
676
|
+
attrs[el.attributes[i].name] = el.attributes[i].value;
|
|
677
|
+
}
|
|
678
|
+
var cs = window.getComputedStyle(el);
|
|
679
|
+
var children = [];
|
|
680
|
+
for (var j = 0; j < el.childNodes.length; j++) {
|
|
681
|
+
var c = serialize(el.childNodes[j]);
|
|
682
|
+
if (c) children.push(c);
|
|
683
|
+
}
|
|
684
|
+
return { tag: el.tagName.toLowerCase(), attrs: attrs, style: { display: cs.display, visibility: cs.visibility }, children: children };
|
|
685
|
+
}
|
|
686
|
+
var el = document.querySelector(${JSON.stringify(selectors[0])});
|
|
687
|
+
return el ? serialize(el) : null;
|
|
688
|
+
})()`,
|
|
689
|
+
);
|
|
690
|
+
const { text, meta } = buildAgentCapture(capRawTree(rawTree));
|
|
691
|
+
return { ok: true, mode, text, meta };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function registerBrowserCapture({ ipcMain, getView }) {
|
|
695
|
+
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
696
|
+
ipcMain.handle('browser:picker-start', validated(schemas.browserViewId, (payload) => pickerStart(payload, getView)));
|
|
697
|
+
ipcMain.handle('browser:picker-stop', validated(schemas.browserViewId, (payload) => pickerStop(payload, getView)));
|
|
698
|
+
ipcMain.handle('browser:capture', validated(schemas.browserCaptureSelection, (payload) => captureSelection(payload, getView)));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
module.exports = {
|
|
702
|
+
registerBrowserCapture,
|
|
703
|
+
attachWindow,
|
|
704
|
+
SELECTOR_CHAIN_SOURCE,
|
|
705
|
+
PICKER_SCRIPT,
|
|
706
|
+
filterTree,
|
|
707
|
+
summarizeTree,
|
|
708
|
+
chunkText,
|
|
709
|
+
estimateTokens,
|
|
710
|
+
buildAgentCapture,
|
|
711
|
+
formatHtmlCapture,
|
|
712
|
+
formatSelectorChain,
|
|
713
|
+
formatA11yTree,
|
|
714
|
+
};
|