@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 CHANGED
@@ -1,6 +1,10 @@
1
1
  # @dmsdc-ai/aigentry-deliberation
2
2
 
3
- MCP Deliberation ServerMulti-session AI deliberation with smart speaker ordering and persona roles.
3
+ Part of aigentrythe 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
@@ -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" },
@@ -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 => `- \`${c.speaker}\` (command: ${c.command})`).join("\n")}\n\n`;
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
- 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}`;
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입니다. 응답을 직접 작성해 \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 제출하세요.`;
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) => (typeof v === "string" ? JSON.parse(v) : 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("참가자 이름 목록 (예: codex, claude, web-chatgpt-1)"),
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
- extra = `\n\n⚡ 자동 실행 완료! 브라우저 LLM 응답이 자동으로 제출되었습니다. (${waitResult.data.elapsedMs}ms)`;
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:** ${provider}\n**Turn ID:** ${turnId}\n**응답 길이:** ${response.length}자\n**소요 시간:** ${waitResult.data.elapsedMs}ms${degradationInfo}\n\n${result.content[0].text}`,
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
- return submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used: "cli_respond" });
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
@@ -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.15",
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
  }