@dmsdc-ai/aigentry-deliberation 0.0.1
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/LICENSE +21 -0
- package/README.md +94 -0
- package/browser-control-port.js +563 -0
- package/degradation-state-machine.js +206 -0
- package/index.js +3156 -0
- package/install.js +202 -0
- package/observer.js +483 -0
- package/package.json +65 -0
- package/public/index.html +1478 -0
- package/selectors/chatgpt-extension.json +21 -0
- package/selectors/chatgpt.json +20 -0
- package/selectors/claude-extension.json +21 -0
- package/selectors/claude.json +19 -0
- package/selectors/extension-providers.json +24 -0
- package/selectors/gemini-extension.json +21 -0
- package/selectors/gemini.json +19 -0
- package/selectors/role-presets.json +28 -0
- package/selectors/roles/critic.md +12 -0
- package/selectors/roles/free.md +1 -0
- package/selectors/roles/implementer.md +12 -0
- package/selectors/roles/mediator.md +12 -0
- package/selectors/roles/researcher.md +12 -0
- package/session-monitor-win.js +94 -0
- package/session-monitor.sh +316 -0
- package/skills/deliberation/SKILL.md +164 -0
- package/skills/deliberation-executor/SKILL.md +86 -0
package/index.js
ADDED
|
@@ -0,0 +1,3156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP Deliberation Server (Global) — v2.5 Multi-Session + Transport Routing + Cross-Platform + BrowserControlPort
|
|
4
|
+
*
|
|
5
|
+
* 모든 프로젝트에서 사용 가능한 AI 간 deliberation 서버.
|
|
6
|
+
* 동시에 여러 deliberation을 병렬 진행할 수 있다.
|
|
7
|
+
*
|
|
8
|
+
* 상태 저장: $INSTALL_DIR/state/{project-slug}/sessions/{id}.json
|
|
9
|
+
* macOS/Linux: ~/.local/lib/mcp-deliberation/
|
|
10
|
+
* Windows: %LOCALAPPDATA%/mcp-deliberation/
|
|
11
|
+
*
|
|
12
|
+
* Tools:
|
|
13
|
+
* deliberation_start 새 토론 시작 → session_id 반환
|
|
14
|
+
* deliberation_status 세션 상태 조회 (session_id 선택적)
|
|
15
|
+
* deliberation_list_active 진행 중인 모든 세션 목록
|
|
16
|
+
* deliberation_context 프로젝트 컨텍스트 로드
|
|
17
|
+
* deliberation_respond 응답 제출 (session_id 필수)
|
|
18
|
+
* deliberation_history 토론 기록 조회 (session_id 선택적)
|
|
19
|
+
* deliberation_synthesize 합성 보고서 생성 (session_id 선택적)
|
|
20
|
+
* deliberation_list 과거 아카이브 목록
|
|
21
|
+
* deliberation_reset 세션 초기화 (session_id 선택적, 없으면 전체)
|
|
22
|
+
* deliberation_speaker_candidates 선택 가능한 스피커 후보(로컬 CLI + 브라우저 LLM 탭) 조회
|
|
23
|
+
* deliberation_browser_llm_tabs 브라우저 LLM 탭 목록 조회
|
|
24
|
+
* deliberation_browser_auto_turn 브라우저 LLM에 자동으로 턴을 전송하고 응답을 수집 (CDP 기반)
|
|
25
|
+
* deliberation_request_review 코드 리뷰 요청 (CLI 리뷰어 자동 호출, sync/async 모드)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
29
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { execFileSync, spawn } from "child_process";
|
|
32
|
+
import fs from "fs";
|
|
33
|
+
import path from "path";
|
|
34
|
+
import os from "os";
|
|
35
|
+
import { OrchestratedBrowserPort } from "./browser-control-port.js";
|
|
36
|
+
|
|
37
|
+
// ── Paths ──────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const HOME = os.homedir();
|
|
40
|
+
const IS_WIN = process.platform === "win32";
|
|
41
|
+
const INSTALL_DIR = IS_WIN
|
|
42
|
+
? path.join(process.env.LOCALAPPDATA || path.join(HOME, "AppData", "Local"), "mcp-deliberation")
|
|
43
|
+
: path.join(HOME, ".local", "lib", "mcp-deliberation");
|
|
44
|
+
const GLOBAL_STATE_DIR = path.join(INSTALL_DIR, "state");
|
|
45
|
+
const GLOBAL_RUNTIME_LOG = path.join(INSTALL_DIR, "runtime.log");
|
|
46
|
+
const OBSIDIAN_VAULT = path.join(HOME, "Documents", "Obsidian Vault");
|
|
47
|
+
const OBSIDIAN_PROJECTS = path.join(OBSIDIAN_VAULT, "10-Projects");
|
|
48
|
+
const DEFAULT_SPEAKERS = ["agent-a", "agent-b"];
|
|
49
|
+
const DEFAULT_CLI_CANDIDATES = [
|
|
50
|
+
"claude",
|
|
51
|
+
"codex",
|
|
52
|
+
"gemini",
|
|
53
|
+
"qwen",
|
|
54
|
+
"chatgpt",
|
|
55
|
+
"aider",
|
|
56
|
+
"llm",
|
|
57
|
+
"opencode",
|
|
58
|
+
"cursor-agent",
|
|
59
|
+
"cursor",
|
|
60
|
+
"continue",
|
|
61
|
+
];
|
|
62
|
+
const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
|
|
63
|
+
|
|
64
|
+
function loadDeliberationConfig() {
|
|
65
|
+
const configPath = path.join(INSTALL_DIR, "config.json");
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function saveDeliberationConfig(config) {
|
|
74
|
+
const configPath = path.join(INSTALL_DIR, "config.json");
|
|
75
|
+
config.updated = new Date().toISOString();
|
|
76
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
|
|
80
|
+
const DEFAULT_LLM_DOMAINS = [
|
|
81
|
+
"chatgpt.com",
|
|
82
|
+
"openai.com",
|
|
83
|
+
"claude.ai",
|
|
84
|
+
"anthropic.com",
|
|
85
|
+
"gemini.google.com",
|
|
86
|
+
"copilot.microsoft.com",
|
|
87
|
+
"poe.com",
|
|
88
|
+
"perplexity.ai",
|
|
89
|
+
"mistral.ai",
|
|
90
|
+
"huggingface.co/chat",
|
|
91
|
+
"deepseek.com",
|
|
92
|
+
"qwen.ai",
|
|
93
|
+
"notebooklm.google.com",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
let _extensionProviderRegistry = null;
|
|
97
|
+
const __dirnameEsm = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
|
|
98
|
+
function loadExtensionProviderRegistry() {
|
|
99
|
+
if (_extensionProviderRegistry) return _extensionProviderRegistry;
|
|
100
|
+
try {
|
|
101
|
+
const registryPath = path.join(__dirnameEsm, "selectors", "extension-providers.json");
|
|
102
|
+
_extensionProviderRegistry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
103
|
+
return _extensionProviderRegistry;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error("Failed to load extension-providers.json:", err.message);
|
|
106
|
+
_extensionProviderRegistry = { providers: [] };
|
|
107
|
+
return _extensionProviderRegistry;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isExtensionLlmTab(url = "", title = "") {
|
|
112
|
+
if (!String(url).startsWith("chrome-extension://")) return false;
|
|
113
|
+
const registry = loadExtensionProviderRegistry();
|
|
114
|
+
const lowerTitle = String(title || "").toLowerCase();
|
|
115
|
+
if (!lowerTitle) return false;
|
|
116
|
+
return registry.providers.some(p =>
|
|
117
|
+
p.titlePatterns.some(pattern => lowerTitle.includes(pattern.toLowerCase()))
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Sprint 1: Smart Speaker Ordering + Persona Roles ────────────
|
|
122
|
+
|
|
123
|
+
function selectNextSpeaker(session) {
|
|
124
|
+
const { speakers, current_speaker, log, ordering_strategy } = session;
|
|
125
|
+
switch (ordering_strategy || "cyclic") {
|
|
126
|
+
case "random":
|
|
127
|
+
return speakers[Math.floor(Math.random() * speakers.length)];
|
|
128
|
+
case "weighted-random": {
|
|
129
|
+
const window = log.slice(-(speakers.length * 2));
|
|
130
|
+
const counts = new Map(speakers.map(s => [s, 0]));
|
|
131
|
+
for (const entry of window) {
|
|
132
|
+
if (counts.has(entry.speaker)) counts.set(entry.speaker, counts.get(entry.speaker) + 1);
|
|
133
|
+
}
|
|
134
|
+
const maxCount = Math.max(...counts.values(), 1);
|
|
135
|
+
const weights = speakers.map(s => maxCount + 1 - counts.get(s));
|
|
136
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
137
|
+
let r = Math.random() * total;
|
|
138
|
+
for (let i = 0; i < speakers.length; i++) {
|
|
139
|
+
r -= weights[i];
|
|
140
|
+
if (r <= 0) return speakers[i];
|
|
141
|
+
}
|
|
142
|
+
return speakers[speakers.length - 1];
|
|
143
|
+
}
|
|
144
|
+
case "cyclic":
|
|
145
|
+
default: {
|
|
146
|
+
const idx = speakers.indexOf(current_speaker);
|
|
147
|
+
return speakers[(idx + 1) % speakers.length];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loadRolePrompt(role) {
|
|
153
|
+
if (!role || role === "free") return "";
|
|
154
|
+
try {
|
|
155
|
+
const promptPath = path.join(__dirnameEsm, "selectors", "roles", `${role}.md`);
|
|
156
|
+
return fs.readFileSync(promptPath, "utf-8").trim();
|
|
157
|
+
} catch {
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ROLE_KEYWORDS = {
|
|
163
|
+
critic: /문제|위험|실패|약점|리스크|반대|비판|결함|취약/,
|
|
164
|
+
implementer: /구현|코드|방법|설계|빌드|개발|함수|모듈|파일/,
|
|
165
|
+
mediator: /합의|정리|결론|종합|요약|중재|절충|균형/,
|
|
166
|
+
researcher: /사례|데이터|연구|벤치마크|비교|논문|참고/,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function inferSuggestedRole(text) {
|
|
170
|
+
const scores = {};
|
|
171
|
+
for (const [role, pattern] of Object.entries(ROLE_KEYWORDS)) {
|
|
172
|
+
const matches = (text.match(new RegExp(pattern, "g")) || []).length;
|
|
173
|
+
if (matches > 0) scores[role] = matches;
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(scores).length === 0) return "free";
|
|
176
|
+
return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseVotes(text) {
|
|
180
|
+
const votes = [];
|
|
181
|
+
for (const line of text.split("\n")) {
|
|
182
|
+
const agree = line.match(/\[AGREE\]/i);
|
|
183
|
+
const disagree = line.match(/\[DISAGREE\]/i);
|
|
184
|
+
const conditional = line.match(/\[CONDITIONAL:\s*(.+?)\]/i);
|
|
185
|
+
if (agree) votes.push({ line: line.trim(), vote: "agree" });
|
|
186
|
+
else if (disagree) votes.push({ line: line.trim(), vote: "disagree" });
|
|
187
|
+
else if (conditional) votes.push({ line: line.trim(), vote: "conditional", condition: conditional[1].trim() });
|
|
188
|
+
}
|
|
189
|
+
return votes;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let _rolePresetsCache = null;
|
|
193
|
+
function loadRolePresets() {
|
|
194
|
+
if (_rolePresetsCache) return _rolePresetsCache;
|
|
195
|
+
try {
|
|
196
|
+
const presetsPath = path.join(__dirnameEsm, "selectors", "role-presets.json");
|
|
197
|
+
_rolePresetsCache = JSON.parse(fs.readFileSync(presetsPath, "utf-8"));
|
|
198
|
+
return _rolePresetsCache;
|
|
199
|
+
} catch {
|
|
200
|
+
_rolePresetsCache = { presets: {} };
|
|
201
|
+
return _rolePresetsCache;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function applyRolePreset(preset, speakers) {
|
|
206
|
+
const presets = loadRolePresets();
|
|
207
|
+
const presetDef = presets.presets[preset];
|
|
208
|
+
if (!presetDef) return {};
|
|
209
|
+
|
|
210
|
+
const roles = presetDef.roles;
|
|
211
|
+
const result = {};
|
|
212
|
+
for (let i = 0; i < speakers.length; i++) {
|
|
213
|
+
result[speakers[i]] = roles[i % roles.length];
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Graceful Degradation Matrix ──────────────────────────────────
|
|
219
|
+
|
|
220
|
+
const DEGRADATION_TIERS = {
|
|
221
|
+
monitoring: {
|
|
222
|
+
tier1: { name: "tmux", description: "tmux 실시간 모니터링 윈도우", check: () => commandExistsInPath("tmux") },
|
|
223
|
+
tier2: { name: "logfile", description: "로그 파일 tail 모니터링", check: () => true },
|
|
224
|
+
tier3: { name: "silent", description: "모니터링 없음 (로그만 기록)", check: () => true },
|
|
225
|
+
},
|
|
226
|
+
browser: {
|
|
227
|
+
tier1: { name: "cdp_auto", description: "CDP 자동 전송/수집", 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; } } },
|
|
228
|
+
tier2: { name: "clipboard", description: "클립보드 기반 수동 전달", check: () => true },
|
|
229
|
+
tier3: { name: "manual", description: "완전 수동 복사/붙여넣기", check: () => true },
|
|
230
|
+
},
|
|
231
|
+
terminal: {
|
|
232
|
+
tier1: { name: "auto_open", description: "터미널 앱 자동 오픈", check: () => process.platform === "darwin" || process.platform === "win32" },
|
|
233
|
+
tier2: { name: "none", description: "터미널 자동 오픈 불가", check: () => true },
|
|
234
|
+
tier3: { name: "none", description: "터미널 자동 오픈 불가", check: () => true },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
async function detectDegradationLevels() {
|
|
239
|
+
const levels = {};
|
|
240
|
+
for (const [feature, tiers] of Object.entries(DEGRADATION_TIERS)) {
|
|
241
|
+
for (const tierKey of ["tier1", "tier2", "tier3"]) {
|
|
242
|
+
const tier = tiers[tierKey];
|
|
243
|
+
const available = await Promise.resolve(tier.check());
|
|
244
|
+
if (available) {
|
|
245
|
+
levels[feature] = { tier: tierKey, name: tier.name, description: tier.description };
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return levels;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatDegradationReport(levels) {
|
|
254
|
+
const lines = [];
|
|
255
|
+
for (const [feature, info] of Object.entries(levels)) {
|
|
256
|
+
const tierNum = parseInt(info.tier.replace("tier", ""));
|
|
257
|
+
const indicator = tierNum === 1 ? "🟢" : tierNum === 2 ? "🟡" : "🔴";
|
|
258
|
+
lines.push(` ${indicator} **${feature}**: ${info.name} — ${info.description}`);
|
|
259
|
+
}
|
|
260
|
+
return lines.join("\n");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const PRODUCT_DISCLAIMER = "ℹ️ 이 도구는 외부 웹사이트를 영구 수정하지 않습니다. 브라우저 문맥을 읽기 전용으로 참조하여 발화자를 라우팅합니다.";
|
|
264
|
+
const LOCKS_SUBDIR = ".locks";
|
|
265
|
+
const LOCK_RETRY_MS = 25;
|
|
266
|
+
const LOCK_TIMEOUT_MS = 8000;
|
|
267
|
+
const LOCK_STALE_MS = 60000;
|
|
268
|
+
|
|
269
|
+
function getProjectSlug() {
|
|
270
|
+
return path.basename(process.cwd());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getProjectStateDir() {
|
|
274
|
+
return path.join(GLOBAL_STATE_DIR, getProjectSlug());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getSessionsDir() {
|
|
278
|
+
return path.join(getProjectStateDir(), "sessions");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getSessionFile(sessionId) {
|
|
282
|
+
return path.join(getSessionsDir(), `${sessionId}.json`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getArchiveDir() {
|
|
286
|
+
const obsidianDir = path.join(OBSIDIAN_PROJECTS, getProjectSlug(), "deliberations");
|
|
287
|
+
if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, getProjectSlug()))) {
|
|
288
|
+
return obsidianDir;
|
|
289
|
+
}
|
|
290
|
+
return path.join(getProjectStateDir(), "archive");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getLocksDir() {
|
|
294
|
+
return path.join(getProjectStateDir(), LOCKS_SUBDIR);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function formatRuntimeError(error) {
|
|
298
|
+
if (error instanceof Error) {
|
|
299
|
+
return error.stack || error.message;
|
|
300
|
+
}
|
|
301
|
+
return String(error);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function appendRuntimeLog(level, message) {
|
|
305
|
+
try {
|
|
306
|
+
fs.mkdirSync(path.dirname(GLOBAL_RUNTIME_LOG), { recursive: true });
|
|
307
|
+
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
|
308
|
+
fs.appendFileSync(GLOBAL_RUNTIME_LOG, line, "utf-8");
|
|
309
|
+
} catch {
|
|
310
|
+
// ignore logging failures
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function safeToolHandler(toolName, handler) {
|
|
315
|
+
return async (args) => {
|
|
316
|
+
try {
|
|
317
|
+
return await handler(args);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const message = formatRuntimeError(error);
|
|
320
|
+
appendRuntimeLog("ERROR", `${toolName}: ${message}`);
|
|
321
|
+
return { content: [{ type: "text", text: `❌ ${toolName} 실패: ${message}` }] };
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sleepMs(ms) {
|
|
327
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
328
|
+
const sab = new SharedArrayBuffer(4);
|
|
329
|
+
const arr = new Int32Array(sab);
|
|
330
|
+
Atomics.wait(arr, 0, 0, Math.floor(ms));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function writeTextAtomic(filePath, text) {
|
|
334
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
335
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
336
|
+
fs.writeFileSync(tmp, text, "utf-8");
|
|
337
|
+
fs.renameSync(tmp, filePath);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function acquireFileLock(lockPath, {
|
|
341
|
+
timeoutMs = LOCK_TIMEOUT_MS,
|
|
342
|
+
retryMs = LOCK_RETRY_MS,
|
|
343
|
+
staleMs = LOCK_STALE_MS,
|
|
344
|
+
} = {}) {
|
|
345
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
346
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
347
|
+
const startedAt = Date.now();
|
|
348
|
+
|
|
349
|
+
while (true) {
|
|
350
|
+
try {
|
|
351
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
352
|
+
fs.writeFileSync(fd, token, "utf-8");
|
|
353
|
+
fs.closeSync(fd);
|
|
354
|
+
return token;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
const isExists = error && typeof error === "object" && "code" in error && error.code === "EEXIST";
|
|
357
|
+
if (!isExists) {
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const stat = fs.statSync(lockPath);
|
|
363
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
364
|
+
fs.unlinkSync(lockPath);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// lock might have been removed concurrently
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
372
|
+
throw new Error(`lock timeout: ${lockPath}`);
|
|
373
|
+
}
|
|
374
|
+
sleepMs(retryMs);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function releaseFileLock(lockPath, token) {
|
|
380
|
+
try {
|
|
381
|
+
const current = fs.readFileSync(lockPath, "utf-8").trim();
|
|
382
|
+
if (current === token) {
|
|
383
|
+
fs.unlinkSync(lockPath);
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
// already released or replaced
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function withFileLock(lockPath, fn, options) {
|
|
391
|
+
const token = acquireFileLock(lockPath, options);
|
|
392
|
+
try {
|
|
393
|
+
return fn();
|
|
394
|
+
} finally {
|
|
395
|
+
releaseFileLock(lockPath, token);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function withProjectLock(fn, options) {
|
|
400
|
+
return withFileLock(path.join(getLocksDir(), "_project.lock"), fn, options);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function withSessionLock(sessionId, fn, options) {
|
|
404
|
+
const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
|
|
405
|
+
return withFileLock(path.join(getLocksDir(), `${safeId}.lock`), fn, options);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeSpeaker(raw) {
|
|
409
|
+
if (typeof raw !== "string") return null;
|
|
410
|
+
const normalized = raw.trim().toLowerCase();
|
|
411
|
+
if (!normalized || normalized === "none") return null;
|
|
412
|
+
return normalized;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function dedupeSpeakers(items = []) {
|
|
416
|
+
const out = [];
|
|
417
|
+
const seen = new Set();
|
|
418
|
+
for (const item of items) {
|
|
419
|
+
const normalized = normalizeSpeaker(item);
|
|
420
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
421
|
+
seen.add(normalized);
|
|
422
|
+
out.push(normalized);
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function resolveCliCandidates() {
|
|
428
|
+
const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
|
|
429
|
+
.split(/[,\s]+/)
|
|
430
|
+
.map(v => v.trim())
|
|
431
|
+
.filter(Boolean);
|
|
432
|
+
|
|
433
|
+
// If config has enabled_clis, use that as the primary filter
|
|
434
|
+
const config = loadDeliberationConfig();
|
|
435
|
+
if (Array.isArray(config.enabled_clis) && config.enabled_clis.length > 0) {
|
|
436
|
+
return dedupeSpeakers([...fromEnv, ...config.enabled_clis]);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function commandExistsInPath(command) {
|
|
443
|
+
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (process.platform === "win32") {
|
|
448
|
+
try {
|
|
449
|
+
execFileSync("where", [command], { stdio: "ignore" });
|
|
450
|
+
return true;
|
|
451
|
+
} catch {
|
|
452
|
+
// keep PATH scan fallback for shells where "where" is unavailable
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const pathVar = process.env.PATH || "";
|
|
457
|
+
const dirs = pathVar.split(path.delimiter).filter(Boolean);
|
|
458
|
+
if (dirs.length === 0) return false;
|
|
459
|
+
|
|
460
|
+
const extensions = process.platform === "win32"
|
|
461
|
+
? ["", ".exe", ".cmd", ".bat", ".ps1"]
|
|
462
|
+
: [""];
|
|
463
|
+
|
|
464
|
+
for (const dir of dirs) {
|
|
465
|
+
for (const ext of extensions) {
|
|
466
|
+
const fullPath = path.join(dir, `${command}${ext}`);
|
|
467
|
+
try {
|
|
468
|
+
fs.accessSync(fullPath, fs.constants.X_OK);
|
|
469
|
+
return true;
|
|
470
|
+
} catch {
|
|
471
|
+
// ignore and continue
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function shellQuote(value) {
|
|
479
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function discoverLocalCliSpeakers() {
|
|
483
|
+
const found = [];
|
|
484
|
+
for (const candidate of resolveCliCandidates()) {
|
|
485
|
+
if (commandExistsInPath(candidate)) {
|
|
486
|
+
found.push(candidate);
|
|
487
|
+
}
|
|
488
|
+
if (found.length >= MAX_AUTO_DISCOVERED_SPEAKERS) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return found;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function detectCallerSpeaker() {
|
|
496
|
+
const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
|
|
497
|
+
if (hinted) return hinted;
|
|
498
|
+
|
|
499
|
+
const pathHint = process.env.PATH || "";
|
|
500
|
+
if (/\bCODEX_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
|
|
501
|
+
return "codex";
|
|
502
|
+
}
|
|
503
|
+
if (pathHint.includes("/.codex/")) {
|
|
504
|
+
return "codex";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (/\bCLAUDE_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
|
|
508
|
+
return "claude";
|
|
509
|
+
}
|
|
510
|
+
if (pathHint.includes("/.claude/")) {
|
|
511
|
+
return "claude";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function resolveClipboardReader() {
|
|
518
|
+
if (process.platform === "darwin" && commandExistsInPath("pbpaste")) {
|
|
519
|
+
return { cmd: "pbpaste", args: [] };
|
|
520
|
+
}
|
|
521
|
+
if (process.platform === "win32") {
|
|
522
|
+
const windowsShell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
|
|
523
|
+
.find(cmd => commandExistsInPath(cmd));
|
|
524
|
+
if (windowsShell) {
|
|
525
|
+
return { cmd: windowsShell, args: ["-NoProfile", "-Command", "Get-Clipboard -Raw"] };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (commandExistsInPath("wl-paste")) {
|
|
529
|
+
return { cmd: "wl-paste", args: ["-n"] };
|
|
530
|
+
}
|
|
531
|
+
if (commandExistsInPath("xclip")) {
|
|
532
|
+
return { cmd: "xclip", args: ["-selection", "clipboard", "-o"] };
|
|
533
|
+
}
|
|
534
|
+
if (commandExistsInPath("xsel")) {
|
|
535
|
+
return { cmd: "xsel", args: ["--clipboard", "--output"] };
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function resolveClipboardWriter() {
|
|
541
|
+
if (process.platform === "darwin" && commandExistsInPath("pbcopy")) {
|
|
542
|
+
return { cmd: "pbcopy", args: [] };
|
|
543
|
+
}
|
|
544
|
+
if (process.platform === "win32") {
|
|
545
|
+
if (commandExistsInPath("clip.exe") || commandExistsInPath("clip")) {
|
|
546
|
+
return { cmd: "clip", args: [] };
|
|
547
|
+
}
|
|
548
|
+
const windowsShell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
|
|
549
|
+
.find(cmd => commandExistsInPath(cmd));
|
|
550
|
+
if (windowsShell) {
|
|
551
|
+
return { cmd: windowsShell, args: ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"] };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (commandExistsInPath("wl-copy")) {
|
|
555
|
+
return { cmd: "wl-copy", args: [] };
|
|
556
|
+
}
|
|
557
|
+
if (commandExistsInPath("xclip")) {
|
|
558
|
+
return { cmd: "xclip", args: ["-selection", "clipboard"] };
|
|
559
|
+
}
|
|
560
|
+
if (commandExistsInPath("xsel")) {
|
|
561
|
+
return { cmd: "xsel", args: ["--clipboard", "--input"] };
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function readClipboardText() {
|
|
567
|
+
const tool = resolveClipboardReader();
|
|
568
|
+
if (!tool) {
|
|
569
|
+
throw new Error("지원되는 클립보드 읽기 명령이 없습니다 (pbpaste/wl-paste/xclip/xsel 등).");
|
|
570
|
+
}
|
|
571
|
+
return execFileSync(tool.cmd, tool.args, {
|
|
572
|
+
encoding: "utf-8",
|
|
573
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
574
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function writeClipboardText(text) {
|
|
579
|
+
const tool = resolveClipboardWriter();
|
|
580
|
+
if (!tool) {
|
|
581
|
+
throw new Error("지원되는 클립보드 쓰기 명령이 없습니다 (pbcopy/wl-copy/xclip/xsel 등).");
|
|
582
|
+
}
|
|
583
|
+
execFileSync(tool.cmd, tool.args, {
|
|
584
|
+
input: text,
|
|
585
|
+
encoding: "utf-8",
|
|
586
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
587
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function isLlmUrl(url = "") {
|
|
592
|
+
const value = String(url || "").trim();
|
|
593
|
+
if (!value) return false;
|
|
594
|
+
try {
|
|
595
|
+
const parsed = new URL(value);
|
|
596
|
+
const host = parsed.hostname.toLowerCase();
|
|
597
|
+
return DEFAULT_LLM_DOMAINS.some(domain => host === domain || host.endsWith(`.${domain}`));
|
|
598
|
+
} catch {
|
|
599
|
+
const lowered = value.toLowerCase();
|
|
600
|
+
return DEFAULT_LLM_DOMAINS.some(domain => lowered.includes(domain));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function dedupeBrowserTabs(tabs = []) {
|
|
605
|
+
const out = [];
|
|
606
|
+
const seen = new Set();
|
|
607
|
+
for (const tab of tabs) {
|
|
608
|
+
const browser = String(tab?.browser || "").trim();
|
|
609
|
+
const title = String(tab?.title || "").trim();
|
|
610
|
+
const url = String(tab?.url || "").trim();
|
|
611
|
+
if (!url || (!isLlmUrl(url) && !isExtensionLlmTab(url, title))) continue;
|
|
612
|
+
const key = `${browser}\t${title}\t${url}`;
|
|
613
|
+
if (seen.has(key)) continue;
|
|
614
|
+
seen.add(key);
|
|
615
|
+
out.push({
|
|
616
|
+
browser: browser || "Browser",
|
|
617
|
+
title: title || "(untitled)",
|
|
618
|
+
url,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function parseInjectedBrowserTabsFromEnv() {
|
|
625
|
+
const raw = process.env.DELIBERATION_BROWSER_TABS_JSON;
|
|
626
|
+
if (!raw) {
|
|
627
|
+
return { tabs: [], note: null };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(raw);
|
|
632
|
+
if (!Array.isArray(parsed)) {
|
|
633
|
+
return { tabs: [], note: "DELIBERATION_BROWSER_TABS_JSON 형식 오류: JSON 배열이어야 합니다." };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const tabs = dedupeBrowserTabs(parsed.map(item => ({
|
|
637
|
+
browser: item?.browser || "External Bridge",
|
|
638
|
+
title: item?.title || "(untitled)",
|
|
639
|
+
url: item?.url || "",
|
|
640
|
+
})));
|
|
641
|
+
return {
|
|
642
|
+
tabs,
|
|
643
|
+
note: tabs.length > 0 ? `환경변수 탭 주입 사용: ${tabs.length}개` : "DELIBERATION_BROWSER_TABS_JSON에 유효한 LLM URL이 없습니다.",
|
|
644
|
+
};
|
|
645
|
+
} catch (error) {
|
|
646
|
+
const reason = error instanceof Error ? error.message : "unknown error";
|
|
647
|
+
return { tabs: [], note: `DELIBERATION_BROWSER_TABS_JSON 파싱 실패: ${reason}` };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function normalizeCdpEndpoint(raw) {
|
|
652
|
+
const value = String(raw || "").trim();
|
|
653
|
+
if (!value) return null;
|
|
654
|
+
|
|
655
|
+
const withProto = /^https?:\/\//i.test(value) ? value : `http://${value}`;
|
|
656
|
+
try {
|
|
657
|
+
const url = new URL(withProto);
|
|
658
|
+
if (!url.pathname || url.pathname === "/") {
|
|
659
|
+
url.pathname = "/json/list";
|
|
660
|
+
}
|
|
661
|
+
return url.toString();
|
|
662
|
+
} catch {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function resolveCdpEndpoints() {
|
|
668
|
+
const fromEnv = (process.env.DELIBERATION_BROWSER_CDP_ENDPOINTS || "")
|
|
669
|
+
.split(/[,\s]+/)
|
|
670
|
+
.map(v => normalizeCdpEndpoint(v))
|
|
671
|
+
.filter(Boolean);
|
|
672
|
+
if (fromEnv.length > 0) {
|
|
673
|
+
return [...new Set(fromEnv)];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const ports = (process.env.DELIBERATION_BROWSER_CDP_PORTS || "9222,9223,9333")
|
|
677
|
+
.split(/[,\s]+/)
|
|
678
|
+
.map(v => Number.parseInt(v, 10))
|
|
679
|
+
.filter(v => Number.isInteger(v) && v > 0 && v < 65536);
|
|
680
|
+
|
|
681
|
+
const endpoints = [];
|
|
682
|
+
for (const port of ports) {
|
|
683
|
+
endpoints.push(`http://127.0.0.1:${port}/json/list`);
|
|
684
|
+
endpoints.push(`http://localhost:${port}/json/list`);
|
|
685
|
+
}
|
|
686
|
+
return [...new Set(endpoints)];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function fetchJson(url, timeoutMs = 900) {
|
|
690
|
+
if (typeof fetch !== "function") {
|
|
691
|
+
throw new Error("fetch API unavailable in current Node runtime");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const controller = new AbortController();
|
|
695
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
696
|
+
try {
|
|
697
|
+
const response = await fetch(url, {
|
|
698
|
+
method: "GET",
|
|
699
|
+
signal: controller.signal,
|
|
700
|
+
headers: { "accept": "application/json" },
|
|
701
|
+
});
|
|
702
|
+
if (!response.ok) {
|
|
703
|
+
throw new Error(`HTTP ${response.status}`);
|
|
704
|
+
}
|
|
705
|
+
return await response.json();
|
|
706
|
+
} finally {
|
|
707
|
+
clearTimeout(timer);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function inferBrowserFromCdpEndpoint(endpoint) {
|
|
712
|
+
try {
|
|
713
|
+
const parsed = new URL(endpoint);
|
|
714
|
+
const port = Number.parseInt(parsed.port, 10);
|
|
715
|
+
if (port === 9222) return "Google Chrome (CDP)";
|
|
716
|
+
if (port === 9223) return "Microsoft Edge (CDP)";
|
|
717
|
+
if (port === 9333) return "Brave Browser (CDP)";
|
|
718
|
+
return `Browser (CDP:${parsed.host})`;
|
|
719
|
+
} catch {
|
|
720
|
+
return "Browser (CDP)";
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function summarizeFailures(items = [], max = 3) {
|
|
725
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
726
|
+
const shown = items.slice(0, max);
|
|
727
|
+
const suffix = items.length > max ? ` 외 ${items.length - max}개` : "";
|
|
728
|
+
return `${shown.join(", ")}${suffix}`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function collectBrowserLlmTabsViaCdp() {
|
|
732
|
+
const endpoints = resolveCdpEndpoints();
|
|
733
|
+
const tabs = [];
|
|
734
|
+
const failures = [];
|
|
735
|
+
|
|
736
|
+
for (const endpoint of endpoints) {
|
|
737
|
+
try {
|
|
738
|
+
const payload = await fetchJson(endpoint);
|
|
739
|
+
if (!Array.isArray(payload)) {
|
|
740
|
+
throw new Error("unexpected payload");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const browser = inferBrowserFromCdpEndpoint(endpoint);
|
|
744
|
+
for (const item of payload) {
|
|
745
|
+
if (!item || String(item.type) !== "page") continue;
|
|
746
|
+
const url = String(item.url || "").trim();
|
|
747
|
+
const title = String(item.title || "").trim();
|
|
748
|
+
if (!isLlmUrl(url) && !isExtensionLlmTab(url, title)) continue;
|
|
749
|
+
tabs.push({
|
|
750
|
+
browser,
|
|
751
|
+
title: title || "(untitled)",
|
|
752
|
+
url,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
} catch (error) {
|
|
756
|
+
const reason = error instanceof Error ? error.message : "unknown error";
|
|
757
|
+
failures.push(`${endpoint} (${reason})`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const uniqTabs = dedupeBrowserTabs(tabs);
|
|
762
|
+
if (uniqTabs.length > 0) {
|
|
763
|
+
const failSummary = summarizeFailures(failures);
|
|
764
|
+
return {
|
|
765
|
+
tabs: uniqTabs,
|
|
766
|
+
note: failSummary ? `일부 CDP 엔드포인트 접근 실패: ${failSummary}` : null,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const failSummary = summarizeFailures(failures);
|
|
771
|
+
return {
|
|
772
|
+
tabs: [],
|
|
773
|
+
note: `CDP에서 LLM 탭을 찾지 못했습니다. 브라우저를 --remote-debugging-port=9222로 실행하거나 DELIBERATION_BROWSER_TABS_JSON으로 탭 목록을 주입하세요.${failSummary ? ` (실패: ${failSummary})` : ""}`,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function ensureCdpAvailable() {
|
|
778
|
+
const endpoints = resolveCdpEndpoints();
|
|
779
|
+
|
|
780
|
+
// First attempt: try existing CDP endpoints
|
|
781
|
+
for (const endpoint of endpoints) {
|
|
782
|
+
try {
|
|
783
|
+
const payload = await fetchJson(endpoint, 1500);
|
|
784
|
+
if (Array.isArray(payload)) {
|
|
785
|
+
return { available: true, endpoint };
|
|
786
|
+
}
|
|
787
|
+
} catch { /* not reachable */ }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// If none respond and platform is macOS, try auto-launching Chrome with CDP
|
|
791
|
+
if (process.platform === "darwin") {
|
|
792
|
+
// Chrome 145+ requires --user-data-dir for CDP to work.
|
|
793
|
+
// The default data dir is rejected, so we copy the profile to ~/.chrome-cdp.
|
|
794
|
+
const chromeUserDataDir = path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
795
|
+
const cdpDataDir = path.join(os.homedir(), ".chrome-cdp");
|
|
796
|
+
const profileDir = "Default";
|
|
797
|
+
|
|
798
|
+
try {
|
|
799
|
+
const srcProfile = path.join(chromeUserDataDir, profileDir);
|
|
800
|
+
const dstProfile = path.join(cdpDataDir, profileDir);
|
|
801
|
+
if (!fs.existsSync(dstProfile) && fs.existsSync(srcProfile)) {
|
|
802
|
+
fs.mkdirSync(cdpDataDir, { recursive: true });
|
|
803
|
+
execFileSync("cp", ["-R", srcProfile, dstProfile], { timeout: 30000, stdio: "ignore" });
|
|
804
|
+
// Create minimal Local State with single profile to avoid profile picker
|
|
805
|
+
const localStateSrc = path.join(chromeUserDataDir, "Local State");
|
|
806
|
+
if (fs.existsSync(localStateSrc)) {
|
|
807
|
+
const state = JSON.parse(fs.readFileSync(localStateSrc, "utf8"));
|
|
808
|
+
state.profile.profiles_created = 1;
|
|
809
|
+
state.profile.last_used = profileDir;
|
|
810
|
+
if (state.profile.info_cache) {
|
|
811
|
+
const kept = {};
|
|
812
|
+
if (state.profile.info_cache[profileDir]) kept[profileDir] = state.profile.info_cache[profileDir];
|
|
813
|
+
state.profile.info_cache = kept;
|
|
814
|
+
}
|
|
815
|
+
fs.writeFileSync(path.join(cdpDataDir, "Local State"), JSON.stringify(state));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
} catch { /* proceed with launch attempt anyway */ }
|
|
819
|
+
|
|
820
|
+
const chromeBin = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
821
|
+
const launchArgs = [
|
|
822
|
+
"--remote-debugging-port=9222",
|
|
823
|
+
"--remote-allow-origins=*",
|
|
824
|
+
`--user-data-dir=${cdpDataDir}`,
|
|
825
|
+
`--profile-directory=${profileDir}`,
|
|
826
|
+
"--no-first-run",
|
|
827
|
+
];
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
const child = spawn(chromeBin, launchArgs, { stdio: "ignore", detached: true });
|
|
831
|
+
child.unref();
|
|
832
|
+
} catch {
|
|
833
|
+
return {
|
|
834
|
+
available: false,
|
|
835
|
+
reason: "Chrome 자동 실행에 실패했습니다. Chrome을 수동으로 --remote-debugging-port=9222 --user-data-dir=~/.chrome-cdp 옵션과 함께 실행해주세요.",
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Wait for Chrome to initialize CDP
|
|
840
|
+
sleepMs(5000);
|
|
841
|
+
|
|
842
|
+
// Retry CDP connection after launch
|
|
843
|
+
for (const endpoint of endpoints) {
|
|
844
|
+
try {
|
|
845
|
+
const payload = await fetchJson(endpoint, 2000);
|
|
846
|
+
if (Array.isArray(payload)) {
|
|
847
|
+
return { available: true, endpoint, launched: true };
|
|
848
|
+
}
|
|
849
|
+
} catch { /* still not reachable */ }
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
available: false,
|
|
854
|
+
reason: "Chrome을 실행했지만 CDP에 연결할 수 없습니다. Chrome을 완전히 종료한 후 다시 시도해주세요. (이미 실행 중인 Chrome이 CDP 없이 시작된 경우 재시작 필요)",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Non-macOS: cannot auto-launch
|
|
859
|
+
return {
|
|
860
|
+
available: false,
|
|
861
|
+
reason: "Chrome CDP를 활성화할 수 없습니다. Chrome을 --remote-debugging-port=9222 옵션과 함께 실행해주세요.",
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function collectBrowserLlmTabsViaAppleScript() {
|
|
866
|
+
if (process.platform !== "darwin") {
|
|
867
|
+
return { tabs: [], note: "AppleScript 탭 스캔은 macOS에서만 지원됩니다." };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const escapedDomains = DEFAULT_LLM_DOMAINS.map(d => d.replace(/"/g, '\\"'));
|
|
871
|
+
const escapedApps = DEFAULT_BROWSER_APPS.map(a => a.replace(/"/g, '\\"'));
|
|
872
|
+
const domainList = `{${escapedDomains.map(d => `"${d}"`).join(", ")}}`;
|
|
873
|
+
const appList = `{${escapedApps.map(a => `"${a}"`).join(", ")}}`;
|
|
874
|
+
|
|
875
|
+
// NOTE: Use stdin pipe (`osascript -`) instead of multiple `-e` flags
|
|
876
|
+
// because osascript's `-e` mode silently breaks with nested try/on error blocks.
|
|
877
|
+
// Also wrap dynamic `tell application` with `using terms from` so that
|
|
878
|
+
// Chrome-specific properties like `tabs` resolve via the scripting dictionary.
|
|
879
|
+
// Use ASCII character 9 for tab delimiter because `using terms from`
|
|
880
|
+
// shadows the built-in `tab` constant, turning it into the literal string "tab".
|
|
881
|
+
const scriptText = `set llmDomains to ${domainList}
|
|
882
|
+
set browserApps to ${appList}
|
|
883
|
+
set outText to ""
|
|
884
|
+
set tabChar to ASCII character 9
|
|
885
|
+
tell application "System Events"
|
|
886
|
+
set runningApps to name of every application process
|
|
887
|
+
end tell
|
|
888
|
+
repeat with appName in browserApps
|
|
889
|
+
if runningApps contains (appName as string) then
|
|
890
|
+
try
|
|
891
|
+
if (appName as string) is "Safari" then
|
|
892
|
+
using terms from application "Safari"
|
|
893
|
+
tell application (appName as string)
|
|
894
|
+
repeat with w in windows
|
|
895
|
+
try
|
|
896
|
+
repeat with t in tabs of w
|
|
897
|
+
set u to URL of t as string
|
|
898
|
+
set matched to false
|
|
899
|
+
repeat with d in llmDomains
|
|
900
|
+
if u contains (d as string) then set matched to true
|
|
901
|
+
end repeat
|
|
902
|
+
if matched then set outText to outText & (appName as string) & tabChar & (name of t as string) & tabChar & u & linefeed
|
|
903
|
+
end repeat
|
|
904
|
+
end try
|
|
905
|
+
end repeat
|
|
906
|
+
end tell
|
|
907
|
+
end using terms from
|
|
908
|
+
else
|
|
909
|
+
using terms from application "Google Chrome"
|
|
910
|
+
tell application (appName as string)
|
|
911
|
+
repeat with w in windows
|
|
912
|
+
try
|
|
913
|
+
repeat with t in tabs of w
|
|
914
|
+
set u to URL of t as string
|
|
915
|
+
set matched to false
|
|
916
|
+
repeat with d in llmDomains
|
|
917
|
+
if u contains (d as string) then set matched to true
|
|
918
|
+
end repeat
|
|
919
|
+
if matched then set outText to outText & (appName as string) & tabChar & (title of t as string) & tabChar & u & linefeed
|
|
920
|
+
end repeat
|
|
921
|
+
end try
|
|
922
|
+
end repeat
|
|
923
|
+
end tell
|
|
924
|
+
end using terms from
|
|
925
|
+
end if
|
|
926
|
+
on error errMsg
|
|
927
|
+
set outText to outText & (appName as string) & tabChar & "ERROR" & tabChar & errMsg & linefeed
|
|
928
|
+
end try
|
|
929
|
+
end if
|
|
930
|
+
end repeat
|
|
931
|
+
return outText`;
|
|
932
|
+
|
|
933
|
+
try {
|
|
934
|
+
const raw = execFileSync("osascript", ["-"], {
|
|
935
|
+
input: scriptText,
|
|
936
|
+
encoding: "utf-8",
|
|
937
|
+
timeout: 8000,
|
|
938
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
939
|
+
});
|
|
940
|
+
const rows = String(raw)
|
|
941
|
+
.split("\n")
|
|
942
|
+
.map(line => line.trim())
|
|
943
|
+
.filter(Boolean)
|
|
944
|
+
.map(line => {
|
|
945
|
+
const [browser = "", title = "", url = ""] = line.split("\t");
|
|
946
|
+
return { browser, title, url };
|
|
947
|
+
});
|
|
948
|
+
const tabs = rows.filter(r => r.title !== "ERROR");
|
|
949
|
+
const errors = rows.filter(r => r.title === "ERROR");
|
|
950
|
+
return {
|
|
951
|
+
tabs,
|
|
952
|
+
note: errors.length > 0
|
|
953
|
+
? `일부 브라우저 접근 실패: ${errors.map(e => `${e.browser} (${e.url})`).join(", ")}`
|
|
954
|
+
: null,
|
|
955
|
+
};
|
|
956
|
+
} catch (error) {
|
|
957
|
+
const reason = error instanceof Error ? error.message : "unknown error";
|
|
958
|
+
return {
|
|
959
|
+
tabs: [],
|
|
960
|
+
note: `브라우저 탭 스캔 실패: ${reason}. macOS 자동화 권한(터미널 -> 브라우저 제어)을 확인하세요.`,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function collectBrowserLlmTabs() {
|
|
966
|
+
const mode = (process.env.DELIBERATION_BROWSER_SCAN_MODE || "auto").trim().toLowerCase();
|
|
967
|
+
const tabs = [];
|
|
968
|
+
const notes = [];
|
|
969
|
+
|
|
970
|
+
const injected = parseInjectedBrowserTabsFromEnv();
|
|
971
|
+
tabs.push(...injected.tabs);
|
|
972
|
+
if (injected.note) notes.push(injected.note);
|
|
973
|
+
|
|
974
|
+
if (mode === "off") {
|
|
975
|
+
return {
|
|
976
|
+
tabs: dedupeBrowserTabs(tabs),
|
|
977
|
+
note: notes.length > 0 ? notes.join(" | ") : "브라우저 탭 자동 스캔이 비활성화되었습니다.",
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const shouldUseAppleScript = mode === "auto" || mode === "applescript";
|
|
982
|
+
if (shouldUseAppleScript && process.platform === "darwin") {
|
|
983
|
+
const mac = collectBrowserLlmTabsViaAppleScript();
|
|
984
|
+
tabs.push(...mac.tabs);
|
|
985
|
+
if (mac.note) notes.push(mac.note);
|
|
986
|
+
} else if (mode === "applescript" && process.platform !== "darwin") {
|
|
987
|
+
notes.push("AppleScript 스캔은 macOS 전용입니다. CDP 스캔으로 전환하세요.");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const shouldUseCdp = mode === "auto" || mode === "cdp";
|
|
991
|
+
if (shouldUseCdp) {
|
|
992
|
+
const cdp = await collectBrowserLlmTabsViaCdp();
|
|
993
|
+
tabs.push(...cdp.tabs);
|
|
994
|
+
if (cdp.note) notes.push(cdp.note);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const uniqTabs = dedupeBrowserTabs(tabs);
|
|
998
|
+
return {
|
|
999
|
+
tabs: uniqTabs,
|
|
1000
|
+
note: notes.length > 0 ? notes.join(" | ") : null,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function inferLlmProvider(url = "", title = "") {
|
|
1005
|
+
const value = String(url).toLowerCase();
|
|
1006
|
+
// Extension side panel: infer from title via registry
|
|
1007
|
+
if (value.startsWith("chrome-extension://") && title) {
|
|
1008
|
+
const registry = loadExtensionProviderRegistry();
|
|
1009
|
+
const lowerTitle = String(title).toLowerCase();
|
|
1010
|
+
for (const entry of registry.providers) {
|
|
1011
|
+
if (entry.titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
|
|
1012
|
+
return entry.provider;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return "extension-llm";
|
|
1016
|
+
}
|
|
1017
|
+
if (value.includes("claude.ai") || value.includes("anthropic.com")) return "claude";
|
|
1018
|
+
if (value.includes("chatgpt.com") || value.includes("openai.com")) return "chatgpt";
|
|
1019
|
+
if (value.includes("gemini.google.com") || value.includes("notebooklm.google.com")) return "gemini";
|
|
1020
|
+
if (value.includes("copilot.microsoft.com")) return "copilot";
|
|
1021
|
+
if (value.includes("perplexity.ai")) return "perplexity";
|
|
1022
|
+
if (value.includes("poe.com")) return "poe";
|
|
1023
|
+
if (value.includes("mistral.ai")) return "mistral";
|
|
1024
|
+
if (value.includes("huggingface.co/chat")) return "huggingface";
|
|
1025
|
+
if (value.includes("deepseek.com")) return "deepseek";
|
|
1026
|
+
if (value.includes("qwen.ai")) return "qwen";
|
|
1027
|
+
return "web-llm";
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
|
|
1031
|
+
const candidates = [];
|
|
1032
|
+
const seen = new Set();
|
|
1033
|
+
|
|
1034
|
+
const add = (candidate) => {
|
|
1035
|
+
const speaker = normalizeSpeaker(candidate?.speaker);
|
|
1036
|
+
if (!speaker || seen.has(speaker)) return;
|
|
1037
|
+
seen.add(speaker);
|
|
1038
|
+
candidates.push({ ...candidate, speaker });
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
if (include_cli) {
|
|
1042
|
+
for (const cli of discoverLocalCliSpeakers()) {
|
|
1043
|
+
add({
|
|
1044
|
+
speaker: cli,
|
|
1045
|
+
type: "cli",
|
|
1046
|
+
label: cli,
|
|
1047
|
+
command: cli,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
let browserNote = null;
|
|
1053
|
+
if (include_browser) {
|
|
1054
|
+
// Ensure CDP is available before probing browser tabs
|
|
1055
|
+
const cdpStatus = await ensureCdpAvailable();
|
|
1056
|
+
if (cdpStatus.launched) {
|
|
1057
|
+
browserNote = "Chrome CDP 자동 실행됨 (--remote-debugging-port=9222)";
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const { tabs, note } = await collectBrowserLlmTabs();
|
|
1061
|
+
browserNote = browserNote ? `${browserNote} | ${note || ""}`.replace(/ \| $/, "") : (note || null);
|
|
1062
|
+
const providerCounts = new Map();
|
|
1063
|
+
for (const tab of tabs) {
|
|
1064
|
+
const provider = inferLlmProvider(tab.url, tab.title);
|
|
1065
|
+
const count = (providerCounts.get(provider) || 0) + 1;
|
|
1066
|
+
providerCounts.set(provider, count);
|
|
1067
|
+
add({
|
|
1068
|
+
speaker: `web-${provider}-${count}`,
|
|
1069
|
+
type: "browser",
|
|
1070
|
+
provider,
|
|
1071
|
+
browser: tab.browser || "",
|
|
1072
|
+
title: tab.title || "",
|
|
1073
|
+
url: tab.url || "",
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// CDP auto-detection: probe endpoints for matching tabs
|
|
1078
|
+
const cdpEndpoints = resolveCdpEndpoints();
|
|
1079
|
+
const cdpTabs = [];
|
|
1080
|
+
for (const endpoint of cdpEndpoints) {
|
|
1081
|
+
try {
|
|
1082
|
+
const tabs = await fetchJson(endpoint, 2000);
|
|
1083
|
+
if (Array.isArray(tabs)) {
|
|
1084
|
+
for (const t of tabs) {
|
|
1085
|
+
if (t.type === "page" && t.url) cdpTabs.push(t);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} catch { /* endpoint not reachable */ }
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Match CDP tabs with discovered browser candidates
|
|
1092
|
+
for (const candidate of candidates) {
|
|
1093
|
+
if (candidate.type !== "browser") continue;
|
|
1094
|
+
// For extension candidates, match by title instead of hostname
|
|
1095
|
+
const candidateUrl = String(candidate.url || "");
|
|
1096
|
+
if (candidateUrl.startsWith("chrome-extension://")) {
|
|
1097
|
+
const candidateTitle = String(candidate.title || "").toLowerCase();
|
|
1098
|
+
if (candidateTitle) {
|
|
1099
|
+
const matches = cdpTabs.filter(t =>
|
|
1100
|
+
String(t.url || "").startsWith("chrome-extension://") &&
|
|
1101
|
+
String(t.title || "").toLowerCase().includes(candidateTitle)
|
|
1102
|
+
);
|
|
1103
|
+
if (matches.length === 1) {
|
|
1104
|
+
candidate.cdp_available = true;
|
|
1105
|
+
candidate.cdp_tab_id = matches[0].id;
|
|
1106
|
+
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
let candidateHost = "";
|
|
1112
|
+
try {
|
|
1113
|
+
candidateHost = new URL(candidate.url).hostname.toLowerCase();
|
|
1114
|
+
} catch { continue; }
|
|
1115
|
+
if (!candidateHost) continue;
|
|
1116
|
+
const matches = cdpTabs.filter(t => {
|
|
1117
|
+
try {
|
|
1118
|
+
return new URL(t.url).hostname.toLowerCase() === candidateHost;
|
|
1119
|
+
} catch { return false; }
|
|
1120
|
+
});
|
|
1121
|
+
if (matches.length === 1) {
|
|
1122
|
+
candidate.cdp_available = true;
|
|
1123
|
+
candidate.cdp_tab_id = matches[0].id;
|
|
1124
|
+
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return { candidates, browserNote };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function formatSpeakerCandidatesReport({ candidates, browserNote }) {
|
|
1133
|
+
const cli = candidates.filter(c => c.type === "cli");
|
|
1134
|
+
const browser = candidates.filter(c => c.type === "browser");
|
|
1135
|
+
|
|
1136
|
+
let out = "## Selectable Speakers\n\n";
|
|
1137
|
+
out += "### CLI\n";
|
|
1138
|
+
if (cli.length === 0) {
|
|
1139
|
+
out += "- (감지된 로컬 CLI 없음)\n\n";
|
|
1140
|
+
} else {
|
|
1141
|
+
out += `${cli.map(c => `- \`${c.speaker}\` (command: ${c.command})`).join("\n")}\n\n`;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
out += "### Browser LLM\n";
|
|
1145
|
+
if (browser.length === 0) {
|
|
1146
|
+
out += "- (감지된 브라우저 LLM 탭 없음)\n";
|
|
1147
|
+
} else {
|
|
1148
|
+
out += `${browser.map(c => {
|
|
1149
|
+
const icon = c.cdp_available ? "⚡자동" : "📋클립보드";
|
|
1150
|
+
const extTag = String(c.url || "").startsWith("chrome-extension://") ? " [Extension]" : "";
|
|
1151
|
+
return `- \`${c.speaker}\` [${icon}]${extTag} [${c.browser}] ${c.title}\n ${c.url}`;
|
|
1152
|
+
}).join("\n")}\n`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (browserNote) {
|
|
1156
|
+
out += `\n\nℹ️ ${browserNote}`;
|
|
1157
|
+
}
|
|
1158
|
+
return out;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function mapParticipantProfiles(speakers, candidates, typeOverrides) {
|
|
1162
|
+
const bySpeaker = new Map();
|
|
1163
|
+
for (const c of candidates || []) {
|
|
1164
|
+
const key = normalizeSpeaker(c.speaker);
|
|
1165
|
+
if (key) bySpeaker.set(key, c);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const overrides = typeOverrides || {};
|
|
1169
|
+
|
|
1170
|
+
const profiles = [];
|
|
1171
|
+
for (const raw of speakers || []) {
|
|
1172
|
+
const speaker = normalizeSpeaker(raw);
|
|
1173
|
+
if (!speaker) continue;
|
|
1174
|
+
|
|
1175
|
+
// Check for explicit type override
|
|
1176
|
+
const overrideType = overrides[speaker] || overrides[raw];
|
|
1177
|
+
if (overrideType) {
|
|
1178
|
+
profiles.push({
|
|
1179
|
+
speaker,
|
|
1180
|
+
type: overrideType,
|
|
1181
|
+
...(overrideType === "browser_auto" ? { provider: "chatgpt" } : {}),
|
|
1182
|
+
});
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const candidate = bySpeaker.get(speaker);
|
|
1187
|
+
if (!candidate) {
|
|
1188
|
+
profiles.push({
|
|
1189
|
+
speaker,
|
|
1190
|
+
type: "manual",
|
|
1191
|
+
});
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (candidate.type === "cli") {
|
|
1196
|
+
profiles.push({
|
|
1197
|
+
speaker,
|
|
1198
|
+
type: "cli",
|
|
1199
|
+
command: candidate.command || speaker,
|
|
1200
|
+
});
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
|
|
1205
|
+
profiles.push({
|
|
1206
|
+
speaker,
|
|
1207
|
+
type: effectiveType,
|
|
1208
|
+
provider: candidate.provider || null,
|
|
1209
|
+
browser: candidate.browser || null,
|
|
1210
|
+
title: candidate.title || null,
|
|
1211
|
+
url: candidate.url || null,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
return profiles;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ── Transport routing ─────────────────────────────────────────
|
|
1218
|
+
|
|
1219
|
+
const TRANSPORT_TYPES = {
|
|
1220
|
+
cli: "cli_respond",
|
|
1221
|
+
browser: "clipboard",
|
|
1222
|
+
browser_auto: "browser_auto",
|
|
1223
|
+
manual: "manual",
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
// BrowserControlPort singleton — initialized lazily on first use
|
|
1227
|
+
let _browserPort = null;
|
|
1228
|
+
function getBrowserPort() {
|
|
1229
|
+
if (!_browserPort) {
|
|
1230
|
+
const cdpEndpoints = resolveCdpEndpoints();
|
|
1231
|
+
_browserPort = new OrchestratedBrowserPort({ cdpEndpoints });
|
|
1232
|
+
}
|
|
1233
|
+
return _browserPort;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function resolveTransportForSpeaker(state, speaker) {
|
|
1237
|
+
const normalizedSpeaker = normalizeSpeaker(speaker);
|
|
1238
|
+
if (!normalizedSpeaker || !state?.participant_profiles) {
|
|
1239
|
+
return { transport: "manual", reason: "no_profile" };
|
|
1240
|
+
}
|
|
1241
|
+
const profile = state.participant_profiles.find(
|
|
1242
|
+
p => normalizeSpeaker(p.speaker) === normalizedSpeaker
|
|
1243
|
+
);
|
|
1244
|
+
if (!profile) {
|
|
1245
|
+
return { transport: "manual", reason: "speaker_not_in_profiles" };
|
|
1246
|
+
}
|
|
1247
|
+
const transport = TRANSPORT_TYPES[profile.type] || "manual";
|
|
1248
|
+
return { transport, profile, reason: null };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function formatTransportGuidance(transport, state, speaker) {
|
|
1252
|
+
const sid = state.id;
|
|
1253
|
+
switch (transport) {
|
|
1254
|
+
case "cli_respond":
|
|
1255
|
+
return `CLI speaker입니다. \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 직접 응답하세요.`;
|
|
1256
|
+
case "clipboard":
|
|
1257
|
+
return `브라우저 LLM speaker입니다. CDP 자동 연결 시도 중... Chrome이 이미 CDP 없이 실행 중이면 재시작이 필요할 수 있습니다.`;
|
|
1258
|
+
case "browser_auto":
|
|
1259
|
+
return `자동 브라우저 speaker입니다. \`deliberation_browser_auto_turn(session_id: "${sid}")\`으로 자동 진행됩니다. CDP를 통해 브라우저 LLM에 직접 입력하고 응답을 읽습니다.`;
|
|
1260
|
+
case "manual":
|
|
1261
|
+
default:
|
|
1262
|
+
return `수동 speaker입니다. 응답을 직접 작성해 \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 제출하세요.`;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function buildSpeakerOrder(speakers, fallbackSpeaker = DEFAULT_SPEAKERS[0], fallbackPlacement = "front") {
|
|
1267
|
+
const ordered = [];
|
|
1268
|
+
const seen = new Set();
|
|
1269
|
+
|
|
1270
|
+
const add = (candidate) => {
|
|
1271
|
+
const speaker = normalizeSpeaker(candidate);
|
|
1272
|
+
if (!speaker || seen.has(speaker)) return;
|
|
1273
|
+
seen.add(speaker);
|
|
1274
|
+
ordered.push(speaker);
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
if (fallbackPlacement === "front") {
|
|
1278
|
+
add(fallbackSpeaker);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (Array.isArray(speakers)) {
|
|
1282
|
+
for (const speaker of speakers) {
|
|
1283
|
+
add(speaker);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (fallbackPlacement !== "front") {
|
|
1288
|
+
add(fallbackSpeaker);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (ordered.length === 0) {
|
|
1292
|
+
for (const speaker of DEFAULT_SPEAKERS) {
|
|
1293
|
+
add(speaker);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return ordered;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function normalizeSessionActors(state) {
|
|
1301
|
+
if (!state || typeof state !== "object") return state;
|
|
1302
|
+
|
|
1303
|
+
const fallbackSpeaker = normalizeSpeaker(state.current_speaker)
|
|
1304
|
+
|| normalizeSpeaker(state.log?.[0]?.speaker)
|
|
1305
|
+
|| DEFAULT_SPEAKERS[0];
|
|
1306
|
+
const speakers = buildSpeakerOrder(state.speakers, fallbackSpeaker, "end");
|
|
1307
|
+
state.speakers = speakers;
|
|
1308
|
+
|
|
1309
|
+
const normalizedCurrent = normalizeSpeaker(state.current_speaker);
|
|
1310
|
+
if (state.status === "active") {
|
|
1311
|
+
state.current_speaker = (normalizedCurrent && speakers.includes(normalizedCurrent))
|
|
1312
|
+
? normalizedCurrent
|
|
1313
|
+
: speakers[0];
|
|
1314
|
+
} else if (normalizedCurrent) {
|
|
1315
|
+
state.current_speaker = normalizedCurrent;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return state;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ── Session ID generation ─────────────────────────────────────
|
|
1322
|
+
|
|
1323
|
+
function generateSessionId(topic) {
|
|
1324
|
+
const slug = topic
|
|
1325
|
+
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
1326
|
+
.replace(/\s+/g, "-")
|
|
1327
|
+
.toLowerCase()
|
|
1328
|
+
.slice(0, 20);
|
|
1329
|
+
const ts = Date.now().toString(36);
|
|
1330
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
1331
|
+
return `${slug}-${ts}${rand}`;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function generateTurnId() {
|
|
1335
|
+
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ── Context detection ──────────────────────────────────────────
|
|
1339
|
+
|
|
1340
|
+
function detectContextDirs() {
|
|
1341
|
+
const dirs = [];
|
|
1342
|
+
const slug = getProjectSlug();
|
|
1343
|
+
|
|
1344
|
+
if (process.env.DELIBERATION_CONTEXT_DIR) {
|
|
1345
|
+
dirs.push(process.env.DELIBERATION_CONTEXT_DIR);
|
|
1346
|
+
}
|
|
1347
|
+
dirs.push(process.cwd());
|
|
1348
|
+
|
|
1349
|
+
const obsidianProject = path.join(OBSIDIAN_PROJECTS, slug);
|
|
1350
|
+
if (fs.existsSync(obsidianProject)) {
|
|
1351
|
+
dirs.push(obsidianProject);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return [...new Set(dirs)];
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function readContextFromDirs(dirs, maxChars = 15000) {
|
|
1358
|
+
let context = "";
|
|
1359
|
+
const seen = new Set();
|
|
1360
|
+
|
|
1361
|
+
for (const dir of dirs) {
|
|
1362
|
+
if (!fs.existsSync(dir)) continue;
|
|
1363
|
+
|
|
1364
|
+
const files = fs.readdirSync(dir)
|
|
1365
|
+
.filter(f => f.endsWith(".md") && !f.startsWith("_") && !f.startsWith("."))
|
|
1366
|
+
.sort();
|
|
1367
|
+
|
|
1368
|
+
for (const file of files) {
|
|
1369
|
+
if (seen.has(file)) continue;
|
|
1370
|
+
seen.add(file);
|
|
1371
|
+
|
|
1372
|
+
const fullPath = path.join(dir, file);
|
|
1373
|
+
let raw;
|
|
1374
|
+
try { raw = fs.readFileSync(fullPath, "utf-8"); } catch { continue; }
|
|
1375
|
+
|
|
1376
|
+
let body = raw;
|
|
1377
|
+
if (body.startsWith("---")) {
|
|
1378
|
+
const end = body.indexOf("---", 3);
|
|
1379
|
+
if (end !== -1) body = body.slice(end + 3).trim();
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const truncated = body.length > 1200
|
|
1383
|
+
? body.slice(0, 1200) + "\n(...)"
|
|
1384
|
+
: body;
|
|
1385
|
+
|
|
1386
|
+
context += `### ${file.replace(".md", "")}\n${truncated}\n\n---\n\n`;
|
|
1387
|
+
|
|
1388
|
+
if (context.length > maxChars) {
|
|
1389
|
+
context = context.slice(0, maxChars) + "\n\n(...context truncated)";
|
|
1390
|
+
return context;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return context || "(컨텍스트 파일을 찾을 수 없습니다)";
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// ── State helpers ──────────────────────────────────────────────
|
|
1398
|
+
|
|
1399
|
+
function ensureDirs() {
|
|
1400
|
+
fs.mkdirSync(getSessionsDir(), { recursive: true });
|
|
1401
|
+
fs.mkdirSync(getArchiveDir(), { recursive: true });
|
|
1402
|
+
fs.mkdirSync(getLocksDir(), { recursive: true });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function loadSession(sessionId) {
|
|
1406
|
+
const file = getSessionFile(sessionId);
|
|
1407
|
+
if (!fs.existsSync(file)) return null;
|
|
1408
|
+
return normalizeSessionActors(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function saveSession(state) {
|
|
1412
|
+
ensureDirs();
|
|
1413
|
+
state.updated = new Date().toISOString();
|
|
1414
|
+
writeTextAtomic(getSessionFile(state.id), JSON.stringify(state, null, 2));
|
|
1415
|
+
syncMarkdown(state);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function listActiveSessions() {
|
|
1419
|
+
const dir = getSessionsDir();
|
|
1420
|
+
if (!fs.existsSync(dir)) return [];
|
|
1421
|
+
|
|
1422
|
+
return fs.readdirSync(dir)
|
|
1423
|
+
.filter(f => f.endsWith(".json"))
|
|
1424
|
+
.map(f => {
|
|
1425
|
+
try {
|
|
1426
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
1427
|
+
return data;
|
|
1428
|
+
} catch { return null; }
|
|
1429
|
+
})
|
|
1430
|
+
.filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function resolveSessionId(sessionId) {
|
|
1434
|
+
// session_id가 주어지면 그대로 사용
|
|
1435
|
+
if (sessionId) return sessionId;
|
|
1436
|
+
|
|
1437
|
+
// 없으면 활성 세션이 1개일 때 자동 선택
|
|
1438
|
+
const active = listActiveSessions();
|
|
1439
|
+
if (active.length === 0) return null;
|
|
1440
|
+
if (active.length === 1) return active[0].id;
|
|
1441
|
+
|
|
1442
|
+
// 여러 개면 null (목록 표시 필요)
|
|
1443
|
+
return "MULTIPLE";
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function syncMarkdown(state) {
|
|
1447
|
+
const filename = `deliberation-${state.id}.md`;
|
|
1448
|
+
// Write to state dir instead of CWD to avoid polluting project root
|
|
1449
|
+
const mdPath = path.join(getProjectStateDir(), filename);
|
|
1450
|
+
try {
|
|
1451
|
+
writeTextAtomic(mdPath, stateToMarkdown(state));
|
|
1452
|
+
} catch { /* ignore sync failures */ }
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function cleanupSyncMarkdown(state) {
|
|
1456
|
+
const filename = `deliberation-${state.id}.md`;
|
|
1457
|
+
// Remove from state dir
|
|
1458
|
+
const statePath = path.join(getProjectStateDir(), filename);
|
|
1459
|
+
try { fs.unlinkSync(statePath); } catch { /* ignore */ }
|
|
1460
|
+
// Also clean up legacy files in CWD (from older versions)
|
|
1461
|
+
const cwdPath = path.join(process.cwd(), filename);
|
|
1462
|
+
try { fs.unlinkSync(cwdPath); } catch { /* ignore */ }
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function stateToMarkdown(s) {
|
|
1466
|
+
const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
|
|
1467
|
+
let md = `---
|
|
1468
|
+
title: "Deliberation - ${s.topic}"
|
|
1469
|
+
session_id: "${s.id}"
|
|
1470
|
+
created: ${s.created}
|
|
1471
|
+
updated: ${s.updated || new Date().toISOString()}
|
|
1472
|
+
type: deliberation
|
|
1473
|
+
status: ${s.status}
|
|
1474
|
+
project: "${s.project}"
|
|
1475
|
+
participants: ${JSON.stringify(speakerOrder)}
|
|
1476
|
+
rounds: ${s.max_rounds}
|
|
1477
|
+
current_round: ${s.current_round}
|
|
1478
|
+
current_speaker: "${s.current_speaker}"
|
|
1479
|
+
tags: [deliberation]
|
|
1480
|
+
---
|
|
1481
|
+
|
|
1482
|
+
# Deliberation: ${s.topic}
|
|
1483
|
+
|
|
1484
|
+
**Session:** ${s.id} | **Project:** ${s.project} | **Status:** ${s.status} | **Round:** ${s.current_round}/${s.max_rounds} | **Next:** ${s.current_speaker}
|
|
1485
|
+
|
|
1486
|
+
---
|
|
1487
|
+
|
|
1488
|
+
`;
|
|
1489
|
+
|
|
1490
|
+
if (s.synthesis) {
|
|
1491
|
+
md += `## Synthesis\n\n${s.synthesis}\n\n---\n\n`;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
md += `## Debate Log\n\n`;
|
|
1495
|
+
for (const entry of s.log) {
|
|
1496
|
+
md += `### ${entry.speaker} — Round ${entry.round}\n\n`;
|
|
1497
|
+
if (entry.channel_used || entry.fallback_reason) {
|
|
1498
|
+
const parts = [];
|
|
1499
|
+
if (entry.channel_used) parts.push(`channel: ${entry.channel_used}`);
|
|
1500
|
+
if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
|
|
1501
|
+
md += `> _${parts.join(" | ")}_\n\n`;
|
|
1502
|
+
}
|
|
1503
|
+
md += `${entry.content}\n\n---\n\n`;
|
|
1504
|
+
}
|
|
1505
|
+
return md;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function archiveState(state) {
|
|
1509
|
+
ensureDirs();
|
|
1510
|
+
const slug = state.topic
|
|
1511
|
+
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
1512
|
+
.replace(/\s+/g, "-")
|
|
1513
|
+
.slice(0, 30);
|
|
1514
|
+
const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
|
|
1515
|
+
const filename = `deliberation-${ts}-${slug}.md`;
|
|
1516
|
+
const dest = path.join(getArchiveDir(), filename);
|
|
1517
|
+
writeTextAtomic(dest, stateToMarkdown(state));
|
|
1518
|
+
return dest;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ── Terminal management ────────────────────────────────────────
|
|
1522
|
+
|
|
1523
|
+
const TMUX_SESSION = "deliberation";
|
|
1524
|
+
const MONITOR_SCRIPT = path.join(INSTALL_DIR, "session-monitor.sh");
|
|
1525
|
+
const MONITOR_SCRIPT_WIN = path.join(INSTALL_DIR, "session-monitor-win.js");
|
|
1526
|
+
|
|
1527
|
+
function tmuxWindowName(sessionId) {
|
|
1528
|
+
// tmux 윈도우 이름은 짧게 (마지막 부분 제거하고 20자)
|
|
1529
|
+
return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function appleScriptQuote(value) {
|
|
1533
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function tryExecFile(command, args = []) {
|
|
1537
|
+
try {
|
|
1538
|
+
execFileSync(command, args, { stdio: "ignore", windowsHide: true });
|
|
1539
|
+
return true;
|
|
1540
|
+
} catch {
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function resolveMonitorShell() {
|
|
1546
|
+
if (commandExistsInPath("bash")) return "bash";
|
|
1547
|
+
if (commandExistsInPath("sh")) return "sh";
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function buildMonitorCommand(sessionId, project) {
|
|
1552
|
+
const shell = resolveMonitorShell();
|
|
1553
|
+
if (!shell) return null;
|
|
1554
|
+
return `${shell} ${shellQuote(MONITOR_SCRIPT)} ${shellQuote(sessionId)} ${shellQuote(project)}`;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function buildMonitorCommandWindows(sessionId, project) {
|
|
1558
|
+
return `node "${MONITOR_SCRIPT_WIN}" "${sessionId}" "${project}"`;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function hasTmuxSession(name) {
|
|
1562
|
+
try {
|
|
1563
|
+
execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore", windowsHide: true });
|
|
1564
|
+
return true;
|
|
1565
|
+
} catch {
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function tmuxWindowCount(name) {
|
|
1571
|
+
try {
|
|
1572
|
+
const output = execFileSync("tmux", ["list-windows", "-t", name], {
|
|
1573
|
+
encoding: "utf-8",
|
|
1574
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1575
|
+
windowsHide: true,
|
|
1576
|
+
});
|
|
1577
|
+
return String(output)
|
|
1578
|
+
.split("\n")
|
|
1579
|
+
.map(line => line.trim())
|
|
1580
|
+
.filter(Boolean)
|
|
1581
|
+
.length;
|
|
1582
|
+
} catch {
|
|
1583
|
+
return 0;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function buildTmuxAttachCommand(sessionId) {
|
|
1588
|
+
const winName = tmuxWindowName(sessionId);
|
|
1589
|
+
return `tmux attach -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function listPhysicalTerminalWindowIds() {
|
|
1593
|
+
if (process.platform !== "darwin") {
|
|
1594
|
+
return [];
|
|
1595
|
+
}
|
|
1596
|
+
try {
|
|
1597
|
+
const output = execFileSync(
|
|
1598
|
+
"osascript",
|
|
1599
|
+
[
|
|
1600
|
+
"-e",
|
|
1601
|
+
'tell application "Terminal"',
|
|
1602
|
+
"-e",
|
|
1603
|
+
"if not running then return \"\"",
|
|
1604
|
+
"-e",
|
|
1605
|
+
"set outText to \"\"",
|
|
1606
|
+
"-e",
|
|
1607
|
+
"repeat with w in windows",
|
|
1608
|
+
"-e",
|
|
1609
|
+
"set outText to outText & (id of w as string) & linefeed",
|
|
1610
|
+
"-e",
|
|
1611
|
+
"end repeat",
|
|
1612
|
+
"-e",
|
|
1613
|
+
"return outText",
|
|
1614
|
+
"-e",
|
|
1615
|
+
"end tell",
|
|
1616
|
+
],
|
|
1617
|
+
{ encoding: "utf-8" }
|
|
1618
|
+
);
|
|
1619
|
+
return String(output)
|
|
1620
|
+
.split("\n")
|
|
1621
|
+
.map(s => Number.parseInt(s.trim(), 10))
|
|
1622
|
+
.filter(n => Number.isInteger(n) && n > 0);
|
|
1623
|
+
} catch {
|
|
1624
|
+
return [];
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function openPhysicalTerminal(sessionId) {
|
|
1629
|
+
const winName = tmuxWindowName(sessionId);
|
|
1630
|
+
const attachCmd = `tmux attach -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1631
|
+
|
|
1632
|
+
if (process.platform === "darwin") {
|
|
1633
|
+
const before = new Set(listPhysicalTerminalWindowIds());
|
|
1634
|
+
try {
|
|
1635
|
+
const output = execFileSync(
|
|
1636
|
+
"osascript",
|
|
1637
|
+
[
|
|
1638
|
+
"-e",
|
|
1639
|
+
'tell application "Terminal"',
|
|
1640
|
+
"-e",
|
|
1641
|
+
"activate",
|
|
1642
|
+
"-e",
|
|
1643
|
+
`do script ${appleScriptQuote(attachCmd)}`,
|
|
1644
|
+
"-e",
|
|
1645
|
+
"delay 0.15",
|
|
1646
|
+
"-e",
|
|
1647
|
+
"return id of front window",
|
|
1648
|
+
"-e",
|
|
1649
|
+
"end tell",
|
|
1650
|
+
],
|
|
1651
|
+
{ encoding: "utf-8" }
|
|
1652
|
+
);
|
|
1653
|
+
const frontId = Number.parseInt(String(output).trim(), 10);
|
|
1654
|
+
const after = listPhysicalTerminalWindowIds();
|
|
1655
|
+
const opened = after.filter(id => !before.has(id));
|
|
1656
|
+
if (opened.length > 0) {
|
|
1657
|
+
return { opened: true, windowIds: [...new Set(opened)] };
|
|
1658
|
+
}
|
|
1659
|
+
if (Number.isInteger(frontId) && frontId > 0) {
|
|
1660
|
+
return { opened: true, windowIds: [frontId] };
|
|
1661
|
+
}
|
|
1662
|
+
return { opened: false, windowIds: [] };
|
|
1663
|
+
} catch {
|
|
1664
|
+
return { opened: false, windowIds: [] };
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
if (process.platform === "linux") {
|
|
1669
|
+
const shell = resolveMonitorShell() || "sh";
|
|
1670
|
+
const launchCmd = `${buildTmuxAttachCommand(sessionId)}; exec ${shell}`;
|
|
1671
|
+
const attempts = [
|
|
1672
|
+
["gnome-terminal", ["--", shell, "-lc", launchCmd]],
|
|
1673
|
+
["kgx", ["--", shell, "-lc", launchCmd]],
|
|
1674
|
+
["konsole", ["-e", shell, "-lc", launchCmd]],
|
|
1675
|
+
["x-terminal-emulator", ["-e", shell, "-lc", launchCmd]],
|
|
1676
|
+
["xterm", ["-e", shell, "-lc", launchCmd]],
|
|
1677
|
+
["alacritty", ["-e", shell, "-lc", launchCmd]],
|
|
1678
|
+
["kitty", [shell, "-lc", launchCmd]],
|
|
1679
|
+
["wezterm", ["start", "--", shell, "-lc", launchCmd]],
|
|
1680
|
+
];
|
|
1681
|
+
|
|
1682
|
+
for (const [command, args] of attempts) {
|
|
1683
|
+
if (!commandExistsInPath(command)) continue;
|
|
1684
|
+
if (tryExecFile(command, args)) {
|
|
1685
|
+
return { opened: true, windowIds: [] };
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
return { opened: false, windowIds: [] };
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (process.platform === "win32") {
|
|
1692
|
+
// Windows: monitor is launched directly by spawnMonitorTerminal (no tmux)
|
|
1693
|
+
// Physical terminal opening is handled there, so just return success
|
|
1694
|
+
return { opened: true, windowIds: [] };
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
return { opened: false, windowIds: [] };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function spawnMonitorTerminal(sessionId) {
|
|
1701
|
+
// Windows: use Windows Terminal or PowerShell directly (no tmux needed)
|
|
1702
|
+
if (process.platform === "win32") {
|
|
1703
|
+
const project = getProjectSlug();
|
|
1704
|
+
const monitorCmd = buildMonitorCommandWindows(sessionId, project);
|
|
1705
|
+
|
|
1706
|
+
// Try Windows Terminal (wt.exe)
|
|
1707
|
+
if (commandExistsInPath("wt") || commandExistsInPath("wt.exe")) {
|
|
1708
|
+
if (tryExecFile("wt", ["new-tab", "--title", "Deliberation Monitor", "cmd", "/c", monitorCmd])) {
|
|
1709
|
+
return true;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Fallback: new PowerShell window
|
|
1714
|
+
const shell = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"].find(c => commandExistsInPath(c));
|
|
1715
|
+
if (shell) {
|
|
1716
|
+
const escaped = monitorCmd.replace(/'/g, "''");
|
|
1717
|
+
if (tryExecFile(shell, ["-NoProfile", "-Command", `Start-Process cmd -ArgumentList '/c','${escaped}'`])) {
|
|
1718
|
+
return true;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// macOS/Linux: use tmux (existing logic)
|
|
1726
|
+
if (!commandExistsInPath("tmux")) {
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
const project = getProjectSlug();
|
|
1731
|
+
const winName = tmuxWindowName(sessionId);
|
|
1732
|
+
const cmd = buildMonitorCommand(sessionId, project);
|
|
1733
|
+
if (!cmd) {
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
try {
|
|
1738
|
+
if (hasTmuxSession(TMUX_SESSION)) {
|
|
1739
|
+
execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
|
|
1740
|
+
stdio: "ignore",
|
|
1741
|
+
windowsHide: true,
|
|
1742
|
+
});
|
|
1743
|
+
} else {
|
|
1744
|
+
execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
|
|
1745
|
+
stdio: "ignore",
|
|
1746
|
+
windowsHide: true,
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
return true;
|
|
1750
|
+
} catch {
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function closePhysicalTerminal(windowId) {
|
|
1756
|
+
if (process.platform !== "darwin") {
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1759
|
+
if (!Number.isInteger(windowId) || windowId <= 0) {
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const windowExists = () => {
|
|
1764
|
+
try {
|
|
1765
|
+
const out = execFileSync(
|
|
1766
|
+
"osascript",
|
|
1767
|
+
[
|
|
1768
|
+
"-e",
|
|
1769
|
+
'tell application "Terminal"',
|
|
1770
|
+
"-e",
|
|
1771
|
+
`if exists window id ${windowId} then return "1"`,
|
|
1772
|
+
"-e",
|
|
1773
|
+
'return "0"',
|
|
1774
|
+
"-e",
|
|
1775
|
+
"end tell",
|
|
1776
|
+
],
|
|
1777
|
+
{ encoding: "utf-8" }
|
|
1778
|
+
).trim();
|
|
1779
|
+
return out === "1";
|
|
1780
|
+
} catch {
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
const dismissCloseDialogs = () => {
|
|
1786
|
+
try {
|
|
1787
|
+
execFileSync(
|
|
1788
|
+
"osascript",
|
|
1789
|
+
[
|
|
1790
|
+
"-e",
|
|
1791
|
+
'tell application "System Events"',
|
|
1792
|
+
"-e",
|
|
1793
|
+
'if exists process "Terminal" then',
|
|
1794
|
+
"-e",
|
|
1795
|
+
'tell process "Terminal"',
|
|
1796
|
+
"-e",
|
|
1797
|
+
"repeat with w in windows",
|
|
1798
|
+
"-e",
|
|
1799
|
+
"try",
|
|
1800
|
+
"-e",
|
|
1801
|
+
"if exists (sheet 1 of w) then",
|
|
1802
|
+
"-e",
|
|
1803
|
+
"if exists button \"종료\" of sheet 1 of w then",
|
|
1804
|
+
"-e",
|
|
1805
|
+
'click button "종료" of sheet 1 of w',
|
|
1806
|
+
"-e",
|
|
1807
|
+
"else if exists button \"Terminate\" of sheet 1 of w then",
|
|
1808
|
+
"-e",
|
|
1809
|
+
'click button "Terminate" of sheet 1 of w',
|
|
1810
|
+
"-e",
|
|
1811
|
+
"else if exists button \"확인\" of sheet 1 of w then",
|
|
1812
|
+
"-e",
|
|
1813
|
+
'click button "확인" of sheet 1 of w',
|
|
1814
|
+
"-e",
|
|
1815
|
+
"else",
|
|
1816
|
+
"-e",
|
|
1817
|
+
"click button 1 of sheet 1 of w",
|
|
1818
|
+
"-e",
|
|
1819
|
+
"end if",
|
|
1820
|
+
"-e",
|
|
1821
|
+
"end if",
|
|
1822
|
+
"-e",
|
|
1823
|
+
"end try",
|
|
1824
|
+
"-e",
|
|
1825
|
+
"end repeat",
|
|
1826
|
+
"-e",
|
|
1827
|
+
"end tell",
|
|
1828
|
+
"-e",
|
|
1829
|
+
"end if",
|
|
1830
|
+
"-e",
|
|
1831
|
+
"end tell",
|
|
1832
|
+
],
|
|
1833
|
+
{ stdio: "ignore" }
|
|
1834
|
+
);
|
|
1835
|
+
} catch {
|
|
1836
|
+
// ignore
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
for (let i = 0; i < 5; i += 1) {
|
|
1841
|
+
try {
|
|
1842
|
+
execFileSync(
|
|
1843
|
+
"osascript",
|
|
1844
|
+
[
|
|
1845
|
+
"-e",
|
|
1846
|
+
'tell application "Terminal"',
|
|
1847
|
+
"-e",
|
|
1848
|
+
"activate",
|
|
1849
|
+
"-e",
|
|
1850
|
+
`if exists window id ${windowId} then`,
|
|
1851
|
+
"-e",
|
|
1852
|
+
"try",
|
|
1853
|
+
"-e",
|
|
1854
|
+
`do script "exit" in window id ${windowId}`,
|
|
1855
|
+
"-e",
|
|
1856
|
+
"end try",
|
|
1857
|
+
"-e",
|
|
1858
|
+
"delay 0.12",
|
|
1859
|
+
"-e",
|
|
1860
|
+
"try",
|
|
1861
|
+
"-e",
|
|
1862
|
+
`close (window id ${windowId})`,
|
|
1863
|
+
"-e",
|
|
1864
|
+
"end try",
|
|
1865
|
+
"-e",
|
|
1866
|
+
"end if",
|
|
1867
|
+
"-e",
|
|
1868
|
+
"end tell",
|
|
1869
|
+
],
|
|
1870
|
+
{ stdio: "ignore" }
|
|
1871
|
+
);
|
|
1872
|
+
} catch {
|
|
1873
|
+
// ignore
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
dismissCloseDialogs();
|
|
1877
|
+
|
|
1878
|
+
if (!windowExists()) {
|
|
1879
|
+
return true;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
return !windowExists();
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function closeMonitorTerminal(sessionId, terminalWindowIds = []) {
|
|
1887
|
+
if (process.platform !== "win32") {
|
|
1888
|
+
const winName = tmuxWindowName(sessionId);
|
|
1889
|
+
try {
|
|
1890
|
+
execFileSync("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${winName}`], {
|
|
1891
|
+
stdio: "ignore",
|
|
1892
|
+
windowsHide: true,
|
|
1893
|
+
});
|
|
1894
|
+
} catch { /* ignore */ }
|
|
1895
|
+
|
|
1896
|
+
try {
|
|
1897
|
+
if (tmuxWindowCount(TMUX_SESSION) === 0) {
|
|
1898
|
+
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], {
|
|
1899
|
+
stdio: "ignore",
|
|
1900
|
+
windowsHide: true,
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
} catch { /* ignore */ }
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
for (const windowId of terminalWindowIds) {
|
|
1907
|
+
closePhysicalTerminal(windowId);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function getSessionWindowIds(state) {
|
|
1912
|
+
if (!state || typeof state !== "object") {
|
|
1913
|
+
return [];
|
|
1914
|
+
}
|
|
1915
|
+
const ids = [];
|
|
1916
|
+
if (Array.isArray(state.monitor_terminal_window_ids)) {
|
|
1917
|
+
for (const id of state.monitor_terminal_window_ids) {
|
|
1918
|
+
if (Number.isInteger(id) && id > 0) {
|
|
1919
|
+
ids.push(id);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (Number.isInteger(state.monitor_terminal_window_id) && state.monitor_terminal_window_id > 0) {
|
|
1924
|
+
ids.push(state.monitor_terminal_window_id);
|
|
1925
|
+
}
|
|
1926
|
+
return [...new Set(ids)];
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function closeAllMonitorTerminals() {
|
|
1930
|
+
try {
|
|
1931
|
+
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], { stdio: "ignore", windowsHide: true });
|
|
1932
|
+
} catch { /* ignore */ }
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function multipleSessionsError() {
|
|
1936
|
+
const active = listActiveSessions();
|
|
1937
|
+
const list = active.map(s => `- **${s.id}**: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
|
|
1938
|
+
return `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function formatRecentLogForPrompt(state, maxEntries = 4) {
|
|
1942
|
+
const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
|
|
1943
|
+
if (entries.length === 0) {
|
|
1944
|
+
return "(아직 이전 응답 없음)";
|
|
1945
|
+
}
|
|
1946
|
+
return entries.map(e => {
|
|
1947
|
+
const content = String(e.content || "").trim();
|
|
1948
|
+
return `- ${e.speaker} (Round ${e.round})\n${content}`;
|
|
1949
|
+
}).join("\n\n");
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
|
|
1953
|
+
const recent = formatRecentLogForPrompt(state, includeHistoryEntries);
|
|
1954
|
+
const extraPrompt = prompt ? `\n[추가 지시]\n${prompt}\n` : "";
|
|
1955
|
+
|
|
1956
|
+
// Role prompt injection
|
|
1957
|
+
const speakerRole = (state.speaker_roles || {})[speaker] || "free";
|
|
1958
|
+
const rolePromptText = loadRolePrompt(speakerRole);
|
|
1959
|
+
const roleSection = rolePromptText
|
|
1960
|
+
? `\n[role]\nrole: ${speakerRole}\n${rolePromptText}\n[/role]\n`
|
|
1961
|
+
: "";
|
|
1962
|
+
|
|
1963
|
+
return `[deliberation_turn_request]
|
|
1964
|
+
session_id: ${state.id}
|
|
1965
|
+
project: ${state.project}
|
|
1966
|
+
topic: ${state.topic}
|
|
1967
|
+
round: ${state.current_round}/${state.max_rounds}
|
|
1968
|
+
target_speaker: ${speaker}
|
|
1969
|
+
required_turn: ${state.current_speaker}${roleSection}
|
|
1970
|
+
|
|
1971
|
+
[recent_log]
|
|
1972
|
+
${recent}
|
|
1973
|
+
[/recent_log]${extraPrompt}
|
|
1974
|
+
|
|
1975
|
+
[response_rule]
|
|
1976
|
+
- 위 토론 맥락을 반영해 ${speaker}의 이번 턴 응답만 작성
|
|
1977
|
+
- 마크다운 본문만 출력 (불필요한 머리말/꼬리말 금지)${speakerRole !== "free" ? `\n- 배정된 역할(${speakerRole})의 관점에서 분석하고 응답` : ""}
|
|
1978
|
+
[/response_rule]
|
|
1979
|
+
[/deliberation_turn_request]
|
|
1980
|
+
`;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason }) {
|
|
1984
|
+
const resolved = resolveSessionId(session_id);
|
|
1985
|
+
if (!resolved) {
|
|
1986
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
1987
|
+
}
|
|
1988
|
+
if (resolved === "MULTIPLE") {
|
|
1989
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
return withSessionLock(resolved, () => {
|
|
1993
|
+
const state = loadSession(resolved);
|
|
1994
|
+
if (!state || state.status !== "active") {
|
|
1995
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const normalizedSpeaker = normalizeSpeaker(speaker);
|
|
1999
|
+
if (!normalizedSpeaker) {
|
|
2000
|
+
return { content: [{ type: "text", text: "speaker 값이 비어 있습니다. 응답자 이름을 지정하세요." }] };
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
state.speakers = buildSpeakerOrder(state.speakers, state.current_speaker, "end");
|
|
2004
|
+
const normalizedCurrentSpeaker = normalizeSpeaker(state.current_speaker);
|
|
2005
|
+
if (!normalizedCurrentSpeaker || !state.speakers.includes(normalizedCurrentSpeaker)) {
|
|
2006
|
+
state.current_speaker = state.speakers[0];
|
|
2007
|
+
} else {
|
|
2008
|
+
state.current_speaker = normalizedCurrentSpeaker;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (state.current_speaker !== normalizedSpeaker) {
|
|
2012
|
+
return {
|
|
2013
|
+
content: [{
|
|
2014
|
+
type: "text",
|
|
2015
|
+
text: `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. ${normalizedSpeaker}는 대기하세요.`,
|
|
2016
|
+
}],
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// turn_id 검증 (선택적 — 제공 시 반드시 일치해야 함)
|
|
2021
|
+
if (turn_id && state.pending_turn_id && turn_id !== state.pending_turn_id) {
|
|
2022
|
+
return {
|
|
2023
|
+
content: [{
|
|
2024
|
+
type: "text",
|
|
2025
|
+
text: `[${state.id}] turn_id 불일치. 예상: "${state.pending_turn_id}", 수신: "${turn_id}". 오래된 요청이거나 중복 제출일 수 있습니다.`,
|
|
2026
|
+
}],
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const votes = parseVotes(content);
|
|
2031
|
+
const suggestedRole = inferSuggestedRole(content);
|
|
2032
|
+
const assignedRole = (state.speaker_roles || {})[normalizedSpeaker] || "free";
|
|
2033
|
+
const roleDrift = assignedRole !== "free" && suggestedRole !== "free" && assignedRole !== suggestedRole;
|
|
2034
|
+
state.log.push({
|
|
2035
|
+
round: state.current_round,
|
|
2036
|
+
speaker: normalizedSpeaker,
|
|
2037
|
+
content,
|
|
2038
|
+
timestamp: new Date().toISOString(),
|
|
2039
|
+
turn_id: state.pending_turn_id || null,
|
|
2040
|
+
channel_used: channel_used || null,
|
|
2041
|
+
fallback_reason: fallback_reason || null,
|
|
2042
|
+
votes: votes.length > 0 ? votes : undefined,
|
|
2043
|
+
suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
|
|
2044
|
+
role_drift: roleDrift || undefined,
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
state.current_speaker = selectNextSpeaker(state);
|
|
2048
|
+
|
|
2049
|
+
// Round transition: check if all speakers have spoken this round
|
|
2050
|
+
const roundEntries = state.log.filter(e => e.round === state.current_round);
|
|
2051
|
+
const spokeSpeakers = new Set(roundEntries.map(e => e.speaker));
|
|
2052
|
+
const allSpoke = state.speakers.every(s => spokeSpeakers.has(s));
|
|
2053
|
+
|
|
2054
|
+
if (allSpoke) {
|
|
2055
|
+
if (state.current_round >= state.max_rounds) {
|
|
2056
|
+
state.status = "awaiting_synthesis";
|
|
2057
|
+
state.current_speaker = "none";
|
|
2058
|
+
saveSession(state);
|
|
2059
|
+
return {
|
|
2060
|
+
content: [{
|
|
2061
|
+
type: "text",
|
|
2062
|
+
text: `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료.\n\n🏁 **모든 라운드 종료!**\ndeliberation_synthesize(session_id: "${state.id}")로 합성 보고서를 작성하세요.`,
|
|
2063
|
+
}],
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
state.current_round += 1;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
if (state.status === "active") {
|
|
2070
|
+
state.pending_turn_id = generateTurnId();
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
saveSession(state);
|
|
2074
|
+
return {
|
|
2075
|
+
content: [{
|
|
2076
|
+
type: "text",
|
|
2077
|
+
text: `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료.\n\n**다음:** ${state.current_speaker} (Round ${state.current_round})`,
|
|
2078
|
+
}],
|
|
2079
|
+
};
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// ── MCP Server ─────────────────────────────────────────────────
|
|
2084
|
+
|
|
2085
|
+
process.on("uncaughtException", (error) => {
|
|
2086
|
+
const message = formatRuntimeError(error);
|
|
2087
|
+
appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
|
|
2088
|
+
try {
|
|
2089
|
+
process.stderr.write(`[mcp-deliberation] uncaughtException: ${message}\n`);
|
|
2090
|
+
} catch {
|
|
2091
|
+
// ignore stderr write failures
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
process.on("unhandledRejection", (reason) => {
|
|
2096
|
+
const message = formatRuntimeError(reason);
|
|
2097
|
+
appendRuntimeLog("UNHANDLED_REJECTION", message);
|
|
2098
|
+
try {
|
|
2099
|
+
process.stderr.write(`[mcp-deliberation] unhandledRejection: ${message}\n`);
|
|
2100
|
+
} catch {
|
|
2101
|
+
// ignore stderr write failures
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
const server = new McpServer({
|
|
2106
|
+
name: "mcp-deliberation",
|
|
2107
|
+
version: "2.4.0",
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
server.tool(
|
|
2111
|
+
"deliberation_start",
|
|
2112
|
+
"새 deliberation을 시작합니다. 여러 토론을 동시에 진행할 수 있습니다.",
|
|
2113
|
+
{
|
|
2114
|
+
topic: z.string().describe("토론 주제"),
|
|
2115
|
+
rounds: z.coerce.number().optional().describe("라운드 수 (미지정 시 config 설정 따름, 기본 3)"),
|
|
2116
|
+
first_speaker: z.string().trim().min(1).max(64).optional().describe("첫 발언자 이름 (미지정 시 speakers의 첫 항목)"),
|
|
2117
|
+
speakers: z.preprocess(
|
|
2118
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
2119
|
+
z.array(z.string().trim().min(1).max(64)).min(1).optional()
|
|
2120
|
+
).describe("참가자 이름 목록 (예: codex, claude, web-chatgpt-1)"),
|
|
2121
|
+
require_manual_speakers: z.preprocess(
|
|
2122
|
+
(v) => (typeof v === "string" ? v === "true" : v),
|
|
2123
|
+
z.boolean().optional()
|
|
2124
|
+
).describe("true면 speakers를 반드시 직접 지정해야 시작 (미지정 시 config 설정 따름)"),
|
|
2125
|
+
auto_discover_speakers: z.preprocess(
|
|
2126
|
+
(v) => (typeof v === "string" ? v === "true" : v),
|
|
2127
|
+
z.boolean().optional()
|
|
2128
|
+
).describe("speakers 생략 시 자동 탐색 여부 (미지정 시 config 설정 따름)"),
|
|
2129
|
+
participant_types: z.preprocess(
|
|
2130
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
2131
|
+
z.record(z.string(), z.enum(["cli", "browser", "browser_auto", "manual"])).optional()
|
|
2132
|
+
).describe("speaker별 타입 오버라이드 (예: {\"chatgpt\": \"browser_auto\"})"),
|
|
2133
|
+
ordering_strategy: z.enum(["auto", "cyclic", "random", "weighted-random"]).optional()
|
|
2134
|
+
.describe("발언 순서 전략: auto(스피커 수에 따라 자동), cyclic(순서대로), random(매턴 무작위), weighted-random(덜 말한 사람 우선)"),
|
|
2135
|
+
speaker_roles: z.preprocess(
|
|
2136
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
2137
|
+
z.record(z.string(), z.enum(["critic", "implementer", "mediator", "researcher", "free"])).optional()
|
|
2138
|
+
).describe("speaker별 역할 배정 (예: {\"claude\": \"critic\", \"codex\": \"implementer\"})"),
|
|
2139
|
+
role_preset: z.enum(["balanced", "debate", "research", "brainstorm", "review", "consensus"]).optional()
|
|
2140
|
+
.describe("역할 프리셋 (balanced/debate/research/brainstorm/review/consensus). speaker_roles가 명시되면 무시됨"),
|
|
2141
|
+
},
|
|
2142
|
+
safeToolHandler("deliberation_start", async ({ topic, rounds, first_speaker, speakers, require_manual_speakers, auto_discover_speakers, participant_types, ordering_strategy, speaker_roles, role_preset }) => {
|
|
2143
|
+
// ── First-time onboarding guard ──
|
|
2144
|
+
const config = loadDeliberationConfig();
|
|
2145
|
+
if (!config.setup_complete) {
|
|
2146
|
+
const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
|
|
2147
|
+
const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
|
|
2148
|
+
return {
|
|
2149
|
+
content: [{
|
|
2150
|
+
type: "text",
|
|
2151
|
+
text: `🎉 **Deliberation 첫 사용을 환영합니다!**\n\n시작 전에 기본 설정을 해주세요.\n\n**현재 감지된 스피커:**\n${candidateText}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n아래 설정을 한 번에 지정할 수 있습니다:\n\n\`\`\`\ndeliberation_cli_config(\n require_speaker_selection: true/false,\n default_rounds: 3,\n default_ordering: "auto"\n)\n\`\`\`\n\n**1. 스피커 참여 모드** (\`require_speaker_selection\`)\n - \`true\` — 매번 참여할 스피커를 직접 선택\n - \`false\` — 감지된 CLI + 브라우저 LLM 전부 자동 참여\n\n**2. 기본 라운드 수** (\`default_rounds\`)\n - \`1\` — 빠른 의견 수렴\n - \`3\` — 기본 (권장)\n - \`5\` — 심층 토론\n\n**3. 발언 순서 전략** (\`default_ordering\`)\n - \`"auto"\` — 2명이면 cyclic, 3명 이상이면 weighted-random (권장)\n - \`"cyclic"\` — 고정 순서\n - \`"random"\` — 매턴 무작위\n - \`"weighted-random"\` — 덜 발언한 사람 우선`,
|
|
2152
|
+
}],
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
const sessionId = generateSessionId(topic);
|
|
2157
|
+
const hasManualSpeakers = Array.isArray(speakers) && speakers.length > 0;
|
|
2158
|
+
const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
|
|
2159
|
+
|
|
2160
|
+
// Resolve effective settings from config
|
|
2161
|
+
const effectiveRequireManual = require_manual_speakers ?? config.require_speaker_selection ?? true;
|
|
2162
|
+
const effectiveAutoDiscover = auto_discover_speakers ?? !effectiveRequireManual;
|
|
2163
|
+
rounds = rounds ?? config.default_rounds ?? 3;
|
|
2164
|
+
const rawOrdering = ordering_strategy ?? config.default_ordering ?? "auto";
|
|
2165
|
+
// Resolve "auto": 2 speakers → cyclic, 3+ → weighted-random
|
|
2166
|
+
ordering_strategy = rawOrdering === "auto" ? undefined : rawOrdering; // resolved after speakers are known
|
|
2167
|
+
|
|
2168
|
+
if (!hasManualSpeakers && effectiveRequireManual) {
|
|
2169
|
+
const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
|
|
2170
|
+
return {
|
|
2171
|
+
content: [{
|
|
2172
|
+
type: "text",
|
|
2173
|
+
text: `스피커를 직접 선택해야 deliberation을 시작할 수 있습니다.\n\n${candidateText}\n\n예시:\n\ndeliberation_start(\n topic: "${topic.replace(/"/g, '\\"')}",\n rounds: ${rounds},\n speakers: ["codex", "web-claude-1", "web-chatgpt-1"],\n first_speaker: "codex"\n)\n\n먼저 deliberation_speaker_candidates를 호출해 현재 선택 가능한 스피커를 확인하세요.`,
|
|
2174
|
+
}],
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
let autoDiscoveredSpeakers = [];
|
|
2179
|
+
let autoParticipantTypes = {};
|
|
2180
|
+
if (!hasManualSpeakers && effectiveAutoDiscover) {
|
|
2181
|
+
// Include ALL candidates: CLI + browser
|
|
2182
|
+
for (const c of candidateSnapshot.candidates) {
|
|
2183
|
+
autoDiscoveredSpeakers.push(c.speaker);
|
|
2184
|
+
if (c.type === "browser" && c.cdp_available) {
|
|
2185
|
+
autoParticipantTypes[c.speaker] = "browser_auto";
|
|
2186
|
+
} else if (c.type === "browser") {
|
|
2187
|
+
autoParticipantTypes[c.speaker] = "browser";
|
|
2188
|
+
} else {
|
|
2189
|
+
autoParticipantTypes[c.speaker] = "cli";
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
// Merge auto-detected participant_types with manual overrides
|
|
2194
|
+
if (!hasManualSpeakers && Object.keys(autoParticipantTypes).length > 0) {
|
|
2195
|
+
participant_types = { ...autoParticipantTypes, ...(participant_types || {}) };
|
|
2196
|
+
}
|
|
2197
|
+
const selectedSpeakers = dedupeSpeakers(hasManualSpeakers
|
|
2198
|
+
? speakers
|
|
2199
|
+
: autoDiscoveredSpeakers);
|
|
2200
|
+
const callerSpeaker = (!hasManualSpeakers && !first_speaker)
|
|
2201
|
+
? detectCallerSpeaker()
|
|
2202
|
+
: null;
|
|
2203
|
+
|
|
2204
|
+
const normalizedFirstSpeaker = normalizeSpeaker(first_speaker)
|
|
2205
|
+
|| normalizeSpeaker(hasManualSpeakers ? selectedSpeakers?.[0] : callerSpeaker)
|
|
2206
|
+
|| normalizeSpeaker(selectedSpeakers?.[0])
|
|
2207
|
+
|| DEFAULT_SPEAKERS[0];
|
|
2208
|
+
const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
|
|
2209
|
+
const participantMode = hasManualSpeakers
|
|
2210
|
+
? "수동 지정"
|
|
2211
|
+
: (autoDiscoveredSpeakers.length > 0 ? "자동 탐색(PATH)" : "기본값");
|
|
2212
|
+
|
|
2213
|
+
const degradationLevels = await detectDegradationLevels();
|
|
2214
|
+
|
|
2215
|
+
const state = {
|
|
2216
|
+
id: sessionId,
|
|
2217
|
+
project: getProjectSlug(),
|
|
2218
|
+
topic,
|
|
2219
|
+
status: "active",
|
|
2220
|
+
max_rounds: rounds,
|
|
2221
|
+
current_round: 1,
|
|
2222
|
+
current_speaker: normalizedFirstSpeaker,
|
|
2223
|
+
speakers: speakerOrder,
|
|
2224
|
+
participant_profiles: mapParticipantProfiles(speakerOrder, candidateSnapshot.candidates, participant_types),
|
|
2225
|
+
log: [],
|
|
2226
|
+
synthesis: null,
|
|
2227
|
+
pending_turn_id: generateTurnId(),
|
|
2228
|
+
monitor_terminal_window_ids: [],
|
|
2229
|
+
ordering_strategy: ordering_strategy || (speakerOrder.length <= 2 ? "cyclic" : "weighted-random"),
|
|
2230
|
+
speaker_roles: speaker_roles || (role_preset ? applyRolePreset(role_preset, speakerOrder) : {}),
|
|
2231
|
+
degradation: degradationLevels,
|
|
2232
|
+
created: new Date().toISOString(),
|
|
2233
|
+
updated: new Date().toISOString(),
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
// Ensure CDP is ready if any speaker requires browser transport
|
|
2237
|
+
const hasBrowserSpeaker = state.participant_profiles.some(
|
|
2238
|
+
p => p.type === "browser" || p.type === "browser_auto"
|
|
2239
|
+
);
|
|
2240
|
+
if (hasBrowserSpeaker) {
|
|
2241
|
+
const cdpReady = await ensureCdpAvailable();
|
|
2242
|
+
if (!cdpReady.available) {
|
|
2243
|
+
return {
|
|
2244
|
+
content: [{
|
|
2245
|
+
type: "text",
|
|
2246
|
+
text: `❌ 브라우저 LLM speaker가 포함되어 있지만 CDP에 연결할 수 없습니다.\n\n${cdpReady.reason}\n\nCDP 연결 후 다시 deliberation_start를 호출하세요.`,
|
|
2247
|
+
}],
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
withSessionLock(sessionId, () => {
|
|
2253
|
+
saveSession(state);
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
const active = listActiveSessions();
|
|
2257
|
+
const tmuxOpened = spawnMonitorTerminal(sessionId);
|
|
2258
|
+
const terminalOpenResult = tmuxOpened
|
|
2259
|
+
? openPhysicalTerminal(sessionId)
|
|
2260
|
+
: { opened: false, windowIds: [] };
|
|
2261
|
+
const terminalWindowIds = Array.isArray(terminalOpenResult.windowIds)
|
|
2262
|
+
? terminalOpenResult.windowIds
|
|
2263
|
+
: [];
|
|
2264
|
+
const physicalOpened = terminalOpenResult.opened === true;
|
|
2265
|
+
if (terminalWindowIds.length > 0) {
|
|
2266
|
+
withSessionLock(sessionId, () => {
|
|
2267
|
+
const latest = loadSession(sessionId);
|
|
2268
|
+
if (!latest) return;
|
|
2269
|
+
latest.monitor_terminal_window_ids = terminalWindowIds;
|
|
2270
|
+
saveSession(latest);
|
|
2271
|
+
});
|
|
2272
|
+
state.monitor_terminal_window_ids = terminalWindowIds;
|
|
2273
|
+
}
|
|
2274
|
+
const isWin = process.platform === "win32";
|
|
2275
|
+
const terminalMsg = !tmuxOpened
|
|
2276
|
+
? isWin
|
|
2277
|
+
? `\n⚠️ Windows Terminal을 찾을 수 없어 모니터 터미널 미생성`
|
|
2278
|
+
: `\n⚠️ tmux를 찾을 수 없어 모니터 터미널 미생성`
|
|
2279
|
+
: physicalOpened
|
|
2280
|
+
? isWin
|
|
2281
|
+
? `\n🖥️ 모니터 터미널 오픈됨 (Windows Terminal)`
|
|
2282
|
+
: `\n🖥️ 모니터 터미널 오픈됨: tmux attach -t ${TMUX_SESSION}`
|
|
2283
|
+
: isWin
|
|
2284
|
+
? `\n⚠️ 모니터 터미널 자동 오픈 실패`
|
|
2285
|
+
: `\n⚠️ tmux 윈도우는 생성됐지만 외부 터미널 자동 오픈 실패. 수동 실행: tmux attach -t ${TMUX_SESSION}`;
|
|
2286
|
+
const manualNotDetected = hasManualSpeakers
|
|
2287
|
+
? speakerOrder.filter(s => !candidateSnapshot.candidates.some(c => c.speaker === s))
|
|
2288
|
+
: [];
|
|
2289
|
+
const detectWarning = manualNotDetected.length > 0
|
|
2290
|
+
? `\n\n⚠️ 현재 환경에서 즉시 검출되지 않은 speaker: ${manualNotDetected.join(", ")}\n(수동 지정으로는 참가 가능)`
|
|
2291
|
+
: "";
|
|
2292
|
+
|
|
2293
|
+
const transportSummary = state.participant_profiles.map(p => {
|
|
2294
|
+
const { transport } = resolveTransportForSpeaker(state, p.speaker);
|
|
2295
|
+
return ` - \`${p.speaker}\`: ${transport} (${p.type})`;
|
|
2296
|
+
}).join("\n");
|
|
2297
|
+
|
|
2298
|
+
return {
|
|
2299
|
+
content: [{
|
|
2300
|
+
type: "text",
|
|
2301
|
+
text: `✅ Deliberation 시작!\n\n**세션:** ${sessionId}\n**프로젝트:** ${state.project}\n**주제:** ${topic}\n**라운드:** ${rounds}\n**발언 순서:** ${state.ordering_strategy || "cyclic"}\n**참가자 구성:** ${participantMode}\n**참가자:** ${speakerOrder.join(", ")}\n**첫 발언:** ${state.current_speaker}\n**동시 진행 세션:** ${active.length}개${terminalMsg}${detectWarning}\n\n**역할 배정:**${role_preset ? ` (프리셋: ${role_preset})` : ""}\n${speakerOrder.map(s => ` - \`${s}\`: ${(state.speaker_roles || {})[s] || "free"}`).join("\n")}\n\n**환경 상태:**\n${formatDegradationReport(state.degradation)}\n\n**Transport 라우팅:**\n${transportSummary}\n\n💡 이후 도구 호출 시 session_id: "${sessionId}" 를 사용하세요.`,
|
|
2302
|
+
}],
|
|
2303
|
+
};
|
|
2304
|
+
})
|
|
2305
|
+
);
|
|
2306
|
+
|
|
2307
|
+
server.tool(
|
|
2308
|
+
"deliberation_speaker_candidates",
|
|
2309
|
+
"사용자가 선택 가능한 스피커 후보(로컬 CLI + 브라우저 LLM 탭)를 조회합니다.",
|
|
2310
|
+
{
|
|
2311
|
+
include_cli: z.boolean().default(true).describe("로컬 CLI 후보 포함"),
|
|
2312
|
+
include_browser: z.boolean().default(true).describe("브라우저 LLM 탭 후보 포함"),
|
|
2313
|
+
},
|
|
2314
|
+
async ({ include_cli, include_browser }) => {
|
|
2315
|
+
const snapshot = await collectSpeakerCandidates({ include_cli, include_browser });
|
|
2316
|
+
const text = formatSpeakerCandidatesReport(snapshot);
|
|
2317
|
+
return { content: [{ type: "text", text: `${text}\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
2318
|
+
}
|
|
2319
|
+
);
|
|
2320
|
+
|
|
2321
|
+
server.tool(
|
|
2322
|
+
"deliberation_list_active",
|
|
2323
|
+
"현재 프로젝트에서 진행 중인 모든 deliberation 세션 목록을 반환합니다.",
|
|
2324
|
+
{},
|
|
2325
|
+
async () => {
|
|
2326
|
+
const active = listActiveSessions();
|
|
2327
|
+
if (active.length === 0) {
|
|
2328
|
+
return { content: [{ type: "text", text: "진행 중인 deliberation이 없습니다." }] };
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
let list = `## 진행 중인 Deliberation (${getProjectSlug()}) — ${active.length}개\n\n`;
|
|
2332
|
+
for (const s of active) {
|
|
2333
|
+
list += `### ${s.id}\n- **주제:** ${s.topic}\n- **상태:** ${s.status} | Round ${s.current_round}/${s.max_rounds} | Next: ${s.current_speaker}\n- **응답 수:** ${s.log.length}\n\n`;
|
|
2334
|
+
}
|
|
2335
|
+
return { content: [{ type: "text", text: list }] };
|
|
2336
|
+
}
|
|
2337
|
+
);
|
|
2338
|
+
|
|
2339
|
+
server.tool(
|
|
2340
|
+
"deliberation_status",
|
|
2341
|
+
"deliberation 상태를 조회합니다. 활성 세션이 1개면 자동 선택, 여러 개면 session_id 필요.",
|
|
2342
|
+
{
|
|
2343
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2344
|
+
},
|
|
2345
|
+
async ({ session_id }) => {
|
|
2346
|
+
const resolved = resolveSessionId(session_id);
|
|
2347
|
+
if (!resolved) {
|
|
2348
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다. deliberation_start로 시작하세요." }] };
|
|
2349
|
+
}
|
|
2350
|
+
if (resolved === "MULTIPLE") {
|
|
2351
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
const state = loadSession(resolved);
|
|
2355
|
+
if (!state) {
|
|
2356
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"을 찾을 수 없습니다.` }] };
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
return {
|
|
2360
|
+
content: [{
|
|
2361
|
+
type: "text",
|
|
2362
|
+
text: `**세션:** ${state.id}\n**프로젝트:** ${state.project}\n**주제:** ${state.topic}\n**상태:** ${state.status}\n**라운드:** ${state.current_round}/${state.max_rounds}\n**참가자:** ${state.speakers.join(", ")}\n**현재 차례:** ${state.current_speaker}\n**응답 수:** ${state.log.length}${state.degradation ? `\n\n**환경 상태:**\n${formatDegradationReport(state.degradation)}` : ""}`,
|
|
2363
|
+
}],
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
);
|
|
2367
|
+
|
|
2368
|
+
server.tool(
|
|
2369
|
+
"deliberation_context",
|
|
2370
|
+
"현재 프로젝트의 컨텍스트(md 파일들)를 로드합니다. CWD + Obsidian 자동 감지.",
|
|
2371
|
+
{},
|
|
2372
|
+
async () => {
|
|
2373
|
+
const dirs = detectContextDirs();
|
|
2374
|
+
const context = readContextFromDirs(dirs);
|
|
2375
|
+
return {
|
|
2376
|
+
content: [{
|
|
2377
|
+
type: "text",
|
|
2378
|
+
text: `## 프로젝트 컨텍스트 (${getProjectSlug()})\n\n**소스:** ${dirs.join(", ")}\n\n${context}`,
|
|
2379
|
+
}],
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
);
|
|
2383
|
+
|
|
2384
|
+
server.tool(
|
|
2385
|
+
"deliberation_browser_llm_tabs",
|
|
2386
|
+
"현재 브라우저에서 열려 있는 LLM 탭(chatgpt/claude/gemini 등)을 조회합니다.",
|
|
2387
|
+
{},
|
|
2388
|
+
async () => {
|
|
2389
|
+
const { tabs, note } = await collectBrowserLlmTabs();
|
|
2390
|
+
if (tabs.length === 0) {
|
|
2391
|
+
const suffix = note ? `\n\n${note}` : "";
|
|
2392
|
+
return { content: [{ type: "text", text: `감지된 LLM 탭이 없습니다.${suffix}` }] };
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const lines = tabs.map((t, i) => `${i + 1}. [${t.browser}] ${t.title}\n ${t.url}`).join("\n");
|
|
2396
|
+
const noteLine = note ? `\n\nℹ️ ${note}` : "";
|
|
2397
|
+
return { content: [{ type: "text", text: `## Browser LLM Tabs\n\n${lines}${noteLine}\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
2398
|
+
}
|
|
2399
|
+
);
|
|
2400
|
+
|
|
2401
|
+
server.tool(
|
|
2402
|
+
"deliberation_route_turn",
|
|
2403
|
+
"현재 턴의 speaker에 맞는 transport를 자동 결정하고 안내합니다. CLI speaker는 자동 응답 경로, 브라우저 speaker는 클립보드 경로로 라우팅합니다.",
|
|
2404
|
+
{
|
|
2405
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2406
|
+
auto_prepare_clipboard: z.boolean().default(true).describe("브라우저 speaker일 때 자동으로 클립보드 prepare 실행"),
|
|
2407
|
+
prompt: z.string().optional().describe("브라우저 LLM에 추가로 전달할 지시"),
|
|
2408
|
+
include_history_entries: z.number().int().min(0).max(12).default(4).describe("프롬프트에 포함할 최근 로그 개수"),
|
|
2409
|
+
},
|
|
2410
|
+
safeToolHandler("deliberation_route_turn", async ({ session_id, auto_prepare_clipboard, prompt, include_history_entries }) => {
|
|
2411
|
+
const resolved = resolveSessionId(session_id);
|
|
2412
|
+
if (!resolved) {
|
|
2413
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2414
|
+
}
|
|
2415
|
+
if (resolved === "MULTIPLE") {
|
|
2416
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const state = loadSession(resolved);
|
|
2420
|
+
if (!state || state.status !== "active") {
|
|
2421
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const speaker = state.current_speaker;
|
|
2425
|
+
const { transport, profile, reason } = resolveTransportForSpeaker(state, speaker);
|
|
2426
|
+
const guidance = formatTransportGuidance(transport, state, speaker);
|
|
2427
|
+
const turnId = state.pending_turn_id || null;
|
|
2428
|
+
|
|
2429
|
+
let extra = "";
|
|
2430
|
+
|
|
2431
|
+
if (transport === "browser_auto") {
|
|
2432
|
+
// Auto-execute browser_auto_turn
|
|
2433
|
+
try {
|
|
2434
|
+
const port = getBrowserPort();
|
|
2435
|
+
const sessionId = state.id;
|
|
2436
|
+
const turnSpeaker = speaker;
|
|
2437
|
+
const turnProvider = profile?.provider || "chatgpt";
|
|
2438
|
+
|
|
2439
|
+
// Build prompt
|
|
2440
|
+
const turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
|
|
2441
|
+
|
|
2442
|
+
// Attach
|
|
2443
|
+
const attachResult = await port.attach(sessionId, { provider: turnProvider, url: profile?.url });
|
|
2444
|
+
if (!attachResult.ok) throw new Error(`attach failed: ${attachResult.error?.message}`);
|
|
2445
|
+
|
|
2446
|
+
// Send turn
|
|
2447
|
+
const autoTurnId = turnId || `auto-${Date.now()}`;
|
|
2448
|
+
const sendResult = await port.sendTurnWithDegradation(sessionId, autoTurnId, turnPrompt);
|
|
2449
|
+
if (!sendResult.ok) throw new Error(`send failed: ${sendResult.error?.message}`);
|
|
2450
|
+
|
|
2451
|
+
// Wait for response
|
|
2452
|
+
const waitResult = await port.waitTurnResult(sessionId, autoTurnId, 45);
|
|
2453
|
+
const degradationState = port.getDegradationState(sessionId);
|
|
2454
|
+
await port.detach(sessionId);
|
|
2455
|
+
|
|
2456
|
+
if (waitResult.ok && waitResult.data?.response) {
|
|
2457
|
+
// Auto-submit the response
|
|
2458
|
+
submitDeliberationTurn({
|
|
2459
|
+
session_id: sessionId,
|
|
2460
|
+
speaker: turnSpeaker,
|
|
2461
|
+
content: waitResult.data.response,
|
|
2462
|
+
turn_id: state.pending_turn_id || generateTurnId(),
|
|
2463
|
+
channel_used: "browser_auto",
|
|
2464
|
+
fallback_reason: null,
|
|
2465
|
+
});
|
|
2466
|
+
extra = `\n\n⚡ 자동 실행 완료! 브라우저 LLM 응답이 자동으로 제출되었습니다. (${waitResult.data.elapsedMs}ms)`;
|
|
2467
|
+
} else {
|
|
2468
|
+
throw new Error(waitResult.error?.message || "no response received");
|
|
2469
|
+
}
|
|
2470
|
+
} catch (autoErr) {
|
|
2471
|
+
const errMsg = autoErr instanceof Error ? autoErr.message : String(autoErr);
|
|
2472
|
+
extra = `\n\n⚠️ 자동 실행 실패 (${errMsg}). Chrome을 --remote-debugging-port=9222로 재시작하세요.`;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
const profileInfo = profile
|
|
2477
|
+
? `\n**프로필:** ${profile.type}${profile.url ? ` | ${profile.url}` : ""}${profile.command ? ` | command: ${profile.command}` : ""}`
|
|
2478
|
+
: "";
|
|
2479
|
+
|
|
2480
|
+
return {
|
|
2481
|
+
content: [{
|
|
2482
|
+
type: "text",
|
|
2483
|
+
text: `## 턴 라우팅 — ${state.id}\n\n**현재 speaker:** ${speaker}\n**Transport:** ${transport}${reason ? ` (fallback: ${reason})` : ""}${profileInfo}\n**역할:** ${(state.speaker_roles || {})[speaker] || "free"}\n**Turn ID:** ${turnId || "(없음)"}\n**라운드:** ${state.current_round}/${state.max_rounds}\n**발언 순서:** ${state.ordering_strategy || "cyclic"}\n\n${guidance}${extra}\n\n${PRODUCT_DISCLAIMER}`,
|
|
2484
|
+
}],
|
|
2485
|
+
};
|
|
2486
|
+
})
|
|
2487
|
+
);
|
|
2488
|
+
|
|
2489
|
+
server.tool(
|
|
2490
|
+
"deliberation_browser_auto_turn",
|
|
2491
|
+
"브라우저 LLM에 자동으로 턴을 전송하고 응답을 수집합니다 (CDP 기반).",
|
|
2492
|
+
{
|
|
2493
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2494
|
+
provider: z.string().optional().default("chatgpt").describe("LLM 프로바이더 (chatgpt, claude, gemini)"),
|
|
2495
|
+
timeout_sec: z.number().optional().default(45).describe("응답 대기 타임아웃 (초)"),
|
|
2496
|
+
},
|
|
2497
|
+
safeToolHandler("deliberation_browser_auto_turn", async ({ session_id, provider, timeout_sec }) => {
|
|
2498
|
+
const resolved = resolveSessionId(session_id);
|
|
2499
|
+
if (!resolved) {
|
|
2500
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2501
|
+
}
|
|
2502
|
+
if (resolved === "MULTIPLE") {
|
|
2503
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
const state = loadSession(resolved);
|
|
2507
|
+
if (!state || state.status !== "active") {
|
|
2508
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
const speaker = state.current_speaker;
|
|
2512
|
+
if (speaker === "none") {
|
|
2513
|
+
return { content: [{ type: "text", text: "현재 발언 차례인 speaker가 없습니다." }] };
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
2517
|
+
if (transport !== "browser_auto" && transport !== "clipboard") {
|
|
2518
|
+
return { content: [{ type: "text", text: `speaker "${speaker}"는 브라우저 타입이 아닙니다 (transport: ${transport}). CLI speaker는 deliberation_respond를 사용하세요.` }] };
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
const turnId = state.pending_turn_id || generateTurnId();
|
|
2522
|
+
const port = getBrowserPort();
|
|
2523
|
+
|
|
2524
|
+
// Step 1: Attach
|
|
2525
|
+
const attachResult = await port.attach(resolved, { provider });
|
|
2526
|
+
if (!attachResult.ok) {
|
|
2527
|
+
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}` }] };
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Step 2: Build turn prompt
|
|
2531
|
+
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
2532
|
+
|
|
2533
|
+
// Step 3: Send turn with degradation
|
|
2534
|
+
const sendResult = await port.sendTurnWithDegradation(resolved, turnId, turnPrompt);
|
|
2535
|
+
if (!sendResult.ok) {
|
|
2536
|
+
// Fallback to clipboard
|
|
2537
|
+
return submitDeliberationTurn({
|
|
2538
|
+
session_id: resolved,
|
|
2539
|
+
speaker,
|
|
2540
|
+
content: `[browser_auto 실패 — fallback] ${sendResult.error.message}`,
|
|
2541
|
+
turn_id: turnId,
|
|
2542
|
+
channel_used: "browser_auto_fallback",
|
|
2543
|
+
fallback_reason: sendResult.error.code,
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Step 4: Wait for response
|
|
2548
|
+
const waitResult = await port.waitTurnResult(resolved, turnId, timeout_sec);
|
|
2549
|
+
if (!waitResult.ok) {
|
|
2550
|
+
return { content: [{ type: "text", text: `⏱️ 브라우저 LLM 응답 대기 타임아웃 (${timeout_sec}초)\n\n**에러:** ${waitResult.error.message}\n\n자동 실행이 타임아웃되었습니다. Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요.\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// Step 5: Submit the response
|
|
2554
|
+
const response = waitResult.data.response;
|
|
2555
|
+
const result = submitDeliberationTurn({
|
|
2556
|
+
session_id: resolved,
|
|
2557
|
+
speaker,
|
|
2558
|
+
content: response,
|
|
2559
|
+
turn_id: turnId,
|
|
2560
|
+
channel_used: "browser_auto",
|
|
2561
|
+
fallback_reason: null,
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
// Step 6: Capture degradation state before detach
|
|
2565
|
+
const degradationState = port.getDegradationState(resolved);
|
|
2566
|
+
|
|
2567
|
+
await port.detach(resolved);
|
|
2568
|
+
const degradationInfo = degradationState
|
|
2569
|
+
? `\n**Degradation:** ${JSON.stringify(degradationState)}`
|
|
2570
|
+
: "";
|
|
2571
|
+
|
|
2572
|
+
return {
|
|
2573
|
+
content: [{
|
|
2574
|
+
type: "text",
|
|
2575
|
+
text: `✅ 브라우저 자동 턴 완료!\n\n**Provider:** ${provider}\n**Turn ID:** ${turnId}\n**응답 길이:** ${response.length}자\n**소요 시간:** ${waitResult.data.elapsedMs}ms${degradationInfo}\n\n${result.content[0].text}`,
|
|
2576
|
+
}],
|
|
2577
|
+
};
|
|
2578
|
+
})
|
|
2579
|
+
);
|
|
2580
|
+
|
|
2581
|
+
server.tool(
|
|
2582
|
+
"deliberation_respond",
|
|
2583
|
+
"현재 턴의 응답을 제출합니다.",
|
|
2584
|
+
{
|
|
2585
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2586
|
+
speaker: z.string().trim().min(1).max(64).describe("응답자 이름"),
|
|
2587
|
+
content: z.string().describe("응답 내용 (마크다운)"),
|
|
2588
|
+
turn_id: z.string().optional().describe("턴 검증 ID (deliberation_route_turn에서 받은 값)"),
|
|
2589
|
+
},
|
|
2590
|
+
safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, turn_id }) => {
|
|
2591
|
+
return submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used: "cli_respond" });
|
|
2592
|
+
})
|
|
2593
|
+
);
|
|
2594
|
+
|
|
2595
|
+
server.tool(
|
|
2596
|
+
"deliberation_history",
|
|
2597
|
+
"토론 기록을 반환합니다.",
|
|
2598
|
+
{
|
|
2599
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2600
|
+
},
|
|
2601
|
+
async ({ session_id }) => {
|
|
2602
|
+
const resolved = resolveSessionId(session_id);
|
|
2603
|
+
if (!resolved) {
|
|
2604
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2605
|
+
}
|
|
2606
|
+
if (resolved === "MULTIPLE") {
|
|
2607
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
const state = loadSession(resolved);
|
|
2611
|
+
if (!state) {
|
|
2612
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"을 찾을 수 없습니다.` }] };
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
if (state.log.length === 0) {
|
|
2616
|
+
return {
|
|
2617
|
+
content: [{
|
|
2618
|
+
type: "text",
|
|
2619
|
+
text: `**세션:** ${state.id}\n**주제:** ${state.topic}\n\n아직 응답이 없습니다. **${state.current_speaker}**가 먼저 응답하세요.`,
|
|
2620
|
+
}],
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
let history = `**세션:** ${state.id}\n**주제:** ${state.topic} | **상태:** ${state.status}\n\n`;
|
|
2625
|
+
for (const e of state.log) {
|
|
2626
|
+
history += `### ${e.speaker} — Round ${e.round}\n\n${e.content}\n\n---\n\n`;
|
|
2627
|
+
}
|
|
2628
|
+
return { content: [{ type: "text", text: history }] };
|
|
2629
|
+
}
|
|
2630
|
+
);
|
|
2631
|
+
|
|
2632
|
+
server.tool(
|
|
2633
|
+
"deliberation_synthesize",
|
|
2634
|
+
"토론을 종료하고 합성 보고서를 제출합니다.",
|
|
2635
|
+
{
|
|
2636
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2637
|
+
synthesis: z.string().describe("합성 보고서 (마크다운)"),
|
|
2638
|
+
},
|
|
2639
|
+
safeToolHandler("deliberation_synthesize", async ({ session_id, synthesis }) => {
|
|
2640
|
+
const resolved = resolveSessionId(session_id);
|
|
2641
|
+
if (!resolved) {
|
|
2642
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2643
|
+
}
|
|
2644
|
+
if (resolved === "MULTIPLE") {
|
|
2645
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
let state = null;
|
|
2649
|
+
let archivePath = null;
|
|
2650
|
+
const lockedResult = withSessionLock(resolved, () => {
|
|
2651
|
+
const loaded = loadSession(resolved);
|
|
2652
|
+
if (!loaded) {
|
|
2653
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"을 찾을 수 없습니다.` }] };
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
loaded.synthesis = synthesis;
|
|
2657
|
+
loaded.status = "completed";
|
|
2658
|
+
loaded.current_speaker = "none";
|
|
2659
|
+
saveSession(loaded);
|
|
2660
|
+
archivePath = archiveState(loaded);
|
|
2661
|
+
cleanupSyncMarkdown(loaded);
|
|
2662
|
+
state = loaded;
|
|
2663
|
+
return null;
|
|
2664
|
+
});
|
|
2665
|
+
if (lockedResult) {
|
|
2666
|
+
return lockedResult;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// 토론 종료 즉시 모니터 터미널(물리 Terminal 포함) 강제 종료
|
|
2670
|
+
closeMonitorTerminal(state.id, getSessionWindowIds(state));
|
|
2671
|
+
|
|
2672
|
+
return {
|
|
2673
|
+
content: [{
|
|
2674
|
+
type: "text",
|
|
2675
|
+
text: `✅ [${state.id}] Deliberation 완료!\n\n**프로젝트:** ${state.project}\n**주제:** ${state.topic}\n**라운드:** ${state.max_rounds}\n**응답:** ${state.log.length}건\n\n📁 ${archivePath}\n🖥️ 모니터 터미널이 즉시 강제 종료되었습니다.`,
|
|
2676
|
+
}],
|
|
2677
|
+
};
|
|
2678
|
+
})
|
|
2679
|
+
);
|
|
2680
|
+
|
|
2681
|
+
server.tool(
|
|
2682
|
+
"deliberation_list",
|
|
2683
|
+
"과거 deliberation 아카이브 목록을 반환합니다.",
|
|
2684
|
+
{},
|
|
2685
|
+
async () => {
|
|
2686
|
+
ensureDirs();
|
|
2687
|
+
const archiveDir = getArchiveDir();
|
|
2688
|
+
if (!fs.existsSync(archiveDir)) {
|
|
2689
|
+
return { content: [{ type: "text", text: "과거 deliberation이 없습니다." }] };
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
const files = fs.readdirSync(archiveDir)
|
|
2693
|
+
.filter(f => f.startsWith("deliberation-") && f.endsWith(".md"))
|
|
2694
|
+
.sort().reverse();
|
|
2695
|
+
|
|
2696
|
+
if (files.length === 0) {
|
|
2697
|
+
return { content: [{ type: "text", text: "과거 deliberation이 없습니다." }] };
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
const list = files.map((f, i) => `${i + 1}. ${f.replace(".md", "")}`).join("\n");
|
|
2701
|
+
return { content: [{ type: "text", text: `## 과거 Deliberation (${getProjectSlug()})\n\n${list}` }] };
|
|
2702
|
+
}
|
|
2703
|
+
);
|
|
2704
|
+
|
|
2705
|
+
server.tool(
|
|
2706
|
+
"deliberation_reset",
|
|
2707
|
+
"deliberation을 초기화합니다. session_id 지정 시 해당 세션만, 미지정 시 전체 초기화.",
|
|
2708
|
+
{
|
|
2709
|
+
session_id: z.string().optional().describe("초기화할 세션 ID (미지정 시 전체 초기화)"),
|
|
2710
|
+
},
|
|
2711
|
+
safeToolHandler("deliberation_reset", async ({ session_id }) => {
|
|
2712
|
+
ensureDirs();
|
|
2713
|
+
const sessionsDir = getSessionsDir();
|
|
2714
|
+
|
|
2715
|
+
if (session_id) {
|
|
2716
|
+
// 특정 세션만 초기화
|
|
2717
|
+
let toCloseIds = [];
|
|
2718
|
+
const result = withSessionLock(session_id, () => {
|
|
2719
|
+
const file = getSessionFile(session_id);
|
|
2720
|
+
if (!fs.existsSync(file)) {
|
|
2721
|
+
return { content: [{ type: "text", text: `세션 "${session_id}"을 찾을 수 없습니다.` }] };
|
|
2722
|
+
}
|
|
2723
|
+
const state = loadSession(session_id);
|
|
2724
|
+
if (state && state.log.length > 0) {
|
|
2725
|
+
archiveState(state);
|
|
2726
|
+
}
|
|
2727
|
+
if (state) cleanupSyncMarkdown(state);
|
|
2728
|
+
toCloseIds = getSessionWindowIds(state);
|
|
2729
|
+
fs.unlinkSync(file);
|
|
2730
|
+
return { content: [{ type: "text", text: `✅ 세션 "${session_id}" 초기화 완료. 🖥️ 모니터 터미널 닫힘.` }] };
|
|
2731
|
+
});
|
|
2732
|
+
if (toCloseIds.length > 0) {
|
|
2733
|
+
closeMonitorTerminal(session_id, toCloseIds);
|
|
2734
|
+
}
|
|
2735
|
+
return result;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// 전체 초기화
|
|
2739
|
+
const resetResult = withProjectLock(() => {
|
|
2740
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
2741
|
+
return { files: [], archived: 0, terminalWindowIds: [], noSessions: true };
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".json"));
|
|
2745
|
+
let archived = 0;
|
|
2746
|
+
const terminalWindowIds = [];
|
|
2747
|
+
|
|
2748
|
+
for (const f of files) {
|
|
2749
|
+
const filePath = path.join(sessionsDir, f);
|
|
2750
|
+
try {
|
|
2751
|
+
const state = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
2752
|
+
for (const id of getSessionWindowIds(state)) {
|
|
2753
|
+
terminalWindowIds.push(id);
|
|
2754
|
+
}
|
|
2755
|
+
if (state.log && state.log.length > 0) {
|
|
2756
|
+
archiveState(state);
|
|
2757
|
+
archived++;
|
|
2758
|
+
}
|
|
2759
|
+
cleanupSyncMarkdown(state);
|
|
2760
|
+
fs.unlinkSync(filePath);
|
|
2761
|
+
} catch {
|
|
2762
|
+
try {
|
|
2763
|
+
fs.unlinkSync(filePath);
|
|
2764
|
+
} catch {
|
|
2765
|
+
// ignore deletion race
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
return { files, archived, terminalWindowIds, noSessions: false };
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
if (resetResult.noSessions) {
|
|
2774
|
+
return { content: [{ type: "text", text: "초기화할 세션이 없습니다." }] };
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
for (const windowId of resetResult.terminalWindowIds) {
|
|
2778
|
+
closePhysicalTerminal(windowId);
|
|
2779
|
+
}
|
|
2780
|
+
closeAllMonitorTerminals();
|
|
2781
|
+
|
|
2782
|
+
return {
|
|
2783
|
+
content: [{
|
|
2784
|
+
type: "text",
|
|
2785
|
+
text: `✅ 전체 초기화 완료. ${resetResult.files.length}개 세션 삭제, ${resetResult.archived}개 아카이브됨. 🖥️ 모든 모니터 터미널 닫힘.`,
|
|
2786
|
+
}],
|
|
2787
|
+
};
|
|
2788
|
+
})
|
|
2789
|
+
);
|
|
2790
|
+
|
|
2791
|
+
server.tool(
|
|
2792
|
+
"deliberation_cli_config",
|
|
2793
|
+
"딜리버레이션 참가자 CLI 설정을 조회하거나 변경합니다. enabled_clis를 지정하면 저장합니다.",
|
|
2794
|
+
{
|
|
2795
|
+
enabled_clis: z.array(z.string()).optional().describe("활성화할 CLI 목록 (예: [\"claude\", \"codex\", \"gemini\"]). 미지정 시 현재 설정 조회"),
|
|
2796
|
+
require_speaker_selection: z.preprocess(
|
|
2797
|
+
(v) => (typeof v === "string" ? v === "true" : v),
|
|
2798
|
+
z.boolean().optional()
|
|
2799
|
+
).describe("true: 매번 사용자가 스피커 선택 후 시작, false: 감지된 스피커 전체 자동 참여"),
|
|
2800
|
+
default_rounds: z.coerce.number().int().min(1).max(10).optional()
|
|
2801
|
+
.describe("기본 라운드 수 (1-10, 기본 3)"),
|
|
2802
|
+
default_ordering: z.enum(["auto", "cyclic", "random", "weighted-random"]).optional()
|
|
2803
|
+
.describe("기본 발언 순서 전략: auto(스피커 수에 따라 자동), cyclic, random, weighted-random"),
|
|
2804
|
+
},
|
|
2805
|
+
safeToolHandler("deliberation_cli_config", async ({ enabled_clis, require_speaker_selection, default_rounds, default_ordering }) => {
|
|
2806
|
+
const config = loadDeliberationConfig();
|
|
2807
|
+
|
|
2808
|
+
// Handle setup config updates
|
|
2809
|
+
let configChanged = false;
|
|
2810
|
+
if (require_speaker_selection !== undefined && require_speaker_selection !== null) {
|
|
2811
|
+
config.require_speaker_selection = require_speaker_selection;
|
|
2812
|
+
configChanged = true;
|
|
2813
|
+
}
|
|
2814
|
+
if (default_rounds !== undefined && default_rounds !== null) {
|
|
2815
|
+
config.default_rounds = default_rounds;
|
|
2816
|
+
configChanged = true;
|
|
2817
|
+
}
|
|
2818
|
+
if (default_ordering !== undefined && default_ordering !== null) {
|
|
2819
|
+
config.default_ordering = default_ordering;
|
|
2820
|
+
configChanged = true;
|
|
2821
|
+
}
|
|
2822
|
+
if (configChanged) {
|
|
2823
|
+
config.setup_complete = true;
|
|
2824
|
+
saveDeliberationConfig(config);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
if (!enabled_clis) {
|
|
2828
|
+
// Read mode: show current config + detected CLIs
|
|
2829
|
+
const detected = discoverLocalCliSpeakers();
|
|
2830
|
+
const configured = Array.isArray(config.enabled_clis) ? config.enabled_clis : [];
|
|
2831
|
+
const mode = configured.length > 0 ? "config" : "auto-detect";
|
|
2832
|
+
|
|
2833
|
+
return {
|
|
2834
|
+
content: [{
|
|
2835
|
+
type: "text",
|
|
2836
|
+
text: `## Deliberation CLI 설정\n\n**모드:** ${mode}\n**스피커 선택:** ${config.require_speaker_selection === false ? "자동 (감지된 스피커 전체 참여)" : "수동 (사용자가 직접 선택)"}\n**기본 라운드:** ${config.default_rounds || 3}\n**발언 순서:** ${config.default_ordering || "auto"}\n**설정된 CLI:** ${configured.length > 0 ? configured.join(", ") : "(없음 — 전체 자동 감지)"}\n**현재 감지된 CLI:** ${detected.join(", ") || "(없음)"}\n**지원 CLI 전체:** ${DEFAULT_CLI_CANDIDATES.join(", ")}\n\n변경하려면:\n\`deliberation_cli_config(require_speaker_selection: false, default_rounds: 3, default_ordering: "auto")\`\n\n전체 자동 감지로 되돌리려면:\n\`deliberation_cli_config(enabled_clis: [])\``,
|
|
2837
|
+
}],
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// Write mode: save new config
|
|
2842
|
+
if (enabled_clis.length === 0) {
|
|
2843
|
+
// Empty array = reset to auto-detect all
|
|
2844
|
+
delete config.enabled_clis;
|
|
2845
|
+
saveDeliberationConfig(config);
|
|
2846
|
+
return {
|
|
2847
|
+
content: [{
|
|
2848
|
+
type: "text",
|
|
2849
|
+
text: `✅ CLI 설정 초기화 완료. 전체 자동 감지 모드로 전환되었습니다.\n감지 대상: ${DEFAULT_CLI_CANDIDATES.join(", ")}`,
|
|
2850
|
+
}],
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// Validate CLIs
|
|
2855
|
+
const valid = [];
|
|
2856
|
+
const invalid = [];
|
|
2857
|
+
for (const cli of enabled_clis) {
|
|
2858
|
+
const normalized = cli.trim().toLowerCase();
|
|
2859
|
+
if (normalized) valid.push(normalized);
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
config.enabled_clis = valid;
|
|
2863
|
+
saveDeliberationConfig(config);
|
|
2864
|
+
|
|
2865
|
+
// Check which are actually installed
|
|
2866
|
+
const installed = valid.filter(cli => {
|
|
2867
|
+
try {
|
|
2868
|
+
execFileSync(process.platform === "win32" ? "where" : "which", [cli], { stdio: "ignore" });
|
|
2869
|
+
return true;
|
|
2870
|
+
} catch { return false; }
|
|
2871
|
+
});
|
|
2872
|
+
const notInstalled = valid.filter(cli => !installed.includes(cli));
|
|
2873
|
+
|
|
2874
|
+
let result = `✅ CLI 설정 저장 완료!\n\n**활성화된 CLI:** ${valid.join(", ")}`;
|
|
2875
|
+
if (installed.length > 0) result += `\n**설치 확인됨:** ${installed.join(", ")}`;
|
|
2876
|
+
if (notInstalled.length > 0) result += `\n**⚠️ 미설치:** ${notInstalled.join(", ")} (PATH에서 찾을 수 없음)`;
|
|
2877
|
+
|
|
2878
|
+
return { content: [{ type: "text", text: result }] };
|
|
2879
|
+
})
|
|
2880
|
+
);
|
|
2881
|
+
|
|
2882
|
+
// ── Request Review (auto-review) ───────────────────────────────
|
|
2883
|
+
|
|
2884
|
+
function invokeCliReviewer(command, prompt, timeoutMs) {
|
|
2885
|
+
const args = ["-p", prompt, "--no-input"];
|
|
2886
|
+
try {
|
|
2887
|
+
const result = execFileSync(command, args, {
|
|
2888
|
+
encoding: "utf-8",
|
|
2889
|
+
timeout: timeoutMs,
|
|
2890
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2891
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
2892
|
+
windowsHide: true,
|
|
2893
|
+
});
|
|
2894
|
+
return { ok: true, response: result.trim() };
|
|
2895
|
+
} catch (error) {
|
|
2896
|
+
if (error && error.killed) {
|
|
2897
|
+
return { ok: false, error: "timeout" };
|
|
2898
|
+
}
|
|
2899
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2900
|
+
return { ok: false, error: msg };
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
function buildReviewPrompt(context, question, priorReviews) {
|
|
2905
|
+
let prompt = `You are a code reviewer. Provide a concise, structured review.\n\n`;
|
|
2906
|
+
prompt += `## Context\n${context}\n\n`;
|
|
2907
|
+
prompt += `## Review Question\n${question}\n\n`;
|
|
2908
|
+
if (priorReviews.length > 0) {
|
|
2909
|
+
prompt += `## Prior Reviews\n`;
|
|
2910
|
+
for (const r of priorReviews) {
|
|
2911
|
+
prompt += `### ${r.reviewer}\n${r.response}\n\n`;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
prompt += `Respond with your review. Be specific about issues, risks, and suggestions.`;
|
|
2915
|
+
return prompt;
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
function synthesizeReviews(context, question, reviews) {
|
|
2919
|
+
if (reviews.length === 0) return "(No reviews completed)";
|
|
2920
|
+
|
|
2921
|
+
let synthesis = `## Review Synthesis\n\n`;
|
|
2922
|
+
synthesis += `**Question:** ${question}\n`;
|
|
2923
|
+
synthesis += `**Reviews:** ${reviews.length}\n\n`;
|
|
2924
|
+
|
|
2925
|
+
synthesis += `### Individual Reviews\n\n`;
|
|
2926
|
+
for (const r of reviews) {
|
|
2927
|
+
synthesis += `#### ${r.reviewer}\n${r.response}\n\n`;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
if (reviews.length > 1) {
|
|
2931
|
+
synthesis += `### Summary\n`;
|
|
2932
|
+
synthesis += `${reviews.length} reviewer(s) provided feedback on: ${question}\n`;
|
|
2933
|
+
synthesis += `Reviewers: ${reviews.map(r => r.reviewer).join(", ")}\n`;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
return synthesis;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
server.tool(
|
|
2940
|
+
"deliberation_request_review",
|
|
2941
|
+
"코드 리뷰를 요청합니다. 여러 CLI 리뷰어에게 동시에 리뷰를 요청하고 결과를 종합합니다.",
|
|
2942
|
+
{
|
|
2943
|
+
context: z.string().describe("리뷰할 변경사항 설명 (코드, diff, 설계 등)"),
|
|
2944
|
+
question: z.string().describe("리뷰 질문 (예: 'Is this error handling sufficient?')"),
|
|
2945
|
+
reviewers: z.array(z.string().trim().min(1).max(64)).min(1).describe("리뷰어 CLI 목록 (예: [\"claude\", \"codex\"])"),
|
|
2946
|
+
mode: z.enum(["sync", "async"]).default("sync").describe("sync: 결과 대기 후 반환, async: session_id 즉시 반환"),
|
|
2947
|
+
deadline_ms: z.number().int().min(5000).max(600000).default(60000).describe("전체 타임아웃 (밀리초, 기본 60초)"),
|
|
2948
|
+
min_reviews: z.number().int().min(1).default(1).describe("최소 필요 리뷰 수 (기본 1)"),
|
|
2949
|
+
on_timeout: z.enum(["partial", "fail"]).default("partial").describe("타임아웃 시 동작: partial=부분 결과 반환, fail=에러"),
|
|
2950
|
+
},
|
|
2951
|
+
safeToolHandler("deliberation_request_review", async ({ context, question, reviewers, mode, deadline_ms, min_reviews, on_timeout }) => {
|
|
2952
|
+
// Validate reviewers exist in PATH
|
|
2953
|
+
const validReviewers = [];
|
|
2954
|
+
const invalidReviewers = [];
|
|
2955
|
+
for (const r of reviewers) {
|
|
2956
|
+
const normalized = normalizeSpeaker(r);
|
|
2957
|
+
if (!normalized) continue;
|
|
2958
|
+
if (commandExistsInPath(normalized)) {
|
|
2959
|
+
validReviewers.push(normalized);
|
|
2960
|
+
} else {
|
|
2961
|
+
invalidReviewers.push(normalized);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
if (validReviewers.length === 0) {
|
|
2966
|
+
return {
|
|
2967
|
+
content: [{
|
|
2968
|
+
type: "text",
|
|
2969
|
+
text: `❌ 유효한 리뷰어가 없습니다. PATH에서 찾을 수 없는 CLI: ${invalidReviewers.join(", ")}\n\n사용 가능한 CLI를 확인하려면 deliberation_speaker_candidates를 호출하세요.`,
|
|
2970
|
+
}],
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
// Create mini-session
|
|
2975
|
+
const sessionId = generateSessionId("review");
|
|
2976
|
+
const callerSpeaker = detectCallerSpeaker() || "requester";
|
|
2977
|
+
const now = new Date().toISOString();
|
|
2978
|
+
|
|
2979
|
+
const state = {
|
|
2980
|
+
id: sessionId,
|
|
2981
|
+
project: getProjectSlug(),
|
|
2982
|
+
topic: question.slice(0, 80),
|
|
2983
|
+
type: "auto_review",
|
|
2984
|
+
status: "active",
|
|
2985
|
+
max_rounds: 1,
|
|
2986
|
+
current_round: 1,
|
|
2987
|
+
current_speaker: validReviewers[0],
|
|
2988
|
+
speakers: validReviewers,
|
|
2989
|
+
participant_profiles: validReviewers.map(r => ({ speaker: r, type: "cli", command: r })),
|
|
2990
|
+
log: [],
|
|
2991
|
+
synthesis: null,
|
|
2992
|
+
requester: callerSpeaker,
|
|
2993
|
+
review_context: context,
|
|
2994
|
+
review_question: question,
|
|
2995
|
+
review_mode: mode,
|
|
2996
|
+
review_deadline_ms: deadline_ms,
|
|
2997
|
+
review_min_reviews: min_reviews,
|
|
2998
|
+
review_on_timeout: on_timeout,
|
|
2999
|
+
pending_turn_id: generateTurnId(),
|
|
3000
|
+
monitor_terminal_window_ids: [],
|
|
3001
|
+
created: now,
|
|
3002
|
+
updated: now,
|
|
3003
|
+
};
|
|
3004
|
+
|
|
3005
|
+
withSessionLock(sessionId, () => {
|
|
3006
|
+
ensureDirs();
|
|
3007
|
+
saveSession(state);
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
// Async mode: return immediately
|
|
3011
|
+
if (mode === "async") {
|
|
3012
|
+
const warn = invalidReviewers.length > 0
|
|
3013
|
+
? `\n⚠️ PATH에서 찾을 수 없는 리뷰어 (제외됨): ${invalidReviewers.join(", ")}`
|
|
3014
|
+
: "";
|
|
3015
|
+
return {
|
|
3016
|
+
content: [{
|
|
3017
|
+
type: "text",
|
|
3018
|
+
text: `✅ 비동기 리뷰 세션 생성됨\n\n**Session ID:** ${sessionId}\n**리뷰어:** ${validReviewers.join(", ")}\n**모드:** async${warn}\n\n진행 상태는 \`deliberation_status(session_id: "${sessionId}")\`로 확인하세요.`,
|
|
3019
|
+
}],
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
// Sync mode: invoke each reviewer sequentially with deadline enforcement
|
|
3024
|
+
const globalStart = Date.now();
|
|
3025
|
+
const softBudgetPerReviewer = Math.floor(deadline_ms / validReviewers.length);
|
|
3026
|
+
const completedReviews = [];
|
|
3027
|
+
const timedOutReviewers = [];
|
|
3028
|
+
const failedReviewers = [];
|
|
3029
|
+
|
|
3030
|
+
for (const reviewer of validReviewers) {
|
|
3031
|
+
const elapsed = Date.now() - globalStart;
|
|
3032
|
+
const remaining = deadline_ms - elapsed;
|
|
3033
|
+
|
|
3034
|
+
// Global deadline check
|
|
3035
|
+
if (remaining <= 1000) {
|
|
3036
|
+
timedOutReviewers.push(reviewer);
|
|
3037
|
+
continue;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// Per-reviewer timeout: min of soft budget and remaining global time
|
|
3041
|
+
const reviewerTimeout = Math.min(softBudgetPerReviewer, remaining);
|
|
3042
|
+
|
|
3043
|
+
const prompt = buildReviewPrompt(context, question, completedReviews);
|
|
3044
|
+
const result = invokeCliReviewer(reviewer, prompt, reviewerTimeout);
|
|
3045
|
+
|
|
3046
|
+
if (result.ok) {
|
|
3047
|
+
const entry = { reviewer, response: result.response };
|
|
3048
|
+
completedReviews.push(entry);
|
|
3049
|
+
|
|
3050
|
+
// Add to session log
|
|
3051
|
+
withSessionLock(sessionId, () => {
|
|
3052
|
+
const latest = loadSession(sessionId);
|
|
3053
|
+
if (!latest) return;
|
|
3054
|
+
latest.log.push({
|
|
3055
|
+
round: 1,
|
|
3056
|
+
speaker: reviewer,
|
|
3057
|
+
content: result.response,
|
|
3058
|
+
timestamp: new Date().toISOString(),
|
|
3059
|
+
turn_id: generateTurnId(),
|
|
3060
|
+
channel_used: "cli_auto_review",
|
|
3061
|
+
fallback_reason: null,
|
|
3062
|
+
});
|
|
3063
|
+
latest.updated = new Date().toISOString();
|
|
3064
|
+
saveSession(latest);
|
|
3065
|
+
});
|
|
3066
|
+
} else if (result.error === "timeout") {
|
|
3067
|
+
timedOutReviewers.push(reviewer);
|
|
3068
|
+
} else {
|
|
3069
|
+
failedReviewers.push({ reviewer, error: result.error });
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// Check min_reviews threshold
|
|
3074
|
+
if (completedReviews.length < min_reviews) {
|
|
3075
|
+
if (on_timeout === "fail") {
|
|
3076
|
+
// Mark session as failed
|
|
3077
|
+
withSessionLock(sessionId, () => {
|
|
3078
|
+
const latest = loadSession(sessionId);
|
|
3079
|
+
if (!latest) return;
|
|
3080
|
+
latest.status = "completed";
|
|
3081
|
+
latest.synthesis = `Review failed: only ${completedReviews.length}/${min_reviews} required reviews completed.`;
|
|
3082
|
+
saveSession(latest);
|
|
3083
|
+
archiveState(latest);
|
|
3084
|
+
cleanupSyncMarkdown(latest);
|
|
3085
|
+
});
|
|
3086
|
+
|
|
3087
|
+
return {
|
|
3088
|
+
content: [{
|
|
3089
|
+
type: "text",
|
|
3090
|
+
text: `❌ 리뷰 실패: 최소 ${min_reviews}개 리뷰 필요, ${completedReviews.length}개만 완료\n\n**Session:** ${sessionId}\n**완료:** ${completedReviews.map(r => r.reviewer).join(", ") || "(없음)"}\n**타임아웃:** ${timedOutReviewers.join(", ") || "(없음)"}\n**실패:** ${failedReviewers.map(r => `${r.reviewer}: ${r.error}`).join(", ") || "(없음)"}`,
|
|
3091
|
+
}],
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
// on_timeout === "partial": fall through to return partial results
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// Synthesize
|
|
3098
|
+
const synthesis = synthesizeReviews(context, question, completedReviews);
|
|
3099
|
+
|
|
3100
|
+
// Complete session
|
|
3101
|
+
let archivePath = null;
|
|
3102
|
+
withSessionLock(sessionId, () => {
|
|
3103
|
+
const latest = loadSession(sessionId);
|
|
3104
|
+
if (!latest) return;
|
|
3105
|
+
latest.status = "completed";
|
|
3106
|
+
latest.synthesis = synthesis;
|
|
3107
|
+
latest.current_speaker = "none";
|
|
3108
|
+
saveSession(latest);
|
|
3109
|
+
archivePath = archiveState(latest);
|
|
3110
|
+
cleanupSyncMarkdown(latest);
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
const totalMs = Date.now() - globalStart;
|
|
3114
|
+
const coverage = `${completedReviews.length}/${validReviewers.length}`;
|
|
3115
|
+
const warn = invalidReviewers.length > 0
|
|
3116
|
+
? `\n**제외된 리뷰어 (미설치):** ${invalidReviewers.join(", ")}`
|
|
3117
|
+
: "";
|
|
3118
|
+
const timeoutInfo = timedOutReviewers.length > 0
|
|
3119
|
+
? `\n**타임아웃 리뷰어:** ${timedOutReviewers.join(", ")}`
|
|
3120
|
+
: "";
|
|
3121
|
+
const failInfo = failedReviewers.length > 0
|
|
3122
|
+
? `\n**실패 리뷰어:** ${failedReviewers.map(r => `${r.reviewer}: ${r.error}`).join(", ")}`
|
|
3123
|
+
: "";
|
|
3124
|
+
|
|
3125
|
+
const resultPayload = {
|
|
3126
|
+
synthesis,
|
|
3127
|
+
completed_reviewers: completedReviews.map(r => r.reviewer),
|
|
3128
|
+
timed_out_reviewers: timedOutReviewers,
|
|
3129
|
+
failed_reviewers: failedReviewers.map(r => r.reviewer),
|
|
3130
|
+
coverage,
|
|
3131
|
+
mode: "sync",
|
|
3132
|
+
session_id: sessionId,
|
|
3133
|
+
elapsed_ms: totalMs,
|
|
3134
|
+
};
|
|
3135
|
+
|
|
3136
|
+
return {
|
|
3137
|
+
content: [{
|
|
3138
|
+
type: "text",
|
|
3139
|
+
text: `## Review 완료\n\n**Session:** ${sessionId}\n**Coverage:** ${coverage}\n**소요 시간:** ${totalMs}ms\n**완료 리뷰어:** ${completedReviews.map(r => r.reviewer).join(", ") || "(없음)"}${timeoutInfo}${failInfo}${warn}\n\n${synthesis}\n\n---\n\n\`\`\`json\n${JSON.stringify(resultPayload, null, 2)}\n\`\`\``,
|
|
3140
|
+
}],
|
|
3141
|
+
};
|
|
3142
|
+
})
|
|
3143
|
+
);
|
|
3144
|
+
|
|
3145
|
+
// ── Start ──────────────────────────────────────────────────────
|
|
3146
|
+
|
|
3147
|
+
// Only start server when run directly (not imported for testing)
|
|
3148
|
+
const __currentFile = new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
3149
|
+
const __entryFile = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
3150
|
+
if (__entryFile && path.resolve(__currentFile) === __entryFile) {
|
|
3151
|
+
const transport = new StdioServerTransport();
|
|
3152
|
+
await server.connect(transport);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// ── Test exports (used by vitest) ──
|
|
3156
|
+
export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS };
|