@dmsdc-ai/aigentry-deliberation 0.0.39 → 0.0.41

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.
@@ -0,0 +1,1575 @@
1
+ /**
2
+ * Speaker/Candidate Discovery domain — speaker detection, role inference,
3
+ * browser LLM tab scanning, CDP probing, transport routing, and candidate
4
+ * report formatting.
5
+ *
6
+ * Extracted from index.js to keep the main entry point focused on MCP tool
7
+ * registration while this module owns all speaker discovery logic.
8
+ */
9
+
10
+ import { execFileSync, spawn } from "child_process";
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import os from "os";
14
+ import {
15
+ TELEPTY_DEFAULT_HOST,
16
+ TELEPTY_TRANSPORT_TIMEOUT_MS,
17
+ TELEPTY_SEMANTIC_TIMEOUT_MS,
18
+ collectTeleptySessions,
19
+ collectTeleptyProcessLocators,
20
+ formatTeleptyHostLabel,
21
+ } from "./telepty.js";
22
+ import { getModelSelectionForTurn } from "../model-router.js";
23
+
24
+ // ── Dependency injection ────────────────────────────────────────
25
+ // Functions that live in index.js but are needed here. Injected once
26
+ // via `initSpeakerDeps()` so we avoid circular imports.
27
+
28
+ let _deps = {
29
+ appendRuntimeLog: () => {},
30
+ loadDeliberationConfig: () => ({}),
31
+ getProjectSlug: () => path.basename(process.cwd()),
32
+ readJsonFileSafe: () => null,
33
+ writeJsonFileAtomic: () => {},
34
+ getSpeakerSelectionFile: () => "",
35
+ };
36
+
37
+ export function initSpeakerDeps(deps) {
38
+ Object.assign(_deps, deps);
39
+ }
40
+
41
+ // ── Module-level __dirname for ESM ──────────────────────────────
42
+ const __dirnameEsm = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
43
+ // selectors live one level up from lib/
44
+ const __projectRoot = path.dirname(__dirnameEsm);
45
+
46
+ // ── Constants ───────────────────────────────────────────────────
47
+
48
+ export const DEFAULT_SPEAKERS = ["agent-a", "agent-b"];
49
+
50
+ export const DEFAULT_CLI_CANDIDATES = [
51
+ "claude",
52
+ "codex",
53
+ "gemini",
54
+ "qwen",
55
+ "chatgpt",
56
+ "aider",
57
+ "llm",
58
+ "opencode",
59
+ "cursor-agent",
60
+ "cursor",
61
+ "continue",
62
+ ];
63
+
64
+ export const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
65
+
66
+ export const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
67
+
68
+ export const DEFAULT_LLM_DOMAINS = [
69
+ "chatgpt.com",
70
+ "openai.com",
71
+ "claude.ai",
72
+ "anthropic.com",
73
+ "gemini.google.com",
74
+ "copilot.microsoft.com",
75
+ "poe.com",
76
+ "perplexity.ai",
77
+ "mistral.ai",
78
+ "huggingface.co/chat",
79
+ "deepseek.com",
80
+ "qwen.ai",
81
+ "notebooklm.google.com",
82
+ ];
83
+
84
+ // Well-known web LLMs — always available as speaker candidates regardless of browser detection.
85
+ // When a matching browser tab is detected, transport upgrades to browser_auto (CDP) or clipboard.
86
+ // When no tab is detected, transport falls back to clipboard (manual paste).
87
+ export const DEFAULT_WEB_SPEAKERS = [
88
+ { speaker: "web-chatgpt", provider: "chatgpt", name: "ChatGPT", url: "https://chatgpt.com" },
89
+ { speaker: "web-claude", provider: "claude", name: "Claude", url: "https://claude.ai" },
90
+ { speaker: "web-gemini", provider: "gemini", name: "Gemini", url: "https://gemini.google.com" },
91
+ { speaker: "web-copilot", provider: "copilot", name: "Copilot", url: "https://copilot.microsoft.com" },
92
+ { speaker: "web-perplexity", provider: "perplexity", name: "Perplexity", url: "https://perplexity.ai" },
93
+ { speaker: "web-deepseek", provider: "deepseek", name: "DeepSeek", url: "https://chat.deepseek.com" },
94
+ { speaker: "web-mistral", provider: "mistral", name: "Mistral", url: "https://mistral.ai" },
95
+ { speaker: "web-poe", provider: "poe", name: "Poe", url: "https://poe.com" },
96
+ { speaker: "web-grok", provider: "grok", name: "Grok", url: "https://grok.com" },
97
+ { speaker: "web-qwen", provider: "qwen", name: "Qwen", url: "https://chat.qwen.ai" },
98
+ { speaker: "web-huggingchat", provider: "huggingchat", name: "HuggingChat", url: "https://huggingface.co/chat" },
99
+ ];
100
+
101
+ export const SPEAKER_SELECTION_FILE = "speaker-selection.json";
102
+ export const SPEAKER_SELECTION_TTL_MS = 10 * 60 * 1000;
103
+
104
+ // CLI-specific invocation flags for non-interactive execution
105
+ export const CLI_INVOCATION_HINTS = {
106
+ claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', defaultModel: null, provider: 'claude' },
107
+ codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '-m', defaultModel: 'gpt-5.4', provider: 'chatgpt' },
108
+ gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '-m', defaultModel: null, provider: 'gemini' },
109
+ aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
110
+ cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
111
+ };
112
+
113
+ export const ROLE_KEYWORDS = {
114
+ critic: /문제|위험|실패|약점|리스크|반대|비판|결함|취약/,
115
+ implementer: /구현|코드|방법|설계|빌드|개발|함수|모듈|파일/,
116
+ mediator: /합의|정리|결론|종합|요약|중재|절충|균형/,
117
+ researcher: /사례|데이터|연구|벤치마크|비교|논문|참고/,
118
+ };
119
+
120
+ export const ROLE_HEADING_MARKERS = {
121
+ critic: /^##?\s*(Critic|비판|약점|심각도|위험\s*분석|검증|평가|Review)/m,
122
+ implementer: /^##?\s*(코드\s*스케치|구현|Implementation|제안\s*코드)/m,
123
+ mediator: /^##?\s*(합의|종합|중재|Consensus|Mediation)/m,
124
+ researcher: /^##?\s*(조사\s*결과|비교\s*분석|Research|사례\s*연구|근거|데이터|Data)/m,
125
+ };
126
+
127
+ export const DEGRADATION_TIERS = {
128
+ monitoring: {
129
+ tier1: { name: "tmux", description: "tmux real-time monitoring window", check: () => commandExistsInPath("tmux") },
130
+ tier2: { name: "logfile", description: "Log file tail monitoring", check: () => true },
131
+ tier3: { name: "silent", description: "No monitoring (log only)", check: () => true },
132
+ },
133
+ browser: {
134
+ tier1: { name: "cdp_auto", description: "CDP auto send/collect", check: async () => { try { const res = await fetch("http://127.0.0.1:9222/json/version", { signal: AbortSignal.timeout(2000) }); return res.ok; } catch { return false; } } },
135
+ tier2: { name: "clipboard", description: "Clipboard-based manual transfer", check: () => true },
136
+ tier3: { name: "manual", description: "Fully manual copy/paste", check: () => true },
137
+ },
138
+ terminal: {
139
+ tier1: { name: "auto_open", description: "Auto-open terminal app", check: () => process.platform === "darwin" || process.platform === "linux" || process.platform === "win32" },
140
+ tier2: { name: "none", description: "Cannot auto-open terminal", check: () => true },
141
+ tier3: { name: "none", description: "Cannot auto-open terminal", check: () => true },
142
+ },
143
+ };
144
+
145
+ export const TRANSPORT_TYPES = {
146
+ cli: "cli_respond",
147
+ telepty: "telepty_bus",
148
+ browser: "clipboard",
149
+ browser_auto: "browser_auto",
150
+ manual: "manual",
151
+ };
152
+
153
+ // ── Module-level caches ─────────────────────────────────────────
154
+
155
+ let _extensionProviderRegistry = null;
156
+ let _rolePresetsCache = null;
157
+
158
+ // ── Utility functions ───────────────────────────────────────────
159
+
160
+ export function commandExistsInPath(command) {
161
+ if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
162
+ return false;
163
+ }
164
+
165
+ if (process.platform === "win32") {
166
+ try {
167
+ execFileSync("where", [command], { stdio: "ignore" });
168
+ return true;
169
+ } catch {
170
+ // keep PATH scan fallback for shells where "where" is unavailable
171
+ }
172
+ }
173
+
174
+ const pathVar = process.env.PATH || "";
175
+ const dirs = pathVar.split(path.delimiter).filter(Boolean);
176
+ if (dirs.length === 0) return false;
177
+
178
+ const extensions = process.platform === "win32"
179
+ ? ["", ".exe", ".cmd", ".bat", ".ps1"]
180
+ : [""];
181
+
182
+ for (const dir of dirs) {
183
+ for (const ext of extensions) {
184
+ const fullPath = path.join(dir, `${command}${ext}`);
185
+ try {
186
+ fs.accessSync(fullPath, fs.constants.X_OK);
187
+ return true;
188
+ } catch {
189
+ // ignore and continue
190
+ }
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+
196
+ export function shellQuote(value) {
197
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
198
+ }
199
+
200
+ // ── Speaker normalization ───────────────────────────────────────
201
+
202
+ export function normalizeSpeaker(raw) {
203
+ if (typeof raw !== "string") return null;
204
+ const normalized = raw.trim().toLowerCase();
205
+ if (!normalized || normalized === "none") return null;
206
+ return normalized;
207
+ }
208
+
209
+ export function dedupeSpeakers(items = []) {
210
+ const out = [];
211
+ const seen = new Set();
212
+ for (const item of items) {
213
+ const normalized = normalizeSpeaker(item);
214
+ if (!normalized || seen.has(normalized)) continue;
215
+ seen.add(normalized);
216
+ out.push(normalized);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // ── Speaker ordering & roles ────────────────────────────────────
222
+
223
+ export function selectNextSpeaker(session) {
224
+ const { speakers, current_speaker, log, ordering_strategy } = session;
225
+ switch (ordering_strategy || "cyclic") {
226
+ case "random":
227
+ return speakers[Math.floor(Math.random() * speakers.length)];
228
+ case "weighted-random": {
229
+ const window = log.slice(-(speakers.length * 2));
230
+ const counts = new Map(speakers.map(s => [s, 0]));
231
+ for (const entry of window) {
232
+ if (counts.has(entry.speaker)) counts.set(entry.speaker, counts.get(entry.speaker) + 1);
233
+ }
234
+ const maxCount = Math.max(...counts.values(), 1);
235
+ const weights = speakers.map(s => maxCount + 1 - counts.get(s));
236
+ const total = weights.reduce((a, b) => a + b, 0);
237
+ let r = Math.random() * total;
238
+ for (let i = 0; i < speakers.length; i++) {
239
+ r -= weights[i];
240
+ if (r <= 0) return speakers[i];
241
+ }
242
+ return speakers[speakers.length - 1];
243
+ }
244
+ case "cyclic":
245
+ default: {
246
+ const idx = speakers.indexOf(current_speaker);
247
+ return speakers[(idx + 1) % speakers.length];
248
+ }
249
+ }
250
+ }
251
+
252
+ export function loadRolePrompt(role) {
253
+ if (!role || role === "free") return "";
254
+ try {
255
+ const promptPath = path.join(__projectRoot, "selectors", "roles", `${role}.md`);
256
+ return fs.readFileSync(promptPath, "utf-8").trim();
257
+ } catch {
258
+ return "";
259
+ }
260
+ }
261
+
262
+ export function inferSuggestedRole(text) {
263
+ const scores = {};
264
+ for (const [role, pattern] of Object.entries(ROLE_KEYWORDS)) {
265
+ const matches = (text.match(new RegExp(pattern, "g")) || []).length;
266
+ if (matches > 0) scores[role] = matches;
267
+ }
268
+ // Structural heading markers get extra weight (equivalent to 5 keyword matches)
269
+ for (const [role, pattern] of Object.entries(ROLE_HEADING_MARKERS)) {
270
+ if (pattern.test(text)) {
271
+ scores[role] = (scores[role] || 0) + 8;
272
+ }
273
+ }
274
+ if (Object.keys(scores).length === 0) return "free";
275
+ return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0];
276
+ }
277
+
278
+ export function parseVotes(text) {
279
+ const votes = [];
280
+ for (const line of text.split("\n")) {
281
+ const agree = line.match(/\[AGREE\]/i);
282
+ const disagree = line.match(/\[DISAGREE\]/i);
283
+ const conditional = line.match(/\[CONDITIONAL:\s*(.+?)\]/i);
284
+ if (agree) votes.push({ line: line.trim(), vote: "agree" });
285
+ else if (disagree) votes.push({ line: line.trim(), vote: "disagree" });
286
+ else if (conditional) votes.push({ line: line.trim(), vote: "conditional", condition: conditional[1].trim() });
287
+ }
288
+ return votes;
289
+ }
290
+
291
+ export function loadRolePresets() {
292
+ if (_rolePresetsCache) return _rolePresetsCache;
293
+ try {
294
+ const presetsPath = path.join(__projectRoot, "selectors", "role-presets.json");
295
+ _rolePresetsCache = JSON.parse(fs.readFileSync(presetsPath, "utf-8"));
296
+ return _rolePresetsCache;
297
+ } catch {
298
+ _rolePresetsCache = { presets: {} };
299
+ return _rolePresetsCache;
300
+ }
301
+ }
302
+
303
+ export function applyRolePreset(preset, speakers) {
304
+ const presets = loadRolePresets();
305
+ const presetDef = presets.presets[preset];
306
+ if (!presetDef) return {};
307
+
308
+ const roles = presetDef.roles;
309
+ const result = {};
310
+ for (let i = 0; i < speakers.length; i++) {
311
+ result[speakers[i]] = roles[i % roles.length];
312
+ }
313
+ return result;
314
+ }
315
+
316
+ // ── Extension provider registry ─────────────────────────────────
317
+
318
+ export function loadExtensionProviderRegistry() {
319
+ if (_extensionProviderRegistry) return _extensionProviderRegistry;
320
+ try {
321
+ const registryPath = path.join(__projectRoot, "selectors", "extension-providers.json");
322
+ _extensionProviderRegistry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
323
+ return _extensionProviderRegistry;
324
+ } catch (err) {
325
+ console.error("Failed to load extension-providers.json:", err.message);
326
+ _extensionProviderRegistry = { providers: [] };
327
+ return _extensionProviderRegistry;
328
+ }
329
+ }
330
+
331
+ export function isExtensionLlmTab(url = "", title = "") {
332
+ if (!String(url).startsWith("chrome-extension://")) return false;
333
+ const registry = loadExtensionProviderRegistry();
334
+ const lowerTitle = String(title || "").toLowerCase();
335
+ if (!lowerTitle) return false;
336
+ return registry.providers.some(p =>
337
+ p.titlePatterns.some(pattern => lowerTitle.includes(pattern.toLowerCase()))
338
+ );
339
+ }
340
+
341
+ // ── Speaker selection tokens ────────────────────────────────────
342
+
343
+ export function createSelectionToken() {
344
+ return `sel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
345
+ }
346
+
347
+ export function issueSpeakerSelectionToken({ candidates, include_browser }) {
348
+ const selectionState = {
349
+ token: createSelectionToken(),
350
+ phase: "candidates",
351
+ created_at: new Date().toISOString(),
352
+ include_browser: !!include_browser,
353
+ candidate_speakers: dedupeSpeakers((candidates || []).map(c => typeof c === "string" ? c : c?.speaker)),
354
+ };
355
+ _deps.writeJsonFileAtomic(_deps.getSpeakerSelectionFile(), selectionState);
356
+ return selectionState;
357
+ }
358
+
359
+ export function loadSpeakerSelectionToken() {
360
+ return _deps.readJsonFileSafe(_deps.getSpeakerSelectionFile());
361
+ }
362
+
363
+ export function clearSpeakerSelectionToken() {
364
+ try {
365
+ fs.unlinkSync(_deps.getSpeakerSelectionFile());
366
+ } catch {
367
+ // ignore missing file
368
+ }
369
+ }
370
+
371
+ export function validateSpeakerSelectionSnapshot({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
372
+ if (!selection_token) {
373
+ return { ok: false, code: "missing_token" };
374
+ }
375
+ if (!selectionState?.token) {
376
+ return { ok: false, code: "missing_selection_state" };
377
+ }
378
+ if (selectionState.token !== selection_token) {
379
+ return { ok: false, code: "token_mismatch" };
380
+ }
381
+
382
+ const createdAtMs = Date.parse(selectionState.created_at || "");
383
+ if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
384
+ return { ok: false, code: "expired_token" };
385
+ }
386
+
387
+ if (!!selectionState.include_browser !== !!includeBrowserSpeakers) {
388
+ return { ok: false, code: "mode_mismatch" };
389
+ }
390
+
391
+ const availableSpeakers = new Set(dedupeSpeakers(selectionState.candidate_speakers || []));
392
+ const requestedSpeakers = dedupeSpeakers(speakers || []);
393
+ const missingSpeakers = requestedSpeakers.filter(speaker => !availableSpeakers.has(speaker));
394
+ if (missingSpeakers.length > 0) {
395
+ return { ok: false, code: "speaker_mismatch", missing_speakers: missingSpeakers };
396
+ }
397
+
398
+ return { ok: true };
399
+ }
400
+
401
+ export function confirmSpeakerSelectionToken({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now(), persist = true }) {
402
+ const snapshotValidation = validateSpeakerSelectionSnapshot({
403
+ selectionState,
404
+ selection_token,
405
+ speakers,
406
+ includeBrowserSpeakers,
407
+ nowMs,
408
+ });
409
+ if (!snapshotValidation.ok) {
410
+ return snapshotValidation;
411
+ }
412
+
413
+ const confirmedSelection = {
414
+ token: createSelectionToken(),
415
+ phase: "confirmed",
416
+ created_at: new Date(nowMs).toISOString(),
417
+ include_browser: !!includeBrowserSpeakers,
418
+ candidate_speakers: dedupeSpeakers(selectionState.candidate_speakers || []),
419
+ selected_speakers: dedupeSpeakers(speakers || []),
420
+ };
421
+ if (persist) {
422
+ _deps.writeJsonFileAtomic(_deps.getSpeakerSelectionFile(), confirmedSelection);
423
+ }
424
+ return { ok: true, selectionState: confirmedSelection };
425
+ }
426
+
427
+ export function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
428
+ const snapshotValidation = validateSpeakerSelectionSnapshot({
429
+ selectionState,
430
+ selection_token,
431
+ speakers,
432
+ includeBrowserSpeakers,
433
+ nowMs,
434
+ });
435
+ if (!snapshotValidation.ok) {
436
+ return snapshotValidation;
437
+ }
438
+
439
+ if (selectionState.phase !== "confirmed" || !Array.isArray(selectionState.selected_speakers)) {
440
+ return { ok: false, code: "selection_not_confirmed" };
441
+ }
442
+
443
+ const expectedSpeakers = dedupeSpeakers(selectionState.selected_speakers || []);
444
+ const requestedSpeakers = dedupeSpeakers(speakers || []);
445
+ if (
446
+ expectedSpeakers.length !== requestedSpeakers.length
447
+ || expectedSpeakers.some(speaker => !requestedSpeakers.includes(speaker))
448
+ ) {
449
+ return {
450
+ ok: false,
451
+ code: "selected_speakers_mismatch",
452
+ expected_speakers: expectedSpeakers,
453
+ requested_speakers: requestedSpeakers,
454
+ };
455
+ }
456
+
457
+ return { ok: true };
458
+ }
459
+
460
+ // ── Browser participant helpers ─────────────────────────────────
461
+
462
+ export function hasExplicitBrowserParticipantSelection({ speakers, participant_types } = {}) {
463
+ const manualSpeakers = Array.isArray(speakers) ? speakers : [];
464
+ const hasBrowserSpeaker = manualSpeakers.some(speaker => {
465
+ const normalized = normalizeSpeaker(speaker);
466
+ return normalized?.startsWith("web-");
467
+ });
468
+ if (hasBrowserSpeaker) return true;
469
+
470
+ const overrides = participant_types && typeof participant_types === "object"
471
+ ? Object.entries(participant_types)
472
+ : [];
473
+
474
+ return overrides.some(([speaker, type]) => {
475
+ const normalized = normalizeSpeaker(speaker);
476
+ return normalized?.startsWith("web-") || type === "browser" || type === "browser_auto";
477
+ });
478
+ }
479
+
480
+ export function resolveIncludeBrowserSpeakers({ include_browser_speakers, config, speakers, participant_types } = {}) {
481
+ if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
482
+ return include_browser_speakers;
483
+ }
484
+ if (config?.include_browser_speakers !== undefined && config?.include_browser_speakers !== null) {
485
+ return config.include_browser_speakers;
486
+ }
487
+ return false;
488
+ }
489
+
490
+ // ── CLI discovery ───────────────────────────────────────────────
491
+
492
+ export function resolveCliCandidates() {
493
+ const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
494
+ .split(/[,\s]+/)
495
+ .map(v => v.trim())
496
+ .filter(Boolean);
497
+
498
+ // If config has enabled_clis, use that as the primary filter
499
+ const config = _deps.loadDeliberationConfig();
500
+ if (Array.isArray(config.enabled_clis) && config.enabled_clis.length > 0) {
501
+ return dedupeSpeakers([...fromEnv, ...config.enabled_clis]);
502
+ }
503
+
504
+ return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
505
+ }
506
+
507
+ export function checkCliLiveness(command) {
508
+ const hint = CLI_INVOCATION_HINTS[command];
509
+ const env = { ...process.env };
510
+ // Unset CLAUDECODE to avoid nested session errors
511
+ if (hint?.envPrefix?.includes("CLAUDECODE=")) {
512
+ delete env.CLAUDECODE;
513
+ }
514
+ try {
515
+ execFileSync(command, ["--version"], {
516
+ stdio: "ignore",
517
+ windowsHide: true,
518
+ timeout: 5000,
519
+ env,
520
+ });
521
+ return true;
522
+ } catch {
523
+ // --version failed, try --help as fallback
524
+ try {
525
+ execFileSync(command, ["--help"], {
526
+ stdio: "ignore",
527
+ windowsHide: true,
528
+ timeout: 5000,
529
+ env,
530
+ });
531
+ return true;
532
+ } catch {
533
+ return false;
534
+ }
535
+ }
536
+ }
537
+
538
+ export function discoverLocalCliSpeakers() {
539
+ const found = [];
540
+ for (const candidate of resolveCliCandidates()) {
541
+ if (commandExistsInPath(candidate)) {
542
+ found.push(candidate);
543
+ }
544
+ if (found.length >= MAX_AUTO_DISCOVERED_SPEAKERS) {
545
+ break;
546
+ }
547
+ }
548
+ return found;
549
+ }
550
+
551
+ export function detectCallerSpeaker() {
552
+ const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
553
+ if (hinted) return hinted;
554
+
555
+ const envKeys = Object.keys(process.env).join(" ");
556
+ const pathHint = process.env.PATH || "";
557
+
558
+ // Codex detection
559
+ if (/\bCODEX_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.codex/")) {
560
+ return "codex";
561
+ }
562
+
563
+ // Claude detection
564
+ if (/\bCLAUDE_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.claude/")) {
565
+ return "claude";
566
+ }
567
+
568
+ // Gemini detection
569
+ if (/\bGOOGLE_GENAI_[A-Z0-9_]+\b/.test(envKeys) || /\bGEMINI_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.gemini/")) {
570
+ return "gemini";
571
+ }
572
+
573
+ // Aider detection
574
+ if (/\bAIDER_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/aider/")) {
575
+ return "aider";
576
+ }
577
+
578
+ return null;
579
+ }
580
+
581
+ // ── URL / domain helpers ────────────────────────────────────────
582
+
583
+ export function isLlmUrl(url = "") {
584
+ const value = String(url || "").trim();
585
+ if (!value) return false;
586
+ try {
587
+ const parsed = new URL(value);
588
+ const host = parsed.hostname.toLowerCase();
589
+ return DEFAULT_LLM_DOMAINS.some(domain => host === domain || host.endsWith(`.${domain}`));
590
+ } catch {
591
+ const lowered = value.toLowerCase();
592
+ return DEFAULT_LLM_DOMAINS.some(domain => lowered.includes(domain));
593
+ }
594
+ }
595
+
596
+ export function dedupeBrowserTabs(tabs = []) {
597
+ const out = [];
598
+ const seen = new Set();
599
+ for (const tab of tabs) {
600
+ const browser = String(tab?.browser || "").trim();
601
+ const title = String(tab?.title || "").trim();
602
+ const url = String(tab?.url || "").trim();
603
+ if (!url || (!isLlmUrl(url) && !isExtensionLlmTab(url, title))) continue;
604
+ // Dedup by title+url (ignore browser name) so that the same tab detected
605
+ // via both AppleScript and CDP is not duplicated. The first occurrence wins,
606
+ // so callers should add preferred sources first (e.g., CDP before AppleScript).
607
+ const key = `${title}\t${url}`;
608
+ if (seen.has(key)) continue;
609
+ seen.add(key);
610
+ out.push({
611
+ browser: browser || "Browser",
612
+ title: title || "(untitled)",
613
+ url,
614
+ });
615
+ }
616
+ return out;
617
+ }
618
+
619
+ export function parseInjectedBrowserTabsFromEnv() {
620
+ const raw = process.env.DELIBERATION_BROWSER_TABS_JSON;
621
+ if (!raw) {
622
+ return { tabs: [], note: null };
623
+ }
624
+
625
+ try {
626
+ const parsed = JSON.parse(raw);
627
+ if (!Array.isArray(parsed)) {
628
+ return { tabs: [], note: "DELIBERATION_BROWSER_TABS_JSON format error: must be a JSON array." };
629
+ }
630
+
631
+ const tabs = dedupeBrowserTabs(parsed.map(item => ({
632
+ browser: item?.browser || "External Bridge",
633
+ title: item?.title || "(untitled)",
634
+ url: item?.url || "",
635
+ })));
636
+ return {
637
+ tabs,
638
+ note: tabs.length > 0 ? `Environment variable tab injection: ${tabs.length} tabs` : "No valid LLM URLs found in DELIBERATION_BROWSER_TABS_JSON.",
639
+ };
640
+ } catch (error) {
641
+ const reason = error instanceof Error ? error.message : "unknown error";
642
+ return { tabs: [], note: `Failed to parse DELIBERATION_BROWSER_TABS_JSON: ${reason}` };
643
+ }
644
+ }
645
+
646
+ // ── CDP helpers ─────────────────────────────────────────────────
647
+
648
+ export function normalizeCdpEndpoint(raw) {
649
+ const value = String(raw || "").trim();
650
+ if (!value) return null;
651
+
652
+ const withProto = /^https?:\/\//i.test(value) ? value : `http://${value}`;
653
+ try {
654
+ const url = new URL(withProto);
655
+ if (!url.pathname || url.pathname === "/") {
656
+ url.pathname = "/json/list";
657
+ }
658
+ return url.toString();
659
+ } catch {
660
+ return null;
661
+ }
662
+ }
663
+
664
+ export function resolveCdpEndpoints() {
665
+ const fromEnv = (process.env.DELIBERATION_BROWSER_CDP_ENDPOINTS || "")
666
+ .split(/[,\s]+/)
667
+ .map(v => normalizeCdpEndpoint(v))
668
+ .filter(Boolean);
669
+ if (fromEnv.length > 0) {
670
+ return [...new Set(fromEnv)];
671
+ }
672
+
673
+ const ports = (process.env.DELIBERATION_BROWSER_CDP_PORTS || "9222,9223,9333")
674
+ .split(/[,\s]+/)
675
+ .map(v => Number.parseInt(v, 10))
676
+ .filter(v => Number.isInteger(v) && v > 0 && v < 65536);
677
+
678
+ const endpoints = [];
679
+ for (const port of ports) {
680
+ endpoints.push(`http://127.0.0.1:${port}/json/list`);
681
+ endpoints.push(`http://localhost:${port}/json/list`);
682
+ }
683
+ return [...new Set(endpoints)];
684
+ }
685
+
686
+ export async function fetchJson(url, timeoutMs = 900) {
687
+ if (typeof fetch !== "function") {
688
+ throw new Error("fetch API unavailable in current Node runtime");
689
+ }
690
+
691
+ const controller = new AbortController();
692
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
693
+ try {
694
+ const response = await fetch(url, {
695
+ method: "GET",
696
+ signal: controller.signal,
697
+ headers: { "accept": "application/json" },
698
+ });
699
+ if (!response.ok) {
700
+ throw new Error(`HTTP ${response.status}`);
701
+ }
702
+ return await response.json();
703
+ } finally {
704
+ clearTimeout(timer);
705
+ }
706
+ }
707
+
708
+ export function inferBrowserFromCdpEndpoint(endpoint) {
709
+ try {
710
+ const parsed = new URL(endpoint);
711
+ const port = Number.parseInt(parsed.port, 10);
712
+ if (port === 9222) return "Google Chrome (CDP)";
713
+ if (port === 9223) return "Microsoft Edge (CDP)";
714
+ if (port === 9333) return "Brave Browser (CDP)";
715
+ return `Browser (CDP:${parsed.host})`;
716
+ } catch {
717
+ return "Browser (CDP)";
718
+ }
719
+ }
720
+
721
+ export function summarizeFailures(items = [], max = 3) {
722
+ if (!Array.isArray(items) || items.length === 0) return null;
723
+ const shown = items.slice(0, max);
724
+ const suffix = items.length > max ? ` and ${items.length - max} more` : "";
725
+ return `${shown.join(", ")}${suffix}`;
726
+ }
727
+
728
+ // ── Browser LLM tab collection ──────────────────────────────────
729
+
730
+ export async function collectBrowserLlmTabsViaCdp() {
731
+ const endpoints = resolveCdpEndpoints();
732
+ const tabs = [];
733
+ const failures = [];
734
+
735
+ for (const endpoint of endpoints) {
736
+ try {
737
+ const payload = await fetchJson(endpoint);
738
+ if (!Array.isArray(payload)) {
739
+ throw new Error("unexpected payload");
740
+ }
741
+
742
+ const browser = inferBrowserFromCdpEndpoint(endpoint);
743
+ for (const item of payload) {
744
+ if (!item || String(item.type) !== "page") continue;
745
+ const url = String(item.url || "").trim();
746
+ const title = String(item.title || "").trim();
747
+ if (!isLlmUrl(url) && !isExtensionLlmTab(url, title)) continue;
748
+ tabs.push({
749
+ browser,
750
+ title: title || "(untitled)",
751
+ url,
752
+ });
753
+ }
754
+ } catch (error) {
755
+ const reason = error instanceof Error ? error.message : "unknown error";
756
+ failures.push(`${endpoint} (${reason})`);
757
+ }
758
+ }
759
+
760
+ const uniqTabs = dedupeBrowserTabs(tabs);
761
+ if (uniqTabs.length > 0) {
762
+ const failSummary = summarizeFailures(failures);
763
+ return {
764
+ tabs: uniqTabs,
765
+ note: failSummary ? `Some CDP endpoint access failed: ${failSummary}` : null,
766
+ };
767
+ }
768
+
769
+ const failSummary = summarizeFailures(failures);
770
+ return {
771
+ tabs: [],
772
+ note: `No LLM tabs found via CDP. Run browser with --remote-debugging-port=9222 or inject tab list via DELIBERATION_BROWSER_TABS_JSON.${failSummary ? ` (failed: ${failSummary})` : ""}`,
773
+ };
774
+ }
775
+
776
+ export async function ensureCdpAvailable() {
777
+ const endpoints = resolveCdpEndpoints();
778
+
779
+ // First attempt: try existing CDP endpoints
780
+ for (const endpoint of endpoints) {
781
+ try {
782
+ const payload = await fetchJson(endpoint, 1500);
783
+ if (Array.isArray(payload)) {
784
+ return { available: true, endpoint };
785
+ }
786
+ } catch { /* not reachable */ }
787
+ }
788
+
789
+ // Auto-launch Chrome with CDP on macOS, Linux, and Windows
790
+ {
791
+ let chromeBin, chromeUserDataDir;
792
+
793
+ if (process.platform === "darwin") {
794
+ chromeBin = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
795
+ chromeUserDataDir = path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome");
796
+ } else if (process.platform === "linux") {
797
+ const chromeCandidates = ["google-chrome", "google-chrome-stable", "google-chrome-beta", "chromium-browser", "chromium"];
798
+ chromeBin = chromeCandidates.find(c => commandExistsInPath(c)) || null;
799
+ if (!chromeBin) {
800
+ return {
801
+ available: false,
802
+ reason: "Chrome/Chromium not found. Install google-chrome or chromium and run with --remote-debugging-port=9222.",
803
+ };
804
+ }
805
+ const googleDir = path.join(os.homedir(), ".config", "google-chrome");
806
+ const chromiumDir = path.join(os.homedir(), ".config", "chromium");
807
+ chromeUserDataDir = fs.existsSync(googleDir) ? googleDir : fs.existsSync(chromiumDir) ? chromiumDir : null;
808
+ } else if (process.platform === "win32") {
809
+ const programFiles = process.env.PROGRAMFILES || "C:\\Program Files";
810
+ const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
811
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
812
+ const winCandidates = [
813
+ path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
814
+ path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
815
+ path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
816
+ path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
817
+ path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
818
+ ];
819
+ chromeBin = winCandidates.find(p => fs.existsSync(p)) || null;
820
+ if (!chromeBin) {
821
+ return {
822
+ available: false,
823
+ reason: "Chrome/Edge not found. Install Chrome or run with --remote-debugging-port=9222.",
824
+ };
825
+ }
826
+ const chromeDir = path.join(localAppData, "Google", "Chrome", "User Data");
827
+ const edgeDir = path.join(localAppData, "Microsoft", "Edge", "User Data");
828
+ chromeUserDataDir = fs.existsSync(chromeDir) ? chromeDir : fs.existsSync(edgeDir) ? edgeDir : null;
829
+ } else {
830
+ return {
831
+ available: false,
832
+ reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
833
+ };
834
+ }
835
+
836
+ // Chrome 145+ requires --user-data-dir for CDP to work.
837
+ // The default data dir is rejected, so we copy the profile to ~/.chrome-cdp.
838
+ // Profile can be set via env DELIBERATION_CHROME_PROFILE or config.chrome_profile (e.g., "Profile 1").
839
+ const cdpDataDir = path.join(os.homedir(), ".chrome-cdp");
840
+ const cdpConfig = _deps.loadDeliberationConfig();
841
+ const profileDir = process.env.DELIBERATION_CHROME_PROFILE || cdpConfig.chrome_profile || "Default";
842
+
843
+ try {
844
+ if (chromeUserDataDir) {
845
+ const srcProfile = path.join(chromeUserDataDir, profileDir);
846
+ const dstProfile = path.join(cdpDataDir, profileDir);
847
+ // Track which profile was copied; re-copy if profile changed
848
+ const profileMarker = path.join(cdpDataDir, ".cdp-profile");
849
+ const lastProfile = fs.existsSync(profileMarker) ? fs.readFileSync(profileMarker, "utf8").trim() : null;
850
+ const needsCopy = !fs.existsSync(dstProfile) || (lastProfile && lastProfile !== profileDir);
851
+ if (needsCopy && fs.existsSync(srcProfile)) {
852
+ // Clean old profile if switching
853
+ if (lastProfile && lastProfile !== profileDir) {
854
+ const oldDst = path.join(cdpDataDir, lastProfile);
855
+ if (fs.existsSync(oldDst)) fs.rmSync(oldDst, { recursive: true, force: true });
856
+ }
857
+ fs.mkdirSync(cdpDataDir, { recursive: true });
858
+ execFileSync("cp", ["-R", srcProfile, dstProfile], { timeout: 30000, stdio: "ignore" });
859
+ fs.writeFileSync(profileMarker, profileDir);
860
+ // Create minimal Local State with single profile to avoid profile picker
861
+ const localStateSrc = path.join(chromeUserDataDir, "Local State");
862
+ if (fs.existsSync(localStateSrc)) {
863
+ const state = JSON.parse(fs.readFileSync(localStateSrc, "utf8"));
864
+ state.profile.profiles_created = 1;
865
+ state.profile.last_used = profileDir;
866
+ if (state.profile.info_cache) {
867
+ const kept = {};
868
+ if (state.profile.info_cache[profileDir]) kept[profileDir] = state.profile.info_cache[profileDir];
869
+ state.profile.info_cache = kept;
870
+ }
871
+ fs.writeFileSync(path.join(cdpDataDir, "Local State"), JSON.stringify(state));
872
+ }
873
+ }
874
+ }
875
+ } catch { /* proceed with launch attempt anyway */ }
876
+
877
+ const launchArgs = [
878
+ "--remote-debugging-port=9222",
879
+ "--remote-allow-origins=http://127.0.0.1:9222",
880
+ `--user-data-dir=${cdpDataDir}`,
881
+ `--profile-directory=${profileDir}`,
882
+ "--no-first-run",
883
+ ];
884
+
885
+ try {
886
+ const child = spawn(chromeBin, launchArgs, { stdio: "ignore", detached: true });
887
+ child.unref();
888
+ } catch {
889
+ return {
890
+ available: false,
891
+ reason: `Failed to auto-launch Chrome. Manually run Chrome with --remote-debugging-port=9222 --user-data-dir=~/.chrome-cdp.`,
892
+ };
893
+ }
894
+
895
+ // Wait for Chrome to initialize CDP
896
+ await new Promise(resolve => setTimeout(resolve, 5000));
897
+
898
+ // Retry CDP connection after launch
899
+ for (const endpoint of endpoints) {
900
+ try {
901
+ const payload = await fetchJson(endpoint, 2000);
902
+ if (Array.isArray(payload)) {
903
+ return { available: true, endpoint, launched: true };
904
+ }
905
+ } catch { /* still not reachable */ }
906
+ }
907
+
908
+ return {
909
+ available: false,
910
+ reason: "Chrome launched but cannot connect to CDP. Fully close Chrome and try again. (Restart required if Chrome was started without CDP)",
911
+ };
912
+ }
913
+
914
+ // Unreachable (all platforms handled above), but keep as safety net
915
+ return {
916
+ available: false,
917
+ reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
918
+ };
919
+ }
920
+
921
+ export function collectBrowserLlmTabsViaAppleScript() {
922
+ if (process.platform !== "darwin") {
923
+ return { tabs: [], note: "AppleScript tab scanning is only supported on macOS." };
924
+ }
925
+
926
+ const escapedDomains = DEFAULT_LLM_DOMAINS.map(d => d.replace(/"/g, '\\"'));
927
+ const escapedApps = DEFAULT_BROWSER_APPS.map(a => a.replace(/"/g, '\\"'));
928
+ const domainList = `{${escapedDomains.map(d => `"${d}"`).join(", ")}}`;
929
+ const appList = `{${escapedApps.map(a => `"${a}"`).join(", ")}}`;
930
+
931
+ // NOTE: Use stdin pipe (`osascript -`) instead of multiple `-e` flags
932
+ // because osascript's `-e` mode silently breaks with nested try/on error blocks.
933
+ // Also wrap dynamic `tell application` with `using terms from` so that
934
+ // Chrome-specific properties like `tabs` resolve via the scripting dictionary.
935
+ // Use ASCII character 9 for tab delimiter because `using terms from`
936
+ // shadows the built-in `tab` constant, turning it into the literal string "tab".
937
+ const scriptText = `set llmDomains to ${domainList}
938
+ set browserApps to ${appList}
939
+ set outText to ""
940
+ set tabChar to ASCII character 9
941
+ tell application "System Events"
942
+ set runningApps to name of every application process
943
+ end tell
944
+ repeat with appName in browserApps
945
+ if runningApps contains (appName as string) then
946
+ try
947
+ if (appName as string) is "Safari" then
948
+ using terms from application "Safari"
949
+ tell application (appName as string)
950
+ repeat with w in windows
951
+ try
952
+ repeat with t in tabs of w
953
+ set u to URL of t as string
954
+ set matched to false
955
+ repeat with d in llmDomains
956
+ if u contains (d as string) then set matched to true
957
+ end repeat
958
+ if matched then set outText to outText & (appName as string) & tabChar & (name of t as string) & tabChar & u & linefeed
959
+ end repeat
960
+ end try
961
+ end repeat
962
+ end tell
963
+ end using terms from
964
+ else
965
+ using terms from application "Google Chrome"
966
+ tell application (appName as string)
967
+ repeat with w in windows
968
+ try
969
+ repeat with t in tabs of w
970
+ set u to URL of t as string
971
+ set matched to false
972
+ repeat with d in llmDomains
973
+ if u contains (d as string) then set matched to true
974
+ end repeat
975
+ if matched then set outText to outText & (appName as string) & tabChar & (title of t as string) & tabChar & u & linefeed
976
+ end repeat
977
+ end try
978
+ end repeat
979
+ end tell
980
+ end using terms from
981
+ end if
982
+ on error errMsg
983
+ set outText to outText & (appName as string) & tabChar & "ERROR" & tabChar & errMsg & linefeed
984
+ end try
985
+ end if
986
+ end repeat
987
+ return outText`;
988
+
989
+ try {
990
+ const raw = execFileSync("osascript", ["-"], {
991
+ input: scriptText,
992
+ encoding: "utf-8",
993
+ timeout: 8000,
994
+ maxBuffer: 2 * 1024 * 1024,
995
+ });
996
+ const rows = String(raw)
997
+ .split("\n")
998
+ .map(line => line.trim())
999
+ .filter(Boolean)
1000
+ .map(line => {
1001
+ const [browser = "", title = "", url = ""] = line.split("\t");
1002
+ return { browser, title, url };
1003
+ });
1004
+ const tabs = rows.filter(r => r.title !== "ERROR");
1005
+ const errors = rows.filter(r => r.title === "ERROR");
1006
+ return {
1007
+ tabs,
1008
+ note: errors.length > 0
1009
+ ? `Some browser access failed: ${errors.map(e => `${e.browser} (${e.url})`).join(", ")}`
1010
+ : null,
1011
+ };
1012
+ } catch (error) {
1013
+ const reason = error instanceof Error ? error.message : "unknown error";
1014
+ return {
1015
+ tabs: [],
1016
+ note: `Browser tab scan failed: ${reason}. Check macOS automation permissions (Terminal -> Browser control).`,
1017
+ };
1018
+ }
1019
+ }
1020
+
1021
+ export async function collectBrowserLlmTabs() {
1022
+ const mode = (process.env.DELIBERATION_BROWSER_SCAN_MODE || "auto").trim().toLowerCase();
1023
+ const tabs = [];
1024
+ const notes = [];
1025
+
1026
+ const injected = parseInjectedBrowserTabsFromEnv();
1027
+ tabs.push(...injected.tabs);
1028
+ if (injected.note) notes.push(injected.note);
1029
+
1030
+ if (mode === "off") {
1031
+ return {
1032
+ tabs: dedupeBrowserTabs(tabs),
1033
+ note: notes.length > 0 ? notes.join(" | ") : "Browser tab auto-scanning is disabled.",
1034
+ };
1035
+ }
1036
+
1037
+ // CDP first: CDP-detected tabs are preferred over AppleScript-detected ones
1038
+ // because they carry CDP metadata (tab ID, WebSocket URL) for browser_auto transport.
1039
+ // Since dedupeBrowserTabs keeps the first occurrence, CDP entries win the dedup.
1040
+ const shouldUseCdp = mode === "auto" || mode === "cdp";
1041
+ if (shouldUseCdp) {
1042
+ const cdp = await collectBrowserLlmTabsViaCdp();
1043
+ tabs.push(...cdp.tabs);
1044
+ if (cdp.note) notes.push(cdp.note);
1045
+ }
1046
+
1047
+ const shouldUseAppleScript = mode === "auto" || mode === "applescript";
1048
+ if (shouldUseAppleScript && process.platform === "darwin") {
1049
+ const mac = collectBrowserLlmTabsViaAppleScript();
1050
+ tabs.push(...mac.tabs);
1051
+ if (mac.note) notes.push(mac.note);
1052
+ } else if (mode === "applescript" && process.platform !== "darwin") {
1053
+ notes.push("AppleScript scanning is macOS only. Switch to CDP scanning.");
1054
+ }
1055
+
1056
+ const uniqTabs = dedupeBrowserTabs(tabs);
1057
+ return {
1058
+ tabs: uniqTabs,
1059
+ note: notes.length > 0 ? notes.join(" | ") : null,
1060
+ };
1061
+ }
1062
+
1063
+ // ── LLM provider inference ──────────────────────────────────────
1064
+
1065
+ export function inferLlmProvider(url = "", title = "") {
1066
+ const value = String(url).toLowerCase();
1067
+ // Extension side panel: infer from title via registry
1068
+ if (value.startsWith("chrome-extension://") && title) {
1069
+ const registry = loadExtensionProviderRegistry();
1070
+ const lowerTitle = String(title).toLowerCase();
1071
+ for (const entry of registry.providers) {
1072
+ if (entry.titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
1073
+ return entry.provider;
1074
+ }
1075
+ }
1076
+ return "extension-llm";
1077
+ }
1078
+ if (value.includes("claude.ai") || value.includes("anthropic.com")) return "claude";
1079
+ if (value.includes("chatgpt.com") || value.includes("openai.com")) return "chatgpt";
1080
+ if (value.includes("gemini.google.com") || value.includes("notebooklm.google.com")) return "gemini";
1081
+ if (value.includes("copilot.microsoft.com")) return "copilot";
1082
+ if (value.includes("perplexity.ai")) return "perplexity";
1083
+ if (value.includes("poe.com")) return "poe";
1084
+ if (value.includes("mistral.ai")) return "mistral";
1085
+ if (value.includes("huggingface.co/chat")) return "huggingchat";
1086
+ if (value.includes("deepseek.com")) return "deepseek";
1087
+ if (value.includes("qwen.ai")) return "qwen";
1088
+ if (value.includes("grok.com")) return "grok";
1089
+ return "web-llm";
1090
+ }
1091
+
1092
+ // ── Speaker candidate collection ────────────────────────────────
1093
+
1094
+ export async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
1095
+ const candidates = [];
1096
+ const seen = new Set();
1097
+ let browserNote = null;
1098
+
1099
+ const add = (candidate) => {
1100
+ const speaker = normalizeSpeaker(candidate?.speaker);
1101
+ if (!speaker || seen.has(speaker)) return;
1102
+ seen.add(speaker);
1103
+ candidates.push({ ...candidate, speaker });
1104
+ };
1105
+
1106
+ if (include_cli) {
1107
+ for (const cli of discoverLocalCliSpeakers()) {
1108
+ const live = checkCliLiveness(cli);
1109
+ add({
1110
+ speaker: cli,
1111
+ type: "cli",
1112
+ label: cli,
1113
+ command: cli,
1114
+ live,
1115
+ });
1116
+ }
1117
+
1118
+ const { sessions: teleptySessions, note: teleptyNote } = await collectTeleptySessions();
1119
+ const locators = collectTeleptyProcessLocators(teleptySessions);
1120
+ for (const session of teleptySessions) {
1121
+ const locator = locators.get(session.id) || {};
1122
+ add({
1123
+ speaker: session.id,
1124
+ type: "telepty",
1125
+ label: session.id,
1126
+ telepty_session_id: session.id,
1127
+ telepty_host: session.host || TELEPTY_DEFAULT_HOST,
1128
+ command: session.command || "wrapped",
1129
+ cwd: session.cwd || null,
1130
+ active_clients: session.active_clients ?? null,
1131
+ runtime_pid: locator.pid ?? null,
1132
+ runtime_tty: locator.tty ?? null,
1133
+ });
1134
+ }
1135
+ if (teleptyNote) {
1136
+ browserNote = browserNote ? `${browserNote} | ${teleptyNote}` : teleptyNote;
1137
+ }
1138
+ }
1139
+
1140
+ if (include_browser) {
1141
+ // Ensure CDP is available before probing browser tabs
1142
+ const cdpStatus = await ensureCdpAvailable();
1143
+ if (cdpStatus.launched) {
1144
+ browserNote = "Chrome CDP auto-launched (--remote-debugging-port=9222)";
1145
+ }
1146
+
1147
+ const { tabs, note } = await collectBrowserLlmTabs();
1148
+ browserNote = browserNote ? `${browserNote} | ${note || ""}`.replace(/ \| $/, "") : (note || null);
1149
+ const providerCounts = new Map();
1150
+ for (const tab of tabs) {
1151
+ const provider = inferLlmProvider(tab.url, tab.title);
1152
+ const count = (providerCounts.get(provider) || 0) + 1;
1153
+ providerCounts.set(provider, count);
1154
+ add({
1155
+ speaker: `web-${provider}-${count}`,
1156
+ type: "browser",
1157
+ provider,
1158
+ browser: tab.browser || "",
1159
+ title: tab.title || "",
1160
+ url: tab.url || "",
1161
+ });
1162
+ }
1163
+
1164
+ // CDP auto-detection: probe endpoints for matching tabs
1165
+ const cdpEndpoints = resolveCdpEndpoints();
1166
+ const cdpTabsMap = new Map(); // dedupe by tab ID (multiple endpoints may return same tabs)
1167
+ for (const endpoint of cdpEndpoints) {
1168
+ try {
1169
+ const tabs = await fetchJson(endpoint, 2000);
1170
+ if (Array.isArray(tabs)) {
1171
+ for (const t of tabs) {
1172
+ if (t.type === "page" && t.url && t.id && !cdpTabsMap.has(t.id)) {
1173
+ cdpTabsMap.set(t.id, t);
1174
+ }
1175
+ }
1176
+ }
1177
+ } catch { /* endpoint not reachable */ }
1178
+ }
1179
+ const cdpTabs = [...cdpTabsMap.values()];
1180
+
1181
+ // Match CDP tabs with discovered browser candidates
1182
+ for (const candidate of candidates) {
1183
+ if (candidate.type !== "browser") continue;
1184
+ // For extension candidates, match by title instead of hostname
1185
+ const candidateUrl = String(candidate.url || "");
1186
+ if (candidateUrl.startsWith("chrome-extension://")) {
1187
+ const candidateTitle = String(candidate.title || "").toLowerCase();
1188
+ if (candidateTitle) {
1189
+ const matches = cdpTabs.filter(t =>
1190
+ String(t.url || "").startsWith("chrome-extension://") &&
1191
+ String(t.title || "").toLowerCase().includes(candidateTitle)
1192
+ );
1193
+ if (matches.length >= 1) {
1194
+ candidate.cdp_available = true;
1195
+ candidate.cdp_tab_id = matches[0].id;
1196
+ candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
1197
+ }
1198
+ }
1199
+ continue;
1200
+ }
1201
+ let candidateHost = "";
1202
+ try {
1203
+ candidateHost = new URL(candidate.url).hostname.toLowerCase();
1204
+ } catch { continue; }
1205
+ if (!candidateHost) continue;
1206
+ const matches = cdpTabs.filter(t => {
1207
+ try {
1208
+ return new URL(t.url).hostname.toLowerCase() === candidateHost;
1209
+ } catch { return false; }
1210
+ });
1211
+ if (matches.length >= 1) {
1212
+ candidate.cdp_available = true;
1213
+ candidate.cdp_tab_id = matches[0].id;
1214
+ candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
1215
+ }
1216
+ }
1217
+
1218
+ // Auto-register well-known web LLMs that weren't already detected via browser scanning.
1219
+ // This ensures web speakers are ALWAYS available regardless of browser detection success.
1220
+ // If a browser tab for the same provider was already detected, skip auto-registration
1221
+ // to avoid duplicates (e.g., detected "web-chatgpt-1" vs auto-registered "web-chatgpt").
1222
+ const detectedProviders = new Set(
1223
+ candidates.filter(c => c.type === "browser" && !c.auto_registered).map(c => c.provider)
1224
+ );
1225
+ // CDP is reachable if we got any tabs from the endpoints (attach() handles auto-tab-creation)
1226
+ const cdpReachable = cdpTabs.length > 0 || cdpStatus.available;
1227
+ for (const ws of DEFAULT_WEB_SPEAKERS) {
1228
+ if (detectedProviders.has(ws.provider)) continue;
1229
+ add({
1230
+ speaker: ws.speaker,
1231
+ type: "browser",
1232
+ provider: ws.provider,
1233
+ browser: "auto-registered",
1234
+ title: ws.name,
1235
+ url: ws.url,
1236
+ auto_registered: true,
1237
+ cdp_available: cdpReachable,
1238
+ });
1239
+ }
1240
+
1241
+ // Second pass: match auto-registered speakers to individual CDP tabs
1242
+ // (they were added after the first matching pass and only got the global cdpReachable flag)
1243
+ if (cdpTabs.length > 0) {
1244
+ for (const candidate of candidates) {
1245
+ if (!candidate.auto_registered || candidate.cdp_tab_id) continue;
1246
+ let candidateHost = "";
1247
+ try {
1248
+ candidateHost = new URL(candidate.url).hostname.toLowerCase();
1249
+ } catch { continue; }
1250
+ if (!candidateHost) continue;
1251
+ const matches = cdpTabs.filter(t => {
1252
+ try {
1253
+ const tabHost = new URL(t.url).hostname.toLowerCase();
1254
+ // Exact match or subdomain match (e.g., chat.deepseek.com matches deepseek.com)
1255
+ return tabHost === candidateHost || tabHost.endsWith("." + candidateHost);
1256
+ } catch { return false; }
1257
+ });
1258
+ if (matches.length >= 1) {
1259
+ candidate.cdp_available = true;
1260
+ candidate.cdp_tab_id = matches[0].id;
1261
+ candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ // Third pass: upgrade browser-detected candidates that missed the first hostname match.
1267
+ // When CDP is reachable, AppleScript-detected speakers should also get browser_auto
1268
+ // transport. The OrchestratedBrowserPort will create/navigate tabs on demand if needed.
1269
+ if (cdpReachable) {
1270
+ for (const candidate of candidates) {
1271
+ if (candidate.type !== "browser" || candidate.auto_registered) continue;
1272
+ if (candidate.cdp_available) continue; // already matched
1273
+ candidate.cdp_available = true;
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ return { candidates, browserNote };
1279
+ }
1280
+
1281
+ // ── Speaker candidate report ────────────────────────────────────
1282
+
1283
+ export function formatSpeakerCandidatesReport({ candidates, browserNote }) {
1284
+ const cli = candidates.filter(c => c.type === "cli");
1285
+ const telepty = candidates.filter(c => c.type === "telepty");
1286
+ const detected = candidates.filter(c => c.type === "browser" && !c.auto_registered);
1287
+ const autoReg = candidates.filter(c => c.type === "browser" && c.auto_registered);
1288
+
1289
+ let out = "## Selectable Speakers\n\n";
1290
+ out += "### CLI\n";
1291
+ if (cli.length === 0) {
1292
+ out += "- (No local CLI detected)\n\n";
1293
+ } else {
1294
+ out += `${cli.map(c => {
1295
+ const status = c.live === false ? " ❌ not executable" : c.live === true ? " ✅ executable" : "";
1296
+ return `- \`${c.speaker}\` (command: ${c.command})${status}`;
1297
+ }).join("\n")}\n\n`;
1298
+ }
1299
+
1300
+ out += "### Telepty Sessions\n";
1301
+ if (telepty.length === 0) {
1302
+ out += "- (No active telepty sessions)\n\n";
1303
+ } else {
1304
+ out += `${telepty.map(c => {
1305
+ const parts = [
1306
+ `command: ${c.command || "wrapped"}`,
1307
+ c.telepty_host ? `host: ${formatTeleptyHostLabel(c.telepty_host)}` : null,
1308
+ Number.isFinite(c.runtime_pid) ? `pid: ${c.runtime_pid}` : null,
1309
+ c.runtime_tty ? `tty: ${c.runtime_tty}` : null,
1310
+ ].filter(Boolean).join(", ");
1311
+ const cwdLine = c.cwd ? `\n cwd: ${c.cwd}` : "";
1312
+ return `- \`${c.speaker}\` (${parts})${cwdLine}`;
1313
+ }).join("\n")}\n\n`;
1314
+ }
1315
+
1316
+ out += "### Browser LLM (detected)\n";
1317
+ if (detected.length === 0) {
1318
+ out += "- (No LLM tabs detected in browser)\n";
1319
+ } else {
1320
+ out += `${detected.map(c => {
1321
+ const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
1322
+ const extTag = String(c.url || "").startsWith("chrome-extension://") ? " [Extension]" : "";
1323
+ return `- \`${c.speaker}\` [${icon}]${extTag} [${c.browser}] ${c.title}\n ${c.url}`;
1324
+ }).join("\n")}\n`;
1325
+ }
1326
+
1327
+ out += "\n### Web LLM (auto-registered)\n";
1328
+ out += `${autoReg.map(c => {
1329
+ const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
1330
+ return `- \`${c.speaker}\` [${icon}] — ${c.title} (${c.url})`;
1331
+ }).join("\n")}\n`;
1332
+
1333
+ if (browserNote) {
1334
+ out += `\n\nℹ️ ${browserNote}`;
1335
+ }
1336
+ return out;
1337
+ }
1338
+
1339
+ // ── Participant profile mapping ─────────────────────────────────
1340
+
1341
+ export function mapParticipantProfiles(speakers, candidates, typeOverrides) {
1342
+ const bySpeaker = new Map();
1343
+ for (const c of candidates || []) {
1344
+ const key = normalizeSpeaker(c.speaker);
1345
+ if (key) bySpeaker.set(key, c);
1346
+ }
1347
+
1348
+ const overrides = typeOverrides || {};
1349
+
1350
+ const profiles = [];
1351
+ for (const raw of speakers || []) {
1352
+ const speaker = normalizeSpeaker(raw);
1353
+ if (!speaker) continue;
1354
+
1355
+ // Check for explicit type override
1356
+ const overrideType = overrides[speaker] || overrides[raw];
1357
+ if (overrideType) {
1358
+ const candidate = bySpeaker.get(speaker);
1359
+ profiles.push({
1360
+ speaker,
1361
+ type: overrideType,
1362
+ ...(overrideType === "browser_auto" || overrideType === "browser" ? {
1363
+ provider: candidate?.provider || null,
1364
+ browser: candidate?.browser || null,
1365
+ title: candidate?.title || null,
1366
+ url: candidate?.url || null,
1367
+ } : {}),
1368
+ });
1369
+ continue;
1370
+ }
1371
+
1372
+ const candidate = bySpeaker.get(speaker);
1373
+ if (!candidate) {
1374
+ // Force CLI type if the speaker is available as a CLI command in PATH
1375
+ if (commandExistsInPath(speaker)) {
1376
+ profiles.push({
1377
+ speaker,
1378
+ type: "cli",
1379
+ command: speaker,
1380
+ });
1381
+ } else {
1382
+ profiles.push({
1383
+ speaker,
1384
+ type: "manual",
1385
+ });
1386
+ }
1387
+ continue;
1388
+ }
1389
+
1390
+ if (candidate.type === "cli") {
1391
+ profiles.push({
1392
+ speaker,
1393
+ type: "cli",
1394
+ command: candidate.command || speaker,
1395
+ });
1396
+ continue;
1397
+ }
1398
+
1399
+ if (candidate.type === "telepty") {
1400
+ profiles.push({
1401
+ speaker,
1402
+ type: "telepty",
1403
+ command: candidate.command || null,
1404
+ telepty_session_id: candidate.telepty_session_id || speaker,
1405
+ telepty_host: candidate.telepty_host || null,
1406
+ runtime_pid: Number.isFinite(candidate.runtime_pid) ? candidate.runtime_pid : null,
1407
+ });
1408
+ continue;
1409
+ }
1410
+
1411
+ const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
1412
+ profiles.push({
1413
+ speaker,
1414
+ type: effectiveType,
1415
+ provider: candidate.provider || null,
1416
+ browser: candidate.browser || null,
1417
+ title: candidate.title || null,
1418
+ url: candidate.url || null,
1419
+ });
1420
+ }
1421
+ return profiles;
1422
+ }
1423
+
1424
+ // ── Speaker ordering ────────────────────────────────────────────
1425
+
1426
+ export function buildSpeakerOrder(speakers, fallbackSpeaker = DEFAULT_SPEAKERS[0], fallbackPlacement = "front") {
1427
+ const ordered = [];
1428
+ const seen = new Set();
1429
+
1430
+ const add = (candidate) => {
1431
+ const speaker = normalizeSpeaker(candidate);
1432
+ if (!speaker || seen.has(speaker)) return;
1433
+ seen.add(speaker);
1434
+ ordered.push(speaker);
1435
+ };
1436
+
1437
+ if (fallbackPlacement === "front") {
1438
+ add(fallbackSpeaker);
1439
+ }
1440
+
1441
+ if (Array.isArray(speakers)) {
1442
+ for (const speaker of speakers) {
1443
+ add(speaker);
1444
+ }
1445
+ }
1446
+
1447
+ if (fallbackPlacement !== "front") {
1448
+ add(fallbackSpeaker);
1449
+ }
1450
+
1451
+ if (ordered.length === 0) {
1452
+ for (const speaker of DEFAULT_SPEAKERS) {
1453
+ add(speaker);
1454
+ }
1455
+ }
1456
+
1457
+ return ordered;
1458
+ }
1459
+
1460
+ export function normalizeSessionActors(state) {
1461
+ if (!state || typeof state !== "object") return state;
1462
+
1463
+ const fallbackSpeaker = normalizeSpeaker(state.current_speaker)
1464
+ || normalizeSpeaker(state.log?.[0]?.speaker)
1465
+ || DEFAULT_SPEAKERS[0];
1466
+ const speakers = buildSpeakerOrder(state.speakers, fallbackSpeaker, "end");
1467
+ state.speakers = speakers;
1468
+
1469
+ const normalizedCurrent = normalizeSpeaker(state.current_speaker);
1470
+ if (state.status === "active") {
1471
+ state.current_speaker = (normalizedCurrent && speakers.includes(normalizedCurrent))
1472
+ ? normalizedCurrent
1473
+ : speakers[0];
1474
+ } else if (normalizedCurrent) {
1475
+ state.current_speaker = normalizedCurrent;
1476
+ }
1477
+
1478
+ return state;
1479
+ }
1480
+
1481
+ // ── Transport routing ───────────────────────────────────────────
1482
+
1483
+ export function resolveTransportForSpeaker(state, speaker) {
1484
+ const normalizedSpeaker = normalizeSpeaker(speaker);
1485
+ if (!normalizedSpeaker || !state?.participant_profiles) {
1486
+ return { transport: "manual", reason: "no_profile" };
1487
+ }
1488
+ const profile = state.participant_profiles.find(
1489
+ p => normalizeSpeaker(p.speaker) === normalizedSpeaker
1490
+ );
1491
+ if (!profile) {
1492
+ return { transport: "manual", reason: "speaker_not_in_profiles" };
1493
+ }
1494
+ const transport = TRANSPORT_TYPES[profile.type] || "manual";
1495
+ return { transport, profile, reason: null };
1496
+ }
1497
+
1498
+ export function formatTransportGuidance(transport, state, speaker) {
1499
+ const sid = state.id;
1500
+ const profile = (state.participant_profiles || []).find(
1501
+ p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
1502
+ ) || null;
1503
+ switch (transport) {
1504
+ case "cli_respond": {
1505
+ const hint = CLI_INVOCATION_HINTS[speaker] || null;
1506
+ let invocationGuide = "";
1507
+ let modelGuide = "";
1508
+ if (hint) {
1509
+ const prefix = hint.envPrefix || '';
1510
+ invocationGuide = `\n\n**CLI invocation:** \`${hint.example}\`\n(flags: \`${prefix}${hint.cmd} ${hint.flags}\`)`;
1511
+ if (hint.modelFlag && hint.provider) {
1512
+ const cliModel = getModelSelectionForTurn(state, speaker, hint.provider);
1513
+ if (cliModel.model !== 'default') {
1514
+ modelGuide = `\n**Recommended model:** ${cliModel.model} (${cliModel.reason})\n**Model flag:** \`${hint.modelFlag} ${cliModel.model}\``;
1515
+ }
1516
+ }
1517
+ }
1518
+ return `CLI speaker. Respond directly via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.${invocationGuide}${modelGuide}\n\n⛔ **No API calls**: Do not call LLM APIs directly via REST API, HTTP requests, urllib, requests, etc. Only use the CLI tools above.`;
1519
+ }
1520
+ case "clipboard":
1521
+ return `Browser LLM speaker. Copy the prompt below and paste it into the browser LLM using **Cmd+V (ㅍ)**, then submit the response via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", use_clipboard: true)\` after copying the LLM's response with **Cmd+C (ㅊ)**.\n\n` +
1522
+ `📋 **Prompt has been copied to your clipboard.** (If not, copy the [turn_prompt] section below manually).\n` +
1523
+ `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
1524
+ `⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
1525
+ case "browser_auto":
1526
+ return `Auto browser speaker. Proceed automatically with \`deliberation_browser_auto_turn(session_id: "${sid}")\`. Inputs directly to browser LLM via CDP and reads responses.\n\n⛔ **No API calls**: Proceeds only via CDP automation. No REST API or HTTP requests.`;
1527
+ case "telepty_bus":
1528
+ return `Telepty session speaker. This turn will be published on the telepty bus as a structured \`turn_request\` envelope for the target session to consume.\n\n` +
1529
+ `📡 **Bus delivery**: deliberation publishes a typed envelope instead of relying on raw PTY inject.\n` +
1530
+ `⏱️ **Timeouts**: transport ack waits ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s, semantic self-submit waits ${TELEPTY_SEMANTIC_TIMEOUT_MS / 1000}s.\n` +
1531
+ `⛔ **No proxy response**: the remote telepty session must answer for itself via \`deliberation_respond(...)\`.`;
1532
+ case "manual":
1533
+ default:
1534
+ if (profile?.type === "telepty" && profile.telepty_session_id) {
1535
+ const hostSuffix = profile.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
1536
+ ? `@${profile.telepty_host}`
1537
+ : "";
1538
+ const pidNote = Number.isFinite(profile.runtime_pid) ? ` (pid ${profile.runtime_pid})` : "";
1539
+ return `Telepty-managed session speaker${pidNote}. Send the [turn_prompt] below to \`telepty inject ${profile.telepty_session_id}${hostSuffix} "<prompt>"\`, then have that remote session self-submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
1540
+ `📋 **Recommended path**: inject the prompt into the telepty session and let the remote session answer for itself.\n` +
1541
+ `⛔ **No proxy response**: Do not answer on behalf of this speaker from the orchestrator.`;
1542
+ }
1543
+ return `Manual speaker. Get a response from the LLM's **web UI or CLI tool** and submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
1544
+ `📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
1545
+ `🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
1546
+ `⛔ **Absolutely no API calls**: Calling LLM APIs directly via REST API, HTTP requests (urllib, requests, fetch, etc.) is forbidden. Only use web browser UI or CLI tools. Direct API key calls will result in deliberation participation being rejected.`;
1547
+ }
1548
+ }
1549
+
1550
+ // ── Degradation detection ───────────────────────────────────────
1551
+
1552
+ export async function detectDegradationLevels() {
1553
+ const levels = {};
1554
+ for (const [feature, tiers] of Object.entries(DEGRADATION_TIERS)) {
1555
+ for (const tierKey of ["tier1", "tier2", "tier3"]) {
1556
+ const tier = tiers[tierKey];
1557
+ const available = await Promise.resolve(tier.check());
1558
+ if (available) {
1559
+ levels[feature] = { tier: tierKey, name: tier.name, description: tier.description };
1560
+ break;
1561
+ }
1562
+ }
1563
+ }
1564
+ return levels;
1565
+ }
1566
+
1567
+ export function formatDegradationReport(levels) {
1568
+ const lines = [];
1569
+ for (const [feature, info] of Object.entries(levels)) {
1570
+ const tierNum = parseInt(info.tier.replace("tier", ""));
1571
+ const indicator = tierNum === 1 ? "🟢" : tierNum === 2 ? "🟡" : "🔴";
1572
+ lines.push(` ${indicator} **${feature}**: ${info.name} — ${info.description}`);
1573
+ }
1574
+ return lines.join("\n");
1575
+ }