@dmsdc-ai/aigentry-deliberation 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/browser-control-port.js +563 -0
- package/degradation-state-machine.js +206 -0
- package/index.js +3156 -0
- package/install.js +202 -0
- package/observer.js +483 -0
- package/package.json +65 -0
- package/public/index.html +1478 -0
- package/selectors/chatgpt-extension.json +21 -0
- package/selectors/chatgpt.json +20 -0
- package/selectors/claude-extension.json +21 -0
- package/selectors/claude.json +19 -0
- package/selectors/extension-providers.json +24 -0
- package/selectors/gemini-extension.json +21 -0
- package/selectors/gemini.json +19 -0
- package/selectors/role-presets.json +28 -0
- package/selectors/roles/critic.md +12 -0
- package/selectors/roles/free.md +1 -0
- package/selectors/roles/implementer.md +12 -0
- package/selectors/roles/mediator.md +12 -0
- package/selectors/roles/researcher.md +12 -0
- package/session-monitor-win.js +94 -0
- package/session-monitor.sh +316 -0
- package/skills/deliberation/SKILL.md +164 -0
- package/skills/deliberation-executor/SKILL.md +86 -0
package/install.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deliberation MCP Server β One-click installer
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx @dmsdc-ai/aigentry-deliberation install
|
|
8
|
+
* node install.js
|
|
9
|
+
*
|
|
10
|
+
* What it does:
|
|
11
|
+
* 1. Copies server files to ~/.local/lib/mcp-deliberation/
|
|
12
|
+
* 2. Installs npm dependencies
|
|
13
|
+
* 3. Registers MCP server in ~/.claude/.mcp.json
|
|
14
|
+
* 4. Ready to use β next Claude Code session will auto-load
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
24
|
+
const IS_WIN = process.platform === "win32";
|
|
25
|
+
const INSTALL_DIR = IS_WIN
|
|
26
|
+
? path.join(process.env.LOCALAPPDATA || path.join(HOME, "AppData", "Local"), "mcp-deliberation")
|
|
27
|
+
: path.join(HOME, ".local", "lib", "mcp-deliberation");
|
|
28
|
+
const MCP_CONFIG = path.join(HOME, ".claude", ".mcp.json");
|
|
29
|
+
|
|
30
|
+
/** Normalize path to forward slashes for JSON config (Windows backslash β forward slash) */
|
|
31
|
+
function toForwardSlash(p) {
|
|
32
|
+
return p.replace(/\\/g, "/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FILES_TO_COPY = [
|
|
36
|
+
"index.js",
|
|
37
|
+
"observer.js",
|
|
38
|
+
"browser-control-port.js",
|
|
39
|
+
"degradation-state-machine.js",
|
|
40
|
+
"session-monitor.sh",
|
|
41
|
+
"session-monitor-win.js",
|
|
42
|
+
"package.json",
|
|
43
|
+
"package-lock.json",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const DIRS_TO_COPY = ["selectors", "public", "skills"];
|
|
47
|
+
|
|
48
|
+
function log(msg) {
|
|
49
|
+
console.log(` ${msg}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function copyFileIfExists(src, dest) {
|
|
53
|
+
if (fs.existsSync(src)) {
|
|
54
|
+
fs.copyFileSync(src, dest);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function copyDirRecursive(src, dest) {
|
|
61
|
+
if (!fs.existsSync(src)) return;
|
|
62
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
63
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
64
|
+
const srcPath = path.join(src, entry.name);
|
|
65
|
+
const destPath = path.join(dest, entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
copyDirRecursive(srcPath, destPath);
|
|
68
|
+
} else {
|
|
69
|
+
fs.copyFileSync(srcPath, destPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function install() {
|
|
75
|
+
console.log("\nπ― Deliberation MCP Server β μ€μΉ μμ\n");
|
|
76
|
+
|
|
77
|
+
// Step 1: Create install directory
|
|
78
|
+
log("π μ€μΉ λλ ν 리 μμ±...");
|
|
79
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
80
|
+
log(` β ${INSTALL_DIR}`);
|
|
81
|
+
|
|
82
|
+
// Step 2: Copy files
|
|
83
|
+
log("π¦ μλ² νμΌ λ³΅μ¬...");
|
|
84
|
+
let copied = 0;
|
|
85
|
+
for (const file of FILES_TO_COPY) {
|
|
86
|
+
if (copyFileIfExists(path.join(__dirname, file), path.join(INSTALL_DIR, file))) {
|
|
87
|
+
copied++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const dir of DIRS_TO_COPY) {
|
|
91
|
+
const src = path.join(__dirname, dir);
|
|
92
|
+
if (fs.existsSync(src)) {
|
|
93
|
+
copyDirRecursive(src, path.join(INSTALL_DIR, dir));
|
|
94
|
+
copied++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
log(` β ${copied}κ° νλͺ© λ³΅μ¬ μλ£`);
|
|
98
|
+
|
|
99
|
+
// Step 3: Install dependencies
|
|
100
|
+
log("π₯ μμ‘΄μ± μ€μΉ...");
|
|
101
|
+
try {
|
|
102
|
+
execSync("npm install --production --no-audit --no-fund", {
|
|
103
|
+
cwd: INSTALL_DIR,
|
|
104
|
+
stdio: "pipe",
|
|
105
|
+
});
|
|
106
|
+
log(" β npm install μλ£");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log(` β οΈ npm install μ€ν¨: ${err.message}`);
|
|
109
|
+
log(" μλ μ€ν: cd ~/.local/lib/mcp-deliberation && npm install");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Step 4: Register MCP server
|
|
113
|
+
log("π§ Claude Code MCP μλ² λ±λ‘...");
|
|
114
|
+
const claudeDir = path.join(HOME, ".claude");
|
|
115
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
116
|
+
|
|
117
|
+
let mcpConfig = {};
|
|
118
|
+
if (fs.existsSync(MCP_CONFIG)) {
|
|
119
|
+
try {
|
|
120
|
+
mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
|
|
121
|
+
} catch {
|
|
122
|
+
mcpConfig = {};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
|
|
127
|
+
|
|
128
|
+
const alreadyRegistered = !!mcpConfig.mcpServers.deliberation;
|
|
129
|
+
mcpConfig.mcpServers.deliberation = {
|
|
130
|
+
command: "node",
|
|
131
|
+
args: [toForwardSlash(path.join(INSTALL_DIR, "index.js"))],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
|
|
135
|
+
log(alreadyRegistered
|
|
136
|
+
? " β κΈ°μ‘΄ λ±λ‘ μ
λ°μ΄νΈ μλ£"
|
|
137
|
+
: " β μλ‘ λ±λ‘ μλ£");
|
|
138
|
+
|
|
139
|
+
// Step 5: Make session-monitor.sh executable
|
|
140
|
+
const monitorScript = path.join(INSTALL_DIR, "session-monitor.sh");
|
|
141
|
+
if (fs.existsSync(monitorScript)) {
|
|
142
|
+
try {
|
|
143
|
+
fs.chmodSync(monitorScript, 0o755);
|
|
144
|
+
} catch { /* ignore on Windows */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Step 6: Preserve existing config
|
|
148
|
+
const configPath = path.join(INSTALL_DIR, "config.json");
|
|
149
|
+
if (!fs.existsSync(configPath)) {
|
|
150
|
+
fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Done
|
|
154
|
+
console.log("\nβ
μ€μΉ μλ£!\n");
|
|
155
|
+
console.log(" λ€μ λ¨κ³:");
|
|
156
|
+
console.log(" 1. Claude Code μΈμ
μ μ¬μμνμΈμ");
|
|
157
|
+
console.log(" 2. \"ν λ‘ μμν΄\" λλ deliberation_start(topic: \"...\") νΈμΆ");
|
|
158
|
+
console.log(" 3. 첫 μ¬μ© μ μ¨λ³΄λ© μμ λκ° κΈ°λ³Έ μ€μ μ μλ΄ν©λλ€\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Entry point
|
|
162
|
+
const args = process.argv.slice(2);
|
|
163
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
164
|
+
console.log(`
|
|
165
|
+
Deliberation MCP Server Installer
|
|
166
|
+
|
|
167
|
+
Usage:
|
|
168
|
+
npx @dmsdc-ai/aigentry-deliberation install
|
|
169
|
+
node install.js
|
|
170
|
+
|
|
171
|
+
Options:
|
|
172
|
+
--help, -h μ΄ λμλ§ νμ
|
|
173
|
+
--uninstall μλ² μ κ±°
|
|
174
|
+
|
|
175
|
+
μ€μΉ κ²½λ‘: ${INSTALL_DIR}
|
|
176
|
+
MCP μ€μ : ${MCP_CONFIG}
|
|
177
|
+
`);
|
|
178
|
+
} else if (args.includes("--uninstall") || args.includes("uninstall")) {
|
|
179
|
+
console.log("\nποΈ Deliberation MCP Server μ κ±°\n");
|
|
180
|
+
|
|
181
|
+
// Remove from MCP config
|
|
182
|
+
if (fs.existsSync(MCP_CONFIG)) {
|
|
183
|
+
try {
|
|
184
|
+
const mcpConfig = JSON.parse(fs.readFileSync(MCP_CONFIG, "utf-8"));
|
|
185
|
+
if (mcpConfig.mcpServers?.deliberation) {
|
|
186
|
+
delete mcpConfig.mcpServers.deliberation;
|
|
187
|
+
fs.writeFileSync(MCP_CONFIG, JSON.stringify(mcpConfig, null, 2));
|
|
188
|
+
log("MCP λ±λ‘ ν΄μ μλ£");
|
|
189
|
+
}
|
|
190
|
+
} catch { /* ignore */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove install directory
|
|
194
|
+
if (fs.existsSync(INSTALL_DIR)) {
|
|
195
|
+
fs.rmSync(INSTALL_DIR, { recursive: true, force: true });
|
|
196
|
+
log("μ€μΉ λλ ν 리 μμ μλ£");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log("\nβ
μ κ±° μλ£. Claude Codeλ₯Ό μ¬μμνμΈμ.\n");
|
|
200
|
+
} else {
|
|
201
|
+
install();
|
|
202
|
+
}
|
package/observer.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deliberation Observer β SSE + Minimal Dashboard
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node observer.js [--port 3847]
|
|
7
|
+
* npx aigentry-deliberation --dashboard
|
|
8
|
+
*
|
|
9
|
+
* Provides:
|
|
10
|
+
* GET / β HTML dashboard
|
|
11
|
+
* GET /api/sessions β JSON list of active sessions
|
|
12
|
+
* GET /api/sessions/:id β JSON session detail
|
|
13
|
+
* GET /api/sessions/:id/stream β SSE real-time log stream
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from "http";
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import path from "path";
|
|
19
|
+
import os from "os";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
import { execSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const STATE_DIR = path.join(os.homedir(), ".local", "lib", "mcp-deliberation", "state");
|
|
25
|
+
const CONFIG_PATH = path.join(os.homedir(), ".local", "lib", "mcp-deliberation", "config.json");
|
|
26
|
+
const DEFAULT_CLI_CANDIDATES = ["claude", "codex", "gemini", "qwen", "chatgpt", "aider", "llm", "opencode", "cursor", "continue"];
|
|
27
|
+
const CLI_LABELS = {
|
|
28
|
+
claude: "Claude", codex: "Codex", gemini: "Gemini", qwen: "Qwen",
|
|
29
|
+
chatgpt: "ChatGPT", aider: "Aider", llm: "LLM (Simon)",
|
|
30
|
+
opencode: "OpenCode", cursor: "Cursor", continue: "Continue"
|
|
31
|
+
};
|
|
32
|
+
const DEFAULT_PORT = 3847;
|
|
33
|
+
|
|
34
|
+
function getProjectSlug() {
|
|
35
|
+
const cwd = process.cwd();
|
|
36
|
+
return path.basename(cwd).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listSessions(projectSlug) {
|
|
40
|
+
const sessionsDir = path.join(STATE_DIR, projectSlug || "", "sessions");
|
|
41
|
+
try {
|
|
42
|
+
const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".json"));
|
|
43
|
+
return files.map(f => {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), "utf-8"));
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}).filter(Boolean);
|
|
48
|
+
} catch { return []; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findSession(sessionId) {
|
|
52
|
+
// Search all project slugs
|
|
53
|
+
try {
|
|
54
|
+
const slugs = fs.readdirSync(STATE_DIR).filter(f => {
|
|
55
|
+
const stat = fs.statSync(path.join(STATE_DIR, f));
|
|
56
|
+
return stat.isDirectory();
|
|
57
|
+
});
|
|
58
|
+
for (const slug of slugs) {
|
|
59
|
+
const sessionsDir = path.join(STATE_DIR, slug, "sessions");
|
|
60
|
+
const filePath = path.join(sessionsDir, `${sessionId}.json`);
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
63
|
+
} catch { continue; }
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getAllActiveSessions() {
|
|
70
|
+
const all = [];
|
|
71
|
+
try {
|
|
72
|
+
const slugs = fs.readdirSync(STATE_DIR).filter(f => {
|
|
73
|
+
try { return fs.statSync(path.join(STATE_DIR, f)).isDirectory(); } catch { return false; }
|
|
74
|
+
});
|
|
75
|
+
for (const slug of slugs) {
|
|
76
|
+
const sessions = listSessions(slug);
|
|
77
|
+
all.push(...sessions.filter(s => s.status === "active"));
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
return all;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// SSE connections per session
|
|
84
|
+
const sseClients = new Map();
|
|
85
|
+
|
|
86
|
+
function broadcastSessionUpdate(sessionId, event, data) {
|
|
87
|
+
const clients = sseClients.get(sessionId) || [];
|
|
88
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
89
|
+
clients.forEach(res => {
|
|
90
|
+
try { res.write(payload); } catch {}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Poll for session changes
|
|
95
|
+
const sessionSnapshots = new Map();
|
|
96
|
+
function pollSessions() {
|
|
97
|
+
for (const [sessionId, clients] of sseClients.entries()) {
|
|
98
|
+
if (clients.length === 0) continue;
|
|
99
|
+
const session = findSession(sessionId);
|
|
100
|
+
if (!session) continue;
|
|
101
|
+
const prev = sessionSnapshots.get(sessionId);
|
|
102
|
+
const currentLogLen = session.log?.length || 0;
|
|
103
|
+
const prevLogLen = prev?.logLength || 0;
|
|
104
|
+
|
|
105
|
+
if (currentLogLen > prevLogLen) {
|
|
106
|
+
// New log entries
|
|
107
|
+
const newEntries = session.log.slice(prevLogLen);
|
|
108
|
+
for (const entry of newEntries) {
|
|
109
|
+
broadcastSessionUpdate(sessionId, "turn", entry);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (prev?.status !== session.status) {
|
|
114
|
+
broadcastSessionUpdate(sessionId, "status", {
|
|
115
|
+
status: session.status,
|
|
116
|
+
current_speaker: session.current_speaker,
|
|
117
|
+
current_round: session.current_round,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
sessionSnapshots.set(sessionId, {
|
|
122
|
+
logLength: currentLogLen,
|
|
123
|
+
status: session.status,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// HTML dashboard
|
|
129
|
+
function getDashboardHtml() {
|
|
130
|
+
const htmlPath = path.join(__dirname, "public", "index.html");
|
|
131
|
+
try {
|
|
132
|
+
return fs.readFileSync(htmlPath, "utf-8");
|
|
133
|
+
} catch {
|
|
134
|
+
return `<!DOCTYPE html><html><body><h1>Dashboard file not found</h1><p>Expected: ${htmlPath}</p></body></html>`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function loadConfig() {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveConfig(config) {
|
|
147
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
148
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkCliInstalled(name) {
|
|
153
|
+
try {
|
|
154
|
+
execSync(`which ${name}`, { stdio: 'ignore' });
|
|
155
|
+
return true;
|
|
156
|
+
} catch { return false; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function fetchCdpTargets() {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const req = http.get("http://localhost:9222/json", (res) => {
|
|
162
|
+
let data = "";
|
|
163
|
+
res.on("data", chunk => data += chunk);
|
|
164
|
+
res.on("end", () => {
|
|
165
|
+
try {
|
|
166
|
+
const targets = JSON.parse(data);
|
|
167
|
+
const llmPatterns = /claude|chatgpt|openai|gemini|bard|copilot|perplexity|deepseek|qwen/i;
|
|
168
|
+
const llmTabs = targets
|
|
169
|
+
.filter(t => t.type === "page" && (llmPatterns.test(t.url) || llmPatterns.test(t.title)))
|
|
170
|
+
.map(t => ({ title: t.title, url: t.url, id: t.id }));
|
|
171
|
+
resolve({ cdp_available: true, tabs: llmTabs });
|
|
172
|
+
} catch { resolve({ cdp_available: true, tabs: [] }); }
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
req.on("error", () => resolve({ cdp_available: false, tabs: [], message: "CDP not available. Launch Chrome with --remote-debugging-port=9222" }));
|
|
176
|
+
req.setTimeout(2000, () => { req.destroy(); resolve({ cdp_available: false, tabs: [], message: "CDP connection timeout" }); });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createSession(topic, speakers, maxRounds) {
|
|
181
|
+
const id = `obs-${Date.now().toString(36)}`;
|
|
182
|
+
const projectSlug = getProjectSlug();
|
|
183
|
+
const sessionsDir = path.join(STATE_DIR, projectSlug, "sessions");
|
|
184
|
+
|
|
185
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
const session = {
|
|
188
|
+
id,
|
|
189
|
+
topic: topic || "Untitled Discussion",
|
|
190
|
+
speakers: speakers || ["claude", "gemini"],
|
|
191
|
+
status: "active",
|
|
192
|
+
current_round: 1,
|
|
193
|
+
max_rounds: maxRounds || 3,
|
|
194
|
+
current_speaker: speakers?.[0] || "claude",
|
|
195
|
+
ordering_strategy: "cyclic",
|
|
196
|
+
log: [],
|
|
197
|
+
created_at: new Date().toISOString(),
|
|
198
|
+
created_by: "observer",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify(session, null, 2));
|
|
202
|
+
return session;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function computeStats() {
|
|
206
|
+
const stats = {
|
|
207
|
+
total_sessions: 0,
|
|
208
|
+
total_turns: 0,
|
|
209
|
+
speakers: {},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const slugs = fs.readdirSync(STATE_DIR).filter(f => {
|
|
214
|
+
try { return fs.statSync(path.join(STATE_DIR, f)).isDirectory(); } catch { return false; }
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
for (const slug of slugs) {
|
|
218
|
+
const sessionsDir = path.join(STATE_DIR, slug, "sessions");
|
|
219
|
+
let files;
|
|
220
|
+
try { files = fs.readdirSync(sessionsDir).filter(f => f.endsWith(".json")); } catch { continue; }
|
|
221
|
+
|
|
222
|
+
for (const file of files) {
|
|
223
|
+
try {
|
|
224
|
+
const session = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), "utf-8"));
|
|
225
|
+
stats.total_sessions++;
|
|
226
|
+
|
|
227
|
+
for (const entry of (session.log || [])) {
|
|
228
|
+
stats.total_turns++;
|
|
229
|
+
const sp = entry.speaker;
|
|
230
|
+
if (!sp) continue;
|
|
231
|
+
|
|
232
|
+
if (!stats.speakers[sp]) {
|
|
233
|
+
stats.speakers[sp] = { turns: 0, votes: { agree: 0, disagree: 0, conditional: 0 }, total_length: 0 };
|
|
234
|
+
}
|
|
235
|
+
stats.speakers[sp].turns++;
|
|
236
|
+
stats.speakers[sp].total_length += (entry.content || "").length;
|
|
237
|
+
|
|
238
|
+
for (const v of (entry.votes || [])) {
|
|
239
|
+
const key = v.toLowerCase();
|
|
240
|
+
if (key in stats.speakers[sp].votes) stats.speakers[sp].votes[key]++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch { continue; }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const sp of Object.keys(stats.speakers)) {
|
|
248
|
+
const s = stats.speakers[sp];
|
|
249
|
+
s.avg_length = s.turns > 0 ? Math.round(s.total_length / s.turns) : 0;
|
|
250
|
+
delete s.total_length;
|
|
251
|
+
}
|
|
252
|
+
} catch {}
|
|
253
|
+
|
|
254
|
+
return stats;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// HTTP Server
|
|
258
|
+
function createServer(port) {
|
|
259
|
+
const server = http.createServer((req, res) => {
|
|
260
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
261
|
+
const pathname = url.pathname;
|
|
262
|
+
|
|
263
|
+
// CORS
|
|
264
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
265
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
266
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
267
|
+
|
|
268
|
+
// OPTIONS preflight
|
|
269
|
+
if (req.method === "OPTIONS") {
|
|
270
|
+
res.writeHead(204);
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Routes
|
|
276
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
277
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
278
|
+
res.end(getDashboardHtml());
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (pathname === "/api/sessions") {
|
|
283
|
+
const sessions = getAllActiveSessions();
|
|
284
|
+
const summary = sessions.map(s => ({
|
|
285
|
+
id: s.id,
|
|
286
|
+
topic: s.topic,
|
|
287
|
+
status: s.status,
|
|
288
|
+
current_round: s.current_round,
|
|
289
|
+
max_rounds: s.max_rounds,
|
|
290
|
+
current_speaker: s.current_speaker,
|
|
291
|
+
speakers: s.speakers,
|
|
292
|
+
log_count: s.log?.length || 0,
|
|
293
|
+
created: s.created,
|
|
294
|
+
}));
|
|
295
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
296
|
+
res.end(JSON.stringify(summary));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (pathname === "/api/sessions/start" && req.method === "POST") {
|
|
301
|
+
let body = "";
|
|
302
|
+
req.on("data", chunk => { body += chunk; });
|
|
303
|
+
req.on("end", () => {
|
|
304
|
+
let parsed;
|
|
305
|
+
try {
|
|
306
|
+
parsed = JSON.parse(body);
|
|
307
|
+
} catch {
|
|
308
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
309
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!parsed.topic) {
|
|
313
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
314
|
+
res.end(JSON.stringify({ error: "topic is required" }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const session = createSession(parsed.topic, parsed.speakers, parsed.max_rounds);
|
|
319
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
320
|
+
res.end(JSON.stringify(session));
|
|
321
|
+
} catch (err) {
|
|
322
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
323
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
|
|
330
|
+
if (sessionMatch) {
|
|
331
|
+
const session = findSession(sessionMatch[1]);
|
|
332
|
+
if (!session) {
|
|
333
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
334
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
338
|
+
res.end(JSON.stringify(session));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const streamMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/stream$/);
|
|
343
|
+
if (streamMatch) {
|
|
344
|
+
const sessionId = streamMatch[1];
|
|
345
|
+
const session = findSession(sessionId);
|
|
346
|
+
if (!session) {
|
|
347
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
348
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// SSE setup
|
|
353
|
+
res.writeHead(200, {
|
|
354
|
+
"Content-Type": "text/event-stream",
|
|
355
|
+
"Cache-Control": "no-cache",
|
|
356
|
+
"Connection": "keep-alive",
|
|
357
|
+
});
|
|
358
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ session_id: sessionId })}\n\n`);
|
|
359
|
+
|
|
360
|
+
// Send current state
|
|
361
|
+
res.write(`event: snapshot\ndata: ${JSON.stringify({
|
|
362
|
+
status: session.status,
|
|
363
|
+
current_speaker: session.current_speaker,
|
|
364
|
+
current_round: session.current_round,
|
|
365
|
+
max_rounds: session.max_rounds,
|
|
366
|
+
speakers: session.speakers,
|
|
367
|
+
log: session.log,
|
|
368
|
+
})}\n\n`);
|
|
369
|
+
|
|
370
|
+
// Register client
|
|
371
|
+
if (!sseClients.has(sessionId)) sseClients.set(sessionId, []);
|
|
372
|
+
sseClients.get(sessionId).push(res);
|
|
373
|
+
sessionSnapshots.set(sessionId, {
|
|
374
|
+
logLength: session.log?.length || 0,
|
|
375
|
+
status: session.status,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
req.on("close", () => {
|
|
379
|
+
const clients = sseClients.get(sessionId) || [];
|
|
380
|
+
sseClients.set(sessionId, clients.filter(c => c !== res));
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (pathname === "/api/config" && req.method === "GET") {
|
|
386
|
+
const config = loadConfig();
|
|
387
|
+
const enabledClis = Array.isArray(config.enabled_clis) ? config.enabled_clis : [];
|
|
388
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
389
|
+
res.end(JSON.stringify({
|
|
390
|
+
mode: enabledClis.length === 0 ? "auto-detect" : "config",
|
|
391
|
+
enabled_clis: enabledClis,
|
|
392
|
+
all_clis: DEFAULT_CLI_CANDIDATES,
|
|
393
|
+
updated: config.updated || null,
|
|
394
|
+
}));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (pathname === "/api/config" && req.method === "POST") {
|
|
399
|
+
let body = "";
|
|
400
|
+
req.on("data", chunk => { body += chunk; });
|
|
401
|
+
req.on("end", () => {
|
|
402
|
+
let parsed;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(body);
|
|
405
|
+
} catch {
|
|
406
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
407
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const enabledClis = Array.isArray(parsed.enabled_clis) ? parsed.enabled_clis : [];
|
|
411
|
+
const config = {
|
|
412
|
+
enabled_clis: enabledClis,
|
|
413
|
+
updated: new Date().toISOString(),
|
|
414
|
+
};
|
|
415
|
+
saveConfig(config);
|
|
416
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
417
|
+
res.end(JSON.stringify({
|
|
418
|
+
mode: enabledClis.length === 0 ? "auto-detect" : "config",
|
|
419
|
+
enabled_clis: enabledClis,
|
|
420
|
+
all_clis: DEFAULT_CLI_CANDIDATES,
|
|
421
|
+
updated: config.updated,
|
|
422
|
+
}));
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (pathname === "/api/cli-status" && req.method === "GET") {
|
|
428
|
+
const clis = DEFAULT_CLI_CANDIDATES.map(name => ({
|
|
429
|
+
name,
|
|
430
|
+
label: CLI_LABELS[name] || name,
|
|
431
|
+
installed: checkCliInstalled(name),
|
|
432
|
+
}));
|
|
433
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
434
|
+
res.end(JSON.stringify({ clis }));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (pathname === "/api/browser-tabs" && req.method === "GET") {
|
|
439
|
+
(async () => {
|
|
440
|
+
const tabs = await fetchCdpTargets();
|
|
441
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
442
|
+
res.end(JSON.stringify(tabs));
|
|
443
|
+
})().catch(err => {
|
|
444
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
445
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (pathname === "/api/stats" && req.method === "GET") {
|
|
451
|
+
const stats = computeStats();
|
|
452
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
453
|
+
res.end(JSON.stringify(stats));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
458
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return server;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Main
|
|
465
|
+
const port = parseInt(process.argv.find((_, i, a) => a[i - 1] === "--port") || DEFAULT_PORT);
|
|
466
|
+
const server = createServer(port);
|
|
467
|
+
|
|
468
|
+
// Poll every 1 second
|
|
469
|
+
const pollInterval = setInterval(pollSessions, 1000);
|
|
470
|
+
|
|
471
|
+
server.listen(port, () => {
|
|
472
|
+
console.log(`Deliberation Observer running at http://localhost:${port}`);
|
|
473
|
+
console.log(` Dashboard: http://localhost:${port}/`);
|
|
474
|
+
console.log(` API: http://localhost:${port}/api/sessions`);
|
|
475
|
+
console.log(` SSE: http://localhost:${port}/api/sessions/{id}/stream`);
|
|
476
|
+
console.log(`\n Press Ctrl+C to stop.`);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
process.on("SIGINT", () => {
|
|
480
|
+
clearInterval(pollInterval);
|
|
481
|
+
server.close();
|
|
482
|
+
process.exit(0);
|
|
483
|
+
});
|