droid-acp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -2
- package/dist/{acp-agent-Ddz1S3Jm.mjs → acp-agent-DUNNYT3R.mjs} +421 -10
- package/dist/acp-agent-DUNNYT3R.mjs.map +1 -0
- package/dist/index.mjs +87 -28
- package/dist/index.mjs.map +1 -1
- package/dist/lib.d.mts +2 -0
- package/dist/lib.d.mts.map +1 -1
- package/dist/lib.mjs +1 -1
- package/package.json +10 -2
- package/dist/acp-agent-Ddz1S3Jm.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ Use Droid from any [ACP-compatible](https://agentclientprotocol.com) clients suc
|
|
|
12
12
|
- Image prompts (e.g. paste screenshots in Zed)
|
|
13
13
|
- Multiple model support
|
|
14
14
|
- Session modes (Spec, Manual, Auto Low/Medium/High)
|
|
15
|
+
- Optional WebSearch proxy (Smithery Exa MCP / custom forward)
|
|
15
16
|
|
|
16
17
|
## Installation
|
|
17
18
|
|
|
@@ -24,11 +25,13 @@ npm install droid-acp
|
|
|
24
25
|
### Prerequisites
|
|
25
26
|
|
|
26
27
|
1. Install Droid CLI from [Factory](https://factory.ai)
|
|
27
|
-
2. Set your Factory API key:
|
|
28
|
+
2. (Recommended) Set your Factory API key for Factory-hosted features:
|
|
28
29
|
```bash
|
|
29
30
|
export FACTORY_API_KEY=fk-...
|
|
30
31
|
```
|
|
31
32
|
|
|
33
|
+
> If you only need WebSearch via the built-in proxy + Smithery Exa MCP, a valid Factory key is not required (droid-acp injects a dummy key into the spawned droid process to satisfy droid's local auth gate).
|
|
34
|
+
|
|
32
35
|
### Running
|
|
33
36
|
|
|
34
37
|
```bash
|
|
@@ -93,6 +96,25 @@ Add to your Zed `settings.json`:
|
|
|
93
96
|
}
|
|
94
97
|
```
|
|
95
98
|
|
|
99
|
+
**Enable WebSearch proxy (Smithery Exa MCP):**
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"agent_servers": {
|
|
104
|
+
"Droid WebSearch": {
|
|
105
|
+
"type": "custom",
|
|
106
|
+
"command": "npx",
|
|
107
|
+
"args": ["droid-acp"],
|
|
108
|
+
"env": {
|
|
109
|
+
"DROID_ACP_WEBSEARCH": "1",
|
|
110
|
+
"SMITHERY_API_KEY": "your_smithery_key",
|
|
111
|
+
"SMITHERY_PROFILE": "your_profile_id"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
96
118
|
### Modes
|
|
97
119
|
|
|
98
120
|
| Command | Mode | Custom Models | Description |
|
|
@@ -104,9 +126,58 @@ Add to your Zed `settings.json`:
|
|
|
104
126
|
|
|
105
127
|
### Environment Variables
|
|
106
128
|
|
|
107
|
-
- `FACTORY_API_KEY` - Your Factory API key (
|
|
129
|
+
- `FACTORY_API_KEY` - Your Factory API key (recommended for Factory-hosted features)
|
|
108
130
|
- `DROID_EXECUTABLE` - Path to the droid binary (optional, defaults to `droid` in PATH)
|
|
109
131
|
|
|
132
|
+
- `DROID_ACP_WEBSEARCH` - Enable local proxy to optionally intercept Droid websearch (`/api/tools/exa/search`)
|
|
133
|
+
- `DROID_ACP_WEBSEARCH_FORWARD_URL` - Optional forward target for websearch (base URL or full URL)
|
|
134
|
+
- `DROID_ACP_WEBSEARCH_FORWARD_MODE` - Forward mode for `DROID_ACP_WEBSEARCH_FORWARD_URL` (`http` or `mcp`, default: `http`)
|
|
135
|
+
- `DROID_ACP_WEBSEARCH_UPSTREAM_URL` - Optional upstream Factory API base URL (default: `FACTORY_API_BASE_URL_OVERRIDE` or `https://api.factory.ai`)
|
|
136
|
+
- `DROID_ACP_WEBSEARCH_HOST` - Optional proxy bind host (default: `127.0.0.1`)
|
|
137
|
+
- `DROID_ACP_WEBSEARCH_PORT` - Optional proxy bind port (default: auto-assign an available port)
|
|
138
|
+
- `DROID_ACP_WEBSEARCH_DEBUG` - Emit a WebSearch status message in the ACP UI (e.g. Zed) for debugging
|
|
139
|
+
|
|
140
|
+
- `SMITHERY_API_KEY` - Optional (recommended) Smithery Exa MCP API key (enables high-quality websearch)
|
|
141
|
+
- `SMITHERY_PROFILE` - Optional Smithery Exa MCP profile id
|
|
142
|
+
|
|
143
|
+
### WebSearch Proxy (optional)
|
|
144
|
+
|
|
145
|
+
Enable the built-in proxy to intercept `POST /api/tools/exa/search` and serve results from Smithery Exa MCP (recommended):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
export SMITHERY_API_KEY="your_smithery_key"
|
|
149
|
+
export SMITHERY_PROFILE="your_profile_id"
|
|
150
|
+
DROID_ACP_WEBSEARCH=1 npx droid-acp
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
To debug proxy wiring (shows `proxyBaseUrl` and a `/health` link in the ACP UI):
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
DROID_ACP_WEBSEARCH=1 DROID_ACP_WEBSEARCH_DEBUG=1 npx droid-acp
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
To forward WebSearch to your own HTTP handler instead:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
DROID_ACP_WEBSEARCH=1 \
|
|
163
|
+
DROID_ACP_WEBSEARCH_FORWARD_URL="http://127.0.0.1:20002" \
|
|
164
|
+
npx droid-acp
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
To forward WebSearch to an MCP endpoint (JSON-RPC `tools/call`), set:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
DROID_ACP_WEBSEARCH=1 \
|
|
171
|
+
DROID_ACP_WEBSEARCH_FORWARD_MODE=mcp \
|
|
172
|
+
DROID_ACP_WEBSEARCH_FORWARD_URL="http://127.0.0.1:20002" \
|
|
173
|
+
npx droid-acp
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Notes:
|
|
177
|
+
|
|
178
|
+
- The proxy exposes `GET /health` on `proxyBaseUrl` (handy for troubleshooting).
|
|
179
|
+
- When `DROID_ACP_WEBSEARCH=1`, droid-acp injects a dummy `FACTORY_API_KEY` into the spawned droid process if none is set, so WebSearch requests can reach the proxy even without Factory login.
|
|
180
|
+
|
|
110
181
|
## Session Modes
|
|
111
182
|
|
|
112
183
|
| Mode | Description | Droid autonomy level |
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
3
4
|
import { createInterface } from "node:readline";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { ReadableStream, WritableStream } from "node:stream/web";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
6
10
|
|
|
7
11
|
//#region src/utils.ts
|
|
8
12
|
var Pushable = class {
|
|
@@ -61,6 +65,16 @@ function nodeToWebReadable(nodeStream) {
|
|
|
61
65
|
nodeStream.on("error", (err) => controller.error(err));
|
|
62
66
|
} });
|
|
63
67
|
}
|
|
68
|
+
function isEnvEnabled(value) {
|
|
69
|
+
if (!value) return false;
|
|
70
|
+
switch (value.trim().toLowerCase()) {
|
|
71
|
+
case "1":
|
|
72
|
+
case "true":
|
|
73
|
+
case "yes":
|
|
74
|
+
case "on": return true;
|
|
75
|
+
default: return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
64
78
|
/** Check if running on Windows */
|
|
65
79
|
const isWindows = process.platform === "win32";
|
|
66
80
|
/**
|
|
@@ -73,11 +87,343 @@ function findDroidExecutable() {
|
|
|
73
87
|
return "droid";
|
|
74
88
|
}
|
|
75
89
|
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/websearch-proxy.ts
|
|
92
|
+
function parseSseJsonPayload(raw) {
|
|
93
|
+
const lines = raw.split(/\r?\n/);
|
|
94
|
+
let currentData = [];
|
|
95
|
+
const flush = (acc) => {
|
|
96
|
+
if (currentData.length === 0) return;
|
|
97
|
+
const joined = currentData.join("\n");
|
|
98
|
+
try {
|
|
99
|
+
acc.push(JSON.parse(joined));
|
|
100
|
+
} catch {}
|
|
101
|
+
currentData = [];
|
|
102
|
+
};
|
|
103
|
+
const parsed = [];
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (line.length === 0) {
|
|
106
|
+
flush(parsed);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (line.startsWith("event:")) continue;
|
|
110
|
+
if (line.startsWith("data:")) {
|
|
111
|
+
const data = line.slice(5).replace(/^ /, "");
|
|
112
|
+
currentData.push(data);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
flush(parsed);
|
|
117
|
+
return parsed.length > 0 ? parsed[parsed.length - 1] : null;
|
|
118
|
+
}
|
|
119
|
+
function parseHttpUrl(value, name) {
|
|
120
|
+
let url;
|
|
121
|
+
try {
|
|
122
|
+
url = new URL(value);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error(`${name} must be a valid URL: ${value}`);
|
|
125
|
+
}
|
|
126
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be http(s): ${value}`);
|
|
127
|
+
return url;
|
|
128
|
+
}
|
|
129
|
+
function resolveForwardTarget(forward, requestPathAndQuery) {
|
|
130
|
+
if (forward.pathname === "/" && forward.search === "" && forward.hash === "") return new URL(requestPathAndQuery, forward);
|
|
131
|
+
const target = new URL(forward.toString());
|
|
132
|
+
if (!target.search && requestPathAndQuery.includes("?")) target.search = requestPathAndQuery.slice(requestPathAndQuery.indexOf("?"));
|
|
133
|
+
return target;
|
|
134
|
+
}
|
|
135
|
+
function isBodylessMethod(method) {
|
|
136
|
+
return method === "GET" || method === "HEAD";
|
|
137
|
+
}
|
|
138
|
+
function toNonEmptyString(value) {
|
|
139
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
140
|
+
}
|
|
141
|
+
function parseWebsearchRequestBody(bodyBuffer) {
|
|
142
|
+
let input;
|
|
143
|
+
try {
|
|
144
|
+
input = JSON.parse(bodyBuffer.toString("utf8"));
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const query = toNonEmptyString(input?.query);
|
|
149
|
+
if (!query) return null;
|
|
150
|
+
const numResultsRaw = input?.numResults;
|
|
151
|
+
return {
|
|
152
|
+
query,
|
|
153
|
+
numResults: typeof numResultsRaw === "number" && Number.isFinite(numResultsRaw) ? Math.max(1, numResultsRaw) : 10
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async function readBody(req, maxBytes) {
|
|
157
|
+
const chunks = [];
|
|
158
|
+
let total = 0;
|
|
159
|
+
for await (const chunk of req) {
|
|
160
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
161
|
+
total += buf.length;
|
|
162
|
+
if (total > maxBytes) throw new Error(`Request body too large (${total} bytes)`);
|
|
163
|
+
chunks.push(buf);
|
|
164
|
+
}
|
|
165
|
+
return Buffer.concat(chunks);
|
|
166
|
+
}
|
|
167
|
+
function parseSearchResultsText(text, numResults) {
|
|
168
|
+
try {
|
|
169
|
+
const json = JSON.parse(text);
|
|
170
|
+
if (Array.isArray(json)) return json.slice(0, numResults);
|
|
171
|
+
} catch {}
|
|
172
|
+
const matches = [...text.matchAll(/^Title:\s*(.*)$/gm)];
|
|
173
|
+
if (matches.length === 0) return null;
|
|
174
|
+
const results = [];
|
|
175
|
+
for (let i = 0; i < matches.length && results.length < numResults; i += 1) {
|
|
176
|
+
const start = matches[i]?.index ?? 0;
|
|
177
|
+
const end = matches[i + 1]?.index ?? text.length;
|
|
178
|
+
const chunk = text.slice(start, end).trim();
|
|
179
|
+
const title = matches[i]?.[1]?.trim() ?? "";
|
|
180
|
+
const url = (chunk.match(/^URL:\s*(.*)$/m)?.[1] ?? "").trim();
|
|
181
|
+
const snippet = (chunk.match(/^Text:\s*(.*)$/m)?.[1] ?? "").trim();
|
|
182
|
+
results.push({
|
|
183
|
+
title,
|
|
184
|
+
url,
|
|
185
|
+
snippet,
|
|
186
|
+
text: snippet
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
async function tryHandleWebsearchWithMcp(res, logger, mcpEndpoint, bodyBuffer) {
|
|
192
|
+
const parsed = parseWebsearchRequestBody(bodyBuffer);
|
|
193
|
+
if (!parsed) return { handled: false };
|
|
194
|
+
const { query, numResults } = parsed;
|
|
195
|
+
const requestBody = {
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
id: 1,
|
|
198
|
+
method: "tools/call",
|
|
199
|
+
params: {
|
|
200
|
+
name: "web_search_exa",
|
|
201
|
+
arguments: {
|
|
202
|
+
query,
|
|
203
|
+
numResults
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
let mcpResponse;
|
|
208
|
+
try {
|
|
209
|
+
const response = await fetch(mcpEndpoint, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: {
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
Accept: "application/json, text/event-stream"
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify(requestBody)
|
|
216
|
+
});
|
|
217
|
+
if ((response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/event-stream")) {
|
|
218
|
+
const parsedSse = parseSseJsonPayload(await response.text());
|
|
219
|
+
if (!parsedSse) throw new Error("Invalid MCP SSE response: missing JSON payload");
|
|
220
|
+
mcpResponse = parsedSse;
|
|
221
|
+
} else mcpResponse = await response.json();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
logger.error("[websearch] MCP request failed:", message);
|
|
225
|
+
return {
|
|
226
|
+
handled: false,
|
|
227
|
+
error: message
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const resultContent = mcpResponse?.result?.content;
|
|
231
|
+
const textBlock = (Array.isArray(resultContent) ? resultContent : []).find((c) => !!c && typeof c === "object" && c.type === "text" && typeof c.text === "string");
|
|
232
|
+
if (!textBlock) {
|
|
233
|
+
const errorMessage = mcpResponse?.error?.message ?? mcpResponse?.error ?? null;
|
|
234
|
+
const message = typeof errorMessage === "string" ? errorMessage : `Invalid MCP response: ${JSON.stringify(mcpResponse)}`;
|
|
235
|
+
logger.error("[websearch] MCP response missing text content:", message);
|
|
236
|
+
return {
|
|
237
|
+
handled: false,
|
|
238
|
+
error: message
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const parsedItems = parseSearchResultsText(textBlock.text, numResults);
|
|
242
|
+
if (!parsedItems) {
|
|
243
|
+
logger.error("[websearch] Failed to parse MCP text payload");
|
|
244
|
+
return {
|
|
245
|
+
handled: false,
|
|
246
|
+
error: "Invalid MCP payload: unsupported format"
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const results = parsedItems.slice(0, numResults).map((item) => {
|
|
250
|
+
const title = toNonEmptyString(item.title) ?? "";
|
|
251
|
+
const url = toNonEmptyString(item.url) ?? "";
|
|
252
|
+
const highlights = Array.isArray(item.highlights) ? item.highlights.filter((v) => typeof v === "string") : [];
|
|
253
|
+
const content = toNonEmptyString(item.text) ?? toNonEmptyString(item.snippet) ?? (highlights.length > 0 ? highlights.join(" ") : "");
|
|
254
|
+
return {
|
|
255
|
+
title,
|
|
256
|
+
url,
|
|
257
|
+
content,
|
|
258
|
+
snippet: content,
|
|
259
|
+
publishedDate: item.publishedDate ?? null,
|
|
260
|
+
author: item.author ?? null,
|
|
261
|
+
score: item.score ?? null
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
265
|
+
res.end(JSON.stringify({ results }));
|
|
266
|
+
return { handled: true };
|
|
267
|
+
}
|
|
268
|
+
async function startWebsearchProxy(options) {
|
|
269
|
+
const logger = options.logger ?? console;
|
|
270
|
+
const host = options.host ?? "127.0.0.1";
|
|
271
|
+
const port = options.port ?? 0;
|
|
272
|
+
const upstreamBase = parseHttpUrl(options.upstreamBaseUrl, "DROID_ACP_WEBSEARCH_UPSTREAM_URL");
|
|
273
|
+
const forward = options.websearchForwardUrl ? parseHttpUrl(options.websearchForwardUrl, "DROID_ACP_WEBSEARCH_FORWARD_URL") : null;
|
|
274
|
+
const forwardMode = options.websearchForwardMode ?? "http";
|
|
275
|
+
const smitheryApiKey = toNonEmptyString(options.smitheryApiKey);
|
|
276
|
+
const smitheryProfile = toNonEmptyString(options.smitheryProfile);
|
|
277
|
+
const smitheryEndpoint = smitheryApiKey && smitheryProfile ? new URL(`https://server.smithery.ai/exa/mcp?api_key=${encodeURIComponent(smitheryApiKey)}&profile=${encodeURIComponent(smitheryProfile)}`) : null;
|
|
278
|
+
let totalRequests = 0;
|
|
279
|
+
let websearchRequests = 0;
|
|
280
|
+
let lastWebsearchAt = null;
|
|
281
|
+
let lastWebsearchOutcome = null;
|
|
282
|
+
const server = createServer((req, res) => {
|
|
283
|
+
(async () => {
|
|
284
|
+
totalRequests += 1;
|
|
285
|
+
const rawUrl = req.url;
|
|
286
|
+
if (!rawUrl) {
|
|
287
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
288
|
+
res.end(JSON.stringify({ error: "Missing URL" }));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const requestUrl = new URL(rawUrl, `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
292
|
+
const pathAndQuery = `${requestUrl.pathname}${requestUrl.search || ""}`;
|
|
293
|
+
if (requestUrl.pathname === "/health") {
|
|
294
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
295
|
+
res.end(JSON.stringify({
|
|
296
|
+
status: "ok",
|
|
297
|
+
upstreamBaseUrl: upstreamBase.toString(),
|
|
298
|
+
websearchForwardUrl: forward?.toString() ?? null,
|
|
299
|
+
websearchForwardMode: forwardMode,
|
|
300
|
+
smitheryEnabled: Boolean(smitheryEndpoint),
|
|
301
|
+
requests: {
|
|
302
|
+
total: totalRequests,
|
|
303
|
+
websearch: websearchRequests,
|
|
304
|
+
lastWebsearchAt,
|
|
305
|
+
lastWebsearchOutcome
|
|
306
|
+
}
|
|
307
|
+
}));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const isWebsearch = requestUrl.pathname.startsWith("/api/tools/exa/search") && req.method === "POST";
|
|
311
|
+
const targetUrl = isWebsearch && forward && forwardMode === "http" ? resolveForwardTarget(forward, pathAndQuery) : new URL(pathAndQuery, upstreamBase);
|
|
312
|
+
const headers = new Headers();
|
|
313
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
314
|
+
if (value === void 0) continue;
|
|
315
|
+
if (key.toLowerCase() === "host") continue;
|
|
316
|
+
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
317
|
+
else headers.set(key, value);
|
|
318
|
+
}
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
req.on("aborted", () => controller.abort());
|
|
321
|
+
let bodyBuffer = null;
|
|
322
|
+
if (isWebsearch) {
|
|
323
|
+
websearchRequests += 1;
|
|
324
|
+
lastWebsearchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
325
|
+
try {
|
|
326
|
+
bodyBuffer = await readBody(req, 1e6);
|
|
327
|
+
} catch {
|
|
328
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
329
|
+
res.end(JSON.stringify({ error: "Request body too large" }));
|
|
330
|
+
lastWebsearchOutcome = "rejected: body too large";
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (forward && forwardMode === "mcp") {
|
|
334
|
+
const r = await tryHandleWebsearchWithMcp(res, logger, forward, bodyBuffer);
|
|
335
|
+
if (r.handled) {
|
|
336
|
+
lastWebsearchOutcome = "handled: mcp forward";
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
340
|
+
res.end(JSON.stringify({
|
|
341
|
+
error: "WebSearch MCP forward failed",
|
|
342
|
+
message: r.error ?? "Unknown error"
|
|
343
|
+
}));
|
|
344
|
+
lastWebsearchOutcome = `error: mcp forward (${r.error ?? "unknown"})`;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (smitheryEndpoint) {
|
|
348
|
+
const r = await tryHandleWebsearchWithMcp(res, logger, smitheryEndpoint, bodyBuffer);
|
|
349
|
+
if (r.handled) {
|
|
350
|
+
lastWebsearchOutcome = "handled: smithery exa mcp";
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
354
|
+
res.end(JSON.stringify({
|
|
355
|
+
error: "Smithery Exa MCP websearch failed",
|
|
356
|
+
message: r.error ?? "Unknown error"
|
|
357
|
+
}));
|
|
358
|
+
lastWebsearchOutcome = `error: smithery exa mcp (${r.error ?? "unknown"})`;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
logger.log("[websearch] proxying", pathAndQuery, "->", targetUrl.toString());
|
|
362
|
+
lastWebsearchOutcome = "proxied: upstream";
|
|
363
|
+
} else logger.log("[factory-proxy]", req.method ?? "GET", pathAndQuery);
|
|
364
|
+
let upstreamResponse;
|
|
365
|
+
try {
|
|
366
|
+
upstreamResponse = await fetch(targetUrl, {
|
|
367
|
+
method: req.method,
|
|
368
|
+
headers,
|
|
369
|
+
body: isBodylessMethod(req.method) ? void 0 : bodyBuffer ? bodyBuffer : req,
|
|
370
|
+
redirect: "manual",
|
|
371
|
+
signal: controller.signal,
|
|
372
|
+
duplex: "half"
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
376
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
377
|
+
res.end(JSON.stringify({
|
|
378
|
+
error: "Upstream request failed",
|
|
379
|
+
message
|
|
380
|
+
}));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const setCookie = upstreamResponse.headers.getSetCookie?.();
|
|
384
|
+
if (setCookie && setCookie.length > 0) res.setHeader("set-cookie", setCookie);
|
|
385
|
+
for (const [key, value] of upstreamResponse.headers) {
|
|
386
|
+
if (key.toLowerCase() === "set-cookie") continue;
|
|
387
|
+
res.setHeader(key, value);
|
|
388
|
+
}
|
|
389
|
+
res.statusCode = upstreamResponse.status;
|
|
390
|
+
if (!upstreamResponse.body) {
|
|
391
|
+
res.end();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
await pipeline(Readable.fromWeb(upstreamResponse.body), res);
|
|
396
|
+
} catch {}
|
|
397
|
+
})();
|
|
398
|
+
});
|
|
399
|
+
await new Promise((resolve, reject) => {
|
|
400
|
+
server.once("error", reject);
|
|
401
|
+
server.listen(port, host, () => {
|
|
402
|
+
server.off("error", reject);
|
|
403
|
+
resolve();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
const address = server.address();
|
|
407
|
+
if (!address || typeof address === "string") {
|
|
408
|
+
server.close();
|
|
409
|
+
throw new Error("Failed to bind websearch proxy server");
|
|
410
|
+
}
|
|
411
|
+
const baseUrl = `http://${host}:${address.port}`;
|
|
412
|
+
logger.log("[websearch] proxy listening on", baseUrl);
|
|
413
|
+
return {
|
|
414
|
+
baseUrl,
|
|
415
|
+
close: () => new Promise((resolve) => {
|
|
416
|
+
server.close(() => resolve());
|
|
417
|
+
})
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
76
421
|
//#endregion
|
|
77
422
|
//#region src/droid-adapter.ts
|
|
78
423
|
function createDroidAdapter(options) {
|
|
79
424
|
let process$1 = null;
|
|
80
425
|
let sessionId = null;
|
|
426
|
+
let websearchProxy = null;
|
|
81
427
|
const machineId = randomUUID();
|
|
82
428
|
const logger = options.logger ?? console;
|
|
83
429
|
const notificationHandlers = [];
|
|
@@ -287,6 +633,12 @@ function createDroidAdapter(options) {
|
|
|
287
633
|
logger.error("Parse error:", err.message);
|
|
288
634
|
}
|
|
289
635
|
};
|
|
636
|
+
const stopWebsearchProxy = () => {
|
|
637
|
+
if (!websearchProxy) return;
|
|
638
|
+
const proxy = websearchProxy;
|
|
639
|
+
websearchProxy = null;
|
|
640
|
+
proxy.close().catch((err) => logger.error("[websearch] proxy close failed:", err));
|
|
641
|
+
};
|
|
290
642
|
return {
|
|
291
643
|
async start() {
|
|
292
644
|
const executable = findDroidExecutable();
|
|
@@ -300,16 +652,45 @@ function createDroidAdapter(options) {
|
|
|
300
652
|
options.cwd
|
|
301
653
|
];
|
|
302
654
|
logger.log("Starting droid:", executable, args.join(" "));
|
|
655
|
+
const env = {
|
|
656
|
+
...globalThis.process.env,
|
|
657
|
+
FORCE_COLOR: "0"
|
|
658
|
+
};
|
|
659
|
+
if (isEnvEnabled(env.DROID_ACP_WEBSEARCH)) {
|
|
660
|
+
stopWebsearchProxy();
|
|
661
|
+
const upstreamBaseUrl = env.DROID_ACP_WEBSEARCH_UPSTREAM_URL ?? env.FACTORY_API_BASE_URL_OVERRIDE ?? "https://api.factory.ai";
|
|
662
|
+
const websearchForwardUrl = env.DROID_ACP_WEBSEARCH_FORWARD_URL;
|
|
663
|
+
const forwardModeRaw = env.DROID_ACP_WEBSEARCH_FORWARD_MODE;
|
|
664
|
+
const websearchForwardMode = typeof forwardModeRaw === "string" && forwardModeRaw.trim().toLowerCase() === "mcp" ? "mcp" : "http";
|
|
665
|
+
const host = env.DROID_ACP_WEBSEARCH_HOST ?? "127.0.0.1";
|
|
666
|
+
const portRaw = env.DROID_ACP_WEBSEARCH_PORT;
|
|
667
|
+
let port;
|
|
668
|
+
if (typeof portRaw === "string" && portRaw.length > 0) {
|
|
669
|
+
const parsed = Number.parseInt(portRaw, 10);
|
|
670
|
+
if (Number.isNaN(parsed) || parsed < 0 || parsed > 65535) throw new Error(`Invalid DROID_ACP_WEBSEARCH_PORT: ${portRaw}`);
|
|
671
|
+
port = parsed;
|
|
672
|
+
}
|
|
673
|
+
websearchProxy = await startWebsearchProxy({
|
|
674
|
+
upstreamBaseUrl,
|
|
675
|
+
websearchForwardUrl,
|
|
676
|
+
websearchForwardMode,
|
|
677
|
+
smitheryApiKey: env.SMITHERY_API_KEY,
|
|
678
|
+
smitheryProfile: env.SMITHERY_PROFILE,
|
|
679
|
+
host,
|
|
680
|
+
port,
|
|
681
|
+
logger
|
|
682
|
+
});
|
|
683
|
+
if (!env.FACTORY_API_KEY) env.FACTORY_API_KEY = "droid-acp-websearch";
|
|
684
|
+
env.FACTORY_API_BASE_URL_OVERRIDE = websearchProxy.baseUrl;
|
|
685
|
+
env.FACTORY_API_BASE_URL = websearchProxy.baseUrl;
|
|
686
|
+
}
|
|
303
687
|
process$1 = spawn(executable, args, {
|
|
304
688
|
stdio: [
|
|
305
689
|
"pipe",
|
|
306
690
|
"pipe",
|
|
307
691
|
"pipe"
|
|
308
692
|
],
|
|
309
|
-
env
|
|
310
|
-
...globalThis.process.env,
|
|
311
|
-
FORCE_COLOR: "0"
|
|
312
|
-
},
|
|
693
|
+
env,
|
|
313
694
|
shell: isWindows,
|
|
314
695
|
windowsHide: true
|
|
315
696
|
});
|
|
@@ -321,6 +702,7 @@ function createDroidAdapter(options) {
|
|
|
321
702
|
process$1.on("exit", (code) => {
|
|
322
703
|
logger.log("Droid exit:", code);
|
|
323
704
|
process$1 = null;
|
|
705
|
+
stopWebsearchProxy();
|
|
324
706
|
exitHandlers.forEach((h) => h(code));
|
|
325
707
|
});
|
|
326
708
|
return new Promise((resolve, reject) => {
|
|
@@ -352,6 +734,9 @@ function createDroidAdapter(options) {
|
|
|
352
734
|
if (Array.isArray(message.images) && message.images.length > 0) params.images = message.images;
|
|
353
735
|
send("droid.add_user_message", params);
|
|
354
736
|
},
|
|
737
|
+
getWebsearchProxyBaseUrl() {
|
|
738
|
+
return websearchProxy?.baseUrl ?? null;
|
|
739
|
+
},
|
|
355
740
|
setMode(level) {
|
|
356
741
|
if (!sessionId) return;
|
|
357
742
|
send("droid.update_session_settings", {
|
|
@@ -384,6 +769,7 @@ function createDroidAdapter(options) {
|
|
|
384
769
|
process$1.kill("SIGTERM");
|
|
385
770
|
process$1 = null;
|
|
386
771
|
}
|
|
772
|
+
stopWebsearchProxy();
|
|
387
773
|
},
|
|
388
774
|
isRunning() {
|
|
389
775
|
return process$1 !== null && !process$1.killed;
|
|
@@ -406,10 +792,7 @@ const ACP_MODES = [
|
|
|
406
792
|
|
|
407
793
|
//#endregion
|
|
408
794
|
//#region src/acp-agent.ts
|
|
409
|
-
const packageJson =
|
|
410
|
-
name: "droid-acp",
|
|
411
|
-
version: "0.1.0"
|
|
412
|
-
};
|
|
795
|
+
const packageJson = createRequire(import.meta.url)("../package.json");
|
|
413
796
|
function normalizeBase64DataUrl(data, fallbackMimeType) {
|
|
414
797
|
const trimmed = data.trim();
|
|
415
798
|
const match = trimmed.match(/^data:([^;,]+);base64,(.*)$/s);
|
|
@@ -562,6 +945,34 @@ var DroidAcpAgent = class {
|
|
|
562
945
|
});
|
|
563
946
|
this.sessions.set(sessionId, session);
|
|
564
947
|
this.logger.log("Session created:", sessionId);
|
|
948
|
+
if (isEnvEnabled(process.env.DROID_ACP_WEBSEARCH_DEBUG) || isEnvEnabled(process.env.DROID_DEBUG)) {
|
|
949
|
+
const websearchProxyBaseUrl = droid.getWebsearchProxyBaseUrl();
|
|
950
|
+
const parentFactoryApiKey = process.env.FACTORY_API_KEY;
|
|
951
|
+
const willInjectDummyFactoryApiKey = isEnvEnabled(process.env.DROID_ACP_WEBSEARCH) && !parentFactoryApiKey;
|
|
952
|
+
setTimeout(() => {
|
|
953
|
+
this.client.sessionUpdate({
|
|
954
|
+
sessionId,
|
|
955
|
+
update: {
|
|
956
|
+
sessionUpdate: "agent_message_chunk",
|
|
957
|
+
content: {
|
|
958
|
+
type: "text",
|
|
959
|
+
text: [
|
|
960
|
+
"[droid-acp] WebSearch 状态",
|
|
961
|
+
`- DROID_ACP_WEBSEARCH: ${process.env.DROID_ACP_WEBSEARCH ?? "<unset>"}`,
|
|
962
|
+
`- DROID_ACP_WEBSEARCH_PORT: ${process.env.DROID_ACP_WEBSEARCH_PORT ?? "<unset>"}`,
|
|
963
|
+
`- DROID_ACP_WEBSEARCH_FORWARD_MODE: ${process.env.DROID_ACP_WEBSEARCH_FORWARD_MODE ?? "<unset>"}`,
|
|
964
|
+
`- DROID_ACP_WEBSEARCH_FORWARD_URL: ${process.env.DROID_ACP_WEBSEARCH_FORWARD_URL ?? "<unset>"}`,
|
|
965
|
+
`- FACTORY_API_KEY: ${parentFactoryApiKey ? "set" : "<unset>"}${willInjectDummyFactoryApiKey ? " (droid child auto-inject dummy)" : ""}`,
|
|
966
|
+
`- SMITHERY_API_KEY: ${process.env.SMITHERY_API_KEY ? "set" : "<unset>"}`,
|
|
967
|
+
`- SMITHERY_PROFILE: ${process.env.SMITHERY_PROFILE ? "set" : "<unset>"}`,
|
|
968
|
+
`- proxyBaseUrl: ${websearchProxyBaseUrl ?? "<not running>"}`,
|
|
969
|
+
websearchProxyBaseUrl ? `- health: ${websearchProxyBaseUrl}/health` : null
|
|
970
|
+
].filter((l) => typeof l === "string").join("\n") + "\n"
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
}, 0);
|
|
975
|
+
}
|
|
565
976
|
setTimeout(() => {
|
|
566
977
|
this.client.sessionUpdate({
|
|
567
978
|
sessionId,
|
|
@@ -1625,5 +2036,5 @@ function runAcp() {
|
|
|
1625
2036
|
}
|
|
1626
2037
|
|
|
1627
2038
|
//#endregion
|
|
1628
|
-
export {
|
|
1629
|
-
//# sourceMappingURL=acp-agent-
|
|
2039
|
+
export { startWebsearchProxy as a, isEnvEnabled as c, createDroidAdapter as i, isWindows as l, runAcp as n, Pushable as o, ACP_MODES as r, findDroidExecutable as s, DroidAcpAgent as t };
|
|
2040
|
+
//# sourceMappingURL=acp-agent-DUNNYT3R.mjs.map
|