@dmsdc-ai/aigentry-devkit 0.1.0
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 +500 -0
- package/bin/aigentry-devkit.js +94 -0
- package/config/CLAUDE.md +744 -0
- package/config/envrc/global.envrc +3 -0
- package/config/settings.json.template +12 -0
- package/hooks/hooks.json +16 -0
- package/hooks/session-start +37 -0
- package/hud/simple-status.sh +126 -0
- package/install.ps1 +203 -0
- package/install.sh +213 -0
- package/mcp-servers/deliberation/index.js +2429 -0
- package/mcp-servers/deliberation/package.json +16 -0
- package/mcp-servers/deliberation/session-monitor.sh +316 -0
- package/package.json +50 -0
- package/skills/clipboard-image/SKILL.md +31 -0
- package/skills/deliberation/SKILL.md +135 -0
- package/skills/deliberation-executor/SKILL.md +86 -0
- package/skills/env-manager/SKILL.md +231 -0
- package/skills/youtube-analyzer/SKILL.md +56 -0
- package/skills/youtube-analyzer/scripts/analyze_youtube.py +383 -0
|
@@ -0,0 +1,2429 @@
|
|
|
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
|
+
* 상태 저장: ~/.local/lib/mcp-deliberation/state/{project-slug}/sessions/{id}.json
|
|
9
|
+
*
|
|
10
|
+
* Tools:
|
|
11
|
+
* deliberation_start 새 토론 시작 → session_id 반환
|
|
12
|
+
* deliberation_status 세션 상태 조회 (session_id 선택적)
|
|
13
|
+
* deliberation_list_active 진행 중인 모든 세션 목록
|
|
14
|
+
* deliberation_context 프로젝트 컨텍스트 로드
|
|
15
|
+
* deliberation_respond 응답 제출 (session_id 필수)
|
|
16
|
+
* deliberation_history 토론 기록 조회 (session_id 선택적)
|
|
17
|
+
* deliberation_synthesize 합성 보고서 생성 (session_id 선택적)
|
|
18
|
+
* deliberation_list 과거 아카이브 목록
|
|
19
|
+
* deliberation_reset 세션 초기화 (session_id 선택적, 없으면 전체)
|
|
20
|
+
* deliberation_speaker_candidates 선택 가능한 스피커 후보(로컬 CLI + 브라우저 LLM 탭) 조회
|
|
21
|
+
* deliberation_browser_llm_tabs 브라우저 LLM 탭 목록 조회
|
|
22
|
+
* deliberation_clipboard_prepare_turn 브라우저 LLM용 턴 프롬프트를 클립보드로 복사
|
|
23
|
+
* deliberation_clipboard_submit_turn 클립보드 텍스트를 현재 턴 응답으로 제출
|
|
24
|
+
* deliberation_browser_auto_turn 브라우저 LLM에 자동으로 턴을 전송하고 응답을 수집 (CDP 기반)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
28
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { execFileSync } from "child_process";
|
|
31
|
+
import fs from "fs";
|
|
32
|
+
import path from "path";
|
|
33
|
+
import os from "os";
|
|
34
|
+
import { OrchestratedBrowserPort } from "./browser-control-port.js";
|
|
35
|
+
|
|
36
|
+
// ── Paths ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const HOME = os.homedir();
|
|
39
|
+
const GLOBAL_STATE_DIR = path.join(HOME, ".local", "lib", "mcp-deliberation", "state");
|
|
40
|
+
const GLOBAL_RUNTIME_LOG = path.join(HOME, ".local", "lib", "mcp-deliberation", "runtime.log");
|
|
41
|
+
const OBSIDIAN_VAULT = path.join(HOME, "Documents", "Obsidian Vault");
|
|
42
|
+
const OBSIDIAN_PROJECTS = path.join(OBSIDIAN_VAULT, "10-Projects");
|
|
43
|
+
const DEFAULT_SPEAKERS = ["agent-a", "agent-b"];
|
|
44
|
+
const DEFAULT_CLI_CANDIDATES = [
|
|
45
|
+
"claude",
|
|
46
|
+
"codex",
|
|
47
|
+
"gemini",
|
|
48
|
+
"qwen",
|
|
49
|
+
"chatgpt",
|
|
50
|
+
"aider",
|
|
51
|
+
"llm",
|
|
52
|
+
"opencode",
|
|
53
|
+
"cursor-agent",
|
|
54
|
+
"cursor",
|
|
55
|
+
"continue",
|
|
56
|
+
];
|
|
57
|
+
const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
|
|
58
|
+
const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
|
|
59
|
+
const DEFAULT_LLM_DOMAINS = [
|
|
60
|
+
"chatgpt.com",
|
|
61
|
+
"openai.com",
|
|
62
|
+
"claude.ai",
|
|
63
|
+
"anthropic.com",
|
|
64
|
+
"gemini.google.com",
|
|
65
|
+
"copilot.microsoft.com",
|
|
66
|
+
"poe.com",
|
|
67
|
+
"perplexity.ai",
|
|
68
|
+
"mistral.ai",
|
|
69
|
+
"huggingface.co/chat",
|
|
70
|
+
"deepseek.com",
|
|
71
|
+
"qwen.ai",
|
|
72
|
+
"notebooklm.google.com",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const PRODUCT_DISCLAIMER = "ℹ️ 이 도구는 외부 웹사이트를 영구 수정하지 않습니다. 브라우저 문맥을 읽기 전용으로 참조하여 발화자를 라우팅합니다.";
|
|
76
|
+
const LOCKS_SUBDIR = ".locks";
|
|
77
|
+
const LOCK_RETRY_MS = 25;
|
|
78
|
+
const LOCK_TIMEOUT_MS = 8000;
|
|
79
|
+
const LOCK_STALE_MS = 60000;
|
|
80
|
+
|
|
81
|
+
function getProjectSlug() {
|
|
82
|
+
return path.basename(process.cwd());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getProjectStateDir() {
|
|
86
|
+
return path.join(GLOBAL_STATE_DIR, getProjectSlug());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getSessionsDir() {
|
|
90
|
+
return path.join(getProjectStateDir(), "sessions");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getSessionFile(sessionId) {
|
|
94
|
+
return path.join(getSessionsDir(), `${sessionId}.json`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getArchiveDir() {
|
|
98
|
+
const obsidianDir = path.join(OBSIDIAN_PROJECTS, getProjectSlug(), "deliberations");
|
|
99
|
+
if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, getProjectSlug()))) {
|
|
100
|
+
return obsidianDir;
|
|
101
|
+
}
|
|
102
|
+
return path.join(getProjectStateDir(), "archive");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getLocksDir() {
|
|
106
|
+
return path.join(getProjectStateDir(), LOCKS_SUBDIR);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatRuntimeError(error) {
|
|
110
|
+
if (error instanceof Error) {
|
|
111
|
+
return error.stack || error.message;
|
|
112
|
+
}
|
|
113
|
+
return String(error);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appendRuntimeLog(level, message) {
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(path.dirname(GLOBAL_RUNTIME_LOG), { recursive: true });
|
|
119
|
+
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
|
120
|
+
fs.appendFileSync(GLOBAL_RUNTIME_LOG, line, "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore logging failures
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function safeToolHandler(toolName, handler) {
|
|
127
|
+
return async (args) => {
|
|
128
|
+
try {
|
|
129
|
+
return await handler(args);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const message = formatRuntimeError(error);
|
|
132
|
+
appendRuntimeLog("ERROR", `${toolName}: ${message}`);
|
|
133
|
+
return { content: [{ type: "text", text: `❌ ${toolName} 실패: ${message}` }] };
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sleepMs(ms) {
|
|
139
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
140
|
+
const sab = new SharedArrayBuffer(4);
|
|
141
|
+
const arr = new Int32Array(sab);
|
|
142
|
+
Atomics.wait(arr, 0, 0, Math.floor(ms));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeTextAtomic(filePath, text) {
|
|
146
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
147
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
148
|
+
fs.writeFileSync(tmp, text, "utf-8");
|
|
149
|
+
fs.renameSync(tmp, filePath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function acquireFileLock(lockPath, {
|
|
153
|
+
timeoutMs = LOCK_TIMEOUT_MS,
|
|
154
|
+
retryMs = LOCK_RETRY_MS,
|
|
155
|
+
staleMs = LOCK_STALE_MS,
|
|
156
|
+
} = {}) {
|
|
157
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
158
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
|
|
161
|
+
while (true) {
|
|
162
|
+
try {
|
|
163
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
164
|
+
fs.writeFileSync(fd, token, "utf-8");
|
|
165
|
+
fs.closeSync(fd);
|
|
166
|
+
return token;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const isExists = error && typeof error === "object" && "code" in error && error.code === "EEXIST";
|
|
169
|
+
if (!isExists) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.statSync(lockPath);
|
|
175
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
176
|
+
fs.unlinkSync(lockPath);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// lock might have been removed concurrently
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
184
|
+
throw new Error(`lock timeout: ${lockPath}`);
|
|
185
|
+
}
|
|
186
|
+
sleepMs(retryMs);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function releaseFileLock(lockPath, token) {
|
|
192
|
+
try {
|
|
193
|
+
const current = fs.readFileSync(lockPath, "utf-8").trim();
|
|
194
|
+
if (current === token) {
|
|
195
|
+
fs.unlinkSync(lockPath);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// already released or replaced
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function withFileLock(lockPath, fn, options) {
|
|
203
|
+
const token = acquireFileLock(lockPath, options);
|
|
204
|
+
try {
|
|
205
|
+
return fn();
|
|
206
|
+
} finally {
|
|
207
|
+
releaseFileLock(lockPath, token);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function withProjectLock(fn, options) {
|
|
212
|
+
return withFileLock(path.join(getLocksDir(), "_project.lock"), fn, options);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function withSessionLock(sessionId, fn, options) {
|
|
216
|
+
const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
|
|
217
|
+
return withFileLock(path.join(getLocksDir(), `${safeId}.lock`), fn, options);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeSpeaker(raw) {
|
|
221
|
+
if (typeof raw !== "string") return null;
|
|
222
|
+
const normalized = raw.trim().toLowerCase();
|
|
223
|
+
if (!normalized || normalized === "none") return null;
|
|
224
|
+
return normalized;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dedupeSpeakers(items = []) {
|
|
228
|
+
const out = [];
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
for (const item of items) {
|
|
231
|
+
const normalized = normalizeSpeaker(item);
|
|
232
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
233
|
+
seen.add(normalized);
|
|
234
|
+
out.push(normalized);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveCliCandidates() {
|
|
240
|
+
const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
|
|
241
|
+
.split(/[,\s]+/)
|
|
242
|
+
.map(v => v.trim())
|
|
243
|
+
.filter(Boolean);
|
|
244
|
+
return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function commandExistsInPath(command) {
|
|
248
|
+
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (process.platform === "win32") {
|
|
253
|
+
try {
|
|
254
|
+
execFileSync("where", [command], { stdio: "ignore" });
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
// keep PATH scan fallback for shells where "where" is unavailable
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const pathVar = process.env.PATH || "";
|
|
262
|
+
const dirs = pathVar.split(path.delimiter).filter(Boolean);
|
|
263
|
+
if (dirs.length === 0) return false;
|
|
264
|
+
|
|
265
|
+
const extensions = process.platform === "win32"
|
|
266
|
+
? ["", ".exe", ".cmd", ".bat", ".ps1"]
|
|
267
|
+
: [""];
|
|
268
|
+
|
|
269
|
+
for (const dir of dirs) {
|
|
270
|
+
for (const ext of extensions) {
|
|
271
|
+
const fullPath = path.join(dir, `${command}${ext}`);
|
|
272
|
+
try {
|
|
273
|
+
fs.accessSync(fullPath, fs.constants.X_OK);
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore and continue
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function shellQuote(value) {
|
|
284
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function discoverLocalCliSpeakers() {
|
|
288
|
+
const found = [];
|
|
289
|
+
for (const candidate of resolveCliCandidates()) {
|
|
290
|
+
if (commandExistsInPath(candidate)) {
|
|
291
|
+
found.push(candidate);
|
|
292
|
+
}
|
|
293
|
+
if (found.length >= MAX_AUTO_DISCOVERED_SPEAKERS) {
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return found;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function detectCallerSpeaker() {
|
|
301
|
+
const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
|
|
302
|
+
if (hinted) return hinted;
|
|
303
|
+
|
|
304
|
+
const pathHint = process.env.PATH || "";
|
|
305
|
+
if (/\bCODEX_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
|
|
306
|
+
return "codex";
|
|
307
|
+
}
|
|
308
|
+
if (pathHint.includes("/.codex/")) {
|
|
309
|
+
return "codex";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (/\bCLAUDE_[A-Z0-9_]+\b/.test(Object.keys(process.env).join(" "))) {
|
|
313
|
+
return "claude";
|
|
314
|
+
}
|
|
315
|
+
if (pathHint.includes("/.claude/")) {
|
|
316
|
+
return "claude";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resolveClipboardReader() {
|
|
323
|
+
if (process.platform === "darwin" && commandExistsInPath("pbpaste")) {
|
|
324
|
+
return { cmd: "pbpaste", args: [] };
|
|
325
|
+
}
|
|
326
|
+
if (process.platform === "win32") {
|
|
327
|
+
const windowsShell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
|
|
328
|
+
.find(cmd => commandExistsInPath(cmd));
|
|
329
|
+
if (windowsShell) {
|
|
330
|
+
return { cmd: windowsShell, args: ["-NoProfile", "-Command", "Get-Clipboard -Raw"] };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (commandExistsInPath("wl-paste")) {
|
|
334
|
+
return { cmd: "wl-paste", args: ["-n"] };
|
|
335
|
+
}
|
|
336
|
+
if (commandExistsInPath("xclip")) {
|
|
337
|
+
return { cmd: "xclip", args: ["-selection", "clipboard", "-o"] };
|
|
338
|
+
}
|
|
339
|
+
if (commandExistsInPath("xsel")) {
|
|
340
|
+
return { cmd: "xsel", args: ["--clipboard", "--output"] };
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function resolveClipboardWriter() {
|
|
346
|
+
if (process.platform === "darwin" && commandExistsInPath("pbcopy")) {
|
|
347
|
+
return { cmd: "pbcopy", args: [] };
|
|
348
|
+
}
|
|
349
|
+
if (process.platform === "win32") {
|
|
350
|
+
if (commandExistsInPath("clip.exe") || commandExistsInPath("clip")) {
|
|
351
|
+
return { cmd: "clip", args: [] };
|
|
352
|
+
}
|
|
353
|
+
const windowsShell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
|
|
354
|
+
.find(cmd => commandExistsInPath(cmd));
|
|
355
|
+
if (windowsShell) {
|
|
356
|
+
return { cmd: windowsShell, args: ["-NoProfile", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"] };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (commandExistsInPath("wl-copy")) {
|
|
360
|
+
return { cmd: "wl-copy", args: [] };
|
|
361
|
+
}
|
|
362
|
+
if (commandExistsInPath("xclip")) {
|
|
363
|
+
return { cmd: "xclip", args: ["-selection", "clipboard"] };
|
|
364
|
+
}
|
|
365
|
+
if (commandExistsInPath("xsel")) {
|
|
366
|
+
return { cmd: "xsel", args: ["--clipboard", "--input"] };
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readClipboardText() {
|
|
372
|
+
const tool = resolveClipboardReader();
|
|
373
|
+
if (!tool) {
|
|
374
|
+
throw new Error("지원되는 클립보드 읽기 명령이 없습니다 (pbpaste/wl-paste/xclip/xsel 등).");
|
|
375
|
+
}
|
|
376
|
+
return execFileSync(tool.cmd, tool.args, {
|
|
377
|
+
encoding: "utf-8",
|
|
378
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
379
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function writeClipboardText(text) {
|
|
384
|
+
const tool = resolveClipboardWriter();
|
|
385
|
+
if (!tool) {
|
|
386
|
+
throw new Error("지원되는 클립보드 쓰기 명령이 없습니다 (pbcopy/wl-copy/xclip/xsel 등).");
|
|
387
|
+
}
|
|
388
|
+
execFileSync(tool.cmd, tool.args, {
|
|
389
|
+
input: text,
|
|
390
|
+
encoding: "utf-8",
|
|
391
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
392
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isLlmUrl(url = "") {
|
|
397
|
+
const value = String(url || "").trim();
|
|
398
|
+
if (!value) return false;
|
|
399
|
+
try {
|
|
400
|
+
const parsed = new URL(value);
|
|
401
|
+
const host = parsed.hostname.toLowerCase();
|
|
402
|
+
return DEFAULT_LLM_DOMAINS.some(domain => host === domain || host.endsWith(`.${domain}`));
|
|
403
|
+
} catch {
|
|
404
|
+
const lowered = value.toLowerCase();
|
|
405
|
+
return DEFAULT_LLM_DOMAINS.some(domain => lowered.includes(domain));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function dedupeBrowserTabs(tabs = []) {
|
|
410
|
+
const out = [];
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
for (const tab of tabs) {
|
|
413
|
+
const browser = String(tab?.browser || "").trim();
|
|
414
|
+
const title = String(tab?.title || "").trim();
|
|
415
|
+
const url = String(tab?.url || "").trim();
|
|
416
|
+
if (!url || !isLlmUrl(url)) continue;
|
|
417
|
+
const key = `${browser}\t${title}\t${url}`;
|
|
418
|
+
if (seen.has(key)) continue;
|
|
419
|
+
seen.add(key);
|
|
420
|
+
out.push({
|
|
421
|
+
browser: browser || "Browser",
|
|
422
|
+
title: title || "(untitled)",
|
|
423
|
+
url,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function parseInjectedBrowserTabsFromEnv() {
|
|
430
|
+
const raw = process.env.DELIBERATION_BROWSER_TABS_JSON;
|
|
431
|
+
if (!raw) {
|
|
432
|
+
return { tabs: [], note: null };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const parsed = JSON.parse(raw);
|
|
437
|
+
if (!Array.isArray(parsed)) {
|
|
438
|
+
return { tabs: [], note: "DELIBERATION_BROWSER_TABS_JSON 형식 오류: JSON 배열이어야 합니다." };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const tabs = dedupeBrowserTabs(parsed.map(item => ({
|
|
442
|
+
browser: item?.browser || "External Bridge",
|
|
443
|
+
title: item?.title || "(untitled)",
|
|
444
|
+
url: item?.url || "",
|
|
445
|
+
})));
|
|
446
|
+
return {
|
|
447
|
+
tabs,
|
|
448
|
+
note: tabs.length > 0 ? `환경변수 탭 주입 사용: ${tabs.length}개` : "DELIBERATION_BROWSER_TABS_JSON에 유효한 LLM URL이 없습니다.",
|
|
449
|
+
};
|
|
450
|
+
} catch (error) {
|
|
451
|
+
const reason = error instanceof Error ? error.message : "unknown error";
|
|
452
|
+
return { tabs: [], note: `DELIBERATION_BROWSER_TABS_JSON 파싱 실패: ${reason}` };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function normalizeCdpEndpoint(raw) {
|
|
457
|
+
const value = String(raw || "").trim();
|
|
458
|
+
if (!value) return null;
|
|
459
|
+
|
|
460
|
+
const withProto = /^https?:\/\//i.test(value) ? value : `http://${value}`;
|
|
461
|
+
try {
|
|
462
|
+
const url = new URL(withProto);
|
|
463
|
+
if (!url.pathname || url.pathname === "/") {
|
|
464
|
+
url.pathname = "/json/list";
|
|
465
|
+
}
|
|
466
|
+
return url.toString();
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function resolveCdpEndpoints() {
|
|
473
|
+
const fromEnv = (process.env.DELIBERATION_BROWSER_CDP_ENDPOINTS || "")
|
|
474
|
+
.split(/[,\s]+/)
|
|
475
|
+
.map(v => normalizeCdpEndpoint(v))
|
|
476
|
+
.filter(Boolean);
|
|
477
|
+
if (fromEnv.length > 0) {
|
|
478
|
+
return [...new Set(fromEnv)];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const ports = (process.env.DELIBERATION_BROWSER_CDP_PORTS || "9222,9223,9333")
|
|
482
|
+
.split(/[,\s]+/)
|
|
483
|
+
.map(v => Number.parseInt(v, 10))
|
|
484
|
+
.filter(v => Number.isInteger(v) && v > 0 && v < 65536);
|
|
485
|
+
|
|
486
|
+
const endpoints = [];
|
|
487
|
+
for (const port of ports) {
|
|
488
|
+
endpoints.push(`http://127.0.0.1:${port}/json/list`);
|
|
489
|
+
endpoints.push(`http://localhost:${port}/json/list`);
|
|
490
|
+
}
|
|
491
|
+
return [...new Set(endpoints)];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function fetchJson(url, timeoutMs = 900) {
|
|
495
|
+
if (typeof fetch !== "function") {
|
|
496
|
+
throw new Error("fetch API unavailable in current Node runtime");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const controller = new AbortController();
|
|
500
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
501
|
+
try {
|
|
502
|
+
const response = await fetch(url, {
|
|
503
|
+
method: "GET",
|
|
504
|
+
signal: controller.signal,
|
|
505
|
+
headers: { "accept": "application/json" },
|
|
506
|
+
});
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
throw new Error(`HTTP ${response.status}`);
|
|
509
|
+
}
|
|
510
|
+
return await response.json();
|
|
511
|
+
} finally {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function inferBrowserFromCdpEndpoint(endpoint) {
|
|
517
|
+
try {
|
|
518
|
+
const parsed = new URL(endpoint);
|
|
519
|
+
const port = Number.parseInt(parsed.port, 10);
|
|
520
|
+
if (port === 9222) return "Google Chrome (CDP)";
|
|
521
|
+
if (port === 9223) return "Microsoft Edge (CDP)";
|
|
522
|
+
if (port === 9333) return "Brave Browser (CDP)";
|
|
523
|
+
return `Browser (CDP:${parsed.host})`;
|
|
524
|
+
} catch {
|
|
525
|
+
return "Browser (CDP)";
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function summarizeFailures(items = [], max = 3) {
|
|
530
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
531
|
+
const shown = items.slice(0, max);
|
|
532
|
+
const suffix = items.length > max ? ` 외 ${items.length - max}개` : "";
|
|
533
|
+
return `${shown.join(", ")}${suffix}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function collectBrowserLlmTabsViaCdp() {
|
|
537
|
+
const endpoints = resolveCdpEndpoints();
|
|
538
|
+
const tabs = [];
|
|
539
|
+
const failures = [];
|
|
540
|
+
|
|
541
|
+
for (const endpoint of endpoints) {
|
|
542
|
+
try {
|
|
543
|
+
const payload = await fetchJson(endpoint);
|
|
544
|
+
if (!Array.isArray(payload)) {
|
|
545
|
+
throw new Error("unexpected payload");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const browser = inferBrowserFromCdpEndpoint(endpoint);
|
|
549
|
+
for (const item of payload) {
|
|
550
|
+
if (!item || String(item.type) !== "page") continue;
|
|
551
|
+
const url = String(item.url || "").trim();
|
|
552
|
+
if (!isLlmUrl(url)) continue;
|
|
553
|
+
tabs.push({
|
|
554
|
+
browser,
|
|
555
|
+
title: String(item.title || "").trim() || "(untitled)",
|
|
556
|
+
url,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
} catch (error) {
|
|
560
|
+
const reason = error instanceof Error ? error.message : "unknown error";
|
|
561
|
+
failures.push(`${endpoint} (${reason})`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const uniqTabs = dedupeBrowserTabs(tabs);
|
|
566
|
+
if (uniqTabs.length > 0) {
|
|
567
|
+
const failSummary = summarizeFailures(failures);
|
|
568
|
+
return {
|
|
569
|
+
tabs: uniqTabs,
|
|
570
|
+
note: failSummary ? `일부 CDP 엔드포인트 접근 실패: ${failSummary}` : null,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const failSummary = summarizeFailures(failures);
|
|
575
|
+
return {
|
|
576
|
+
tabs: [],
|
|
577
|
+
note: `CDP에서 LLM 탭을 찾지 못했습니다. 브라우저를 --remote-debugging-port=9222로 실행하거나 DELIBERATION_BROWSER_TABS_JSON으로 탭 목록을 주입하세요.${failSummary ? ` (실패: ${failSummary})` : ""}`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function collectBrowserLlmTabsViaAppleScript() {
|
|
582
|
+
if (process.platform !== "darwin") {
|
|
583
|
+
return { tabs: [], note: "AppleScript 탭 스캔은 macOS에서만 지원됩니다." };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const escapedDomains = DEFAULT_LLM_DOMAINS.map(d => d.replace(/"/g, '\\"'));
|
|
587
|
+
const escapedApps = DEFAULT_BROWSER_APPS.map(a => a.replace(/"/g, '\\"'));
|
|
588
|
+
const domainList = `{${escapedDomains.map(d => `"${d}"`).join(", ")}}`;
|
|
589
|
+
const appList = `{${escapedApps.map(a => `"${a}"`).join(", ")}}`;
|
|
590
|
+
|
|
591
|
+
const script = [
|
|
592
|
+
`set llmDomains to ${domainList}`,
|
|
593
|
+
`set browserApps to ${appList}`,
|
|
594
|
+
"set outText to \"\"",
|
|
595
|
+
// Pre-check running apps via System Events (no locate dialog)
|
|
596
|
+
"tell application \"System Events\"",
|
|
597
|
+
"set runningApps to name of every application process",
|
|
598
|
+
"end tell",
|
|
599
|
+
"repeat with appName in browserApps",
|
|
600
|
+
"if runningApps contains (appName as string) then",
|
|
601
|
+
"try",
|
|
602
|
+
"tell application (appName as string)",
|
|
603
|
+
"if (appName as string) is \"Safari\" then",
|
|
604
|
+
"repeat with w in windows",
|
|
605
|
+
"try",
|
|
606
|
+
"repeat with t in tabs of w",
|
|
607
|
+
"set u to URL of t as string",
|
|
608
|
+
"set matched to false",
|
|
609
|
+
"repeat with d in llmDomains",
|
|
610
|
+
"if u contains (d as string) then set matched to true",
|
|
611
|
+
"end repeat",
|
|
612
|
+
"if matched then set outText to outText & (appName as string) & tab & (name of t as string) & tab & u & linefeed",
|
|
613
|
+
"end repeat",
|
|
614
|
+
"end try",
|
|
615
|
+
"end repeat",
|
|
616
|
+
"else",
|
|
617
|
+
"repeat with w in windows",
|
|
618
|
+
"try",
|
|
619
|
+
"repeat with t in tabs of w",
|
|
620
|
+
"set u to URL of t as string",
|
|
621
|
+
"set matched to false",
|
|
622
|
+
"repeat with d in llmDomains",
|
|
623
|
+
"if u contains (d as string) then set matched to true",
|
|
624
|
+
"end repeat",
|
|
625
|
+
"if matched then set outText to outText & (appName as string) & tab & (title of t as string) & tab & u & linefeed",
|
|
626
|
+
"end repeat",
|
|
627
|
+
"end try",
|
|
628
|
+
"end repeat",
|
|
629
|
+
"end if",
|
|
630
|
+
"end tell",
|
|
631
|
+
"on error errMsg",
|
|
632
|
+
"set outText to outText & (appName as string) & tab & \"ERROR\" & tab & errMsg & linefeed",
|
|
633
|
+
"end try",
|
|
634
|
+
"end if",
|
|
635
|
+
"end repeat",
|
|
636
|
+
"return outText",
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
const raw = execFileSync("osascript", script.flatMap(line => ["-e", line]), {
|
|
641
|
+
encoding: "utf-8",
|
|
642
|
+
timeout: 8000,
|
|
643
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
644
|
+
});
|
|
645
|
+
const rows = String(raw)
|
|
646
|
+
.split("\n")
|
|
647
|
+
.map(line => line.trim())
|
|
648
|
+
.filter(Boolean)
|
|
649
|
+
.map(line => {
|
|
650
|
+
const [browser = "", title = "", url = ""] = line.split("\t");
|
|
651
|
+
return { browser, title, url };
|
|
652
|
+
});
|
|
653
|
+
const tabs = rows.filter(r => r.title !== "ERROR");
|
|
654
|
+
const errors = rows.filter(r => r.title === "ERROR");
|
|
655
|
+
return {
|
|
656
|
+
tabs,
|
|
657
|
+
note: errors.length > 0
|
|
658
|
+
? `일부 브라우저 접근 실패: ${errors.map(e => `${e.browser} (${e.url})`).join(", ")}`
|
|
659
|
+
: null,
|
|
660
|
+
};
|
|
661
|
+
} catch (error) {
|
|
662
|
+
const reason = error instanceof Error ? error.message : "unknown error";
|
|
663
|
+
return {
|
|
664
|
+
tabs: [],
|
|
665
|
+
note: `브라우저 탭 스캔 실패: ${reason}. macOS 자동화 권한(터미널 -> 브라우저 제어)을 확인하세요.`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function collectBrowserLlmTabs() {
|
|
671
|
+
const mode = (process.env.DELIBERATION_BROWSER_SCAN_MODE || "auto").trim().toLowerCase();
|
|
672
|
+
const tabs = [];
|
|
673
|
+
const notes = [];
|
|
674
|
+
|
|
675
|
+
const injected = parseInjectedBrowserTabsFromEnv();
|
|
676
|
+
tabs.push(...injected.tabs);
|
|
677
|
+
if (injected.note) notes.push(injected.note);
|
|
678
|
+
|
|
679
|
+
if (mode === "off") {
|
|
680
|
+
return {
|
|
681
|
+
tabs: dedupeBrowserTabs(tabs),
|
|
682
|
+
note: notes.length > 0 ? notes.join(" | ") : "브라우저 탭 자동 스캔이 비활성화되었습니다.",
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const shouldUseAppleScript = mode === "auto" || mode === "applescript";
|
|
687
|
+
if (shouldUseAppleScript && process.platform === "darwin") {
|
|
688
|
+
const mac = collectBrowserLlmTabsViaAppleScript();
|
|
689
|
+
tabs.push(...mac.tabs);
|
|
690
|
+
if (mac.note) notes.push(mac.note);
|
|
691
|
+
} else if (mode === "applescript" && process.platform !== "darwin") {
|
|
692
|
+
notes.push("AppleScript 스캔은 macOS 전용입니다. CDP 스캔으로 전환하세요.");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const shouldUseCdp = mode === "auto" || mode === "cdp";
|
|
696
|
+
if (shouldUseCdp) {
|
|
697
|
+
const cdp = await collectBrowserLlmTabsViaCdp();
|
|
698
|
+
tabs.push(...cdp.tabs);
|
|
699
|
+
if (cdp.note) notes.push(cdp.note);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const uniqTabs = dedupeBrowserTabs(tabs);
|
|
703
|
+
return {
|
|
704
|
+
tabs: uniqTabs,
|
|
705
|
+
note: notes.length > 0 ? notes.join(" | ") : null,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function inferLlmProvider(url = "") {
|
|
710
|
+
const value = String(url).toLowerCase();
|
|
711
|
+
if (value.includes("claude.ai") || value.includes("anthropic.com")) return "claude";
|
|
712
|
+
if (value.includes("chatgpt.com") || value.includes("openai.com")) return "chatgpt";
|
|
713
|
+
if (value.includes("gemini.google.com") || value.includes("notebooklm.google.com")) return "gemini";
|
|
714
|
+
if (value.includes("copilot.microsoft.com")) return "copilot";
|
|
715
|
+
if (value.includes("perplexity.ai")) return "perplexity";
|
|
716
|
+
if (value.includes("poe.com")) return "poe";
|
|
717
|
+
if (value.includes("mistral.ai")) return "mistral";
|
|
718
|
+
if (value.includes("huggingface.co/chat")) return "huggingface";
|
|
719
|
+
if (value.includes("deepseek.com")) return "deepseek";
|
|
720
|
+
if (value.includes("qwen.ai")) return "qwen";
|
|
721
|
+
return "web-llm";
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
|
|
725
|
+
const candidates = [];
|
|
726
|
+
const seen = new Set();
|
|
727
|
+
|
|
728
|
+
const add = (candidate) => {
|
|
729
|
+
const speaker = normalizeSpeaker(candidate?.speaker);
|
|
730
|
+
if (!speaker || seen.has(speaker)) return;
|
|
731
|
+
seen.add(speaker);
|
|
732
|
+
candidates.push({ ...candidate, speaker });
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
if (include_cli) {
|
|
736
|
+
for (const cli of discoverLocalCliSpeakers()) {
|
|
737
|
+
add({
|
|
738
|
+
speaker: cli,
|
|
739
|
+
type: "cli",
|
|
740
|
+
label: cli,
|
|
741
|
+
command: cli,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let browserNote = null;
|
|
747
|
+
if (include_browser) {
|
|
748
|
+
const { tabs, note } = await collectBrowserLlmTabs();
|
|
749
|
+
browserNote = note || null;
|
|
750
|
+
const providerCounts = new Map();
|
|
751
|
+
for (const tab of tabs) {
|
|
752
|
+
const provider = inferLlmProvider(tab.url);
|
|
753
|
+
const count = (providerCounts.get(provider) || 0) + 1;
|
|
754
|
+
providerCounts.set(provider, count);
|
|
755
|
+
add({
|
|
756
|
+
speaker: `web-${provider}-${count}`,
|
|
757
|
+
type: "browser",
|
|
758
|
+
provider,
|
|
759
|
+
browser: tab.browser || "",
|
|
760
|
+
title: tab.title || "",
|
|
761
|
+
url: tab.url || "",
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// CDP auto-detection: probe endpoints for matching tabs
|
|
766
|
+
const cdpEndpoints = resolveCdpEndpoints();
|
|
767
|
+
const cdpTabs = [];
|
|
768
|
+
for (const endpoint of cdpEndpoints) {
|
|
769
|
+
try {
|
|
770
|
+
const tabs = await fetchJson(endpoint, 2000);
|
|
771
|
+
if (Array.isArray(tabs)) {
|
|
772
|
+
for (const t of tabs) {
|
|
773
|
+
if (t.type === "page" && t.url) cdpTabs.push(t);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
} catch { /* endpoint not reachable */ }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Match CDP tabs with discovered browser candidates
|
|
780
|
+
for (const candidate of candidates) {
|
|
781
|
+
if (candidate.type !== "browser") continue;
|
|
782
|
+
let candidateHost = "";
|
|
783
|
+
try {
|
|
784
|
+
candidateHost = new URL(candidate.url).hostname.toLowerCase();
|
|
785
|
+
} catch { continue; }
|
|
786
|
+
if (!candidateHost) continue;
|
|
787
|
+
const matches = cdpTabs.filter(t => {
|
|
788
|
+
try {
|
|
789
|
+
return new URL(t.url).hostname.toLowerCase() === candidateHost;
|
|
790
|
+
} catch { return false; }
|
|
791
|
+
});
|
|
792
|
+
if (matches.length === 1) {
|
|
793
|
+
candidate.cdp_available = true;
|
|
794
|
+
candidate.cdp_tab_id = matches[0].id;
|
|
795
|
+
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return { candidates, browserNote };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function formatSpeakerCandidatesReport({ candidates, browserNote }) {
|
|
804
|
+
const cli = candidates.filter(c => c.type === "cli");
|
|
805
|
+
const browser = candidates.filter(c => c.type === "browser");
|
|
806
|
+
|
|
807
|
+
let out = "## Selectable Speakers\n\n";
|
|
808
|
+
out += "### CLI\n";
|
|
809
|
+
if (cli.length === 0) {
|
|
810
|
+
out += "- (감지된 로컬 CLI 없음)\n\n";
|
|
811
|
+
} else {
|
|
812
|
+
out += `${cli.map(c => `- \`${c.speaker}\` (command: ${c.command})`).join("\n")}\n\n`;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
out += "### Browser LLM\n";
|
|
816
|
+
if (browser.length === 0) {
|
|
817
|
+
out += "- (감지된 브라우저 LLM 탭 없음)\n";
|
|
818
|
+
} else {
|
|
819
|
+
out += `${browser.map(c => {
|
|
820
|
+
const icon = c.cdp_available ? "⚡자동" : "📋클립보드";
|
|
821
|
+
return `- \`${c.speaker}\` [${icon}] [${c.browser}] ${c.title}\n ${c.url}`;
|
|
822
|
+
}).join("\n")}\n`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (browserNote) {
|
|
826
|
+
out += `\n\nℹ️ ${browserNote}`;
|
|
827
|
+
}
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function mapParticipantProfiles(speakers, candidates, typeOverrides) {
|
|
832
|
+
const bySpeaker = new Map();
|
|
833
|
+
for (const c of candidates || []) {
|
|
834
|
+
const key = normalizeSpeaker(c.speaker);
|
|
835
|
+
if (key) bySpeaker.set(key, c);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const overrides = typeOverrides || {};
|
|
839
|
+
|
|
840
|
+
const profiles = [];
|
|
841
|
+
for (const raw of speakers || []) {
|
|
842
|
+
const speaker = normalizeSpeaker(raw);
|
|
843
|
+
if (!speaker) continue;
|
|
844
|
+
|
|
845
|
+
// Check for explicit type override
|
|
846
|
+
const overrideType = overrides[speaker] || overrides[raw];
|
|
847
|
+
if (overrideType) {
|
|
848
|
+
profiles.push({
|
|
849
|
+
speaker,
|
|
850
|
+
type: overrideType,
|
|
851
|
+
...(overrideType === "browser_auto" ? { provider: "chatgpt" } : {}),
|
|
852
|
+
});
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const candidate = bySpeaker.get(speaker);
|
|
857
|
+
if (!candidate) {
|
|
858
|
+
profiles.push({
|
|
859
|
+
speaker,
|
|
860
|
+
type: "manual",
|
|
861
|
+
});
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (candidate.type === "cli") {
|
|
866
|
+
profiles.push({
|
|
867
|
+
speaker,
|
|
868
|
+
type: "cli",
|
|
869
|
+
command: candidate.command || speaker,
|
|
870
|
+
});
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
|
|
875
|
+
profiles.push({
|
|
876
|
+
speaker,
|
|
877
|
+
type: effectiveType,
|
|
878
|
+
provider: candidate.provider || null,
|
|
879
|
+
browser: candidate.browser || null,
|
|
880
|
+
title: candidate.title || null,
|
|
881
|
+
url: candidate.url || null,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
return profiles;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ── Transport routing ─────────────────────────────────────────
|
|
888
|
+
|
|
889
|
+
const TRANSPORT_TYPES = {
|
|
890
|
+
cli: "cli_respond",
|
|
891
|
+
browser: "clipboard",
|
|
892
|
+
browser_auto: "browser_auto",
|
|
893
|
+
manual: "manual",
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// BrowserControlPort singleton — initialized lazily on first use
|
|
897
|
+
let _browserPort = null;
|
|
898
|
+
function getBrowserPort() {
|
|
899
|
+
if (!_browserPort) {
|
|
900
|
+
const cdpEndpoints = resolveCdpEndpoints();
|
|
901
|
+
_browserPort = new OrchestratedBrowserPort({ cdpEndpoints });
|
|
902
|
+
}
|
|
903
|
+
return _browserPort;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function resolveTransportForSpeaker(state, speaker) {
|
|
907
|
+
const normalizedSpeaker = normalizeSpeaker(speaker);
|
|
908
|
+
if (!normalizedSpeaker || !state?.participant_profiles) {
|
|
909
|
+
return { transport: "manual", reason: "no_profile" };
|
|
910
|
+
}
|
|
911
|
+
const profile = state.participant_profiles.find(
|
|
912
|
+
p => normalizeSpeaker(p.speaker) === normalizedSpeaker
|
|
913
|
+
);
|
|
914
|
+
if (!profile) {
|
|
915
|
+
return { transport: "manual", reason: "speaker_not_in_profiles" };
|
|
916
|
+
}
|
|
917
|
+
const transport = TRANSPORT_TYPES[profile.type] || "manual";
|
|
918
|
+
return { transport, profile, reason: null };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function formatTransportGuidance(transport, state, speaker) {
|
|
922
|
+
const sid = state.id;
|
|
923
|
+
switch (transport) {
|
|
924
|
+
case "cli_respond":
|
|
925
|
+
return `CLI speaker입니다. \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 직접 응답하세요.`;
|
|
926
|
+
case "clipboard":
|
|
927
|
+
return `브라우저 LLM speaker입니다. 다음 순서로 진행하세요:\n1. \`deliberation_clipboard_prepare_turn(session_id: "${sid}")\` → 클립보드에 프롬프트 복사\n2. 브라우저 LLM에 붙여넣고 응답 생성\n3. 응답을 복사한 뒤 \`deliberation_clipboard_submit_turn(session_id: "${sid}", speaker: "${speaker}")\` 호출`;
|
|
928
|
+
case "browser_auto":
|
|
929
|
+
return `자동 브라우저 speaker입니다. \`deliberation_browser_auto_turn(session_id: "${sid}")\`으로 자동 진행됩니다. CDP를 통해 브라우저 LLM에 직접 입력하고 응답을 읽습니다.`;
|
|
930
|
+
case "manual":
|
|
931
|
+
default:
|
|
932
|
+
return `수동 speaker입니다. 응답을 직접 작성해 \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`로 제출하세요.`;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function buildSpeakerOrder(speakers, fallbackSpeaker = DEFAULT_SPEAKERS[0], fallbackPlacement = "front") {
|
|
937
|
+
const ordered = [];
|
|
938
|
+
const seen = new Set();
|
|
939
|
+
|
|
940
|
+
const add = (candidate) => {
|
|
941
|
+
const speaker = normalizeSpeaker(candidate);
|
|
942
|
+
if (!speaker || seen.has(speaker)) return;
|
|
943
|
+
seen.add(speaker);
|
|
944
|
+
ordered.push(speaker);
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
if (fallbackPlacement === "front") {
|
|
948
|
+
add(fallbackSpeaker);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (Array.isArray(speakers)) {
|
|
952
|
+
for (const speaker of speakers) {
|
|
953
|
+
add(speaker);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (fallbackPlacement !== "front") {
|
|
958
|
+
add(fallbackSpeaker);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (ordered.length === 0) {
|
|
962
|
+
for (const speaker of DEFAULT_SPEAKERS) {
|
|
963
|
+
add(speaker);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return ordered;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function normalizeSessionActors(state) {
|
|
971
|
+
if (!state || typeof state !== "object") return state;
|
|
972
|
+
|
|
973
|
+
const fallbackSpeaker = normalizeSpeaker(state.current_speaker)
|
|
974
|
+
|| normalizeSpeaker(state.log?.[0]?.speaker)
|
|
975
|
+
|| DEFAULT_SPEAKERS[0];
|
|
976
|
+
const speakers = buildSpeakerOrder(state.speakers, fallbackSpeaker, "end");
|
|
977
|
+
state.speakers = speakers;
|
|
978
|
+
|
|
979
|
+
const normalizedCurrent = normalizeSpeaker(state.current_speaker);
|
|
980
|
+
if (state.status === "active") {
|
|
981
|
+
state.current_speaker = (normalizedCurrent && speakers.includes(normalizedCurrent))
|
|
982
|
+
? normalizedCurrent
|
|
983
|
+
: speakers[0];
|
|
984
|
+
} else if (normalizedCurrent) {
|
|
985
|
+
state.current_speaker = normalizedCurrent;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return state;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ── Session ID generation ─────────────────────────────────────
|
|
992
|
+
|
|
993
|
+
function generateSessionId(topic) {
|
|
994
|
+
const slug = topic
|
|
995
|
+
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
996
|
+
.replace(/\s+/g, "-")
|
|
997
|
+
.toLowerCase()
|
|
998
|
+
.slice(0, 20);
|
|
999
|
+
const ts = Date.now().toString(36);
|
|
1000
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
1001
|
+
return `${slug}-${ts}${rand}`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function generateTurnId() {
|
|
1005
|
+
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ── Context detection ──────────────────────────────────────────
|
|
1009
|
+
|
|
1010
|
+
function detectContextDirs() {
|
|
1011
|
+
const dirs = [];
|
|
1012
|
+
const slug = getProjectSlug();
|
|
1013
|
+
|
|
1014
|
+
if (process.env.DELIBERATION_CONTEXT_DIR) {
|
|
1015
|
+
dirs.push(process.env.DELIBERATION_CONTEXT_DIR);
|
|
1016
|
+
}
|
|
1017
|
+
dirs.push(process.cwd());
|
|
1018
|
+
|
|
1019
|
+
const obsidianProject = path.join(OBSIDIAN_PROJECTS, slug);
|
|
1020
|
+
if (fs.existsSync(obsidianProject)) {
|
|
1021
|
+
dirs.push(obsidianProject);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return [...new Set(dirs)];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function readContextFromDirs(dirs, maxChars = 15000) {
|
|
1028
|
+
let context = "";
|
|
1029
|
+
const seen = new Set();
|
|
1030
|
+
|
|
1031
|
+
for (const dir of dirs) {
|
|
1032
|
+
if (!fs.existsSync(dir)) continue;
|
|
1033
|
+
|
|
1034
|
+
const files = fs.readdirSync(dir)
|
|
1035
|
+
.filter(f => f.endsWith(".md") && !f.startsWith("_") && !f.startsWith("."))
|
|
1036
|
+
.sort();
|
|
1037
|
+
|
|
1038
|
+
for (const file of files) {
|
|
1039
|
+
if (seen.has(file)) continue;
|
|
1040
|
+
seen.add(file);
|
|
1041
|
+
|
|
1042
|
+
const fullPath = path.join(dir, file);
|
|
1043
|
+
let raw;
|
|
1044
|
+
try { raw = fs.readFileSync(fullPath, "utf-8"); } catch { continue; }
|
|
1045
|
+
|
|
1046
|
+
let body = raw;
|
|
1047
|
+
if (body.startsWith("---")) {
|
|
1048
|
+
const end = body.indexOf("---", 3);
|
|
1049
|
+
if (end !== -1) body = body.slice(end + 3).trim();
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const truncated = body.length > 1200
|
|
1053
|
+
? body.slice(0, 1200) + "\n(...)"
|
|
1054
|
+
: body;
|
|
1055
|
+
|
|
1056
|
+
context += `### ${file.replace(".md", "")}\n${truncated}\n\n---\n\n`;
|
|
1057
|
+
|
|
1058
|
+
if (context.length > maxChars) {
|
|
1059
|
+
context = context.slice(0, maxChars) + "\n\n(...context truncated)";
|
|
1060
|
+
return context;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return context || "(컨텍스트 파일을 찾을 수 없습니다)";
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ── State helpers ──────────────────────────────────────────────
|
|
1068
|
+
|
|
1069
|
+
function ensureDirs() {
|
|
1070
|
+
fs.mkdirSync(getSessionsDir(), { recursive: true });
|
|
1071
|
+
fs.mkdirSync(getArchiveDir(), { recursive: true });
|
|
1072
|
+
fs.mkdirSync(getLocksDir(), { recursive: true });
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function loadSession(sessionId) {
|
|
1076
|
+
const file = getSessionFile(sessionId);
|
|
1077
|
+
if (!fs.existsSync(file)) return null;
|
|
1078
|
+
return normalizeSessionActors(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function saveSession(state) {
|
|
1082
|
+
ensureDirs();
|
|
1083
|
+
state.updated = new Date().toISOString();
|
|
1084
|
+
writeTextAtomic(getSessionFile(state.id), JSON.stringify(state, null, 2));
|
|
1085
|
+
syncMarkdown(state);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function listActiveSessions() {
|
|
1089
|
+
const dir = getSessionsDir();
|
|
1090
|
+
if (!fs.existsSync(dir)) return [];
|
|
1091
|
+
|
|
1092
|
+
return fs.readdirSync(dir)
|
|
1093
|
+
.filter(f => f.endsWith(".json"))
|
|
1094
|
+
.map(f => {
|
|
1095
|
+
try {
|
|
1096
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
1097
|
+
return data;
|
|
1098
|
+
} catch { return null; }
|
|
1099
|
+
})
|
|
1100
|
+
.filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function resolveSessionId(sessionId) {
|
|
1104
|
+
// session_id가 주어지면 그대로 사용
|
|
1105
|
+
if (sessionId) return sessionId;
|
|
1106
|
+
|
|
1107
|
+
// 없으면 활성 세션이 1개일 때 자동 선택
|
|
1108
|
+
const active = listActiveSessions();
|
|
1109
|
+
if (active.length === 0) return null;
|
|
1110
|
+
if (active.length === 1) return active[0].id;
|
|
1111
|
+
|
|
1112
|
+
// 여러 개면 null (목록 표시 필요)
|
|
1113
|
+
return "MULTIPLE";
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function syncMarkdown(state) {
|
|
1117
|
+
const filename = `deliberation-${state.id}.md`;
|
|
1118
|
+
const mdPath = path.join(process.cwd(), filename);
|
|
1119
|
+
try {
|
|
1120
|
+
writeTextAtomic(mdPath, stateToMarkdown(state));
|
|
1121
|
+
} catch {
|
|
1122
|
+
const fallback = path.join(getProjectStateDir(), filename);
|
|
1123
|
+
writeTextAtomic(fallback, stateToMarkdown(state));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function stateToMarkdown(s) {
|
|
1128
|
+
const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
|
|
1129
|
+
let md = `---
|
|
1130
|
+
title: "Deliberation - ${s.topic}"
|
|
1131
|
+
session_id: "${s.id}"
|
|
1132
|
+
created: ${s.created}
|
|
1133
|
+
updated: ${s.updated || new Date().toISOString()}
|
|
1134
|
+
type: deliberation
|
|
1135
|
+
status: ${s.status}
|
|
1136
|
+
project: "${s.project}"
|
|
1137
|
+
participants: ${JSON.stringify(speakerOrder)}
|
|
1138
|
+
rounds: ${s.max_rounds}
|
|
1139
|
+
current_round: ${s.current_round}
|
|
1140
|
+
current_speaker: "${s.current_speaker}"
|
|
1141
|
+
tags: [deliberation]
|
|
1142
|
+
---
|
|
1143
|
+
|
|
1144
|
+
# Deliberation: ${s.topic}
|
|
1145
|
+
|
|
1146
|
+
**Session:** ${s.id} | **Project:** ${s.project} | **Status:** ${s.status} | **Round:** ${s.current_round}/${s.max_rounds} | **Next:** ${s.current_speaker}
|
|
1147
|
+
|
|
1148
|
+
---
|
|
1149
|
+
|
|
1150
|
+
`;
|
|
1151
|
+
|
|
1152
|
+
if (s.synthesis) {
|
|
1153
|
+
md += `## Synthesis\n\n${s.synthesis}\n\n---\n\n`;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
md += `## Debate Log\n\n`;
|
|
1157
|
+
for (const entry of s.log) {
|
|
1158
|
+
md += `### ${entry.speaker} — Round ${entry.round}\n\n`;
|
|
1159
|
+
if (entry.channel_used || entry.fallback_reason) {
|
|
1160
|
+
const parts = [];
|
|
1161
|
+
if (entry.channel_used) parts.push(`channel: ${entry.channel_used}`);
|
|
1162
|
+
if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
|
|
1163
|
+
md += `> _${parts.join(" | ")}_\n\n`;
|
|
1164
|
+
}
|
|
1165
|
+
md += `${entry.content}\n\n---\n\n`;
|
|
1166
|
+
}
|
|
1167
|
+
return md;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function archiveState(state) {
|
|
1171
|
+
ensureDirs();
|
|
1172
|
+
const slug = state.topic
|
|
1173
|
+
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
1174
|
+
.replace(/\s+/g, "-")
|
|
1175
|
+
.slice(0, 30);
|
|
1176
|
+
const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
|
|
1177
|
+
const filename = `deliberation-${ts}-${slug}.md`;
|
|
1178
|
+
const dest = path.join(getArchiveDir(), filename);
|
|
1179
|
+
writeTextAtomic(dest, stateToMarkdown(state));
|
|
1180
|
+
return dest;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ── Terminal management ────────────────────────────────────────
|
|
1184
|
+
|
|
1185
|
+
const TMUX_SESSION = "deliberation";
|
|
1186
|
+
const MONITOR_SCRIPT = path.join(HOME, ".local", "lib", "mcp-deliberation", "session-monitor.sh");
|
|
1187
|
+
|
|
1188
|
+
function tmuxWindowName(sessionId) {
|
|
1189
|
+
// tmux 윈도우 이름은 짧게 (마지막 부분 제거하고 20자)
|
|
1190
|
+
return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function appleScriptQuote(value) {
|
|
1194
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function tryExecFile(command, args = []) {
|
|
1198
|
+
try {
|
|
1199
|
+
execFileSync(command, args, { stdio: "ignore", windowsHide: true });
|
|
1200
|
+
return true;
|
|
1201
|
+
} catch {
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function resolveMonitorShell() {
|
|
1207
|
+
if (commandExistsInPath("bash")) return "bash";
|
|
1208
|
+
if (commandExistsInPath("sh")) return "sh";
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function buildMonitorCommand(sessionId, project) {
|
|
1213
|
+
const shell = resolveMonitorShell();
|
|
1214
|
+
if (!shell) return null;
|
|
1215
|
+
return `${shell} ${shellQuote(MONITOR_SCRIPT)} ${shellQuote(sessionId)} ${shellQuote(project)}`;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function hasTmuxSession(name) {
|
|
1219
|
+
try {
|
|
1220
|
+
execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore", windowsHide: true });
|
|
1221
|
+
return true;
|
|
1222
|
+
} catch {
|
|
1223
|
+
return false;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function tmuxWindowCount(name) {
|
|
1228
|
+
try {
|
|
1229
|
+
const output = execFileSync("tmux", ["list-windows", "-t", name], {
|
|
1230
|
+
encoding: "utf-8",
|
|
1231
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1232
|
+
windowsHide: true,
|
|
1233
|
+
});
|
|
1234
|
+
return String(output)
|
|
1235
|
+
.split("\n")
|
|
1236
|
+
.map(line => line.trim())
|
|
1237
|
+
.filter(Boolean)
|
|
1238
|
+
.length;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return 0;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function buildTmuxAttachCommand(sessionId) {
|
|
1245
|
+
const winName = tmuxWindowName(sessionId);
|
|
1246
|
+
return `tmux attach -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function listPhysicalTerminalWindowIds() {
|
|
1250
|
+
if (process.platform !== "darwin") {
|
|
1251
|
+
return [];
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
const output = execFileSync(
|
|
1255
|
+
"osascript",
|
|
1256
|
+
[
|
|
1257
|
+
"-e",
|
|
1258
|
+
'tell application "Terminal"',
|
|
1259
|
+
"-e",
|
|
1260
|
+
"if not running then return \"\"",
|
|
1261
|
+
"-e",
|
|
1262
|
+
"set outText to \"\"",
|
|
1263
|
+
"-e",
|
|
1264
|
+
"repeat with w in windows",
|
|
1265
|
+
"-e",
|
|
1266
|
+
"set outText to outText & (id of w as string) & linefeed",
|
|
1267
|
+
"-e",
|
|
1268
|
+
"end repeat",
|
|
1269
|
+
"-e",
|
|
1270
|
+
"return outText",
|
|
1271
|
+
"-e",
|
|
1272
|
+
"end tell",
|
|
1273
|
+
],
|
|
1274
|
+
{ encoding: "utf-8" }
|
|
1275
|
+
);
|
|
1276
|
+
return String(output)
|
|
1277
|
+
.split("\n")
|
|
1278
|
+
.map(s => Number.parseInt(s.trim(), 10))
|
|
1279
|
+
.filter(n => Number.isInteger(n) && n > 0);
|
|
1280
|
+
} catch {
|
|
1281
|
+
return [];
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function openPhysicalTerminal(sessionId) {
|
|
1286
|
+
const winName = tmuxWindowName(sessionId);
|
|
1287
|
+
const attachCmd = `tmux attach -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
1288
|
+
|
|
1289
|
+
if (process.platform === "darwin") {
|
|
1290
|
+
const before = new Set(listPhysicalTerminalWindowIds());
|
|
1291
|
+
try {
|
|
1292
|
+
const output = execFileSync(
|
|
1293
|
+
"osascript",
|
|
1294
|
+
[
|
|
1295
|
+
"-e",
|
|
1296
|
+
'tell application "Terminal"',
|
|
1297
|
+
"-e",
|
|
1298
|
+
`do script ${appleScriptQuote(attachCmd)}`,
|
|
1299
|
+
"-e",
|
|
1300
|
+
"delay 0.15",
|
|
1301
|
+
"-e",
|
|
1302
|
+
"return id of front window",
|
|
1303
|
+
"-e",
|
|
1304
|
+
"end tell",
|
|
1305
|
+
],
|
|
1306
|
+
{ encoding: "utf-8" }
|
|
1307
|
+
);
|
|
1308
|
+
const frontId = Number.parseInt(String(output).trim(), 10);
|
|
1309
|
+
const after = listPhysicalTerminalWindowIds();
|
|
1310
|
+
const opened = after.filter(id => !before.has(id));
|
|
1311
|
+
if (opened.length > 0) {
|
|
1312
|
+
return { opened: true, windowIds: [...new Set(opened)] };
|
|
1313
|
+
}
|
|
1314
|
+
if (Number.isInteger(frontId) && frontId > 0) {
|
|
1315
|
+
return { opened: true, windowIds: [frontId] };
|
|
1316
|
+
}
|
|
1317
|
+
return { opened: false, windowIds: [] };
|
|
1318
|
+
} catch {
|
|
1319
|
+
return { opened: false, windowIds: [] };
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (process.platform === "linux") {
|
|
1324
|
+
const shell = resolveMonitorShell() || "sh";
|
|
1325
|
+
const launchCmd = `${buildTmuxAttachCommand(sessionId)}; exec ${shell}`;
|
|
1326
|
+
const attempts = [
|
|
1327
|
+
["gnome-terminal", ["--", shell, "-lc", launchCmd]],
|
|
1328
|
+
["kgx", ["--", shell, "-lc", launchCmd]],
|
|
1329
|
+
["konsole", ["-e", shell, "-lc", launchCmd]],
|
|
1330
|
+
["x-terminal-emulator", ["-e", shell, "-lc", launchCmd]],
|
|
1331
|
+
["xterm", ["-e", shell, "-lc", launchCmd]],
|
|
1332
|
+
["alacritty", ["-e", shell, "-lc", launchCmd]],
|
|
1333
|
+
["kitty", [shell, "-lc", launchCmd]],
|
|
1334
|
+
["wezterm", ["start", "--", shell, "-lc", launchCmd]],
|
|
1335
|
+
];
|
|
1336
|
+
|
|
1337
|
+
for (const [command, args] of attempts) {
|
|
1338
|
+
if (!commandExistsInPath(command)) continue;
|
|
1339
|
+
if (tryExecFile(command, args)) {
|
|
1340
|
+
return { opened: true, windowIds: [] };
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return { opened: false, windowIds: [] };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
if (process.platform === "win32") {
|
|
1347
|
+
const attachForWindows = `tmux attach -t "${TMUX_SESSION}"`;
|
|
1348
|
+
if ((commandExistsInPath("wt.exe") || commandExistsInPath("wt"))
|
|
1349
|
+
&& tryExecFile("wt", ["new-tab", "powershell", "-NoExit", "-Command", attachForWindows])) {
|
|
1350
|
+
return { opened: true, windowIds: [] };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const shell = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]
|
|
1354
|
+
.find(cmd => commandExistsInPath(cmd));
|
|
1355
|
+
if (shell) {
|
|
1356
|
+
const targetShell = shell.toLowerCase().startsWith("pwsh") ? "pwsh" : "powershell";
|
|
1357
|
+
const escaped = attachForWindows.replace(/'/g, "''");
|
|
1358
|
+
const script = `Start-Process ${targetShell} -ArgumentList '-NoExit','-Command','${escaped}'`;
|
|
1359
|
+
if (tryExecFile(shell, ["-NoProfile", "-Command", script])) {
|
|
1360
|
+
return { opened: true, windowIds: [] };
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return { opened: false, windowIds: [] };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function spawnMonitorTerminal(sessionId) {
|
|
1369
|
+
if (!commandExistsInPath("tmux")) {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const project = getProjectSlug();
|
|
1374
|
+
const winName = tmuxWindowName(sessionId);
|
|
1375
|
+
const cmd = buildMonitorCommand(sessionId, project);
|
|
1376
|
+
if (!cmd) {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
try {
|
|
1381
|
+
if (hasTmuxSession(TMUX_SESSION)) {
|
|
1382
|
+
execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
|
|
1383
|
+
stdio: "ignore",
|
|
1384
|
+
windowsHide: true,
|
|
1385
|
+
});
|
|
1386
|
+
} else {
|
|
1387
|
+
execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
|
|
1388
|
+
stdio: "ignore",
|
|
1389
|
+
windowsHide: true,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
return true;
|
|
1393
|
+
} catch {
|
|
1394
|
+
return false;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function closePhysicalTerminal(windowId) {
|
|
1399
|
+
if (process.platform !== "darwin") {
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
if (!Number.isInteger(windowId) || windowId <= 0) {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const windowExists = () => {
|
|
1407
|
+
try {
|
|
1408
|
+
const out = execFileSync(
|
|
1409
|
+
"osascript",
|
|
1410
|
+
[
|
|
1411
|
+
"-e",
|
|
1412
|
+
'tell application "Terminal"',
|
|
1413
|
+
"-e",
|
|
1414
|
+
`if exists window id ${windowId} then return "1"`,
|
|
1415
|
+
"-e",
|
|
1416
|
+
'return "0"',
|
|
1417
|
+
"-e",
|
|
1418
|
+
"end tell",
|
|
1419
|
+
],
|
|
1420
|
+
{ encoding: "utf-8" }
|
|
1421
|
+
).trim();
|
|
1422
|
+
return out === "1";
|
|
1423
|
+
} catch {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
const dismissCloseDialogs = () => {
|
|
1429
|
+
try {
|
|
1430
|
+
execFileSync(
|
|
1431
|
+
"osascript",
|
|
1432
|
+
[
|
|
1433
|
+
"-e",
|
|
1434
|
+
'tell application "System Events"',
|
|
1435
|
+
"-e",
|
|
1436
|
+
'if exists process "Terminal" then',
|
|
1437
|
+
"-e",
|
|
1438
|
+
'tell process "Terminal"',
|
|
1439
|
+
"-e",
|
|
1440
|
+
"repeat with w in windows",
|
|
1441
|
+
"-e",
|
|
1442
|
+
"try",
|
|
1443
|
+
"-e",
|
|
1444
|
+
"if exists (sheet 1 of w) then",
|
|
1445
|
+
"-e",
|
|
1446
|
+
"if exists button \"종료\" of sheet 1 of w then",
|
|
1447
|
+
"-e",
|
|
1448
|
+
'click button "종료" of sheet 1 of w',
|
|
1449
|
+
"-e",
|
|
1450
|
+
"else if exists button \"Terminate\" of sheet 1 of w then",
|
|
1451
|
+
"-e",
|
|
1452
|
+
'click button "Terminate" of sheet 1 of w',
|
|
1453
|
+
"-e",
|
|
1454
|
+
"else if exists button \"확인\" of sheet 1 of w then",
|
|
1455
|
+
"-e",
|
|
1456
|
+
'click button "확인" of sheet 1 of w',
|
|
1457
|
+
"-e",
|
|
1458
|
+
"else",
|
|
1459
|
+
"-e",
|
|
1460
|
+
"click button 1 of sheet 1 of w",
|
|
1461
|
+
"-e",
|
|
1462
|
+
"end if",
|
|
1463
|
+
"-e",
|
|
1464
|
+
"end if",
|
|
1465
|
+
"-e",
|
|
1466
|
+
"end try",
|
|
1467
|
+
"-e",
|
|
1468
|
+
"end repeat",
|
|
1469
|
+
"-e",
|
|
1470
|
+
"end tell",
|
|
1471
|
+
"-e",
|
|
1472
|
+
"end if",
|
|
1473
|
+
"-e",
|
|
1474
|
+
"end tell",
|
|
1475
|
+
],
|
|
1476
|
+
{ stdio: "ignore" }
|
|
1477
|
+
);
|
|
1478
|
+
} catch {
|
|
1479
|
+
// ignore
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
for (let i = 0; i < 5; i += 1) {
|
|
1484
|
+
try {
|
|
1485
|
+
execFileSync(
|
|
1486
|
+
"osascript",
|
|
1487
|
+
[
|
|
1488
|
+
"-e",
|
|
1489
|
+
'tell application "Terminal"',
|
|
1490
|
+
"-e",
|
|
1491
|
+
"activate",
|
|
1492
|
+
"-e",
|
|
1493
|
+
`if exists window id ${windowId} then`,
|
|
1494
|
+
"-e",
|
|
1495
|
+
"try",
|
|
1496
|
+
"-e",
|
|
1497
|
+
`do script "exit" in window id ${windowId}`,
|
|
1498
|
+
"-e",
|
|
1499
|
+
"end try",
|
|
1500
|
+
"-e",
|
|
1501
|
+
"delay 0.12",
|
|
1502
|
+
"-e",
|
|
1503
|
+
"try",
|
|
1504
|
+
"-e",
|
|
1505
|
+
`close (window id ${windowId})`,
|
|
1506
|
+
"-e",
|
|
1507
|
+
"end try",
|
|
1508
|
+
"-e",
|
|
1509
|
+
"end if",
|
|
1510
|
+
"-e",
|
|
1511
|
+
"end tell",
|
|
1512
|
+
],
|
|
1513
|
+
{ stdio: "ignore" }
|
|
1514
|
+
);
|
|
1515
|
+
} catch {
|
|
1516
|
+
// ignore
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
dismissCloseDialogs();
|
|
1520
|
+
|
|
1521
|
+
if (!windowExists()) {
|
|
1522
|
+
return true;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
return !windowExists();
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function closeMonitorTerminal(sessionId, terminalWindowIds = []) {
|
|
1530
|
+
const winName = tmuxWindowName(sessionId);
|
|
1531
|
+
try {
|
|
1532
|
+
execFileSync("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${winName}`], {
|
|
1533
|
+
stdio: "ignore",
|
|
1534
|
+
windowsHide: true,
|
|
1535
|
+
});
|
|
1536
|
+
} catch { /* ignore */ }
|
|
1537
|
+
|
|
1538
|
+
try {
|
|
1539
|
+
if (tmuxWindowCount(TMUX_SESSION) === 0) {
|
|
1540
|
+
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], {
|
|
1541
|
+
stdio: "ignore",
|
|
1542
|
+
windowsHide: true,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
} catch { /* ignore */ }
|
|
1546
|
+
|
|
1547
|
+
for (const windowId of terminalWindowIds) {
|
|
1548
|
+
closePhysicalTerminal(windowId);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function getSessionWindowIds(state) {
|
|
1553
|
+
if (!state || typeof state !== "object") {
|
|
1554
|
+
return [];
|
|
1555
|
+
}
|
|
1556
|
+
const ids = [];
|
|
1557
|
+
if (Array.isArray(state.monitor_terminal_window_ids)) {
|
|
1558
|
+
for (const id of state.monitor_terminal_window_ids) {
|
|
1559
|
+
if (Number.isInteger(id) && id > 0) {
|
|
1560
|
+
ids.push(id);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (Number.isInteger(state.monitor_terminal_window_id) && state.monitor_terminal_window_id > 0) {
|
|
1565
|
+
ids.push(state.monitor_terminal_window_id);
|
|
1566
|
+
}
|
|
1567
|
+
return [...new Set(ids)];
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function closeAllMonitorTerminals() {
|
|
1571
|
+
try {
|
|
1572
|
+
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], { stdio: "ignore", windowsHide: true });
|
|
1573
|
+
} catch { /* ignore */ }
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function multipleSessionsError() {
|
|
1577
|
+
const active = listActiveSessions();
|
|
1578
|
+
const list = active.map(s => `- **${s.id}**: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
|
|
1579
|
+
return `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function formatRecentLogForPrompt(state, maxEntries = 4) {
|
|
1583
|
+
const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
|
|
1584
|
+
if (entries.length === 0) {
|
|
1585
|
+
return "(아직 이전 응답 없음)";
|
|
1586
|
+
}
|
|
1587
|
+
return entries.map(e => {
|
|
1588
|
+
const content = String(e.content || "").trim();
|
|
1589
|
+
return `- ${e.speaker} (Round ${e.round})\n${content}`;
|
|
1590
|
+
}).join("\n\n");
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
|
|
1594
|
+
const recent = formatRecentLogForPrompt(state, includeHistoryEntries);
|
|
1595
|
+
const extraPrompt = prompt ? `\n[추가 지시]\n${prompt}\n` : "";
|
|
1596
|
+
return `[deliberation_turn_request]
|
|
1597
|
+
session_id: ${state.id}
|
|
1598
|
+
project: ${state.project}
|
|
1599
|
+
topic: ${state.topic}
|
|
1600
|
+
round: ${state.current_round}/${state.max_rounds}
|
|
1601
|
+
target_speaker: ${speaker}
|
|
1602
|
+
required_turn: ${state.current_speaker}
|
|
1603
|
+
|
|
1604
|
+
[recent_log]
|
|
1605
|
+
${recent}
|
|
1606
|
+
[/recent_log]${extraPrompt}
|
|
1607
|
+
|
|
1608
|
+
[response_rule]
|
|
1609
|
+
- 위 토론 맥락을 반영해 ${speaker}의 이번 턴 응답만 작성
|
|
1610
|
+
- 마크다운 본문만 출력 (불필요한 머리말/꼬리말 금지)
|
|
1611
|
+
[/response_rule]
|
|
1612
|
+
[/deliberation_turn_request]
|
|
1613
|
+
`;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason }) {
|
|
1617
|
+
const resolved = resolveSessionId(session_id);
|
|
1618
|
+
if (!resolved) {
|
|
1619
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
1620
|
+
}
|
|
1621
|
+
if (resolved === "MULTIPLE") {
|
|
1622
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return withSessionLock(resolved, () => {
|
|
1626
|
+
const state = loadSession(resolved);
|
|
1627
|
+
if (!state || state.status !== "active") {
|
|
1628
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const normalizedSpeaker = normalizeSpeaker(speaker);
|
|
1632
|
+
if (!normalizedSpeaker) {
|
|
1633
|
+
return { content: [{ type: "text", text: "speaker 값이 비어 있습니다. 응답자 이름을 지정하세요." }] };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
state.speakers = buildSpeakerOrder(state.speakers, state.current_speaker, "end");
|
|
1637
|
+
const normalizedCurrentSpeaker = normalizeSpeaker(state.current_speaker);
|
|
1638
|
+
if (!normalizedCurrentSpeaker || !state.speakers.includes(normalizedCurrentSpeaker)) {
|
|
1639
|
+
state.current_speaker = state.speakers[0];
|
|
1640
|
+
} else {
|
|
1641
|
+
state.current_speaker = normalizedCurrentSpeaker;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
if (state.current_speaker !== normalizedSpeaker) {
|
|
1645
|
+
return {
|
|
1646
|
+
content: [{
|
|
1647
|
+
type: "text",
|
|
1648
|
+
text: `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. ${normalizedSpeaker}는 대기하세요.`,
|
|
1649
|
+
}],
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// turn_id 검증 (선택적 — 제공 시 반드시 일치해야 함)
|
|
1654
|
+
if (turn_id && state.pending_turn_id && turn_id !== state.pending_turn_id) {
|
|
1655
|
+
return {
|
|
1656
|
+
content: [{
|
|
1657
|
+
type: "text",
|
|
1658
|
+
text: `[${state.id}] turn_id 불일치. 예상: "${state.pending_turn_id}", 수신: "${turn_id}". 오래된 요청이거나 중복 제출일 수 있습니다.`,
|
|
1659
|
+
}],
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
state.log.push({
|
|
1664
|
+
round: state.current_round,
|
|
1665
|
+
speaker: normalizedSpeaker,
|
|
1666
|
+
content,
|
|
1667
|
+
timestamp: new Date().toISOString(),
|
|
1668
|
+
turn_id: state.pending_turn_id || null,
|
|
1669
|
+
channel_used: channel_used || null,
|
|
1670
|
+
fallback_reason: fallback_reason || null,
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
const idx = state.speakers.indexOf(normalizedSpeaker);
|
|
1674
|
+
const nextIdx = (idx + 1) % state.speakers.length;
|
|
1675
|
+
state.current_speaker = state.speakers[nextIdx];
|
|
1676
|
+
|
|
1677
|
+
if (nextIdx === 0) {
|
|
1678
|
+
if (state.current_round >= state.max_rounds) {
|
|
1679
|
+
state.status = "awaiting_synthesis";
|
|
1680
|
+
state.current_speaker = "none";
|
|
1681
|
+
saveSession(state);
|
|
1682
|
+
return {
|
|
1683
|
+
content: [{
|
|
1684
|
+
type: "text",
|
|
1685
|
+
text: `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료.\n\n🏁 **모든 라운드 종료!**\ndeliberation_synthesize(session_id: "${state.id}")로 합성 보고서를 작성하세요.`,
|
|
1686
|
+
}],
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
state.current_round += 1;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
if (state.status === "active") {
|
|
1693
|
+
state.pending_turn_id = generateTurnId();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
saveSession(state);
|
|
1697
|
+
return {
|
|
1698
|
+
content: [{
|
|
1699
|
+
type: "text",
|
|
1700
|
+
text: `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료.\n\n**다음:** ${state.current_speaker} (Round ${state.current_round})`,
|
|
1701
|
+
}],
|
|
1702
|
+
};
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ── MCP Server ─────────────────────────────────────────────────
|
|
1707
|
+
|
|
1708
|
+
process.on("uncaughtException", (error) => {
|
|
1709
|
+
const message = formatRuntimeError(error);
|
|
1710
|
+
appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
|
|
1711
|
+
try {
|
|
1712
|
+
process.stderr.write(`[mcp-deliberation] uncaughtException: ${message}\n`);
|
|
1713
|
+
} catch {
|
|
1714
|
+
// ignore stderr write failures
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
process.on("unhandledRejection", (reason) => {
|
|
1719
|
+
const message = formatRuntimeError(reason);
|
|
1720
|
+
appendRuntimeLog("UNHANDLED_REJECTION", message);
|
|
1721
|
+
try {
|
|
1722
|
+
process.stderr.write(`[mcp-deliberation] unhandledRejection: ${message}\n`);
|
|
1723
|
+
} catch {
|
|
1724
|
+
// ignore stderr write failures
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
const server = new McpServer({
|
|
1729
|
+
name: "mcp-deliberation",
|
|
1730
|
+
version: "2.4.0",
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
server.tool(
|
|
1734
|
+
"deliberation_start",
|
|
1735
|
+
"새 deliberation을 시작합니다. 여러 토론을 동시에 진행할 수 있습니다.",
|
|
1736
|
+
{
|
|
1737
|
+
topic: z.string().describe("토론 주제"),
|
|
1738
|
+
rounds: z.number().default(3).describe("라운드 수 (기본 3)"),
|
|
1739
|
+
first_speaker: z.string().trim().min(1).max(64).optional().describe("첫 발언자 이름 (미지정 시 speakers의 첫 항목)"),
|
|
1740
|
+
speakers: z.array(z.string().trim().min(1).max(64)).min(1).optional().describe("참가자 이름 목록 (예: codex, claude, web-chatgpt-1)"),
|
|
1741
|
+
require_manual_speakers: z.boolean().default(true).describe("true면 speakers를 반드시 직접 지정해야 시작"),
|
|
1742
|
+
auto_discover_speakers: z.boolean().default(false).describe("speakers 생략 시 PATH 기반 자동 탐색 여부 (require_manual_speakers=false일 때만 사용)"),
|
|
1743
|
+
participant_types: z.record(z.string(), z.enum(["cli", "browser", "browser_auto", "manual"])).optional().describe("speaker별 타입 오버라이드 (예: {\"chatgpt\": \"browser_auto\"})"),
|
|
1744
|
+
},
|
|
1745
|
+
safeToolHandler("deliberation_start", async ({ topic, rounds, first_speaker, speakers, require_manual_speakers, auto_discover_speakers, participant_types }) => {
|
|
1746
|
+
const sessionId = generateSessionId(topic);
|
|
1747
|
+
const hasManualSpeakers = Array.isArray(speakers) && speakers.length > 0;
|
|
1748
|
+
const candidateSnapshot = await collectSpeakerCandidates({ include_cli: true, include_browser: true });
|
|
1749
|
+
|
|
1750
|
+
if (!hasManualSpeakers && require_manual_speakers) {
|
|
1751
|
+
const candidateText = formatSpeakerCandidatesReport(candidateSnapshot);
|
|
1752
|
+
return {
|
|
1753
|
+
content: [{
|
|
1754
|
+
type: "text",
|
|
1755
|
+
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를 호출해 현재 선택 가능한 스피커를 확인하세요.`,
|
|
1756
|
+
}],
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const autoDiscoveredSpeakers = (!hasManualSpeakers && auto_discover_speakers)
|
|
1761
|
+
? discoverLocalCliSpeakers()
|
|
1762
|
+
: [];
|
|
1763
|
+
const selectedSpeakers = dedupeSpeakers(hasManualSpeakers
|
|
1764
|
+
? speakers
|
|
1765
|
+
: autoDiscoveredSpeakers);
|
|
1766
|
+
const callerSpeaker = (!hasManualSpeakers && !first_speaker)
|
|
1767
|
+
? detectCallerSpeaker()
|
|
1768
|
+
: null;
|
|
1769
|
+
|
|
1770
|
+
const normalizedFirstSpeaker = normalizeSpeaker(first_speaker)
|
|
1771
|
+
|| normalizeSpeaker(hasManualSpeakers ? selectedSpeakers?.[0] : callerSpeaker)
|
|
1772
|
+
|| normalizeSpeaker(selectedSpeakers?.[0])
|
|
1773
|
+
|| DEFAULT_SPEAKERS[0];
|
|
1774
|
+
const speakerOrder = buildSpeakerOrder(selectedSpeakers, normalizedFirstSpeaker, "front");
|
|
1775
|
+
const participantMode = hasManualSpeakers
|
|
1776
|
+
? "수동 지정"
|
|
1777
|
+
: (autoDiscoveredSpeakers.length > 0 ? "자동 탐색(PATH)" : "기본값");
|
|
1778
|
+
|
|
1779
|
+
const state = {
|
|
1780
|
+
id: sessionId,
|
|
1781
|
+
project: getProjectSlug(),
|
|
1782
|
+
topic,
|
|
1783
|
+
status: "active",
|
|
1784
|
+
max_rounds: rounds,
|
|
1785
|
+
current_round: 1,
|
|
1786
|
+
current_speaker: normalizedFirstSpeaker,
|
|
1787
|
+
speakers: speakerOrder,
|
|
1788
|
+
participant_profiles: mapParticipantProfiles(speakerOrder, candidateSnapshot.candidates, participant_types),
|
|
1789
|
+
log: [],
|
|
1790
|
+
synthesis: null,
|
|
1791
|
+
pending_turn_id: generateTurnId(),
|
|
1792
|
+
monitor_terminal_window_ids: [],
|
|
1793
|
+
created: new Date().toISOString(),
|
|
1794
|
+
updated: new Date().toISOString(),
|
|
1795
|
+
};
|
|
1796
|
+
withSessionLock(sessionId, () => {
|
|
1797
|
+
saveSession(state);
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
const active = listActiveSessions();
|
|
1801
|
+
const tmuxOpened = spawnMonitorTerminal(sessionId);
|
|
1802
|
+
const terminalOpenResult = tmuxOpened
|
|
1803
|
+
? openPhysicalTerminal(sessionId)
|
|
1804
|
+
: { opened: false, windowIds: [] };
|
|
1805
|
+
const terminalWindowIds = Array.isArray(terminalOpenResult.windowIds)
|
|
1806
|
+
? terminalOpenResult.windowIds
|
|
1807
|
+
: [];
|
|
1808
|
+
const physicalOpened = terminalOpenResult.opened === true;
|
|
1809
|
+
if (terminalWindowIds.length > 0) {
|
|
1810
|
+
withSessionLock(sessionId, () => {
|
|
1811
|
+
const latest = loadSession(sessionId);
|
|
1812
|
+
if (!latest) return;
|
|
1813
|
+
latest.monitor_terminal_window_ids = terminalWindowIds;
|
|
1814
|
+
saveSession(latest);
|
|
1815
|
+
});
|
|
1816
|
+
state.monitor_terminal_window_ids = terminalWindowIds;
|
|
1817
|
+
}
|
|
1818
|
+
const terminalMsg = !tmuxOpened
|
|
1819
|
+
? `\n⚠️ tmux를 찾을 수 없어 모니터 터미널 미생성`
|
|
1820
|
+
: physicalOpened
|
|
1821
|
+
? `\n🖥️ 모니터 터미널 오픈됨: tmux attach -t ${TMUX_SESSION}`
|
|
1822
|
+
: `\n⚠️ tmux 윈도우는 생성됐지만 외부 터미널 자동 오픈 실패. 수동 실행: tmux attach -t ${TMUX_SESSION}`;
|
|
1823
|
+
const manualNotDetected = hasManualSpeakers
|
|
1824
|
+
? speakerOrder.filter(s => !candidateSnapshot.candidates.some(c => c.speaker === s))
|
|
1825
|
+
: [];
|
|
1826
|
+
const detectWarning = manualNotDetected.length > 0
|
|
1827
|
+
? `\n\n⚠️ 현재 환경에서 즉시 검출되지 않은 speaker: ${manualNotDetected.join(", ")}\n(수동 지정으로는 참가 가능)`
|
|
1828
|
+
: "";
|
|
1829
|
+
|
|
1830
|
+
const transportSummary = state.participant_profiles.map(p => {
|
|
1831
|
+
const { transport } = resolveTransportForSpeaker(state, p.speaker);
|
|
1832
|
+
return ` - \`${p.speaker}\`: ${transport} (${p.type})`;
|
|
1833
|
+
}).join("\n");
|
|
1834
|
+
|
|
1835
|
+
return {
|
|
1836
|
+
content: [{
|
|
1837
|
+
type: "text",
|
|
1838
|
+
text: `✅ Deliberation 시작!\n\n**세션:** ${sessionId}\n**프로젝트:** ${state.project}\n**주제:** ${topic}\n**라운드:** ${rounds}\n**참가자 구성:** ${participantMode}\n**참가자:** ${speakerOrder.join(", ")}\n**첫 발언:** ${state.current_speaker}\n**동시 진행 세션:** ${active.length}개${terminalMsg}${detectWarning}\n\n**Transport 라우팅:**\n${transportSummary}\n\n💡 이후 도구 호출 시 session_id: "${sessionId}" 를 사용하세요.`,
|
|
1839
|
+
}],
|
|
1840
|
+
};
|
|
1841
|
+
})
|
|
1842
|
+
);
|
|
1843
|
+
|
|
1844
|
+
server.tool(
|
|
1845
|
+
"deliberation_speaker_candidates",
|
|
1846
|
+
"사용자가 선택 가능한 스피커 후보(로컬 CLI + 브라우저 LLM 탭)를 조회합니다.",
|
|
1847
|
+
{
|
|
1848
|
+
include_cli: z.boolean().default(true).describe("로컬 CLI 후보 포함"),
|
|
1849
|
+
include_browser: z.boolean().default(true).describe("브라우저 LLM 탭 후보 포함"),
|
|
1850
|
+
},
|
|
1851
|
+
async ({ include_cli, include_browser }) => {
|
|
1852
|
+
const snapshot = await collectSpeakerCandidates({ include_cli, include_browser });
|
|
1853
|
+
const text = formatSpeakerCandidatesReport(snapshot);
|
|
1854
|
+
return { content: [{ type: "text", text: `${text}\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
1855
|
+
}
|
|
1856
|
+
);
|
|
1857
|
+
|
|
1858
|
+
server.tool(
|
|
1859
|
+
"deliberation_list_active",
|
|
1860
|
+
"현재 프로젝트에서 진행 중인 모든 deliberation 세션 목록을 반환합니다.",
|
|
1861
|
+
{},
|
|
1862
|
+
async () => {
|
|
1863
|
+
const active = listActiveSessions();
|
|
1864
|
+
if (active.length === 0) {
|
|
1865
|
+
return { content: [{ type: "text", text: "진행 중인 deliberation이 없습니다." }] };
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
let list = `## 진행 중인 Deliberation (${getProjectSlug()}) — ${active.length}개\n\n`;
|
|
1869
|
+
for (const s of active) {
|
|
1870
|
+
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`;
|
|
1871
|
+
}
|
|
1872
|
+
return { content: [{ type: "text", text: list }] };
|
|
1873
|
+
}
|
|
1874
|
+
);
|
|
1875
|
+
|
|
1876
|
+
server.tool(
|
|
1877
|
+
"deliberation_status",
|
|
1878
|
+
"deliberation 상태를 조회합니다. 활성 세션이 1개면 자동 선택, 여러 개면 session_id 필요.",
|
|
1879
|
+
{
|
|
1880
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
1881
|
+
},
|
|
1882
|
+
async ({ session_id }) => {
|
|
1883
|
+
const resolved = resolveSessionId(session_id);
|
|
1884
|
+
if (!resolved) {
|
|
1885
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다. deliberation_start로 시작하세요." }] };
|
|
1886
|
+
}
|
|
1887
|
+
if (resolved === "MULTIPLE") {
|
|
1888
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const state = loadSession(resolved);
|
|
1892
|
+
if (!state) {
|
|
1893
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"을 찾을 수 없습니다.` }] };
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
return {
|
|
1897
|
+
content: [{
|
|
1898
|
+
type: "text",
|
|
1899
|
+
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}`,
|
|
1900
|
+
}],
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
);
|
|
1904
|
+
|
|
1905
|
+
server.tool(
|
|
1906
|
+
"deliberation_context",
|
|
1907
|
+
"현재 프로젝트의 컨텍스트(md 파일들)를 로드합니다. CWD + Obsidian 자동 감지.",
|
|
1908
|
+
{},
|
|
1909
|
+
async () => {
|
|
1910
|
+
const dirs = detectContextDirs();
|
|
1911
|
+
const context = readContextFromDirs(dirs);
|
|
1912
|
+
return {
|
|
1913
|
+
content: [{
|
|
1914
|
+
type: "text",
|
|
1915
|
+
text: `## 프로젝트 컨텍스트 (${getProjectSlug()})\n\n**소스:** ${dirs.join(", ")}\n\n${context}`,
|
|
1916
|
+
}],
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
);
|
|
1920
|
+
|
|
1921
|
+
server.tool(
|
|
1922
|
+
"deliberation_browser_llm_tabs",
|
|
1923
|
+
"현재 브라우저에서 열려 있는 LLM 탭(chatgpt/claude/gemini 등)을 조회합니다.",
|
|
1924
|
+
{},
|
|
1925
|
+
async () => {
|
|
1926
|
+
const { tabs, note } = await collectBrowserLlmTabs();
|
|
1927
|
+
if (tabs.length === 0) {
|
|
1928
|
+
const suffix = note ? `\n\n${note}` : "";
|
|
1929
|
+
return { content: [{ type: "text", text: `감지된 LLM 탭이 없습니다.${suffix}` }] };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const lines = tabs.map((t, i) => `${i + 1}. [${t.browser}] ${t.title}\n ${t.url}`).join("\n");
|
|
1933
|
+
const noteLine = note ? `\n\nℹ️ ${note}` : "";
|
|
1934
|
+
return { content: [{ type: "text", text: `## Browser LLM Tabs\n\n${lines}${noteLine}\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
1935
|
+
}
|
|
1936
|
+
);
|
|
1937
|
+
|
|
1938
|
+
server.tool(
|
|
1939
|
+
"deliberation_clipboard_prepare_turn",
|
|
1940
|
+
"현재 턴 요청 프롬프트를 생성해 클립보드에 복사합니다. 브라우저 LLM에 붙여넣어 사용하세요.",
|
|
1941
|
+
{
|
|
1942
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
1943
|
+
speaker: z.string().trim().min(1).max(64).optional().describe("대상 speaker (미지정 시 현재 차례)"),
|
|
1944
|
+
prompt: z.string().optional().describe("브라우저 LLM에 추가로 전달할 지시"),
|
|
1945
|
+
include_history_entries: z.number().int().min(0).max(12).default(4).describe("프롬프트에 포함할 최근 로그 개수"),
|
|
1946
|
+
},
|
|
1947
|
+
async ({ session_id, speaker, prompt, include_history_entries }) => {
|
|
1948
|
+
const resolved = resolveSessionId(session_id);
|
|
1949
|
+
if (!resolved) {
|
|
1950
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
1951
|
+
}
|
|
1952
|
+
if (resolved === "MULTIPLE") {
|
|
1953
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const state = loadSession(resolved);
|
|
1957
|
+
if (!state || state.status !== "active") {
|
|
1958
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const targetSpeaker = normalizeSpeaker(speaker) || normalizeSpeaker(state.current_speaker) || state.speakers[0];
|
|
1962
|
+
if (targetSpeaker !== state.current_speaker) {
|
|
1963
|
+
return {
|
|
1964
|
+
content: [{
|
|
1965
|
+
type: "text",
|
|
1966
|
+
text: `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. prepare 대상 speaker는 현재 차례와 같아야 합니다.`,
|
|
1967
|
+
}],
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const payload = buildClipboardTurnPrompt(state, targetSpeaker, prompt, include_history_entries);
|
|
1972
|
+
try {
|
|
1973
|
+
writeClipboardText(payload);
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
1976
|
+
return { content: [{ type: "text", text: `클립보드 복사 실패: ${message}` }] };
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
return {
|
|
1980
|
+
content: [{
|
|
1981
|
+
type: "text",
|
|
1982
|
+
text: `✅ [${state.id}] 턴 프롬프트를 클립보드에 복사했습니다.\n\n**대상 speaker:** ${targetSpeaker}\n**라운드:** ${state.current_round}/${state.max_rounds}\n\n다음 단계:\n1. 브라우저 LLM에 붙여넣고 응답 생성\n2. 응답 본문을 복사\n3. deliberation_clipboard_submit_turn(session_id: "${state.id}", speaker: "${targetSpeaker}") 호출\n\n${PRODUCT_DISCLAIMER}`,
|
|
1983
|
+
}],
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
);
|
|
1987
|
+
|
|
1988
|
+
server.tool(
|
|
1989
|
+
"deliberation_clipboard_submit_turn",
|
|
1990
|
+
"클립보드 텍스트(또는 content)를 현재 턴 응답으로 제출합니다.",
|
|
1991
|
+
{
|
|
1992
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
1993
|
+
speaker: z.string().trim().min(1).max(64).describe("응답자 이름"),
|
|
1994
|
+
content: z.string().optional().describe("응답 내용 (미지정 시 클립보드 텍스트 사용)"),
|
|
1995
|
+
trim_content: z.boolean().default(false).describe("응답 앞뒤 공백 제거 여부"),
|
|
1996
|
+
turn_id: z.string().optional().describe("턴 검증 ID"),
|
|
1997
|
+
},
|
|
1998
|
+
safeToolHandler("deliberation_clipboard_submit_turn", async ({ session_id, speaker, content, trim_content, turn_id }) => {
|
|
1999
|
+
let body = content;
|
|
2000
|
+
if (typeof body !== "string") {
|
|
2001
|
+
try {
|
|
2002
|
+
body = readClipboardText();
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
2005
|
+
return { content: [{ type: "text", text: `클립보드 읽기 실패: ${message}` }] };
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
if (trim_content) {
|
|
2010
|
+
body = body.trim();
|
|
2011
|
+
}
|
|
2012
|
+
if (!body || body.trim().length === 0) {
|
|
2013
|
+
return { content: [{ type: "text", text: "제출할 응답이 비어 있습니다. 클립보드 또는 content를 확인하세요." }] };
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
return submitDeliberationTurn({ session_id, speaker, content: body, turn_id, channel_used: "clipboard" });
|
|
2017
|
+
})
|
|
2018
|
+
);
|
|
2019
|
+
|
|
2020
|
+
server.tool(
|
|
2021
|
+
"deliberation_route_turn",
|
|
2022
|
+
"현재 턴의 speaker에 맞는 transport를 자동 결정하고 안내합니다. CLI speaker는 자동 응답 경로, 브라우저 speaker는 클립보드 경로로 라우팅합니다.",
|
|
2023
|
+
{
|
|
2024
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2025
|
+
auto_prepare_clipboard: z.boolean().default(true).describe("브라우저 speaker일 때 자동으로 클립보드 prepare 실행"),
|
|
2026
|
+
prompt: z.string().optional().describe("브라우저 LLM에 추가로 전달할 지시"),
|
|
2027
|
+
include_history_entries: z.number().int().min(0).max(12).default(4).describe("프롬프트에 포함할 최근 로그 개수"),
|
|
2028
|
+
},
|
|
2029
|
+
safeToolHandler("deliberation_route_turn", async ({ session_id, auto_prepare_clipboard, prompt, include_history_entries }) => {
|
|
2030
|
+
const resolved = resolveSessionId(session_id);
|
|
2031
|
+
if (!resolved) {
|
|
2032
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2033
|
+
}
|
|
2034
|
+
if (resolved === "MULTIPLE") {
|
|
2035
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const state = loadSession(resolved);
|
|
2039
|
+
if (!state || state.status !== "active") {
|
|
2040
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const speaker = state.current_speaker;
|
|
2044
|
+
const { transport, profile, reason } = resolveTransportForSpeaker(state, speaker);
|
|
2045
|
+
const guidance = formatTransportGuidance(transport, state, speaker);
|
|
2046
|
+
const turnId = state.pending_turn_id || null;
|
|
2047
|
+
|
|
2048
|
+
let extra = "";
|
|
2049
|
+
|
|
2050
|
+
if (transport === "clipboard" && auto_prepare_clipboard) {
|
|
2051
|
+
// 자동으로 클립보드 prepare 실행
|
|
2052
|
+
const payload = buildClipboardTurnPrompt(state, speaker, prompt, include_history_entries);
|
|
2053
|
+
try {
|
|
2054
|
+
writeClipboardText(payload);
|
|
2055
|
+
extra = `\n\n✅ 클립보드에 턴 프롬프트가 자동 복사되었습니다.`;
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
2058
|
+
extra = `\n\n⚠️ 클립보드 자동 복사 실패: ${message}\n수동으로 deliberation_clipboard_prepare_turn을 호출하세요.`;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
if (transport === "browser_auto") {
|
|
2063
|
+
// Auto-execute browser_auto_turn
|
|
2064
|
+
try {
|
|
2065
|
+
const port = getBrowserPort();
|
|
2066
|
+
const sessionId = state.id;
|
|
2067
|
+
const turnSpeaker = speaker;
|
|
2068
|
+
const turnProvider = profile?.provider || "chatgpt";
|
|
2069
|
+
|
|
2070
|
+
// Build prompt
|
|
2071
|
+
const turnPrompt = buildClipboardTurnPrompt(state, turnSpeaker, prompt, include_history_entries);
|
|
2072
|
+
|
|
2073
|
+
// Attach
|
|
2074
|
+
const attachResult = await port.attach(sessionId, { provider: turnProvider, url: profile?.url });
|
|
2075
|
+
if (!attachResult.ok) throw new Error(`attach failed: ${attachResult.error?.message}`);
|
|
2076
|
+
|
|
2077
|
+
// Send turn
|
|
2078
|
+
const autoTurnId = turnId || `auto-${Date.now()}`;
|
|
2079
|
+
const sendResult = await port.sendTurnWithDegradation(sessionId, autoTurnId, turnPrompt);
|
|
2080
|
+
if (!sendResult.ok) throw new Error(`send failed: ${sendResult.error?.message}`);
|
|
2081
|
+
|
|
2082
|
+
// Wait for response
|
|
2083
|
+
const waitResult = await port.waitTurnResult(sessionId, autoTurnId, 45);
|
|
2084
|
+
const degradationState = port.getDegradationState(sessionId);
|
|
2085
|
+
await port.detach(sessionId);
|
|
2086
|
+
|
|
2087
|
+
if (waitResult.ok && waitResult.data?.response) {
|
|
2088
|
+
// Auto-submit the response
|
|
2089
|
+
submitDeliberationTurn({
|
|
2090
|
+
session_id: sessionId,
|
|
2091
|
+
speaker: turnSpeaker,
|
|
2092
|
+
content: waitResult.data.response,
|
|
2093
|
+
turn_id: state.pending_turn_id || generateTurnId(),
|
|
2094
|
+
channel_used: "browser_auto",
|
|
2095
|
+
fallback_reason: null,
|
|
2096
|
+
});
|
|
2097
|
+
extra = `\n\n⚡ 자동 실행 완료! 브라우저 LLM 응답이 자동으로 제출되었습니다. (${waitResult.data.elapsedMs}ms)`;
|
|
2098
|
+
} else {
|
|
2099
|
+
throw new Error(waitResult.error?.message || "no response received");
|
|
2100
|
+
}
|
|
2101
|
+
} catch (autoErr) {
|
|
2102
|
+
// Fallback to clipboard
|
|
2103
|
+
const errMsg = autoErr instanceof Error ? autoErr.message : String(autoErr);
|
|
2104
|
+
const payload = buildClipboardTurnPrompt(state, speaker, prompt, include_history_entries);
|
|
2105
|
+
try {
|
|
2106
|
+
writeClipboardText(payload);
|
|
2107
|
+
extra = `\n\n⚠️ 자동 실행 실패 (${errMsg}). 클립보드 모드로 폴백했습니다.\n✅ 클립보드에 턴 프롬프트가 복사되었습니다.`;
|
|
2108
|
+
} catch (clipErr) {
|
|
2109
|
+
extra = `\n\n⚠️ 자동 실행 실패 (${errMsg}). 클립보드 복사도 실패했습니다.\n수동으로 deliberation_clipboard_prepare_turn을 호출하세요.`;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
const profileInfo = profile
|
|
2115
|
+
? `\n**프로필:** ${profile.type}${profile.url ? ` | ${profile.url}` : ""}${profile.command ? ` | command: ${profile.command}` : ""}`
|
|
2116
|
+
: "";
|
|
2117
|
+
|
|
2118
|
+
return {
|
|
2119
|
+
content: [{
|
|
2120
|
+
type: "text",
|
|
2121
|
+
text: `## 턴 라우팅 — ${state.id}\n\n**현재 speaker:** ${speaker}\n**Transport:** ${transport}${reason ? ` (fallback: ${reason})` : ""}${profileInfo}\n**Turn ID:** ${turnId || "(없음)"}\n**라운드:** ${state.current_round}/${state.max_rounds}\n\n${guidance}${extra}\n\n${PRODUCT_DISCLAIMER}`,
|
|
2122
|
+
}],
|
|
2123
|
+
};
|
|
2124
|
+
})
|
|
2125
|
+
);
|
|
2126
|
+
|
|
2127
|
+
server.tool(
|
|
2128
|
+
"deliberation_browser_auto_turn",
|
|
2129
|
+
"브라우저 LLM에 자동으로 턴을 전송하고 응답을 수집합니다 (CDP 기반).",
|
|
2130
|
+
{
|
|
2131
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2132
|
+
provider: z.string().optional().default("chatgpt").describe("LLM 프로바이더 (chatgpt, claude, gemini)"),
|
|
2133
|
+
timeout_sec: z.number().optional().default(45).describe("응답 대기 타임아웃 (초)"),
|
|
2134
|
+
},
|
|
2135
|
+
safeToolHandler("deliberation_browser_auto_turn", async ({ session_id, provider, timeout_sec }) => {
|
|
2136
|
+
const resolved = resolveSessionId(session_id);
|
|
2137
|
+
if (!resolved) {
|
|
2138
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2139
|
+
}
|
|
2140
|
+
if (resolved === "MULTIPLE") {
|
|
2141
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const state = loadSession(resolved);
|
|
2145
|
+
if (!state || state.status !== "active") {
|
|
2146
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"이 활성 상태가 아닙니다.` }] };
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
const speaker = state.current_speaker;
|
|
2150
|
+
if (speaker === "none") {
|
|
2151
|
+
return { content: [{ type: "text", text: "현재 발언 차례인 speaker가 없습니다." }] };
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
2155
|
+
if (transport !== "browser_auto" && transport !== "clipboard") {
|
|
2156
|
+
return { content: [{ type: "text", text: `speaker "${speaker}"는 브라우저 타입이 아닙니다 (transport: ${transport}). CLI speaker는 deliberation_respond를 사용하세요.` }] };
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const turnId = state.pending_turn_id || generateTurnId();
|
|
2160
|
+
const port = getBrowserPort();
|
|
2161
|
+
|
|
2162
|
+
// Step 1: Attach
|
|
2163
|
+
const attachResult = await port.attach(resolved, { provider });
|
|
2164
|
+
if (!attachResult.ok) {
|
|
2165
|
+
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}` }] };
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// Step 2: Build turn prompt
|
|
2169
|
+
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
2170
|
+
|
|
2171
|
+
// Step 3: Send turn with degradation
|
|
2172
|
+
const sendResult = await port.sendTurnWithDegradation(resolved, turnId, turnPrompt);
|
|
2173
|
+
if (!sendResult.ok) {
|
|
2174
|
+
// Fallback to clipboard
|
|
2175
|
+
return submitDeliberationTurn({
|
|
2176
|
+
session_id: resolved,
|
|
2177
|
+
speaker,
|
|
2178
|
+
content: `[browser_auto 실패 — fallback] ${sendResult.error.message}`,
|
|
2179
|
+
turn_id: turnId,
|
|
2180
|
+
channel_used: "browser_auto_fallback",
|
|
2181
|
+
fallback_reason: sendResult.error.code,
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// Step 4: Wait for response
|
|
2186
|
+
const waitResult = await port.waitTurnResult(resolved, turnId, timeout_sec);
|
|
2187
|
+
if (!waitResult.ok) {
|
|
2188
|
+
return { content: [{ type: "text", text: `⏱️ 브라우저 LLM 응답 대기 타임아웃 (${timeout_sec}초)\n\n**에러:** ${waitResult.error.message}\n\nclipboard fallback으로 수동 진행하세요:\n1. \`deliberation_clipboard_prepare_turn(session_id: "${resolved}")\`\n2. 브라우저에 붙여넣기\n3. \`deliberation_clipboard_submit_turn(session_id: "${resolved}")\`\n\n${PRODUCT_DISCLAIMER}` }] };
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// Step 5: Submit the response
|
|
2192
|
+
const response = waitResult.data.response;
|
|
2193
|
+
const result = submitDeliberationTurn({
|
|
2194
|
+
session_id: resolved,
|
|
2195
|
+
speaker,
|
|
2196
|
+
content: response,
|
|
2197
|
+
turn_id: turnId,
|
|
2198
|
+
channel_used: "browser_auto",
|
|
2199
|
+
fallback_reason: null,
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
// Step 6: Capture degradation state before detach
|
|
2203
|
+
const degradationState = port.getDegradationState(resolved);
|
|
2204
|
+
|
|
2205
|
+
await port.detach(resolved);
|
|
2206
|
+
const degradationInfo = degradationState
|
|
2207
|
+
? `\n**Degradation:** ${JSON.stringify(degradationState)}`
|
|
2208
|
+
: "";
|
|
2209
|
+
|
|
2210
|
+
return {
|
|
2211
|
+
content: [{
|
|
2212
|
+
type: "text",
|
|
2213
|
+
text: `✅ 브라우저 자동 턴 완료!\n\n**Provider:** ${provider}\n**Turn ID:** ${turnId}\n**응답 길이:** ${response.length}자\n**소요 시간:** ${waitResult.data.elapsedMs}ms${degradationInfo}\n\n${result.content[0].text}`,
|
|
2214
|
+
}],
|
|
2215
|
+
};
|
|
2216
|
+
})
|
|
2217
|
+
);
|
|
2218
|
+
|
|
2219
|
+
server.tool(
|
|
2220
|
+
"deliberation_respond",
|
|
2221
|
+
"현재 턴의 응답을 제출합니다.",
|
|
2222
|
+
{
|
|
2223
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2224
|
+
speaker: z.string().trim().min(1).max(64).describe("응답자 이름"),
|
|
2225
|
+
content: z.string().describe("응답 내용 (마크다운)"),
|
|
2226
|
+
turn_id: z.string().optional().describe("턴 검증 ID (deliberation_route_turn에서 받은 값)"),
|
|
2227
|
+
},
|
|
2228
|
+
safeToolHandler("deliberation_respond", async ({ session_id, speaker, content, turn_id }) => {
|
|
2229
|
+
return submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used: "cli_respond" });
|
|
2230
|
+
})
|
|
2231
|
+
);
|
|
2232
|
+
|
|
2233
|
+
server.tool(
|
|
2234
|
+
"deliberation_history",
|
|
2235
|
+
"토론 기록을 반환합니다.",
|
|
2236
|
+
{
|
|
2237
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2238
|
+
},
|
|
2239
|
+
async ({ session_id }) => {
|
|
2240
|
+
const resolved = resolveSessionId(session_id);
|
|
2241
|
+
if (!resolved) {
|
|
2242
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2243
|
+
}
|
|
2244
|
+
if (resolved === "MULTIPLE") {
|
|
2245
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
const state = loadSession(resolved);
|
|
2249
|
+
if (!state) {
|
|
2250
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"을 찾을 수 없습니다.` }] };
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
if (state.log.length === 0) {
|
|
2254
|
+
return {
|
|
2255
|
+
content: [{
|
|
2256
|
+
type: "text",
|
|
2257
|
+
text: `**세션:** ${state.id}\n**주제:** ${state.topic}\n\n아직 응답이 없습니다. **${state.current_speaker}**가 먼저 응답하세요.`,
|
|
2258
|
+
}],
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
let history = `**세션:** ${state.id}\n**주제:** ${state.topic} | **상태:** ${state.status}\n\n`;
|
|
2263
|
+
for (const e of state.log) {
|
|
2264
|
+
history += `### ${e.speaker} — Round ${e.round}\n\n${e.content}\n\n---\n\n`;
|
|
2265
|
+
}
|
|
2266
|
+
return { content: [{ type: "text", text: history }] };
|
|
2267
|
+
}
|
|
2268
|
+
);
|
|
2269
|
+
|
|
2270
|
+
server.tool(
|
|
2271
|
+
"deliberation_synthesize",
|
|
2272
|
+
"토론을 종료하고 합성 보고서를 제출합니다.",
|
|
2273
|
+
{
|
|
2274
|
+
session_id: z.string().optional().describe("세션 ID (여러 세션 진행 중이면 필수)"),
|
|
2275
|
+
synthesis: z.string().describe("합성 보고서 (마크다운)"),
|
|
2276
|
+
},
|
|
2277
|
+
safeToolHandler("deliberation_synthesize", async ({ session_id, synthesis }) => {
|
|
2278
|
+
const resolved = resolveSessionId(session_id);
|
|
2279
|
+
if (!resolved) {
|
|
2280
|
+
return { content: [{ type: "text", text: "활성 deliberation이 없습니다." }] };
|
|
2281
|
+
}
|
|
2282
|
+
if (resolved === "MULTIPLE") {
|
|
2283
|
+
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
let state = null;
|
|
2287
|
+
let archivePath = null;
|
|
2288
|
+
const lockedResult = withSessionLock(resolved, () => {
|
|
2289
|
+
const loaded = loadSession(resolved);
|
|
2290
|
+
if (!loaded) {
|
|
2291
|
+
return { content: [{ type: "text", text: `세션 "${resolved}"을 찾을 수 없습니다.` }] };
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
loaded.synthesis = synthesis;
|
|
2295
|
+
loaded.status = "completed";
|
|
2296
|
+
loaded.current_speaker = "none";
|
|
2297
|
+
saveSession(loaded);
|
|
2298
|
+
archivePath = archiveState(loaded);
|
|
2299
|
+
state = loaded;
|
|
2300
|
+
return null;
|
|
2301
|
+
});
|
|
2302
|
+
if (lockedResult) {
|
|
2303
|
+
return lockedResult;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// 토론 종료 즉시 모니터 터미널(물리 Terminal 포함) 강제 종료
|
|
2307
|
+
closeMonitorTerminal(state.id, getSessionWindowIds(state));
|
|
2308
|
+
|
|
2309
|
+
return {
|
|
2310
|
+
content: [{
|
|
2311
|
+
type: "text",
|
|
2312
|
+
text: `✅ [${state.id}] Deliberation 완료!\n\n**프로젝트:** ${state.project}\n**주제:** ${state.topic}\n**라운드:** ${state.max_rounds}\n**응답:** ${state.log.length}건\n\n📁 ${archivePath}\n🖥️ 모니터 터미널이 즉시 강제 종료되었습니다.`,
|
|
2313
|
+
}],
|
|
2314
|
+
};
|
|
2315
|
+
})
|
|
2316
|
+
);
|
|
2317
|
+
|
|
2318
|
+
server.tool(
|
|
2319
|
+
"deliberation_list",
|
|
2320
|
+
"과거 deliberation 아카이브 목록을 반환합니다.",
|
|
2321
|
+
{},
|
|
2322
|
+
async () => {
|
|
2323
|
+
ensureDirs();
|
|
2324
|
+
const archiveDir = getArchiveDir();
|
|
2325
|
+
if (!fs.existsSync(archiveDir)) {
|
|
2326
|
+
return { content: [{ type: "text", text: "과거 deliberation이 없습니다." }] };
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
const files = fs.readdirSync(archiveDir)
|
|
2330
|
+
.filter(f => f.startsWith("deliberation-") && f.endsWith(".md"))
|
|
2331
|
+
.sort().reverse();
|
|
2332
|
+
|
|
2333
|
+
if (files.length === 0) {
|
|
2334
|
+
return { content: [{ type: "text", text: "과거 deliberation이 없습니다." }] };
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
const list = files.map((f, i) => `${i + 1}. ${f.replace(".md", "")}`).join("\n");
|
|
2338
|
+
return { content: [{ type: "text", text: `## 과거 Deliberation (${getProjectSlug()})\n\n${list}` }] };
|
|
2339
|
+
}
|
|
2340
|
+
);
|
|
2341
|
+
|
|
2342
|
+
server.tool(
|
|
2343
|
+
"deliberation_reset",
|
|
2344
|
+
"deliberation을 초기화합니다. session_id 지정 시 해당 세션만, 미지정 시 전체 초기화.",
|
|
2345
|
+
{
|
|
2346
|
+
session_id: z.string().optional().describe("초기화할 세션 ID (미지정 시 전체 초기화)"),
|
|
2347
|
+
},
|
|
2348
|
+
safeToolHandler("deliberation_reset", async ({ session_id }) => {
|
|
2349
|
+
ensureDirs();
|
|
2350
|
+
const sessionsDir = getSessionsDir();
|
|
2351
|
+
|
|
2352
|
+
if (session_id) {
|
|
2353
|
+
// 특정 세션만 초기화
|
|
2354
|
+
let toCloseIds = [];
|
|
2355
|
+
const result = withSessionLock(session_id, () => {
|
|
2356
|
+
const file = getSessionFile(session_id);
|
|
2357
|
+
if (!fs.existsSync(file)) {
|
|
2358
|
+
return { content: [{ type: "text", text: `세션 "${session_id}"을 찾을 수 없습니다.` }] };
|
|
2359
|
+
}
|
|
2360
|
+
const state = loadSession(session_id);
|
|
2361
|
+
if (state && state.log.length > 0) {
|
|
2362
|
+
archiveState(state);
|
|
2363
|
+
}
|
|
2364
|
+
toCloseIds = getSessionWindowIds(state);
|
|
2365
|
+
fs.unlinkSync(file);
|
|
2366
|
+
return { content: [{ type: "text", text: `✅ 세션 "${session_id}" 초기화 완료. 🖥️ 모니터 터미널 닫힘.` }] };
|
|
2367
|
+
});
|
|
2368
|
+
if (toCloseIds.length > 0) {
|
|
2369
|
+
closeMonitorTerminal(session_id, toCloseIds);
|
|
2370
|
+
}
|
|
2371
|
+
return result;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// 전체 초기화
|
|
2375
|
+
const resetResult = withProjectLock(() => {
|
|
2376
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
2377
|
+
return { files: [], archived: 0, terminalWindowIds: [], noSessions: true };
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".json"));
|
|
2381
|
+
let archived = 0;
|
|
2382
|
+
const terminalWindowIds = [];
|
|
2383
|
+
|
|
2384
|
+
for (const f of files) {
|
|
2385
|
+
const filePath = path.join(sessionsDir, f);
|
|
2386
|
+
try {
|
|
2387
|
+
const state = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
2388
|
+
for (const id of getSessionWindowIds(state)) {
|
|
2389
|
+
terminalWindowIds.push(id);
|
|
2390
|
+
}
|
|
2391
|
+
if (state.log && state.log.length > 0) {
|
|
2392
|
+
archiveState(state);
|
|
2393
|
+
archived++;
|
|
2394
|
+
}
|
|
2395
|
+
fs.unlinkSync(filePath);
|
|
2396
|
+
} catch {
|
|
2397
|
+
try {
|
|
2398
|
+
fs.unlinkSync(filePath);
|
|
2399
|
+
} catch {
|
|
2400
|
+
// ignore deletion race
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
return { files, archived, terminalWindowIds, noSessions: false };
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
if (resetResult.noSessions) {
|
|
2409
|
+
return { content: [{ type: "text", text: "초기화할 세션이 없습니다." }] };
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
for (const windowId of resetResult.terminalWindowIds) {
|
|
2413
|
+
closePhysicalTerminal(windowId);
|
|
2414
|
+
}
|
|
2415
|
+
closeAllMonitorTerminals();
|
|
2416
|
+
|
|
2417
|
+
return {
|
|
2418
|
+
content: [{
|
|
2419
|
+
type: "text",
|
|
2420
|
+
text: `✅ 전체 초기화 완료. ${resetResult.files.length}개 세션 삭제, ${resetResult.archived}개 아카이브됨. 🖥️ 모든 모니터 터미널 닫힘.`,
|
|
2421
|
+
}],
|
|
2422
|
+
};
|
|
2423
|
+
})
|
|
2424
|
+
);
|
|
2425
|
+
|
|
2426
|
+
// ── Start ──────────────────────────────────────────────────────
|
|
2427
|
+
|
|
2428
|
+
const transport = new StdioServerTransport();
|
|
2429
|
+
await server.connect(transport);
|