@dmsdc-ai/aigentry-deliberation 0.0.13 → 0.0.14

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.
@@ -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
- // Step 1: Focus input and insert text via execCommand for React/ProseMirror compatibility
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
- const result = await this._cdpEvaluate(binding, `
249
+ // Focus the input element first — use click() + focus() for full framework initialization
250
+ const focusInput = await this._cdpEvaluate(binding, `
230
251
  (function() {
231
- const input = document.querySelector(${inputSel});
232
- if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
233
-
234
- // Focus and select all existing content
235
- input.focus();
236
- if (input.isContentEditable) {
237
- // For contenteditable (ChatGPT ProseMirror, Claude, etc.)
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: true };
261
+ return { ok: false, error: 'INPUT_NOT_FOUND' };
259
262
  })()
260
263
  `);
261
264
 
262
- if (!result.ok) {
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 via Enter key (primary) + button click (fallback)
273
- const sendResult = await this._cdpEvaluate(binding, `
274
- (function() {
275
- const input = document.querySelector(${inputSel});
276
- if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
277
-
278
- // Primary: dispatch Enter key event on the input
279
- input.focus();
280
- const enterEvent = new KeyboardEvent('keydown', {
281
- key: 'Enter', code: 'Enter',
282
- keyCode: 13, which: 13,
283
- bubbles: true, cancelable: true
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
- input.dispatchEvent(enterEvent);
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
- // Fallback: also click send button if it exists and is enabled
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
- // Check if streaming is complete
549
+ const elapsed = Date.now() - startTime;
330
550
  const status = await this._cdpEvaluate(binding, `
331
551
  (function() {
332
- const streaming = document.querySelector(${streamSel});
333
- if (streaming) return { streaming: true };
334
- const responses = document.querySelectorAll(${respContSel});
335
- if (responses.length === 0) return { streaming: true };
336
- const last = responses[responses.length - 1];
337
- const content = last.querySelector(${respSel});
338
- return {
339
- streaming: false,
340
- text: content ? content.textContent : last.textContent,
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" },
@@ -1198,6 +1199,31 @@ async function collectSpeakerCandidates({ include_cli = true, include_browser =
1198
1199
  cdp_available: cdpReachable,
1199
1200
  });
1200
1201
  }
1202
+
1203
+ // Second pass: match auto-registered speakers to individual CDP tabs
1204
+ // (they were added after the first matching pass and only got the global cdpReachable flag)
1205
+ if (cdpTabs.length > 0) {
1206
+ for (const candidate of candidates) {
1207
+ if (!candidate.auto_registered || candidate.cdp_tab_id) continue;
1208
+ let candidateHost = "";
1209
+ try {
1210
+ candidateHost = new URL(candidate.url).hostname.toLowerCase();
1211
+ } catch { continue; }
1212
+ if (!candidateHost) continue;
1213
+ const matches = cdpTabs.filter(t => {
1214
+ try {
1215
+ const tabHost = new URL(t.url).hostname.toLowerCase();
1216
+ // Exact match or subdomain match (e.g., chat.deepseek.com matches deepseek.com)
1217
+ return tabHost === candidateHost || tabHost.endsWith("." + candidateHost);
1218
+ } catch { return false; }
1219
+ });
1220
+ if (matches.length >= 1) {
1221
+ candidate.cdp_available = true;
1222
+ candidate.cdp_tab_id = matches[0].id;
1223
+ candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
1224
+ }
1225
+ }
1226
+ }
1201
1227
  }
1202
1228
 
1203
1229
  return { candidates, browserNote };
@@ -1340,11 +1366,11 @@ function resolveTransportForSpeaker(state, speaker) {
1340
1366
 
1341
1367
  // CLI-specific invocation flags for non-interactive execution
1342
1368
  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 "프롬프트"' },
1369
+ claude: { cmd: "claude", flags: '-p --output-format text', example: 'claude -p --output-format text "프롬프트"', modelFlag: '--model', provider: 'claude' },
1370
+ codex: { cmd: "codex", flags: 'exec', example: 'codex exec "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
1371
+ gemini: { cmd: "gemini", flags: '', example: 'gemini "프롬프트"', modelFlag: '--model', provider: 'gemini' },
1372
+ aider: { cmd: "aider", flags: '--message', example: 'aider --message "프롬프트"', modelFlag: '--model', provider: 'chatgpt' },
1373
+ cursor: { cmd: "cursor", flags: '', example: 'cursor "프롬프트"', modelFlag: null, provider: 'chatgpt' },
1348
1374
  };
1349
1375
 
1350
1376
  function formatTransportGuidance(transport, state, speaker) {
@@ -1352,18 +1378,26 @@ function formatTransportGuidance(transport, state, speaker) {
1352
1378
  switch (transport) {
1353
1379
  case "cli_respond": {
1354
1380
  const hint = CLI_INVOCATION_HINTS[speaker] || null;
1355
- const invocationGuide = hint
1356
- ? `\n\n**CLI 호출 방법:** \`${hint.example}\`\n(플래그: \`${hint.cmd} ${hint.flags}\`)`
1357
- : "";
1358
- return `CLI speaker입니다. \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 직접 응답하세요.${invocationGuide}`;
1381
+ let invocationGuide = "";
1382
+ let modelGuide = "";
1383
+ if (hint) {
1384
+ invocationGuide = `\n\n**CLI 호출 방법:** \`${hint.example}\`\n(플래그: \`${hint.cmd} ${hint.flags}\`)`;
1385
+ if (hint.modelFlag && hint.provider) {
1386
+ const cliModel = getModelSelectionForTurn(state, speaker, hint.provider);
1387
+ if (cliModel.model !== 'default') {
1388
+ modelGuide = `\n**추천 모델:** ${cliModel.model} (${cliModel.reason})\n**모델 플래그:** \`${hint.modelFlag} ${cliModel.model}\``;
1389
+ }
1390
+ }
1391
+ }
1392
+ 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
1393
  }
1360
1394
  case "clipboard":
1361
- return `브라우저 LLM speaker입니다. CDP 자동 연결 시도 중... Chrome이 이미 CDP 없이 실행 중이면 재시작이 필요할 수 있습니다.`;
1395
+ return `브라우저 LLM speaker입니다. CDP 자동 연결 시도 중... Chrome이 이미 CDP 없이 실행 중이면 재시작이 필요할 수 있습니다.\n\n⛔ **API 호출 금지**: 이 speaker는 웹 브라우저로만 응답합니다. REST API, HTTP 요청으로 LLM을 호출하지 마세요.`;
1362
1396
  case "browser_auto":
1363
- return `자동 브라우저 speaker입니다. \`deliberation_browser_auto_turn(session_id: "${sid}")\`으로 자동 진행됩니다. CDP를 통해 브라우저 LLM에 직접 입력하고 응답을 읽습니다.`;
1397
+ return `자동 브라우저 speaker입니다. \`deliberation_browser_auto_turn(session_id: "${sid}")\`으로 자동 진행됩니다. CDP를 통해 브라우저 LLM에 직접 입력하고 응답을 읽습니다.\n\n⛔ **API 호출 금지**: CDP 자동화로만 진행합니다. REST API, HTTP 요청 사용 금지.`;
1364
1398
  case "manual":
1365
1399
  default:
1366
- return `수동 speaker입니다. 응답을 직접 작성해 \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 제출하세요.`;
1400
+ 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
1401
  }
1368
1402
  }
1369
1403
 
@@ -1671,6 +1705,32 @@ function hasTmuxSession(name) {
1671
1705
  }
1672
1706
  }
1673
1707
 
1708
+ function hasTmuxWindow(sessionName, windowName) {
1709
+ try {
1710
+ const output = execFileSync("tmux", ["list-windows", "-t", sessionName, "-F", "#{window_name}"], {
1711
+ encoding: "utf-8",
1712
+ stdio: ["ignore", "pipe", "ignore"],
1713
+ windowsHide: true,
1714
+ });
1715
+ return String(output).split("\n").map(s => s.trim()).includes(windowName);
1716
+ } catch {
1717
+ return false;
1718
+ }
1719
+ }
1720
+
1721
+ function tmuxHasAttachedClients(sessionName) {
1722
+ try {
1723
+ const output = execFileSync("tmux", ["list-clients", "-t", sessionName], {
1724
+ encoding: "utf-8",
1725
+ stdio: ["ignore", "pipe", "ignore"],
1726
+ windowsHide: true,
1727
+ });
1728
+ return String(output).trim().split("\n").filter(Boolean).length > 0;
1729
+ } catch {
1730
+ return false;
1731
+ }
1732
+ }
1733
+
1674
1734
  function tmuxWindowCount(name) {
1675
1735
  try {
1676
1736
  const output = execFileSync("tmux", ["list-windows", "-t", name], {
@@ -1733,6 +1793,17 @@ function openPhysicalTerminal(sessionId) {
1733
1793
  const winName = tmuxWindowName(sessionId);
1734
1794
  const attachCmd = `tmux attach -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
1735
1795
 
1796
+ // If a terminal is already attached to this tmux session, just switch to the right window
1797
+ if (tmuxHasAttachedClients(TMUX_SESSION)) {
1798
+ try {
1799
+ execFileSync("tmux", ["select-window", "-t", `${TMUX_SESSION}:${winName}`], {
1800
+ stdio: "ignore",
1801
+ windowsHide: true,
1802
+ });
1803
+ } catch { /* window might not exist yet */ }
1804
+ return { opened: true, windowIds: [] };
1805
+ }
1806
+
1736
1807
  if (process.platform === "darwin") {
1737
1808
  const before = new Set(listPhysicalTerminalWindowIds());
1738
1809
  try {
@@ -1840,6 +1911,10 @@ function spawnMonitorTerminal(sessionId) {
1840
1911
 
1841
1912
  try {
1842
1913
  if (hasTmuxSession(TMUX_SESSION)) {
1914
+ // Skip if a window with the same name already exists (prevents duplicates)
1915
+ if (hasTmuxWindow(TMUX_SESSION, winName)) {
1916
+ return true;
1917
+ }
1843
1918
  execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
1844
1919
  stdio: "ignore",
1845
1920
  windowsHide: true,
@@ -2223,9 +2298,18 @@ server.tool(
2223
2298
  rounds: z.coerce.number().optional().describe("라운드 수 (미지정 시 config 설정 따름, 기본 3)"),
2224
2299
  first_speaker: z.string().trim().min(1).max(64).optional().describe("첫 발언자 이름 (미지정 시 speakers의 첫 항목)"),
2225
2300
  speakers: z.preprocess(
2226
- (v) => (typeof v === "string" ? JSON.parse(v) : v),
2301
+ (v) => {
2302
+ const parsed = typeof v === "string" ? JSON.parse(v) : v;
2303
+ if (!Array.isArray(parsed)) return parsed;
2304
+ // Normalize: accept both string[] and {name, role?, instructions?}[]
2305
+ return parsed.map(item => (typeof item === "object" && item !== null && item.name) ? item.name : item);
2306
+ },
2227
2307
  z.array(z.string().trim().min(1).max(64)).min(1).optional()
2228
- ).describe("참가자 이름 목록 (예: codex, claude, web-chatgpt-1)"),
2308
+ ).describe("참가자 이름 목록. 문자열 배열 또는 {name, role, instructions} 객체 배열 모두 지원"),
2309
+ speaker_instructions: z.preprocess(
2310
+ (v) => (typeof v === "string" ? JSON.parse(v) : v),
2311
+ z.record(z.string(), z.string()).optional()
2312
+ ).describe("speaker별 추가 지시사항 (예: {\"claude\": \"비판적으로 검토\"})"),
2229
2313
  require_manual_speakers: z.preprocess(
2230
2314
  (v) => (typeof v === "string" ? v === "true" : v),
2231
2315
  z.boolean().optional()
@@ -2247,7 +2331,7 @@ server.tool(
2247
2331
  role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
2248
2332
  .describe("역할 프리셋 (balanced/debate/research/brainstorm/review/consensus). speaker_roles가 명시되면 무시됨"),
2249
2333
  },
2250
- safeToolHandler("deliberation_start", async ({ topic, rounds, first_speaker, speakers, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
2334
+ 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
2335
  // ── First-time onboarding guard ──
2252
2336
  const config = loadDeliberationConfig();
2253
2337
  if (!config.setup_complete) {
@@ -2327,6 +2411,19 @@ server.tool(
2327
2411
  || normalizeSpeaker(selectedSpeakers?.[0])
2328
2412
  || DEFAULT_SPEAKERS[0];
2329
2413
  const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
2414
+
2415
+ // Warn if only 1 speaker — deliberation requires 2+
2416
+ if (speakerOrder.length < 2) {
2417
+ const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
2418
+ const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
2419
+ return {
2420
+ content: [{
2421
+ type: "text",
2422
+ 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"])`,
2423
+ }],
2424
+ };
2425
+ }
2426
+
2330
2427
  const participantMode = hasManualSpeakers
2331
2428
  ? "수동 지정"
2332
2429
  : (autoDiscoveredSpeakers.length > 0 ? "자동 탐색(PATH)" : "기본값");
@@ -2557,6 +2654,9 @@ server.tool(
2557
2654
  const turnSpeaker = speaker;
2558
2655
  const turnProvider = profile?.provider || "chatgpt";
2559
2656
 
2657
+ // Dynamic model selection
2658
+ const modelSelection = getModelSelectionForTurn(state, turnSpeaker, turnProvider);
2659
+
2560
2660
  // Build prompt
2561
2661
  const turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
2562
2662
 
@@ -2564,6 +2664,11 @@ server.tool(
2564
2664
  const attachResult = await port.attach(sessionId, { provider: turnProvider, url: profile?.url });
2565
2665
  if (!attachResult.ok) throw new Error(`attach failed: ${attachResult.error?.message}`);
2566
2666
 
2667
+ // Switch model if needed
2668
+ if (modelSelection.model !== 'default') {
2669
+ await port.switchModel(sessionId, modelSelection.model);
2670
+ }
2671
+
2567
2672
  // Send turn
2568
2673
  const autoTurnId = turnId || `auto-${Date.now()}`;
2569
2674
  const sendResult = await port.sendTurnWithDegradation(sessionId, autoTurnId, turnPrompt);
@@ -2584,7 +2689,8 @@ server.tool(
2584
2689
  channel_used: "browser_auto",
2585
2690
  fallback_reason: null,
2586
2691
  });
2587
- extra = `\n\n⚡ 자동 실행 완료! 브라우저 LLM 응답이 자동으로 제출되었습니다. (${waitResult.data.elapsedMs}ms)`;
2692
+ const routeModelInfo = modelSelection.model !== 'default' ? ` | 모델: ${modelSelection.model}` : "";
2693
+ extra = `\n\n⚡ 자동 실행 완료! 브라우저 LLM 응답이 자동으로 제출되었습니다. (${waitResult.data.elapsedMs}ms${routeModelInfo})`;
2588
2694
  } else {
2589
2695
  throw new Error(waitResult.error?.message || "no response received");
2590
2696
  }
@@ -2641,6 +2747,12 @@ server.tool(
2641
2747
 
2642
2748
  const turnId = state.pending_turn_id || generateTurnId();
2643
2749
  const port = getBrowserPort();
2750
+ const effectiveProvider = (state.participant_profiles || []).find(
2751
+ p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
2752
+ )?.provider || provider;
2753
+
2754
+ // Dynamic model selection based on prompt context
2755
+ const modelSelection = getModelSelectionForTurn(state, speaker, effectiveProvider);
2644
2756
 
2645
2757
  // Step 1: Attach (pass URL from participant profile for auto-tab-creation)
2646
2758
  const speakerProfile = (state.participant_profiles || []).find(
@@ -2655,6 +2767,18 @@ server.tool(
2655
2767
  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
2768
  }
2657
2769
 
2770
+ // Step 1.2: Login detection — check if user is logged in to the web LLM
2771
+ const loginCheck = await port.checkLogin(resolved);
2772
+ if (loginCheck && !loginCheck.loggedIn) {
2773
+ await port.detach(resolved);
2774
+ 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는 건너뛰는 것이 올바른 동작입니다.` }] };
2775
+ }
2776
+
2777
+ // Step 1.5: Switch model based on context analysis
2778
+ if (modelSelection.model !== 'default') {
2779
+ await port.switchModel(resolved, modelSelection.model);
2780
+ }
2781
+
2658
2782
  // Step 2: Build turn prompt
2659
2783
  const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
2660
2784
 
@@ -2697,10 +2821,14 @@ server.tool(
2697
2821
  ? `\n**Degradation:** ${JSON.stringify(degradationState)}`
2698
2822
  : "";
2699
2823
 
2824
+ const modelInfo = modelSelection.model !== 'default'
2825
+ ? `\n**모델:** ${modelSelection.model} (${modelSelection.reason})\n**분석:** category=${modelSelection.category}, complexity=${modelSelection.complexity}`
2826
+ : "";
2827
+
2700
2828
  return {
2701
2829
  content: [{
2702
2830
  type: "text",
2703
- text: `✅ 브라우저 자동 턴 완료!\n\n**Provider:** ${provider}\n**Turn ID:** ${turnId}\n**응답 길이:** ${response.length}자\n**소요 시간:** ${waitResult.data.elapsedMs}ms${degradationInfo}\n\n${result.content[0].text}`,
2831
+ 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
2832
  }],
2705
2833
  };
2706
2834
  })
@@ -2712,11 +2840,24 @@ server.tool(
2712
2840
  {
2713
2841
  session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
2714
2842
  speaker: z.string().trim().min(1).max(64).describe("응답자 이름"),
2715
- content: z.string().describe("응답 내용 (마크다운)"),
2843
+ content: z.string().optional().describe("응답 내용 (마크다운). content 또는 content_file 중 하나 필수."),
2844
+ content_file: z.string().optional().describe("응답 내용이 담긴 파일 경로. JSON 이스케이프 문제 회피용. 파일 내용이 그대로 content로 사용됩니다."),
2716
2845
  turn_id: z.string().optional().describe("턴 검증 ID (deliberation_route_turn에서 받은 값)"),
2717
2846
  },
2718
- safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, turn_id }) => {
2719
- return submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used: "cli_respond" });
2847
+ safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, content_file, turn_id }) => {
2848
+ // Support reading content from file to avoid JSON escaping issues
2849
+ let finalContent = content;
2850
+ if (content_file && !content) {
2851
+ try {
2852
+ finalContent = fs.readFileSync(content_file, "utf-8").trim();
2853
+ } catch (e) {
2854
+ return { content: [{ type: "text", text: `❌ content_file 읽기 실패: ${e.message}` }] };
2855
+ }
2856
+ }
2857
+ if (!finalContent) {
2858
+ return { content: [{ type: "text", text: "❌ content 또는 content_file 중 하나를 제공해야 합니다." }] };
2859
+ }
2860
+ return submitDeliberationTurn({ session_id, speaker, content: finalContent, turn_id, channel_used: "cli_respond" });
2720
2861
  })
2721
2862
  );
2722
2863
 
package/install.js CHANGED
@@ -37,6 +37,7 @@ const FILES_TO_COPY = [
37
37
  "observer.js",
38
38
  "browser-control-port.js",
39
39
  "degradation-state-machine.js",
40
+ "model-router.js",
40
41
  "session-monitor.sh",
41
42
  "session-monitor-win.js",
42
43
  "package.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-deliberation",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "MCP Deliberation Server — Multi-session AI deliberation with smart speaker ordering and persona roles",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "provider": "chatgpt",
3
- "version": "1.0.0",
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": 45000
17
+ "streamingTimeoutMs": 120000
18
18
  },
19
- "notes": "ChatGPT DOM selectors - update version when selectors change"
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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "provider": "claude",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "domains": ["claude.ai"],
5
5
  "selectors": {
6
- "inputSelector": "div.ProseMirror[contenteditable='true'], div.tiptap.ProseMirror",
7
- "sendButton": "button[aria-label='Send Message'], button[aria-label='메시지 보내기'], button[aria-label='Send message']",
8
- "responseSelector": ".font-claude-message .grid-cols-1 .grid .min-w-0",
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
- "notes": "Claude web selectors - send button aria-label is locale-dependent (EN: 'Send Message', KR: '메시지 보내기'). Button only appears when input has text."
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
  }
@@ -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, .markdown-body",
9
- "responseContainer": ".chat-message-content, [data-role='assistant']",
10
- "streamingIndicator": ".chat-message--streaming, .typing-indicator"
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
- "notes": "DeepSeek verified via CDP. Input: textarea.ds-scroll-area (placeholder locale-dependent). Send: div[role='button'].ds-icon-button (no aria-label, Enter key is primary). Hashed class names (_27c9245, _7436101) may change."
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
  }
@@ -1,19 +1,23 @@
1
1
  {
2
2
  "provider": "gemini",
3
- "version": "1.0.0",
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": ".loading-indicator, .model-response-text .loading"
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": 45000
16
+ "streamingTimeoutMs": 60000
17
17
  },
18
- "notes": "Gemini web selectors - verify via DOM inspection if selectors change"
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
  }
@@ -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, .prose",
9
- "responseContainer": "[data-role='assistant'], .message-assistant",
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
- "notes": "xAI Grok - uses tiptap ProseMirror contenteditable. Send button aria-label is locale-dependent (EN: 'Submit', KR: '제출')."
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, .markdown",
9
- "responseContainer": ".message-assistant, [data-role='assistant']",
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
- "notes": "HuggingChat - textarea with placeholder 'Ask anything'. Send button has aria-label='Send message'."
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
  }
@@ -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*='botMessageBubble'], [class*='Message_bot']",
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. Input: textarea.GrowingTextArea_textArea__ZWQbP (hashed). Send: button[aria-label='메시지 전송'] (KR locale) / 'Send message' (EN). Hashed class names change between deployments."
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
  }
@@ -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": ".markdown-body, .message-content",
9
- "responseContainer": ".message-assistant, [data-role='assistant']",
10
- "streamingIndicator": ".typing-indicator, [data-streaming='true']"
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": "Alibaba Qwen Chat - uses textarea.message-input-textarea. Send button has class 'send-button'."
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
  }