@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/index.js
CHANGED
|
@@ -69,14 +69,159 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
69
69
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
70
70
|
import { z } from "zod";
|
|
71
71
|
import { execFileSync, spawn } from "child_process";
|
|
72
|
-
import { createHash } from "crypto";
|
|
73
72
|
import fs from "fs";
|
|
74
73
|
import path from "path";
|
|
75
74
|
import { fileURLToPath } from "url";
|
|
76
75
|
import os from "os";
|
|
77
|
-
import WebSocket from "ws";
|
|
78
76
|
import { OrchestratedBrowserPort } from "./browser-control-port.js";
|
|
79
77
|
import { getModelSelectionForTurn } from "./model-router.js";
|
|
78
|
+
import {
|
|
79
|
+
initSpeakerDeps,
|
|
80
|
+
// Constants
|
|
81
|
+
DEFAULT_SPEAKERS,
|
|
82
|
+
DEFAULT_CLI_CANDIDATES,
|
|
83
|
+
MAX_AUTO_DISCOVERED_SPEAKERS,
|
|
84
|
+
DEFAULT_BROWSER_APPS,
|
|
85
|
+
DEFAULT_LLM_DOMAINS,
|
|
86
|
+
DEFAULT_WEB_SPEAKERS,
|
|
87
|
+
SPEAKER_SELECTION_FILE,
|
|
88
|
+
SPEAKER_SELECTION_TTL_MS,
|
|
89
|
+
CLI_INVOCATION_HINTS,
|
|
90
|
+
ROLE_KEYWORDS,
|
|
91
|
+
ROLE_HEADING_MARKERS,
|
|
92
|
+
DEGRADATION_TIERS,
|
|
93
|
+
TRANSPORT_TYPES,
|
|
94
|
+
// Utility
|
|
95
|
+
commandExistsInPath,
|
|
96
|
+
shellQuote,
|
|
97
|
+
// Speaker normalization & ordering
|
|
98
|
+
normalizeSpeaker,
|
|
99
|
+
dedupeSpeakers,
|
|
100
|
+
selectNextSpeaker,
|
|
101
|
+
loadRolePrompt,
|
|
102
|
+
inferSuggestedRole,
|
|
103
|
+
parseVotes,
|
|
104
|
+
loadRolePresets,
|
|
105
|
+
applyRolePreset,
|
|
106
|
+
loadExtensionProviderRegistry,
|
|
107
|
+
isExtensionLlmTab,
|
|
108
|
+
// Speaker selection tokens
|
|
109
|
+
createSelectionToken,
|
|
110
|
+
issueSpeakerSelectionToken,
|
|
111
|
+
loadSpeakerSelectionToken,
|
|
112
|
+
clearSpeakerSelectionToken,
|
|
113
|
+
validateSpeakerSelectionSnapshot,
|
|
114
|
+
confirmSpeakerSelectionToken,
|
|
115
|
+
validateSpeakerSelectionRequest,
|
|
116
|
+
// Browser participant helpers
|
|
117
|
+
hasExplicitBrowserParticipantSelection,
|
|
118
|
+
resolveIncludeBrowserSpeakers,
|
|
119
|
+
// CLI discovery
|
|
120
|
+
resolveCliCandidates,
|
|
121
|
+
checkCliLiveness,
|
|
122
|
+
discoverLocalCliSpeakers,
|
|
123
|
+
detectCallerSpeaker,
|
|
124
|
+
// URL / domain helpers
|
|
125
|
+
isLlmUrl,
|
|
126
|
+
dedupeBrowserTabs,
|
|
127
|
+
parseInjectedBrowserTabsFromEnv,
|
|
128
|
+
// CDP helpers
|
|
129
|
+
normalizeCdpEndpoint,
|
|
130
|
+
resolveCdpEndpoints,
|
|
131
|
+
fetchJson,
|
|
132
|
+
inferBrowserFromCdpEndpoint,
|
|
133
|
+
summarizeFailures,
|
|
134
|
+
// Browser LLM tab collection
|
|
135
|
+
collectBrowserLlmTabsViaCdp,
|
|
136
|
+
ensureCdpAvailable,
|
|
137
|
+
collectBrowserLlmTabsViaAppleScript,
|
|
138
|
+
collectBrowserLlmTabs,
|
|
139
|
+
// LLM provider inference
|
|
140
|
+
inferLlmProvider,
|
|
141
|
+
// Speaker candidate collection
|
|
142
|
+
collectSpeakerCandidates,
|
|
143
|
+
formatSpeakerCandidatesReport,
|
|
144
|
+
mapParticipantProfiles,
|
|
145
|
+
// Speaker ordering
|
|
146
|
+
buildSpeakerOrder,
|
|
147
|
+
normalizeSessionActors,
|
|
148
|
+
// Transport routing
|
|
149
|
+
resolveTransportForSpeaker,
|
|
150
|
+
formatTransportGuidance,
|
|
151
|
+
// Degradation
|
|
152
|
+
detectDegradationLevels,
|
|
153
|
+
formatDegradationReport,
|
|
154
|
+
} from "./lib/speaker-discovery.js";
|
|
155
|
+
import {
|
|
156
|
+
initSessionDeps,
|
|
157
|
+
generateSessionId,
|
|
158
|
+
generateTurnId,
|
|
159
|
+
detectContextDirs,
|
|
160
|
+
readContextFromDirs,
|
|
161
|
+
getArchiveDir,
|
|
162
|
+
findSessionRecord,
|
|
163
|
+
ensureDirs,
|
|
164
|
+
loadSession,
|
|
165
|
+
saveSession,
|
|
166
|
+
listActiveSessions,
|
|
167
|
+
resolveSessionId,
|
|
168
|
+
syncMarkdown,
|
|
169
|
+
cleanupSyncMarkdown,
|
|
170
|
+
formatSourceMetadataLine,
|
|
171
|
+
stateToMarkdown,
|
|
172
|
+
archiveState,
|
|
173
|
+
multipleSessionsError,
|
|
174
|
+
truncatePromptText,
|
|
175
|
+
getPromptBudgetForSpeaker,
|
|
176
|
+
formatRecentLogForPrompt,
|
|
177
|
+
buildActiveReportingSection,
|
|
178
|
+
buildClipboardTurnPrompt,
|
|
179
|
+
submitDeliberationTurn,
|
|
180
|
+
} from "./lib/session.js";
|
|
181
|
+
import {
|
|
182
|
+
initTransportDeps,
|
|
183
|
+
// Constants
|
|
184
|
+
TMUX_SESSION,
|
|
185
|
+
MONITOR_SCRIPT,
|
|
186
|
+
MONITOR_SCRIPT_WIN,
|
|
187
|
+
// Terminal management
|
|
188
|
+
tmuxWindowName,
|
|
189
|
+
appleScriptQuote,
|
|
190
|
+
tryExecFile,
|
|
191
|
+
resolveMonitorShell,
|
|
192
|
+
buildMonitorCommand,
|
|
193
|
+
buildMonitorCommandWindows,
|
|
194
|
+
hasTmuxSession,
|
|
195
|
+
hasTmuxWindow,
|
|
196
|
+
tmuxHasAttachedClients,
|
|
197
|
+
isTmuxWindowViewed,
|
|
198
|
+
tmuxWindowCount,
|
|
199
|
+
buildTmuxAttachCommand,
|
|
200
|
+
listPhysicalTerminalWindowIds,
|
|
201
|
+
openPhysicalTerminal,
|
|
202
|
+
spawnMonitorTerminal,
|
|
203
|
+
closePhysicalTerminal,
|
|
204
|
+
closeMonitorTerminal,
|
|
205
|
+
getSessionWindowIds,
|
|
206
|
+
closeAllMonitorTerminals,
|
|
207
|
+
// Browser port singleton
|
|
208
|
+
getBrowserPort,
|
|
209
|
+
// CLI auto-turn helpers
|
|
210
|
+
getCliAutoTurnTimeoutSec,
|
|
211
|
+
getCliExecArgs,
|
|
212
|
+
buildCliAutoTurnFailureText,
|
|
213
|
+
// Auto-turn execution core
|
|
214
|
+
runCliAutoTurnCore,
|
|
215
|
+
runBrowserAutoTurnCore,
|
|
216
|
+
runTeleptyBusAutoTurnCore,
|
|
217
|
+
runUntilBlockedCore,
|
|
218
|
+
generateAutoSynthesis,
|
|
219
|
+
runAutoHandoff,
|
|
220
|
+
// Review helpers
|
|
221
|
+
invokeCliReviewer,
|
|
222
|
+
buildReviewPrompt,
|
|
223
|
+
synthesizeReviews,
|
|
224
|
+
} from "./lib/transport.js";
|
|
80
225
|
import { readClipboardText, writeClipboardText, hasClipboardImage, captureClipboardImage } from "./clipboard.js";
|
|
81
226
|
import {
|
|
82
227
|
DECISION_STAGES, STAGE_TRANSITIONS,
|
|
@@ -86,6 +231,63 @@ import {
|
|
|
86
231
|
loadTemplates, matchTemplate,
|
|
87
232
|
} from "./decision-engine.js";
|
|
88
233
|
import { detectLang, t } from "./i18n.js";
|
|
234
|
+
import { checkToolEntitlement } from "./lib/entitlement.js";
|
|
235
|
+
import {
|
|
236
|
+
initTeleptyDeps,
|
|
237
|
+
// Schemas
|
|
238
|
+
StructuredActionableTaskSchema,
|
|
239
|
+
StructuredExperimentOutcomeSchema,
|
|
240
|
+
StructuredSynthesisSchema,
|
|
241
|
+
StructuredExecutionContractSchema,
|
|
242
|
+
TeleptyEnvelopeSchema,
|
|
243
|
+
TeleptyTurnRequestPayloadSchema,
|
|
244
|
+
TeleptyTurnCompletedPayloadSchema,
|
|
245
|
+
TeleptyDeliberationCompletedPayloadSchema,
|
|
246
|
+
TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS,
|
|
247
|
+
// Constants
|
|
248
|
+
TELEPTY_CONFIG_FILE,
|
|
249
|
+
TELEPTY_DEFAULT_HOST,
|
|
250
|
+
TELEPTY_PORT,
|
|
251
|
+
TELEPTY_TRANSPORT_TIMEOUT_MS,
|
|
252
|
+
TELEPTY_SEMANTIC_TIMEOUT_MS,
|
|
253
|
+
TELEPTY_BUS_RECONNECT_MS,
|
|
254
|
+
TELEPTY_SESSION_HEALTH_STALE_MS,
|
|
255
|
+
// State
|
|
256
|
+
teleptyBusState,
|
|
257
|
+
pendingTeleptyTurnRequests,
|
|
258
|
+
// Functions
|
|
259
|
+
hashPromptText,
|
|
260
|
+
sortJsonValue,
|
|
261
|
+
hashStructuredSynthesis,
|
|
262
|
+
buildExecutionContract,
|
|
263
|
+
createEnvelopeId,
|
|
264
|
+
validateTeleptyEnvelope,
|
|
265
|
+
resolveTeleptySourceHost,
|
|
266
|
+
buildTeleptyEnvelope,
|
|
267
|
+
buildTeleptyTurnRequestEnvelope,
|
|
268
|
+
buildTeleptyTurnCompletedEnvelope,
|
|
269
|
+
buildTeleptySynthesisEnvelope,
|
|
270
|
+
resolveTeleptyBusUrl,
|
|
271
|
+
cleanupPendingTeleptyTurn,
|
|
272
|
+
registerPendingTeleptyTurnRequest,
|
|
273
|
+
ackPendingTeleptyTurn,
|
|
274
|
+
completePendingTeleptySemantic,
|
|
275
|
+
updateTeleptySessionHealth,
|
|
276
|
+
getTeleptySessionHealth,
|
|
277
|
+
handleTeleptyBusMessage,
|
|
278
|
+
ensureTeleptyBusSubscriber,
|
|
279
|
+
callBrainIngest,
|
|
280
|
+
notifyTeleptyBus,
|
|
281
|
+
getDefaultOrchestratorSessionId,
|
|
282
|
+
buildTurnCompletionNotificationText,
|
|
283
|
+
notifyTeleptySessionInject,
|
|
284
|
+
dispatchTeleptyTurnRequest,
|
|
285
|
+
loadTeleptyAuthToken,
|
|
286
|
+
formatTeleptyHostLabel,
|
|
287
|
+
collectTeleptySessions,
|
|
288
|
+
scoreTeleptyProcessMatch,
|
|
289
|
+
collectTeleptyProcessLocators,
|
|
290
|
+
} from "./lib/telepty.js";
|
|
89
291
|
|
|
90
292
|
// ── Paths ──────────────────────────────────────────────────────
|
|
91
293
|
|
|
@@ -98,29 +300,6 @@ const GLOBAL_STATE_DIR = path.join(INSTALL_DIR, "state");
|
|
|
98
300
|
const GLOBAL_RUNTIME_LOG = path.join(INSTALL_DIR, "runtime.log");
|
|
99
301
|
const OBSIDIAN_VAULT = path.join(HOME, "Documents", "Obsidian Vault");
|
|
100
302
|
const OBSIDIAN_PROJECTS = path.join(OBSIDIAN_VAULT, "10-Projects");
|
|
101
|
-
const DEFAULT_SPEAKERS = ["agent-a", "agent-b"];
|
|
102
|
-
const DEFAULT_CLI_CANDIDATES = [
|
|
103
|
-
"claude",
|
|
104
|
-
"codex",
|
|
105
|
-
"gemini",
|
|
106
|
-
"qwen",
|
|
107
|
-
"chatgpt",
|
|
108
|
-
"aider",
|
|
109
|
-
"llm",
|
|
110
|
-
"opencode",
|
|
111
|
-
"cursor-agent",
|
|
112
|
-
"cursor",
|
|
113
|
-
"continue",
|
|
114
|
-
];
|
|
115
|
-
const MAX_AUTO_DISCOVERED_SPEAKERS = 12;
|
|
116
|
-
const TELEPTY_CONFIG_FILE = path.join(HOME, ".telepty", "config.json");
|
|
117
|
-
const TELEPTY_DEFAULT_HOST = process.env.TELEPTY_HOST || "127.0.0.1";
|
|
118
|
-
const TELEPTY_PORT = Number(process.env.TELEPTY_PORT || 3848);
|
|
119
|
-
const TELEPTY_TRANSPORT_TIMEOUT_MS = 5_000;
|
|
120
|
-
const TELEPTY_SEMANTIC_TIMEOUT_MS = 60_000;
|
|
121
|
-
const TELEPTY_BUS_RECONNECT_MS = 5_000;
|
|
122
|
-
const TELEPTY_SESSION_HEALTH_STALE_MS = 25_000;
|
|
123
|
-
|
|
124
303
|
function loadDeliberationConfig() {
|
|
125
304
|
const configPath = path.join(INSTALL_DIR, "config.json");
|
|
126
305
|
try {
|
|
@@ -132,326 +311,16 @@ function loadDeliberationConfig() {
|
|
|
132
311
|
|
|
133
312
|
function saveDeliberationConfig(config) {
|
|
134
313
|
const configPath = path.join(INSTALL_DIR, "config.json");
|
|
314
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
135
315
|
config.updated = new Date().toISOString();
|
|
136
316
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
137
317
|
}
|
|
138
318
|
|
|
139
|
-
const StructuredActionableTaskSchema = z.object({
|
|
140
|
-
id: z.number(),
|
|
141
|
-
task: z.string(),
|
|
142
|
-
files: z.array(z.string()).optional(),
|
|
143
|
-
project: z.string().optional(),
|
|
144
|
-
priority: z.enum(["high", "medium", "low"]).optional(),
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const StructuredExperimentOutcomeSchema = z.object({
|
|
148
|
-
verdict: z.enum(["keep", "discard", "modify"]),
|
|
149
|
-
confidence: z.number().min(0).max(1).optional(),
|
|
150
|
-
measurement_window_hours: z.number().nonnegative().optional(),
|
|
151
|
-
patches: z.array(z.unknown()).optional(),
|
|
152
|
-
suggested_action: z.enum(["advance", "revert", "iterate"]).optional(),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
const StructuredSynthesisSchema = z.object({
|
|
156
|
-
summary: z.string(),
|
|
157
|
-
decisions: z.array(z.string()),
|
|
158
|
-
actionable_tasks: z.array(StructuredActionableTaskSchema),
|
|
159
|
-
experiment_outcome: StructuredExperimentOutcomeSchema.optional(),
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
const StructuredExecutionContractSchema = z.object({
|
|
163
|
-
schema_version: z.number().int().positive(),
|
|
164
|
-
source_session_id: z.string().min(1),
|
|
165
|
-
deliberation_id: z.string().min(1),
|
|
166
|
-
summary: z.string(),
|
|
167
|
-
decisions: z.array(z.string()),
|
|
168
|
-
tasks: z.array(StructuredActionableTaskSchema),
|
|
169
|
-
experiment_outcome: StructuredExperimentOutcomeSchema.nullable().optional(),
|
|
170
|
-
unresolved_questions: z.array(z.string()),
|
|
171
|
-
artifact_refs: z.array(z.string()),
|
|
172
|
-
generated_from: z.object({
|
|
173
|
-
structured_synthesis_hash: z.string().length(40),
|
|
174
|
-
}),
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
const TeleptyEnvelopeSchema = z.object({
|
|
178
|
-
version: z.number().int().positive().optional(),
|
|
179
|
-
message_id: z.string().min(1),
|
|
180
|
-
session_id: z.string().min(1),
|
|
181
|
-
project: z.string().min(1),
|
|
182
|
-
kind: z.string().min(1),
|
|
183
|
-
source: z.string().min(1),
|
|
184
|
-
source_host: z.string().min(1).optional(),
|
|
185
|
-
target: z.string().min(1),
|
|
186
|
-
reply_to: z.string().nullable().optional(),
|
|
187
|
-
trace: z.array(z.string()),
|
|
188
|
-
payload: z.unknown(),
|
|
189
|
-
ts: z.string().min(1),
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
const TeleptyTurnRequestPayloadSchema = z.object({
|
|
193
|
-
turn_id: z.string().min(1),
|
|
194
|
-
round: z.number().int().positive(),
|
|
195
|
-
max_rounds: z.number().int().positive(),
|
|
196
|
-
speaker: z.string().min(1),
|
|
197
|
-
role: z.string().nullable().optional(),
|
|
198
|
-
prompt: z.string().min(1),
|
|
199
|
-
content: z.string().min(1).describe("PTY-compatible alias for prompt field"),
|
|
200
|
-
prompt_sha1: z.string().length(40),
|
|
201
|
-
history_entries: z.number().int().nonnegative().optional(),
|
|
202
|
-
transport_timeout_ms: z.number().int().positive(),
|
|
203
|
-
semantic_timeout_ms: z.number().int().positive(),
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const TeleptyTurnCompletedPayloadSchema = z.object({
|
|
207
|
-
turn_id: z.string().nullable().optional(),
|
|
208
|
-
speaker: z.string().min(1),
|
|
209
|
-
round: z.number().int().positive(),
|
|
210
|
-
max_rounds: z.number().int().positive(),
|
|
211
|
-
next_speaker: z.string().min(1),
|
|
212
|
-
next_round: z.number().int().positive(),
|
|
213
|
-
status: z.string().min(1),
|
|
214
|
-
total_responses: z.number().int().nonnegative(),
|
|
215
|
-
channel_used: z.string().nullable().optional(),
|
|
216
|
-
fallback_reason: z.string().nullable().optional(),
|
|
217
|
-
orchestrator_session_id: z.string().nullable().optional(),
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
const TeleptyDeliberationCompletedPayloadSchema = z.object({
|
|
221
|
-
topic: z.string(),
|
|
222
|
-
synthesis: z.string(),
|
|
223
|
-
structured_synthesis: StructuredSynthesisSchema.nullable().optional(),
|
|
224
|
-
execution_contract: StructuredExecutionContractSchema.nullable().optional(),
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
const TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS = {
|
|
228
|
-
turn_request: TeleptyTurnRequestPayloadSchema,
|
|
229
|
-
turn_completed: TeleptyTurnCompletedPayloadSchema,
|
|
230
|
-
deliberation_completed: TeleptyDeliberationCompletedPayloadSchema,
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const DEFAULT_BROWSER_APPS = ["Google Chrome", "Brave Browser", "Arc", "Microsoft Edge", "Safari"];
|
|
234
|
-
const DEFAULT_LLM_DOMAINS = [
|
|
235
|
-
"chatgpt.com",
|
|
236
|
-
"openai.com",
|
|
237
|
-
"claude.ai",
|
|
238
|
-
"anthropic.com",
|
|
239
|
-
"gemini.google.com",
|
|
240
|
-
"copilot.microsoft.com",
|
|
241
|
-
"poe.com",
|
|
242
|
-
"perplexity.ai",
|
|
243
|
-
"mistral.ai",
|
|
244
|
-
"huggingface.co/chat",
|
|
245
|
-
"deepseek.com",
|
|
246
|
-
"qwen.ai",
|
|
247
|
-
"notebooklm.google.com",
|
|
248
|
-
];
|
|
249
|
-
|
|
250
|
-
// Well-known web LLMs — always available as speaker candidates regardless of browser detection.
|
|
251
|
-
// When a matching browser tab is detected, transport upgrades to browser_auto (CDP) or clipboard.
|
|
252
|
-
// When no tab is detected, transport falls back to clipboard (manual paste).
|
|
253
|
-
const DEFAULT_WEB_SPEAKERS = [
|
|
254
|
-
{ speaker: "web-chatgpt", provider: "chatgpt", name: "ChatGPT", url: "https://chatgpt.com" },
|
|
255
|
-
{ speaker: "web-claude", provider: "claude", name: "Claude", url: "https://claude.ai" },
|
|
256
|
-
{ speaker: "web-gemini", provider: "gemini", name: "Gemini", url: "https://gemini.google.com" },
|
|
257
|
-
{ speaker: "web-copilot", provider: "copilot", name: "Copilot", url: "https://copilot.microsoft.com" },
|
|
258
|
-
{ speaker: "web-perplexity", provider: "perplexity", name: "Perplexity", url: "https://perplexity.ai" },
|
|
259
|
-
{ speaker: "web-deepseek", provider: "deepseek", name: "DeepSeek", url: "https://chat.deepseek.com" },
|
|
260
|
-
{ speaker: "web-mistral", provider: "mistral", name: "Mistral", url: "https://mistral.ai" },
|
|
261
|
-
{ speaker: "web-poe", provider: "poe", name: "Poe", url: "https://poe.com" },
|
|
262
|
-
{ speaker: "web-grok", provider: "grok", name: "Grok", url: "https://grok.com" },
|
|
263
|
-
{ speaker: "web-qwen", provider: "qwen", name: "Qwen", url: "https://chat.qwen.ai" },
|
|
264
|
-
{ speaker: "web-huggingchat", provider: "huggingchat", name: "HuggingChat", url: "https://huggingface.co/chat" },
|
|
265
|
-
];
|
|
266
|
-
|
|
267
|
-
let _extensionProviderRegistry = null;
|
|
268
|
-
const __dirnameEsm = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
|
|
269
|
-
function loadExtensionProviderRegistry() {
|
|
270
|
-
if (_extensionProviderRegistry) return _extensionProviderRegistry;
|
|
271
|
-
try {
|
|
272
|
-
const registryPath = path.join(__dirnameEsm, "selectors", "extension-providers.json");
|
|
273
|
-
_extensionProviderRegistry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
|
|
274
|
-
return _extensionProviderRegistry;
|
|
275
|
-
} catch (err) {
|
|
276
|
-
console.error("Failed to load extension-providers.json:", err.message);
|
|
277
|
-
_extensionProviderRegistry = { providers: [] };
|
|
278
|
-
return _extensionProviderRegistry;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function isExtensionLlmTab(url = "", title = "") {
|
|
283
|
-
if (!String(url).startsWith("chrome-extension://")) return false;
|
|
284
|
-
const registry = loadExtensionProviderRegistry();
|
|
285
|
-
const lowerTitle = String(title || "").toLowerCase();
|
|
286
|
-
if (!lowerTitle) return false;
|
|
287
|
-
return registry.providers.some(p =>
|
|
288
|
-
p.titlePatterns.some(pattern => lowerTitle.includes(pattern.toLowerCase()))
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ── Sprint 1: Smart Speaker Ordering + Persona Roles ────────────
|
|
293
|
-
|
|
294
|
-
function selectNextSpeaker(session) {
|
|
295
|
-
const { speakers, current_speaker, log, ordering_strategy } = session;
|
|
296
|
-
switch (ordering_strategy || "cyclic") {
|
|
297
|
-
case "random":
|
|
298
|
-
return speakers[Math.floor(Math.random() * speakers.length)];
|
|
299
|
-
case "weighted-random": {
|
|
300
|
-
const window = log.slice(-(speakers.length * 2));
|
|
301
|
-
const counts = new Map(speakers.map(s => [s, 0]));
|
|
302
|
-
for (const entry of window) {
|
|
303
|
-
if (counts.has(entry.speaker)) counts.set(entry.speaker, counts.get(entry.speaker) + 1);
|
|
304
|
-
}
|
|
305
|
-
const maxCount = Math.max(...counts.values(), 1);
|
|
306
|
-
const weights = speakers.map(s => maxCount + 1 - counts.get(s));
|
|
307
|
-
const total = weights.reduce((a, b) => a + b, 0);
|
|
308
|
-
let r = Math.random() * total;
|
|
309
|
-
for (let i = 0; i < speakers.length; i++) {
|
|
310
|
-
r -= weights[i];
|
|
311
|
-
if (r <= 0) return speakers[i];
|
|
312
|
-
}
|
|
313
|
-
return speakers[speakers.length - 1];
|
|
314
|
-
}
|
|
315
|
-
case "cyclic":
|
|
316
|
-
default: {
|
|
317
|
-
const idx = speakers.indexOf(current_speaker);
|
|
318
|
-
return speakers[(idx + 1) % speakers.length];
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function loadRolePrompt(role) {
|
|
324
|
-
if (!role || role === "free") return "";
|
|
325
|
-
try {
|
|
326
|
-
const promptPath = path.join(__dirnameEsm, "selectors", "roles", `${role}.md`);
|
|
327
|
-
return fs.readFileSync(promptPath, "utf-8").trim();
|
|
328
|
-
} catch {
|
|
329
|
-
return "";
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const ROLE_KEYWORDS = {
|
|
334
|
-
critic: /문제|위험|실패|약점|리스크|반대|비판|결함|취약/,
|
|
335
|
-
implementer: /구현|코드|방법|설계|빌드|개발|함수|모듈|파일/,
|
|
336
|
-
mediator: /합의|정리|결론|종합|요약|중재|절충|균형/,
|
|
337
|
-
researcher: /사례|데이터|연구|벤치마크|비교|논문|참고/,
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
const ROLE_HEADING_MARKERS = {
|
|
341
|
-
critic: /^##?\s*(Critic|비판|약점|심각도|위험\s*분석|검증|평가|Review)/m,
|
|
342
|
-
implementer: /^##?\s*(코드\s*스케치|구현|Implementation|제안\s*코드)/m,
|
|
343
|
-
mediator: /^##?\s*(합의|종합|중재|Consensus|Mediation)/m,
|
|
344
|
-
researcher: /^##?\s*(조사\s*결과|비교\s*분석|Research|사례\s*연구|근거|데이터|Data)/m,
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
function inferSuggestedRole(text) {
|
|
348
|
-
const scores = {};
|
|
349
|
-
for (const [role, pattern] of Object.entries(ROLE_KEYWORDS)) {
|
|
350
|
-
const matches = (text.match(new RegExp(pattern, "g")) || []).length;
|
|
351
|
-
if (matches > 0) scores[role] = matches;
|
|
352
|
-
}
|
|
353
|
-
// Structural heading markers get extra weight (equivalent to 5 keyword matches)
|
|
354
|
-
for (const [role, pattern] of Object.entries(ROLE_HEADING_MARKERS)) {
|
|
355
|
-
if (pattern.test(text)) {
|
|
356
|
-
scores[role] = (scores[role] || 0) + 8;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
if (Object.keys(scores).length === 0) return "free";
|
|
360
|
-
return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0];
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function parseVotes(text) {
|
|
364
|
-
const votes = [];
|
|
365
|
-
for (const line of text.split("\n")) {
|
|
366
|
-
const agree = line.match(/\[AGREE\]/i);
|
|
367
|
-
const disagree = line.match(/\[DISAGREE\]/i);
|
|
368
|
-
const conditional = line.match(/\[CONDITIONAL:\s*(.+?)\]/i);
|
|
369
|
-
if (agree) votes.push({ line: line.trim(), vote: "agree" });
|
|
370
|
-
else if (disagree) votes.push({ line: line.trim(), vote: "disagree" });
|
|
371
|
-
else if (conditional) votes.push({ line: line.trim(), vote: "conditional", condition: conditional[1].trim() });
|
|
372
|
-
}
|
|
373
|
-
return votes;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
let _rolePresetsCache = null;
|
|
377
|
-
function loadRolePresets() {
|
|
378
|
-
if (_rolePresetsCache) return _rolePresetsCache;
|
|
379
|
-
try {
|
|
380
|
-
const presetsPath = path.join(__dirnameEsm, "selectors", "role-presets.json");
|
|
381
|
-
_rolePresetsCache = JSON.parse(fs.readFileSync(presetsPath, "utf-8"));
|
|
382
|
-
return _rolePresetsCache;
|
|
383
|
-
} catch {
|
|
384
|
-
_rolePresetsCache = { presets: {} };
|
|
385
|
-
return _rolePresetsCache;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function applyRolePreset(preset, speakers) {
|
|
390
|
-
const presets = loadRolePresets();
|
|
391
|
-
const presetDef = presets.presets[preset];
|
|
392
|
-
if (!presetDef) return {};
|
|
393
|
-
|
|
394
|
-
const roles = presetDef.roles;
|
|
395
|
-
const result = {};
|
|
396
|
-
for (let i = 0; i < speakers.length; i++) {
|
|
397
|
-
result[speakers[i]] = roles[i % roles.length];
|
|
398
|
-
}
|
|
399
|
-
return result;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ── Graceful Degradation Matrix ──────────────────────────────────
|
|
403
|
-
|
|
404
|
-
const DEGRADATION_TIERS = {
|
|
405
|
-
monitoring: {
|
|
406
|
-
tier1: { name: "tmux", description: "tmux real-time monitoring window", check: () => commandExistsInPath("tmux") },
|
|
407
|
-
tier2: { name: "logfile", description: "Log file tail monitoring", check: () => true },
|
|
408
|
-
tier3: { name: "silent", description: "No monitoring (log only)", check: () => true },
|
|
409
|
-
},
|
|
410
|
-
browser: {
|
|
411
|
-
tier1: { name: "cdp_auto", description: "CDP auto send/collect", check: async () => { try { const res = await fetch("http://127.0.0.1:9222/json/version", { signal: AbortSignal.timeout(2000) }); return res.ok; } catch { return false; } } },
|
|
412
|
-
tier2: { name: "clipboard", description: "Clipboard-based manual transfer", check: () => true },
|
|
413
|
-
tier3: { name: "manual", description: "Fully manual copy/paste", check: () => true },
|
|
414
|
-
},
|
|
415
|
-
terminal: {
|
|
416
|
-
tier1: { name: "auto_open", description: "Auto-open terminal app", check: () => process.platform === "darwin" || process.platform === "linux" || process.platform === "win32" },
|
|
417
|
-
tier2: { name: "none", description: "Cannot auto-open terminal", check: () => true },
|
|
418
|
-
tier3: { name: "none", description: "Cannot auto-open terminal", check: () => true },
|
|
419
|
-
},
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
async function detectDegradationLevels() {
|
|
423
|
-
const levels = {};
|
|
424
|
-
for (const [feature, tiers] of Object.entries(DEGRADATION_TIERS)) {
|
|
425
|
-
for (const tierKey of ["tier1", "tier2", "tier3"]) {
|
|
426
|
-
const tier = tiers[tierKey];
|
|
427
|
-
const available = await Promise.resolve(tier.check());
|
|
428
|
-
if (available) {
|
|
429
|
-
levels[feature] = { tier: tierKey, name: tier.name, description: tier.description };
|
|
430
|
-
break;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return levels;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function formatDegradationReport(levels) {
|
|
438
|
-
const lines = [];
|
|
439
|
-
for (const [feature, info] of Object.entries(levels)) {
|
|
440
|
-
const tierNum = parseInt(info.tier.replace("tier", ""));
|
|
441
|
-
const indicator = tierNum === 1 ? "🟢" : tierNum === 2 ? "🟡" : "🔴";
|
|
442
|
-
lines.push(` ${indicator} **${feature}**: ${info.name} — ${info.description}`);
|
|
443
|
-
}
|
|
444
|
-
return lines.join("\n");
|
|
445
|
-
}
|
|
446
|
-
|
|
447
319
|
const PRODUCT_DISCLAIMER = "ℹ️ This tool does not permanently modify external websites. It reads browser context in read-only mode to route speakers.";
|
|
448
320
|
const LOCKS_SUBDIR = ".locks";
|
|
449
321
|
const LOCK_RETRY_MS = 25;
|
|
450
322
|
const LOCK_TIMEOUT_MS = 8000;
|
|
451
323
|
const LOCK_STALE_MS = 60000;
|
|
452
|
-
const SPEAKER_SELECTION_FILE = "speaker-selection.json";
|
|
453
|
-
const SPEAKER_SELECTION_TTL_MS = 10 * 60 * 1000;
|
|
454
|
-
|
|
455
324
|
function getProjectSlug() {
|
|
456
325
|
return path.basename(process.cwd());
|
|
457
326
|
}
|
|
@@ -496,624 +365,8 @@ function listStateProjects() {
|
|
|
496
365
|
}
|
|
497
366
|
}
|
|
498
367
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (typeof sessionRef === "object" && sessionRef !== null && sessionRef.id) {
|
|
503
|
-
const project = getSessionProject(sessionRef, preferProject);
|
|
504
|
-
const file = getSessionFile(sessionRef.id, project);
|
|
505
|
-
const state = readJsonFileSafe(file);
|
|
506
|
-
if (!state) return null;
|
|
507
|
-
const normalized = normalizeSessionActors(state);
|
|
508
|
-
if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
|
|
509
|
-
return null;
|
|
510
|
-
}
|
|
511
|
-
return { file, project, state: normalized };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const sessionId = String(sessionRef);
|
|
515
|
-
const preferred = normalizeProjectSlug(preferProject);
|
|
516
|
-
const projects = [...new Set([preferred, ...listStateProjects()])];
|
|
517
|
-
for (const project of projects) {
|
|
518
|
-
const file = getSessionFile(sessionId, project);
|
|
519
|
-
const state = readJsonFileSafe(file);
|
|
520
|
-
if (!state) continue;
|
|
521
|
-
const normalized = normalizeSessionActors(state);
|
|
522
|
-
if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
return { file, project: normalized.project || project, state: normalized };
|
|
526
|
-
}
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const teleptyBusState = {
|
|
531
|
-
ws: null,
|
|
532
|
-
status: "idle",
|
|
533
|
-
connectPromise: null,
|
|
534
|
-
reconnectTimer: null,
|
|
535
|
-
lastError: null,
|
|
536
|
-
lastConnectedAt: null,
|
|
537
|
-
lastMessageAt: null,
|
|
538
|
-
healthBySession: new Map(),
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
const pendingTeleptyTurnRequests = new Map();
|
|
542
|
-
|
|
543
|
-
function hashPromptText(value) {
|
|
544
|
-
return createHash("sha1").update(String(value || "")).digest("hex");
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function sortJsonValue(value) {
|
|
548
|
-
if (Array.isArray(value)) {
|
|
549
|
-
return value.map(sortJsonValue);
|
|
550
|
-
}
|
|
551
|
-
if (value && typeof value === "object") {
|
|
552
|
-
return Object.keys(value)
|
|
553
|
-
.sort()
|
|
554
|
-
.reduce((acc, key) => {
|
|
555
|
-
acc[key] = sortJsonValue(value[key]);
|
|
556
|
-
return acc;
|
|
557
|
-
}, {});
|
|
558
|
-
}
|
|
559
|
-
return value;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function hashStructuredSynthesis(structured) {
|
|
563
|
-
return hashPromptText(JSON.stringify(sortJsonValue(structured || null)));
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Build a deterministic execution contract from structured synthesis.
|
|
568
|
-
*
|
|
569
|
-
* Data model canonical roles:
|
|
570
|
-
* - structured_synthesis: human + reasoning canonical — rich context for
|
|
571
|
-
* human review (decisions rationale, experiment outcomes, full task descriptions).
|
|
572
|
-
* - execution_contract: automation canonical — minimal, deterministic task list
|
|
573
|
-
* derived from structured_synthesis via SHA-1 hash for provenance tracking.
|
|
574
|
-
* Consumers (inbox-watcher, devkit, registry, orchestrator) MUST prefer
|
|
575
|
-
* execution_contract when available; fall back to structured_synthesis only
|
|
576
|
-
* when execution_contract is absent.
|
|
577
|
-
*/
|
|
578
|
-
function buildExecutionContract({ state, structured }) {
|
|
579
|
-
if (!structured) return null;
|
|
580
|
-
return {
|
|
581
|
-
schema_version: 2,
|
|
582
|
-
source_session_id: state.id,
|
|
583
|
-
deliberation_id: state.id,
|
|
584
|
-
summary: structured.summary || "",
|
|
585
|
-
decisions: structured.decisions || [],
|
|
586
|
-
tasks: structured.actionable_tasks || [],
|
|
587
|
-
experiment_outcome: structured.experiment_outcome || null,
|
|
588
|
-
unresolved_questions: [],
|
|
589
|
-
artifact_refs: [],
|
|
590
|
-
generated_from: {
|
|
591
|
-
structured_synthesis_hash: hashStructuredSynthesis(structured),
|
|
592
|
-
},
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
function createEnvelopeId(prefix = "env") {
|
|
597
|
-
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function validateTeleptyEnvelope(envelope) {
|
|
601
|
-
const parsed = TeleptyEnvelopeSchema.parse(envelope);
|
|
602
|
-
const payloadSchema = TELEPTY_ENVELOPE_PAYLOAD_SCHEMAS[parsed.kind];
|
|
603
|
-
if (payloadSchema) {
|
|
604
|
-
payloadSchema.parse(parsed.payload);
|
|
605
|
-
}
|
|
606
|
-
return parsed;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function resolveTeleptySourceHost() {
|
|
610
|
-
const explicit = process.env.TELEPTY_SOURCE_HOST;
|
|
611
|
-
if (typeof explicit === "string" && explicit.trim()) {
|
|
612
|
-
return explicit.trim();
|
|
613
|
-
}
|
|
614
|
-
const hostname = os.hostname();
|
|
615
|
-
return typeof hostname === "string" && hostname.trim() ? hostname.trim() : undefined;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function buildTeleptyEnvelope({ session_id, project, kind, source, source_host = resolveTeleptySourceHost(), target, reply_to = null, trace = [], payload, ts = new Date().toISOString(), message_id = createEnvelopeId(kind) }) {
|
|
619
|
-
return validateTeleptyEnvelope({
|
|
620
|
-
version: 1,
|
|
621
|
-
message_id,
|
|
622
|
-
session_id,
|
|
623
|
-
project,
|
|
624
|
-
kind,
|
|
625
|
-
source,
|
|
626
|
-
source_host,
|
|
627
|
-
target,
|
|
628
|
-
reply_to,
|
|
629
|
-
trace,
|
|
630
|
-
payload,
|
|
631
|
-
ts,
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function buildTeleptyTurnRequestEnvelope({ state, speaker, turnId, turnPrompt, includeHistoryEntries = 0, profile }) {
|
|
636
|
-
const role = (state.speaker_roles || {})[speaker] || null;
|
|
637
|
-
const target = profile?.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
|
|
638
|
-
? `${profile.telepty_session_id}@${profile.telepty_host}`
|
|
639
|
-
: profile?.telepty_session_id || speaker;
|
|
640
|
-
return buildTeleptyEnvelope({
|
|
641
|
-
session_id: state.id,
|
|
642
|
-
project: state.project || getProjectSlug(),
|
|
643
|
-
kind: "turn_request",
|
|
644
|
-
source: `deliberation:${state.id}`,
|
|
645
|
-
target,
|
|
646
|
-
reply_to: state.id,
|
|
647
|
-
trace: [
|
|
648
|
-
`project:${state.project || getProjectSlug()}`,
|
|
649
|
-
`speaker:${speaker}`,
|
|
650
|
-
`turn:${turnId}`,
|
|
651
|
-
],
|
|
652
|
-
payload: {
|
|
653
|
-
turn_id: turnId,
|
|
654
|
-
round: state.current_round,
|
|
655
|
-
max_rounds: state.max_rounds,
|
|
656
|
-
speaker,
|
|
657
|
-
role,
|
|
658
|
-
prompt: turnPrompt,
|
|
659
|
-
content: turnPrompt,
|
|
660
|
-
prompt_sha1: hashPromptText(turnPrompt),
|
|
661
|
-
history_entries: includeHistoryEntries,
|
|
662
|
-
transport_timeout_ms: TELEPTY_TRANSPORT_TIMEOUT_MS,
|
|
663
|
-
semantic_timeout_ms: TELEPTY_SEMANTIC_TIMEOUT_MS,
|
|
664
|
-
},
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function buildTeleptyTurnCompletedEnvelope({ state, entry }) {
|
|
669
|
-
return buildTeleptyEnvelope({
|
|
670
|
-
session_id: state.id,
|
|
671
|
-
project: state.project || getProjectSlug(),
|
|
672
|
-
kind: "turn_completed",
|
|
673
|
-
source: `deliberation:${state.id}`,
|
|
674
|
-
target: "telepty-bus",
|
|
675
|
-
reply_to: state.orchestrator_session_id || state.id,
|
|
676
|
-
trace: [
|
|
677
|
-
`project:${state.project || getProjectSlug()}`,
|
|
678
|
-
`speaker:${entry.speaker}`,
|
|
679
|
-
`turn:${entry.turn_id || "none"}`,
|
|
680
|
-
],
|
|
681
|
-
payload: {
|
|
682
|
-
turn_id: entry.turn_id || null,
|
|
683
|
-
speaker: entry.speaker,
|
|
684
|
-
round: entry.round,
|
|
685
|
-
max_rounds: state.max_rounds,
|
|
686
|
-
next_speaker: state.current_speaker || "none",
|
|
687
|
-
next_round: state.current_round,
|
|
688
|
-
status: state.status,
|
|
689
|
-
total_responses: Array.isArray(state.log) ? state.log.length : 0,
|
|
690
|
-
channel_used: entry.channel_used || null,
|
|
691
|
-
fallback_reason: entry.fallback_reason || null,
|
|
692
|
-
orchestrator_session_id: state.orchestrator_session_id || null,
|
|
693
|
-
},
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function buildTeleptySynthesisEnvelope({ state, synthesis, structured, executionContract }) {
|
|
698
|
-
const derivedExecutionContract =
|
|
699
|
-
executionContract !== undefined
|
|
700
|
-
? executionContract
|
|
701
|
-
: (structured ? buildExecutionContract({ state, structured }) : (state.execution_contract || null));
|
|
702
|
-
return buildTeleptyEnvelope({
|
|
703
|
-
session_id: state.id,
|
|
704
|
-
project: state.project || getProjectSlug(),
|
|
705
|
-
kind: "deliberation_completed",
|
|
706
|
-
source: `deliberation:${state.id}`,
|
|
707
|
-
target: "telepty-bus",
|
|
708
|
-
reply_to: state.id,
|
|
709
|
-
trace: [
|
|
710
|
-
`project:${state.project || getProjectSlug()}`,
|
|
711
|
-
"stage:synthesis",
|
|
712
|
-
],
|
|
713
|
-
payload: {
|
|
714
|
-
topic: state.topic,
|
|
715
|
-
synthesis,
|
|
716
|
-
structured_synthesis: structured || null,
|
|
717
|
-
execution_contract: derivedExecutionContract || null,
|
|
718
|
-
},
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function resolveTeleptyBusUrl(host = TELEPTY_DEFAULT_HOST) {
|
|
723
|
-
const url = new URL(`ws://${host}:${TELEPTY_PORT}/api/bus`);
|
|
724
|
-
const token = loadTeleptyAuthToken();
|
|
725
|
-
if (token) {
|
|
726
|
-
url.searchParams.set("token", token);
|
|
727
|
-
}
|
|
728
|
-
return url.toString();
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
function cleanupPendingTeleptyTurn(messageId) {
|
|
732
|
-
const entry = pendingTeleptyTurnRequests.get(messageId);
|
|
733
|
-
if (!entry) return;
|
|
734
|
-
if (entry.transportTimer) clearTimeout(entry.transportTimer);
|
|
735
|
-
if (entry.semanticTimer) clearTimeout(entry.semanticTimer);
|
|
736
|
-
pendingTeleptyTurnRequests.delete(messageId);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
function registerPendingTeleptyTurnRequest({ envelope, profile, speaker }) {
|
|
740
|
-
const nowMs = Date.now();
|
|
741
|
-
const entry = {
|
|
742
|
-
message_id: envelope.message_id,
|
|
743
|
-
deliberation_session_id: envelope.session_id,
|
|
744
|
-
project: envelope.project,
|
|
745
|
-
speaker,
|
|
746
|
-
turn_id: envelope.payload.turn_id,
|
|
747
|
-
target_session_id: profile?.telepty_session_id || speaker,
|
|
748
|
-
target_host: profile?.telepty_host || TELEPTY_DEFAULT_HOST,
|
|
749
|
-
prompt_sha1: envelope.payload.prompt_sha1,
|
|
750
|
-
published_at: envelope.ts,
|
|
751
|
-
transport_status: "pending",
|
|
752
|
-
semantic_status: "pending",
|
|
753
|
-
transport_deadline_at: new Date(nowMs + TELEPTY_TRANSPORT_TIMEOUT_MS).toISOString(),
|
|
754
|
-
semantic_deadline_at: new Date(nowMs + TELEPTY_SEMANTIC_TIMEOUT_MS).toISOString(),
|
|
755
|
-
};
|
|
756
|
-
entry.transportPromise = new Promise(resolve => {
|
|
757
|
-
entry.resolveTransport = resolve;
|
|
758
|
-
});
|
|
759
|
-
entry.semanticPromise = new Promise(resolve => {
|
|
760
|
-
entry.resolveSemantic = resolve;
|
|
761
|
-
});
|
|
762
|
-
entry.transportTimer = setTimeout(() => {
|
|
763
|
-
if (entry.transport_status !== "pending") return;
|
|
764
|
-
entry.transport_status = "timeout";
|
|
765
|
-
appendRuntimeLog("WARN", `TELEPTY_TRANSPORT_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
|
|
766
|
-
entry.resolveTransport?.({ ok: false, code: "transport_timeout" });
|
|
767
|
-
}, TELEPTY_TRANSPORT_TIMEOUT_MS);
|
|
768
|
-
entry.semanticTimer = setTimeout(() => {
|
|
769
|
-
if (entry.semantic_status !== "pending") return;
|
|
770
|
-
entry.semantic_status = "timeout";
|
|
771
|
-
appendRuntimeLog("WARN", `TELEPTY_SEMANTIC_TIMEOUT: ${entry.deliberation_session_id} | speaker: ${entry.speaker} | target: ${entry.target_session_id}`);
|
|
772
|
-
entry.resolveSemantic?.({ ok: false, code: "semantic_timeout" });
|
|
773
|
-
setTimeout(() => cleanupPendingTeleptyTurn(entry.message_id), 5_000);
|
|
774
|
-
}, TELEPTY_SEMANTIC_TIMEOUT_MS);
|
|
775
|
-
pendingTeleptyTurnRequests.set(entry.message_id, entry);
|
|
776
|
-
return entry;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
function ackPendingTeleptyTurn(event) {
|
|
780
|
-
const promptHash = hashPromptText(event?.content || "");
|
|
781
|
-
const targetSessionId = String(event?.target_agent || "");
|
|
782
|
-
const candidate = [...pendingTeleptyTurnRequests.values()]
|
|
783
|
-
.filter(entry =>
|
|
784
|
-
entry.transport_status === "pending"
|
|
785
|
-
&& entry.target_session_id === targetSessionId
|
|
786
|
-
&& entry.prompt_sha1 === promptHash
|
|
787
|
-
)
|
|
788
|
-
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
|
|
789
|
-
if (!candidate) return null;
|
|
790
|
-
|
|
791
|
-
candidate.transport_status = "ack";
|
|
792
|
-
candidate.inject_id = event.inject_id || null;
|
|
793
|
-
candidate.transport_acked_at = new Date().toISOString();
|
|
794
|
-
if (candidate.transportTimer) clearTimeout(candidate.transportTimer);
|
|
795
|
-
candidate.resolveTransport?.({
|
|
796
|
-
ok: true,
|
|
797
|
-
code: "inject_written",
|
|
798
|
-
inject_id: event.inject_id || null,
|
|
799
|
-
});
|
|
800
|
-
appendRuntimeLog("INFO", `TELEPTY_TRANSPORT_ACK: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id} | inject_id: ${event.inject_id || "n/a"}`);
|
|
801
|
-
return candidate;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
function completePendingTeleptySemantic({ sessionId, speaker, turnId }) {
|
|
805
|
-
const candidate = [...pendingTeleptyTurnRequests.values()]
|
|
806
|
-
.filter(entry =>
|
|
807
|
-
entry.semantic_status === "pending"
|
|
808
|
-
&& entry.deliberation_session_id === sessionId
|
|
809
|
-
&& normalizeSpeaker(entry.speaker) === normalizeSpeaker(speaker)
|
|
810
|
-
&& (!turnId || !entry.turn_id || entry.turn_id === turnId)
|
|
811
|
-
)
|
|
812
|
-
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
|
|
813
|
-
if (!candidate) return null;
|
|
814
|
-
|
|
815
|
-
candidate.semantic_status = "completed";
|
|
816
|
-
candidate.semantic_completed_at = new Date().toISOString();
|
|
817
|
-
if (candidate.semanticTimer) clearTimeout(candidate.semanticTimer);
|
|
818
|
-
candidate.resolveSemantic?.({ ok: true, code: "responded" });
|
|
819
|
-
appendRuntimeLog("INFO", `TELEPTY_SEMANTIC_COMPLETE: ${candidate.deliberation_session_id} | speaker: ${candidate.speaker} | target: ${candidate.target_session_id}`);
|
|
820
|
-
setTimeout(() => cleanupPendingTeleptyTurn(candidate.message_id), 5_000);
|
|
821
|
-
return candidate;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function updateTeleptySessionHealth(event) {
|
|
825
|
-
const sessionId = event?.session_id;
|
|
826
|
-
if (!sessionId) return null;
|
|
827
|
-
const health = {
|
|
828
|
-
session_id: sessionId,
|
|
829
|
-
payload: event.payload || {},
|
|
830
|
-
timestamp: event.timestamp || new Date().toISOString(),
|
|
831
|
-
seen_at: new Date().toISOString(),
|
|
832
|
-
};
|
|
833
|
-
teleptyBusState.healthBySession.set(sessionId, health);
|
|
834
|
-
return health;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function getTeleptySessionHealth(sessionId, nowMs = Date.now()) {
|
|
838
|
-
const entry = teleptyBusState.healthBySession.get(sessionId);
|
|
839
|
-
if (!entry) return null;
|
|
840
|
-
const seenAtMs = Date.parse(entry.seen_at || entry.timestamp || "");
|
|
841
|
-
const ageMs = Number.isFinite(seenAtMs) ? nowMs - seenAtMs : null;
|
|
842
|
-
return {
|
|
843
|
-
...entry,
|
|
844
|
-
age_ms: ageMs,
|
|
845
|
-
stale: Number.isFinite(ageMs) ? ageMs > TELEPTY_SESSION_HEALTH_STALE_MS : true,
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function handleTeleptyBusMessage(raw) {
|
|
850
|
-
let parsed = null;
|
|
851
|
-
try {
|
|
852
|
-
parsed = JSON.parse(String(raw));
|
|
853
|
-
} catch {
|
|
854
|
-
return null;
|
|
855
|
-
}
|
|
856
|
-
teleptyBusState.lastMessageAt = new Date().toISOString();
|
|
857
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
858
|
-
|
|
859
|
-
if (parsed.type === "inject_written") {
|
|
860
|
-
return ackPendingTeleptyTurn(parsed);
|
|
861
|
-
}
|
|
862
|
-
if (parsed.type === "session_health") {
|
|
863
|
-
return updateTeleptySessionHealth(parsed);
|
|
864
|
-
}
|
|
865
|
-
return parsed;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
async function ensureTeleptyBusSubscriber() {
|
|
869
|
-
if (teleptyBusState.ws && teleptyBusState.ws.readyState === WebSocket.OPEN) {
|
|
870
|
-
return { ok: true, status: "open" };
|
|
871
|
-
}
|
|
872
|
-
if (teleptyBusState.connectPromise) {
|
|
873
|
-
return teleptyBusState.connectPromise;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
teleptyBusState.connectPromise = new Promise((resolve) => {
|
|
877
|
-
try {
|
|
878
|
-
let settled = false;
|
|
879
|
-
const finish = (result) => {
|
|
880
|
-
if (settled) return;
|
|
881
|
-
settled = true;
|
|
882
|
-
resolve(result);
|
|
883
|
-
};
|
|
884
|
-
teleptyBusState.status = "connecting";
|
|
885
|
-
const ws = new WebSocket(resolveTeleptyBusUrl());
|
|
886
|
-
teleptyBusState.ws = ws;
|
|
887
|
-
|
|
888
|
-
ws.once("open", () => {
|
|
889
|
-
teleptyBusState.status = "open";
|
|
890
|
-
teleptyBusState.lastConnectedAt = new Date().toISOString();
|
|
891
|
-
teleptyBusState.lastError = null;
|
|
892
|
-
appendRuntimeLog("INFO", "TELEPTY_BUS_CONNECTED");
|
|
893
|
-
finish({ ok: true, status: "open" });
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
ws.on("message", (data) => {
|
|
897
|
-
handleTeleptyBusMessage(data.toString());
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
ws.on("error", (err) => {
|
|
901
|
-
teleptyBusState.lastError = String(err?.message || err);
|
|
902
|
-
appendRuntimeLog("WARN", `TELEPTY_BUS_ERROR: ${teleptyBusState.lastError}`);
|
|
903
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
904
|
-
teleptyBusState.status = "error";
|
|
905
|
-
teleptyBusState.ws = null;
|
|
906
|
-
teleptyBusState.connectPromise = null;
|
|
907
|
-
finish({ ok: false, status: "error", error: teleptyBusState.lastError });
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
ws.on("close", () => {
|
|
912
|
-
teleptyBusState.status = "closed";
|
|
913
|
-
teleptyBusState.ws = null;
|
|
914
|
-
teleptyBusState.connectPromise = null;
|
|
915
|
-
if (!settled) {
|
|
916
|
-
finish({ ok: false, status: "closed", error: teleptyBusState.lastError || "socket closed" });
|
|
917
|
-
}
|
|
918
|
-
if (!teleptyBusState.reconnectTimer) {
|
|
919
|
-
teleptyBusState.reconnectTimer = setTimeout(() => {
|
|
920
|
-
teleptyBusState.reconnectTimer = null;
|
|
921
|
-
ensureTeleptyBusSubscriber().catch(() => {});
|
|
922
|
-
}, TELEPTY_BUS_RECONNECT_MS);
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
|
-
} catch (err) {
|
|
926
|
-
teleptyBusState.status = "error";
|
|
927
|
-
teleptyBusState.lastError = String(err?.message || err);
|
|
928
|
-
teleptyBusState.connectPromise = null;
|
|
929
|
-
resolve({ ok: false, status: "error", error: teleptyBusState.lastError });
|
|
930
|
-
}
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
const result = await teleptyBusState.connectPromise;
|
|
934
|
-
if (!result.ok) {
|
|
935
|
-
teleptyBusState.connectPromise = null;
|
|
936
|
-
} else if (teleptyBusState.ws?.readyState === WebSocket.OPEN) {
|
|
937
|
-
teleptyBusState.connectPromise = null;
|
|
938
|
-
}
|
|
939
|
-
return result;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
async function callBrainIngest(executionContract) {
|
|
943
|
-
if (!executionContract) return { ok: false, reason: "no_contract" };
|
|
944
|
-
try {
|
|
945
|
-
const inboxDir = path.join(os.homedir(), ".aigentry", "inbox");
|
|
946
|
-
if (!fs.existsSync(inboxDir)) {
|
|
947
|
-
fs.mkdirSync(inboxDir, { recursive: true });
|
|
948
|
-
}
|
|
949
|
-
const fileName = `handoff-${executionContract.deliberation_id}.json`;
|
|
950
|
-
const filePath = path.join(inboxDir, fileName);
|
|
951
|
-
fs.writeFileSync(filePath, JSON.stringify(executionContract, null, 2), "utf8");
|
|
952
|
-
appendRuntimeLog("INFO", `BRAIN_INGEST: wrote handoff file ${filePath}`);
|
|
953
|
-
return { ok: true, path: filePath };
|
|
954
|
-
} catch (err) {
|
|
955
|
-
appendRuntimeLog("WARN", `BRAIN_INGEST: failed to write handoff file: ${err.message}`);
|
|
956
|
-
return { ok: false, error: err.message };
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
async function notifyTeleptyBus(event) {
|
|
961
|
-
const host = process.env.TELEPTY_HOST || "localhost";
|
|
962
|
-
const port = process.env.TELEPTY_PORT || "3848";
|
|
963
|
-
const token = loadTeleptyAuthToken();
|
|
964
|
-
try {
|
|
965
|
-
const res = await fetch(`http://${host}:${port}/api/bus/publish`, {
|
|
966
|
-
method: "POST",
|
|
967
|
-
headers: {
|
|
968
|
-
"Content-Type": "application/json",
|
|
969
|
-
...(token ? { "x-telepty-token": token } : {}),
|
|
970
|
-
},
|
|
971
|
-
body: JSON.stringify(event),
|
|
972
|
-
});
|
|
973
|
-
const data = await res.json().catch(() => null);
|
|
974
|
-
if (res.ok) {
|
|
975
|
-
appendRuntimeLog("INFO", `HANDOFF: Telepty bus notified: ${event.kind || event.type || "unknown"}`);
|
|
976
|
-
return { ok: true, delivered: data?.delivered ?? null };
|
|
977
|
-
}
|
|
978
|
-
return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
|
|
979
|
-
} catch (err) {
|
|
980
|
-
appendRuntimeLog("WARN", `HANDOFF: Telepty bus notification failed: ${err.message}`);
|
|
981
|
-
return { ok: false, error: err.message };
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function getDefaultOrchestratorSessionId() {
|
|
986
|
-
// Check multiple env vars that may indicate an orchestrator context
|
|
987
|
-
const candidates = [
|
|
988
|
-
process.env.TELEPTY_SESSION_ID,
|
|
989
|
-
process.env.DELIBERATION_ORCHESTRATOR_ID,
|
|
990
|
-
process.env.ORCHESTRATOR_SESSION_ID,
|
|
991
|
-
];
|
|
992
|
-
for (const value of candidates) {
|
|
993
|
-
if (typeof value === "string" && value.trim()) return value.trim();
|
|
994
|
-
}
|
|
995
|
-
return null;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
function buildTurnCompletionNotificationText(state, entry) {
|
|
999
|
-
const nextSpeaker = state.current_speaker || "none";
|
|
1000
|
-
const turnId = entry.turn_id || "(none)";
|
|
1001
|
-
if (state.status === "awaiting_synthesis") {
|
|
1002
|
-
return [
|
|
1003
|
-
`[deliberation turn complete]`,
|
|
1004
|
-
`session_id: ${state.id}`,
|
|
1005
|
-
`speaker: ${entry.speaker}`,
|
|
1006
|
-
`turn_id: ${turnId}`,
|
|
1007
|
-
`round: ${entry.round}/${state.max_rounds}`,
|
|
1008
|
-
`status: awaiting_synthesis`,
|
|
1009
|
-
`responses: ${state.log.length}`,
|
|
1010
|
-
`all rounds complete; run deliberation_synthesize(session_id: "${state.id}")`,
|
|
1011
|
-
`no further reply needed.`,
|
|
1012
|
-
].join("\n");
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
return [
|
|
1016
|
-
`[deliberation turn complete]`,
|
|
1017
|
-
`session_id: ${state.id}`,
|
|
1018
|
-
`speaker: ${entry.speaker}`,
|
|
1019
|
-
`turn_id: ${turnId}`,
|
|
1020
|
-
`round: ${entry.round}/${state.max_rounds}`,
|
|
1021
|
-
`status: ${state.status}`,
|
|
1022
|
-
`next_speaker: ${nextSpeaker}`,
|
|
1023
|
-
`next_round: ${state.current_round}/${state.max_rounds}`,
|
|
1024
|
-
`responses: ${state.log.length}`,
|
|
1025
|
-
`informational notification only.`,
|
|
1026
|
-
`no further reply needed.`,
|
|
1027
|
-
].join("\n");
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
async function notifyTeleptySessionInject({ targetSessionId, prompt, fromSessionId, replyToSessionId = null, host = TELEPTY_DEFAULT_HOST }) {
|
|
1031
|
-
if (!targetSessionId || !prompt) return { ok: false, error: "missing target or prompt" };
|
|
1032
|
-
const token = loadTeleptyAuthToken();
|
|
1033
|
-
if (!token) return { ok: false, error: "telepty auth token unavailable" };
|
|
1034
|
-
|
|
1035
|
-
try {
|
|
1036
|
-
const response = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions/${encodeURIComponent(targetSessionId)}/inject`, {
|
|
1037
|
-
method: "POST",
|
|
1038
|
-
headers: {
|
|
1039
|
-
"Content-Type": "application/json",
|
|
1040
|
-
"x-telepty-token": token,
|
|
1041
|
-
},
|
|
1042
|
-
body: JSON.stringify({
|
|
1043
|
-
prompt,
|
|
1044
|
-
from: fromSessionId || null,
|
|
1045
|
-
reply_to: replyToSessionId || null,
|
|
1046
|
-
deliberation_session_id: null,
|
|
1047
|
-
thread_id: null,
|
|
1048
|
-
}),
|
|
1049
|
-
});
|
|
1050
|
-
const data = await response.json().catch(() => null);
|
|
1051
|
-
if (!response.ok) {
|
|
1052
|
-
return { ok: false, error: data?.error || `HTTP ${response.status}` };
|
|
1053
|
-
}
|
|
1054
|
-
return { ok: true, inject_id: data?.inject_id || null };
|
|
1055
|
-
} catch (err) {
|
|
1056
|
-
return { ok: false, error: String(err?.message || err) };
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
async function dispatchTeleptyTurnRequest({ state, speaker, prompt = null, includeHistoryEntries = 4, awaitSemantic = false }) {
|
|
1061
|
-
const { profile } = resolveTransportForSpeaker(state, speaker);
|
|
1062
|
-
const turnId = state.pending_turn_id || generateTurnId();
|
|
1063
|
-
const turnPrompt = buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries);
|
|
1064
|
-
const busReady = await ensureTeleptyBusSubscriber();
|
|
1065
|
-
const envelope = buildTeleptyTurnRequestEnvelope({
|
|
1066
|
-
state,
|
|
1067
|
-
speaker,
|
|
1068
|
-
turnId,
|
|
1069
|
-
turnPrompt,
|
|
1070
|
-
includeHistoryEntries,
|
|
1071
|
-
profile,
|
|
1072
|
-
});
|
|
1073
|
-
const pending = registerPendingTeleptyTurnRequest({ envelope, profile, speaker });
|
|
1074
|
-
const publishResult = await notifyTeleptyBus(envelope);
|
|
1075
|
-
const health = profile?.telepty_session_id ? getTeleptySessionHealth(profile.telepty_session_id) : null;
|
|
1076
|
-
|
|
1077
|
-
if (!publishResult.ok) {
|
|
1078
|
-
cleanupPendingTeleptyTurn(envelope.message_id);
|
|
1079
|
-
return {
|
|
1080
|
-
ok: false,
|
|
1081
|
-
stage: "publish",
|
|
1082
|
-
envelope,
|
|
1083
|
-
turnPrompt,
|
|
1084
|
-
publishResult,
|
|
1085
|
-
busReady,
|
|
1086
|
-
health,
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
const transportResult = await pending.transportPromise;
|
|
1091
|
-
let semanticResult = null;
|
|
1092
|
-
if (awaitSemantic && transportResult.ok) {
|
|
1093
|
-
semanticResult = await pending.semanticPromise;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
return {
|
|
1097
|
-
ok: !awaitSemantic ? transportResult.ok : Boolean(semanticResult?.ok),
|
|
1098
|
-
stage: awaitSemantic ? (semanticResult?.ok ? "semantic" : (semanticResult?.code || "semantic_timeout")) : (transportResult.ok ? "transport" : (transportResult?.code || "transport_timeout")),
|
|
1099
|
-
envelope,
|
|
1100
|
-
turnPrompt,
|
|
1101
|
-
publishResult,
|
|
1102
|
-
transportResult,
|
|
1103
|
-
semanticResult,
|
|
1104
|
-
busReady,
|
|
1105
|
-
health,
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function getArchiveDir(projectSlug = getProjectSlug()) {
|
|
1110
|
-
const slug = normalizeProjectSlug(projectSlug);
|
|
1111
|
-
const obsidianDir = path.join(OBSIDIAN_PROJECTS, slug, "deliberations");
|
|
1112
|
-
if (fs.existsSync(path.join(OBSIDIAN_PROJECTS, slug))) {
|
|
1113
|
-
return obsidianDir;
|
|
1114
|
-
}
|
|
1115
|
-
return path.join(getProjectStateDir(slug), "archive");
|
|
1116
|
-
}
|
|
368
|
+
// findSessionRecord — moved to lib/session.js
|
|
369
|
+
// getArchiveDir — moved to lib/session.js
|
|
1117
370
|
|
|
1118
371
|
function getLocksDir(projectSlug = getProjectSlug()) {
|
|
1119
372
|
return path.join(getProjectStateDir(projectSlug), LOCKS_SUBDIR);
|
|
@@ -1154,6 +407,15 @@ function appendRuntimeLog(level, message) {
|
|
|
1154
407
|
|
|
1155
408
|
function safeToolHandler(toolName, handler) {
|
|
1156
409
|
return async (args) => {
|
|
410
|
+
const entitlement = checkToolEntitlement(toolName);
|
|
411
|
+
if (!entitlement.allowed) {
|
|
412
|
+
return {
|
|
413
|
+
content: [{
|
|
414
|
+
type: "text",
|
|
415
|
+
text: `⛔ ${entitlement.reason}. Current tier: ${entitlement.tier}.\nUpgrade: ${entitlement.upgrade_url || 'https://aigentry.dev/upgrade'}`,
|
|
416
|
+
}],
|
|
417
|
+
};
|
|
418
|
+
}
|
|
1157
419
|
try {
|
|
1158
420
|
return await handler(args);
|
|
1159
421
|
} catch (error) {
|
|
@@ -1251,2505 +513,51 @@ function withFileLock(lockPath, fn, options) {
|
|
|
1251
513
|
}
|
|
1252
514
|
|
|
1253
515
|
function withProjectLock(projectSlug, fn, options) {
|
|
1254
|
-
if (typeof projectSlug === "function") {
|
|
1255
|
-
return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
|
|
1256
|
-
}
|
|
1257
|
-
return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
function withSessionLock(sessionRef, fn, options) {
|
|
1261
|
-
const sessionId = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.id : sessionRef;
|
|
1262
|
-
const explicitProject = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.project : null;
|
|
1263
|
-
const record = findSessionRecord(sessionRef, { preferProject: explicitProject || getProjectSlug() });
|
|
1264
|
-
const projectSlug = explicitProject || record?.project || getProjectSlug();
|
|
1265
|
-
const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
|
|
1266
|
-
return withFileLock(path.join(getLocksDir(projectSlug), `${safeId}.lock`), fn, options);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
function normalizeSpeaker(raw) {
|
|
1270
|
-
if (typeof raw !== "string") return null;
|
|
1271
|
-
const normalized = raw.trim().toLowerCase();
|
|
1272
|
-
if (!normalized || normalized === "none") return null;
|
|
1273
|
-
return normalized;
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function dedupeSpeakers(items = []) {
|
|
1277
|
-
const out = [];
|
|
1278
|
-
const seen = new Set();
|
|
1279
|
-
for (const item of items) {
|
|
1280
|
-
const normalized = normalizeSpeaker(item);
|
|
1281
|
-
if (!normalized || seen.has(normalized)) continue;
|
|
1282
|
-
seen.add(normalized);
|
|
1283
|
-
out.push(normalized);
|
|
1284
|
-
}
|
|
1285
|
-
return out;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function createSelectionToken() {
|
|
1289
|
-
return `sel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function issueSpeakerSelectionToken({ candidates, include_browser }) {
|
|
1293
|
-
const selectionState = {
|
|
1294
|
-
token: createSelectionToken(),
|
|
1295
|
-
phase: "candidates",
|
|
1296
|
-
created_at: new Date().toISOString(),
|
|
1297
|
-
include_browser: !!include_browser,
|
|
1298
|
-
candidate_speakers: dedupeSpeakers((candidates || []).map(c => typeof c === "string" ? c : c?.speaker)),
|
|
1299
|
-
};
|
|
1300
|
-
writeJsonFileAtomic(getSpeakerSelectionFile(), selectionState);
|
|
1301
|
-
return selectionState;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
function loadSpeakerSelectionToken() {
|
|
1305
|
-
return readJsonFileSafe(getSpeakerSelectionFile());
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
function clearSpeakerSelectionToken() {
|
|
1309
|
-
try {
|
|
1310
|
-
fs.unlinkSync(getSpeakerSelectionFile());
|
|
1311
|
-
} catch {
|
|
1312
|
-
// ignore missing file
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function validateSpeakerSelectionSnapshot({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
|
|
1317
|
-
if (!selection_token) {
|
|
1318
|
-
return { ok: false, code: "missing_token" };
|
|
1319
|
-
}
|
|
1320
|
-
if (!selectionState?.token) {
|
|
1321
|
-
return { ok: false, code: "missing_selection_state" };
|
|
1322
|
-
}
|
|
1323
|
-
if (selectionState.token !== selection_token) {
|
|
1324
|
-
return { ok: false, code: "token_mismatch" };
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
const createdAtMs = Date.parse(selectionState.created_at || "");
|
|
1328
|
-
if (!Number.isFinite(createdAtMs) || (nowMs - createdAtMs) > SPEAKER_SELECTION_TTL_MS) {
|
|
1329
|
-
return { ok: false, code: "expired_token" };
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
if (!!selectionState.include_browser !== !!includeBrowserSpeakers) {
|
|
1333
|
-
return { ok: false, code: "mode_mismatch" };
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
const availableSpeakers = new Set(dedupeSpeakers(selectionState.candidate_speakers || []));
|
|
1337
|
-
const requestedSpeakers = dedupeSpeakers(speakers || []);
|
|
1338
|
-
const missingSpeakers = requestedSpeakers.filter(speaker => !availableSpeakers.has(speaker));
|
|
1339
|
-
if (missingSpeakers.length > 0) {
|
|
1340
|
-
return { ok: false, code: "speaker_mismatch", missing_speakers: missingSpeakers };
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
return { ok: true };
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function confirmSpeakerSelectionToken({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now(), persist = true }) {
|
|
1347
|
-
const snapshotValidation = validateSpeakerSelectionSnapshot({
|
|
1348
|
-
selectionState,
|
|
1349
|
-
selection_token,
|
|
1350
|
-
speakers,
|
|
1351
|
-
includeBrowserSpeakers,
|
|
1352
|
-
nowMs,
|
|
1353
|
-
});
|
|
1354
|
-
if (!snapshotValidation.ok) {
|
|
1355
|
-
return snapshotValidation;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
const confirmedSelection = {
|
|
1359
|
-
token: createSelectionToken(),
|
|
1360
|
-
phase: "confirmed",
|
|
1361
|
-
created_at: new Date(nowMs).toISOString(),
|
|
1362
|
-
include_browser: !!includeBrowserSpeakers,
|
|
1363
|
-
candidate_speakers: dedupeSpeakers(selectionState.candidate_speakers || []),
|
|
1364
|
-
selected_speakers: dedupeSpeakers(speakers || []),
|
|
1365
|
-
};
|
|
1366
|
-
if (persist) {
|
|
1367
|
-
writeJsonFileAtomic(getSpeakerSelectionFile(), confirmedSelection);
|
|
1368
|
-
}
|
|
1369
|
-
return { ok: true, selectionState: confirmedSelection };
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
function validateSpeakerSelectionRequest({ selectionState, selection_token, speakers, includeBrowserSpeakers, nowMs = Date.now() }) {
|
|
1373
|
-
const snapshotValidation = validateSpeakerSelectionSnapshot({
|
|
1374
|
-
selectionState,
|
|
1375
|
-
selection_token,
|
|
1376
|
-
speakers,
|
|
1377
|
-
includeBrowserSpeakers,
|
|
1378
|
-
nowMs,
|
|
1379
|
-
});
|
|
1380
|
-
if (!snapshotValidation.ok) {
|
|
1381
|
-
return snapshotValidation;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
if (selectionState.phase !== "confirmed" || !Array.isArray(selectionState.selected_speakers)) {
|
|
1385
|
-
return { ok: false, code: "selection_not_confirmed" };
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
const expectedSpeakers = dedupeSpeakers(selectionState.selected_speakers || []);
|
|
1389
|
-
const requestedSpeakers = dedupeSpeakers(speakers || []);
|
|
1390
|
-
if (
|
|
1391
|
-
expectedSpeakers.length !== requestedSpeakers.length
|
|
1392
|
-
|| expectedSpeakers.some(speaker => !requestedSpeakers.includes(speaker))
|
|
1393
|
-
) {
|
|
1394
|
-
return {
|
|
1395
|
-
ok: false,
|
|
1396
|
-
code: "selected_speakers_mismatch",
|
|
1397
|
-
expected_speakers: expectedSpeakers,
|
|
1398
|
-
requested_speakers: requestedSpeakers,
|
|
1399
|
-
};
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
return { ok: true };
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
function hasExplicitBrowserParticipantSelection({ speakers, participant_types } = {}) {
|
|
1406
|
-
const manualSpeakers = Array.isArray(speakers) ? speakers : [];
|
|
1407
|
-
const hasBrowserSpeaker = manualSpeakers.some(speaker => {
|
|
1408
|
-
const normalized = normalizeSpeaker(speaker);
|
|
1409
|
-
return normalized?.startsWith("web-");
|
|
1410
|
-
});
|
|
1411
|
-
if (hasBrowserSpeaker) return true;
|
|
1412
|
-
|
|
1413
|
-
const overrides = participant_types && typeof participant_types === "object"
|
|
1414
|
-
? Object.entries(participant_types)
|
|
1415
|
-
: [];
|
|
1416
|
-
|
|
1417
|
-
return overrides.some(([speaker, type]) => {
|
|
1418
|
-
const normalized = normalizeSpeaker(speaker);
|
|
1419
|
-
return normalized?.startsWith("web-") || type === "browser" || type === "browser_auto";
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
function resolveIncludeBrowserSpeakers({ include_browser_speakers, config, speakers, participant_types } = {}) {
|
|
1424
|
-
if (include_browser_speakers !== undefined && include_browser_speakers !== null) {
|
|
1425
|
-
return include_browser_speakers;
|
|
1426
|
-
}
|
|
1427
|
-
if (config?.include_browser_speakers !== undefined && config?.include_browser_speakers !== null) {
|
|
1428
|
-
return config.include_browser_speakers;
|
|
1429
|
-
}
|
|
1430
|
-
return false;
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
function resolveCliCandidates() {
|
|
1434
|
-
const fromEnv = (process.env.DELIBERATION_CLI_CANDIDATES || "")
|
|
1435
|
-
.split(/[,\s]+/)
|
|
1436
|
-
.map(v => v.trim())
|
|
1437
|
-
.filter(Boolean);
|
|
1438
|
-
|
|
1439
|
-
// If config has enabled_clis, use that as the primary filter
|
|
1440
|
-
const config = loadDeliberationConfig();
|
|
1441
|
-
if (Array.isArray(config.enabled_clis) && config.enabled_clis.length > 0) {
|
|
1442
|
-
return dedupeSpeakers([...fromEnv, ...config.enabled_clis]);
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
return dedupeSpeakers([...fromEnv, ...DEFAULT_CLI_CANDIDATES]);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
function loadTeleptyAuthToken() {
|
|
1449
|
-
try {
|
|
1450
|
-
const raw = fs.readFileSync(TELEPTY_CONFIG_FILE, "utf-8");
|
|
1451
|
-
const parsed = JSON.parse(raw);
|
|
1452
|
-
return typeof parsed?.authToken === "string" && parsed.authToken.trim()
|
|
1453
|
-
? parsed.authToken.trim()
|
|
1454
|
-
: null;
|
|
1455
|
-
} catch {
|
|
1456
|
-
return null;
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
function formatTeleptyHostLabel(host) {
|
|
1461
|
-
return !host || host === "127.0.0.1" || host === "localhost" ? "Local" : host;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
async function collectTeleptySessions() {
|
|
1465
|
-
const token = loadTeleptyAuthToken();
|
|
1466
|
-
if (!token) {
|
|
1467
|
-
return { sessions: [], note: "telepty auth token not found." };
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
const host = TELEPTY_DEFAULT_HOST;
|
|
1471
|
-
try {
|
|
1472
|
-
const res = await fetch(`http://${host}:${TELEPTY_PORT}/api/sessions`, {
|
|
1473
|
-
headers: { "x-telepty-token": token },
|
|
1474
|
-
signal: AbortSignal.timeout(1500),
|
|
1475
|
-
});
|
|
1476
|
-
if (!res.ok) {
|
|
1477
|
-
return { sessions: [], note: `telepty daemon unavailable (${res.status}).` };
|
|
1478
|
-
}
|
|
1479
|
-
const sessions = await res.json();
|
|
1480
|
-
if (!Array.isArray(sessions)) {
|
|
1481
|
-
return { sessions: [], note: "telepty session response format was invalid." };
|
|
1482
|
-
}
|
|
1483
|
-
ensureTeleptyBusSubscriber().catch(() => {});
|
|
1484
|
-
return {
|
|
1485
|
-
sessions: sessions.map(session => ({ host, ...session })),
|
|
1486
|
-
note: null,
|
|
1487
|
-
};
|
|
1488
|
-
} catch {
|
|
1489
|
-
return { sessions: [], note: null };
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
function scoreTeleptyProcessMatch(session, baseCommand = "", fullCommand = "") {
|
|
1494
|
-
const base = String(baseCommand || "").toLowerCase();
|
|
1495
|
-
const full = String(fullCommand || "").toLowerCase();
|
|
1496
|
-
const wanted = String(session?.command || "").trim().toLowerCase();
|
|
1497
|
-
let score = 0;
|
|
1498
|
-
|
|
1499
|
-
if (wanted && (base === wanted || full.startsWith(`${wanted} `) || full.includes(` ${wanted} `))) {
|
|
1500
|
-
score += 10;
|
|
1501
|
-
}
|
|
1502
|
-
if (base === "node" || base === "telepty") {
|
|
1503
|
-
score -= 2;
|
|
1504
|
-
}
|
|
1505
|
-
if (full.includes("mcp-deliberation") || full.includes("oh-my-claudecode") || full.includes("bridge/mcp-server")) {
|
|
1506
|
-
score -= 3;
|
|
1507
|
-
}
|
|
1508
|
-
return score;
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
function collectTeleptyProcessLocators(sessions = []) {
|
|
1512
|
-
const wantedSessions = new Map(
|
|
1513
|
-
sessions
|
|
1514
|
-
.filter(session => session?.id)
|
|
1515
|
-
.map(session => [String(session.id), session])
|
|
1516
|
-
);
|
|
1517
|
-
if (wantedSessions.size === 0) {
|
|
1518
|
-
return new Map();
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
try {
|
|
1522
|
-
const env = {
|
|
1523
|
-
HOME: process.env.HOME,
|
|
1524
|
-
PATH: process.env.PATH,
|
|
1525
|
-
SHELL: process.env.SHELL,
|
|
1526
|
-
USER: process.env.USER,
|
|
1527
|
-
LOGNAME: process.env.LOGNAME,
|
|
1528
|
-
TERM: process.env.TERM,
|
|
1529
|
-
};
|
|
1530
|
-
const raw = execFileSync("ps", ["eww", "-axo", "pid=,tty=,comm=,command="], {
|
|
1531
|
-
encoding: "utf-8",
|
|
1532
|
-
windowsHide: true,
|
|
1533
|
-
timeout: 2500,
|
|
1534
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
1535
|
-
env,
|
|
1536
|
-
});
|
|
1537
|
-
|
|
1538
|
-
const best = new Map();
|
|
1539
|
-
for (const line of String(raw).split("\n")) {
|
|
1540
|
-
if (!line.includes("TELEPTY_SESSION_ID=")) continue;
|
|
1541
|
-
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/);
|
|
1542
|
-
if (!match) continue;
|
|
1543
|
-
const [, pid, tty, comm, command] = match;
|
|
1544
|
-
const sessionIdMatch = command.match(/(?:^|\s)TELEPTY_SESSION_ID=([^\s]+)/);
|
|
1545
|
-
const sessionId = sessionIdMatch?.[1];
|
|
1546
|
-
if (!sessionId || !wantedSessions.has(sessionId)) continue;
|
|
1547
|
-
|
|
1548
|
-
const session = wantedSessions.get(sessionId);
|
|
1549
|
-
const score = scoreTeleptyProcessMatch(session, comm, command);
|
|
1550
|
-
const current = best.get(sessionId);
|
|
1551
|
-
if (!current || score > current.score) {
|
|
1552
|
-
best.set(sessionId, { pid: Number(pid), tty, score });
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
return new Map(
|
|
1557
|
-
[...best.entries()].map(([sessionId, value]) => [
|
|
1558
|
-
sessionId,
|
|
1559
|
-
{ pid: Number.isFinite(value.pid) ? value.pid : null, tty: value.tty || null },
|
|
1560
|
-
])
|
|
1561
|
-
);
|
|
1562
|
-
} catch {
|
|
1563
|
-
return new Map();
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
function commandExistsInPath(command) {
|
|
1568
|
-
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
|
1569
|
-
return false;
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
if (process.platform === "win32") {
|
|
1573
|
-
try {
|
|
1574
|
-
execFileSync("where", [command], { stdio: "ignore" });
|
|
1575
|
-
return true;
|
|
1576
|
-
} catch {
|
|
1577
|
-
// keep PATH scan fallback for shells where "where" is unavailable
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
const pathVar = process.env.PATH || "";
|
|
1582
|
-
const dirs = pathVar.split(path.delimiter).filter(Boolean);
|
|
1583
|
-
if (dirs.length === 0) return false;
|
|
1584
|
-
|
|
1585
|
-
const extensions = process.platform === "win32"
|
|
1586
|
-
? ["", ".exe", ".cmd", ".bat", ".ps1"]
|
|
1587
|
-
: [""];
|
|
1588
|
-
|
|
1589
|
-
for (const dir of dirs) {
|
|
1590
|
-
for (const ext of extensions) {
|
|
1591
|
-
const fullPath = path.join(dir, `${command}${ext}`);
|
|
1592
|
-
try {
|
|
1593
|
-
fs.accessSync(fullPath, fs.constants.X_OK);
|
|
1594
|
-
return true;
|
|
1595
|
-
} catch {
|
|
1596
|
-
// ignore and continue
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
return false;
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
function shellQuote(value) {
|
|
1604
|
-
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
function checkCliLiveness(command) {
|
|
1608
|
-
const hint = CLI_INVOCATION_HINTS[command];
|
|
1609
|
-
const env = { ...process.env };
|
|
1610
|
-
// Unset CLAUDECODE to avoid nested session errors
|
|
1611
|
-
if (hint?.envPrefix?.includes("CLAUDECODE=")) {
|
|
1612
|
-
delete env.CLAUDECODE;
|
|
1613
|
-
}
|
|
1614
|
-
try {
|
|
1615
|
-
execFileSync(command, ["--version"], {
|
|
1616
|
-
stdio: "ignore",
|
|
1617
|
-
windowsHide: true,
|
|
1618
|
-
timeout: 5000,
|
|
1619
|
-
env,
|
|
1620
|
-
});
|
|
1621
|
-
return true;
|
|
1622
|
-
} catch {
|
|
1623
|
-
// --version failed, try --help as fallback
|
|
1624
|
-
try {
|
|
1625
|
-
execFileSync(command, ["--help"], {
|
|
1626
|
-
stdio: "ignore",
|
|
1627
|
-
windowsHide: true,
|
|
1628
|
-
timeout: 5000,
|
|
1629
|
-
env,
|
|
1630
|
-
});
|
|
1631
|
-
return true;
|
|
1632
|
-
} catch {
|
|
1633
|
-
return false;
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
function discoverLocalCliSpeakers() {
|
|
1639
|
-
const found = [];
|
|
1640
|
-
for (const candidate of resolveCliCandidates()) {
|
|
1641
|
-
if (commandExistsInPath(candidate)) {
|
|
1642
|
-
found.push(candidate);
|
|
1643
|
-
}
|
|
1644
|
-
if (found.length >= MAX_AUTO_DISCOVERED_SPEAKERS) {
|
|
1645
|
-
break;
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
return found;
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
function detectCallerSpeaker() {
|
|
1652
|
-
const hinted = normalizeSpeaker(process.env.DELIBERATION_CALLER_SPEAKER);
|
|
1653
|
-
if (hinted) return hinted;
|
|
1654
|
-
|
|
1655
|
-
const envKeys = Object.keys(process.env).join(" ");
|
|
1656
|
-
const pathHint = process.env.PATH || "";
|
|
1657
|
-
|
|
1658
|
-
// Codex detection
|
|
1659
|
-
if (/\bCODEX_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.codex/")) {
|
|
1660
|
-
return "codex";
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
// Claude detection
|
|
1664
|
-
if (/\bCLAUDE_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.claude/")) {
|
|
1665
|
-
return "claude";
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
// Gemini detection
|
|
1669
|
-
if (/\bGOOGLE_GENAI_[A-Z0-9_]+\b/.test(envKeys) || /\bGEMINI_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/.gemini/")) {
|
|
1670
|
-
return "gemini";
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
// Aider detection
|
|
1674
|
-
if (/\bAIDER_[A-Z0-9_]+\b/.test(envKeys) || pathHint.includes("/aider/")) {
|
|
1675
|
-
return "aider";
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
return null;
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
function isLlmUrl(url = "") {
|
|
1682
|
-
const value = String(url || "").trim();
|
|
1683
|
-
if (!value) return false;
|
|
1684
|
-
try {
|
|
1685
|
-
const parsed = new URL(value);
|
|
1686
|
-
const host = parsed.hostname.toLowerCase();
|
|
1687
|
-
return DEFAULT_LLM_DOMAINS.some(domain => host === domain || host.endsWith(`.${domain}`));
|
|
1688
|
-
} catch {
|
|
1689
|
-
const lowered = value.toLowerCase();
|
|
1690
|
-
return DEFAULT_LLM_DOMAINS.some(domain => lowered.includes(domain));
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
function dedupeBrowserTabs(tabs = []) {
|
|
1695
|
-
const out = [];
|
|
1696
|
-
const seen = new Set();
|
|
1697
|
-
for (const tab of tabs) {
|
|
1698
|
-
const browser = String(tab?.browser || "").trim();
|
|
1699
|
-
const title = String(tab?.title || "").trim();
|
|
1700
|
-
const url = String(tab?.url || "").trim();
|
|
1701
|
-
if (!url || (!isLlmUrl(url) && !isExtensionLlmTab(url, title))) continue;
|
|
1702
|
-
// Dedup by title+url (ignore browser name) so that the same tab detected
|
|
1703
|
-
// via both AppleScript and CDP is not duplicated. The first occurrence wins,
|
|
1704
|
-
// so callers should add preferred sources first (e.g., CDP before AppleScript).
|
|
1705
|
-
const key = `${title}\t${url}`;
|
|
1706
|
-
if (seen.has(key)) continue;
|
|
1707
|
-
seen.add(key);
|
|
1708
|
-
out.push({
|
|
1709
|
-
browser: browser || "Browser",
|
|
1710
|
-
title: title || "(untitled)",
|
|
1711
|
-
url,
|
|
1712
|
-
});
|
|
1713
|
-
}
|
|
1714
|
-
return out;
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
function parseInjectedBrowserTabsFromEnv() {
|
|
1718
|
-
const raw = process.env.DELIBERATION_BROWSER_TABS_JSON;
|
|
1719
|
-
if (!raw) {
|
|
1720
|
-
return { tabs: [], note: null };
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
try {
|
|
1724
|
-
const parsed = JSON.parse(raw);
|
|
1725
|
-
if (!Array.isArray(parsed)) {
|
|
1726
|
-
return { tabs: [], note: "DELIBERATION_BROWSER_TABS_JSON format error: must be a JSON array." };
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
const tabs = dedupeBrowserTabs(parsed.map(item => ({
|
|
1730
|
-
browser: item?.browser || "External Bridge",
|
|
1731
|
-
title: item?.title || "(untitled)",
|
|
1732
|
-
url: item?.url || "",
|
|
1733
|
-
})));
|
|
1734
|
-
return {
|
|
1735
|
-
tabs,
|
|
1736
|
-
note: tabs.length > 0 ? `Environment variable tab injection: ${tabs.length} tabs` : "No valid LLM URLs found in DELIBERATION_BROWSER_TABS_JSON.",
|
|
1737
|
-
};
|
|
1738
|
-
} catch (error) {
|
|
1739
|
-
const reason = error instanceof Error ? error.message : "unknown error";
|
|
1740
|
-
return { tabs: [], note: `Failed to parse DELIBERATION_BROWSER_TABS_JSON: ${reason}` };
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
function normalizeCdpEndpoint(raw) {
|
|
1745
|
-
const value = String(raw || "").trim();
|
|
1746
|
-
if (!value) return null;
|
|
1747
|
-
|
|
1748
|
-
const withProto = /^https?:\/\//i.test(value) ? value : `http://${value}`;
|
|
1749
|
-
try {
|
|
1750
|
-
const url = new URL(withProto);
|
|
1751
|
-
if (!url.pathname || url.pathname === "/") {
|
|
1752
|
-
url.pathname = "/json/list";
|
|
1753
|
-
}
|
|
1754
|
-
return url.toString();
|
|
1755
|
-
} catch {
|
|
1756
|
-
return null;
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
function resolveCdpEndpoints() {
|
|
1761
|
-
const fromEnv = (process.env.DELIBERATION_BROWSER_CDP_ENDPOINTS || "")
|
|
1762
|
-
.split(/[,\s]+/)
|
|
1763
|
-
.map(v => normalizeCdpEndpoint(v))
|
|
1764
|
-
.filter(Boolean);
|
|
1765
|
-
if (fromEnv.length > 0) {
|
|
1766
|
-
return [...new Set(fromEnv)];
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
const ports = (process.env.DELIBERATION_BROWSER_CDP_PORTS || "9222,9223,9333")
|
|
1770
|
-
.split(/[,\s]+/)
|
|
1771
|
-
.map(v => Number.parseInt(v, 10))
|
|
1772
|
-
.filter(v => Number.isInteger(v) && v > 0 && v < 65536);
|
|
1773
|
-
|
|
1774
|
-
const endpoints = [];
|
|
1775
|
-
for (const port of ports) {
|
|
1776
|
-
endpoints.push(`http://127.0.0.1:${port}/json/list`);
|
|
1777
|
-
endpoints.push(`http://localhost:${port}/json/list`);
|
|
1778
|
-
}
|
|
1779
|
-
return [...new Set(endpoints)];
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
async function fetchJson(url, timeoutMs = 900) {
|
|
1783
|
-
if (typeof fetch !== "function") {
|
|
1784
|
-
throw new Error("fetch API unavailable in current Node runtime");
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
const controller = new AbortController();
|
|
1788
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1789
|
-
try {
|
|
1790
|
-
const response = await fetch(url, {
|
|
1791
|
-
method: "GET",
|
|
1792
|
-
signal: controller.signal,
|
|
1793
|
-
headers: { "accept": "application/json" },
|
|
1794
|
-
});
|
|
1795
|
-
if (!response.ok) {
|
|
1796
|
-
throw new Error(`HTTP ${response.status}`);
|
|
1797
|
-
}
|
|
1798
|
-
return await response.json();
|
|
1799
|
-
} finally {
|
|
1800
|
-
clearTimeout(timer);
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
function inferBrowserFromCdpEndpoint(endpoint) {
|
|
1805
|
-
try {
|
|
1806
|
-
const parsed = new URL(endpoint);
|
|
1807
|
-
const port = Number.parseInt(parsed.port, 10);
|
|
1808
|
-
if (port === 9222) return "Google Chrome (CDP)";
|
|
1809
|
-
if (port === 9223) return "Microsoft Edge (CDP)";
|
|
1810
|
-
if (port === 9333) return "Brave Browser (CDP)";
|
|
1811
|
-
return `Browser (CDP:${parsed.host})`;
|
|
1812
|
-
} catch {
|
|
1813
|
-
return "Browser (CDP)";
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
function summarizeFailures(items = [], max = 3) {
|
|
1818
|
-
if (!Array.isArray(items) || items.length === 0) return null;
|
|
1819
|
-
const shown = items.slice(0, max);
|
|
1820
|
-
const suffix = items.length > max ? ` and ${items.length - max} more` : "";
|
|
1821
|
-
return `${shown.join(", ")}${suffix}`;
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
async function collectBrowserLlmTabsViaCdp() {
|
|
1825
|
-
const endpoints = resolveCdpEndpoints();
|
|
1826
|
-
const tabs = [];
|
|
1827
|
-
const failures = [];
|
|
1828
|
-
|
|
1829
|
-
for (const endpoint of endpoints) {
|
|
1830
|
-
try {
|
|
1831
|
-
const payload = await fetchJson(endpoint);
|
|
1832
|
-
if (!Array.isArray(payload)) {
|
|
1833
|
-
throw new Error("unexpected payload");
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
const browser = inferBrowserFromCdpEndpoint(endpoint);
|
|
1837
|
-
for (const item of payload) {
|
|
1838
|
-
if (!item || String(item.type) !== "page") continue;
|
|
1839
|
-
const url = String(item.url || "").trim();
|
|
1840
|
-
const title = String(item.title || "").trim();
|
|
1841
|
-
if (!isLlmUrl(url) && !isExtensionLlmTab(url, title)) continue;
|
|
1842
|
-
tabs.push({
|
|
1843
|
-
browser,
|
|
1844
|
-
title: title || "(untitled)",
|
|
1845
|
-
url,
|
|
1846
|
-
});
|
|
1847
|
-
}
|
|
1848
|
-
} catch (error) {
|
|
1849
|
-
const reason = error instanceof Error ? error.message : "unknown error";
|
|
1850
|
-
failures.push(`${endpoint} (${reason})`);
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
const uniqTabs = dedupeBrowserTabs(tabs);
|
|
1855
|
-
if (uniqTabs.length > 0) {
|
|
1856
|
-
const failSummary = summarizeFailures(failures);
|
|
1857
|
-
return {
|
|
1858
|
-
tabs: uniqTabs,
|
|
1859
|
-
note: failSummary ? `Some CDP endpoint access failed: ${failSummary}` : null,
|
|
1860
|
-
};
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
const failSummary = summarizeFailures(failures);
|
|
1864
|
-
return {
|
|
1865
|
-
tabs: [],
|
|
1866
|
-
note: `No LLM tabs found via CDP. Run browser with --remote-debugging-port=9222 or inject tab list via DELIBERATION_BROWSER_TABS_JSON.${failSummary ? ` (failed: ${failSummary})` : ""}`,
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
async function ensureCdpAvailable() {
|
|
1871
|
-
const endpoints = resolveCdpEndpoints();
|
|
1872
|
-
|
|
1873
|
-
// First attempt: try existing CDP endpoints
|
|
1874
|
-
for (const endpoint of endpoints) {
|
|
1875
|
-
try {
|
|
1876
|
-
const payload = await fetchJson(endpoint, 1500);
|
|
1877
|
-
if (Array.isArray(payload)) {
|
|
1878
|
-
return { available: true, endpoint };
|
|
1879
|
-
}
|
|
1880
|
-
} catch { /* not reachable */ }
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
// Auto-launch Chrome with CDP on macOS, Linux, and Windows
|
|
1884
|
-
{
|
|
1885
|
-
let chromeBin, chromeUserDataDir;
|
|
1886
|
-
|
|
1887
|
-
if (process.platform === "darwin") {
|
|
1888
|
-
chromeBin = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
1889
|
-
chromeUserDataDir = path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
1890
|
-
} else if (process.platform === "linux") {
|
|
1891
|
-
const chromeCandidates = ["google-chrome", "google-chrome-stable", "google-chrome-beta", "chromium-browser", "chromium"];
|
|
1892
|
-
chromeBin = chromeCandidates.find(c => commandExistsInPath(c)) || null;
|
|
1893
|
-
if (!chromeBin) {
|
|
1894
|
-
return {
|
|
1895
|
-
available: false,
|
|
1896
|
-
reason: "Chrome/Chromium not found. Install google-chrome or chromium and run with --remote-debugging-port=9222.",
|
|
1897
|
-
};
|
|
1898
|
-
}
|
|
1899
|
-
const googleDir = path.join(os.homedir(), ".config", "google-chrome");
|
|
1900
|
-
const chromiumDir = path.join(os.homedir(), ".config", "chromium");
|
|
1901
|
-
chromeUserDataDir = fs.existsSync(googleDir) ? googleDir : fs.existsSync(chromiumDir) ? chromiumDir : null;
|
|
1902
|
-
} else if (process.platform === "win32") {
|
|
1903
|
-
const programFiles = process.env.PROGRAMFILES || "C:\\Program Files";
|
|
1904
|
-
const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
|
|
1905
|
-
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
1906
|
-
const winCandidates = [
|
|
1907
|
-
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
|
1908
|
-
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
1909
|
-
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
|
1910
|
-
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
1911
|
-
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
1912
|
-
];
|
|
1913
|
-
chromeBin = winCandidates.find(p => fs.existsSync(p)) || null;
|
|
1914
|
-
if (!chromeBin) {
|
|
1915
|
-
return {
|
|
1916
|
-
available: false,
|
|
1917
|
-
reason: "Chrome/Edge not found. Install Chrome or run with --remote-debugging-port=9222.",
|
|
1918
|
-
};
|
|
1919
|
-
}
|
|
1920
|
-
const chromeDir = path.join(localAppData, "Google", "Chrome", "User Data");
|
|
1921
|
-
const edgeDir = path.join(localAppData, "Microsoft", "Edge", "User Data");
|
|
1922
|
-
chromeUserDataDir = fs.existsSync(chromeDir) ? chromeDir : fs.existsSync(edgeDir) ? edgeDir : null;
|
|
1923
|
-
} else {
|
|
1924
|
-
return {
|
|
1925
|
-
available: false,
|
|
1926
|
-
reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
|
|
1927
|
-
};
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
// Chrome 145+ requires --user-data-dir for CDP to work.
|
|
1931
|
-
// The default data dir is rejected, so we copy the profile to ~/.chrome-cdp.
|
|
1932
|
-
// Profile can be set via env DELIBERATION_CHROME_PROFILE or config.chrome_profile (e.g., "Profile 1").
|
|
1933
|
-
const cdpDataDir = path.join(os.homedir(), ".chrome-cdp");
|
|
1934
|
-
const cdpConfig = loadDeliberationConfig();
|
|
1935
|
-
const profileDir = process.env.DELIBERATION_CHROME_PROFILE || cdpConfig.chrome_profile || "Default";
|
|
1936
|
-
|
|
1937
|
-
try {
|
|
1938
|
-
if (chromeUserDataDir) {
|
|
1939
|
-
const srcProfile = path.join(chromeUserDataDir, profileDir);
|
|
1940
|
-
const dstProfile = path.join(cdpDataDir, profileDir);
|
|
1941
|
-
// Track which profile was copied; re-copy if profile changed
|
|
1942
|
-
const profileMarker = path.join(cdpDataDir, ".cdp-profile");
|
|
1943
|
-
const lastProfile = fs.existsSync(profileMarker) ? fs.readFileSync(profileMarker, "utf8").trim() : null;
|
|
1944
|
-
const needsCopy = !fs.existsSync(dstProfile) || (lastProfile && lastProfile !== profileDir);
|
|
1945
|
-
if (needsCopy && fs.existsSync(srcProfile)) {
|
|
1946
|
-
// Clean old profile if switching
|
|
1947
|
-
if (lastProfile && lastProfile !== profileDir) {
|
|
1948
|
-
const oldDst = path.join(cdpDataDir, lastProfile);
|
|
1949
|
-
if (fs.existsSync(oldDst)) fs.rmSync(oldDst, { recursive: true, force: true });
|
|
1950
|
-
}
|
|
1951
|
-
fs.mkdirSync(cdpDataDir, { recursive: true });
|
|
1952
|
-
execFileSync("cp", ["-R", srcProfile, dstProfile], { timeout: 30000, stdio: "ignore" });
|
|
1953
|
-
fs.writeFileSync(profileMarker, profileDir);
|
|
1954
|
-
// Create minimal Local State with single profile to avoid profile picker
|
|
1955
|
-
const localStateSrc = path.join(chromeUserDataDir, "Local State");
|
|
1956
|
-
if (fs.existsSync(localStateSrc)) {
|
|
1957
|
-
const state = JSON.parse(fs.readFileSync(localStateSrc, "utf8"));
|
|
1958
|
-
state.profile.profiles_created = 1;
|
|
1959
|
-
state.profile.last_used = profileDir;
|
|
1960
|
-
if (state.profile.info_cache) {
|
|
1961
|
-
const kept = {};
|
|
1962
|
-
if (state.profile.info_cache[profileDir]) kept[profileDir] = state.profile.info_cache[profileDir];
|
|
1963
|
-
state.profile.info_cache = kept;
|
|
1964
|
-
}
|
|
1965
|
-
fs.writeFileSync(path.join(cdpDataDir, "Local State"), JSON.stringify(state));
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
} catch { /* proceed with launch attempt anyway */ }
|
|
1970
|
-
|
|
1971
|
-
const launchArgs = [
|
|
1972
|
-
"--remote-debugging-port=9222",
|
|
1973
|
-
"--remote-allow-origins=http://127.0.0.1:9222",
|
|
1974
|
-
`--user-data-dir=${cdpDataDir}`,
|
|
1975
|
-
`--profile-directory=${profileDir}`,
|
|
1976
|
-
"--no-first-run",
|
|
1977
|
-
];
|
|
1978
|
-
|
|
1979
|
-
try {
|
|
1980
|
-
const child = spawn(chromeBin, launchArgs, { stdio: "ignore", detached: true });
|
|
1981
|
-
child.unref();
|
|
1982
|
-
} catch {
|
|
1983
|
-
return {
|
|
1984
|
-
available: false,
|
|
1985
|
-
reason: `Failed to auto-launch Chrome. Manually run Chrome with --remote-debugging-port=9222 --user-data-dir=~/.chrome-cdp.`,
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
// Wait for Chrome to initialize CDP
|
|
1990
|
-
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
1991
|
-
|
|
1992
|
-
// Retry CDP connection after launch
|
|
1993
|
-
for (const endpoint of endpoints) {
|
|
1994
|
-
try {
|
|
1995
|
-
const payload = await fetchJson(endpoint, 2000);
|
|
1996
|
-
if (Array.isArray(payload)) {
|
|
1997
|
-
return { available: true, endpoint, launched: true };
|
|
1998
|
-
}
|
|
1999
|
-
} catch { /* still not reachable */ }
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
return {
|
|
2003
|
-
available: false,
|
|
2004
|
-
reason: "Chrome launched but cannot connect to CDP. Fully close Chrome and try again. (Restart required if Chrome was started without CDP)",
|
|
2005
|
-
};
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
// Unreachable (all platforms handled above), but keep as safety net
|
|
2009
|
-
return {
|
|
2010
|
-
available: false,
|
|
2011
|
-
reason: "Cannot activate Chrome CDP. Run Chrome with --remote-debugging-port=9222.",
|
|
2012
|
-
};
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
function collectBrowserLlmTabsViaAppleScript() {
|
|
2016
|
-
if (process.platform !== "darwin") {
|
|
2017
|
-
return { tabs: [], note: "AppleScript tab scanning is only supported on macOS." };
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
const escapedDomains = DEFAULT_LLM_DOMAINS.map(d => d.replace(/"/g, '\\"'));
|
|
2021
|
-
const escapedApps = DEFAULT_BROWSER_APPS.map(a => a.replace(/"/g, '\\"'));
|
|
2022
|
-
const domainList = `{${escapedDomains.map(d => `"${d}"`).join(", ")}}`;
|
|
2023
|
-
const appList = `{${escapedApps.map(a => `"${a}"`).join(", ")}}`;
|
|
2024
|
-
|
|
2025
|
-
// NOTE: Use stdin pipe (`osascript -`) instead of multiple `-e` flags
|
|
2026
|
-
// because osascript's `-e` mode silently breaks with nested try/on error blocks.
|
|
2027
|
-
// Also wrap dynamic `tell application` with `using terms from` so that
|
|
2028
|
-
// Chrome-specific properties like `tabs` resolve via the scripting dictionary.
|
|
2029
|
-
// Use ASCII character 9 for tab delimiter because `using terms from`
|
|
2030
|
-
// shadows the built-in `tab` constant, turning it into the literal string "tab".
|
|
2031
|
-
const scriptText = `set llmDomains to ${domainList}
|
|
2032
|
-
set browserApps to ${appList}
|
|
2033
|
-
set outText to ""
|
|
2034
|
-
set tabChar to ASCII character 9
|
|
2035
|
-
tell application "System Events"
|
|
2036
|
-
set runningApps to name of every application process
|
|
2037
|
-
end tell
|
|
2038
|
-
repeat with appName in browserApps
|
|
2039
|
-
if runningApps contains (appName as string) then
|
|
2040
|
-
try
|
|
2041
|
-
if (appName as string) is "Safari" then
|
|
2042
|
-
using terms from application "Safari"
|
|
2043
|
-
tell application (appName as string)
|
|
2044
|
-
repeat with w in windows
|
|
2045
|
-
try
|
|
2046
|
-
repeat with t in tabs of w
|
|
2047
|
-
set u to URL of t as string
|
|
2048
|
-
set matched to false
|
|
2049
|
-
repeat with d in llmDomains
|
|
2050
|
-
if u contains (d as string) then set matched to true
|
|
2051
|
-
end repeat
|
|
2052
|
-
if matched then set outText to outText & (appName as string) & tabChar & (name of t as string) & tabChar & u & linefeed
|
|
2053
|
-
end repeat
|
|
2054
|
-
end try
|
|
2055
|
-
end repeat
|
|
2056
|
-
end tell
|
|
2057
|
-
end using terms from
|
|
2058
|
-
else
|
|
2059
|
-
using terms from application "Google Chrome"
|
|
2060
|
-
tell application (appName as string)
|
|
2061
|
-
repeat with w in windows
|
|
2062
|
-
try
|
|
2063
|
-
repeat with t in tabs of w
|
|
2064
|
-
set u to URL of t as string
|
|
2065
|
-
set matched to false
|
|
2066
|
-
repeat with d in llmDomains
|
|
2067
|
-
if u contains (d as string) then set matched to true
|
|
2068
|
-
end repeat
|
|
2069
|
-
if matched then set outText to outText & (appName as string) & tabChar & (title of t as string) & tabChar & u & linefeed
|
|
2070
|
-
end repeat
|
|
2071
|
-
end try
|
|
2072
|
-
end repeat
|
|
2073
|
-
end tell
|
|
2074
|
-
end using terms from
|
|
2075
|
-
end if
|
|
2076
|
-
on error errMsg
|
|
2077
|
-
set outText to outText & (appName as string) & tabChar & "ERROR" & tabChar & errMsg & linefeed
|
|
2078
|
-
end try
|
|
2079
|
-
end if
|
|
2080
|
-
end repeat
|
|
2081
|
-
return outText`;
|
|
2082
|
-
|
|
2083
|
-
try {
|
|
2084
|
-
const raw = execFileSync("osascript", ["-"], {
|
|
2085
|
-
input: scriptText,
|
|
2086
|
-
encoding: "utf-8",
|
|
2087
|
-
timeout: 8000,
|
|
2088
|
-
maxBuffer: 2 * 1024 * 1024,
|
|
2089
|
-
});
|
|
2090
|
-
const rows = String(raw)
|
|
2091
|
-
.split("\n")
|
|
2092
|
-
.map(line => line.trim())
|
|
2093
|
-
.filter(Boolean)
|
|
2094
|
-
.map(line => {
|
|
2095
|
-
const [browser = "", title = "", url = ""] = line.split("\t");
|
|
2096
|
-
return { browser, title, url };
|
|
2097
|
-
});
|
|
2098
|
-
const tabs = rows.filter(r => r.title !== "ERROR");
|
|
2099
|
-
const errors = rows.filter(r => r.title === "ERROR");
|
|
2100
|
-
return {
|
|
2101
|
-
tabs,
|
|
2102
|
-
note: errors.length > 0
|
|
2103
|
-
? `Some browser access failed: ${errors.map(e => `${e.browser} (${e.url})`).join(", ")}`
|
|
2104
|
-
: null,
|
|
2105
|
-
};
|
|
2106
|
-
} catch (error) {
|
|
2107
|
-
const reason = error instanceof Error ? error.message : "unknown error";
|
|
2108
|
-
return {
|
|
2109
|
-
tabs: [],
|
|
2110
|
-
note: `Browser tab scan failed: ${reason}. Check macOS automation permissions (Terminal -> Browser control).`,
|
|
2111
|
-
};
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
async function collectBrowserLlmTabs() {
|
|
2116
|
-
const mode = (process.env.DELIBERATION_BROWSER_SCAN_MODE || "auto").trim().toLowerCase();
|
|
2117
|
-
const tabs = [];
|
|
2118
|
-
const notes = [];
|
|
2119
|
-
|
|
2120
|
-
const injected = parseInjectedBrowserTabsFromEnv();
|
|
2121
|
-
tabs.push(...injected.tabs);
|
|
2122
|
-
if (injected.note) notes.push(injected.note);
|
|
2123
|
-
|
|
2124
|
-
if (mode === "off") {
|
|
2125
|
-
return {
|
|
2126
|
-
tabs: dedupeBrowserTabs(tabs),
|
|
2127
|
-
note: notes.length > 0 ? notes.join(" | ") : "Browser tab auto-scanning is disabled.",
|
|
2128
|
-
};
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
// CDP first: CDP-detected tabs are preferred over AppleScript-detected ones
|
|
2132
|
-
// because they carry CDP metadata (tab ID, WebSocket URL) for browser_auto transport.
|
|
2133
|
-
// Since dedupeBrowserTabs keeps the first occurrence, CDP entries win the dedup.
|
|
2134
|
-
const shouldUseCdp = mode === "auto" || mode === "cdp";
|
|
2135
|
-
if (shouldUseCdp) {
|
|
2136
|
-
const cdp = await collectBrowserLlmTabsViaCdp();
|
|
2137
|
-
tabs.push(...cdp.tabs);
|
|
2138
|
-
if (cdp.note) notes.push(cdp.note);
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
const shouldUseAppleScript = mode === "auto" || mode === "applescript";
|
|
2142
|
-
if (shouldUseAppleScript && process.platform === "darwin") {
|
|
2143
|
-
const mac = collectBrowserLlmTabsViaAppleScript();
|
|
2144
|
-
tabs.push(...mac.tabs);
|
|
2145
|
-
if (mac.note) notes.push(mac.note);
|
|
2146
|
-
} else if (mode === "applescript" && process.platform !== "darwin") {
|
|
2147
|
-
notes.push("AppleScript scanning is macOS only. Switch to CDP scanning.");
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
const uniqTabs = dedupeBrowserTabs(tabs);
|
|
2151
|
-
return {
|
|
2152
|
-
tabs: uniqTabs,
|
|
2153
|
-
note: notes.length > 0 ? notes.join(" | ") : null,
|
|
2154
|
-
};
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
function inferLlmProvider(url = "", title = "") {
|
|
2158
|
-
const value = String(url).toLowerCase();
|
|
2159
|
-
// Extension side panel: infer from title via registry
|
|
2160
|
-
if (value.startsWith("chrome-extension://") && title) {
|
|
2161
|
-
const registry = loadExtensionProviderRegistry();
|
|
2162
|
-
const lowerTitle = String(title).toLowerCase();
|
|
2163
|
-
for (const entry of registry.providers) {
|
|
2164
|
-
if (entry.titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
|
|
2165
|
-
return entry.provider;
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
return "extension-llm";
|
|
2169
|
-
}
|
|
2170
|
-
if (value.includes("claude.ai") || value.includes("anthropic.com")) return "claude";
|
|
2171
|
-
if (value.includes("chatgpt.com") || value.includes("openai.com")) return "chatgpt";
|
|
2172
|
-
if (value.includes("gemini.google.com") || value.includes("notebooklm.google.com")) return "gemini";
|
|
2173
|
-
if (value.includes("copilot.microsoft.com")) return "copilot";
|
|
2174
|
-
if (value.includes("perplexity.ai")) return "perplexity";
|
|
2175
|
-
if (value.includes("poe.com")) return "poe";
|
|
2176
|
-
if (value.includes("mistral.ai")) return "mistral";
|
|
2177
|
-
if (value.includes("huggingface.co/chat")) return "huggingchat";
|
|
2178
|
-
if (value.includes("deepseek.com")) return "deepseek";
|
|
2179
|
-
if (value.includes("qwen.ai")) return "qwen";
|
|
2180
|
-
if (value.includes("grok.com")) return "grok";
|
|
2181
|
-
return "web-llm";
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
async function collectSpeakerCandidates({ include_cli = true, include_browser = true } = {}) {
|
|
2185
|
-
const candidates = [];
|
|
2186
|
-
const seen = new Set();
|
|
2187
|
-
let browserNote = null;
|
|
2188
|
-
|
|
2189
|
-
const add = (candidate) => {
|
|
2190
|
-
const speaker = normalizeSpeaker(candidate?.speaker);
|
|
2191
|
-
if (!speaker || seen.has(speaker)) return;
|
|
2192
|
-
seen.add(speaker);
|
|
2193
|
-
candidates.push({ ...candidate, speaker });
|
|
2194
|
-
};
|
|
2195
|
-
|
|
2196
|
-
if (include_cli) {
|
|
2197
|
-
for (const cli of discoverLocalCliSpeakers()) {
|
|
2198
|
-
const live = checkCliLiveness(cli);
|
|
2199
|
-
add({
|
|
2200
|
-
speaker: cli,
|
|
2201
|
-
type: "cli",
|
|
2202
|
-
label: cli,
|
|
2203
|
-
command: cli,
|
|
2204
|
-
live,
|
|
2205
|
-
});
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
const { sessions: teleptySessions, note: teleptyNote } = await collectTeleptySessions();
|
|
2209
|
-
const locators = collectTeleptyProcessLocators(teleptySessions);
|
|
2210
|
-
for (const session of teleptySessions) {
|
|
2211
|
-
const locator = locators.get(session.id) || {};
|
|
2212
|
-
add({
|
|
2213
|
-
speaker: session.id,
|
|
2214
|
-
type: "telepty",
|
|
2215
|
-
label: session.id,
|
|
2216
|
-
telepty_session_id: session.id,
|
|
2217
|
-
telepty_host: session.host || TELEPTY_DEFAULT_HOST,
|
|
2218
|
-
command: session.command || "wrapped",
|
|
2219
|
-
cwd: session.cwd || null,
|
|
2220
|
-
active_clients: session.active_clients ?? null,
|
|
2221
|
-
runtime_pid: locator.pid ?? null,
|
|
2222
|
-
runtime_tty: locator.tty ?? null,
|
|
2223
|
-
});
|
|
2224
|
-
}
|
|
2225
|
-
if (teleptyNote) {
|
|
2226
|
-
browserNote = browserNote ? `${browserNote} | ${teleptyNote}` : teleptyNote;
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
if (include_browser) {
|
|
2231
|
-
// Ensure CDP is available before probing browser tabs
|
|
2232
|
-
const cdpStatus = await ensureCdpAvailable();
|
|
2233
|
-
if (cdpStatus.launched) {
|
|
2234
|
-
browserNote = "Chrome CDP auto-launched (--remote-debugging-port=9222)";
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
const { tabs, note } = await collectBrowserLlmTabs();
|
|
2238
|
-
browserNote = browserNote ? `${browserNote} | ${note || ""}`.replace(/ \| $/, "") : (note || null);
|
|
2239
|
-
const providerCounts = new Map();
|
|
2240
|
-
for (const tab of tabs) {
|
|
2241
|
-
const provider = inferLlmProvider(tab.url, tab.title);
|
|
2242
|
-
const count = (providerCounts.get(provider) || 0) + 1;
|
|
2243
|
-
providerCounts.set(provider, count);
|
|
2244
|
-
add({
|
|
2245
|
-
speaker: `web-${provider}-${count}`,
|
|
2246
|
-
type: "browser",
|
|
2247
|
-
provider,
|
|
2248
|
-
browser: tab.browser || "",
|
|
2249
|
-
title: tab.title || "",
|
|
2250
|
-
url: tab.url || "",
|
|
2251
|
-
});
|
|
2252
|
-
}
|
|
2253
|
-
|
|
2254
|
-
// CDP auto-detection: probe endpoints for matching tabs
|
|
2255
|
-
const cdpEndpoints = resolveCdpEndpoints();
|
|
2256
|
-
const cdpTabsMap = new Map(); // dedupe by tab ID (multiple endpoints may return same tabs)
|
|
2257
|
-
for (const endpoint of cdpEndpoints) {
|
|
2258
|
-
try {
|
|
2259
|
-
const tabs = await fetchJson(endpoint, 2000);
|
|
2260
|
-
if (Array.isArray(tabs)) {
|
|
2261
|
-
for (const t of tabs) {
|
|
2262
|
-
if (t.type === "page" && t.url && t.id && !cdpTabsMap.has(t.id)) {
|
|
2263
|
-
cdpTabsMap.set(t.id, t);
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
}
|
|
2267
|
-
} catch { /* endpoint not reachable */ }
|
|
2268
|
-
}
|
|
2269
|
-
const cdpTabs = [...cdpTabsMap.values()];
|
|
2270
|
-
|
|
2271
|
-
// Match CDP tabs with discovered browser candidates
|
|
2272
|
-
for (const candidate of candidates) {
|
|
2273
|
-
if (candidate.type !== "browser") continue;
|
|
2274
|
-
// For extension candidates, match by title instead of hostname
|
|
2275
|
-
const candidateUrl = String(candidate.url || "");
|
|
2276
|
-
if (candidateUrl.startsWith("chrome-extension://")) {
|
|
2277
|
-
const candidateTitle = String(candidate.title || "").toLowerCase();
|
|
2278
|
-
if (candidateTitle) {
|
|
2279
|
-
const matches = cdpTabs.filter(t =>
|
|
2280
|
-
String(t.url || "").startsWith("chrome-extension://") &&
|
|
2281
|
-
String(t.title || "").toLowerCase().includes(candidateTitle)
|
|
2282
|
-
);
|
|
2283
|
-
if (matches.length >= 1) {
|
|
2284
|
-
candidate.cdp_available = true;
|
|
2285
|
-
candidate.cdp_tab_id = matches[0].id;
|
|
2286
|
-
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
continue;
|
|
2290
|
-
}
|
|
2291
|
-
let candidateHost = "";
|
|
2292
|
-
try {
|
|
2293
|
-
candidateHost = new URL(candidate.url).hostname.toLowerCase();
|
|
2294
|
-
} catch { continue; }
|
|
2295
|
-
if (!candidateHost) continue;
|
|
2296
|
-
const matches = cdpTabs.filter(t => {
|
|
2297
|
-
try {
|
|
2298
|
-
return new URL(t.url).hostname.toLowerCase() === candidateHost;
|
|
2299
|
-
} catch { return false; }
|
|
2300
|
-
});
|
|
2301
|
-
if (matches.length >= 1) {
|
|
2302
|
-
candidate.cdp_available = true;
|
|
2303
|
-
candidate.cdp_tab_id = matches[0].id;
|
|
2304
|
-
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
// Auto-register well-known web LLMs that weren't already detected via browser scanning.
|
|
2309
|
-
// This ensures web speakers are ALWAYS available regardless of browser detection success.
|
|
2310
|
-
// If a browser tab for the same provider was already detected, skip auto-registration
|
|
2311
|
-
// to avoid duplicates (e.g., detected "web-chatgpt-1" vs auto-registered "web-chatgpt").
|
|
2312
|
-
const detectedProviders = new Set(
|
|
2313
|
-
candidates.filter(c => c.type === "browser" && !c.auto_registered).map(c => c.provider)
|
|
2314
|
-
);
|
|
2315
|
-
// CDP is reachable if we got any tabs from the endpoints (attach() handles auto-tab-creation)
|
|
2316
|
-
const cdpReachable = cdpTabs.length > 0 || cdpStatus.available;
|
|
2317
|
-
for (const ws of DEFAULT_WEB_SPEAKERS) {
|
|
2318
|
-
if (detectedProviders.has(ws.provider)) continue;
|
|
2319
|
-
add({
|
|
2320
|
-
speaker: ws.speaker,
|
|
2321
|
-
type: "browser",
|
|
2322
|
-
provider: ws.provider,
|
|
2323
|
-
browser: "auto-registered",
|
|
2324
|
-
title: ws.name,
|
|
2325
|
-
url: ws.url,
|
|
2326
|
-
auto_registered: true,
|
|
2327
|
-
cdp_available: cdpReachable,
|
|
2328
|
-
});
|
|
2329
|
-
}
|
|
2330
|
-
|
|
2331
|
-
// Second pass: match auto-registered speakers to individual CDP tabs
|
|
2332
|
-
// (they were added after the first matching pass and only got the global cdpReachable flag)
|
|
2333
|
-
if (cdpTabs.length > 0) {
|
|
2334
|
-
for (const candidate of candidates) {
|
|
2335
|
-
if (!candidate.auto_registered || candidate.cdp_tab_id) continue;
|
|
2336
|
-
let candidateHost = "";
|
|
2337
|
-
try {
|
|
2338
|
-
candidateHost = new URL(candidate.url).hostname.toLowerCase();
|
|
2339
|
-
} catch { continue; }
|
|
2340
|
-
if (!candidateHost) continue;
|
|
2341
|
-
const matches = cdpTabs.filter(t => {
|
|
2342
|
-
try {
|
|
2343
|
-
const tabHost = new URL(t.url).hostname.toLowerCase();
|
|
2344
|
-
// Exact match or subdomain match (e.g., chat.deepseek.com matches deepseek.com)
|
|
2345
|
-
return tabHost === candidateHost || tabHost.endsWith("." + candidateHost);
|
|
2346
|
-
} catch { return false; }
|
|
2347
|
-
});
|
|
2348
|
-
if (matches.length >= 1) {
|
|
2349
|
-
candidate.cdp_available = true;
|
|
2350
|
-
candidate.cdp_tab_id = matches[0].id;
|
|
2351
|
-
candidate.cdp_ws_url = matches[0].webSocketDebuggerUrl;
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
|
|
2356
|
-
// Third pass: upgrade browser-detected candidates that missed the first hostname match.
|
|
2357
|
-
// When CDP is reachable, AppleScript-detected speakers should also get browser_auto
|
|
2358
|
-
// transport. The OrchestratedBrowserPort will create/navigate tabs on demand if needed.
|
|
2359
|
-
if (cdpReachable) {
|
|
2360
|
-
for (const candidate of candidates) {
|
|
2361
|
-
if (candidate.type !== "browser" || candidate.auto_registered) continue;
|
|
2362
|
-
if (candidate.cdp_available) continue; // already matched
|
|
2363
|
-
candidate.cdp_available = true;
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
return { candidates, browserNote };
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
function formatSpeakerCandidatesReport({ candidates, browserNote }) {
|
|
2372
|
-
const cli = candidates.filter(c => c.type === "cli");
|
|
2373
|
-
const telepty = candidates.filter(c => c.type === "telepty");
|
|
2374
|
-
const detected = candidates.filter(c => c.type === "browser" && !c.auto_registered);
|
|
2375
|
-
const autoReg = candidates.filter(c => c.type === "browser" && c.auto_registered);
|
|
2376
|
-
|
|
2377
|
-
let out = "## Selectable Speakers\n\n";
|
|
2378
|
-
out += "### CLI\n";
|
|
2379
|
-
if (cli.length === 0) {
|
|
2380
|
-
out += "- (No local CLI detected)\n\n";
|
|
2381
|
-
} else {
|
|
2382
|
-
out += `${cli.map(c => {
|
|
2383
|
-
const status = c.live === false ? " ❌ not executable" : c.live === true ? " ✅ executable" : "";
|
|
2384
|
-
return `- \`${c.speaker}\` (command: ${c.command})${status}`;
|
|
2385
|
-
}).join("\n")}\n\n`;
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
out += "### Telepty Sessions\n";
|
|
2389
|
-
if (telepty.length === 0) {
|
|
2390
|
-
out += "- (No active telepty sessions)\n\n";
|
|
2391
|
-
} else {
|
|
2392
|
-
out += `${telepty.map(c => {
|
|
2393
|
-
const parts = [
|
|
2394
|
-
`command: ${c.command || "wrapped"}`,
|
|
2395
|
-
c.telepty_host ? `host: ${formatTeleptyHostLabel(c.telepty_host)}` : null,
|
|
2396
|
-
Number.isFinite(c.runtime_pid) ? `pid: ${c.runtime_pid}` : null,
|
|
2397
|
-
c.runtime_tty ? `tty: ${c.runtime_tty}` : null,
|
|
2398
|
-
].filter(Boolean).join(", ");
|
|
2399
|
-
const cwdLine = c.cwd ? `\n cwd: ${c.cwd}` : "";
|
|
2400
|
-
return `- \`${c.speaker}\` (${parts})${cwdLine}`;
|
|
2401
|
-
}).join("\n")}\n\n`;
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
out += "### Browser LLM (detected)\n";
|
|
2405
|
-
if (detected.length === 0) {
|
|
2406
|
-
out += "- (No LLM tabs detected in browser)\n";
|
|
2407
|
-
} else {
|
|
2408
|
-
out += `${detected.map(c => {
|
|
2409
|
-
const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
|
|
2410
|
-
const extTag = String(c.url || "").startsWith("chrome-extension://") ? " [Extension]" : "";
|
|
2411
|
-
return `- \`${c.speaker}\` [${icon}]${extTag} [${c.browser}] ${c.title}\n ${c.url}`;
|
|
2412
|
-
}).join("\n")}\n`;
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
out += "\n### Web LLM (auto-registered)\n";
|
|
2416
|
-
out += `${autoReg.map(c => {
|
|
2417
|
-
const icon = c.cdp_available ? "⚡auto" : "📋clipboard";
|
|
2418
|
-
return `- \`${c.speaker}\` [${icon}] — ${c.title} (${c.url})`;
|
|
2419
|
-
}).join("\n")}\n`;
|
|
2420
|
-
|
|
2421
|
-
if (browserNote) {
|
|
2422
|
-
out += `\n\nℹ️ ${browserNote}`;
|
|
2423
|
-
}
|
|
2424
|
-
return out;
|
|
2425
|
-
}
|
|
2426
|
-
|
|
2427
|
-
function mapParticipantProfiles(speakers, candidates, typeOverrides) {
|
|
2428
|
-
const bySpeaker = new Map();
|
|
2429
|
-
for (const c of candidates || []) {
|
|
2430
|
-
const key = normalizeSpeaker(c.speaker);
|
|
2431
|
-
if (key) bySpeaker.set(key, c);
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
|
-
const overrides = typeOverrides || {};
|
|
2435
|
-
|
|
2436
|
-
const profiles = [];
|
|
2437
|
-
for (const raw of speakers || []) {
|
|
2438
|
-
const speaker = normalizeSpeaker(raw);
|
|
2439
|
-
if (!speaker) continue;
|
|
2440
|
-
|
|
2441
|
-
// Check for explicit type override
|
|
2442
|
-
const overrideType = overrides[speaker] || overrides[raw];
|
|
2443
|
-
if (overrideType) {
|
|
2444
|
-
const candidate = bySpeaker.get(speaker);
|
|
2445
|
-
profiles.push({
|
|
2446
|
-
speaker,
|
|
2447
|
-
type: overrideType,
|
|
2448
|
-
...(overrideType === "browser_auto" || overrideType === "browser" ? {
|
|
2449
|
-
provider: candidate?.provider || null,
|
|
2450
|
-
browser: candidate?.browser || null,
|
|
2451
|
-
title: candidate?.title || null,
|
|
2452
|
-
url: candidate?.url || null,
|
|
2453
|
-
} : {}),
|
|
2454
|
-
});
|
|
2455
|
-
continue;
|
|
2456
|
-
}
|
|
2457
|
-
|
|
2458
|
-
const candidate = bySpeaker.get(speaker);
|
|
2459
|
-
if (!candidate) {
|
|
2460
|
-
// Force CLI type if the speaker is available as a CLI command in PATH
|
|
2461
|
-
if (commandExistsInPath(speaker)) {
|
|
2462
|
-
profiles.push({
|
|
2463
|
-
speaker,
|
|
2464
|
-
type: "cli",
|
|
2465
|
-
command: speaker,
|
|
2466
|
-
});
|
|
2467
|
-
} else {
|
|
2468
|
-
profiles.push({
|
|
2469
|
-
speaker,
|
|
2470
|
-
type: "manual",
|
|
2471
|
-
});
|
|
2472
|
-
}
|
|
2473
|
-
continue;
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
if (candidate.type === "cli") {
|
|
2477
|
-
profiles.push({
|
|
2478
|
-
speaker,
|
|
2479
|
-
type: "cli",
|
|
2480
|
-
command: candidate.command || speaker,
|
|
2481
|
-
});
|
|
2482
|
-
continue;
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
if (candidate.type === "telepty") {
|
|
2486
|
-
profiles.push({
|
|
2487
|
-
speaker,
|
|
2488
|
-
type: "telepty",
|
|
2489
|
-
command: candidate.command || null,
|
|
2490
|
-
telepty_session_id: candidate.telepty_session_id || speaker,
|
|
2491
|
-
telepty_host: candidate.telepty_host || null,
|
|
2492
|
-
runtime_pid: Number.isFinite(candidate.runtime_pid) ? candidate.runtime_pid : null,
|
|
2493
|
-
});
|
|
2494
|
-
continue;
|
|
2495
|
-
}
|
|
2496
|
-
|
|
2497
|
-
const effectiveType = candidate.cdp_available ? "browser_auto" : "browser";
|
|
2498
|
-
profiles.push({
|
|
2499
|
-
speaker,
|
|
2500
|
-
type: effectiveType,
|
|
2501
|
-
provider: candidate.provider || null,
|
|
2502
|
-
browser: candidate.browser || null,
|
|
2503
|
-
title: candidate.title || null,
|
|
2504
|
-
url: candidate.url || null,
|
|
2505
|
-
});
|
|
2506
|
-
}
|
|
2507
|
-
return profiles;
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
|
-
// ── Transport routing ─────────────────────────────────────────
|
|
2511
|
-
|
|
2512
|
-
const TRANSPORT_TYPES = {
|
|
2513
|
-
cli: "cli_respond",
|
|
2514
|
-
telepty: "telepty_bus",
|
|
2515
|
-
browser: "clipboard",
|
|
2516
|
-
browser_auto: "browser_auto",
|
|
2517
|
-
manual: "manual",
|
|
2518
|
-
};
|
|
2519
|
-
|
|
2520
|
-
// BrowserControlPort singleton — initialized lazily on first use
|
|
2521
|
-
let _browserPort = null;
|
|
2522
|
-
function getBrowserPort() {
|
|
2523
|
-
if (!_browserPort) {
|
|
2524
|
-
const cdpEndpoints = resolveCdpEndpoints();
|
|
2525
|
-
_browserPort = new OrchestratedBrowserPort({ cdpEndpoints });
|
|
2526
|
-
}
|
|
2527
|
-
return _browserPort;
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
function resolveTransportForSpeaker(state, speaker) {
|
|
2531
|
-
const normalizedSpeaker = normalizeSpeaker(speaker);
|
|
2532
|
-
if (!normalizedSpeaker || !state?.participant_profiles) {
|
|
2533
|
-
return { transport: "manual", reason: "no_profile" };
|
|
2534
|
-
}
|
|
2535
|
-
const profile = state.participant_profiles.find(
|
|
2536
|
-
p => normalizeSpeaker(p.speaker) === normalizedSpeaker
|
|
2537
|
-
);
|
|
2538
|
-
if (!profile) {
|
|
2539
|
-
return { transport: "manual", reason: "speaker_not_in_profiles" };
|
|
2540
|
-
}
|
|
2541
|
-
const transport = TRANSPORT_TYPES[profile.type] || "manual";
|
|
2542
|
-
return { transport, profile, reason: null };
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
// CLI-specific invocation flags for non-interactive execution
|
|
2546
|
-
const CLI_INVOCATION_HINTS = {
|
|
2547
|
-
claude: { cmd: "claude", flags: '-p --output-format text', example: 'CLAUDECODE= claude -p --output-format text "prompt"', envPrefix: 'CLAUDECODE=', modelFlag: '--model', provider: 'claude' },
|
|
2548
|
-
codex: { cmd: "codex", flags: 'exec -', example: 'echo "prompt" | codex exec -', stdinMode: true, modelFlag: '--model', defaultModel: 'default', provider: 'chatgpt' },
|
|
2549
|
-
gemini: { cmd: "gemini", flags: '', example: 'gemini "prompt"', modelFlag: '--model', provider: 'gemini' },
|
|
2550
|
-
aider: { cmd: "aider", flags: '--message', example: 'aider --message "prompt"', modelFlag: '--model', provider: 'chatgpt' },
|
|
2551
|
-
cursor: { cmd: "cursor", flags: '', example: 'cursor "prompt"', modelFlag: null, provider: 'chatgpt' },
|
|
2552
|
-
};
|
|
2553
|
-
|
|
2554
|
-
function formatTransportGuidance(transport, state, speaker) {
|
|
2555
|
-
const sid = state.id;
|
|
2556
|
-
const profile = (state.participant_profiles || []).find(
|
|
2557
|
-
p => normalizeSpeaker(p.speaker) === normalizeSpeaker(speaker)
|
|
2558
|
-
) || null;
|
|
2559
|
-
switch (transport) {
|
|
2560
|
-
case "cli_respond": {
|
|
2561
|
-
const hint = CLI_INVOCATION_HINTS[speaker] || null;
|
|
2562
|
-
let invocationGuide = "";
|
|
2563
|
-
let modelGuide = "";
|
|
2564
|
-
if (hint) {
|
|
2565
|
-
const prefix = hint.envPrefix || '';
|
|
2566
|
-
invocationGuide = `\n\n**CLI invocation:** \`${hint.example}\`\n(flags: \`${prefix}${hint.cmd} ${hint.flags}\`)`;
|
|
2567
|
-
if (hint.modelFlag && hint.provider) {
|
|
2568
|
-
const cliModel = getModelSelectionForTurn(state, speaker, hint.provider);
|
|
2569
|
-
if (cliModel.model !== 'default') {
|
|
2570
|
-
modelGuide = `\n**Recommended model:** ${cliModel.model} (${cliModel.reason})\n**Model flag:** \`${hint.modelFlag} ${cliModel.model}\``;
|
|
2571
|
-
}
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
return `CLI speaker. Respond directly via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.${invocationGuide}${modelGuide}\n\n⛔ **No API calls**: Do not call LLM APIs directly via REST API, HTTP requests, urllib, requests, etc. Only use the CLI tools above.`;
|
|
2575
|
-
}
|
|
2576
|
-
case "clipboard":
|
|
2577
|
-
return `Browser LLM speaker. Copy the prompt below and paste it into the browser LLM using **Cmd+V (ㅍ)**, then submit the response via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", use_clipboard: true)\` after copying the LLM's response with **Cmd+C (ㅊ)**.\n\n` +
|
|
2578
|
-
`📋 **Prompt has been copied to your clipboard.** (If not, copy the [turn_prompt] section below manually).\n` +
|
|
2579
|
-
`🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
|
|
2580
|
-
`⛔ **No API calls**: This speaker responds only via web browser. Do not call LLMs via REST API or HTTP requests.`;
|
|
2581
|
-
case "browser_auto":
|
|
2582
|
-
return `Auto browser speaker. Proceed automatically with \`deliberation_browser_auto_turn(session_id: "${sid}")\`. Inputs directly to browser LLM via CDP and reads responses.\n\n⛔ **No API calls**: Proceeds only via CDP automation. No REST API or HTTP requests.`;
|
|
2583
|
-
case "telepty_bus":
|
|
2584
|
-
return `Telepty session speaker. This turn will be published on the telepty bus as a structured \`turn_request\` envelope for the target session to consume.\n\n` +
|
|
2585
|
-
`📡 **Bus delivery**: deliberation publishes a typed envelope instead of relying on raw PTY inject.\n` +
|
|
2586
|
-
`⏱️ **Timeouts**: transport ack waits ${TELEPTY_TRANSPORT_TIMEOUT_MS / 1000}s, semantic self-submit waits ${TELEPTY_SEMANTIC_TIMEOUT_MS / 1000}s.\n` +
|
|
2587
|
-
`⛔ **No proxy response**: the remote telepty session must answer for itself via \`deliberation_respond(...)\`.`;
|
|
2588
|
-
case "manual":
|
|
2589
|
-
default:
|
|
2590
|
-
if (profile?.type === "telepty" && profile.telepty_session_id) {
|
|
2591
|
-
const hostSuffix = profile.telepty_host && !["127.0.0.1", "localhost"].includes(profile.telepty_host)
|
|
2592
|
-
? `@${profile.telepty_host}`
|
|
2593
|
-
: "";
|
|
2594
|
-
const pidNote = Number.isFinite(profile.runtime_pid) ? ` (pid ${profile.runtime_pid})` : "";
|
|
2595
|
-
return `Telepty-managed session speaker${pidNote}. Send the [turn_prompt] below to \`telepty inject ${profile.telepty_session_id}${hostSuffix} "<prompt>"\`, then have that remote session self-submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
|
|
2596
|
-
`📋 **Recommended path**: inject the prompt into the telepty session and let the remote session answer for itself.\n` +
|
|
2597
|
-
`⛔ **No proxy response**: Do not answer on behalf of this speaker from the orchestrator.`;
|
|
2598
|
-
}
|
|
2599
|
-
return `Manual speaker. Get a response from the LLM's **web UI or CLI tool** and submit via \`deliberation_respond(session_id: "${sid}", speaker: "${speaker}", content: "...")\`.\n\n` +
|
|
2600
|
-
`📋 **Copy the [turn_prompt] section below** to the web UI.\n` +
|
|
2601
|
-
`🖼️ **To include an image:** Copy the image to your clipboard and use \`include_clipboard_image: true\` in \`deliberation_respond\`.\n\n` +
|
|
2602
|
-
`⛔ **Absolutely no API calls**: Calling LLM APIs directly via REST API, HTTP requests (urllib, requests, fetch, etc.) is forbidden. Only use web browser UI or CLI tools. Direct API key calls will result in deliberation participation being rejected.`;
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
|
|
2606
|
-
function buildSpeakerOrder(speakers, fallbackSpeaker = DEFAULT_SPEAKERS[0], fallbackPlacement = "front") {
|
|
2607
|
-
const ordered = [];
|
|
2608
|
-
const seen = new Set();
|
|
2609
|
-
|
|
2610
|
-
const add = (candidate) => {
|
|
2611
|
-
const speaker = normalizeSpeaker(candidate);
|
|
2612
|
-
if (!speaker || seen.has(speaker)) return;
|
|
2613
|
-
seen.add(speaker);
|
|
2614
|
-
ordered.push(speaker);
|
|
2615
|
-
};
|
|
2616
|
-
|
|
2617
|
-
if (fallbackPlacement === "front") {
|
|
2618
|
-
add(fallbackSpeaker);
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
if (Array.isArray(speakers)) {
|
|
2622
|
-
for (const speaker of speakers) {
|
|
2623
|
-
add(speaker);
|
|
2624
|
-
}
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
if (fallbackPlacement !== "front") {
|
|
2628
|
-
add(fallbackSpeaker);
|
|
2629
|
-
}
|
|
2630
|
-
|
|
2631
|
-
if (ordered.length === 0) {
|
|
2632
|
-
for (const speaker of DEFAULT_SPEAKERS) {
|
|
2633
|
-
add(speaker);
|
|
2634
|
-
}
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
return ordered;
|
|
2638
|
-
}
|
|
2639
|
-
|
|
2640
|
-
function normalizeSessionActors(state) {
|
|
2641
|
-
if (!state || typeof state !== "object") return state;
|
|
2642
|
-
|
|
2643
|
-
const fallbackSpeaker = normalizeSpeaker(state.current_speaker)
|
|
2644
|
-
|| normalizeSpeaker(state.log?.[0]?.speaker)
|
|
2645
|
-
|| DEFAULT_SPEAKERS[0];
|
|
2646
|
-
const speakers = buildSpeakerOrder(state.speakers, fallbackSpeaker, "end");
|
|
2647
|
-
state.speakers = speakers;
|
|
2648
|
-
|
|
2649
|
-
const normalizedCurrent = normalizeSpeaker(state.current_speaker);
|
|
2650
|
-
if (state.status === "active") {
|
|
2651
|
-
state.current_speaker = (normalizedCurrent && speakers.includes(normalizedCurrent))
|
|
2652
|
-
? normalizedCurrent
|
|
2653
|
-
: speakers[0];
|
|
2654
|
-
} else if (normalizedCurrent) {
|
|
2655
|
-
state.current_speaker = normalizedCurrent;
|
|
2656
|
-
}
|
|
2657
|
-
|
|
2658
|
-
return state;
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
// ── Session ID generation ─────────────────────────────────────
|
|
2662
|
-
|
|
2663
|
-
function generateSessionId(topic) {
|
|
2664
|
-
const slug = topic
|
|
2665
|
-
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
2666
|
-
.replace(/\s+/g, "-")
|
|
2667
|
-
.toLowerCase()
|
|
2668
|
-
.slice(0, 20);
|
|
2669
|
-
const ts = Date.now().toString(36);
|
|
2670
|
-
const rand = Math.random().toString(36).slice(2, 6);
|
|
2671
|
-
return `${slug}-${ts}${rand}`;
|
|
2672
|
-
}
|
|
2673
|
-
|
|
2674
|
-
function generateTurnId() {
|
|
2675
|
-
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
2676
|
-
}
|
|
2677
|
-
|
|
2678
|
-
// ── Context detection ──────────────────────────────────────────
|
|
2679
|
-
|
|
2680
|
-
function detectContextDirs() {
|
|
2681
|
-
const dirs = [];
|
|
2682
|
-
const slug = getProjectSlug();
|
|
2683
|
-
|
|
2684
|
-
if (process.env.DELIBERATION_CONTEXT_DIR) {
|
|
2685
|
-
dirs.push(process.env.DELIBERATION_CONTEXT_DIR);
|
|
2686
|
-
}
|
|
2687
|
-
dirs.push(process.cwd());
|
|
2688
|
-
|
|
2689
|
-
const obsidianProject = path.join(OBSIDIAN_PROJECTS, slug);
|
|
2690
|
-
if (fs.existsSync(obsidianProject)) {
|
|
2691
|
-
dirs.push(obsidianProject);
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2694
|
-
return [...new Set(dirs)];
|
|
2695
|
-
}
|
|
2696
|
-
|
|
2697
|
-
function readContextFromDirs(dirs, maxChars = 15000) {
|
|
2698
|
-
let context = "";
|
|
2699
|
-
const seen = new Set();
|
|
2700
|
-
|
|
2701
|
-
for (const dir of dirs) {
|
|
2702
|
-
if (!fs.existsSync(dir)) continue;
|
|
2703
|
-
|
|
2704
|
-
const files = fs.readdirSync(dir)
|
|
2705
|
-
.filter(f => f.endsWith(".md") && !f.startsWith("_") && !f.startsWith("."))
|
|
2706
|
-
.sort();
|
|
2707
|
-
|
|
2708
|
-
for (const file of files) {
|
|
2709
|
-
if (seen.has(file)) continue;
|
|
2710
|
-
seen.add(file);
|
|
2711
|
-
|
|
2712
|
-
const fullPath = path.join(dir, file);
|
|
2713
|
-
let raw;
|
|
2714
|
-
try { raw = fs.readFileSync(fullPath, "utf-8"); } catch { continue; }
|
|
2715
|
-
|
|
2716
|
-
let body = raw;
|
|
2717
|
-
if (body.startsWith("---")) {
|
|
2718
|
-
const end = body.indexOf("---", 3);
|
|
2719
|
-
if (end !== -1) body = body.slice(end + 3).trim();
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
|
-
const truncated = body.length > 1200
|
|
2723
|
-
? body.slice(0, 1200) + "\n(...)"
|
|
2724
|
-
: body;
|
|
2725
|
-
|
|
2726
|
-
context += `### ${file.replace(".md", "")}\n${truncated}\n\n---\n\n`;
|
|
2727
|
-
|
|
2728
|
-
if (context.length > maxChars) {
|
|
2729
|
-
context = context.slice(0, maxChars) + "\n\n(...context truncated)";
|
|
2730
|
-
return context;
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
}
|
|
2734
|
-
return context || "(No context files found)";
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
// ── State helpers ──────────────────────────────────────────────
|
|
2738
|
-
|
|
2739
|
-
function ensureDirs(projectSlug = getProjectSlug()) {
|
|
2740
|
-
fs.mkdirSync(getSessionsDir(projectSlug), { recursive: true });
|
|
2741
|
-
fs.mkdirSync(getArchiveDir(projectSlug), { recursive: true });
|
|
2742
|
-
fs.mkdirSync(getLocksDir(projectSlug), { recursive: true });
|
|
2743
|
-
}
|
|
2744
|
-
|
|
2745
|
-
function loadSession(sessionRef) {
|
|
2746
|
-
const record = findSessionRecord(sessionRef);
|
|
2747
|
-
return record?.state || null;
|
|
2748
|
-
}
|
|
2749
|
-
|
|
2750
|
-
function saveSession(state) {
|
|
2751
|
-
ensureDirs(state.project);
|
|
2752
|
-
state.updated = new Date().toISOString();
|
|
2753
|
-
writeTextAtomic(getSessionFile(state), JSON.stringify(state, null, 2));
|
|
2754
|
-
syncMarkdown(state);
|
|
2755
|
-
}
|
|
2756
|
-
|
|
2757
|
-
function listActiveSessions(projectSlug) {
|
|
2758
|
-
const projects = projectSlug
|
|
2759
|
-
? [normalizeProjectSlug(projectSlug)]
|
|
2760
|
-
: [...new Set([getProjectSlug(), ...listStateProjects()])];
|
|
2761
|
-
|
|
2762
|
-
return projects.flatMap(project => {
|
|
2763
|
-
const dir = getSessionsDir(project);
|
|
2764
|
-
if (!fs.existsSync(dir)) return [];
|
|
2765
|
-
|
|
2766
|
-
return fs.readdirSync(dir)
|
|
2767
|
-
.filter(f => f.endsWith(".json"))
|
|
2768
|
-
.map(f => {
|
|
2769
|
-
try {
|
|
2770
|
-
const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
|
|
2771
|
-
return normalizeSessionActors(data);
|
|
2772
|
-
} catch {
|
|
2773
|
-
return null;
|
|
2774
|
-
}
|
|
2775
|
-
})
|
|
2776
|
-
.filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
|
|
2777
|
-
});
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
function resolveSessionId(sessionId) {
|
|
2781
|
-
// Use session_id directly if provided
|
|
2782
|
-
if (sessionId) return sessionId;
|
|
2783
|
-
|
|
2784
|
-
// Auto-select when only one active session
|
|
2785
|
-
const active = listActiveSessions();
|
|
2786
|
-
if (active.length === 0) return null;
|
|
2787
|
-
if (active.length === 1) return active[0].id;
|
|
2788
|
-
|
|
2789
|
-
// null if multiple (need to show list)
|
|
2790
|
-
return "MULTIPLE";
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
function syncMarkdown(state) {
|
|
2794
|
-
const filename = `deliberation-${state.id}.md`;
|
|
2795
|
-
const mdPath = path.join(getProjectStateDir(state.project), filename);
|
|
2796
|
-
try {
|
|
2797
|
-
writeTextAtomic(mdPath, stateToMarkdown(state));
|
|
2798
|
-
} catch { /* ignore sync failures */ }
|
|
2799
|
-
}
|
|
2800
|
-
|
|
2801
|
-
function cleanupSyncMarkdown(state) {
|
|
2802
|
-
const filename = `deliberation-${state.id}.md`;
|
|
2803
|
-
const statePath = path.join(getProjectStateDir(state.project), filename);
|
|
2804
|
-
try { fs.unlinkSync(statePath); } catch { /* ignore */ }
|
|
2805
|
-
// Also clean up legacy files in CWD (from older versions)
|
|
2806
|
-
const cwdPath = path.join(process.cwd(), filename);
|
|
2807
|
-
try { fs.unlinkSync(cwdPath); } catch { /* ignore */ }
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
function formatSourceMetadataLine(meta) {
|
|
2811
|
-
if (!meta || typeof meta !== "object") return "";
|
|
2812
|
-
const parts = [];
|
|
2813
|
-
if (meta.source_machine_id) parts.push(`machine: ${meta.source_machine_id}`);
|
|
2814
|
-
if (meta.source_session_id) parts.push(`session: ${meta.source_session_id}`);
|
|
2815
|
-
if (meta.transport_scope) parts.push(`transport: ${meta.transport_scope}`);
|
|
2816
|
-
if (meta.reply_origin) parts.push(`origin: ${meta.reply_origin}`);
|
|
2817
|
-
if (meta.timestamp) parts.push(`timestamp: ${meta.timestamp}`);
|
|
2818
|
-
if (Array.isArray(meta.artifact_refs) && meta.artifact_refs.length > 0) {
|
|
2819
|
-
parts.push(`artifacts: ${meta.artifact_refs.join(", ")}`);
|
|
2820
|
-
}
|
|
2821
|
-
return parts.length > 0 ? `> _source: ${parts.join(" | ")}_\n\n` : "";
|
|
2822
|
-
}
|
|
2823
|
-
|
|
2824
|
-
function stateToMarkdown(s) {
|
|
2825
|
-
const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
|
|
2826
|
-
let md = `---
|
|
2827
|
-
title: "Deliberation - ${s.topic}"
|
|
2828
|
-
session_id: "${s.id}"
|
|
2829
|
-
created: ${s.created}
|
|
2830
|
-
updated: ${s.updated || new Date().toISOString()}
|
|
2831
|
-
type: deliberation
|
|
2832
|
-
status: ${s.status}
|
|
2833
|
-
project: "${s.project}"
|
|
2834
|
-
participants: ${JSON.stringify(speakerOrder)}
|
|
2835
|
-
rounds: ${s.max_rounds}
|
|
2836
|
-
current_round: ${s.current_round}
|
|
2837
|
-
current_speaker: "${s.current_speaker}"
|
|
2838
|
-
tags: [deliberation]
|
|
2839
|
-
---
|
|
2840
|
-
|
|
2841
|
-
# Deliberation: ${s.topic}
|
|
2842
|
-
|
|
2843
|
-
**Session:** ${s.id} | **Project:** ${s.project} | **Status:** ${s.status} | **Round:** ${s.current_round}/${s.max_rounds} | **Next:** ${s.current_speaker}
|
|
2844
|
-
|
|
2845
|
-
---
|
|
2846
|
-
|
|
2847
|
-
`;
|
|
2848
|
-
|
|
2849
|
-
if (s.synthesis) {
|
|
2850
|
-
md += `## Synthesis\n\n${s.synthesis}\n\n---\n\n`;
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
if (s.structured_synthesis) {
|
|
2854
|
-
md += `## Structured Synthesis\n\n\`\`\`json\n${JSON.stringify(s.structured_synthesis, null, 2)}\n\`\`\`\n\n---\n\n`;
|
|
2855
|
-
}
|
|
2856
|
-
|
|
2857
|
-
if (s.execution_contract) {
|
|
2858
|
-
md += `## Execution Contract\n\n\`\`\`json\n${JSON.stringify(s.execution_contract, null, 2)}\n\`\`\`\n\n---\n\n`;
|
|
2859
|
-
}
|
|
2860
|
-
|
|
2861
|
-
md += `## Debate Log\n\n`;
|
|
2862
|
-
for (const entry of s.log) {
|
|
2863
|
-
md += `### ${entry.speaker} — Round ${entry.round}\n\n`;
|
|
2864
|
-
if (entry.channel_used || entry.fallback_reason) {
|
|
2865
|
-
const parts = [];
|
|
2866
|
-
if (entry.channel_used) parts.push(`channel: ${entry.channel_used}`);
|
|
2867
|
-
if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
|
|
2868
|
-
md += `> _${parts.join(" | ")}_\n\n`;
|
|
2869
|
-
}
|
|
2870
|
-
md += formatSourceMetadataLine(entry.source_metadata);
|
|
2871
|
-
md += `${entry.content}\n\n`;
|
|
2872
|
-
if (entry.attachments && entry.attachments.length > 0) {
|
|
2873
|
-
for (const att of entry.attachments) {
|
|
2874
|
-
if (att.type === "image") {
|
|
2875
|
-
md += `\n\n`;
|
|
2876
|
-
}
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
md += `---\n\n`;
|
|
2880
|
-
}
|
|
2881
|
-
return md;
|
|
2882
|
-
}
|
|
2883
|
-
|
|
2884
|
-
function archiveState(state) {
|
|
2885
|
-
ensureDirs(state.project);
|
|
2886
|
-
const slug = state.topic
|
|
2887
|
-
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
2888
|
-
.replace(/\s+/g, "-")
|
|
2889
|
-
.slice(0, 30);
|
|
2890
|
-
const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
|
|
2891
|
-
const filename = `deliberation-${ts}-${slug}.md`;
|
|
2892
|
-
const dest = path.join(getArchiveDir(state.project), filename);
|
|
2893
|
-
writeTextAtomic(dest, stateToMarkdown(state));
|
|
2894
|
-
|
|
2895
|
-
// Write machine-readable execution_contract sidecar for automation consumers
|
|
2896
|
-
if (state.execution_contract) {
|
|
2897
|
-
const contractDest = dest.replace(/\.md$/, ".contract.json");
|
|
2898
|
-
writeTextAtomic(contractDest, JSON.stringify({
|
|
2899
|
-
...state.execution_contract,
|
|
2900
|
-
_meta: {
|
|
2901
|
-
archived_from: state.id,
|
|
2902
|
-
project: state.project,
|
|
2903
|
-
topic: state.topic,
|
|
2904
|
-
archived_at: new Date().toISOString(),
|
|
2905
|
-
},
|
|
2906
|
-
}, null, 2));
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
return dest;
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
// ── Terminal management ────────────────────────────────────────
|
|
2913
|
-
|
|
2914
|
-
const TMUX_SESSION = "deliberation";
|
|
2915
|
-
const MONITOR_SCRIPT = path.join(INSTALL_DIR, "session-monitor.sh");
|
|
2916
|
-
const MONITOR_SCRIPT_WIN = path.join(INSTALL_DIR, "session-monitor-win.js");
|
|
2917
|
-
|
|
2918
|
-
function tmuxWindowName(sessionId) {
|
|
2919
|
-
// Keep tmux window name short (remove last part, 20 chars)
|
|
2920
|
-
return sessionId.replace(/[^a-zA-Z0-9가-힣-]/g, "").slice(0, 25);
|
|
2921
|
-
}
|
|
2922
|
-
|
|
2923
|
-
function appleScriptQuote(value) {
|
|
2924
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
2925
|
-
}
|
|
2926
|
-
|
|
2927
|
-
function tryExecFile(command, args = []) {
|
|
2928
|
-
try {
|
|
2929
|
-
execFileSync(command, args, { stdio: "ignore", windowsHide: true });
|
|
2930
|
-
return true;
|
|
2931
|
-
} catch {
|
|
2932
|
-
return false;
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
function resolveMonitorShell() {
|
|
2937
|
-
if (commandExistsInPath("bash")) return "bash";
|
|
2938
|
-
if (commandExistsInPath("sh")) return "sh";
|
|
2939
|
-
return null;
|
|
2940
|
-
}
|
|
2941
|
-
|
|
2942
|
-
function buildMonitorCommand(sessionId, project) {
|
|
2943
|
-
const shell = resolveMonitorShell();
|
|
2944
|
-
if (!shell) return null;
|
|
2945
|
-
return `${shell} ${shellQuote(MONITOR_SCRIPT)} ${shellQuote(sessionId)} ${shellQuote(project)}`;
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
|
-
function buildMonitorCommandWindows(sessionId, project) {
|
|
2949
|
-
return `node "${MONITOR_SCRIPT_WIN}" "${sessionId}" "${project}"`;
|
|
2950
|
-
}
|
|
2951
|
-
|
|
2952
|
-
function hasTmuxSession(name) {
|
|
2953
|
-
try {
|
|
2954
|
-
execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore", windowsHide: true });
|
|
2955
|
-
return true;
|
|
2956
|
-
} catch {
|
|
2957
|
-
return false;
|
|
2958
|
-
}
|
|
2959
|
-
}
|
|
2960
|
-
|
|
2961
|
-
function hasTmuxWindow(sessionName, windowName) {
|
|
2962
|
-
try {
|
|
2963
|
-
const output = execFileSync("tmux", ["list-windows", "-t", sessionName, "-F", "#{window_name}"], {
|
|
2964
|
-
encoding: "utf-8",
|
|
2965
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2966
|
-
windowsHide: true,
|
|
2967
|
-
});
|
|
2968
|
-
return String(output).split("\n").map(s => s.trim()).includes(windowName);
|
|
2969
|
-
} catch {
|
|
2970
|
-
return false;
|
|
2971
|
-
}
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
function tmuxHasAttachedClients(sessionName) {
|
|
2975
|
-
try {
|
|
2976
|
-
const output = execFileSync("tmux", ["list-clients", "-t", sessionName], {
|
|
2977
|
-
encoding: "utf-8",
|
|
2978
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2979
|
-
windowsHide: true,
|
|
2980
|
-
});
|
|
2981
|
-
return String(output).trim().split("\n").filter(Boolean).length > 0;
|
|
2982
|
-
} catch {
|
|
2983
|
-
return false;
|
|
2984
|
-
}
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2987
|
-
function isTmuxWindowViewed(sessionName, windowName) {
|
|
2988
|
-
try {
|
|
2989
|
-
// List all clients and check for matching window name.
|
|
2990
|
-
// Grouped sessions (created via 'new-session -t') share the same windows,
|
|
2991
|
-
// so checking for the window name anywhere in the client list is sufficient.
|
|
2992
|
-
const output = execFileSync("tmux", ["list-clients", "-F", "#{window_name}"], {
|
|
2993
|
-
encoding: "utf-8",
|
|
2994
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2995
|
-
windowsHide: true,
|
|
2996
|
-
});
|
|
2997
|
-
return String(output).split("\n").map(s => s.trim()).filter(Boolean).includes(windowName);
|
|
2998
|
-
} catch {
|
|
2999
|
-
return false;
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
|
|
3003
|
-
function tmuxWindowCount(name) {
|
|
3004
|
-
try {
|
|
3005
|
-
const output = execFileSync("tmux", ["list-windows", "-t", name], {
|
|
3006
|
-
encoding: "utf-8",
|
|
3007
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
3008
|
-
windowsHide: true,
|
|
3009
|
-
});
|
|
3010
|
-
return String(output)
|
|
3011
|
-
.split("\n")
|
|
3012
|
-
.map(line => line.trim())
|
|
3013
|
-
.filter(Boolean)
|
|
3014
|
-
.length;
|
|
3015
|
-
} catch {
|
|
3016
|
-
return 0;
|
|
3017
|
-
}
|
|
3018
|
-
}
|
|
3019
|
-
|
|
3020
|
-
function buildTmuxAttachCommand(sessionId) {
|
|
3021
|
-
const winName = tmuxWindowName(sessionId);
|
|
3022
|
-
// Use grouped session (new-session -t) so each terminal has independent active window.
|
|
3023
|
-
// This prevents window-switching conflicts when multiple deliberations run concurrently.
|
|
3024
|
-
return `tmux new-session -t ${shellQuote(TMUX_SESSION)} \\; select-window -t ${shellQuote(`${TMUX_SESSION}:${winName}`)}`;
|
|
3025
|
-
}
|
|
3026
|
-
|
|
3027
|
-
function listPhysicalTerminalWindowIds() {
|
|
3028
|
-
if (process.platform !== "darwin") {
|
|
3029
|
-
return [];
|
|
3030
|
-
}
|
|
3031
|
-
try {
|
|
3032
|
-
const output = execFileSync(
|
|
3033
|
-
"osascript",
|
|
3034
|
-
[
|
|
3035
|
-
"-e",
|
|
3036
|
-
'tell application "Terminal"',
|
|
3037
|
-
"-e",
|
|
3038
|
-
"if not running then return \"\"",
|
|
3039
|
-
"-e",
|
|
3040
|
-
"set outText to \"\"",
|
|
3041
|
-
"-e",
|
|
3042
|
-
"repeat with w in windows",
|
|
3043
|
-
"-e",
|
|
3044
|
-
"set outText to outText & (id of w as string) & linefeed",
|
|
3045
|
-
"-e",
|
|
3046
|
-
"end repeat",
|
|
3047
|
-
"-e",
|
|
3048
|
-
"return outText",
|
|
3049
|
-
"-e",
|
|
3050
|
-
"end tell",
|
|
3051
|
-
],
|
|
3052
|
-
{ encoding: "utf-8" }
|
|
3053
|
-
);
|
|
3054
|
-
return String(output)
|
|
3055
|
-
.split("\n")
|
|
3056
|
-
.map(s => Number.parseInt(s.trim(), 10))
|
|
3057
|
-
.filter(n => Number.isInteger(n) && n > 0);
|
|
3058
|
-
} catch {
|
|
3059
|
-
return [];
|
|
3060
|
-
}
|
|
3061
|
-
}
|
|
3062
|
-
|
|
3063
|
-
function openPhysicalTerminal(sessionId) {
|
|
3064
|
-
const winName = tmuxWindowName(sessionId);
|
|
3065
|
-
// Use grouped session (new-session -t) for independent active window per client
|
|
3066
|
-
const attachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
3067
|
-
|
|
3068
|
-
// Prevent duplicate windows for the SAME session:
|
|
3069
|
-
// If a client is already viewing this specific window, just activate Terminal.app
|
|
3070
|
-
if (isTmuxWindowViewed(TMUX_SESSION, winName)) {
|
|
3071
|
-
appendRuntimeLog("INFO", `TMUX_WINDOW_ALREADY_VIEWED: ${winName}. Activating existing Terminal.`);
|
|
3072
|
-
if (process.platform === "darwin") {
|
|
3073
|
-
try {
|
|
3074
|
-
execFileSync("osascript", ["-e", 'tell application "Terminal" to activate'], { stdio: "ignore" });
|
|
3075
|
-
} catch { /* ignore */ }
|
|
3076
|
-
}
|
|
3077
|
-
return { opened: true, windowIds: [] };
|
|
3078
|
-
}
|
|
3079
|
-
|
|
3080
|
-
// If a terminal is already attached to OTHER windows, open a NEW grouped session
|
|
3081
|
-
// instead of select-window (which would hijack all attached clients' views).
|
|
3082
|
-
if (tmuxHasAttachedClients(TMUX_SESSION)) {
|
|
3083
|
-
if (process.platform === "darwin") {
|
|
3084
|
-
const groupAttachCmd = `tmux new-session -t "${TMUX_SESSION}" \\; select-window -t "${TMUX_SESSION}:${winName}"`;
|
|
3085
|
-
try {
|
|
3086
|
-
execFileSync(
|
|
3087
|
-
"osascript",
|
|
3088
|
-
[
|
|
3089
|
-
"-e", 'tell application "Terminal"',
|
|
3090
|
-
"-e", "activate",
|
|
3091
|
-
"-e", `do script ${appleScriptQuote(groupAttachCmd)}`,
|
|
3092
|
-
"-e", "end tell",
|
|
3093
|
-
],
|
|
3094
|
-
{ encoding: "utf-8" }
|
|
3095
|
-
);
|
|
3096
|
-
return { opened: true, windowIds: [] };
|
|
3097
|
-
} catch { /* fall through to default behavior */ }
|
|
3098
|
-
}
|
|
3099
|
-
// Non-macOS or fallback: don't force select-window, just report success
|
|
3100
|
-
// The monitor window already exists in tmux; user can switch manually
|
|
3101
|
-
return { opened: true, windowIds: [] };
|
|
3102
|
-
}
|
|
3103
|
-
|
|
3104
|
-
if (process.platform === "darwin") {
|
|
3105
|
-
const before = new Set(listPhysicalTerminalWindowIds());
|
|
3106
|
-
try {
|
|
3107
|
-
const output = execFileSync(
|
|
3108
|
-
"osascript",
|
|
3109
|
-
[
|
|
3110
|
-
"-e",
|
|
3111
|
-
'tell application "Terminal"',
|
|
3112
|
-
"-e",
|
|
3113
|
-
"activate",
|
|
3114
|
-
"-e",
|
|
3115
|
-
`do script ${appleScriptQuote(attachCmd)}`,
|
|
3116
|
-
"-e",
|
|
3117
|
-
"delay 0.15",
|
|
3118
|
-
"-e",
|
|
3119
|
-
"return id of front window",
|
|
3120
|
-
"-e",
|
|
3121
|
-
"end tell",
|
|
3122
|
-
],
|
|
3123
|
-
{ encoding: "utf-8" }
|
|
3124
|
-
);
|
|
3125
|
-
const frontId = Number.parseInt(String(output).trim(), 10);
|
|
3126
|
-
const after = listPhysicalTerminalWindowIds();
|
|
3127
|
-
const opened = after.filter(id => !before.has(id));
|
|
3128
|
-
if (opened.length > 0) {
|
|
3129
|
-
return { opened: true, windowIds: [...new Set(opened)] };
|
|
3130
|
-
}
|
|
3131
|
-
if (Number.isInteger(frontId) && frontId > 0) {
|
|
3132
|
-
return { opened: true, windowIds: [frontId] };
|
|
3133
|
-
}
|
|
3134
|
-
return { opened: false, windowIds: [] };
|
|
3135
|
-
} catch {
|
|
3136
|
-
return { opened: false, windowIds: [] };
|
|
3137
|
-
}
|
|
3138
|
-
}
|
|
3139
|
-
|
|
3140
|
-
if (process.platform === "linux") {
|
|
3141
|
-
const shell = resolveMonitorShell() || "sh";
|
|
3142
|
-
const launchCmd = `${buildTmuxAttachCommand(sessionId)}; exec ${shell}`;
|
|
3143
|
-
const attempts = [
|
|
3144
|
-
["gnome-terminal", ["--", shell, "-lc", launchCmd]],
|
|
3145
|
-
["kgx", ["--", shell, "-lc", launchCmd]],
|
|
3146
|
-
["konsole", ["-e", shell, "-lc", launchCmd]],
|
|
3147
|
-
["x-terminal-emulator", ["-e", shell, "-lc", launchCmd]],
|
|
3148
|
-
["xterm", ["-e", shell, "-lc", launchCmd]],
|
|
3149
|
-
["alacritty", ["-e", shell, "-lc", launchCmd]],
|
|
3150
|
-
["kitty", [shell, "-lc", launchCmd]],
|
|
3151
|
-
["wezterm", ["start", "--", shell, "-lc", launchCmd]],
|
|
3152
|
-
];
|
|
3153
|
-
|
|
3154
|
-
for (const [command, args] of attempts) {
|
|
3155
|
-
if (!commandExistsInPath(command)) continue;
|
|
3156
|
-
if (tryExecFile(command, args)) {
|
|
3157
|
-
return { opened: true, windowIds: [] };
|
|
3158
|
-
}
|
|
3159
|
-
}
|
|
3160
|
-
return { opened: false, windowIds: [] };
|
|
3161
|
-
}
|
|
3162
|
-
|
|
3163
|
-
if (process.platform === "win32") {
|
|
3164
|
-
// Windows: monitor is launched directly by spawnMonitorTerminal (no tmux)
|
|
3165
|
-
// Physical terminal opening is handled there, so just return success
|
|
3166
|
-
return { opened: true, windowIds: [] };
|
|
3167
|
-
}
|
|
3168
|
-
|
|
3169
|
-
return { opened: false, windowIds: [] };
|
|
3170
|
-
}
|
|
3171
|
-
|
|
3172
|
-
function spawnMonitorTerminal(sessionId) {
|
|
3173
|
-
// Windows: use Windows Terminal or PowerShell directly (no tmux needed)
|
|
3174
|
-
if (process.platform === "win32") {
|
|
3175
|
-
const project = getProjectSlug();
|
|
3176
|
-
const monitorCmd = buildMonitorCommandWindows(sessionId, project);
|
|
3177
|
-
|
|
3178
|
-
// Try Windows Terminal (wt.exe)
|
|
3179
|
-
if (commandExistsInPath("wt") || commandExistsInPath("wt.exe")) {
|
|
3180
|
-
if (tryExecFile("wt", ["new-tab", "--title", "Deliberation Monitor", "cmd", "/c", monitorCmd])) {
|
|
3181
|
-
return true;
|
|
3182
|
-
}
|
|
3183
|
-
}
|
|
3184
|
-
|
|
3185
|
-
// Fallback: new PowerShell window
|
|
3186
|
-
const shell = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"].find(c => commandExistsInPath(c));
|
|
3187
|
-
if (shell) {
|
|
3188
|
-
const escaped = monitorCmd.replace(/'/g, "''");
|
|
3189
|
-
if (tryExecFile(shell, ["-NoProfile", "-Command", `Start-Process cmd -ArgumentList '/c','${escaped}'`])) {
|
|
3190
|
-
return true;
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
return false;
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
// macOS/Linux: use tmux (existing logic)
|
|
3198
|
-
if (!commandExistsInPath("tmux")) {
|
|
3199
|
-
return false;
|
|
3200
|
-
}
|
|
3201
|
-
|
|
3202
|
-
const project = getProjectSlug();
|
|
3203
|
-
const winName = tmuxWindowName(sessionId);
|
|
3204
|
-
const cmd = buildMonitorCommand(sessionId, project);
|
|
3205
|
-
if (!cmd) {
|
|
3206
|
-
return false;
|
|
3207
|
-
}
|
|
3208
|
-
|
|
3209
|
-
try {
|
|
3210
|
-
if (hasTmuxSession(TMUX_SESSION)) {
|
|
3211
|
-
// Skip if a window with the same name already exists (prevents duplicates)
|
|
3212
|
-
if (hasTmuxWindow(TMUX_SESSION, winName)) {
|
|
3213
|
-
appendRuntimeLog("INFO", `TMUX_WINDOW_EXISTS: ${winName} in ${TMUX_SESSION}`);
|
|
3214
|
-
return true;
|
|
3215
|
-
}
|
|
3216
|
-
execFileSync("tmux", ["new-window", "-t", TMUX_SESSION, "-n", winName, cmd], {
|
|
3217
|
-
stdio: "ignore",
|
|
3218
|
-
windowsHide: true,
|
|
3219
|
-
});
|
|
3220
|
-
appendRuntimeLog("INFO", `TMUX_WINDOW_CREATED: ${winName} in existing ${TMUX_SESSION}`);
|
|
3221
|
-
} else {
|
|
3222
|
-
execFileSync("tmux", ["new-session", "-d", "-s", TMUX_SESSION, "-n", winName, cmd], {
|
|
3223
|
-
stdio: "ignore",
|
|
3224
|
-
windowsHide: true,
|
|
3225
|
-
});
|
|
3226
|
-
appendRuntimeLog("INFO", `TMUX_SESSION_CREATED: ${TMUX_SESSION} with window ${winName}`);
|
|
3227
|
-
}
|
|
3228
|
-
return true;
|
|
3229
|
-
} catch {
|
|
3230
|
-
return false;
|
|
3231
|
-
}
|
|
3232
|
-
}
|
|
3233
|
-
|
|
3234
|
-
function closePhysicalTerminal(windowId) {
|
|
3235
|
-
if (process.platform !== "darwin") {
|
|
3236
|
-
return false;
|
|
3237
|
-
}
|
|
3238
|
-
if (!Number.isInteger(windowId) || windowId <= 0) {
|
|
3239
|
-
return false;
|
|
3240
|
-
}
|
|
3241
|
-
|
|
3242
|
-
const windowExists = () => {
|
|
3243
|
-
try {
|
|
3244
|
-
const out = execFileSync(
|
|
3245
|
-
"osascript",
|
|
3246
|
-
[
|
|
3247
|
-
"-e",
|
|
3248
|
-
'tell application "Terminal"',
|
|
3249
|
-
"-e",
|
|
3250
|
-
`if exists window id ${windowId} then return "1"`,
|
|
3251
|
-
"-e",
|
|
3252
|
-
'return "0"',
|
|
3253
|
-
"-e",
|
|
3254
|
-
"end tell",
|
|
3255
|
-
],
|
|
3256
|
-
{ encoding: "utf-8" }
|
|
3257
|
-
).trim();
|
|
3258
|
-
return out === "1";
|
|
3259
|
-
} catch {
|
|
3260
|
-
return false;
|
|
3261
|
-
}
|
|
3262
|
-
};
|
|
3263
|
-
|
|
3264
|
-
const dismissCloseDialogs = () => {
|
|
3265
|
-
try {
|
|
3266
|
-
execFileSync(
|
|
3267
|
-
"osascript",
|
|
3268
|
-
[
|
|
3269
|
-
"-e",
|
|
3270
|
-
'tell application "System Events"',
|
|
3271
|
-
"-e",
|
|
3272
|
-
'if exists process "Terminal" then',
|
|
3273
|
-
"-e",
|
|
3274
|
-
'tell process "Terminal"',
|
|
3275
|
-
"-e",
|
|
3276
|
-
"repeat with w in windows",
|
|
3277
|
-
"-e",
|
|
3278
|
-
"try",
|
|
3279
|
-
"-e",
|
|
3280
|
-
"if exists (sheet 1 of w) then",
|
|
3281
|
-
"-e",
|
|
3282
|
-
"if exists button \"종료\" of sheet 1 of w then",
|
|
3283
|
-
"-e",
|
|
3284
|
-
'click button "종료" of sheet 1 of w',
|
|
3285
|
-
"-e",
|
|
3286
|
-
"else if exists button \"Terminate\" of sheet 1 of w then",
|
|
3287
|
-
"-e",
|
|
3288
|
-
'click button "Terminate" of sheet 1 of w',
|
|
3289
|
-
"-e",
|
|
3290
|
-
"else if exists button \"확인\" of sheet 1 of w then",
|
|
3291
|
-
"-e",
|
|
3292
|
-
'click button "확인" of sheet 1 of w',
|
|
3293
|
-
"-e",
|
|
3294
|
-
"else",
|
|
3295
|
-
"-e",
|
|
3296
|
-
"click button 1 of sheet 1 of w",
|
|
3297
|
-
"-e",
|
|
3298
|
-
"end if",
|
|
3299
|
-
"-e",
|
|
3300
|
-
"end if",
|
|
3301
|
-
"-e",
|
|
3302
|
-
"end try",
|
|
3303
|
-
"-e",
|
|
3304
|
-
"end repeat",
|
|
3305
|
-
"-e",
|
|
3306
|
-
"end tell",
|
|
3307
|
-
"-e",
|
|
3308
|
-
"end if",
|
|
3309
|
-
"-e",
|
|
3310
|
-
"end tell",
|
|
3311
|
-
],
|
|
3312
|
-
{ stdio: "ignore" }
|
|
3313
|
-
);
|
|
3314
|
-
} catch {
|
|
3315
|
-
// ignore
|
|
3316
|
-
}
|
|
3317
|
-
};
|
|
3318
|
-
|
|
3319
|
-
for (let i = 0; i < 5; i += 1) {
|
|
3320
|
-
try {
|
|
3321
|
-
execFileSync(
|
|
3322
|
-
"osascript",
|
|
3323
|
-
[
|
|
3324
|
-
"-e",
|
|
3325
|
-
'tell application "Terminal"',
|
|
3326
|
-
"-e",
|
|
3327
|
-
"activate",
|
|
3328
|
-
"-e",
|
|
3329
|
-
`if exists window id ${windowId} then`,
|
|
3330
|
-
"-e",
|
|
3331
|
-
"try",
|
|
3332
|
-
"-e",
|
|
3333
|
-
`do script "exit" in window id ${windowId}`,
|
|
3334
|
-
"-e",
|
|
3335
|
-
"end try",
|
|
3336
|
-
"-e",
|
|
3337
|
-
"delay 0.12",
|
|
3338
|
-
"-e",
|
|
3339
|
-
"try",
|
|
3340
|
-
"-e",
|
|
3341
|
-
`close (window id ${windowId})`,
|
|
3342
|
-
"-e",
|
|
3343
|
-
"end try",
|
|
3344
|
-
"-e",
|
|
3345
|
-
"end if",
|
|
3346
|
-
"-e",
|
|
3347
|
-
"end tell",
|
|
3348
|
-
],
|
|
3349
|
-
{ stdio: "ignore" }
|
|
3350
|
-
);
|
|
3351
|
-
} catch {
|
|
3352
|
-
// ignore
|
|
3353
|
-
}
|
|
3354
|
-
|
|
3355
|
-
dismissCloseDialogs();
|
|
3356
|
-
|
|
3357
|
-
if (!windowExists()) {
|
|
3358
|
-
return true;
|
|
3359
|
-
}
|
|
3360
|
-
}
|
|
3361
|
-
|
|
3362
|
-
return !windowExists();
|
|
3363
|
-
}
|
|
3364
|
-
|
|
3365
|
-
function closeMonitorTerminal(sessionId, terminalWindowIds = []) {
|
|
3366
|
-
if (process.platform !== "win32") {
|
|
3367
|
-
const winName = tmuxWindowName(sessionId);
|
|
3368
|
-
try {
|
|
3369
|
-
execFileSync("tmux", ["kill-window", "-t", `${TMUX_SESSION}:${winName}`], {
|
|
3370
|
-
stdio: "ignore",
|
|
3371
|
-
windowsHide: true,
|
|
3372
|
-
});
|
|
3373
|
-
} catch { /* ignore */ }
|
|
3374
|
-
|
|
3375
|
-
try {
|
|
3376
|
-
if (tmuxWindowCount(TMUX_SESSION) === 0) {
|
|
3377
|
-
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], {
|
|
3378
|
-
stdio: "ignore",
|
|
3379
|
-
windowsHide: true,
|
|
3380
|
-
});
|
|
3381
|
-
}
|
|
3382
|
-
} catch { /* ignore */ }
|
|
3383
|
-
}
|
|
3384
|
-
|
|
3385
|
-
for (const windowId of terminalWindowIds) {
|
|
3386
|
-
closePhysicalTerminal(windowId);
|
|
3387
|
-
}
|
|
3388
|
-
}
|
|
3389
|
-
|
|
3390
|
-
function getSessionWindowIds(state) {
|
|
3391
|
-
if (!state || typeof state !== "object") {
|
|
3392
|
-
return [];
|
|
3393
|
-
}
|
|
3394
|
-
const ids = [];
|
|
3395
|
-
if (Array.isArray(state.monitor_terminal_window_ids)) {
|
|
3396
|
-
for (const id of state.monitor_terminal_window_ids) {
|
|
3397
|
-
if (Number.isInteger(id) && id > 0) {
|
|
3398
|
-
ids.push(id);
|
|
3399
|
-
}
|
|
3400
|
-
}
|
|
3401
|
-
}
|
|
3402
|
-
if (Number.isInteger(state.monitor_terminal_window_id) && state.monitor_terminal_window_id > 0) {
|
|
3403
|
-
ids.push(state.monitor_terminal_window_id);
|
|
3404
|
-
}
|
|
3405
|
-
return [...new Set(ids)];
|
|
3406
|
-
}
|
|
3407
|
-
|
|
3408
|
-
function closeAllMonitorTerminals() {
|
|
3409
|
-
try {
|
|
3410
|
-
execFileSync("tmux", ["kill-session", "-t", TMUX_SESSION], { stdio: "ignore", windowsHide: true });
|
|
3411
|
-
} catch { /* ignore */ }
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3414
|
-
function multipleSessionsError() {
|
|
3415
|
-
const active = listActiveSessions();
|
|
3416
|
-
const list = active.map(s => `- **${s.id}** [${s.project || "unknown"}]: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
|
|
3417
|
-
return t(`Multiple active sessions found. Please specify session_id:\n\n${list}`, `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`, "en");
|
|
3418
|
-
}
|
|
3419
|
-
|
|
3420
|
-
function truncatePromptText(text, maxChars) {
|
|
3421
|
-
const value = String(text || "").trim();
|
|
3422
|
-
if (!value || !Number.isFinite(maxChars) || maxChars <= 0 || value.length <= maxChars) {
|
|
3423
|
-
return value;
|
|
3424
|
-
}
|
|
3425
|
-
const remaining = value.length - maxChars;
|
|
3426
|
-
return `${value.slice(0, maxChars).trimEnd()}\n...(truncated ${remaining} chars)`;
|
|
3427
|
-
}
|
|
3428
|
-
|
|
3429
|
-
function getPromptBudgetForSpeaker(speaker, includeHistoryEntries = 4) {
|
|
3430
|
-
const defaultBudget = {
|
|
3431
|
-
maxEntries: Math.max(0, includeHistoryEntries),
|
|
3432
|
-
maxCharsPerEntry: 1600,
|
|
3433
|
-
maxTotalChars: 6400,
|
|
3434
|
-
maxTopicChars: 3200,
|
|
3435
|
-
};
|
|
3436
|
-
switch (speaker) {
|
|
3437
|
-
case "codex":
|
|
3438
|
-
return {
|
|
3439
|
-
maxEntries: Math.min(Math.max(0, includeHistoryEntries), 3),
|
|
3440
|
-
maxCharsPerEntry: 1200,
|
|
3441
|
-
maxTotalChars: 3600,
|
|
3442
|
-
maxTopicChars: 2200,
|
|
3443
|
-
};
|
|
3444
|
-
case "gemini":
|
|
3445
|
-
return {
|
|
3446
|
-
maxEntries: Math.min(Math.max(0, includeHistoryEntries), 4),
|
|
3447
|
-
maxCharsPerEntry: 1400,
|
|
3448
|
-
maxTotalChars: 5600,
|
|
3449
|
-
maxTopicChars: 2800,
|
|
3450
|
-
};
|
|
3451
|
-
default:
|
|
3452
|
-
return defaultBudget;
|
|
3453
|
-
}
|
|
3454
|
-
}
|
|
3455
|
-
|
|
3456
|
-
function formatRecentLogForPrompt(state, maxEntries = 4, options = {}) {
|
|
3457
|
-
const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
|
|
3458
|
-
if (entries.length === 0) {
|
|
3459
|
-
return "(No previous responses yet)";
|
|
3460
|
-
}
|
|
3461
|
-
const maxCharsPerEntry = options.maxCharsPerEntry || 1600;
|
|
3462
|
-
const maxTotalChars = options.maxTotalChars || maxCharsPerEntry * entries.length;
|
|
3463
|
-
const rendered = [];
|
|
3464
|
-
let usedChars = 0;
|
|
3465
|
-
|
|
3466
|
-
for (const entry of entries) {
|
|
3467
|
-
const header = `- ${entry.speaker} (Round ${entry.round})`;
|
|
3468
|
-
const remainingChars = Math.max(0, maxTotalChars - usedChars - header.length - 1);
|
|
3469
|
-
const entryBudget = Math.max(200, Math.min(maxCharsPerEntry, remainingChars || maxCharsPerEntry));
|
|
3470
|
-
const content = truncatePromptText(entry.content, entryBudget);
|
|
3471
|
-
const block = `${header}\n${content}`;
|
|
3472
|
-
rendered.push(block);
|
|
3473
|
-
usedChars += block.length + 2;
|
|
3474
|
-
if (usedChars >= maxTotalChars) {
|
|
3475
|
-
break;
|
|
3476
|
-
}
|
|
3477
|
-
}
|
|
3478
|
-
|
|
3479
|
-
return rendered.join("\n\n");
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
function getCliAutoTurnTimeoutSec({ speaker, requestedTimeoutSec, promptLength, priorTurns }) {
|
|
3483
|
-
const requested = Number.isFinite(requestedTimeoutSec) ? requestedTimeoutSec : 120;
|
|
3484
|
-
if (speaker === "codex") {
|
|
3485
|
-
let recommended = Math.max(requested, priorTurns === 0 ? 240 : 180);
|
|
3486
|
-
if (promptLength > 6000) {
|
|
3487
|
-
recommended = Math.max(recommended, 300);
|
|
3488
|
-
}
|
|
3489
|
-
if (promptLength > 10000 || priorTurns >= 1) {
|
|
3490
|
-
recommended = Math.max(recommended, 420);
|
|
3491
|
-
}
|
|
3492
|
-
return recommended;
|
|
3493
|
-
}
|
|
3494
|
-
return priorTurns === 0 ? Math.max(requested, 180) : requested;
|
|
3495
|
-
}
|
|
3496
|
-
|
|
3497
|
-
function getCliExecArgs(speaker) {
|
|
3498
|
-
switch (speaker) {
|
|
3499
|
-
case "claude":
|
|
3500
|
-
return ["-p", "--output-format", "text"];
|
|
3501
|
-
case "codex":
|
|
3502
|
-
return [
|
|
3503
|
-
"exec",
|
|
3504
|
-
"--ephemeral",
|
|
3505
|
-
"-c", 'approval_policy="never"',
|
|
3506
|
-
"-c", 'sandbox_mode="read-only"',
|
|
3507
|
-
"-c", 'model_reasoning_effort="low"',
|
|
3508
|
-
"-",
|
|
3509
|
-
];
|
|
3510
|
-
case "gemini":
|
|
3511
|
-
return null;
|
|
3512
|
-
default:
|
|
3513
|
-
return null;
|
|
3514
|
-
}
|
|
3515
|
-
}
|
|
3516
|
-
|
|
3517
|
-
function buildCliAutoTurnFailureText({ state, speaker, hint, err, effectiveTimeout, promptLength, priorTurns }) {
|
|
3518
|
-
const isTimeout = /CLI timeout \(/.test(String(err?.message || ""));
|
|
3519
|
-
if (!isTimeout) {
|
|
3520
|
-
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: "...").`;
|
|
3521
|
-
}
|
|
3522
|
-
|
|
3523
|
-
const retryTimeout = speaker === "codex"
|
|
3524
|
-
? Math.min(Math.max(effectiveTimeout, 420), 600)
|
|
3525
|
-
: Math.min(effectiveTimeout + 60, 300);
|
|
3526
|
-
|
|
3527
|
-
return t(
|
|
3528
|
-
`⏱️ CLI auto-turn timed out.\n\n` +
|
|
3529
|
-
`**Speaker:** ${speaker}\n` +
|
|
3530
|
-
`**CLI:** ${hint.cmd}\n` +
|
|
3531
|
-
`**Timeout:** ${effectiveTimeout}s\n` +
|
|
3532
|
-
`**Prompt size:** ${promptLength} chars\n` +
|
|
3533
|
-
`**Prior turns by speaker:** ${priorTurns}\n` +
|
|
3534
|
-
`**Session state:** still waiting on ${speaker} for Round ${state.current_round}\n\n` +
|
|
3535
|
-
`This usually means the CLI stayed busy longer than the timeout. It does **not** necessarily mean the model is down.\n` +
|
|
3536
|
-
`${speaker === "codex" ? `Codex is the slowest CLI in recent deliberation logs, especially when recent_log contains long prior responses.\n` : ""}` +
|
|
3537
|
-
`Recommended next step: retry with \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\`.\n` +
|
|
3538
|
-
`Manual fallback: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
|
|
3539
|
-
`⏱️ CLI 자동 턴이 타임아웃되었습니다.\n\n` +
|
|
3540
|
-
`**Speaker:** ${speaker}\n` +
|
|
3541
|
-
`**CLI:** ${hint.cmd}\n` +
|
|
3542
|
-
`**Timeout:** ${effectiveTimeout}s\n` +
|
|
3543
|
-
`**Prompt 크기:** ${promptLength} chars\n` +
|
|
3544
|
-
`**이 speaker의 이전 발언 수:** ${priorTurns}\n` +
|
|
3545
|
-
`**세션 상태:** Round ${state.current_round}에서 아직 ${speaker} 응답을 기다리는 중\n\n` +
|
|
3546
|
-
`이건 보통 CLI가 제한 시간 안에 응답을 끝내지 못했다는 뜻입니다. 모델이 완전히 죽었다는 의미는 아닙니다.\n` +
|
|
3547
|
-
`${speaker === "codex" ? `최근 딜리버레이션 로그 기준으로 Codex는 이전 응답 전문이 길게 들어가면 가장 느린 편입니다.\n` : ""}` +
|
|
3548
|
-
`권장 조치: \`deliberation_cli_auto_turn(session_id: "${state.id}", timeout_sec: ${retryTimeout})\` 로 재시도하세요.\n` +
|
|
3549
|
-
`수동 대안: \`deliberation_respond(session_id: "${state.id}", speaker: "${speaker}", content: "...")\`.`,
|
|
3550
|
-
state?.lang
|
|
3551
|
-
);
|
|
3552
|
-
}
|
|
3553
|
-
|
|
3554
|
-
function buildActiveReportingSection(state, speaker) {
|
|
3555
|
-
const orchestratorSessionId = state?.orchestrator_session_id;
|
|
3556
|
-
if (!orchestratorSessionId) return "";
|
|
3557
|
-
|
|
3558
|
-
return `\n[active_reporting_rule]
|
|
3559
|
-
- After you successfully submit this turn with \`deliberation_respond(...)\`, send a brief status update to the orchestrator session.
|
|
3560
|
-
- Primary method (telepty available):
|
|
3561
|
-
\`telepty inject --from \"$TELEPTY_SESSION_ID\" --reply-to ${orchestratorSessionId} ${orchestratorSessionId} \"[deliberation status] session_id: ${state.id} speaker: ${speaker} round: ${state.current_round}/${state.max_rounds} submitted. no further reply needed.\"\`
|
|
3562
|
-
- Fallback (telepty unavailable): Include the status in your next tool call or response context.
|
|
3563
|
-
- Keep it to one short line. Do not wait for a reply.
|
|
3564
|
-
- This rule applies regardless of transport type (CLI, browser, telepty_bus).
|
|
3565
|
-
[/active_reporting_rule]
|
|
3566
|
-
`;
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
|
|
3570
|
-
const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
|
|
3571
|
-
const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
|
|
3572
|
-
const extraPrompt = prompt ? `\n[Additional instructions]\n${prompt}\n` : "";
|
|
3573
|
-
const topic = truncatePromptText(state.topic, promptBudget.maxTopicChars);
|
|
3574
|
-
const noToolRule = speaker === "codex"
|
|
3575
|
-
? `\n- Do not inspect files, run shell commands, browse, or call tools. Answer only from the provided discussion context.`
|
|
3576
|
-
: "";
|
|
3577
|
-
const activeReportingSection = buildActiveReportingSection(state, speaker);
|
|
3578
|
-
|
|
3579
|
-
// Role prompt injection
|
|
3580
|
-
const speakerRole = (state.speaker_roles || {})[speaker] || "free";
|
|
3581
|
-
const rolePromptText = loadRolePrompt(speakerRole);
|
|
3582
|
-
const roleSection = rolePromptText
|
|
3583
|
-
? `\n[role]\nrole: ${speakerRole}\n${rolePromptText}\n[/role]\n`
|
|
3584
|
-
: "";
|
|
3585
|
-
|
|
3586
|
-
return `[deliberation_turn_request]
|
|
3587
|
-
session_id: ${state.id}
|
|
3588
|
-
project: ${state.project}
|
|
3589
|
-
topic: ${topic}
|
|
3590
|
-
round: ${state.current_round}/${state.max_rounds}
|
|
3591
|
-
target_speaker: ${speaker}
|
|
3592
|
-
required_turn: ${state.current_speaker}${roleSection}${activeReportingSection}
|
|
3593
|
-
|
|
3594
|
-
[recent_log]
|
|
3595
|
-
${recent}
|
|
3596
|
-
[/recent_log]${extraPrompt}
|
|
3597
|
-
|
|
3598
|
-
[response_rule]
|
|
3599
|
-
- Write only ${speaker}'s response for this turn reflecting the discussion context above
|
|
3600
|
-
- Output markdown body only (no unnecessary headers/footers)${speakerRole !== "free" ? `\n- Analyze and respond from the perspective of assigned role (${speakerRole})` : ""}
|
|
3601
|
-
- Keep the response concise and decision-oriented${noToolRule}
|
|
3602
|
-
- Must include one of [AGREE], [DISAGREE], or [CONDITIONAL: reason] at the end of response
|
|
3603
|
-
[/response_rule]
|
|
3604
|
-
[/deliberation_turn_request]
|
|
3605
|
-
`;
|
|
3606
|
-
}
|
|
3607
|
-
|
|
3608
|
-
function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments, source_metadata }) {
|
|
3609
|
-
const resolved = resolveSessionId(session_id);
|
|
3610
|
-
if (!resolved) {
|
|
3611
|
-
return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
|
|
3612
|
-
}
|
|
3613
|
-
if (resolved === "MULTIPLE") {
|
|
3614
|
-
return { content: [{ type: "text", text: multipleSessionsError() }] };
|
|
3615
|
-
}
|
|
3616
|
-
|
|
3617
|
-
let completionState = null;
|
|
3618
|
-
let completionEntry = null;
|
|
3619
|
-
const result = withSessionLock(resolved, () => {
|
|
3620
|
-
const state = loadSession(resolved);
|
|
3621
|
-
if (!state || state.status !== "active") {
|
|
3622
|
-
return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
|
|
3623
|
-
}
|
|
3624
|
-
|
|
3625
|
-
const normalizedSpeaker = normalizeSpeaker(speaker);
|
|
3626
|
-
if (!normalizedSpeaker) {
|
|
3627
|
-
return { content: [{ type: "text", text: t("Speaker value is empty. Please specify a speaker name.", "speaker 값이 비어 있습니다. 응답자 이름을 지정하세요.", "en") }] };
|
|
3628
|
-
}
|
|
3629
|
-
|
|
3630
|
-
state.speakers = buildSpeakerOrder(state.speakers, state.current_speaker, "end");
|
|
3631
|
-
const normalizedCurrentSpeaker = normalizeSpeaker(state.current_speaker);
|
|
3632
|
-
if (!normalizedCurrentSpeaker || !state.speakers.includes(normalizedCurrentSpeaker)) {
|
|
3633
|
-
state.current_speaker = state.speakers[0];
|
|
3634
|
-
} else {
|
|
3635
|
-
state.current_speaker = normalizedCurrentSpeaker;
|
|
3636
|
-
}
|
|
3637
|
-
|
|
3638
|
-
if (state.current_speaker !== normalizedSpeaker) {
|
|
3639
|
-
return {
|
|
3640
|
-
content: [{
|
|
3641
|
-
type: "text",
|
|
3642
|
-
text: t(`[${state.id}] It is currently **${state.current_speaker}**'s turn. ${normalizedSpeaker} please wait.`, `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. ${normalizedSpeaker}는 대기하세요.`, state?.lang),
|
|
3643
|
-
}],
|
|
3644
|
-
};
|
|
3645
|
-
}
|
|
3646
|
-
|
|
3647
|
-
// turn_id validation (optional — must match if provided)
|
|
3648
|
-
if (turn_id && state.pending_turn_id && turn_id !== state.pending_turn_id) {
|
|
3649
|
-
return {
|
|
3650
|
-
content: [{
|
|
3651
|
-
type: "text",
|
|
3652
|
-
text: t(`[${state.id}] turn_id mismatch. Expected: "${state.pending_turn_id}", received: "${turn_id}". May be a stale request or duplicate submission.`, `[${state.id}] turn_id 불일치. 예상: "${state.pending_turn_id}", 수신: "${turn_id}". 오래된 요청이거나 중복 제출일 수 있습니다.`, state?.lang),
|
|
3653
|
-
}],
|
|
3654
|
-
};
|
|
3655
|
-
}
|
|
516
|
+
if (typeof projectSlug === "function") {
|
|
517
|
+
return withFileLock(path.join(getLocksDir(), "_project.lock"), projectSlug, fn);
|
|
518
|
+
}
|
|
519
|
+
return withFileLock(path.join(getLocksDir(projectSlug), "_project.lock"), fn, options);
|
|
520
|
+
}
|
|
3656
521
|
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
round: state.current_round,
|
|
3666
|
-
speaker: normalizedSpeaker,
|
|
3667
|
-
content,
|
|
3668
|
-
timestamp: new Date().toISOString(),
|
|
3669
|
-
turn_id: state.pending_turn_id || null,
|
|
3670
|
-
channel_used: channel_used || null,
|
|
3671
|
-
fallback_reason: fallback_reason || null,
|
|
3672
|
-
votes: votes.length > 0 ? votes : undefined,
|
|
3673
|
-
suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
|
|
3674
|
-
role_drift: roleDrift || undefined,
|
|
3675
|
-
attachments: attachments || undefined,
|
|
3676
|
-
source_metadata: source_metadata || undefined,
|
|
3677
|
-
};
|
|
3678
|
-
state.log.push(logEntry);
|
|
3679
|
-
completePendingTeleptySemantic({
|
|
3680
|
-
sessionId: state.id,
|
|
3681
|
-
speaker: normalizedSpeaker,
|
|
3682
|
-
turnId: state.pending_turn_id || turn_id || null,
|
|
3683
|
-
});
|
|
3684
|
-
appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"} | attachments: ${attachments ? attachments.length : 0}${source_metadata?.source_machine_id ? ` | source_machine: ${source_metadata.source_machine_id}` : ""}`);
|
|
522
|
+
function withSessionLock(sessionRef, fn, options) {
|
|
523
|
+
const sessionId = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.id : sessionRef;
|
|
524
|
+
const explicitProject = typeof sessionRef === "object" && sessionRef !== null ? sessionRef.project : null;
|
|
525
|
+
const record = findSessionRecord(sessionRef, { preferProject: explicitProject || getProjectSlug() });
|
|
526
|
+
const projectSlug = explicitProject || record?.project || getProjectSlug();
|
|
527
|
+
const safeId = String(sessionId).replace(/[^a-zA-Z0-9가-힣._-]/g, "_");
|
|
528
|
+
return withFileLock(path.join(getLocksDir(projectSlug), `${safeId}.lock`), fn, options);
|
|
529
|
+
}
|
|
3685
530
|
|
|
3686
|
-
|
|
531
|
+
// Speaker/Candidate Discovery functions moved to lib/speaker-discovery.js
|
|
3687
532
|
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
533
|
+
// BrowserControlPort singleton — moved to lib/transport.js
|
|
534
|
+
// Session ID generation, context detection, state helpers, markdown sync,
|
|
535
|
+
// archival — all moved to lib/session.js
|
|
536
|
+
// Terminal management — moved to lib/transport.js
|
|
3692
537
|
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
return {
|
|
3699
|
-
content: [{
|
|
3700
|
-
type: "text",
|
|
3701
|
-
text: t(`✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} complete. Forum updated (${state.log.length} responses accumulated).\n\n🏁 **All rounds complete!**\nCreate a synthesis report with deliberation_synthesize(session_id: "${state.id}").`, `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료. Forum 업데이트됨 (${state.log.length}건 응답 축적).\n\n🏁 **모든 라운드 종료!**\ndeliberation_synthesize(session_id: "${state.id}")로 합성 보고서를 작성하세요.`, state?.lang),
|
|
3702
|
-
}],
|
|
3703
|
-
};
|
|
3704
|
-
}
|
|
3705
|
-
state.current_round += 1;
|
|
3706
|
-
}
|
|
538
|
+
// multipleSessionsError, truncatePromptText, getPromptBudgetForSpeaker,
|
|
539
|
+
// formatRecentLogForPrompt — moved to lib/session.js
|
|
540
|
+
// getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText — moved to lib/transport.js
|
|
541
|
+
// buildActiveReportingSection, buildClipboardTurnPrompt,
|
|
542
|
+
// submitDeliberationTurn — moved to lib/session.js
|
|
3707
543
|
|
|
3708
|
-
|
|
3709
|
-
state.pending_turn_id = generateTurnId();
|
|
3710
|
-
}
|
|
544
|
+
// ── MCP Server ─────────────────────────────────────────────────
|
|
3711
545
|
|
|
3712
|
-
|
|
3713
|
-
|
|
546
|
+
// Gracefully handle EPIPE on stdout/stderr (MCP client disconnect)
|
|
547
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
548
|
+
stream.on("error", (err) => {
|
|
549
|
+
if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED") {
|
|
550
|
+
process.exit(0);
|
|
3714
551
|
}
|
|
3715
|
-
completionEntry = {
|
|
3716
|
-
...logEntry,
|
|
3717
|
-
turn_id: logEntry.turn_id || turn_id || null,
|
|
3718
|
-
};
|
|
3719
|
-
completionState = {
|
|
3720
|
-
...state,
|
|
3721
|
-
log: [...state.log],
|
|
3722
|
-
};
|
|
3723
|
-
saveSession(state);
|
|
3724
|
-
return {
|
|
3725
|
-
content: [{
|
|
3726
|
-
type: "text",
|
|
3727
|
-
text: t(`✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} complete. Forum updated (${state.log.length} responses accumulated).\n\n**Next:** ${state.current_speaker} (Round ${state.current_round})`, `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료. Forum 업데이트됨 (${state.log.length}건 응답 축적).\n\n**다음:** ${state.current_speaker} (Round ${state.current_round})`, state?.lang),
|
|
3728
|
-
}],
|
|
3729
|
-
};
|
|
3730
552
|
});
|
|
3731
|
-
|
|
3732
|
-
if (completionState && completionEntry) {
|
|
3733
|
-
const envelope = buildTeleptyTurnCompletedEnvelope({ state: completionState, entry: completionEntry });
|
|
3734
|
-
notifyTeleptyBus(envelope).catch(() => {});
|
|
3735
|
-
|
|
3736
|
-
const orchestratorSessionId = completionState.orchestrator_session_id || null;
|
|
3737
|
-
if (orchestratorSessionId) {
|
|
3738
|
-
const notificationText = buildTurnCompletionNotificationText(completionState, completionEntry);
|
|
3739
|
-
notifyTeleptySessionInject({
|
|
3740
|
-
targetSessionId: orchestratorSessionId,
|
|
3741
|
-
prompt: notificationText,
|
|
3742
|
-
fromSessionId: `deliberation:${completionState.id}`,
|
|
3743
|
-
}).catch(() => {});
|
|
3744
|
-
}
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
|
-
return result;
|
|
3748
553
|
}
|
|
3749
554
|
|
|
3750
|
-
// ── MCP Server ─────────────────────────────────────────────────
|
|
3751
|
-
|
|
3752
555
|
process.on("uncaughtException", (error) => {
|
|
556
|
+
// EPIPE = MCP client disconnected (normal shutdown). Exit cleanly.
|
|
557
|
+
if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") {
|
|
558
|
+
try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
|
|
559
|
+
process.exit(0);
|
|
560
|
+
}
|
|
3753
561
|
const message = formatRuntimeError(error);
|
|
3754
562
|
appendRuntimeLog("UNCAUGHT_EXCEPTION", message);
|
|
3755
563
|
try {
|
|
@@ -3760,6 +568,11 @@ process.on("uncaughtException", (error) => {
|
|
|
3760
568
|
});
|
|
3761
569
|
|
|
3762
570
|
process.on("unhandledRejection", (reason) => {
|
|
571
|
+
// EPIPE = MCP client disconnected (normal shutdown). Exit cleanly.
|
|
572
|
+
if (reason?.code === "EPIPE" || reason?.code === "ERR_STREAM_DESTROYED") {
|
|
573
|
+
try { appendRuntimeLog("INFO", "Client disconnected (EPIPE). Shutting down."); } catch { /* noop */ }
|
|
574
|
+
process.exit(0);
|
|
575
|
+
}
|
|
3763
576
|
const message = formatRuntimeError(reason);
|
|
3764
577
|
appendRuntimeLog("UNHANDLED_REJECTION", message);
|
|
3765
578
|
try {
|
|
@@ -3773,6 +586,55 @@ process.on("unhandledRejection", (reason) => {
|
|
|
3773
586
|
const __pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json");
|
|
3774
587
|
const __pkgVersion = JSON.parse(fs.readFileSync(__pkgPath, "utf-8")).version;
|
|
3775
588
|
|
|
589
|
+
// ── Initialize speaker-discovery module dependencies ──
|
|
590
|
+
initSpeakerDeps({
|
|
591
|
+
appendRuntimeLog,
|
|
592
|
+
loadDeliberationConfig,
|
|
593
|
+
getProjectSlug,
|
|
594
|
+
readJsonFileSafe,
|
|
595
|
+
writeJsonFileAtomic,
|
|
596
|
+
getSpeakerSelectionFile,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ── Initialize telepty module dependencies ──
|
|
600
|
+
initTeleptyDeps({
|
|
601
|
+
appendRuntimeLog,
|
|
602
|
+
normalizeSpeaker,
|
|
603
|
+
getProjectSlug,
|
|
604
|
+
resolveTransportForSpeaker,
|
|
605
|
+
generateTurnId,
|
|
606
|
+
buildClipboardTurnPrompt,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// ── Initialize session module dependencies ──
|
|
610
|
+
initSessionDeps({
|
|
611
|
+
appendRuntimeLog,
|
|
612
|
+
writeTextAtomic,
|
|
613
|
+
readJsonFileSafe,
|
|
614
|
+
writeJsonFileAtomic,
|
|
615
|
+
withSessionLock,
|
|
616
|
+
getProjectSlug,
|
|
617
|
+
normalizeProjectSlug,
|
|
618
|
+
getProjectStateDir,
|
|
619
|
+
getSessionsDir,
|
|
620
|
+
getSessionFile,
|
|
621
|
+
getSessionProject,
|
|
622
|
+
listStateProjects,
|
|
623
|
+
getLocksDir,
|
|
624
|
+
GLOBAL_STATE_DIR,
|
|
625
|
+
OBSIDIAN_PROJECTS,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// ── Initialize transport module dependencies ──
|
|
629
|
+
initTransportDeps({
|
|
630
|
+
appendRuntimeLog,
|
|
631
|
+
getProjectSlug,
|
|
632
|
+
getSessionFile,
|
|
633
|
+
withSessionLock,
|
|
634
|
+
loadDeliberationConfig,
|
|
635
|
+
resolveCdpEndpoints,
|
|
636
|
+
});
|
|
637
|
+
|
|
3776
638
|
const server = new McpServer({
|
|
3777
639
|
name: "mcp-deliberation",
|
|
3778
640
|
version: __pkgVersion,
|
|
@@ -4566,534 +1428,7 @@ server.tool(
|
|
|
4566
1428
|
})
|
|
4567
1429
|
);
|
|
4568
1430
|
|
|
4569
|
-
//
|
|
4570
|
-
// Auto-handoff orchestrator helpers
|
|
4571
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
4572
|
-
|
|
4573
|
-
/**
|
|
4574
|
-
* Run a single CLI auto-turn for the given session and speaker.
|
|
4575
|
-
* Returns { ok: true, response, elapsedMs } or { ok: false, error }.
|
|
4576
|
-
*/
|
|
4577
|
-
async function runCliAutoTurnCore(sessionId, speaker, timeoutSec = 120) {
|
|
4578
|
-
const state = loadSession(sessionId);
|
|
4579
|
-
if (!state || state.status !== "active") {
|
|
4580
|
-
return { ok: false, error: "Session not active" };
|
|
4581
|
-
}
|
|
4582
|
-
|
|
4583
|
-
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
4584
|
-
if (transport !== "cli_respond") {
|
|
4585
|
-
return { ok: false, error: `Speaker "${speaker}" is not CLI type` };
|
|
4586
|
-
}
|
|
4587
|
-
|
|
4588
|
-
const hint = CLI_INVOCATION_HINTS[speaker];
|
|
4589
|
-
if (!hint) return { ok: false, error: `No CLI hints for "${speaker}"` };
|
|
4590
|
-
if (!checkCliLiveness(hint.cmd)) return { ok: false, error: `CLI "${hint.cmd}" not available` };
|
|
4591
|
-
|
|
4592
|
-
const turnId = state.pending_turn_id || generateTurnId();
|
|
4593
|
-
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
4594
|
-
const speakerPriorTurns = state.log.filter(e => e.speaker === speaker).length;
|
|
4595
|
-
const effectiveTimeout = getCliAutoTurnTimeoutSec({
|
|
4596
|
-
speaker,
|
|
4597
|
-
requestedTimeoutSec: timeoutSec,
|
|
4598
|
-
promptLength: turnPrompt.length,
|
|
4599
|
-
priorTurns: speakerPriorTurns,
|
|
4600
|
-
});
|
|
4601
|
-
|
|
4602
|
-
const startTime = Date.now();
|
|
4603
|
-
try {
|
|
4604
|
-
const response = await new Promise((resolve, reject) => {
|
|
4605
|
-
const env = { ...process.env };
|
|
4606
|
-
if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
|
|
4607
|
-
|
|
4608
|
-
let child;
|
|
4609
|
-
let stdout = "";
|
|
4610
|
-
let stderr = "";
|
|
4611
|
-
let settled = false;
|
|
4612
|
-
let forceKillTimer = null;
|
|
4613
|
-
|
|
4614
|
-
const resolveOnce = (v) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); resolve(v); } };
|
|
4615
|
-
const rejectOnce = (e) => { if (!settled) { settled = true; if (forceKillTimer) clearTimeout(forceKillTimer); reject(e); } };
|
|
4616
|
-
|
|
4617
|
-
switch (speaker) {
|
|
4618
|
-
case "claude":
|
|
4619
|
-
child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
|
|
4620
|
-
child.stdin.write(turnPrompt);
|
|
4621
|
-
child.stdin.end();
|
|
4622
|
-
break;
|
|
4623
|
-
case "codex":
|
|
4624
|
-
child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
|
|
4625
|
-
child.stdin.write(turnPrompt);
|
|
4626
|
-
child.stdin.end();
|
|
4627
|
-
break;
|
|
4628
|
-
case "gemini":
|
|
4629
|
-
child = spawn("gemini", ["-p", turnPrompt], { env, windowsHide: true });
|
|
4630
|
-
break;
|
|
4631
|
-
default: {
|
|
4632
|
-
const flags = hint.flags ? hint.flags.split(/\s+/) : [];
|
|
4633
|
-
child = spawn(hint.cmd, [...flags, turnPrompt], { env, windowsHide: true });
|
|
4634
|
-
break;
|
|
4635
|
-
}
|
|
4636
|
-
}
|
|
4637
|
-
|
|
4638
|
-
const timer = setTimeout(() => {
|
|
4639
|
-
try { child.kill("SIGTERM"); } catch {}
|
|
4640
|
-
forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 5000);
|
|
4641
|
-
if (typeof forceKillTimer?.unref === "function") forceKillTimer.unref();
|
|
4642
|
-
rejectOnce(new Error(`CLI timeout (${effectiveTimeout}s)`));
|
|
4643
|
-
}, effectiveTimeout * 1000);
|
|
4644
|
-
|
|
4645
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
4646
|
-
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
4647
|
-
|
|
4648
|
-
child.on("close", (code) => {
|
|
4649
|
-
clearTimeout(timer);
|
|
4650
|
-
if (code !== 0 && !stdout.trim()) {
|
|
4651
|
-
rejectOnce(new Error(`CLI exit code ${code}: ${stderr.slice(0, 500)}`));
|
|
4652
|
-
} else {
|
|
4653
|
-
resolveOnce(stdout.trim());
|
|
4654
|
-
}
|
|
4655
|
-
});
|
|
4656
|
-
|
|
4657
|
-
child.on("error", (err) => rejectOnce(err));
|
|
4658
|
-
});
|
|
4659
|
-
|
|
4660
|
-
// Submit the turn
|
|
4661
|
-
submitDeliberationTurn({
|
|
4662
|
-
session_id: sessionId,
|
|
4663
|
-
speaker,
|
|
4664
|
-
content: response,
|
|
4665
|
-
turn_id: turnId,
|
|
4666
|
-
channel_used: "cli_auto",
|
|
4667
|
-
});
|
|
4668
|
-
|
|
4669
|
-
return { ok: true, response, elapsedMs: Date.now() - startTime };
|
|
4670
|
-
} catch (err) {
|
|
4671
|
-
return { ok: false, error: err.message };
|
|
4672
|
-
}
|
|
4673
|
-
}
|
|
4674
|
-
|
|
4675
|
-
async function runBrowserAutoTurnCore(sessionId, speaker, timeoutSec = 45) {
|
|
4676
|
-
const state = loadSession(sessionId);
|
|
4677
|
-
if (!state || state.status !== "active") {
|
|
4678
|
-
return { ok: false, error: "Session not active" };
|
|
4679
|
-
}
|
|
4680
|
-
|
|
4681
|
-
const { transport, profile } = resolveTransportForSpeaker(state, speaker);
|
|
4682
|
-
if (transport !== "browser_auto") {
|
|
4683
|
-
return { ok: false, error: `Speaker "${speaker}" is not browser_auto type` };
|
|
4684
|
-
}
|
|
4685
|
-
|
|
4686
|
-
const turnId = state.pending_turn_id || generateTurnId();
|
|
4687
|
-
const port = getBrowserPort();
|
|
4688
|
-
const effectiveProvider = profile?.provider || "chatgpt";
|
|
4689
|
-
const modelSelection = getModelSelectionForTurn(state, speaker, effectiveProvider);
|
|
4690
|
-
const turnPrompt = buildClipboardTurnPrompt(state, speaker, null, 3);
|
|
4691
|
-
const startTime = Date.now();
|
|
4692
|
-
|
|
4693
|
-
try {
|
|
4694
|
-
const attachResult = await port.attach(sessionId, {
|
|
4695
|
-
provider: effectiveProvider,
|
|
4696
|
-
url: profile?.url || undefined,
|
|
4697
|
-
});
|
|
4698
|
-
if (!attachResult.ok) {
|
|
4699
|
-
return { ok: false, error: `attach failed: ${attachResult.error?.message || "unknown error"}` };
|
|
4700
|
-
}
|
|
4701
|
-
|
|
4702
|
-
const loginCheck = await port.checkLogin(sessionId);
|
|
4703
|
-
if (loginCheck && !loginCheck.loggedIn) {
|
|
4704
|
-
await port.detach(sessionId);
|
|
4705
|
-
return { ok: false, error: `login required: ${loginCheck.reason || "not logged in"}` };
|
|
4706
|
-
}
|
|
4707
|
-
|
|
4708
|
-
if (modelSelection.model !== "default") {
|
|
4709
|
-
await port.switchModel(sessionId, modelSelection.model);
|
|
4710
|
-
}
|
|
4711
|
-
|
|
4712
|
-
const sendResult = await port.sendTurnWithDegradation(sessionId, turnId, turnPrompt);
|
|
4713
|
-
if (!sendResult.ok) {
|
|
4714
|
-
await port.detach(sessionId);
|
|
4715
|
-
return { ok: false, error: `send failed: ${sendResult.error?.message || "unknown error"}` };
|
|
4716
|
-
}
|
|
4717
|
-
|
|
4718
|
-
const waitResult = await port.waitTurnResult(sessionId, turnId, timeoutSec);
|
|
4719
|
-
await port.detach(sessionId);
|
|
4720
|
-
if (!waitResult.ok || !waitResult.data?.response) {
|
|
4721
|
-
return { ok: false, error: waitResult.error?.message || "no response received" };
|
|
4722
|
-
}
|
|
4723
|
-
|
|
4724
|
-
submitDeliberationTurn({
|
|
4725
|
-
session_id: sessionId,
|
|
4726
|
-
speaker,
|
|
4727
|
-
content: waitResult.data.response,
|
|
4728
|
-
turn_id: turnId,
|
|
4729
|
-
channel_used: "browser_auto",
|
|
4730
|
-
});
|
|
4731
|
-
|
|
4732
|
-
return {
|
|
4733
|
-
ok: true,
|
|
4734
|
-
response: waitResult.data.response,
|
|
4735
|
-
elapsedMs: Date.now() - startTime,
|
|
4736
|
-
model: modelSelection.model,
|
|
4737
|
-
provider: effectiveProvider,
|
|
4738
|
-
};
|
|
4739
|
-
} catch (err) {
|
|
4740
|
-
try { await port.detach(sessionId); } catch {}
|
|
4741
|
-
return { ok: false, error: err?.message || String(err) };
|
|
4742
|
-
}
|
|
4743
|
-
}
|
|
4744
|
-
|
|
4745
|
-
async function runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries = 4) {
|
|
4746
|
-
const state = loadSession(sessionId);
|
|
4747
|
-
if (!state || state.status !== "active") {
|
|
4748
|
-
return { ok: false, error: "Session not active" };
|
|
4749
|
-
}
|
|
4750
|
-
|
|
4751
|
-
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
4752
|
-
if (transport !== "telepty_bus") {
|
|
4753
|
-
return { ok: false, error: `Speaker "${speaker}" is not telepty_bus type` };
|
|
4754
|
-
}
|
|
4755
|
-
|
|
4756
|
-
const startTime = Date.now();
|
|
4757
|
-
const dispatchResult = await dispatchTeleptyTurnRequest({
|
|
4758
|
-
state,
|
|
4759
|
-
speaker,
|
|
4760
|
-
includeHistoryEntries,
|
|
4761
|
-
awaitSemantic: true,
|
|
4762
|
-
});
|
|
4763
|
-
if (!dispatchResult.publishResult?.ok) {
|
|
4764
|
-
return {
|
|
4765
|
-
ok: false,
|
|
4766
|
-
blocked: true,
|
|
4767
|
-
error: dispatchResult.publishResult?.error || dispatchResult.publishResult?.status || "telepty bus publish failed",
|
|
4768
|
-
envelope: dispatchResult.envelope,
|
|
4769
|
-
turnPrompt: dispatchResult.turnPrompt,
|
|
4770
|
-
};
|
|
4771
|
-
}
|
|
4772
|
-
if (!dispatchResult.transportResult?.ok) {
|
|
4773
|
-
return {
|
|
4774
|
-
ok: false,
|
|
4775
|
-
blocked: true,
|
|
4776
|
-
error: dispatchResult.transportResult?.code || "transport timeout",
|
|
4777
|
-
envelope: dispatchResult.envelope,
|
|
4778
|
-
turnPrompt: dispatchResult.turnPrompt,
|
|
4779
|
-
};
|
|
4780
|
-
}
|
|
4781
|
-
if (!dispatchResult.semanticResult?.ok) {
|
|
4782
|
-
return {
|
|
4783
|
-
ok: false,
|
|
4784
|
-
blocked: true,
|
|
4785
|
-
error: dispatchResult.semanticResult?.code || "semantic timeout",
|
|
4786
|
-
envelope: dispatchResult.envelope,
|
|
4787
|
-
turnPrompt: dispatchResult.turnPrompt,
|
|
4788
|
-
};
|
|
4789
|
-
}
|
|
4790
|
-
|
|
4791
|
-
return {
|
|
4792
|
-
ok: true,
|
|
4793
|
-
elapsedMs: Date.now() - startTime,
|
|
4794
|
-
envelope: dispatchResult.envelope,
|
|
4795
|
-
publishResult: dispatchResult.publishResult,
|
|
4796
|
-
transportResult: dispatchResult.transportResult,
|
|
4797
|
-
semanticResult: dispatchResult.semanticResult,
|
|
4798
|
-
};
|
|
4799
|
-
}
|
|
4800
|
-
|
|
4801
|
-
async function runUntilBlockedCore(sessionId, {
|
|
4802
|
-
maxTurns = 12,
|
|
4803
|
-
cliTimeoutSec = 120,
|
|
4804
|
-
browserTimeoutSec = 45,
|
|
4805
|
-
includeHistoryEntries = 4,
|
|
4806
|
-
} = {}) {
|
|
4807
|
-
const steps = [];
|
|
4808
|
-
|
|
4809
|
-
for (let iteration = 0; iteration < maxTurns; iteration += 1) {
|
|
4810
|
-
const state = loadSession(sessionId);
|
|
4811
|
-
if (!state) {
|
|
4812
|
-
return { ok: false, status: "missing", error: "Session not found", steps };
|
|
4813
|
-
}
|
|
4814
|
-
if (state.status !== "active" || state.current_speaker === "none") {
|
|
4815
|
-
return { ok: true, status: state.status, steps };
|
|
4816
|
-
}
|
|
4817
|
-
|
|
4818
|
-
const speaker = state.current_speaker;
|
|
4819
|
-
const { transport } = resolveTransportForSpeaker(state, speaker);
|
|
4820
|
-
const callerSpeaker = detectCallerSpeaker();
|
|
4821
|
-
if (transport === "cli_respond" && callerSpeaker && normalizeSpeaker(callerSpeaker) === normalizeSpeaker(speaker)) {
|
|
4822
|
-
return {
|
|
4823
|
-
ok: true,
|
|
4824
|
-
status: "blocked",
|
|
4825
|
-
block_reason: "self_turn",
|
|
4826
|
-
speaker,
|
|
4827
|
-
transport,
|
|
4828
|
-
turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
|
|
4829
|
-
steps,
|
|
4830
|
-
};
|
|
4831
|
-
}
|
|
4832
|
-
|
|
4833
|
-
if (transport === "manual" || transport === "clipboard") {
|
|
4834
|
-
return {
|
|
4835
|
-
ok: true,
|
|
4836
|
-
status: "blocked",
|
|
4837
|
-
block_reason: "manual_transport",
|
|
4838
|
-
speaker,
|
|
4839
|
-
transport,
|
|
4840
|
-
turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
|
|
4841
|
-
steps,
|
|
4842
|
-
};
|
|
4843
|
-
}
|
|
4844
|
-
|
|
4845
|
-
let result = null;
|
|
4846
|
-
if (transport === "cli_respond") {
|
|
4847
|
-
result = await runCliAutoTurnCore(sessionId, speaker, cliTimeoutSec);
|
|
4848
|
-
} else if (transport === "browser_auto") {
|
|
4849
|
-
result = await runBrowserAutoTurnCore(sessionId, speaker, browserTimeoutSec);
|
|
4850
|
-
} else if (transport === "telepty_bus") {
|
|
4851
|
-
result = await runTeleptyBusAutoTurnCore(sessionId, speaker, includeHistoryEntries);
|
|
4852
|
-
} else {
|
|
4853
|
-
return {
|
|
4854
|
-
ok: true,
|
|
4855
|
-
status: "blocked",
|
|
4856
|
-
block_reason: "unsupported_transport",
|
|
4857
|
-
speaker,
|
|
4858
|
-
transport,
|
|
4859
|
-
turn_prompt: buildClipboardTurnPrompt(state, speaker, null, includeHistoryEntries),
|
|
4860
|
-
steps,
|
|
4861
|
-
};
|
|
4862
|
-
}
|
|
4863
|
-
|
|
4864
|
-
steps.push({
|
|
4865
|
-
speaker,
|
|
4866
|
-
transport,
|
|
4867
|
-
ok: Boolean(result?.ok),
|
|
4868
|
-
error: result?.error || null,
|
|
4869
|
-
elapsedMs: result?.elapsedMs || null,
|
|
4870
|
-
blocked: Boolean(result?.blocked),
|
|
4871
|
-
});
|
|
4872
|
-
|
|
4873
|
-
if (!result?.ok) {
|
|
4874
|
-
return {
|
|
4875
|
-
ok: Boolean(result?.blocked),
|
|
4876
|
-
status: result?.blocked ? "blocked" : "error",
|
|
4877
|
-
block_reason: result?.blocked ? (result.error || "transport_blocked") : null,
|
|
4878
|
-
speaker,
|
|
4879
|
-
transport,
|
|
4880
|
-
error: result?.error || null,
|
|
4881
|
-
turn_prompt: result?.turnPrompt || null,
|
|
4882
|
-
steps,
|
|
4883
|
-
};
|
|
4884
|
-
}
|
|
4885
|
-
}
|
|
4886
|
-
|
|
4887
|
-
const finalState = loadSession(sessionId);
|
|
4888
|
-
return {
|
|
4889
|
-
ok: true,
|
|
4890
|
-
status: finalState?.status === "active" ? "max_turns_reached" : (finalState?.status || "completed"),
|
|
4891
|
-
steps,
|
|
4892
|
-
};
|
|
4893
|
-
}
|
|
4894
|
-
|
|
4895
|
-
/**
|
|
4896
|
-
* Generate structured synthesis by calling a CLI speaker with a synthesis prompt.
|
|
4897
|
-
*/
|
|
4898
|
-
async function generateAutoSynthesis(sessionId) {
|
|
4899
|
-
const state = loadSession(sessionId);
|
|
4900
|
-
if (!state) return null;
|
|
4901
|
-
|
|
4902
|
-
const historyText = state.log.map(e => `[${e.speaker}] ${e.content}`).join("\n\n---\n\n");
|
|
4903
|
-
|
|
4904
|
-
const synthesisPrompt = `You are a deliberation synthesizer. Analyze this discussion and produce ONLY a JSON response (no markdown, no explanation).
|
|
4905
|
-
|
|
4906
|
-
Topic: ${state.topic}
|
|
4907
|
-
Project: ${state.project}
|
|
4908
|
-
Rounds: ${state.max_rounds}
|
|
4909
|
-
|
|
4910
|
-
Discussion:
|
|
4911
|
-
${historyText}
|
|
4912
|
-
|
|
4913
|
-
Respond with EXACTLY this JSON structure:
|
|
4914
|
-
{
|
|
4915
|
-
"summary": "Brief summary of the outcome",
|
|
4916
|
-
"decisions": ["Decision 1", "Decision 2"],
|
|
4917
|
-
"actionable_tasks": [
|
|
4918
|
-
{"id": 1, "task": "What to do", "files": ["path/to/file.ts"], "project": "${state.project}", "priority": "high|medium|low"}
|
|
4919
|
-
],
|
|
4920
|
-
"markdown_synthesis": "# Full synthesis in markdown\\n\\n..."
|
|
4921
|
-
}`;
|
|
4922
|
-
|
|
4923
|
-
// Use the first available CLI speaker to generate synthesis
|
|
4924
|
-
const speaker = state.speakers.find(s => {
|
|
4925
|
-
const hint = CLI_INVOCATION_HINTS[s];
|
|
4926
|
-
return hint && checkCliLiveness(hint.cmd);
|
|
4927
|
-
});
|
|
4928
|
-
|
|
4929
|
-
if (!speaker) return null;
|
|
4930
|
-
|
|
4931
|
-
const hint = CLI_INVOCATION_HINTS[speaker];
|
|
4932
|
-
|
|
4933
|
-
try {
|
|
4934
|
-
const response = await new Promise((resolve, reject) => {
|
|
4935
|
-
const env = { ...process.env };
|
|
4936
|
-
if (hint.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
|
|
4937
|
-
|
|
4938
|
-
let child;
|
|
4939
|
-
let stdout = "";
|
|
4940
|
-
|
|
4941
|
-
switch (speaker) {
|
|
4942
|
-
case "claude":
|
|
4943
|
-
child = spawn("claude", getCliExecArgs("claude"), { env, windowsHide: true });
|
|
4944
|
-
child.stdin.write(synthesisPrompt);
|
|
4945
|
-
child.stdin.end();
|
|
4946
|
-
break;
|
|
4947
|
-
case "codex":
|
|
4948
|
-
child = spawn("codex", getCliExecArgs("codex"), { env, windowsHide: true });
|
|
4949
|
-
child.stdin.write(synthesisPrompt);
|
|
4950
|
-
child.stdin.end();
|
|
4951
|
-
break;
|
|
4952
|
-
case "gemini":
|
|
4953
|
-
child = spawn("gemini", ["-p", synthesisPrompt], { env, windowsHide: true });
|
|
4954
|
-
break;
|
|
4955
|
-
default: {
|
|
4956
|
-
const flags = hint.flags ? hint.flags.split(/\s+/) : [];
|
|
4957
|
-
child = spawn(hint.cmd, [...flags, synthesisPrompt], { env, windowsHide: true });
|
|
4958
|
-
break;
|
|
4959
|
-
}
|
|
4960
|
-
}
|
|
4961
|
-
|
|
4962
|
-
const timer = setTimeout(() => {
|
|
4963
|
-
try { child.kill("SIGTERM"); } catch {}
|
|
4964
|
-
reject(new Error("Synthesis generation timeout"));
|
|
4965
|
-
}, 180000); // 3 min timeout for synthesis
|
|
4966
|
-
|
|
4967
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
4968
|
-
child.on("close", (code) => {
|
|
4969
|
-
clearTimeout(timer);
|
|
4970
|
-
resolve(stdout.trim());
|
|
4971
|
-
});
|
|
4972
|
-
child.on("error", reject);
|
|
4973
|
-
});
|
|
4974
|
-
|
|
4975
|
-
// Extract JSON from response (may have markdown wrapping)
|
|
4976
|
-
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
4977
|
-
if (!jsonMatch) return { markdown_synthesis: response };
|
|
4978
|
-
|
|
4979
|
-
try {
|
|
4980
|
-
return JSON.parse(jsonMatch[0]);
|
|
4981
|
-
} catch {
|
|
4982
|
-
return { markdown_synthesis: response };
|
|
4983
|
-
}
|
|
4984
|
-
} catch (err) {
|
|
4985
|
-
appendRuntimeLog("ERROR", `AUTO_SYNTHESIS_FAILED: ${sessionId} | ${err.message}`);
|
|
4986
|
-
return null;
|
|
4987
|
-
}
|
|
4988
|
-
}
|
|
4989
|
-
|
|
4990
|
-
/**
|
|
4991
|
-
* Orchestrate full auto-handoff: run all turns -> synthesize -> inbox -> telepty.
|
|
4992
|
-
* Called as fire-and-forget from deliberation_start when auto_execute is true.
|
|
4993
|
-
*/
|
|
4994
|
-
async function runAutoHandoff(sessionId) {
|
|
4995
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_START: ${sessionId}`);
|
|
4996
|
-
|
|
4997
|
-
try {
|
|
4998
|
-
// Phase 1: Run all deliberation turns
|
|
4999
|
-
let maxIterations = 100; // safety limit
|
|
5000
|
-
while (maxIterations-- > 0) {
|
|
5001
|
-
const state = loadSession(sessionId);
|
|
5002
|
-
if (!state) {
|
|
5003
|
-
appendRuntimeLog("ERROR", `AUTO_HANDOFF: Session ${sessionId} disappeared`);
|
|
5004
|
-
return;
|
|
5005
|
-
}
|
|
5006
|
-
if (state.status !== "active") {
|
|
5007
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF: Session ${sessionId} status=${state.status}, turns done`);
|
|
5008
|
-
break;
|
|
5009
|
-
}
|
|
5010
|
-
|
|
5011
|
-
const speaker = state.current_speaker;
|
|
5012
|
-
if (speaker === "none") break;
|
|
5013
|
-
|
|
5014
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN: ${sessionId} | speaker: ${speaker} | round: ${state.current_round}/${state.max_rounds}`);
|
|
5015
|
-
|
|
5016
|
-
const runResult = await runUntilBlockedCore(sessionId, { maxTurns: 1, includeHistoryEntries: 3 });
|
|
5017
|
-
const step = runResult.steps.at(-1) || null;
|
|
5018
|
-
if (!runResult.ok || runResult.status === "blocked") {
|
|
5019
|
-
appendRuntimeLog("WARN", `AUTO_HANDOFF_TURN_BLOCKED: ${sessionId} | speaker: ${speaker} | ${runResult.block_reason || runResult.error || "unknown"}`);
|
|
5020
|
-
break;
|
|
5021
|
-
}
|
|
5022
|
-
|
|
5023
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_TURN_OK: ${sessionId} | speaker: ${speaker} | ${step?.elapsedMs || 0}ms`);
|
|
5024
|
-
}
|
|
5025
|
-
|
|
5026
|
-
// Phase 2: Generate structured synthesis
|
|
5027
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZE: ${sessionId}`);
|
|
5028
|
-
let synthResult = await generateAutoSynthesis(sessionId);
|
|
5029
|
-
|
|
5030
|
-
// Phase 3: Call synthesize (reuse existing logic)
|
|
5031
|
-
const state = loadSession(sessionId);
|
|
5032
|
-
if (!state) return;
|
|
5033
|
-
|
|
5034
|
-
// Fallback: if synthesis generation failed, build a basic structure from the discussion
|
|
5035
|
-
if (!synthResult || (!synthResult.summary && !synthResult.actionable_tasks)) {
|
|
5036
|
-
appendRuntimeLog("WARN", `AUTO_HANDOFF_SYNTH_FALLBACK: ${sessionId} | Building fallback from discussion log`);
|
|
5037
|
-
const turns = state.log || [];
|
|
5038
|
-
const fallbackSummary = turns.length > 0
|
|
5039
|
-
? `Deliberation on "${state.topic}" completed with ${turns.length} turns from ${[...new Set(turns.map(t => t.speaker))].join(", ")}.`
|
|
5040
|
-
: `Deliberation on "${state.topic}" completed.`;
|
|
5041
|
-
synthResult = {
|
|
5042
|
-
summary: fallbackSummary,
|
|
5043
|
-
decisions: [`Discussed: ${state.topic}`],
|
|
5044
|
-
actionable_tasks: [],
|
|
5045
|
-
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")}`,
|
|
5046
|
-
};
|
|
5047
|
-
}
|
|
5048
|
-
|
|
5049
|
-
const markdownSynthesis = synthResult?.markdown_synthesis ||
|
|
5050
|
-
`# 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")}`;
|
|
5051
|
-
|
|
5052
|
-
const structured = {
|
|
5053
|
-
summary: synthResult.summary || "",
|
|
5054
|
-
decisions: synthResult.decisions || [],
|
|
5055
|
-
actionable_tasks: synthResult.actionable_tasks || [],
|
|
5056
|
-
};
|
|
5057
|
-
|
|
5058
|
-
// Apply synthesis to session
|
|
5059
|
-
withSessionLock(sessionId, () => {
|
|
5060
|
-
const loaded = loadSession(sessionId);
|
|
5061
|
-
if (!loaded) return;
|
|
5062
|
-
loaded.synthesis = markdownSynthesis;
|
|
5063
|
-
loaded.structured_synthesis = structured;
|
|
5064
|
-
loaded.execution_contract = buildExecutionContract({ state: loaded, structured });
|
|
5065
|
-
loaded.status = "completed";
|
|
5066
|
-
loaded.current_speaker = "none";
|
|
5067
|
-
saveSession(loaded);
|
|
5068
|
-
archiveState(loaded);
|
|
5069
|
-
cleanupSyncMarkdown(loaded);
|
|
5070
|
-
|
|
5071
|
-
const sessionFile = getSessionFile(loaded);
|
|
5072
|
-
try { if (fs.existsSync(sessionFile)) fs.unlinkSync(sessionFile); } catch {}
|
|
5073
|
-
});
|
|
5074
|
-
|
|
5075
|
-
closeMonitorTerminal(sessionId, getSessionWindowIds(state));
|
|
5076
|
-
|
|
5077
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_SYNTHESIZED: ${sessionId}`);
|
|
5078
|
-
|
|
5079
|
-
// Phase 4: Notify telepty bus with full structured data for dustcraw to consume
|
|
5080
|
-
if (state.auto_execute) {
|
|
5081
|
-
const envelope = buildTeleptySynthesisEnvelope({
|
|
5082
|
-
state,
|
|
5083
|
-
synthesis: markdownSynthesis,
|
|
5084
|
-
structured,
|
|
5085
|
-
});
|
|
5086
|
-
await notifyTeleptyBus(envelope).catch(() => {});
|
|
5087
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_NOTIFIED: ${sessionId} | telepty event sent`);
|
|
5088
|
-
}
|
|
5089
|
-
|
|
5090
|
-
appendRuntimeLog("INFO", `AUTO_HANDOFF_COMPLETE: ${sessionId}`);
|
|
5091
|
-
} catch (err) {
|
|
5092
|
-
appendRuntimeLog("ERROR", `AUTO_HANDOFF_ERROR: ${sessionId} | ${err.message}`);
|
|
5093
|
-
}
|
|
5094
|
-
}
|
|
5095
|
-
|
|
5096
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
1431
|
+
// Auto-handoff orchestrator helpers — moved to lib/transport.js
|
|
5097
1432
|
|
|
5098
1433
|
server.tool(
|
|
5099
1434
|
"deliberation_cli_auto_turn",
|
|
@@ -5735,6 +2070,21 @@ server.tool(
|
|
|
5735
2070
|
// Notify brain ingest if endpoint configured
|
|
5736
2071
|
callBrainIngest(state.execution_contract).catch(() => {}); // fire-and-forget
|
|
5737
2072
|
|
|
2073
|
+
// Emit lesson_learned events for orchestrator lessons.json auto-population
|
|
2074
|
+
if (state.execution_contract?.decisions?.length > 0) {
|
|
2075
|
+
const lessonEvent = {
|
|
2076
|
+
type: "lesson_learned",
|
|
2077
|
+
session_id: state.id,
|
|
2078
|
+
timestamp: new Date().toISOString(),
|
|
2079
|
+
project: state.project || getProjectSlug(),
|
|
2080
|
+
category: "decision",
|
|
2081
|
+
lesson: state.execution_contract.summary || state.synthesis?.slice(0, 200) || "",
|
|
2082
|
+
decisions: state.execution_contract.decisions,
|
|
2083
|
+
};
|
|
2084
|
+
notifyTeleptyBus(lessonEvent).catch(() => {}); // fire-and-forget
|
|
2085
|
+
appendRuntimeLog("INFO", `LESSON_LEARNED: ${state.id} | decisions: ${state.execution_contract.decisions.length} | project: ${lessonEvent.project}`);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
5738
2088
|
return {
|
|
5739
2089
|
content: [{
|
|
5740
2090
|
type: "text",
|
|
@@ -5959,92 +2309,7 @@ server.tool(
|
|
|
5959
2309
|
})
|
|
5960
2310
|
);
|
|
5961
2311
|
|
|
5962
|
-
//
|
|
5963
|
-
|
|
5964
|
-
function invokeCliReviewer(command, prompt, timeoutMs) {
|
|
5965
|
-
const hint = CLI_INVOCATION_HINTS[command];
|
|
5966
|
-
let args;
|
|
5967
|
-
let opts = { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024, windowsHide: true };
|
|
5968
|
-
const env = { ...process.env };
|
|
5969
|
-
|
|
5970
|
-
switch (command) {
|
|
5971
|
-
case "claude":
|
|
5972
|
-
if (hint?.envPrefix?.includes("CLAUDECODE=")) delete env.CLAUDECODE;
|
|
5973
|
-
args = ["-p", "--output-format", "text", "--no-input"];
|
|
5974
|
-
opts.input = prompt;
|
|
5975
|
-
break;
|
|
5976
|
-
case "codex":
|
|
5977
|
-
args = ["exec", "-"];
|
|
5978
|
-
opts.input = prompt;
|
|
5979
|
-
break;
|
|
5980
|
-
case "gemini":
|
|
5981
|
-
args = ["-p", prompt];
|
|
5982
|
-
opts.stdio = ["ignore", "pipe", "pipe"];
|
|
5983
|
-
break;
|
|
5984
|
-
default: {
|
|
5985
|
-
const flags = hint?.flags ? hint.flags.split(/\s+/).filter(Boolean) : ["-p"];
|
|
5986
|
-
args = [...flags, prompt];
|
|
5987
|
-
opts.stdio = ["ignore", "pipe", "pipe"];
|
|
5988
|
-
break;
|
|
5989
|
-
}
|
|
5990
|
-
}
|
|
5991
|
-
|
|
5992
|
-
try {
|
|
5993
|
-
const result = execFileSync(command, args, { ...opts, env });
|
|
5994
|
-
let cleaned = result;
|
|
5995
|
-
if (command === "codex") {
|
|
5996
|
-
const lines = result.split("\n");
|
|
5997
|
-
const codexLineIdx = lines.findIndex(l => l.trim() === "codex");
|
|
5998
|
-
if (codexLineIdx !== -1) {
|
|
5999
|
-
cleaned = lines.slice(codexLineIdx + 1)
|
|
6000
|
-
.filter(line => !/^(tokens used$|^[0-9,]*$)/.test(line))
|
|
6001
|
-
.join("\n");
|
|
6002
|
-
}
|
|
6003
|
-
}
|
|
6004
|
-
return { ok: true, response: cleaned.trim() };
|
|
6005
|
-
} catch (error) {
|
|
6006
|
-
if (error && error.killed) {
|
|
6007
|
-
return { ok: false, error: "timeout" };
|
|
6008
|
-
}
|
|
6009
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
6010
|
-
return { ok: false, error: msg };
|
|
6011
|
-
}
|
|
6012
|
-
}
|
|
6013
|
-
|
|
6014
|
-
function buildReviewPrompt(context, question, priorReviews) {
|
|
6015
|
-
let prompt = `You are a code reviewer. Provide a concise, structured review.\n\n`;
|
|
6016
|
-
prompt += `## Context\n${context}\n\n`;
|
|
6017
|
-
prompt += `## Review Question\n${question}\n\n`;
|
|
6018
|
-
if (priorReviews.length > 0) {
|
|
6019
|
-
prompt += `## Prior Reviews\n`;
|
|
6020
|
-
for (const r of priorReviews) {
|
|
6021
|
-
prompt += `### ${r.reviewer}\n${r.response}\n\n`;
|
|
6022
|
-
}
|
|
6023
|
-
}
|
|
6024
|
-
prompt += `Respond with your review. Be specific about issues, risks, and suggestions.`;
|
|
6025
|
-
return prompt;
|
|
6026
|
-
}
|
|
6027
|
-
|
|
6028
|
-
function synthesizeReviews(context, question, reviews) {
|
|
6029
|
-
if (reviews.length === 0) return "(No reviews completed)";
|
|
6030
|
-
|
|
6031
|
-
let synthesis = `## Review Synthesis\n\n`;
|
|
6032
|
-
synthesis += `**Question:** ${question}\n`;
|
|
6033
|
-
synthesis += `**Reviews:** ${reviews.length}\n\n`;
|
|
6034
|
-
|
|
6035
|
-
synthesis += `### Individual Reviews\n\n`;
|
|
6036
|
-
for (const r of reviews) {
|
|
6037
|
-
synthesis += `#### ${r.reviewer}\n${r.response}\n\n`;
|
|
6038
|
-
}
|
|
6039
|
-
|
|
6040
|
-
if (reviews.length > 1) {
|
|
6041
|
-
synthesis += `### Summary\n`;
|
|
6042
|
-
synthesis += `${reviews.length} reviewer(s) provided feedback on: ${question}\n`;
|
|
6043
|
-
synthesis += `Reviewers: ${reviews.map(r => r.reviewer).join(", ")}\n`;
|
|
6044
|
-
}
|
|
6045
|
-
|
|
6046
|
-
return synthesis;
|
|
6047
|
-
}
|
|
2312
|
+
// invokeCliReviewer, buildReviewPrompt, synthesizeReviews — moved to lib/transport.js
|
|
6048
2313
|
|
|
6049
2314
|
server.tool(
|
|
6050
2315
|
"deliberation_request_review",
|
|
@@ -6563,6 +2828,21 @@ server.tool(
|
|
|
6563
2828
|
actionPlan = state.actionPlan;
|
|
6564
2829
|
|
|
6565
2830
|
appendRuntimeLog("INFO", `DECISION_COMPLETE: ${resolved} | conflicts_resolved: ${responses.length} | decision: ${(actionPlan?.decision || "").slice(0, 60)}`);
|
|
2831
|
+
|
|
2832
|
+
// Emit lesson_learned for decision outcomes
|
|
2833
|
+
if (actionPlan?.decision) {
|
|
2834
|
+
const lessonEvent = {
|
|
2835
|
+
type: "lesson_learned",
|
|
2836
|
+
session_id: resolved,
|
|
2837
|
+
timestamp: new Date().toISOString(),
|
|
2838
|
+
project: state.project || getProjectSlug(),
|
|
2839
|
+
category: "decision",
|
|
2840
|
+
lesson: actionPlan.decision,
|
|
2841
|
+
decisions: actionPlan.decision ? [actionPlan.decision] : [],
|
|
2842
|
+
};
|
|
2843
|
+
notifyTeleptyBus(lessonEvent).catch(() => {}); // fire-and-forget
|
|
2844
|
+
appendRuntimeLog("INFO", `LESSON_LEARNED: ${resolved} | decision: ${actionPlan.decision.slice(0, 60)}`);
|
|
2845
|
+
}
|
|
6566
2846
|
});
|
|
6567
2847
|
|
|
6568
2848
|
if (synthesisText.startsWith("❌")) {
|
|
@@ -6719,4 +2999,4 @@ if (__entryFile && path.resolve(__currentFile) === __entryFile) {
|
|
|
6719
2999
|
}
|
|
6720
3000
|
|
|
6721
3001
|
// ── Test exports (used by vitest) ──
|
|
6722
|
-
export { selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptyTurnCompletedEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
|
|
3002
|
+
export { checkToolEntitlement, selectNextSpeaker, loadRolePrompt, inferSuggestedRole, parseVotes, ROLE_KEYWORDS, ROLE_HEADING_MARKERS, loadRolePresets, applyRolePreset, detectDegradationLevels, formatDegradationReport, DEGRADATION_TIERS, DECISION_STAGES, STAGE_TRANSITIONS, createDecisionSession, advanceStage, buildConflictMap, parseOpinionFromResponse, buildOpinionPrompt, generateConflictQuestions, buildSynthesis, buildActionPlan, loadTemplates, matchTemplate, hasExplicitBrowserParticipantSelection, resolveIncludeBrowserSpeakers, confirmSpeakerSelectionToken, validateSpeakerSelectionRequest, truncatePromptText, getPromptBudgetForSpeaker, formatRecentLogForPrompt, getCliAutoTurnTimeoutSec, getCliExecArgs, buildCliAutoTurnFailureText, buildClipboardTurnPrompt, getProjectStateDir, loadSession, saveSession, listActiveSessions, multipleSessionsError, findSessionRecord, mapParticipantProfiles, formatSpeakerCandidatesReport, buildTeleptyTurnRequestEnvelope, buildTeleptyTurnCompletedEnvelope, buildTeleptySynthesisEnvelope, validateTeleptyEnvelope, registerPendingTeleptyTurnRequest, handleTeleptyBusMessage, completePendingTeleptySemantic, cleanupPendingTeleptyTurn, getTeleptySessionHealth, TELEPTY_TRANSPORT_TIMEOUT_MS, TELEPTY_SEMANTIC_TIMEOUT_MS };
|