@dmsdc-ai/aigentry-deliberation 0.0.39 → 0.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -218
- package/examples/README.md +25 -0
- package/examples/basic-deliberation.md +99 -0
- package/examples/browser-automation.md +120 -0
- package/examples/code-review.md +78 -0
- package/examples/structured-synthesis.md +96 -0
- package/index.js +337 -4057
- package/install.js +1 -1
- package/lib/entitlement.js +120 -0
- package/lib/session.js +623 -0
- package/lib/speaker-discovery.js +1575 -0
- package/lib/telepty.js +868 -0
- package/lib/transport.js +1300 -0
- package/package.json +9 -3
package/lib/transport.js
ADDED
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport/Terminal domain — tmux terminal management, browser port singleton,
|
|
3
|
+
* CLI/browser/telepty auto-turn execution, review helpers, and auto-handoff
|
|
4
|
+
* orchestration.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from index.js to keep the main entry point focused on MCP tool
|
|
7
|
+
* registration while this module owns all transport execution logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync, spawn } from "child_process";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import os from "os";
|
|
14
|
+
|
|
15
|
+
// ── Direct imports from sibling modules ──────────────────────────
|
|
16
|
+
import {
|
|
17
|
+
dispatchTeleptyTurnRequest,
|
|
18
|
+
buildTeleptySynthesisEnvelope,
|
|
19
|
+
notifyTeleptyBus,
|
|
20
|
+
callBrainIngest,
|
|
21
|
+
buildExecutionContract,
|
|
22
|
+
ensureTeleptyBusSubscriber,
|
|
23
|
+
TELEPTY_TRANSPORT_TIMEOUT_MS,
|
|
24
|
+
TELEPTY_SEMANTIC_TIMEOUT_MS,
|
|
25
|
+
} from "./telepty.js";
|
|
26
|
+
import {
|
|
27
|
+
resolveTransportForSpeaker,
|
|
28
|
+
normalizeSpeaker,
|
|
29
|
+
checkCliLiveness,
|
|
30
|
+
CLI_INVOCATION_HINTS,
|
|
31
|
+
collectSpeakerCandidates,
|
|
32
|
+
mapParticipantProfiles,
|
|
33
|
+
buildSpeakerOrder,
|
|
34
|
+
commandExistsInPath,
|
|
35
|
+
shellQuote,
|
|
36
|
+
detectCallerSpeaker,
|
|
37
|
+
} from "./speaker-discovery.js";
|
|
38
|
+
import {
|
|
39
|
+
loadSession,
|
|
40
|
+
saveSession,
|
|
41
|
+
resolveSessionId,
|
|
42
|
+
submitDeliberationTurn,
|
|
43
|
+
buildClipboardTurnPrompt,
|
|
44
|
+
archiveState,
|
|
45
|
+
cleanupSyncMarkdown,
|
|
46
|
+
ensureDirs,
|
|
47
|
+
generateTurnId,
|
|
48
|
+
formatRecentLogForPrompt,
|
|
49
|
+
truncatePromptText,
|
|
50
|
+
getPromptBudgetForSpeaker,
|
|
51
|
+
} from "./session.js";
|
|
52
|
+
import { OrchestratedBrowserPort } from "../browser-control-port.js";
|
|
53
|
+
import { getModelSelectionForTurn } from "../model-router.js";
|
|
54
|
+
import { t } from "../i18n.js";
|
|
55
|
+
|
|
56
|
+
// ── Dependency injection ────────────────────────────────────────
|
|
57
|
+
// Functions that live in index.js but are needed here. Injected once
|
|
58
|
+
// via `initTransportDeps()` so we avoid circular imports.
|
|
59
|
+
|
|
60
|
+
let _deps = {
|
|
61
|
+
appendRuntimeLog: () => {},
|
|
62
|
+
getProjectSlug: () => path.basename(process.cwd()),
|
|
63
|
+
getSessionFile: () => "",
|
|
64
|
+
withSessionLock: (ref, fn) => fn(),
|
|
65
|
+
loadDeliberationConfig: () => ({}),
|
|
66
|
+
resolveCdpEndpoints: () => [],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function initTransportDeps(deps) {
|
|
70
|
+
Object.assign(_deps, deps);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Constants ───────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const HOME = os.homedir();
|
|
76
|
+
const IS_WIN = process.platform === "win32";
|
|
77
|
+
const INSTALL_DIR = IS_WIN
|
|
78
|
+
? path.join(process.env.LOCALAPPDATA || path.join(HOME, "AppData", "Local"), "mcp-deliberation")
|
|
79
|
+
: path.join(HOME, ".local", "lib", "mcp-deliberation");
|
|
80
|
+
|
|
81
|
+
export const TMUX_SESSION = "deliberation";
|
|
82
|
+
export const MONITOR_SCRIPT = path.join(INSTALL_DIR, "session-monitor.sh");
|
|
83
|
+
export const MONITOR_SCRIPT_WIN = path.join(INSTALL_DIR, "session-monitor-win.js");
|
|
84
|
+
|
|
85
|
+
// ── Terminal management (tmux, AppleScript terminal) ────────────
|
|
86
|
+
|
|
87
|
+
export function tmuxWindowName(sessionId) {
|
|
88
|
+
return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function appleScriptQuote(value) {
|
|
92
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function tryExecFile(command, args = []) {
|
|
96
|
+
try {
|
|
97
|
+
execFileSync(command, args, { stdio: "ignore", windowsHide: true });
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveMonitorShell() {
|
|
105
|
+
if (commandExistsInPath("bash")) return "bash";
|
|
106
|
+
if (commandExistsInPath("sh")) return "sh";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildMonitorCommand(sessionId, project) {
|
|
111
|
+
const shell = resolveMonitorShell();
|
|
112
|
+
if (!shell) return null;
|
|
113
|
+
return `${shell} ${shellQuote(MONITOR_SCRIPT)} ${shellQuote(sessionId)} ${shellQuote(project)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildMonitorCommandWindows(sessionId, project) {
|
|
117
|
+
return `node "${MONITOR_SCRIPT_WIN}" "${sessionId}" "${project}"`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function hasTmuxSession(name) {
|
|
121
|
+
try {
|
|
122
|
+
execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore", windowsHide: true });
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function hasTmuxWindow(sessionName, windowName) {
|
|
130
|
+
try {
|
|
131
|
+
const output = execFileSync("tmux", ["list-windows", "-t", sessionName, "-F", "#{window_name}"], {
|
|
132
|
+
encoding: "utf-8",
|
|
133
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
134
|
+
windowsHide: true,
|
|
135
|
+
});
|
|
136
|
+
return String(output).split("\n").map(s => s.trim()).includes(windowName);
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function tmuxHasAttachedClients(sessionName) {
|
|
143
|
+
try {
|
|
144
|
+
const output = execFileSync("tmux", ["list-clients", "-t", sessionName], {
|
|
145
|
+
encoding: "utf-8",
|
|
146
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
});
|
|
149
|
+
return String(output).trim().split("\n").filter(Boolean).length > 0;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isTmuxWindowViewed(sessionName, windowName) {
|
|
156
|
+
try {
|
|
157
|
+
// List all clients and check for matching window name.
|
|
158
|
+
// Grouped sessions (created via 'new-session -t') share the same windows,
|
|
159
|
+
// so checking for the window name anywhere in the client list is sufficient.
|
|
160
|
+
const output = execFileSync("tmux", ["list-clients", "-F", "#{window_name}"], {
|
|
161
|
+
encoding: "utf-8",
|
|
162
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
163
|
+
windowsHide: true,
|
|
164
|
+
});
|
|
165
|
+
return String(output).split("\n").map(s => s.trim()).filter(Boolean).includes(windowName);
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function tmuxWindowCount(name) {
|
|
172
|
+
try {
|
|
173
|
+
const output = execFileSync("tmux", ["list-windows", "-t", name], {
|
|
174
|
+
encoding: "utf-8",
|
|
175
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
176
|
+
windowsHide: true,
|
|
177
|
+
});
|
|
178
|
+
return String(output)
|
|
179
|
+
.split("\n")
|
|
180
|
+
.map(line => line.trim())
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.length;
|
|
183
|
+
} catch {
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function buildTmuxAttachCommand(sessionId) {
|
|
189
|
+
const winName = tmuxWindowName(sessionId);
|
|
190
|
+
// Use grouped session (new-session -t) so each terminal has independent active window.
|
|
191
|
+
// This prevents window-switching conflicts when multiple deliberations run concurrently.
|
|
192
|
+
return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function listPhysicalTerminalWindowIds() {
|
|
196
|
+
if (process.platform !== "darwin") {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const output = execFileSync(
|
|
201
|
+
"osascript",
|
|
202
|
+
[
|
|
203
|
+
"-e",
|
|
204
|
+
'tell application "Terminal"',
|
|
205
|
+
"-e",
|
|
206
|
+
"if not running then return \"\"",
|
|
207
|
+
"-e",
|
|
208
|
+
"set outText to \"\"",
|
|
209
|
+
"-e",
|
|
210
|
+
"repeat with w in windows",
|
|
211
|
+
"-e",
|
|
212
|
+
"set outText to outText & (id of w as string) & linefeed",
|
|
213
|
+
"-e",
|
|
214
|
+
"end repeat",
|
|
215
|
+
"-e",
|
|
216
|
+
"return outText",
|
|
217
|
+
"-e",
|
|
218
|
+
"end tell",
|
|
219
|
+
],
|
|
220
|
+
{ encoding: "utf-8" }
|
|
221
|
+
);
|
|
222
|
+
return String(output)
|
|
223
|
+
.split("\n")
|
|
224
|
+
.map(s => Number.parseInt(s.trim(), 10))
|
|
225
|
+
.filter(n => Number.isInteger(n) && n > 0);
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function openPhysicalTerminal(sessionId) {
|
|
232
|
+
const winName = tmuxWindowName(sessionId);
|
|
233
|
+
// Use grouped session (new-session -t) for independent active window per client
|
|
234
|
+
const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
235
|
+
|
|
236
|
+
// Prevent duplicate windows for the SAME session:
|
|
237
|
+
// If a client is already viewing this specific window, just activate Terminal.app
|
|
238
|
+
if (isTmuxWindowViewed(TMUX_SESSION, winName)) {
|
|
239
|
+
_deps.appendRuntimeLog("INFO", `TMUX_WINDOW_ALREADY_VIEWED: ${winName}. Activating existing Terminal.`);
|
|
240
|
+
if (process.platform === "darwin") {
|
|
241
|
+
try {
|
|
242
|
+
execFileSync("osascript", ["-e", 'tell application "Terminal" to activate'], { stdio: "ignore" });
|
|
243
|
+
} catch { /* ignore */ }
|
|
244
|
+
}
|
|
245
|
+
return { opened: true, windowIds: [] };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// If a terminal is already attached to OTHER windows, open a NEW grouped session
|
|
249
|
+
// instead of select-window (which would hijack all attached clients' views).
|
|
250
|
+
if (tmuxHasAttachedClients(TMUX_SESSION)) {
|
|
251
|
+
if (process.platform === "darwin") {
|
|
252
|
+
const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
253
|
+
try {
|
|
254
|
+
execFileSync(
|
|
255
|
+
"osascript",
|
|
256
|
+
[
|
|
257
|
+
"-e", 'tell application "Terminal"',
|
|
258
|
+
"-e", "activate",
|
|
259
|
+
"-e", `do script ${appleScriptQuote(groupAttachCmd)}`,
|
|
260
|
+
"-e", "end tell",
|
|
261
|
+
],
|
|
262
|
+
{ encoding: "utf-8" }
|
|
263
|
+
);
|
|
264
|
+
return { opened: true, windowIds: [] };
|
|
265
|
+
} catch { /* fall through to default behavior */ }
|
|
266
|
+
}
|
|
267
|
+
// Non-macOS or fallback: don't force select-window, just report success
|
|
268
|
+
// The monitor window already exists in tmux; user can switch manually
|
|
269
|
+
return { opened: true, windowIds: [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (process.platform === "darwin") {
|
|
273
|
+
const before = new Set(listPhysicalTerminalWindowIds());
|
|
274
|
+
try {
|
|
275
|
+
const output = execFileSync(
|
|
276
|
+
"osascript",
|
|
277
|
+
[
|
|
278
|
+
"-e",
|
|
279
|
+
'tell application "Terminal"',
|
|
280
|
+
"-e",
|
|
281
|
+
"activate",
|
|
282
|
+
"-e",
|
|
283
|
+
`do script ${appleScriptQuote(attachCmd)}`,
|
|
284
|
+
"-e",
|
|
285
|
+
"delay 0.15",
|
|
286
|
+
"-e",
|
|
287
|
+
"return id of front window",
|
|
288
|
+
"-e",
|
|
289
|
+
"end tell",
|
|
290
|
+
],
|
|
291
|
+
{ encoding: "utf-8" }
|
|
292
|
+
);
|
|
293
|
+
const frontId = Number.parseInt(String(output).trim(), 10);
|
|
294
|
+
const after = listPhysicalTerminalWindowIds();
|
|
295
|
+
const opened = after.filter(id => !before.has(id));
|
|
296
|
+
if (opened.length > 0) {
|
|
297
|
+
return { opened: true, windowIds: [...new Set(opened)] };
|
|
298
|
+
}
|
|
299
|
+
if (Number.isInteger(frontId) && frontId > 0) {
|
|
300
|
+
return { opened: true, windowIds: [frontId] };
|
|
301
|
+
}
|
|
302
|
+
return { opened: false, windowIds: [] };
|
|
303
|
+
} catch {
|
|
304
|
+
return { opened: false, windowIds: [] };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (process.platform === "linux") {
|
|
309
|
+
const shell = resolveMonitorShell() || "sh";
|
|
310
|
+
const launchCmd = `${buildTmuxAttachCommand(sessionId)}; exec ${shell}`;
|
|
311
|
+
const attempts = [
|
|
312
|
+
["gnome-terminal", ["--", shell, "-lc", launchCmd]],
|
|
313
|
+
["kgx", ["--", shell, "-lc", launchCmd]],
|
|
314
|
+
["konsole", ["-e", shell, "-lc", launchCmd]],
|
|
315
|
+
["x-terminal-emulator", ["-e", shell, "-lc", launchCmd]],
|
|
316
|
+
["xterm", ["-e", shell, "-lc", launchCmd]],
|
|
317
|
+
["alacritty", ["-e", shell, "-lc", launchCmd]],
|
|
318
|
+
["kitty", [shell, "-lc", launchCmd]],
|
|
319
|
+
["wezterm", ["start", "--", shell, "-lc", launchCmd]],
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
for (const [command, args] of attempts) {
|
|
323
|
+
if (!commandExistsInPath(command)) continue;
|
|
324
|
+
if (tryExecFile(command, args)) {
|
|
325
|
+
return { opened: true, windowIds: [] };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { opened: false, windowIds: [] };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (process.platform === "win32") {
|
|
332
|
+
// Windows: monitor is launched directly by spawnMonitorTerminal (no tmux)
|
|
333
|
+
// Physical terminal opening is handled there, so just return success
|
|
334
|
+
return { opened: true, windowIds: [] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { opened: false, windowIds: [] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function spawnMonitorTerminal(sessionId) {
|
|
341
|
+
// Windows: use Windows Terminal or PowerShell directly (no tmux needed)
|
|
342
|
+
if (process.platform === "win32") {
|
|
343
|
+
const project = _deps.getProjectSlug();
|
|
344
|
+
const monitorCmd = buildMonitorCommandWindows(sessionId, project);
|
|
345
|
+
|
|
346
|
+
// Try Windows Terminal (wt.exe)
|
|
347
|
+
if (commandExistsInPath("wt") || commandExistsInPath("wt.exe")) {
|
|
348
|
+
if (tryExecFile("wt", ["new-tab", "--title", "Deliberation Monitor", "cmd", "/c", monitorCmd])) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Fallback: new PowerShell window
|
|
354
|
+
const shell = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"].find(c => commandExistsInPath(c));
|
|
355
|
+
if (shell) {
|
|
356
|
+
const escaped = monitorCmd.replace(/'/g, "''");
|
|
357
|
+
if (tryExecFile(shell, ["-NoProfile", "-Command", `Start-Process cmd -ArgumentList '/c','${escaped}'`])) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// macOS/Linux: use tmux (existing logic)
|
|
366
|
+
if (!commandExistsInPath("tmux")) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const project = _deps.getProjectSlug();
|
|
371
|
+
const winName = tmuxWindowName(sessionId);
|
|
372
|
+
const cmd = buildMonitorCommand(sessionId, project);
|
|
373
|
+
if (!cmd) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
if (hasTmuxSession(TMUX_SESSION)) {
|
|
379
|
+
// Skip if a window with the same name already exists (prevents duplicates)
|
|
380
|
+
if (hasTmuxWindow(TMUX_SESSION, winName)) {
|
|
381
|
+
_deps.appendRuntimeLog("INFO", `TMUX_WINDOW_EXISTS: ${winName} in ${TMUX_SESSION}`);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
|
|
385
|
+
stdio: "ignore",
|
|
386
|
+
windowsHide: true,
|
|
387
|
+
});
|
|
388
|
+
_deps.appendRuntimeLog("INFO", `TMUX_WINDOW_CREATED: ${winName} in existing ${TMUX_SESSION}`);
|
|
389
|
+
} else {
|
|
390
|
+
execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
|
|
391
|
+
stdio: "ignore",
|
|
392
|
+
windowsHide: true,
|
|
393
|
+
});
|
|
394
|
+
_deps.appendRuntimeLog("INFO", `TMUX_SESSION_CREATED: ${TMUX_SESSION} with window ${winName}`);
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function closePhysicalTerminal(windowId) {
|
|
403
|
+
if (process.platform !== "darwin") {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (!Number.isInteger(windowId) || windowId <= 0) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const windowExists = () => {
|
|
411
|
+
try {
|
|
412
|
+
const out = execFileSync(
|
|
413
|
+
"osascript",
|
|
414
|
+
[
|
|
415
|
+
"-e",
|
|
416
|
+
'tell application "Terminal"',
|
|
417
|
+
"-e",
|
|
418
|
+
`if exists window id ${windowId} then return "1"`,
|
|
419
|
+
"-e",
|
|
420
|
+
'return "0"',
|
|
421
|
+
"-e",
|
|
422
|
+
"end tell",
|
|
423
|
+
],
|
|
424
|
+
{ encoding: "utf-8" }
|
|
425
|
+
).trim();
|
|
426
|
+
return out === "1";
|
|
427
|
+
} catch {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const dismissCloseDialogs = () => {
|
|
433
|
+
try {
|
|
434
|
+
execFileSync(
|
|
435
|
+
"osascript",
|
|
436
|
+
[
|
|
437
|
+
"-e",
|
|
438
|
+
'tell application "System Events"',
|
|
439
|
+
"-e",
|
|
440
|
+
'if exists process "Terminal" then',
|
|
441
|
+
"-e",
|
|
442
|
+
'tell process "Terminal"',
|
|
443
|
+
"-e",
|
|
444
|
+
"repeat with w in windows",
|
|
445
|
+
"-e",
|
|
446
|
+
"try",
|
|
447
|
+
"-e",
|
|
448
|
+
"if exists (sheet 1 of w) then",
|
|
449
|
+
"-e",
|
|
450
|
+
"if exists button \"종료\" of sheet 1 of w then",
|
|
451
|
+
"-e",
|
|
452
|
+
'click button "종료" of sheet 1 of w',
|
|
453
|
+
"-e",
|
|
454
|
+
"else if exists button \"Terminate\" of sheet 1 of w then",
|
|
455
|
+
"-e",
|
|
456
|
+
'click button "Terminate" of sheet 1 of w',
|
|
457
|
+
"-e",
|
|
458
|
+
"else if exists button \"확인\" of sheet 1 of w then",
|
|
459
|
+
"-e",
|
|
460
|
+
'click button "확인" of sheet 1 of w',
|
|
461
|
+
"-e",
|
|
462
|
+
"else",
|
|
463
|
+
"-e",
|
|
464
|
+
"click button 1 of sheet 1 of w",
|
|
465
|
+
"-e",
|
|
466
|
+
"end if",
|
|
467
|
+
"-e",
|
|
468
|
+
"end if",
|
|
469
|
+
"-e",
|
|
470
|
+
"end try",
|
|
471
|
+
"-e",
|
|
472
|
+
"end repeat",
|
|
473
|
+
"-e",
|
|
474
|
+
"end tell",
|
|
475
|
+
"-e",
|
|
476
|
+
"end if",
|
|
477
|
+
"-e",
|
|
478
|
+
"end tell",
|
|
479
|
+
],
|
|
480
|
+
{ stdio: "ignore" }
|
|
481
|
+
);
|
|
482
|
+
} catch {
|
|
483
|
+
// ignore
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
for (let i = 0; i < 5; i += 1) {
|
|
488
|
+
try {
|
|
489
|
+
execFileSync(
|
|
490
|
+
"osascript",
|
|
491
|
+
[
|
|
492
|
+
"-e",
|
|
493
|
+
'tell application "Terminal"',
|
|
494
|
+
"-e",
|
|
495
|
+
"activate",
|
|
496
|
+
"-e",
|
|
497
|
+
`if exists window id ${windowId} then`,
|
|
498
|
+
"-e",
|
|
499
|
+
"try",
|
|
500
|
+
"-e",
|
|
501
|
+
`do script "exit" in window id ${windowId}`,
|
|
502
|
+
"-e",
|
|
503
|
+
"end try",
|
|
504
|
+
"-e",
|
|
505
|
+
"delay 0.12",
|
|
506
|
+
"-e",
|
|
507
|
+
"try",
|
|
508
|
+
"-e",
|
|
509
|
+
`close (window id ${windowId})`,
|
|
510
|
+
"-e",
|
|
511
|
+
"end try",
|
|
512
|
+
"-e",
|
|
513
|
+
"end if",
|
|
514
|
+
"-e",
|
|
515
|
+
"end tell",
|
|
516
|
+
],
|
|
517
|
+
{ stdio: "ignore" }
|
|
518
|
+
);
|
|
519
|
+
} catch {
|
|
520
|
+
// ignore
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
dismissCloseDialogs();
|
|
524
|
+
|
|
525
|
+
if (!windowExists()) {
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return !windowExists();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function closeMonitorTerminal(sessionId, terminalWindowIds = []) {
|
|
534
|
+
if (process.platform !== "win32") {
|
|
535
|
+
const winName = tmuxWindowName(sessionId);
|
|
536
|
+
try {
|
|
537
|
+
execFileSync("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${winName}`], {
|
|
538
|
+
stdio: "ignore",
|
|
539
|
+
windowsHide: true,
|
|
540
|
+
});
|
|
541
|
+
} catch { /* ignore */ }
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
if (tmuxWindowCount(TMUX_SESSION) === 0) {
|
|
545
|
+
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], {
|
|
546
|
+
stdio: "ignore",
|
|
547
|
+
windowsHide: true,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
} catch { /* ignore */ }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const windowId of terminalWindowIds) {
|
|
554
|
+
closePhysicalTerminal(windowId);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function getSessionWindowIds(state) {
|
|
559
|
+
if (!state || typeof state !== "object") {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
const ids = [];
|
|
563
|
+
if (Array.isArray(state.monitor_terminal_window_ids)) {
|
|
564
|
+
for (const id of state.monitor_terminal_window_ids) {
|
|
565
|
+
if (Number.isInteger(id) && id > 0) {
|
|
566
|
+
ids.push(id);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (Number.isInteger(state.monitor_terminal_window_id) && state.monitor_terminal_window_id > 0) {
|
|
571
|
+
ids.push(state.monitor_terminal_window_id);
|
|
572
|
+
}
|
|
573
|
+
return [...new Set(ids)];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function closeAllMonitorTerminals() {
|
|
577
|
+
try {
|
|
578
|
+
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], { stdio: "ignore", windowsHide: true });
|
|
579
|
+
} catch { /* ignore */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── BrowserControlPort singleton ────────────────────────────────
|
|
583
|
+
|
|
584
|
+
let _browserPort = null;
|
|
585
|
+
export function getBrowserPort() {
|
|
586
|
+
if (!_browserPort) {
|
|
587
|
+
const cdpEndpoints = _deps.resolveCdpEndpoints();
|
|
588
|
+
_browserPort = new OrchestratedBrowserPort({ cdpEndpoints });
|
|
589
|
+
}
|
|
590
|
+
return _browserPort;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── CLI auto-turn helpers ───────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
export function getCliAutoTurnTimeoutSec({ speaker, requestedTimeoutSec, promptLength, priorTurns }) {
|
|
596
|
+
const requested = Number.isFinite(requestedTimeoutSec) ? requestedTimeoutSec : 120;
|
|
597
|
+
if (speaker === "codex") {
|
|
598
|
+
let recommended = Math.max(requested, priorTurns === 0 ? 240 : 180);
|
|
599
|
+
if (promptLength > 6000) {
|
|
600
|
+
recommended = Math.max(recommended, 300);
|
|
601
|
+
}
|
|
602
|
+
if (promptLength > 10000 || priorTurns >= 1) {
|
|
603
|
+
recommended = Math.max(recommended, 420);
|
|
604
|
+
}
|
|
605
|
+
return recommended;
|
|
606
|
+
}
|
|
607
|
+
return priorTurns === 0 ? Math.max(requested, 180) : requested;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function getCliExecArgs(speaker, model) {
|
|
611
|
+
const hint = CLI_INVOCATION_HINTS[speaker];
|
|
612
|
+
switch (speaker) {
|
|
613
|
+
case "claude": {
|
|
614
|
+
const args = ["-p", "--output-format", "text"];
|
|
615
|
+
// claude uses its own config for model selection; model flag not appended
|
|
616
|
+
return args;
|
|
617
|
+
}
|
|
618
|
+
case "codex": {
|
|
619
|
+
const args = [
|
|
620
|
+
"exec",
|
|
621
|
+
"--ephemeral",
|
|
622
|
+
"-c", 'approval_policy="never"',
|
|
623
|
+
"-c", 'sandbox_mode="read-only"',
|
|
624
|
+
"-c", 'model_reasoning_effort="low"',
|
|
625
|
+
"-",
|
|
626
|
+
];
|
|
627
|
+
if (model && hint?.modelFlag) {
|
|
628
|
+
// Insert model flag before the trailing "-" stdin marker
|
|
629
|
+
args.splice(args.length - 1, 0, hint.modelFlag, model);
|
|
630
|
+
}
|
|
631
|
+
return args;
|
|
632
|
+
}
|
|
633
|
+
case "gemini":
|
|
634
|
+
return null;
|
|
635
|
+
default:
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function buildCliAutoTurnFailureText({ state, speaker, hint, err, effectiveTimeout, promptLength, priorTurns }) {
|
|
641
|
+
const isTimeout = /CLI timeout \(/.test(String(err?.message || ""));
|
|
642
|
+
if (!isTimeout) {
|
|
643
|
+
return `❌ CLI auto-turn failed: ${err.message}\n\n**Speaker:** ${speaker}\n**CLI:** ${hint.cmd}\n\nYou can submit a manual response via deliberation_respond(speaker: "${speaker}", content: "...").`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const retryTimeout = speaker === "codex"
|
|
647
|
+
? Math.min(Math.max(effectiveTimeout, 420), 600)
|
|
648
|
+
: Math.min(effectiveTimeout + 60, 300);
|
|
649
|
+
|
|
650
|
+
return t(
|
|
651
|
+
`⏱️ CLI auto-turn timed out.\n\n` +
|
|
652
|
+
`**Speaker:** ${speaker}\n` +
|
|
653
|
+
`**CLI:** ${hint.cmd}\n` +
|
|
654
|
+
`**Timeout:** ${effectiveTimeout}s\n` +
|
|
655
|
+
`**Prompt size:** ${promptLength} chars\n` +
|
|
656
|
+
`**Prior turns by speaker:** ${priorTurns}\n` +
|
|
657
|
+
`**Session state:** still waiting on ${speaker} for Round ${state.current_round}\n\n` +
|
|
658
|
+
`This usually means the CLI stayed busy longer than the timeout. It does **not** necessarily mean the model is down.\n` +
|
|
659
|
+
`${speaker === "codex" ? `Codex is the slowest CLI in recent deliberation logs, especially when recent_log contains long prior responses.\n` : ""}` +
|
|
660
|
+
`Recommended next step: retry with \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\`.\n` +
|
|
661
|
+
`Manual fallback: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
|
|
662
|
+
`⏱️ CLI 자동 턴이 타임아웃되었습니다.\n\n` +
|
|
663
|
+
`**Speaker:** ${speaker}\n` +
|
|
664
|
+
`**CLI:** ${hint.cmd}\n` +
|
|
665
|
+
`**Timeout:** ${effectiveTimeout}s\n` +
|
|
666
|
+
`**Prompt 크기:** ${promptLength} chars\n` +
|
|
667
|
+
`**이 speaker의 이전 발언 수:** ${priorTurns}\n` +
|
|
668
|
+
`**세션 상태:** Round ${state.current_round}에서 아직 ${speaker} 응답을 기다리는 중\n\n` +
|
|
669
|
+
`이건 보통 CLI가 제한 시간 안에 응답을 끝내지 못했다는 뜻입니다. 모델이 완전히 죽었다는 의미는 아닙니다.\n` +
|
|
670
|
+
`${speaker === "codex" ? `최근 딜리버레이션 로그 기준으로 Codex는 이전 응답 전문이 길게 들어가면 가장 느린 편입니다.\n` : ""}` +
|
|
671
|
+
`권장 조치: \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\` 로 재시도하세요.\n` +
|
|
672
|
+
`수동 대안: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
|
|
673
|
+
state?.lang
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ── Auto-turn execution core ────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Run a single CLI auto-turn for the given session and speaker.
|
|
681
|
+
* Returns { ok: true, response, elapsedMs } or { ok: false, error }.
|
|
682
|
+
*/
|
|
683
|
+
export async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
|
|
684
|
+
const state = loadSession(sessionId);
|
|
685
|
+
if (!state || state.status !== "active") {
|
|
686
|
+
return { ok: false, error: "Session not active" };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
690
|
+
if (transport !== "cli_respond") {
|
|
691
|
+
return { ok: false, error: `Speaker "${speaker}" is not CLI type` };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const hint = CLI_INVOCATION_HINTS[speaker];
|
|
695
|
+
if (!hint) return { ok: false, error: `No CLI hints for "${speaker}"` };
|
|
696
|
+
if (!checkCliLiveness(hint.cmd)) return { ok: false, error: `CLI "${hint.cmd}" not available` };
|
|
697
|
+
|
|
698
|
+
const turnId = state.pending_turn_id || generateTurnId();
|
|
699
|
+
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
700
|
+
const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
|
|
701
|
+
const effectiveTimeout = getCliAutoTurnTimeoutSec({
|
|
702
|
+
speaker,
|
|
703
|
+
requestedTimeoutSec: timeoutSec,
|
|
704
|
+
promptLength: turnPrompt.length,
|
|
705
|
+
priorTurns: speakerPriorTurns,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const startTime = Date.now();
|
|
709
|
+
try {
|
|
710
|
+
const response = await new Promise((resolve, reject) => {
|
|
711
|
+
const env = { ...process.env };
|
|
712
|
+
if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
|
|
713
|
+
|
|
714
|
+
let child;
|
|
715
|
+
let stdout = "";
|
|
716
|
+
let stderr = "";
|
|
717
|
+
let settled = false;
|
|
718
|
+
let forceKillTimer = null;
|
|
719
|
+
|
|
720
|
+
const resolveOnce = (v) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); resolve(v); } };
|
|
721
|
+
const rejectOnce = (e) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); reject(e); } };
|
|
722
|
+
|
|
723
|
+
const speakerHint = CLI_INVOCATION_HINTS[speaker];
|
|
724
|
+
const speakerModel = speakerHint?.defaultModel ?? null;
|
|
725
|
+
|
|
726
|
+
switch (speaker) {
|
|
727
|
+
case "claude":
|
|
728
|
+
child = spawn("claude", getCliExecArgs("claude", null), { env, windowsHide: true });
|
|
729
|
+
child.stdin.write(turnPrompt);
|
|
730
|
+
child.stdin.end();
|
|
731
|
+
break;
|
|
732
|
+
case "codex":
|
|
733
|
+
child = spawn("codex", getCliExecArgs("codex", speakerModel), { env, windowsHide: true });
|
|
734
|
+
child.stdin.write(turnPrompt);
|
|
735
|
+
child.stdin.end();
|
|
736
|
+
break;
|
|
737
|
+
case "gemini": {
|
|
738
|
+
const geminiArgs = speakerModel && speakerHint?.modelFlag
|
|
739
|
+
? [speakerHint.modelFlag, speakerModel, "-p", turnPrompt]
|
|
740
|
+
: ["-p", turnPrompt];
|
|
741
|
+
child = spawn("gemini", geminiArgs, { env, windowsHide: true });
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
default: {
|
|
745
|
+
const flags = hint.flags ? hint.flags.split(/\s+/) : [];
|
|
746
|
+
child = spawn(hint.cmd, [...flags, turnPrompt], { env, windowsHide: true });
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const timer = setTimeout(() => {
|
|
752
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
753
|
+
forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 5000);
|
|
754
|
+
if (typeof forceKillTimer?.unref === "function") forceKillTimer.unref();
|
|
755
|
+
rejectOnce(new Error(`CLI timeout (${effectiveTimeout}s)`));
|
|
756
|
+
}, effectiveTimeout * 1000);
|
|
757
|
+
|
|
758
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
759
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
760
|
+
|
|
761
|
+
child.on("close", (code) => {
|
|
762
|
+
clearTimeout(timer);
|
|
763
|
+
if (code !== 0 && !stdout.trim()) {
|
|
764
|
+
rejectOnce(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
|
|
765
|
+
} else {
|
|
766
|
+
resolveOnce(stdout.trim());
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
child.on("error", (err) => rejectOnce(err));
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// Submit the turn
|
|
774
|
+
submitDeliberationTurn({
|
|
775
|
+
session_id: sessionId,
|
|
776
|
+
speaker,
|
|
777
|
+
content: response,
|
|
778
|
+
turn_id: turnId,
|
|
779
|
+
channel_used: "cli_auto",
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
return { ok: true, response, elapsedMs: Date.now() - startTime };
|
|
783
|
+
} catch (err) {
|
|
784
|
+
return { ok: false, error: err.message };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function runBrowserAutoTurnCore(sessionId, speaker, timeoutSec = 45) {
|
|
789
|
+
const state = loadSession(sessionId);
|
|
790
|
+
if (!state || state.status !== "active") {
|
|
791
|
+
return { ok: false, error: "Session not active" };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const { transport, profile } = resolveTransportForSpeaker(state, speaker);
|
|
795
|
+
if (transport !== "browser_auto") {
|
|
796
|
+
return { ok: false, error: `Speaker "${speaker}" is not browser_auto type` };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const turnId = state.pending_turn_id || generateTurnId();
|
|
800
|
+
const port = getBrowserPort();
|
|
801
|
+
const effectiveProvider = profile?.provider || "chatgpt";
|
|
802
|
+
const modelSelection = getModelSelectionForTurn(state, speaker, effectiveProvider);
|
|
803
|
+
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
804
|
+
const startTime = Date.now();
|
|
805
|
+
|
|
806
|
+
try {
|
|
807
|
+
const attachResult = await port.attach(sessionId, {
|
|
808
|
+
provider: effectiveProvider,
|
|
809
|
+
url: profile?.url || undefined,
|
|
810
|
+
});
|
|
811
|
+
if (!attachResult.ok) {
|
|
812
|
+
return { ok: false, error: `attach failed: ${attachResult.error?.message || "unknown error"}` };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const loginCheck = await port.checkLogin(sessionId);
|
|
816
|
+
if (loginCheck && !loginCheck.loggedIn) {
|
|
817
|
+
await port.detach(sessionId);
|
|
818
|
+
return { ok: false, error: `login required: ${loginCheck.reason || "not logged in"}` };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (modelSelection.model !== "default") {
|
|
822
|
+
await port.switchModel(sessionId, modelSelection.model);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const sendResult = await port.sendTurnWithDegradation(sessionId, turnId, turnPrompt);
|
|
826
|
+
if (!sendResult.ok) {
|
|
827
|
+
await port.detach(sessionId);
|
|
828
|
+
return { ok: false, error: `send failed: ${sendResult.error?.message || "unknown error"}` };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const waitResult = await port.waitTurnResult(sessionId, turnId, timeoutSec);
|
|
832
|
+
await port.detach(sessionId);
|
|
833
|
+
if (!waitResult.ok || !waitResult.data?.response) {
|
|
834
|
+
return { ok: false, error: waitResult.error?.message || "no response received" };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
submitDeliberationTurn({
|
|
838
|
+
session_id: sessionId,
|
|
839
|
+
speaker,
|
|
840
|
+
content: waitResult.data.response,
|
|
841
|
+
turn_id: turnId,
|
|
842
|
+
channel_used: "browser_auto",
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
ok: true,
|
|
847
|
+
response: waitResult.data.response,
|
|
848
|
+
elapsedMs: Date.now() - startTime,
|
|
849
|
+
model: modelSelection.model,
|
|
850
|
+
provider: effectiveProvider,
|
|
851
|
+
};
|
|
852
|
+
} catch (err) {
|
|
853
|
+
try { await port.detach(sessionId); } catch {}
|
|
854
|
+
return { ok: false, error: err?.message || String(err) };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export async function runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries = 4) {
|
|
859
|
+
const state = loadSession(sessionId);
|
|
860
|
+
if (!state || state.status !== "active") {
|
|
861
|
+
return { ok: false, error: "Session not active" };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
865
|
+
if (transport !== "telepty_bus") {
|
|
866
|
+
return { ok: false, error: `Speaker "${speaker}" is not telepty_bus type` };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const startTime = Date.now();
|
|
870
|
+
const dispatchResult = await dispatchTeleptyTurnRequest({
|
|
871
|
+
state,
|
|
872
|
+
speaker,
|
|
873
|
+
includeHistoryEntries,
|
|
874
|
+
awaitSemantic: true,
|
|
875
|
+
});
|
|
876
|
+
if (!dispatchResult.publishResult?.ok) {
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
blocked: true,
|
|
880
|
+
error: dispatchResult.publishResult?.error || dispatchResult.publishResult?.status || "telepty bus publish failed",
|
|
881
|
+
envelope: dispatchResult.envelope,
|
|
882
|
+
turnPrompt: dispatchResult.turnPrompt,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
if (!dispatchResult.transportResult?.ok) {
|
|
886
|
+
return {
|
|
887
|
+
ok: false,
|
|
888
|
+
blocked: true,
|
|
889
|
+
error: dispatchResult.transportResult?.code || "transport timeout",
|
|
890
|
+
envelope: dispatchResult.envelope,
|
|
891
|
+
turnPrompt: dispatchResult.turnPrompt,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
if (!dispatchResult.semanticResult?.ok) {
|
|
895
|
+
return {
|
|
896
|
+
ok: false,
|
|
897
|
+
blocked: true,
|
|
898
|
+
error: dispatchResult.semanticResult?.code || "semantic timeout",
|
|
899
|
+
envelope: dispatchResult.envelope,
|
|
900
|
+
turnPrompt: dispatchResult.turnPrompt,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return {
|
|
905
|
+
ok: true,
|
|
906
|
+
elapsedMs: Date.now() - startTime,
|
|
907
|
+
envelope: dispatchResult.envelope,
|
|
908
|
+
publishResult: dispatchResult.publishResult,
|
|
909
|
+
transportResult: dispatchResult.transportResult,
|
|
910
|
+
semanticResult: dispatchResult.semanticResult,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export async function runUntilBlockedCore(sessionId, {
|
|
915
|
+
maxTurns = 12,
|
|
916
|
+
cliTimeoutSec = 120,
|
|
917
|
+
browserTimeoutSec = 45,
|
|
918
|
+
includeHistoryEntries = 4,
|
|
919
|
+
} = {}) {
|
|
920
|
+
const steps = [];
|
|
921
|
+
|
|
922
|
+
for (let iteration = 0; iteration < maxTurns; iteration += 1) {
|
|
923
|
+
const state = loadSession(sessionId);
|
|
924
|
+
if (!state) {
|
|
925
|
+
return { ok: false, status: "missing", error: "Session not found", steps };
|
|
926
|
+
}
|
|
927
|
+
if (state.status !== "active" || state.current_speaker === "none") {
|
|
928
|
+
return { ok: true, status: state.status, steps };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const speaker = state.current_speaker;
|
|
932
|
+
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
933
|
+
const callerSpeaker = detectCallerSpeaker();
|
|
934
|
+
if (transport === "cli_respond" && callerSpeaker && normalizeSpeaker(callerSpeaker) === normalizeSpeaker(speaker)) {
|
|
935
|
+
return {
|
|
936
|
+
ok: true,
|
|
937
|
+
status: "blocked",
|
|
938
|
+
block_reason: "self_turn",
|
|
939
|
+
speaker,
|
|
940
|
+
transport,
|
|
941
|
+
turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
|
|
942
|
+
steps,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (transport === "manual" || transport === "clipboard") {
|
|
947
|
+
return {
|
|
948
|
+
ok: true,
|
|
949
|
+
status: "blocked",
|
|
950
|
+
block_reason: "manual_transport",
|
|
951
|
+
speaker,
|
|
952
|
+
transport,
|
|
953
|
+
turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
|
|
954
|
+
steps,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let result = null;
|
|
959
|
+
if (transport === "cli_respond") {
|
|
960
|
+
result = await runCliAutoTurnCore(sessionId, speaker, cliTimeoutSec);
|
|
961
|
+
} else if (transport === "browser_auto") {
|
|
962
|
+
result = await runBrowserAutoTurnCore(sessionId, speaker, browserTimeoutSec);
|
|
963
|
+
} else if (transport === "telepty_bus") {
|
|
964
|
+
result = await runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries);
|
|
965
|
+
} else {
|
|
966
|
+
return {
|
|
967
|
+
ok: true,
|
|
968
|
+
status: "blocked",
|
|
969
|
+
block_reason: "unsupported_transport",
|
|
970
|
+
speaker,
|
|
971
|
+
transport,
|
|
972
|
+
turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
|
|
973
|
+
steps,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
steps.push({
|
|
978
|
+
speaker,
|
|
979
|
+
transport,
|
|
980
|
+
ok: Boolean(result?.ok),
|
|
981
|
+
error: result?.error || null,
|
|
982
|
+
elapsedMs: result?.elapsedMs || null,
|
|
983
|
+
blocked: Boolean(result?.blocked),
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
if (!result?.ok) {
|
|
987
|
+
return {
|
|
988
|
+
ok: Boolean(result?.blocked),
|
|
989
|
+
status: result?.blocked ? "blocked" : "error",
|
|
990
|
+
block_reason: result?.blocked ? (result.error || "transport_blocked") : null,
|
|
991
|
+
speaker,
|
|
992
|
+
transport,
|
|
993
|
+
error: result?.error || null,
|
|
994
|
+
turn_prompt: result?.turnPrompt || null,
|
|
995
|
+
steps,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const finalState = loadSession(sessionId);
|
|
1001
|
+
return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
status: finalState?.status === "active" ? "max_turns_reached" : (finalState?.status || "completed"),
|
|
1004
|
+
steps,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Generate structured synthesis by calling a CLI speaker with a synthesis prompt.
|
|
1010
|
+
*/
|
|
1011
|
+
export async function generateAutoSynthesis(sessionId) {
|
|
1012
|
+
const state = loadSession(sessionId);
|
|
1013
|
+
if (!state) return null;
|
|
1014
|
+
|
|
1015
|
+
const historyText = state.log.map(e => `[${e.speaker}] ${e.content}`).join("\n\n---\n\n");
|
|
1016
|
+
|
|
1017
|
+
const synthesisPrompt = `You are a deliberation synthesizer. Analyze this discussion and produce ONLY a JSON response (no markdown, no explanation).
|
|
1018
|
+
|
|
1019
|
+
Topic: ${state.topic}
|
|
1020
|
+
Project: ${state.project}
|
|
1021
|
+
Rounds: ${state.max_rounds}
|
|
1022
|
+
|
|
1023
|
+
Discussion:
|
|
1024
|
+
${historyText}
|
|
1025
|
+
|
|
1026
|
+
Respond with EXACTLY this JSON structure:
|
|
1027
|
+
{
|
|
1028
|
+
"summary": "Brief summary of the outcome",
|
|
1029
|
+
"decisions": ["Decision 1", "Decision 2"],
|
|
1030
|
+
"actionable_tasks": [
|
|
1031
|
+
{"id": 1, "task": "What to do", "files": ["path/to/file.ts"], "project": "${state.project}", "priority": "high|medium|low"}
|
|
1032
|
+
],
|
|
1033
|
+
"markdown_synthesis": "# Full synthesis in markdown\\n\\n..."
|
|
1034
|
+
}`;
|
|
1035
|
+
|
|
1036
|
+
// Use the first available CLI speaker to generate synthesis
|
|
1037
|
+
const speaker = state.speakers.find(s => {
|
|
1038
|
+
const hint = CLI_INVOCATION_HINTS[s];
|
|
1039
|
+
return hint && checkCliLiveness(hint.cmd);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
if (!speaker) return null;
|
|
1043
|
+
|
|
1044
|
+
const hint = CLI_INVOCATION_HINTS[speaker];
|
|
1045
|
+
|
|
1046
|
+
try {
|
|
1047
|
+
const response = await new Promise((resolve, reject) => {
|
|
1048
|
+
const env = { ...process.env };
|
|
1049
|
+
if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
|
|
1050
|
+
|
|
1051
|
+
let child;
|
|
1052
|
+
let stdout = "";
|
|
1053
|
+
|
|
1054
|
+
const synthModel = hint?.defaultModel ?? null;
|
|
1055
|
+
|
|
1056
|
+
switch (speaker) {
|
|
1057
|
+
case "claude":
|
|
1058
|
+
child = spawn("claude", getCliExecArgs("claude", null), { env, windowsHide: true });
|
|
1059
|
+
child.stdin.write(synthesisPrompt);
|
|
1060
|
+
child.stdin.end();
|
|
1061
|
+
break;
|
|
1062
|
+
case "codex":
|
|
1063
|
+
child = spawn("codex", getCliExecArgs("codex", synthModel), { env, windowsHide: true });
|
|
1064
|
+
child.stdin.write(synthesisPrompt);
|
|
1065
|
+
child.stdin.end();
|
|
1066
|
+
break;
|
|
1067
|
+
case "gemini": {
|
|
1068
|
+
const geminiArgs = synthModel && hint?.modelFlag
|
|
1069
|
+
? [hint.modelFlag, synthModel, "-p", synthesisPrompt]
|
|
1070
|
+
: ["-p", synthesisPrompt];
|
|
1071
|
+
child = spawn("gemini", geminiArgs, { env, windowsHide: true });
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
default: {
|
|
1075
|
+
const flags = hint.flags ? hint.flags.split(/\s+/) : [];
|
|
1076
|
+
child = spawn(hint.cmd, [...flags, synthesisPrompt], { env, windowsHide: true });
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const timer = setTimeout(() => {
|
|
1082
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
1083
|
+
reject(new Error("Synthesis generation timeout"));
|
|
1084
|
+
}, 180000); // 3 min timeout for synthesis
|
|
1085
|
+
|
|
1086
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
1087
|
+
child.on("close", (code) => {
|
|
1088
|
+
clearTimeout(timer);
|
|
1089
|
+
resolve(stdout.trim());
|
|
1090
|
+
});
|
|
1091
|
+
child.on("error", reject);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// Extract JSON from response (may have markdown wrapping)
|
|
1095
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
1096
|
+
if (!jsonMatch) return { markdown_synthesis: response };
|
|
1097
|
+
|
|
1098
|
+
try {
|
|
1099
|
+
return JSON.parse(jsonMatch[0]);
|
|
1100
|
+
} catch {
|
|
1101
|
+
return { markdown_synthesis: response };
|
|
1102
|
+
}
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
_deps.appendRuntimeLog("ERROR", `AUTO_SYNTHESIS_FAILED: ${sessionId} | ${err.message}`);
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Orchestrate full auto-handoff: run all turns -> synthesize -> inbox -> telepty.
|
|
1111
|
+
* Called as fire-and-forget from deliberation_start when auto_execute is true.
|
|
1112
|
+
*/
|
|
1113
|
+
export async function runAutoHandoff(sessionId) {
|
|
1114
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_START: ${sessionId}`);
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
// Phase 1: Run all deliberation turns
|
|
1118
|
+
let maxIterations = 100; // safety limit
|
|
1119
|
+
while (maxIterations-- > 0) {
|
|
1120
|
+
const state = loadSession(sessionId);
|
|
1121
|
+
if (!state) {
|
|
1122
|
+
_deps.appendRuntimeLog("ERROR", `AUTO_HANDOFF: Session ${sessionId} disappeared`);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (state.status !== "active") {
|
|
1126
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF: Session ${sessionId} status=${state.status}, turns done`);
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const speaker = state.current_speaker;
|
|
1131
|
+
if (speaker === "none") break;
|
|
1132
|
+
|
|
1133
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN: ${sessionId} | speaker: ${speaker} | round: ${state.current_round}/${state.max_rounds}`);
|
|
1134
|
+
|
|
1135
|
+
const runResult = await runUntilBlockedCore(sessionId, { maxTurns: 1, includeHistoryEntries: 3 });
|
|
1136
|
+
const step = runResult.steps.at(-1) || null;
|
|
1137
|
+
if (!runResult.ok || runResult.status === "blocked") {
|
|
1138
|
+
_deps.appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_BLOCKED: ${sessionId} | speaker: ${speaker} | ${runResult.block_reason || runResult.error || "unknown"}`);
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${step?.elapsedMs || 0}ms`);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Phase 2: Generate structured synthesis
|
|
1146
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZE: ${sessionId}`);
|
|
1147
|
+
let synthResult = await generateAutoSynthesis(sessionId);
|
|
1148
|
+
|
|
1149
|
+
// Phase 3: Call synthesize (reuse existing logic)
|
|
1150
|
+
const state = loadSession(sessionId);
|
|
1151
|
+
if (!state) return;
|
|
1152
|
+
|
|
1153
|
+
// Fallback: if synthesis generation failed, build a basic structure from the discussion
|
|
1154
|
+
if (!synthResult || (!synthResult.summary && !synthResult.actionable_tasks)) {
|
|
1155
|
+
_deps.appendRuntimeLog("WARN", `AUTO_HANDOFF_SYNTH_FALLBACK: ${sessionId} | Building fallback from discussion log`);
|
|
1156
|
+
const turns = state.log || [];
|
|
1157
|
+
const fallbackSummary = turns.length > 0
|
|
1158
|
+
? `Deliberation on "${state.topic}" completed with ${turns.length} turns from ${[...new Set(turns.map(t => t.speaker))].join(", ")}.`
|
|
1159
|
+
: `Deliberation on "${state.topic}" completed.`;
|
|
1160
|
+
synthResult = {
|
|
1161
|
+
summary: fallbackSummary,
|
|
1162
|
+
decisions: [`Discussed: ${state.topic}`],
|
|
1163
|
+
actionable_tasks: [],
|
|
1164
|
+
markdown_synthesis: `# Auto-generated synthesis (fallback)\n\n${fallbackSummary}\n\n## Discussion\n${turns.map(t => `**${t.speaker}**: ${typeof t.content === 'string' ? t.content.substring(0, 200) : '(no content)'}${t.content && t.content.length > 200 ? '...' : ''}`).join("\n\n")}`,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const markdownSynthesis = synthResult?.markdown_synthesis ||
|
|
1169
|
+
`# Auto-generated synthesis\n\n${synthResult?.summary || "Deliberation completed."}\n\n## Decisions\n${(synthResult?.decisions || []).map(d => `- ${d}`).join("\n")}\n\n## Tasks\n${(synthResult?.actionable_tasks || []).map(t => `- [${t.priority}] ${t.task}`).join("\n")}`;
|
|
1170
|
+
|
|
1171
|
+
const structured = {
|
|
1172
|
+
summary: synthResult.summary || "",
|
|
1173
|
+
decisions: synthResult.decisions || [],
|
|
1174
|
+
actionable_tasks: synthResult.actionable_tasks || [],
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// Apply synthesis to session
|
|
1178
|
+
_deps.withSessionLock(sessionId, () => {
|
|
1179
|
+
const loaded = loadSession(sessionId);
|
|
1180
|
+
if (!loaded) return;
|
|
1181
|
+
loaded.synthesis = markdownSynthesis;
|
|
1182
|
+
loaded.structured_synthesis = structured;
|
|
1183
|
+
loaded.execution_contract = buildExecutionContract({ state: loaded, structured });
|
|
1184
|
+
loaded.status = "completed";
|
|
1185
|
+
loaded.current_speaker = "none";
|
|
1186
|
+
saveSession(loaded);
|
|
1187
|
+
archiveState(loaded);
|
|
1188
|
+
cleanupSyncMarkdown(loaded);
|
|
1189
|
+
|
|
1190
|
+
const sessionFile = _deps.getSessionFile(loaded);
|
|
1191
|
+
try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch {}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
closeMonitorTerminal(sessionId, getSessionWindowIds(state));
|
|
1195
|
+
|
|
1196
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZED: ${sessionId}`);
|
|
1197
|
+
|
|
1198
|
+
// Phase 4: Notify telepty bus with full structured data for dustcraw to consume
|
|
1199
|
+
if (state.auto_execute) {
|
|
1200
|
+
const envelope = buildTeleptySynthesisEnvelope({
|
|
1201
|
+
state,
|
|
1202
|
+
synthesis: markdownSynthesis,
|
|
1203
|
+
structured,
|
|
1204
|
+
});
|
|
1205
|
+
await notifyTeleptyBus(envelope).catch(() => {});
|
|
1206
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_NOTIFIED: ${sessionId} | telepty event sent`);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
_deps.appendRuntimeLog("INFO", `AUTO_HANDOFF_COMPLETE: ${sessionId}`);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
_deps.appendRuntimeLog("ERROR", `AUTO_HANDOFF_ERROR: ${sessionId} | ${err.message}`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ── Review helpers ──────────────────────────────────────────────
|
|
1216
|
+
|
|
1217
|
+
export function invokeCliReviewer(command, prompt, timeoutMs) {
|
|
1218
|
+
const hint = CLI_INVOCATION_HINTS[command];
|
|
1219
|
+
let args;
|
|
1220
|
+
let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, windowsHide: true };
|
|
1221
|
+
const env = { ...process.env };
|
|
1222
|
+
|
|
1223
|
+
switch (command) {
|
|
1224
|
+
case "claude":
|
|
1225
|
+
if (hint?.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
|
|
1226
|
+
args = ["-p", "--output-format", "text", "--no-input"];
|
|
1227
|
+
opts.input = prompt;
|
|
1228
|
+
break;
|
|
1229
|
+
case "codex":
|
|
1230
|
+
args = ["exec", "-"];
|
|
1231
|
+
opts.input = prompt;
|
|
1232
|
+
break;
|
|
1233
|
+
case "gemini":
|
|
1234
|
+
args = ["-p", prompt];
|
|
1235
|
+
opts.stdio = ["ignore", "pipe", "pipe"];
|
|
1236
|
+
break;
|
|
1237
|
+
default: {
|
|
1238
|
+
const flags = hint?.flags ? hint.flags.split(/\s+/).filter(Boolean) : ["-p"];
|
|
1239
|
+
args = [...flags, prompt];
|
|
1240
|
+
opts.stdio = ["ignore", "pipe", "pipe"];
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
try {
|
|
1246
|
+
const result = execFileSync(command, args, { ...opts, env });
|
|
1247
|
+
let cleaned = result;
|
|
1248
|
+
if (command === "codex") {
|
|
1249
|
+
const lines = result.split("\n");
|
|
1250
|
+
const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
|
|
1251
|
+
if (codexLineIdx !== -1) {
|
|
1252
|
+
cleaned = lines.slice(codexLineIdx + 1)
|
|
1253
|
+
.filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
|
|
1254
|
+
.join("\n");
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return { ok: true, response: cleaned.trim() };
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
if (error && error.killed) {
|
|
1260
|
+
return { ok: false, error: "timeout" };
|
|
1261
|
+
}
|
|
1262
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1263
|
+
return { ok: false, error: msg };
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
export function buildReviewPrompt(context, question, priorReviews) {
|
|
1268
|
+
let prompt = `You are a code reviewer. Provide a concise, structured review.\n\n`;
|
|
1269
|
+
prompt += `## Context\n${context}\n\n`;
|
|
1270
|
+
prompt += `## Review Question\n${question}\n\n`;
|
|
1271
|
+
if (priorReviews.length > 0) {
|
|
1272
|
+
prompt += `## Prior Reviews\n`;
|
|
1273
|
+
for (const r of priorReviews) {
|
|
1274
|
+
prompt += `### ${r.reviewer}\n${r.response}\n\n`;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
prompt += `Respond with your review. Be specific about issues, risks, and suggestions.`;
|
|
1278
|
+
return prompt;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
export function synthesizeReviews(context, question, reviews) {
|
|
1282
|
+
if (reviews.length === 0) return "(No reviews completed)";
|
|
1283
|
+
|
|
1284
|
+
let synthesis = `## Review Synthesis\n\n`;
|
|
1285
|
+
synthesis += `**Question:** ${question}\n`;
|
|
1286
|
+
synthesis += `**Reviews:** ${reviews.length}\n\n`;
|
|
1287
|
+
|
|
1288
|
+
synthesis += `### Individual Reviews\n\n`;
|
|
1289
|
+
for (const r of reviews) {
|
|
1290
|
+
synthesis += `#### ${r.reviewer}\n${r.response}\n\n`;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (reviews.length > 1) {
|
|
1294
|
+
synthesis += `### Summary\n`;
|
|
1295
|
+
synthesis += `${reviews.length} reviewer(s) provided feedback on: ${question}\n`;
|
|
1296
|
+
synthesis += `Reviewers: ${reviews.map(r => r.reviewer).join(", ")}\n`;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return synthesis;
|
|
1300
|
+
}
|