@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dmsdc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @dmsdc-ai/aigentry-deliberation
|
|
2
|
+
|
|
3
|
+
MCP Deliberation Server — Multi-session AI deliberation with smart speaker ordering and persona roles.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multi-session** parallel deliberation support
|
|
8
|
+
- **Smart speaker ordering**: cyclic, random, weighted-random strategies
|
|
9
|
+
- **Persona roles**: critic, implementer, mediator, researcher, free — with prompt templates
|
|
10
|
+
- **Vote parsing**: [AGREE] / [DISAGREE] / [CONDITIONAL] extraction
|
|
11
|
+
- **Browser LLM integration**: CDP-based auto-turn for ChatGPT, Claude, Gemini browser tabs
|
|
12
|
+
- **Chrome Extension support**: Side panel detection via title-based matching
|
|
13
|
+
- **Cross-platform**: macOS (tmux + Terminal.app), Windows (Windows Terminal), Linux
|
|
14
|
+
- **Obsidian archiving**: Auto-archive deliberation results to Obsidian vault
|
|
15
|
+
- **Session monitoring**: Real-time tmux/terminal monitoring
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### As standalone MCP server
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @dmsdc-ai/aigentry-deliberation
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Via aigentry-devkit
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @dmsdc-ai/aigentry-devkit setup
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Manual
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/dmsdc-ai/aigentry-deliberation.git
|
|
35
|
+
cd aigentry-deliberation
|
|
36
|
+
npm install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## MCP Configuration
|
|
40
|
+
|
|
41
|
+
Add to `~/.claude/.mcp.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"deliberation": {
|
|
47
|
+
"command": "node",
|
|
48
|
+
"args": ["/path/to/aigentry-deliberation/index.js"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## MCP Tools
|
|
55
|
+
|
|
56
|
+
| Tool | Description |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| `deliberation_start` | Start a new deliberation session |
|
|
59
|
+
| `deliberation_respond` | Submit a speaker's response |
|
|
60
|
+
| `deliberation_synthesize` | Generate synthesis report |
|
|
61
|
+
| `deliberation_status` | Check session status |
|
|
62
|
+
| `deliberation_context` | Load project context |
|
|
63
|
+
| `deliberation_history` | View discussion history |
|
|
64
|
+
| `deliberation_list_active` | List active sessions |
|
|
65
|
+
| `deliberation_list` | List archived sessions |
|
|
66
|
+
| `deliberation_reset` | Reset session(s) |
|
|
67
|
+
| `deliberation_speaker_candidates` | List available speakers |
|
|
68
|
+
| `deliberation_browser_llm_tabs` | List browser LLM tabs |
|
|
69
|
+
| `deliberation_browser_auto_turn` | Auto-send turn to browser LLM |
|
|
70
|
+
| `deliberation_route_turn` | Route turn to appropriate transport |
|
|
71
|
+
| `deliberation_request_review` | Request code review |
|
|
72
|
+
| `deliberation_cli_config` | Configure CLI settings |
|
|
73
|
+
|
|
74
|
+
## Speaker Ordering Strategies
|
|
75
|
+
|
|
76
|
+
| Strategy | Description |
|
|
77
|
+
|----------|-------------|
|
|
78
|
+
| `cyclic` | Sequential round-robin (default) |
|
|
79
|
+
| `random` | Random selection each turn |
|
|
80
|
+
| `weighted-random` | Less-spoken speakers prioritized |
|
|
81
|
+
|
|
82
|
+
## Persona Roles
|
|
83
|
+
|
|
84
|
+
| Role | Focus |
|
|
85
|
+
|------|-------|
|
|
86
|
+
| `critic` | Risk analysis, weaknesses, counterarguments |
|
|
87
|
+
| `implementer` | Technical feasibility, code design |
|
|
88
|
+
| `mediator` | Consensus building, synthesis |
|
|
89
|
+
| `researcher` | Data, benchmarks, references |
|
|
90
|
+
| `free` | No role constraint (default) |
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserControlPort — Abstract interface + Chrome DevTools MCP adapter
|
|
3
|
+
*
|
|
4
|
+
* Deliberation 합의 스펙:
|
|
5
|
+
* - 6 메서드: attach, sendTurn, waitTurnResult, health, recover, detach
|
|
6
|
+
* - Chrome DevTools MCP 1차 구현
|
|
7
|
+
* - DegradationStateMachine 위임 복구
|
|
8
|
+
* - MVP: ChatGPT 단일 지원
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { DegradationStateMachine, makeResult, ERROR_CODES } from "./degradation-state-machine.js";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
// ─── Selector Config Loader ───
|
|
19
|
+
|
|
20
|
+
function loadSelectorConfig(provider) {
|
|
21
|
+
const configPath = join(__dirname, "selectors", `${provider}.json`);
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── BrowserControlPort Interface ───
|
|
31
|
+
|
|
32
|
+
class BrowserControlPort {
|
|
33
|
+
/**
|
|
34
|
+
* Bind to a browser tab for a deliberation session.
|
|
35
|
+
* @param {string} sessionId
|
|
36
|
+
* @param {{ url?: string, provider?: string }} targetHint
|
|
37
|
+
* @returns {Promise<Result>}
|
|
38
|
+
*/
|
|
39
|
+
async attach(sessionId, targetHint) {
|
|
40
|
+
throw new Error("attach() not implemented");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Send a turn message to the LLM chat input.
|
|
45
|
+
* @param {string} sessionId
|
|
46
|
+
* @param {string} turnId
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {Promise<Result>}
|
|
49
|
+
*/
|
|
50
|
+
async sendTurn(sessionId, turnId, text) {
|
|
51
|
+
throw new Error("sendTurn() not implemented");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Wait for the LLM to produce a response.
|
|
56
|
+
* @param {string} sessionId
|
|
57
|
+
* @param {string} turnId
|
|
58
|
+
* @param {number} timeoutSec
|
|
59
|
+
* @returns {Promise<Result>}
|
|
60
|
+
*/
|
|
61
|
+
async waitTurnResult(sessionId, turnId, timeoutSec) {
|
|
62
|
+
throw new Error("waitTurnResult() not implemented");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if the browser binding is healthy.
|
|
67
|
+
* @param {string} sessionId
|
|
68
|
+
* @returns {Promise<Result>}
|
|
69
|
+
*/
|
|
70
|
+
async health(sessionId) {
|
|
71
|
+
throw new Error("health() not implemented");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Recover from failure.
|
|
76
|
+
* @param {string} sessionId
|
|
77
|
+
* @param {"rebind"|"reload"|"reopen"} mode
|
|
78
|
+
* @returns {Promise<Result>}
|
|
79
|
+
*/
|
|
80
|
+
async recover(sessionId, mode) {
|
|
81
|
+
throw new Error("recover() not implemented");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detach from the browser tab.
|
|
86
|
+
* @param {string} sessionId
|
|
87
|
+
* @returns {Promise<Result>}
|
|
88
|
+
*/
|
|
89
|
+
async detach(sessionId) {
|
|
90
|
+
throw new Error("detach() not implemented");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Chrome DevTools MCP Adapter ───
|
|
95
|
+
|
|
96
|
+
class DevToolsMcpAdapter extends BrowserControlPort {
|
|
97
|
+
constructor({ cdpEndpoints = [], autoResend = true } = {}) {
|
|
98
|
+
super();
|
|
99
|
+
/** @type {Map<string, { tabId: string, wsUrl: string, provider: string, selectors: object }>} */
|
|
100
|
+
this.bindings = new Map();
|
|
101
|
+
this.cdpEndpoints = cdpEndpoints;
|
|
102
|
+
this.autoResend = autoResend;
|
|
103
|
+
this._cmdId = 0;
|
|
104
|
+
/** @type {Map<string, Set<string>>} dedupe: sessionId → Set<turnId> */
|
|
105
|
+
this.sentTurns = new Map();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async attach(sessionId, targetHint = {}) {
|
|
109
|
+
const provider = targetHint.provider || "chatgpt";
|
|
110
|
+
const selectorConfig = loadSelectorConfig(provider);
|
|
111
|
+
if (!selectorConfig) {
|
|
112
|
+
return makeResult(false, null, {
|
|
113
|
+
code: "INVALID_SELECTOR_CONFIG",
|
|
114
|
+
message: `No selector config found for provider: ${provider}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Find matching tab via CDP /json/list
|
|
119
|
+
const targetUrl = targetHint.url;
|
|
120
|
+
const domains = selectorConfig.domains || [];
|
|
121
|
+
|
|
122
|
+
let foundTab = null;
|
|
123
|
+
for (const endpoint of this.cdpEndpoints) {
|
|
124
|
+
try {
|
|
125
|
+
const resp = await fetch(endpoint, {
|
|
126
|
+
signal: AbortSignal.timeout(3000),
|
|
127
|
+
headers: { accept: "application/json" },
|
|
128
|
+
});
|
|
129
|
+
const tabs = await resp.json();
|
|
130
|
+
for (const tab of tabs) {
|
|
131
|
+
if (tab.type !== "page") continue;
|
|
132
|
+
const tabUrl = tab.url || "";
|
|
133
|
+
if (targetUrl && tabUrl.includes(targetUrl)) {
|
|
134
|
+
foundTab = { ...tab, endpoint };
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (domains.some(d => tabUrl.includes(d))) {
|
|
138
|
+
foundTab = { ...tab, endpoint };
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
// Strategy 3: Extension title match
|
|
142
|
+
if (selectorConfig.isExtension && tabUrl.startsWith("chrome-extension://")) {
|
|
143
|
+
const titlePatterns = selectorConfig.titlePatterns || [];
|
|
144
|
+
const lowerTitle = String(tab.title || "").toLowerCase();
|
|
145
|
+
if (titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
|
|
146
|
+
foundTab = { ...tab, endpoint };
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (foundTab) break;
|
|
152
|
+
} catch {
|
|
153
|
+
// endpoint not reachable
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!foundTab) {
|
|
158
|
+
return makeResult(false, null, {
|
|
159
|
+
code: "BIND_FAILED",
|
|
160
|
+
message: `No matching browser tab found for provider "${provider}" (checked ${this.cdpEndpoints.length} endpoints)`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.bindings.set(sessionId, {
|
|
165
|
+
tabId: foundTab.id,
|
|
166
|
+
wsUrl: foundTab.webSocketDebuggerUrl,
|
|
167
|
+
provider,
|
|
168
|
+
selectors: selectorConfig.selectors,
|
|
169
|
+
timing: selectorConfig.timing,
|
|
170
|
+
pageUrl: foundTab.url,
|
|
171
|
+
title: foundTab.title,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return makeResult(true, {
|
|
175
|
+
provider,
|
|
176
|
+
tabId: foundTab.id,
|
|
177
|
+
title: foundTab.title,
|
|
178
|
+
url: foundTab.url,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async sendTurn(sessionId, turnId, text) {
|
|
183
|
+
const binding = this.bindings.get(sessionId);
|
|
184
|
+
if (!binding) {
|
|
185
|
+
return makeResult(false, null, {
|
|
186
|
+
code: "BIND_FAILED",
|
|
187
|
+
message: `No binding for session ${sessionId}. Call attach() first.`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Idempotency check
|
|
192
|
+
if (!this.sentTurns.has(sessionId)) this.sentTurns.set(sessionId, new Set());
|
|
193
|
+
const sent = this.sentTurns.get(sessionId);
|
|
194
|
+
if (sent.has(turnId)) {
|
|
195
|
+
return makeResult(true, { deduplicated: true, turnId });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// Step 1: Focus input and insert text via execCommand for React/ProseMirror compatibility
|
|
200
|
+
const inputSel = JSON.stringify(binding.selectors.inputSelector);
|
|
201
|
+
const sendBtnSel = JSON.stringify(binding.selectors.sendButton);
|
|
202
|
+
const escapedText = JSON.stringify(text);
|
|
203
|
+
|
|
204
|
+
const result = await this._cdpEvaluate(binding, `
|
|
205
|
+
(function() {
|
|
206
|
+
const input = document.querySelector(${inputSel});
|
|
207
|
+
if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
|
|
208
|
+
|
|
209
|
+
// Focus and select all existing content
|
|
210
|
+
input.focus();
|
|
211
|
+
if (input.isContentEditable) {
|
|
212
|
+
// For contenteditable (ChatGPT ProseMirror, Claude, etc.)
|
|
213
|
+
const sel = window.getSelection();
|
|
214
|
+
const range = document.createRange();
|
|
215
|
+
range.selectNodeContents(input);
|
|
216
|
+
sel.removeAllRanges();
|
|
217
|
+
sel.addRange(range);
|
|
218
|
+
// execCommand triggers framework state updates (React, ProseMirror, Quill)
|
|
219
|
+
document.execCommand('insertText', false, ${escapedText});
|
|
220
|
+
} else {
|
|
221
|
+
// For regular <textarea>/<input>
|
|
222
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
223
|
+
Object.getPrototypeOf(input), 'value'
|
|
224
|
+
)?.set || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
225
|
+
if (nativeSetter) {
|
|
226
|
+
nativeSetter.call(input, ${escapedText});
|
|
227
|
+
} else {
|
|
228
|
+
input.value = ${escapedText};
|
|
229
|
+
}
|
|
230
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
231
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
232
|
+
}
|
|
233
|
+
return { ok: true };
|
|
234
|
+
})()
|
|
235
|
+
`);
|
|
236
|
+
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
return makeResult(false, null, {
|
|
239
|
+
code: "DOM_CHANGED",
|
|
240
|
+
message: `Input selector not found: ${binding.selectors.inputSelector}`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Small delay for framework state propagation
|
|
245
|
+
await new Promise(r => setTimeout(r, binding.timing?.sendDelayMs || 200));
|
|
246
|
+
|
|
247
|
+
// Step 2: Send via Enter key (primary) + button click (fallback)
|
|
248
|
+
const sendResult = await this._cdpEvaluate(binding, `
|
|
249
|
+
(function() {
|
|
250
|
+
const input = document.querySelector(${inputSel});
|
|
251
|
+
if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
|
|
252
|
+
|
|
253
|
+
// Primary: dispatch Enter key event on the input
|
|
254
|
+
input.focus();
|
|
255
|
+
const enterEvent = new KeyboardEvent('keydown', {
|
|
256
|
+
key: 'Enter', code: 'Enter',
|
|
257
|
+
keyCode: 13, which: 13,
|
|
258
|
+
bubbles: true, cancelable: true
|
|
259
|
+
});
|
|
260
|
+
input.dispatchEvent(enterEvent);
|
|
261
|
+
|
|
262
|
+
// Fallback: also click send button if it exists and is enabled
|
|
263
|
+
const btn = document.querySelector(${sendBtnSel});
|
|
264
|
+
if (btn && !btn.disabled) {
|
|
265
|
+
btn.click();
|
|
266
|
+
}
|
|
267
|
+
return { ok: true };
|
|
268
|
+
})()
|
|
269
|
+
`);
|
|
270
|
+
|
|
271
|
+
if (!sendResult.ok) {
|
|
272
|
+
return makeResult(false, null, {
|
|
273
|
+
code: "SEND_FAILED",
|
|
274
|
+
message: `Send button not found: ${binding.selectors.sendButton}`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
sent.add(turnId);
|
|
279
|
+
return makeResult(true, { turnId, sent: true });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return this._classifyError(err);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async waitTurnResult(sessionId, turnId, timeoutSec = 45) {
|
|
286
|
+
const binding = this.bindings.get(sessionId);
|
|
287
|
+
if (!binding) {
|
|
288
|
+
return makeResult(false, null, {
|
|
289
|
+
code: "BIND_FAILED",
|
|
290
|
+
message: `No binding for session ${sessionId}`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const timeoutMs = timeoutSec * 1000;
|
|
295
|
+
const pollInterval = binding.timing?.pollIntervalMs || 500;
|
|
296
|
+
const startTime = Date.now();
|
|
297
|
+
|
|
298
|
+
const streamSel = JSON.stringify(binding.selectors.streamingIndicator);
|
|
299
|
+
const respContSel = JSON.stringify(binding.selectors.responseContainer);
|
|
300
|
+
const respSel = JSON.stringify(binding.selectors.responseSelector);
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
304
|
+
// Check if streaming is complete
|
|
305
|
+
const status = await this._cdpEvaluate(binding, `
|
|
306
|
+
(function() {
|
|
307
|
+
const streaming = document.querySelector(${streamSel});
|
|
308
|
+
if (streaming) return { streaming: true };
|
|
309
|
+
const responses = document.querySelectorAll(${respContSel});
|
|
310
|
+
if (responses.length === 0) return { streaming: true };
|
|
311
|
+
const last = responses[responses.length - 1];
|
|
312
|
+
const content = last.querySelector(${respSel});
|
|
313
|
+
return {
|
|
314
|
+
streaming: false,
|
|
315
|
+
text: content ? content.textContent : last.textContent,
|
|
316
|
+
};
|
|
317
|
+
})()
|
|
318
|
+
`);
|
|
319
|
+
|
|
320
|
+
if (status.data && !status.data.streaming && status.data.text) {
|
|
321
|
+
return makeResult(true, {
|
|
322
|
+
turnId,
|
|
323
|
+
response: status.data.text.trim(),
|
|
324
|
+
elapsedMs: Date.now() - startTime,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return makeResult(false, null, {
|
|
332
|
+
code: "TIMEOUT",
|
|
333
|
+
message: `Response not received within ${timeoutSec}s`,
|
|
334
|
+
});
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return this._classifyError(err);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async health(sessionId) {
|
|
341
|
+
const binding = this.bindings.get(sessionId);
|
|
342
|
+
if (!binding) {
|
|
343
|
+
return makeResult(true, { bound: false, sessionId });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const result = await this._cdpEvaluate(binding, "document.readyState");
|
|
348
|
+
return makeResult(true, {
|
|
349
|
+
bound: true,
|
|
350
|
+
sessionId,
|
|
351
|
+
provider: binding.provider,
|
|
352
|
+
pageUrl: binding.pageUrl,
|
|
353
|
+
readyState: result.data,
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return makeResult(false, null, {
|
|
357
|
+
code: "TAB_CLOSED",
|
|
358
|
+
message: `Health check failed: ${err.message}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async recover(sessionId, mode = "rebind") {
|
|
364
|
+
const binding = this.bindings.get(sessionId);
|
|
365
|
+
|
|
366
|
+
switch (mode) {
|
|
367
|
+
case "rebind": {
|
|
368
|
+
// Re-scan for the tab
|
|
369
|
+
if (!binding) return makeResult(false, null, { code: "BIND_FAILED", message: "No previous binding to rebind" });
|
|
370
|
+
return this.attach(sessionId, { provider: binding.provider });
|
|
371
|
+
}
|
|
372
|
+
case "reload": {
|
|
373
|
+
if (!binding) return makeResult(false, null, { code: "TAB_CLOSED", message: "No binding to reload" });
|
|
374
|
+
try {
|
|
375
|
+
await this._cdpCommand(binding, "Page.reload", {});
|
|
376
|
+
await new Promise(r => setTimeout(r, 3000)); // wait for reload
|
|
377
|
+
return makeResult(true, { mode: "reload", sessionId });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
return this._classifyError(err);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
case "reopen": {
|
|
383
|
+
// Detach old binding, try re-attach
|
|
384
|
+
this.bindings.delete(sessionId);
|
|
385
|
+
const provider = binding?.provider || "chatgpt";
|
|
386
|
+
return this.attach(sessionId, { provider });
|
|
387
|
+
}
|
|
388
|
+
default:
|
|
389
|
+
return makeResult(false, null, { code: "SEND_FAILED", message: `Unknown recover mode: ${mode}` });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async detach(sessionId) {
|
|
394
|
+
this.bindings.delete(sessionId);
|
|
395
|
+
this.sentTurns.delete(sessionId);
|
|
396
|
+
return makeResult(true, { sessionId, detached: true });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── CDP Helpers ───
|
|
400
|
+
|
|
401
|
+
async _cdpEvaluate(binding, expression) {
|
|
402
|
+
return this._cdpCommand(binding, "Runtime.evaluate", {
|
|
403
|
+
expression,
|
|
404
|
+
returnByValue: true,
|
|
405
|
+
}).then(result => {
|
|
406
|
+
const val = result?.result?.value;
|
|
407
|
+
if (val && typeof val === "object" && val.ok === false) {
|
|
408
|
+
return makeResult(false, null, { code: "DOM_CHANGED", message: val.error || "DOM evaluation failed" });
|
|
409
|
+
}
|
|
410
|
+
return makeResult(true, val);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async _cdpCommand(binding, method, params = {}) {
|
|
415
|
+
if (!binding.wsUrl) {
|
|
416
|
+
throw Object.assign(new Error("No WebSocket URL for CDP"), { code: "MCP_CHANNEL_CLOSED" });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Use dynamic import for WebSocket (Node 18+ has it globally, or ws package)
|
|
420
|
+
const ws = await this._connectWs(binding.wsUrl);
|
|
421
|
+
const id = ++this._cmdId;
|
|
422
|
+
|
|
423
|
+
return new Promise((resolve, reject) => {
|
|
424
|
+
const timeout = setTimeout(() => {
|
|
425
|
+
ws.close();
|
|
426
|
+
reject(Object.assign(new Error("CDP command timeout"), { code: "TIMEOUT" }));
|
|
427
|
+
}, 10000);
|
|
428
|
+
|
|
429
|
+
ws.onmessage = (event) => {
|
|
430
|
+
try {
|
|
431
|
+
const data = JSON.parse(typeof event === "string" ? event : event.data);
|
|
432
|
+
if (data.id === id) {
|
|
433
|
+
clearTimeout(timeout);
|
|
434
|
+
ws.close();
|
|
435
|
+
if (data.error) {
|
|
436
|
+
reject(Object.assign(new Error(data.error.message), { code: "SEND_FAILED" }));
|
|
437
|
+
} else {
|
|
438
|
+
resolve(data.result);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} catch { /* ignore parse errors */ }
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
ws.onerror = (err) => {
|
|
445
|
+
clearTimeout(timeout);
|
|
446
|
+
ws.close();
|
|
447
|
+
reject(Object.assign(new Error(err.message || "WebSocket error"), { code: "NETWORK_DISCONNECTED" }));
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async _connectWs(url) {
|
|
455
|
+
// Node.js 22+ has global WebSocket; fallback to ws package
|
|
456
|
+
if (typeof globalThis.WebSocket !== "undefined") {
|
|
457
|
+
const ws = new globalThis.WebSocket(url);
|
|
458
|
+
await new Promise((resolve, reject) => {
|
|
459
|
+
ws.onopen = resolve;
|
|
460
|
+
ws.onerror = reject;
|
|
461
|
+
});
|
|
462
|
+
return ws;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Try dynamic import of ws
|
|
466
|
+
try {
|
|
467
|
+
const { default: WS } = await import("ws");
|
|
468
|
+
const ws = new WS(url);
|
|
469
|
+
await new Promise((resolve, reject) => {
|
|
470
|
+
ws.on("open", resolve);
|
|
471
|
+
ws.on("error", reject);
|
|
472
|
+
});
|
|
473
|
+
return ws;
|
|
474
|
+
} catch {
|
|
475
|
+
throw Object.assign(new Error("WebSocket not available. Install 'ws' package or use Node 22+."), { code: "MCP_CHANNEL_CLOSED" });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_classifyError(err) {
|
|
480
|
+
const code = err.code || "UNKNOWN";
|
|
481
|
+
if (ERROR_CODES[code]) {
|
|
482
|
+
return makeResult(false, null, { code, message: err.message });
|
|
483
|
+
}
|
|
484
|
+
// Classify by message patterns
|
|
485
|
+
if (/ECONNREFUSED|ENOTFOUND|fetch failed/i.test(err.message)) {
|
|
486
|
+
return makeResult(false, null, { code: "NETWORK_DISCONNECTED", message: err.message });
|
|
487
|
+
}
|
|
488
|
+
if (/WebSocket|ws:/i.test(err.message)) {
|
|
489
|
+
return makeResult(false, null, { code: "MCP_CHANNEL_CLOSED", message: err.message });
|
|
490
|
+
}
|
|
491
|
+
if (/target.*closed|page.*crashed/i.test(err.message)) {
|
|
492
|
+
return makeResult(false, null, { code: "BROWSER_CRASHED", message: err.message });
|
|
493
|
+
}
|
|
494
|
+
return makeResult(false, null, { code: "UNKNOWN", message: err.message });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── Orchestrated Port (with DegradationStateMachine) ───
|
|
499
|
+
|
|
500
|
+
class OrchestratedBrowserPort {
|
|
501
|
+
constructor({ cdpEndpoints = [], autoResend = true, skipEnabled = false } = {}) {
|
|
502
|
+
this.adapter = new DevToolsMcpAdapter({ cdpEndpoints, autoResend });
|
|
503
|
+
this.machines = new Map(); // sessionId → DegradationStateMachine
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
_getOrCreateMachine(sessionId) {
|
|
507
|
+
if (!this.machines.has(sessionId)) {
|
|
508
|
+
this.machines.set(sessionId, new DegradationStateMachine({
|
|
509
|
+
onRetry: () => makeResult(false, null, { code: "SEND_FAILED", message: "retry pass-through" }),
|
|
510
|
+
onRebind: () => this.adapter.recover(sessionId, "rebind"),
|
|
511
|
+
onReload: () => this.adapter.recover(sessionId, "reload"),
|
|
512
|
+
onFallback: (lastResult) => {
|
|
513
|
+
return makeResult(false, null, {
|
|
514
|
+
code: "TIMEOUT",
|
|
515
|
+
message: "All degradation stages exhausted. Falling back to clipboard mode.",
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
return this.machines.get(sessionId);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async attach(sessionId, targetHint) {
|
|
524
|
+
return this.adapter.attach(sessionId, targetHint);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Send a turn with full degradation pipeline.
|
|
529
|
+
*/
|
|
530
|
+
async sendTurnWithDegradation(sessionId, turnId, text) {
|
|
531
|
+
const machine = this._getOrCreateMachine(sessionId);
|
|
532
|
+
return machine.execute(() => this.adapter.sendTurn(sessionId, turnId, text));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async waitTurnResult(sessionId, turnId, timeoutSec) {
|
|
536
|
+
return this.adapter.waitTurnResult(sessionId, turnId, timeoutSec);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async health(sessionId) {
|
|
540
|
+
return this.adapter.health(sessionId);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async recover(sessionId, mode) {
|
|
544
|
+
return this.adapter.recover(sessionId, mode);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async detach(sessionId) {
|
|
548
|
+
this.machines.delete(sessionId);
|
|
549
|
+
return this.adapter.detach(sessionId);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
getDegradationState(sessionId) {
|
|
553
|
+
const machine = this.machines.get(sessionId);
|
|
554
|
+
return machine ? machine.toJSON() : null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export {
|
|
559
|
+
BrowserControlPort,
|
|
560
|
+
DevToolsMcpAdapter,
|
|
561
|
+
OrchestratedBrowserPort,
|
|
562
|
+
loadSelectorConfig,
|
|
563
|
+
};
|