@dmsdc-ai/aigentry-deliberation 0.0.13 → 0.0.15
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/README.md +16 -1
- package/browser-control-port.js +516 -61
- package/index.js +223 -22
- package/install.js +1 -0
- package/package.json +1 -1
- package/selectors/chatgpt.json +9 -5
- package/selectors/claude.json +10 -6
- package/selectors/deepseek.json +8 -4
- package/selectors/gemini.json +9 -5
- package/selectors/grok.json +7 -3
- package/selectors/huggingchat.json +7 -3
- package/selectors/poe.json +2 -2
- package/selectors/qwen.json +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @dmsdc-ai/aigentry-deliberation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of aigentry — the open-source engine that makes AI decisions auditable.
|
|
4
|
+
|
|
5
|
+
**The only tool that lets multiple AIs debate before deciding.**
|
|
6
|
+
|
|
7
|
+
MCP Deliberation Server — Multi-session AI deliberation with smart speaker ordering and persona roles. No competitor has this: aigentry-deliberation is the killer feature of the aigentry platform, enabling structured multi-AI debate with full audit trails before any decision is committed.
|
|
4
8
|
|
|
5
9
|
## Features
|
|
6
10
|
|
|
@@ -87,6 +91,17 @@ npx @dmsdc-ai/aigentry-deliberation uninstall
|
|
|
87
91
|
| `researcher` | Data, benchmarks, references |
|
|
88
92
|
| `free` | No role constraint (default) |
|
|
89
93
|
|
|
94
|
+
## aigentry Ecosystem
|
|
95
|
+
|
|
96
|
+
aigentry-deliberation is one component of the unified aigentry platform. All packages work together to make AI decisions transparent and auditable.
|
|
97
|
+
|
|
98
|
+
| Package | Description |
|
|
99
|
+
|---------|-------------|
|
|
100
|
+
| [`@dmsdc-ai/aigentry-brain`](https://github.com/dmsdc-ai/aigentry-brain) | Cross-LLM memory OS |
|
|
101
|
+
| [`@dmsdc-ai/aigentry-devkit`](https://github.com/dmsdc-ai/aigentry-devkit) | Developer tools and hooks |
|
|
102
|
+
| [`aigentry-registry`](https://github.com/dmsdc-ai/aigentry-registry) | AI agent evaluation (Python) |
|
|
103
|
+
| [`aigentry-ssot`](https://github.com/dmsdc-ai/aigentry-ssot) | MCP contract schemas |
|
|
104
|
+
|
|
90
105
|
## License
|
|
91
106
|
|
|
92
107
|
MIT
|
package/browser-control-port.js
CHANGED
|
@@ -194,6 +194,7 @@ class DevToolsMcpAdapter extends BrowserControlPort {
|
|
|
194
194
|
timing: selectorConfig.timing,
|
|
195
195
|
pageUrl: foundTab.url,
|
|
196
196
|
title: foundTab.title,
|
|
197
|
+
modelSelector: selectorConfig.modelSelector || null,
|
|
197
198
|
});
|
|
198
199
|
|
|
199
200
|
return makeResult(true, {
|
|
@@ -221,77 +222,290 @@ class DevToolsMcpAdapter extends BrowserControlPort {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
try {
|
|
224
|
-
//
|
|
225
|
+
// Capture baseline response count BEFORE send (for waitTurnResult to detect NEW responses)
|
|
226
|
+
const respContSel = JSON.stringify(binding.selectors.responseContainer);
|
|
227
|
+
const respSel = JSON.stringify(binding.selectors.responseSelector);
|
|
228
|
+
const baseline = await this._cdpEvaluate(binding, `
|
|
229
|
+
(function() {
|
|
230
|
+
var cc = 0, dc = 0;
|
|
231
|
+
${respContSel}.split(',').forEach(function(s) {
|
|
232
|
+
try { cc += document.querySelectorAll(s.trim()).length; } catch(e) {}
|
|
233
|
+
});
|
|
234
|
+
${respSel}.split(',').forEach(function(s) {
|
|
235
|
+
try { dc += document.querySelectorAll(s.trim()).length; } catch(e) {}
|
|
236
|
+
});
|
|
237
|
+
return { containerCount: cc, directCount: dc };
|
|
238
|
+
})()
|
|
239
|
+
`);
|
|
240
|
+
binding._baselineResponseCount = baseline.data?.containerCount || 0;
|
|
241
|
+
binding._baselineDirectCount = baseline.data?.directCount || 0;
|
|
242
|
+
binding._lastSentText = text; // Store for response filtering
|
|
243
|
+
|
|
244
|
+
// Step 1: Focus input and insert text
|
|
225
245
|
const inputSel = JSON.stringify(binding.selectors.inputSelector);
|
|
226
246
|
const sendBtnSel = JSON.stringify(binding.selectors.sendButton);
|
|
227
247
|
const escapedText = JSON.stringify(text);
|
|
228
248
|
|
|
229
|
-
|
|
249
|
+
// Focus the input element first — use click() + focus() for full framework initialization
|
|
250
|
+
const focusInput = await this._cdpEvaluate(binding, `
|
|
230
251
|
(function() {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const sel = window.getSelection();
|
|
239
|
-
const range = document.createRange();
|
|
240
|
-
range.selectNodeContents(input);
|
|
241
|
-
sel.removeAllRanges();
|
|
242
|
-
sel.addRange(range);
|
|
243
|
-
// execCommand triggers framework state updates (React, ProseMirror, Quill)
|
|
244
|
-
document.execCommand('insertText', false, ${escapedText});
|
|
245
|
-
} else {
|
|
246
|
-
// For regular <textarea>/<input>
|
|
247
|
-
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
248
|
-
Object.getPrototypeOf(input), 'value'
|
|
249
|
-
)?.set || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
250
|
-
if (nativeSetter) {
|
|
251
|
-
nativeSetter.call(input, ${escapedText});
|
|
252
|
-
} else {
|
|
253
|
-
input.value = ${escapedText};
|
|
252
|
+
var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
253
|
+
for (var i = 0; i < sels.length; i++) {
|
|
254
|
+
var input = document.querySelector(sels[i]);
|
|
255
|
+
if (input) {
|
|
256
|
+
input.click();
|
|
257
|
+
input.focus();
|
|
258
|
+
return { ok: true, isContentEditable: input.isContentEditable, tagName: input.tagName };
|
|
254
259
|
}
|
|
255
|
-
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
256
|
-
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
257
260
|
}
|
|
258
|
-
return { ok:
|
|
261
|
+
return { ok: false, error: 'INPUT_NOT_FOUND' };
|
|
259
262
|
})()
|
|
260
263
|
`);
|
|
261
264
|
|
|
262
|
-
if (!
|
|
265
|
+
if (!focusInput.ok || focusInput.data?.error) {
|
|
263
266
|
return makeResult(false, null, {
|
|
264
267
|
code: "DOM_CHANGED",
|
|
265
268
|
message: `Input selector not found: ${binding.selectors.inputSelector}`,
|
|
266
269
|
});
|
|
267
270
|
}
|
|
268
271
|
|
|
272
|
+
const isCE = focusInput.data?.isContentEditable;
|
|
273
|
+
|
|
274
|
+
if (isCE) {
|
|
275
|
+
// For contenteditable: use CDP Input.insertText (works with tiptap/ProseMirror/Quill)
|
|
276
|
+
// First clear existing content
|
|
277
|
+
await this._cdpEvaluate(binding, `
|
|
278
|
+
(function() {
|
|
279
|
+
var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
280
|
+
var input = null;
|
|
281
|
+
for (var i = 0; i < sels.length; i++) { input = document.querySelector(sels[i]); if (input) break; }
|
|
282
|
+
if (!input) return { ok: true };
|
|
283
|
+
input.click();
|
|
284
|
+
input.focus();
|
|
285
|
+
var sel = window.getSelection();
|
|
286
|
+
var range = document.createRange();
|
|
287
|
+
range.selectNodeContents(input);
|
|
288
|
+
sel.removeAllRanges();
|
|
289
|
+
sel.addRange(range);
|
|
290
|
+
document.execCommand('delete', false);
|
|
291
|
+
return { ok: true };
|
|
292
|
+
})()
|
|
293
|
+
`);
|
|
294
|
+
await new Promise(r => setTimeout(r, 50));
|
|
295
|
+
|
|
296
|
+
// Use CDP Input.insertText — triggers all framework handlers (tiptap, ProseMirror, Quill)
|
|
297
|
+
try {
|
|
298
|
+
await this._cdpCommand(binding, "Input.insertText", { text });
|
|
299
|
+
} catch {
|
|
300
|
+
// Fallback: execCommand (works for tiptap when properly focused via click)
|
|
301
|
+
await this._cdpEvaluate(binding, `
|
|
302
|
+
(function() {
|
|
303
|
+
var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
304
|
+
for (var i = 0; i < sels.length; i++) {
|
|
305
|
+
var input = document.querySelector(sels[i]);
|
|
306
|
+
if (input) { input.click(); input.focus(); document.execCommand('insertText', false, ${escapedText}); break; }
|
|
307
|
+
}
|
|
308
|
+
return { ok: true };
|
|
309
|
+
})()
|
|
310
|
+
`);
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
// For regular <textarea>/<input>: clear existing content, then use CDP Input.insertText
|
|
314
|
+
await this._cdpEvaluate(binding, `
|
|
315
|
+
(function() {
|
|
316
|
+
var input = document.querySelector(${inputSel});
|
|
317
|
+
if (!input) return { ok: true };
|
|
318
|
+
input.focus();
|
|
319
|
+
input.select ? input.select() : null;
|
|
320
|
+
input.value = '';
|
|
321
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
322
|
+
return { ok: true };
|
|
323
|
+
})()
|
|
324
|
+
`);
|
|
325
|
+
await new Promise(r => setTimeout(r, 50));
|
|
326
|
+
|
|
327
|
+
// CDP Input.insertText triggers OS-level text input
|
|
328
|
+
try {
|
|
329
|
+
await this._cdpCommand(binding, "Input.insertText", { text });
|
|
330
|
+
} catch {
|
|
331
|
+
// Fallback: nativeSetter approach
|
|
332
|
+
await this._cdpEvaluate(binding, `
|
|
333
|
+
(function() {
|
|
334
|
+
var input = document.querySelector(${inputSel});
|
|
335
|
+
if (!input) return { ok: true };
|
|
336
|
+
var nativeSetter = Object.getOwnPropertyDescriptor(
|
|
337
|
+
Object.getPrototypeOf(input), 'value'
|
|
338
|
+
);
|
|
339
|
+
if (nativeSetter && nativeSetter.set) {
|
|
340
|
+
nativeSetter.set.call(input, ${escapedText});
|
|
341
|
+
} else {
|
|
342
|
+
input.value = ${escapedText};
|
|
343
|
+
}
|
|
344
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
345
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
346
|
+
return { ok: true };
|
|
347
|
+
})()
|
|
348
|
+
`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Sync React/Vue state: CDP Input.insertText updates DOM but may not trigger React onChange.
|
|
352
|
+
// Re-set value via nativeSetter + dispatch input event to force framework state sync.
|
|
353
|
+
await this._cdpEvaluate(binding, `
|
|
354
|
+
(function() {
|
|
355
|
+
var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
356
|
+
for (var i = 0; i < sels.length; i++) {
|
|
357
|
+
var input = document.querySelector(sels[i]);
|
|
358
|
+
if (input && input.value) {
|
|
359
|
+
var proto = Object.getPrototypeOf(input);
|
|
360
|
+
var desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
361
|
+
if (desc && desc.set) {
|
|
362
|
+
desc.set.call(input, input.value);
|
|
363
|
+
}
|
|
364
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
365
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
366
|
+
return { ok: true };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { ok: true };
|
|
370
|
+
})()
|
|
371
|
+
`);
|
|
372
|
+
}
|
|
373
|
+
|
|
269
374
|
// Small delay for framework state propagation
|
|
270
375
|
await new Promise(r => setTimeout(r, binding.timing?.sendDelayMs || 200));
|
|
271
376
|
|
|
272
|
-
// Step 2: Send
|
|
273
|
-
|
|
274
|
-
(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
377
|
+
// Step 2: Send — strategy depends on input type
|
|
378
|
+
if (isCE) {
|
|
379
|
+
// For contenteditable (ProseMirror/Quill/tiptap): click send button (Enter = newline)
|
|
380
|
+
// Retry up to 4 times with delay — send button may appear after framework state update
|
|
381
|
+
let sendAttempted = false;
|
|
382
|
+
for (let attempt = 0; attempt < 4 && !sendAttempted; attempt++) {
|
|
383
|
+
if (attempt > 0) await new Promise(r => setTimeout(r, 400));
|
|
384
|
+
const sendResult = await this._cdpEvaluate(binding, `
|
|
385
|
+
(function() {
|
|
386
|
+
var btnSels = ${sendBtnSel}.split(',').map(function(s) { return s.trim(); });
|
|
387
|
+
for (var i = 0; i < btnSels.length; i++) {
|
|
388
|
+
try {
|
|
389
|
+
var btn = document.querySelector(btnSels[i]);
|
|
390
|
+
if (!btn) continue;
|
|
391
|
+
var isVisible = btn.offsetParent !== null || btn.getClientRects().length > 0;
|
|
392
|
+
var isEnabled = btn.getAttribute('aria-disabled') !== 'true' && !btn.disabled;
|
|
393
|
+
if (isVisible && isEnabled) {
|
|
394
|
+
// Use MouseEvent dispatch (works with React, div[role=button], etc.)
|
|
395
|
+
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
|
396
|
+
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
|
|
397
|
+
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
398
|
+
return { ok: true, method: 'button', sel: btnSels[i], attempt: ${attempt} };
|
|
399
|
+
}
|
|
400
|
+
} catch(e) {}
|
|
401
|
+
}
|
|
402
|
+
// Fallback: try form submit
|
|
403
|
+
var input = document.querySelector(${inputSel});
|
|
404
|
+
var form = input ? input.closest('form') : null;
|
|
405
|
+
if (form) {
|
|
406
|
+
form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
407
|
+
return { ok: true, method: 'form-submit' };
|
|
408
|
+
}
|
|
409
|
+
return { ok: false, method: 'none' };
|
|
410
|
+
})()
|
|
411
|
+
`);
|
|
412
|
+
if (sendResult.data?.method !== 'none') {
|
|
413
|
+
sendAttempted = true;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Last resort: try Enter key via CDP (may work for some editors)
|
|
417
|
+
if (!sendAttempted) {
|
|
418
|
+
try {
|
|
419
|
+
await this._cdpCommand(binding, "Input.dispatchKeyEvent", {
|
|
420
|
+
type: "rawKeyDown", key: "Enter", code: "Enter",
|
|
421
|
+
windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13,
|
|
422
|
+
});
|
|
423
|
+
await this._cdpCommand(binding, "Input.dispatchKeyEvent", {
|
|
424
|
+
type: "keyUp", key: "Enter", code: "Enter",
|
|
425
|
+
windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13,
|
|
426
|
+
});
|
|
427
|
+
} catch {}
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
// For regular textarea/input: try send button click FIRST (more reliable), Enter as fallback
|
|
431
|
+
// Step 2a: Try clicking send button (prefer LAST match — rightmost button is typically send)
|
|
432
|
+
let textareaSendClicked = false;
|
|
433
|
+
const btnClickResult = await this._cdpEvaluate(binding, `
|
|
434
|
+
(function() {
|
|
435
|
+
var btnSels = ${sendBtnSel}.split(',').map(function(s) { return s.trim(); });
|
|
436
|
+
// Collect ALL matching buttons
|
|
437
|
+
var allBtns = [];
|
|
438
|
+
for (var i = 0; i < btnSels.length; i++) {
|
|
439
|
+
try {
|
|
440
|
+
var matches = document.querySelectorAll(btnSels[i]);
|
|
441
|
+
for (var j = 0; j < matches.length; j++) allBtns.push({ el: matches[j], sel: btnSels[i] });
|
|
442
|
+
} catch(e) {}
|
|
443
|
+
}
|
|
444
|
+
// Try in REVERSE order — last matching button near input is typically the send button
|
|
445
|
+
for (var k = allBtns.length - 1; k >= 0; k--) {
|
|
446
|
+
var btn = allBtns[k].el;
|
|
447
|
+
var isVisible = btn.offsetParent !== null || btn.getClientRects().length > 0;
|
|
448
|
+
var isEnabled = btn.getAttribute('aria-disabled') !== 'true' && !btn.disabled;
|
|
449
|
+
if (isVisible && isEnabled) {
|
|
450
|
+
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
|
451
|
+
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
|
|
452
|
+
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
453
|
+
return { ok: true, method: 'button-reverse', sel: allBtns[k].sel, idx: k };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { ok: false, method: 'none', btnCount: allBtns.length };
|
|
457
|
+
})()
|
|
458
|
+
`);
|
|
459
|
+
textareaSendClicked = btnClickResult.data?.method !== 'none';
|
|
460
|
+
|
|
461
|
+
// Step 2b: Also send Enter key (some providers need it in addition to or instead of button)
|
|
462
|
+
await new Promise(r => setTimeout(r, 100));
|
|
463
|
+
const focusResult = await this._cdpEvaluate(binding, `
|
|
464
|
+
(function() {
|
|
465
|
+
var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
466
|
+
for (var i = 0; i < sels.length; i++) {
|
|
467
|
+
var input = document.querySelector(sels[i]);
|
|
468
|
+
if (input) { input.focus(); return { ok: true }; }
|
|
469
|
+
}
|
|
470
|
+
return { ok: false };
|
|
471
|
+
})()
|
|
472
|
+
`);
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
await this._cdpCommand(binding, "Input.dispatchKeyEvent", {
|
|
476
|
+
type: "rawKeyDown", key: "Enter", code: "Enter",
|
|
477
|
+
windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13,
|
|
478
|
+
});
|
|
479
|
+
await this._cdpCommand(binding, "Input.dispatchKeyEvent", {
|
|
480
|
+
type: "char", key: "\r", code: "Enter",
|
|
481
|
+
windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13,
|
|
284
482
|
});
|
|
285
|
-
|
|
483
|
+
await this._cdpCommand(binding, "Input.dispatchKeyEvent", {
|
|
484
|
+
type: "keyUp", key: "Enter", code: "Enter",
|
|
485
|
+
windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13,
|
|
486
|
+
});
|
|
487
|
+
} catch {
|
|
488
|
+
// JS fallback
|
|
489
|
+
await this._cdpEvaluate(binding, `
|
|
490
|
+
(function() {
|
|
491
|
+
var sels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
492
|
+
for (var i = 0; i < sels.length; i++) {
|
|
493
|
+
var input = document.querySelector(sels[i]);
|
|
494
|
+
if (input) {
|
|
495
|
+
input.focus();
|
|
496
|
+
['keydown','keypress','keyup'].forEach(function(t) {
|
|
497
|
+
input.dispatchEvent(new KeyboardEvent(t, { key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true, cancelable:true }));
|
|
498
|
+
});
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return { ok: true };
|
|
503
|
+
})()
|
|
504
|
+
`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
286
507
|
|
|
287
|
-
|
|
288
|
-
const btn = document.querySelector(${sendBtnSel});
|
|
289
|
-
if (btn && !btn.disabled) {
|
|
290
|
-
btn.click();
|
|
291
|
-
}
|
|
292
|
-
return { ok: true };
|
|
293
|
-
})()
|
|
294
|
-
`);
|
|
508
|
+
const sendResult = { ok: true };
|
|
295
509
|
|
|
296
510
|
if (!sendResult.ok) {
|
|
297
511
|
return makeResult(false, null, {
|
|
@@ -323,22 +537,122 @@ class DevToolsMcpAdapter extends BrowserControlPort {
|
|
|
323
537
|
const streamSel = JSON.stringify(binding.selectors.streamingIndicator);
|
|
324
538
|
const respContSel = JSON.stringify(binding.selectors.responseContainer);
|
|
325
539
|
const respSel = JSON.stringify(binding.selectors.responseSelector);
|
|
540
|
+
const baselineContainers = binding._baselineResponseCount || 0;
|
|
541
|
+
const baselineDirect = binding._baselineDirectCount || 0;
|
|
542
|
+
const sentText = JSON.stringify(binding._lastSentText || "");
|
|
543
|
+
|
|
544
|
+
let lastSeenText = '';
|
|
545
|
+
let stableCount = 0;
|
|
326
546
|
|
|
327
547
|
try {
|
|
328
548
|
while (Date.now() - startTime < timeoutMs) {
|
|
329
|
-
|
|
549
|
+
const elapsed = Date.now() - startTime;
|
|
330
550
|
const status = await this._cdpEvaluate(binding, `
|
|
331
551
|
(function() {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
text
|
|
341
|
-
|
|
552
|
+
var sentMsg = ${sentText};
|
|
553
|
+
|
|
554
|
+
// Helper: check if text is actually a response (not user echo, placeholder, or UI noise)
|
|
555
|
+
function isResponse(txt) {
|
|
556
|
+
if (!txt || !txt.trim()) return false;
|
|
557
|
+
var t = txt.trim();
|
|
558
|
+
// Reject if text is exact match of sent message (user echo)
|
|
559
|
+
if (sentMsg && t === sentMsg) return false;
|
|
560
|
+
// Reject if text is >80% similar to sent message (likely echo with minor formatting diff)
|
|
561
|
+
if (sentMsg && t.length > 20 && (t.includes(sentMsg) || sentMsg === t.substring(0, sentMsg.length))) return false;
|
|
562
|
+
// Reject known loading/placeholder texts
|
|
563
|
+
var placeholders = ['...', '…', '생각 중...', '생각 중', '생각이 끝났습니다',
|
|
564
|
+
'조금만 더 기다려 주세요', '조금만 더 기다려 주세요.', 'Thinking', 'Thinking...',
|
|
565
|
+
'Loading', 'Loading...', 'Generating', 'Generating...'];
|
|
566
|
+
if (placeholders.indexOf(t) >= 0) return false;
|
|
567
|
+
// Reject very short text (likely placeholder dots or single chars)
|
|
568
|
+
if (t.length < 2) return false;
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Helper: clean container text (strip common UI noise like button labels, timestamps)
|
|
573
|
+
function cleanContainerText(txt) {
|
|
574
|
+
if (!txt) return '';
|
|
575
|
+
// Remove common UI text patterns appended by buttons
|
|
576
|
+
return txt.replace(/\s*(Copied|Copy|복사|복사됨|공유|Share)\s*$/gi, '')
|
|
577
|
+
.replace(/\s*(오전|오후)\s+\d{1,2}:\d{2}\s*$/g, '') // Korean timestamps
|
|
578
|
+
.replace(/\s*\d+\.\d+초\s*$/g, '') // Duration indicators
|
|
579
|
+
.trim();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Step 1: Check streaming indicators
|
|
583
|
+
var isStreaming = false;
|
|
584
|
+
var streamSels = ${streamSel}.split(',').map(function(s) { return s.trim(); });
|
|
585
|
+
for (var i = 0; i < streamSels.length; i++) {
|
|
586
|
+
try { if (document.querySelector(streamSels[i])) { isStreaming = true; break; } } catch(e) {}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Step 2: Try to extract response text (even if streaming — for stabilization)
|
|
590
|
+
var candidateText = '';
|
|
591
|
+
var candidateStrategy = '';
|
|
592
|
+
|
|
593
|
+
var contSels = ${respContSel}.split(',').map(function(s) { return s.trim(); });
|
|
594
|
+
var allContainers = [];
|
|
595
|
+
for (var i = 0; i < contSels.length; i++) {
|
|
596
|
+
try {
|
|
597
|
+
var found = document.querySelectorAll(contSels[i]);
|
|
598
|
+
for (var j = 0; j < found.length; j++) allContainers.push(found[j]);
|
|
599
|
+
} catch(e) {}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (allContainers.length > ${baselineContainers}) {
|
|
603
|
+
var last = allContainers[allContainers.length - 1];
|
|
604
|
+
// Scroll into view to force content-visibility rendering (ChatGPT workaround)
|
|
605
|
+
try { last.scrollIntoView({ block: 'end' }); } catch(e) {}
|
|
606
|
+
var respSels = ${respSel}.split(',').map(function(s) { return s.trim(); });
|
|
607
|
+
for (var i = 0; i < respSels.length; i++) {
|
|
608
|
+
try {
|
|
609
|
+
var content = last.querySelector(respSels[i]);
|
|
610
|
+
// Try textContent first, then innerText as fallback for content-visibility
|
|
611
|
+
var txt = content ? (content.textContent || content.innerText || '') : '';
|
|
612
|
+
if (isResponse(txt)) {
|
|
613
|
+
candidateText = txt;
|
|
614
|
+
candidateStrategy = 'container';
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
} catch(e) {}
|
|
618
|
+
}
|
|
619
|
+
if (!candidateText) {
|
|
620
|
+
var rawTxt = cleanContainerText(last.textContent);
|
|
621
|
+
if (isResponse(rawTxt)) {
|
|
622
|
+
candidateText = rawTxt;
|
|
623
|
+
candidateStrategy = 'container-text';
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Step 3: Fallback — try responseSelector directly (after 3s)
|
|
629
|
+
if (!candidateText && ${elapsed} >= 3000) {
|
|
630
|
+
var respSels2 = ${respSel}.split(',').map(function(s) { return s.trim(); });
|
|
631
|
+
var allDirect = [];
|
|
632
|
+
for (var i = 0; i < respSels2.length; i++) {
|
|
633
|
+
try {
|
|
634
|
+
var found = document.querySelectorAll(respSels2[i]);
|
|
635
|
+
for (var j = 0; j < found.length; j++) allDirect.push(found[j]);
|
|
636
|
+
} catch(e) {}
|
|
637
|
+
}
|
|
638
|
+
if (allDirect.length > ${baselineDirect}) {
|
|
639
|
+
for (var k = allDirect.length - 1; k >= ${baselineDirect}; k--) {
|
|
640
|
+
var txt = allDirect[k].textContent?.trim();
|
|
641
|
+
if (isResponse(txt)) {
|
|
642
|
+
candidateText = txt;
|
|
643
|
+
candidateStrategy = 'direct';
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// If not streaming and we have text, done
|
|
651
|
+
if (!isStreaming && candidateText) {
|
|
652
|
+
return { streaming: false, text: candidateText, strategy: candidateStrategy };
|
|
653
|
+
}
|
|
654
|
+
// Return streaming status + candidate text for stabilization tracking
|
|
655
|
+
return { streaming: true, candidateText: candidateText || '' };
|
|
342
656
|
})()
|
|
343
657
|
`);
|
|
344
658
|
|
|
@@ -347,9 +661,28 @@ class DevToolsMcpAdapter extends BrowserControlPort {
|
|
|
347
661
|
turnId,
|
|
348
662
|
response: status.data.text.trim(),
|
|
349
663
|
elapsedMs: Date.now() - startTime,
|
|
664
|
+
strategy: status.data.strategy,
|
|
350
665
|
});
|
|
351
666
|
}
|
|
352
667
|
|
|
668
|
+
// Text stabilization: if streaming indicator persists but text hasn't changed for 3+ polls, consider done
|
|
669
|
+
if (status.data?.candidateText) {
|
|
670
|
+
if (status.data.candidateText === lastSeenText) {
|
|
671
|
+
stableCount++;
|
|
672
|
+
if (stableCount >= 3) {
|
|
673
|
+
return makeResult(true, {
|
|
674
|
+
turnId,
|
|
675
|
+
response: status.data.candidateText.trim(),
|
|
676
|
+
elapsedMs: Date.now() - startTime,
|
|
677
|
+
strategy: 'stabilized',
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
lastSeenText = status.data.candidateText;
|
|
682
|
+
stableCount = 0;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
353
686
|
await new Promise(r => setTimeout(r, pollInterval));
|
|
354
687
|
}
|
|
355
688
|
|
|
@@ -362,6 +695,120 @@ class DevToolsMcpAdapter extends BrowserControlPort {
|
|
|
362
695
|
}
|
|
363
696
|
}
|
|
364
697
|
|
|
698
|
+
async switchModel(sessionId, modelName) {
|
|
699
|
+
const binding = this.bindings.get(sessionId);
|
|
700
|
+
if (!binding) {
|
|
701
|
+
return makeResult(false, null, { code: "BIND_FAILED", message: "No binding for session. Call attach() first." });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const modelSel = binding.modelSelector;
|
|
705
|
+
if (!modelSel) {
|
|
706
|
+
return makeResult(true, { skipped: true, reason: "No model selector config for this provider" });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const triggerSel = JSON.stringify(modelSel.trigger);
|
|
710
|
+
const optionSel = JSON.stringify(modelSel.optionSelector || "[role='option'], [role='menuitem'], li");
|
|
711
|
+
const escapedModel = JSON.stringify(modelName.toLowerCase());
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
const result = await this._cdpEvaluate(binding, `
|
|
715
|
+
(function() {
|
|
716
|
+
var trigger = document.querySelector(${triggerSel});
|
|
717
|
+
if (!trigger) return { ok: false, error: 'TRIGGER_NOT_FOUND' };
|
|
718
|
+
trigger.click();
|
|
719
|
+
|
|
720
|
+
return new Promise(function(resolve) {
|
|
721
|
+
setTimeout(function() {
|
|
722
|
+
var optSels = ${optionSel}.split(',').map(function(s) { return s.trim(); });
|
|
723
|
+
var allOptions = [];
|
|
724
|
+
for (var i = 0; i < optSels.length; i++) {
|
|
725
|
+
try {
|
|
726
|
+
var found = document.querySelectorAll(optSels[i]);
|
|
727
|
+
for (var j = 0; j < found.length; j++) allOptions.push(found[j]);
|
|
728
|
+
} catch(e) {}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
var target = null;
|
|
732
|
+
for (var k = 0; k < allOptions.length; k++) {
|
|
733
|
+
var text = (allOptions[k].textContent || '').toLowerCase().trim();
|
|
734
|
+
if (text.indexOf(${escapedModel}) >= 0) {
|
|
735
|
+
target = allOptions[k];
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!target) {
|
|
741
|
+
resolve({ ok: false, error: 'MODEL_OPTION_NOT_FOUND', searched: allOptions.length });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
target.click();
|
|
746
|
+
|
|
747
|
+
setTimeout(function() {
|
|
748
|
+
resolve({ ok: true, matched: target.textContent.trim() });
|
|
749
|
+
}, 200);
|
|
750
|
+
}, 300);
|
|
751
|
+
});
|
|
752
|
+
})()
|
|
753
|
+
`);
|
|
754
|
+
|
|
755
|
+
if (!result.ok || (result.data && result.data.ok === false)) {
|
|
756
|
+
var errData = result.data || {};
|
|
757
|
+
return makeResult(false, null, {
|
|
758
|
+
code: "DOM_CHANGED",
|
|
759
|
+
message: errData.error || "Model switch failed",
|
|
760
|
+
detail: errData,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return makeResult(true, { modelName, matched: result.data?.matched });
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return this._classifyError(err);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async checkLogin(sessionId) {
|
|
771
|
+
const binding = this.bindings.get(sessionId);
|
|
772
|
+
if (!binding) return null;
|
|
773
|
+
|
|
774
|
+
try {
|
|
775
|
+
const inputSel = JSON.stringify(binding.selectors.inputSelector);
|
|
776
|
+
const result = await this._cdpEvaluate(binding, `
|
|
777
|
+
(function() {
|
|
778
|
+
var url = location.href.toLowerCase();
|
|
779
|
+
|
|
780
|
+
// Check for login/auth URL patterns
|
|
781
|
+
var loginUrlPatterns = ['/login', '/signin', '/sign-in', '/auth', '/oauth', '/sso', '/accounts'];
|
|
782
|
+
var isLoginUrl = loginUrlPatterns.some(function(p) { return url.indexOf(p) >= 0; });
|
|
783
|
+
|
|
784
|
+
// Check for login form indicators
|
|
785
|
+
var hasPasswordField = !!document.querySelector('input[type="password"]');
|
|
786
|
+
var loginTexts = ['sign in', 'log in', 'login', '로그인', '登录', 'continue with google', 'continue with email'];
|
|
787
|
+
var bodyText = document.body ? document.body.textContent.substring(0, 2000).toLowerCase() : '';
|
|
788
|
+
var hasLoginText = loginTexts.some(function(t) { return bodyText.indexOf(t) >= 0; }) && hasPasswordField;
|
|
789
|
+
|
|
790
|
+
// Check if chat input exists (logged-in indicator)
|
|
791
|
+
var inputSels = ${inputSel}.split(',').map(function(s) { return s.trim(); });
|
|
792
|
+
var hasInput = false;
|
|
793
|
+
for (var i = 0; i < inputSels.length; i++) {
|
|
794
|
+
if (document.querySelector(inputSels[i])) { hasInput = true; break; }
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (hasInput) return { loggedIn: true };
|
|
798
|
+
if (isLoginUrl) return { loggedIn: false, reason: 'login_page_url', url: url.substring(0, 100) };
|
|
799
|
+
if (hasPasswordField && hasLoginText) return { loggedIn: false, reason: 'login_form_detected', url: url.substring(0, 100) };
|
|
800
|
+
if (!hasInput && document.readyState === 'complete') {
|
|
801
|
+
return { loggedIn: false, reason: 'no_chat_input_found', url: url.substring(0, 100) };
|
|
802
|
+
}
|
|
803
|
+
return { loggedIn: true };
|
|
804
|
+
})()
|
|
805
|
+
`);
|
|
806
|
+
return result.data;
|
|
807
|
+
} catch {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
365
812
|
async health(sessionId) {
|
|
366
813
|
const binding = this.bindings.get(sessionId);
|
|
367
814
|
if (!binding) {
|
|
@@ -561,6 +1008,14 @@ class OrchestratedBrowserPort {
|
|
|
561
1008
|
return this.adapter.waitTurnResult(sessionId, turnId, timeoutSec);
|
|
562
1009
|
}
|
|
563
1010
|
|
|
1011
|
+
async switchModel(sessionId, modelName) {
|
|
1012
|
+
return this.adapter.switchModel(sessionId, modelName);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async checkLogin(sessionId) {
|
|
1016
|
+
return this.adapter.checkLogin(sessionId);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
564
1019
|
async health(sessionId) {
|
|
565
1020
|
return this.adapter.health(sessionId);
|
|
566
1021
|
}
|
package/index.js
CHANGED
|
@@ -66,6 +66,7 @@ import path from "path";
|
|
|
66
66
|
import { fileURLToPath } from "url";
|
|
67
67
|
import os from "os";
|
|
68
68
|
import { OrchestratedBrowserPort } from "./browser-control-port.js";
|
|
69
|
+
import { getModelSelectionForTurn } from "./model-router.js";
|
|
69
70
|
|
|
70
71
|
// ── Paths ──────────────────────────────────────────────────────
|
|
71
72
|
|
|
@@ -135,7 +136,7 @@ const DEFAULT_WEB_SPEAKERS = [
|
|
|
135
136
|
{ speaker: "web-gemini", provider: "gemini", name: "Gemini", url: "https://gemini.google.com" },
|
|
136
137
|
{ speaker: "web-copilot", provider: "copilot", name: "Copilot", url: "https://copilot.microsoft.com" },
|
|
137
138
|
{ speaker: "web-perplexity", provider: "perplexity", name: "Perplexity", url: "https://perplexity.ai" },
|
|
138
|
-
{ speaker: "web-deepseek", provider: "deepseek", name: "DeepSeek", url: "https://deepseek.com" },
|
|
139
|
+
{ speaker: "web-deepseek", provider: "deepseek", name: "DeepSeek", url: "https://chat.deepseek.com" },
|
|
139
140
|
{ speaker: "web-mistral", provider: "mistral", name: "Mistral", url: "https://mistral.ai" },
|
|
140
141
|
{ speaker: "web-poe", provider: "poe", name: "Poe", url: "https://poe.com" },
|
|
141
142
|
{ speaker: "web-grok", provider: "grok", name: "Grok", url: "https://grok.com" },
|
|
@@ -529,6 +530,37 @@ function shellQuote(value) {
|
|
|
529
530
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
530
531
|
}
|
|
531
532
|
|
|
533
|
+
function checkCliLiveness(command) {
|
|
534
|
+
const hint = CLI_INVOCATION_HINTS[command];
|
|
535
|
+
const env = { ...process.env };
|
|
536
|
+
// Unset CLAUDECODE to avoid nested session errors
|
|
537
|
+
if (hint?.envPrefix?.includes("CLAUDECODE=")) {
|
|
538
|
+
delete env.CLAUDECODE;
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
execFileSync(command, ["--version"], {
|
|
542
|
+
stdio: "ignore",
|
|
543
|
+
windowsHide: true,
|
|
544
|
+
timeout: 5000,
|
|
545
|
+
env,
|
|
546
|
+
});
|
|
547
|
+
return true;
|
|
548
|
+
} catch {
|
|
549
|
+
// --version failed, try --help as fallback
|
|
550
|
+
try {
|
|
551
|
+
execFileSync(command, ["--help"], {
|
|
552
|
+
stdio: "ignore",
|
|
553
|
+
windowsHide: true,
|
|
554
|
+
timeout: 5000,
|
|
555
|
+
env,
|
|
556
|
+
});
|
|
557
|
+
return true;
|
|
558
|
+
} catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
532
564
|
function discoverLocalCliSpeakers() {
|
|
533
565
|
const found = [];
|
|
534
566
|
for (const candidate of resolveCliCandidates()) {
|
|
@@ -1091,11 +1123,13 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
|
|
|
1091
1123
|
|
|
1092
1124
|
if (include_cli) {
|
|
1093
1125
|
for (const cli of discoverLocalCliSpeakers()) {
|
|
1126
|
+
const live = checkCliLiveness(cli);
|
|
1094
1127
|
add({
|
|
1095
1128
|
speaker: cli,
|
|
1096
1129
|
type: "cli",
|
|
1097
1130
|
label: cli,
|
|
1098
1131
|
command: cli,
|
|
1132
|
+
live,
|
|
1099
1133
|
});
|
|
1100
1134
|
}
|
|
1101
1135
|
}
|
|
@@ -1198,6 +1232,31 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
|
|
|
1198
1232
|
cdp_available: cdpReachable,
|
|
1199
1233
|
});
|
|
1200
1234
|
}
|
|
1235
|
+
|
|
1236
|
+
// Second pass: match auto-registered speakers to individual CDP tabs
|
|
1237
|
+
// (they were added after the first matching pass and only got the global cdpReachable flag)
|
|
1238
|
+
if (cdpTabs.length > 0) {
|
|
1239
|
+
for (const candidate of candidates) {
|
|
1240
|
+
if (!candidate.auto_registered || candidate.cdp_tab_id) continue;
|
|
1241
|
+
let candidateHost = "";
|
|
1242
|
+
try {
|
|
1243
|
+
candidateHost = new URL(candidate.url).hostname.toLowerCase();
|
|
1244
|
+
} catch { continue; }
|
|
1245
|
+
if (!candidateHost) continue;
|
|
1246
|
+
const matches = cdpTabs.filter(t => {
|
|
1247
|
+
try {
|
|
1248
|
+
const tabHost = new URL(t.url).hostname.toLowerCase();
|
|
1249
|
+
// Exact match or subdomain match (e.g., chat.deepseek.com matches deepseek.com)
|
|
1250
|
+
return tabHost === candidateHost || tabHost.endsWith("." + candidateHost);
|
|
1251
|
+
} catch { return false; }
|
|
1252
|
+
});
|
|
1253
|
+
if (matches.length >= 1) {
|
|
1254
|
+
candidate.cdp_available = true;
|
|
1255
|
+
candidate.cdp_tab_id = matches[0].id;
|
|
1256
|
+
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1201
1260
|
}
|
|
1202
1261
|
|
|
1203
1262
|
return { candidates, browserNote };
|
|
@@ -1213,7 +1272,10 @@ function formatSpeakerCandidatesReport({ candidates, browserNote }) {
|
|
|
1213
1272
|
if (cli.length === 0) {
|
|
1214
1273
|
out += "- (감지된 로컬 CLI 없음)\n\n";
|
|
1215
1274
|
} else {
|
|
1216
|
-
out += `${cli.map(c =>
|
|
1275
|
+
out += `${cli.map(c => {
|
|
1276
|
+
const status = c.live === false ? " ❌ 실행 불가" : c.live === true ? " ✅ 실행 가능" : "";
|
|
1277
|
+
return `- \`${c.speaker}\` (command: ${c.command})${status}`;
|
|
1278
|
+
}).join("\n")}\n\n`;
|
|
1217
1279
|
}
|
|
1218
1280
|
|
|
1219
1281
|
out += "### Browser LLM (감지됨)\n";
|
|
@@ -1340,11 +1402,11 @@ function resolveTransportForSpeaker(state, speaker) {
|
|
|
1340
1402
|
|
|
1341
1403
|
// CLI-specific invocation flags for non-interactive execution
|
|
1342
1404
|
const CLI_INVOCATION_HINTS = {
|
|
1343
|
-
claude: { cmd: "claude", flags: '-p --output-format text', example: 'claude -p --output-format text "프롬프트"' },
|
|
1344
|
-
codex: { cmd: "codex", flags: 'exec', example: 'codex exec "프롬프트"' },
|
|
1345
|
-
gemini: { cmd: "gemini", flags: '', example: 'gemini "프롬프트"' },
|
|
1346
|
-
aider: { cmd: "aider", flags: '--message', example: 'aider --message "프롬프트"' },
|
|
1347
|
-
cursor: { cmd: "cursor", flags: '', example: 'cursor "프롬프트"' },
|
|
1405
|
+
claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "프롬프트"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
|
|
1406
|
+
codex: { cmd: "codex", flags: 'exec', example: 'codex exec "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
|
|
1407
|
+
gemini: { cmd: "gemini", flags: '', example: 'gemini "프롬프트"', modelFlag: '--model', provider: 'gemini' },
|
|
1408
|
+
aider: { cmd: "aider", flags: '--message', example: 'aider --message "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
|
|
1409
|
+
cursor: { cmd: "cursor", flags: '', example: 'cursor "프롬프트"', modelFlag: null, provider: 'chatgpt' },
|
|
1348
1410
|
};
|
|
1349
1411
|
|
|
1350
1412
|
function formatTransportGuidance(transport, state, speaker) {
|
|
@@ -1352,18 +1414,27 @@ function formatTransportGuidance(transport, state, speaker) {
|
|
|
1352
1414
|
switch (transport) {
|
|
1353
1415
|
case "cli_respond": {
|
|
1354
1416
|
const hint = CLI_INVOCATION_HINTS[speaker] || null;
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1417
|
+
let invocationGuide = "";
|
|
1418
|
+
let modelGuide = "";
|
|
1419
|
+
if (hint) {
|
|
1420
|
+
const prefix = hint.envPrefix || '';
|
|
1421
|
+
invocationGuide = `\n\n**CLI 호출 방법:** \`${hint.example}\`\n(플래그: \`${prefix}${hint.cmd} ${hint.flags}\`)`;
|
|
1422
|
+
if (hint.modelFlag && hint.provider) {
|
|
1423
|
+
const cliModel = getModelSelectionForTurn(state, speaker, hint.provider);
|
|
1424
|
+
if (cliModel.model !== 'default') {
|
|
1425
|
+
modelGuide = `\n**추천 모델:** ${cliModel.model} (${cliModel.reason})\n**모델 플래그:** \`${hint.modelFlag} ${cliModel.model}\``;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return `CLI speaker입니다. \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 직접 응답하세요.${invocationGuide}${modelGuide}\n\n⛔ **API 호출 금지**: REST API, HTTP 요청, urllib, requests 등으로 LLM API를 직접 호출하지 마세요. 반드시 위 CLI 도구만 사용하세요.`;
|
|
1359
1430
|
}
|
|
1360
1431
|
case "clipboard":
|
|
1361
|
-
return `브라우저 LLM speaker입니다. CDP 자동 연결 시도 중... Chrome이 이미 CDP 없이 실행 중이면 재시작이 필요할 수
|
|
1432
|
+
return `브라우저 LLM speaker입니다. CDP 자동 연결 시도 중... Chrome이 이미 CDP 없이 실행 중이면 재시작이 필요할 수 있습니다.\n\n⛔ **API 호출 금지**: 이 speaker는 웹 브라우저로만 응답합니다. REST API, HTTP 요청으로 LLM을 호출하지 마세요.`;
|
|
1362
1433
|
case "browser_auto":
|
|
1363
|
-
return `자동 브라우저 speaker입니다. \`deliberation_browser_auto_turn(session_id: "${sid}")\`으로 자동 진행됩니다. CDP를 통해 브라우저 LLM에 직접 입력하고 응답을
|
|
1434
|
+
return `자동 브라우저 speaker입니다. \`deliberation_browser_auto_turn(session_id: "${sid}")\`으로 자동 진행됩니다. CDP를 통해 브라우저 LLM에 직접 입력하고 응답을 읽습니다.\n\n⛔ **API 호출 금지**: CDP 자동화로만 진행합니다. REST API, HTTP 요청 사용 금지.`;
|
|
1364
1435
|
case "manual":
|
|
1365
1436
|
default:
|
|
1366
|
-
return `수동 speaker입니다. 응답을
|
|
1437
|
+
return `수동 speaker입니다. 해당 LLM의 **웹 UI 또는 CLI 도구**를 통해 응답을 받아 \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 제출하세요.\n\n⛔ **API 호출 절대 금지**: REST API, HTTP 요청(urllib, requests, fetch 등)으로 LLM API를 직접 호출하는 것은 금지됩니다. 반드시 웹 브라우저 UI 또는 CLI 도구만 사용하세요. API 키로 직접 호출하면 deliberation 참여가 거부됩니다.`;
|
|
1367
1438
|
}
|
|
1368
1439
|
}
|
|
1369
1440
|
|
|
@@ -1671,6 +1742,32 @@ function hasTmuxSession(name) {
|
|
|
1671
1742
|
}
|
|
1672
1743
|
}
|
|
1673
1744
|
|
|
1745
|
+
function hasTmuxWindow(sessionName, windowName) {
|
|
1746
|
+
try {
|
|
1747
|
+
const output = execFileSync("tmux", ["list-windows", "-t", sessionName, "-F", "#{window_name}"], {
|
|
1748
|
+
encoding: "utf-8",
|
|
1749
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1750
|
+
windowsHide: true,
|
|
1751
|
+
});
|
|
1752
|
+
return String(output).split("\n").map(s => s.trim()).includes(windowName);
|
|
1753
|
+
} catch {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function tmuxHasAttachedClients(sessionName) {
|
|
1759
|
+
try {
|
|
1760
|
+
const output = execFileSync("tmux", ["list-clients", "-t", sessionName], {
|
|
1761
|
+
encoding: "utf-8",
|
|
1762
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1763
|
+
windowsHide: true,
|
|
1764
|
+
});
|
|
1765
|
+
return String(output).trim().split("\n").filter(Boolean).length > 0;
|
|
1766
|
+
} catch {
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1674
1771
|
function tmuxWindowCount(name) {
|
|
1675
1772
|
try {
|
|
1676
1773
|
const output = execFileSync("tmux", ["list-windows", "-t", name], {
|
|
@@ -1733,6 +1830,17 @@ function openPhysicalTerminal(sessionId) {
|
|
|
1733
1830
|
const winName = tmuxWindowName(sessionId);
|
|
1734
1831
|
const attachCmd = `tmux attach -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1735
1832
|
|
|
1833
|
+
// If a terminal is already attached to this tmux session, just switch to the right window
|
|
1834
|
+
if (tmuxHasAttachedClients(TMUX_SESSION)) {
|
|
1835
|
+
try {
|
|
1836
|
+
execFileSync("tmux", ["select-window", "-t", `${TMUX_SESSION}:${winName}`], {
|
|
1837
|
+
stdio: "ignore",
|
|
1838
|
+
windowsHide: true,
|
|
1839
|
+
});
|
|
1840
|
+
} catch { /* window might not exist yet */ }
|
|
1841
|
+
return { opened: true, windowIds: [] };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1736
1844
|
if (process.platform === "darwin") {
|
|
1737
1845
|
const before = new Set(listPhysicalTerminalWindowIds());
|
|
1738
1846
|
try {
|
|
@@ -1840,6 +1948,10 @@ function spawnMonitorTerminal(sessionId) {
|
|
|
1840
1948
|
|
|
1841
1949
|
try {
|
|
1842
1950
|
if (hasTmuxSession(TMUX_SESSION)) {
|
|
1951
|
+
// Skip if a window with the same name already exists (prevents duplicates)
|
|
1952
|
+
if (hasTmuxWindow(TMUX_SESSION, winName)) {
|
|
1953
|
+
return true;
|
|
1954
|
+
}
|
|
1843
1955
|
execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
|
|
1844
1956
|
stdio: "ignore",
|
|
1845
1957
|
windowsHide: true,
|
|
@@ -2223,9 +2335,18 @@ server.tool(
|
|
|
2223
2335
|
rounds: z.coerce.number().optional().describe("라운드 수 (미지정 시 config 설정 따름, 기본 3)"),
|
|
2224
2336
|
first_speaker: z.string().trim().min(1).max(64).optional().describe("첫 발언자 이름 (미지정 시 speakers의 첫 항목)"),
|
|
2225
2337
|
speakers: z.preprocess(
|
|
2226
|
-
(v) =>
|
|
2338
|
+
(v) => {
|
|
2339
|
+
const parsed = typeof v === "string" ? JSON.parse(v) : v;
|
|
2340
|
+
if (!Array.isArray(parsed)) return parsed;
|
|
2341
|
+
// Normalize: accept both string[] and {name, role?, instructions?}[]
|
|
2342
|
+
return parsed.map(item => (typeof item === "object" && item !== null && item.name) ? item.name : item);
|
|
2343
|
+
},
|
|
2227
2344
|
z.array(z.string().trim().min(1).max(64)).min(1).optional()
|
|
2228
|
-
).describe("참가자 이름
|
|
2345
|
+
).describe("참가자 이름 목록. 문자열 배열 또는 {name, role, instructions} 객체 배열 모두 지원"),
|
|
2346
|
+
speaker_instructions: z.preprocess(
|
|
2347
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
2348
|
+
z.record(z.string(), z.string()).optional()
|
|
2349
|
+
).describe("speaker별 추가 지시사항 (예: {\"claude\": \"비판적으로 검토\"})"),
|
|
2229
2350
|
require_manual_speakers: z.preprocess(
|
|
2230
2351
|
(v) => (typeof v === "string" ? v === "true" : v),
|
|
2231
2352
|
z.boolean().optional()
|
|
@@ -2247,7 +2368,7 @@ server.tool(
|
|
|
2247
2368
|
role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
|
|
2248
2369
|
.describe("역할 프리셋 (balanced/debate/research/brainstorm/review/consensus). speaker_roles가 명시되면 무시됨"),
|
|
2249
2370
|
},
|
|
2250
|
-
safeToolHandler("deliberation_start", async ({ topic, rounds, first_speaker, speakers, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
|
|
2371
|
+
safeToolHandler("deliberation_start", async ({ topic, rounds, first_speaker, speakers, speaker_instructions, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
|
|
2251
2372
|
// ── First-time onboarding guard ──
|
|
2252
2373
|
const config = loadDeliberationConfig();
|
|
2253
2374
|
if (!config.setup_complete) {
|
|
@@ -2327,6 +2448,42 @@ server.tool(
|
|
|
2327
2448
|
|| normalizeSpeaker(selectedSpeakers?.[0])
|
|
2328
2449
|
|| DEFAULT_SPEAKERS[0];
|
|
2329
2450
|
const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
|
|
2451
|
+
|
|
2452
|
+
// Warn if only 1 speaker — deliberation requires 2+
|
|
2453
|
+
if (speakerOrder.length < 2) {
|
|
2454
|
+
const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
|
|
2455
|
+
const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
|
|
2456
|
+
return {
|
|
2457
|
+
content: [{
|
|
2458
|
+
type: "text",
|
|
2459
|
+
text: `⚠️ Deliberation에는 최소 2명의 speaker가 필요합니다. 현재 ${speakerOrder.length}명만 지정됨: ${speakerOrder.join(", ")}\n\n사용 가능한 스피커 후보:\n${candidateText}\n\n예시:\ndeliberation_start(topic: "${topic.slice(0, 50)}...", speakers: ["claude", "codex", "web-gemini-1"])`,
|
|
2460
|
+
}],
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// Liveness check: verify CLI speakers are actually executable
|
|
2465
|
+
const cliSpeakersInOrder = speakerOrder.filter(s => !s.startsWith("web-"));
|
|
2466
|
+
const nonLiveCli = [];
|
|
2467
|
+
for (const s of cliSpeakersInOrder) {
|
|
2468
|
+
if (!checkCliLiveness(s)) {
|
|
2469
|
+
nonLiveCli.push(s);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (nonLiveCli.length > 0) {
|
|
2473
|
+
const liveSpeakers = speakerOrder.filter(s => !nonLiveCli.includes(s));
|
|
2474
|
+
const liveSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
|
|
2475
|
+
const candidateText = formatSpeakerCandidatesReport(liveSnapshot);
|
|
2476
|
+
const liveList = liveSpeakers.length > 0
|
|
2477
|
+
? `\n\n실행 가능한 스피커만으로 시작하려면:\ndeliberation_start(topic: "${topic.slice(0, 50)}...", speakers: ${JSON.stringify(liveSpeakers)})`
|
|
2478
|
+
: "";
|
|
2479
|
+
return {
|
|
2480
|
+
content: [{
|
|
2481
|
+
type: "text",
|
|
2482
|
+
text: `⚠️ 일부 CLI 스피커가 현재 실행 불가합니다:\n${nonLiveCli.map(s => ` - \`${s}\` ❌`).join("\n")}\n\n실행 가능: ${liveSpeakers.length > 0 ? liveSpeakers.map(s => `\`${s}\``).join(", ") : "(없음)"}\n\n${candidateText}${liveList}\n\n실행 불가 원인: CLI가 설치되지 않았거나, 중첩 세션 제약, 또는 인증 만료일 수 있습니다.`,
|
|
2483
|
+
}],
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2330
2487
|
const participantMode = hasManualSpeakers
|
|
2331
2488
|
? "수동 지정"
|
|
2332
2489
|
: (autoDiscoveredSpeakers.length > 0 ? "자동 탐색(PATH)" : "기본값");
|
|
@@ -2557,6 +2714,9 @@ server.tool(
|
|
|
2557
2714
|
const turnSpeaker = speaker;
|
|
2558
2715
|
const turnProvider = profile?.provider || "chatgpt";
|
|
2559
2716
|
|
|
2717
|
+
// Dynamic model selection
|
|
2718
|
+
const modelSelection = getModelSelectionForTurn(state, turnSpeaker, turnProvider);
|
|
2719
|
+
|
|
2560
2720
|
// Build prompt
|
|
2561
2721
|
const turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
|
|
2562
2722
|
|
|
@@ -2564,6 +2724,11 @@ server.tool(
|
|
|
2564
2724
|
const attachResult = await port.attach(sessionId, { provider: turnProvider, url: profile?.url });
|
|
2565
2725
|
if (!attachResult.ok) throw new Error(`attach failed: ${attachResult.error?.message}`);
|
|
2566
2726
|
|
|
2727
|
+
// Switch model if needed
|
|
2728
|
+
if (modelSelection.model !== 'default') {
|
|
2729
|
+
await port.switchModel(sessionId, modelSelection.model);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2567
2732
|
// Send turn
|
|
2568
2733
|
const autoTurnId = turnId || `auto-${Date.now()}`;
|
|
2569
2734
|
const sendResult = await port.sendTurnWithDegradation(sessionId, autoTurnId, turnPrompt);
|
|
@@ -2584,7 +2749,8 @@ server.tool(
|
|
|
2584
2749
|
channel_used: "browser_auto",
|
|
2585
2750
|
fallback_reason: null,
|
|
2586
2751
|
});
|
|
2587
|
-
|
|
2752
|
+
const routeModelInfo = modelSelection.model !== 'default' ? ` | 모델: ${modelSelection.model}` : "";
|
|
2753
|
+
extra = `\n\n⚡ 자동 실행 완료! 브라우저 LLM 응답이 자동으로 제출되었습니다. (${waitResult.data.elapsedMs}ms${routeModelInfo})`;
|
|
2588
2754
|
} else {
|
|
2589
2755
|
throw new Error(waitResult.error?.message || "no response received");
|
|
2590
2756
|
}
|
|
@@ -2641,6 +2807,12 @@ server.tool(
|
|
|
2641
2807
|
|
|
2642
2808
|
const turnId = state.pending_turn_id || generateTurnId();
|
|
2643
2809
|
const port = getBrowserPort();
|
|
2810
|
+
const effectiveProvider = (state.participant_profiles || []).find(
|
|
2811
|
+
p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
|
|
2812
|
+
)?.provider || provider;
|
|
2813
|
+
|
|
2814
|
+
// Dynamic model selection based on prompt context
|
|
2815
|
+
const modelSelection = getModelSelectionForTurn(state, speaker, effectiveProvider);
|
|
2644
2816
|
|
|
2645
2817
|
// Step 1: Attach (pass URL from participant profile for auto-tab-creation)
|
|
2646
2818
|
const speakerProfile = (state.participant_profiles || []).find(
|
|
@@ -2655,6 +2827,18 @@ server.tool(
|
|
|
2655
2827
|
return { content: [{ type: "text", text: `❌ 브라우저 탭 바인딩 실패: ${attachResult.error.message}\n\n**에러 코드:** ${attachResult.error.code}\n**도메인:** ${attachResult.error.domain}\n\nCDP 디버깅 포트가 활성화된 브라우저가 실행 중인지 확인하세요.\n\`google-chrome --remote-debugging-port=9222\`\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
2656
2828
|
}
|
|
2657
2829
|
|
|
2830
|
+
// Step 1.2: Login detection — check if user is logged in to the web LLM
|
|
2831
|
+
const loginCheck = await port.checkLogin(resolved);
|
|
2832
|
+
if (loginCheck && !loginCheck.loggedIn) {
|
|
2833
|
+
await port.detach(resolved);
|
|
2834
|
+
return { content: [{ type: "text", text: `⚠️ **${speaker} 로그인 필요** — 웹 LLM에 로그인되어 있지 않습니다.\n\n**감지된 상태:** ${loginCheck.reason}\n**URL:** ${loginCheck.url || 'N/A'}\n\n이 speaker는 건너뜁니다. 브라우저에서 해당 LLM에 로그인한 후 다시 시도하세요.\n\n⛔ **API 호출로 대체하지 마세요.** 로그인되지 않은 speaker는 건너뛰는 것이 올바른 동작입니다.` }] };
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// Step 1.5: Switch model based on context analysis
|
|
2838
|
+
if (modelSelection.model !== 'default') {
|
|
2839
|
+
await port.switchModel(resolved, modelSelection.model);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2658
2842
|
// Step 2: Build turn prompt
|
|
2659
2843
|
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
2660
2844
|
|
|
@@ -2697,10 +2881,14 @@ server.tool(
|
|
|
2697
2881
|
? `\n**Degradation:** ${JSON.stringify(degradationState)}`
|
|
2698
2882
|
: "";
|
|
2699
2883
|
|
|
2884
|
+
const modelInfo = modelSelection.model !== 'default'
|
|
2885
|
+
? `\n**모델:** ${modelSelection.model} (${modelSelection.reason})\n**분석:** category=${modelSelection.category}, complexity=${modelSelection.complexity}`
|
|
2886
|
+
: "";
|
|
2887
|
+
|
|
2700
2888
|
return {
|
|
2701
2889
|
content: [{
|
|
2702
2890
|
type: "text",
|
|
2703
|
-
text: `✅ 브라우저 자동 턴 완료!\n\n**Provider:** ${
|
|
2891
|
+
text: `✅ 브라우저 자동 턴 완료!\n\n**Provider:** ${effectiveProvider}\n**Turn ID:** ${turnId}${modelInfo}\n**응답 길이:** ${response.length}자\n**소요 시간:** ${waitResult.data.elapsedMs}ms${degradationInfo}\n\n${result.content[0].text}`,
|
|
2704
2892
|
}],
|
|
2705
2893
|
};
|
|
2706
2894
|
})
|
|
@@ -2712,11 +2900,24 @@ server.tool(
|
|
|
2712
2900
|
{
|
|
2713
2901
|
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2714
2902
|
speaker: z.string().trim().min(1).max(64).describe("응답자 이름"),
|
|
2715
|
-
content: z.string().describe("응답 내용 (마크다운)"),
|
|
2903
|
+
content: z.string().optional().describe("응답 내용 (마크다운). content 또는 content_file 중 하나 필수."),
|
|
2904
|
+
content_file: z.string().optional().describe("응답 내용이 담긴 파일 경로. JSON 이스케이프 문제 회피용. 파일 내용이 그대로 content로 사용됩니다."),
|
|
2716
2905
|
turn_id: z.string().optional().describe("턴 검증 ID (deliberation_route_turn에서 받은 값)"),
|
|
2717
2906
|
},
|
|
2718
|
-
safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, turn_id }) => {
|
|
2719
|
-
|
|
2907
|
+
safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, turn_id }) => {
|
|
2908
|
+
// Support reading content from file to avoid JSON escaping issues
|
|
2909
|
+
let finalContent = content;
|
|
2910
|
+
if (content_file && !content) {
|
|
2911
|
+
try {
|
|
2912
|
+
finalContent = fs.readFileSync(content_file, "utf-8").trim();
|
|
2913
|
+
} catch (e) {
|
|
2914
|
+
return { content: [{ type: "text", text: `❌ content_file 읽기 실패: ${e.message}` }] };
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
if (!finalContent) {
|
|
2918
|
+
return { content: [{ type: "text", text: "❌ content 또는 content_file 중 하나를 제공해야 합니다." }] };
|
|
2919
|
+
}
|
|
2920
|
+
return submitDeliberationTurn({ session_id, speaker, content: finalContent, turn_id, channel_used: "cli_respond" });
|
|
2720
2921
|
})
|
|
2721
2922
|
);
|
|
2722
2923
|
|
package/install.js
CHANGED
package/package.json
CHANGED
package/selectors/chatgpt.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"provider": "chatgpt",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"domains": ["chat.openai.com", "chatgpt.com"],
|
|
5
5
|
"selectors": {
|
|
6
6
|
"inputSelector": "#prompt-textarea",
|
|
7
|
-
"sendButton": "button[data-testid='send-button']",
|
|
8
|
-
"responseSelector": ".markdown.prose",
|
|
7
|
+
"sendButton": "button.composer-submit-button-color, button[data-testid='send-button']",
|
|
8
|
+
"responseSelector": ".markdown.prose:not(.result-thinking), .result-thinking.markdown.prose",
|
|
9
9
|
"responseContainer": "[data-message-author-role='assistant']",
|
|
10
10
|
"streamingIndicator": ".result-streaming",
|
|
11
11
|
"conversationList": "nav[aria-label='Chat history']"
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
"inputDelayMs": 100,
|
|
15
15
|
"sendDelayMs": 200,
|
|
16
16
|
"pollIntervalMs": 500,
|
|
17
|
-
"streamingTimeoutMs":
|
|
17
|
+
"streamingTimeoutMs": 120000
|
|
18
18
|
},
|
|
19
|
-
"
|
|
19
|
+
"modelSelector": {
|
|
20
|
+
"trigger": "button[data-testid='model-switcher-dropdown-button'], button[data-testid='model-selector-dropdown'], button[class*='model']",
|
|
21
|
+
"optionSelector": "[role='option'], [role='menuitem'], [data-testid*='model-selector']"
|
|
22
|
+
},
|
|
23
|
+
"notes": "ChatGPT verified via CDP 2026-03-01. Input: #prompt-textarea (ProseMirror contenteditable div). Send button: button.composer-submit-button-color (primary, aria-label='Voice 시작' in KR locale — this is the submit button despite misleading label). button[data-testid='send-button'] no longer exists. Container: [data-message-author-role='assistant'] (still valid). Response: .markdown.prose:not(.result-thinking). Streaming: .result-streaming. Note: existing conversation responses may have empty textContent due to content-visibility virtualization — only fresh/visible responses will have text."
|
|
20
24
|
}
|
package/selectors/claude.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"provider": "claude",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"domains": ["claude.ai"],
|
|
5
5
|
"selectors": {
|
|
6
|
-
"inputSelector": "div
|
|
7
|
-
"sendButton": "button[aria-label='
|
|
8
|
-
"responseSelector": ".font-claude-
|
|
9
|
-
"responseContainer": "[data-is-streaming]",
|
|
6
|
+
"inputSelector": "div[data-testid='chat-input'], div.tiptap.ProseMirror, div.ProseMirror[contenteditable='true'], textarea[data-testid='chat-input-ssr']",
|
|
7
|
+
"sendButton": "button[aria-label='메시지 보내기'], button[aria-label='Send Message'], button[aria-label*='보내'], button[aria-label*='Send'], fieldset button[type='submit']",
|
|
8
|
+
"responseSelector": ".font-claude-response-body, .standard-markdown, .font-claude-response",
|
|
9
|
+
"responseContainer": "[data-is-streaming='false'], [data-is-streaming]",
|
|
10
10
|
"streamingIndicator": "[data-is-streaming='true']"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
@@ -15,5 +15,9 @@
|
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
16
|
"streamingTimeoutMs": 60000
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"modelSelector": {
|
|
19
|
+
"trigger": "button[data-testid='model-selector-dropdown']",
|
|
20
|
+
"optionSelector": "[role='option'], [role='menuitem'], [data-testid*='model']"
|
|
21
|
+
},
|
|
22
|
+
"notes": "Claude verified via CDP 2026-03-01. Input: div[data-testid='chat-input'] (tiptap ProseMirror contenteditable). Send button: aria-label='메시지 보내기' (KR) or 'Send Message' (EN). Container: [data-is-streaming='false'] (completed) or [data-is-streaming='true'] (streaming) — the data-is-streaming attribute is always present on the assistant message wrapper. Response text: .font-claude-response-body or .standard-markdown. Streaming indicator: [data-is-streaming='true'] only (NOT .font-claude-response which persists after completion). .font-claude-message no longer exists. data-testid='assistant-message' no longer exists. Model selector: data-testid='model-selector-dropdown'."
|
|
19
23
|
}
|
package/selectors/deepseek.json
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
"selectors": {
|
|
6
6
|
"inputSelector": "textarea.ds-scroll-area, textarea[placeholder*='DeepSeek'], textarea[placeholder*='Send a message']",
|
|
7
7
|
"sendButton": "div[role='button'].ds-icon-button, button[type='submit']",
|
|
8
|
-
"responseSelector": ".ds-markdown, .ds-markdown--block
|
|
9
|
-
"responseContainer": ".
|
|
10
|
-
"streamingIndicator": ".
|
|
8
|
+
"responseSelector": ".ds-markdown, .ds-markdown--block",
|
|
9
|
+
"responseContainer": ".ds-message",
|
|
10
|
+
"streamingIndicator": ".ds-message--streaming, [class*='streaming'], .typing-indicator"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
13
13
|
"inputDelayMs": 100,
|
|
@@ -15,5 +15,9 @@
|
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
16
|
"streamingTimeoutMs": 60000
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"modelSelector": {
|
|
19
|
+
"trigger": "div[class*='model-selector'], button[class*='model']",
|
|
20
|
+
"optionSelector": "[role='option'], [role='menuitem'], [class*='model-item']"
|
|
21
|
+
},
|
|
22
|
+
"notes": "DeepSeek verified via CDP. Container: DIV.ds-message. Response: DIV.ds-markdown > P.ds-markdown-paragraph. Input: textarea.ds-scroll-area. Send: div[role='button'].ds-icon-button (Enter key primary). Hashed class names may change."
|
|
19
23
|
}
|
package/selectors/gemini.json
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"provider": "gemini",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"domains": ["gemini.google.com"],
|
|
5
5
|
"selectors": {
|
|
6
6
|
"inputSelector": ".ql-editor[contenteditable='true']",
|
|
7
|
-
"sendButton": "button.send-button, button[aria-label='Send message']",
|
|
7
|
+
"sendButton": "button.send, button.send-button, button[aria-label='Send message'], button[aria-label='전송']",
|
|
8
8
|
"responseSelector": ".markdown.markdown-main-panel",
|
|
9
9
|
"responseContainer": ".model-response-text",
|
|
10
|
-
"streamingIndicator": "
|
|
10
|
+
"streamingIndicator": "button[aria-label*='중지'], button[aria-label*='Stop generating'], .loading-spinner"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
13
13
|
"inputDelayMs": 100,
|
|
14
14
|
"sendDelayMs": 300,
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
|
-
"streamingTimeoutMs":
|
|
16
|
+
"streamingTimeoutMs": 60000
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"modelSelector": {
|
|
19
|
+
"trigger": "button.input-area-switch, button[aria-label='모드 선택 도구 열기']",
|
|
20
|
+
"optionSelector": "[role='option'], [role='menuitem'], mat-option, .option-item"
|
|
21
|
+
},
|
|
22
|
+
"notes": "Gemini verified via CDP. Container: STRUCTURED-CONTENT-CONTAINER.model-response-text. Response: DIV.markdown.markdown-main-panel. Streaming: .model-response-text[class*='processing-state']. Model: button.input-area-switch."
|
|
19
23
|
}
|
package/selectors/grok.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"selectors": {
|
|
6
6
|
"inputSelector": "div.tiptap.ProseMirror[contenteditable='true'], div.ProseMirror[contenteditable='true']",
|
|
7
7
|
"sendButton": "button[aria-label='제출'], button[aria-label='Submit'], button[type='submit']",
|
|
8
|
-
"responseSelector": ".markdown, .
|
|
9
|
-
"responseContainer": "[
|
|
8
|
+
"responseSelector": ".response-content-markdown, .markdown",
|
|
9
|
+
"responseContainer": "div[id^='response-'], .message-bubble",
|
|
10
10
|
"streamingIndicator": "[data-streaming='true'], .animate-pulse"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
@@ -15,5 +15,9 @@
|
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
16
|
"streamingTimeoutMs": 60000
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"modelSelector": {
|
|
19
|
+
"trigger": "button[class*='model'], [data-testid*='model']",
|
|
20
|
+
"optionSelector": "[role='option'], [role='menuitem'], [class*='model-option']"
|
|
21
|
+
},
|
|
22
|
+
"notes": "Grok verified via CDP. Container: div[id^='response-'] (unique response IDs). Response: .response-content-markdown. User messages use separate container without response- prefix. Send button locale-dependent (EN: 'Submit', KR: '제출')."
|
|
19
23
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"selectors": {
|
|
6
6
|
"inputSelector": "textarea[placeholder='Ask anything'], textarea[placeholder*='Ask']",
|
|
7
7
|
"sendButton": "button[aria-label='Send message'], button[type='submit']",
|
|
8
|
-
"responseSelector": ".prose
|
|
9
|
-
"responseContainer": "
|
|
8
|
+
"responseSelector": ".prose",
|
|
9
|
+
"responseContainer": "[data-message-role='assistant']",
|
|
10
10
|
"streamingIndicator": ".animate-pulse, [data-streaming='true']"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
@@ -15,5 +15,9 @@
|
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
16
|
"streamingTimeoutMs": 60000
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"modelSelector": {
|
|
19
|
+
"trigger": "button[class*='model'], [data-testid*='model-selector']",
|
|
20
|
+
"optionSelector": "[role='option'], [role='menuitem'], button[class*='model']"
|
|
21
|
+
},
|
|
22
|
+
"notes": "HuggingChat verified via CDP. Container: div[data-message-role='assistant'][data-message-id]. Response: DIV.prose inside container. SvelteKit-based app."
|
|
19
23
|
}
|
package/selectors/poe.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"inputSelector": "textarea[class*='GrowingTextArea'], textarea[class*='textArea']",
|
|
7
7
|
"sendButton": "button[aria-label='메시지 전송'], button[aria-label='Send message'], button[class*='button_primary']",
|
|
8
8
|
"responseSelector": "[class*='Markdown_markdownContainer'], [class*='markdownContainer']",
|
|
9
|
-
"responseContainer": "[class*='
|
|
9
|
+
"responseContainer": "[class*='leftSideMessageBubble'], [class*='Message_bot']",
|
|
10
10
|
"streamingIndicator": "[class*='streamingBotMessage'], [class*='ChatMessage_streaming']"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
16
|
"streamingTimeoutMs": 45000
|
|
17
17
|
},
|
|
18
|
-
"notes": "Poe verified via CDP.
|
|
18
|
+
"notes": "Poe verified via CDP. Container: Message_leftSideMessageBubble (bot only, right side is user). Response: Markdown_markdownContainer > Prose_prose. Hashed class suffixes change between deployments. Send: aria-label locale-dependent."
|
|
19
19
|
}
|
package/selectors/qwen.json
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
"selectors": {
|
|
6
6
|
"inputSelector": "textarea.message-input-textarea, textarea[placeholder*='도와드릴까요'], textarea[placeholder*='help you']",
|
|
7
7
|
"sendButton": "button.send-button, button[type='submit']",
|
|
8
|
-
"responseSelector": ".
|
|
9
|
-
"responseContainer": ".message-assistant,
|
|
10
|
-
"streamingIndicator": ".
|
|
8
|
+
"responseSelector": ".response-message-content, .markdown-body",
|
|
9
|
+
"responseContainer": ".qwen-chat-message-assistant, .chat-response-message",
|
|
10
|
+
"streamingIndicator": ".phase-thinking, .typing-indicator"
|
|
11
11
|
},
|
|
12
12
|
"timing": {
|
|
13
13
|
"inputDelayMs": 100,
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
"pollIntervalMs": 500,
|
|
16
16
|
"streamingTimeoutMs": 60000
|
|
17
17
|
},
|
|
18
|
-
"notes": "
|
|
18
|
+
"notes": "Qwen verified via CDP. Container: .qwen-chat-message-assistant or .chat-response-message. Response: .response-message-content.phase-answer. Thinking phase: .phase-thinking class. '생각이 끝났습니다' indicates thinking complete."
|
|
19
19
|
}
|