lynkr 8.0.0 → 8.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/README.md +1 -1
- package/package.json +1 -1
- package/src/api/openai-router.js +34 -2
- package/src/clients/standard-tools.js +23 -0
- package/src/config/index.js +20 -0
- package/src/orchestrator/index.js +2 -2
- package/src/server.js +2 -12
- package/src/tools/index.js +4 -0
- package/src/tools/lazy-loader.js +7 -0
- package/src/tools/tinyfish.js +358 -0
- package/src/tools/truncate.js +1 -0
- package/.github/FUNDING.yml +0 -15
- package/.github/workflows/README.md +0 -215
- package/.github/workflows/ci.yml +0 -69
- package/.github/workflows/index.yml +0 -62
- package/.github/workflows/web-tools-tests.yml +0 -56
- package/CITATIONS.bib +0 -6
- package/DEPLOYMENT.md +0 -1001
- package/LYNKR-TUI-PLAN.md +0 -984
- package/PERFORMANCE-REPORT.md +0 -866
- package/PLAN-per-client-model-routing.md +0 -252
- package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
- package/docs/BingSiteAuth.xml +0 -4
- package/docs/docs-style.css +0 -478
- package/docs/docs.html +0 -198
- package/docs/google5be250e608e6da39.html +0 -1
- package/docs/index.html +0 -577
- package/docs/index.md +0 -584
- package/docs/robots.txt +0 -4
- package/docs/sitemap.xml +0 -44
- package/docs/style.css +0 -1223
- package/docs/toon-integration-spec.md +0 -130
- package/documentation/README.md +0 -101
- package/documentation/api.md +0 -806
- package/documentation/claude-code-cli.md +0 -679
- package/documentation/codex-cli.md +0 -397
- package/documentation/contributing.md +0 -571
- package/documentation/cursor-integration.md +0 -734
- package/documentation/docker.md +0 -874
- package/documentation/embeddings.md +0 -762
- package/documentation/faq.md +0 -713
- package/documentation/features.md +0 -403
- package/documentation/headroom.md +0 -519
- package/documentation/installation.md +0 -758
- package/documentation/memory-system.md +0 -476
- package/documentation/production.md +0 -636
- package/documentation/providers.md +0 -1009
- package/documentation/routing.md +0 -476
- package/documentation/testing.md +0 -629
- package/documentation/token-optimization.md +0 -325
- package/documentation/tools.md +0 -697
- package/documentation/troubleshooting.md +0 -969
- package/final-test.js +0 -33
- package/headroom-sidecar/config.py +0 -93
- package/headroom-sidecar/requirements.txt +0 -14
- package/headroom-sidecar/server.py +0 -451
- package/monitor-agents.sh +0 -31
- package/scripts/audit-log-reader.js +0 -399
- package/scripts/compact-dictionary.js +0 -204
- package/scripts/test-deduplication.js +0 -448
- package/src/db/database.sqlite +0 -0
- package/te +0 -11622
- package/test/README.md +0 -212
- package/test/azure-openai-config.test.js +0 -213
- package/test/azure-openai-error-resilience.test.js +0 -238
- package/test/azure-openai-format-conversion.test.js +0 -354
- package/test/azure-openai-integration.test.js +0 -287
- package/test/azure-openai-routing.test.js +0 -175
- package/test/azure-openai-streaming.test.js +0 -171
- package/test/bedrock-integration.test.js +0 -457
- package/test/comprehensive-test-suite.js +0 -928
- package/test/config-validation.test.js +0 -207
- package/test/cursor-integration.test.js +0 -484
- package/test/format-conversion.test.js +0 -578
- package/test/hybrid-routing-integration.test.js +0 -269
- package/test/hybrid-routing-performance.test.js +0 -428
- package/test/llamacpp-integration.test.js +0 -882
- package/test/lmstudio-integration.test.js +0 -347
- package/test/memory/extractor.test.js +0 -398
- package/test/memory/retriever.test.js +0 -613
- package/test/memory/retriever.test.js.bak +0 -585
- package/test/memory/search.test.js +0 -537
- package/test/memory/search.test.js.bak +0 -389
- package/test/memory/store.test.js +0 -344
- package/test/memory/store.test.js.bak +0 -312
- package/test/memory/surprise.test.js +0 -300
- package/test/memory-performance.test.js +0 -472
- package/test/openai-integration.test.js +0 -683
- package/test/openrouter-error-resilience.test.js +0 -418
- package/test/passthrough-mode.test.js +0 -385
- package/test/performance-benchmark.js +0 -351
- package/test/performance-tests.js +0 -528
- package/test/routing.test.js +0 -225
- package/test/toon-compression.test.js +0 -131
- package/test/web-tools.test.js +0 -329
- package/test-agents-simple.js +0 -43
- package/test-cli-connection.sh +0 -33
- package/test-learning-unit.js +0 -126
- package/test-learning.js +0 -112
- package/test-parallel-agents.sh +0 -124
- package/test-parallel-direct.js +0 -155
- package/test-subagents.sh +0 -117
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lynkr",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"description": "Self-hosted Claude Code & Cursor proxy with Databricks,AWS BedRock,Azure adapters, openrouter, Ollama,llamacpp,LM Studio, workspace tooling, and MCP integration.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/src/api/openai-router.js
CHANGED
|
@@ -126,6 +126,13 @@ const CLIENT_TOOL_MAPPINGS = {
|
|
|
126
126
|
mapArgs: (a) => ({
|
|
127
127
|
query: a.query || ""
|
|
128
128
|
})
|
|
129
|
+
},
|
|
130
|
+
"WebAgent": {
|
|
131
|
+
name: "web_agent",
|
|
132
|
+
mapArgs: (a) => ({
|
|
133
|
+
url: a.url || "",
|
|
134
|
+
goal: a.goal || ""
|
|
135
|
+
})
|
|
129
136
|
}
|
|
130
137
|
},
|
|
131
138
|
|
|
@@ -181,6 +188,13 @@ const CLIENT_TOOL_MAPPINGS = {
|
|
|
181
188
|
path: a.path || a.directory || ".",
|
|
182
189
|
recursive: false
|
|
183
190
|
})
|
|
191
|
+
},
|
|
192
|
+
"WebAgent": {
|
|
193
|
+
name: "web_agent",
|
|
194
|
+
mapArgs: (a) => ({
|
|
195
|
+
url: a.url || "",
|
|
196
|
+
goal: a.goal || ""
|
|
197
|
+
})
|
|
184
198
|
}
|
|
185
199
|
},
|
|
186
200
|
|
|
@@ -237,6 +251,13 @@ const CLIENT_TOOL_MAPPINGS = {
|
|
|
237
251
|
path: a.path || a.directory || ".",
|
|
238
252
|
recursive: false
|
|
239
253
|
})
|
|
254
|
+
},
|
|
255
|
+
"WebAgent": {
|
|
256
|
+
name: "web_agent",
|
|
257
|
+
mapArgs: (a) => ({
|
|
258
|
+
url: a.url || "",
|
|
259
|
+
goal: a.goal || ""
|
|
260
|
+
})
|
|
240
261
|
}
|
|
241
262
|
},
|
|
242
263
|
|
|
@@ -287,6 +308,13 @@ const CLIENT_TOOL_MAPPINGS = {
|
|
|
287
308
|
mapArgs: (a) => ({
|
|
288
309
|
filepath: a.path || a.directory || "."
|
|
289
310
|
})
|
|
311
|
+
},
|
|
312
|
+
"WebAgent": {
|
|
313
|
+
name: "web_agent",
|
|
314
|
+
mapArgs: (a) => ({
|
|
315
|
+
url: a.url || "",
|
|
316
|
+
goal: a.goal || ""
|
|
317
|
+
})
|
|
290
318
|
}
|
|
291
319
|
}
|
|
292
320
|
};
|
|
@@ -406,8 +434,10 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
406
434
|
// that have a mapping in CLIENT_TOOL_MAPPINGS — this ensures clients like
|
|
407
435
|
// Codex don't see tools they can't handle (Task, WebFetch, NotebookEdit)
|
|
408
436
|
// while Claude Code (unknown client) gets the full IDE_SAFE_TOOLS set.
|
|
437
|
+
// Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
|
|
409
438
|
const clientType = detectClient(req.headers);
|
|
410
|
-
|
|
439
|
+
const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
|
|
440
|
+
if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
|
|
411
441
|
const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
|
|
412
442
|
const clientTools = clientMappings
|
|
413
443
|
? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
|
|
@@ -1450,8 +1480,10 @@ router.post("/responses", async (req, res) => {
|
|
|
1450
1480
|
}, "After Chat→Anthropic conversion");
|
|
1451
1481
|
|
|
1452
1482
|
// Inject tools if client didn't send any (same two-layer filtering as chat/completions).
|
|
1483
|
+
// Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
|
|
1453
1484
|
const clientType = detectClient(req.headers);
|
|
1454
|
-
|
|
1485
|
+
const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
|
|
1486
|
+
if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
|
|
1455
1487
|
const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
|
|
1456
1488
|
const clientTools = clientMappings
|
|
1457
1489
|
? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
|
|
@@ -380,6 +380,29 @@ EXAMPLE: User says "explore this project" → Call Task with subagent_type="Expl
|
|
|
380
380
|
required: ["url", "prompt"]
|
|
381
381
|
}
|
|
382
382
|
},
|
|
383
|
+
{
|
|
384
|
+
name: "WebAgent",
|
|
385
|
+
description: "Launches a browser agent to navigate a website and accomplish a goal. Use when you need to interact with dynamic web content (click buttons, fill forms, extract data from JS-rendered pages) beyond what a simple HTTP fetch can do. Returns structured JSON. Takes 10-60 seconds.",
|
|
386
|
+
input_schema: {
|
|
387
|
+
type: "object",
|
|
388
|
+
properties: {
|
|
389
|
+
url: {
|
|
390
|
+
type: "string",
|
|
391
|
+
description: "Target URL to navigate to"
|
|
392
|
+
},
|
|
393
|
+
goal: {
|
|
394
|
+
type: "string",
|
|
395
|
+
description: "What to accomplish on the page. Be specific about what data to extract or actions to take."
|
|
396
|
+
},
|
|
397
|
+
browser_profile: {
|
|
398
|
+
type: "string",
|
|
399
|
+
enum: ["lite", "stealth"],
|
|
400
|
+
description: "lite (default, faster) or stealth (for bot-protected sites)"
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
required: ["url", "goal"]
|
|
404
|
+
}
|
|
405
|
+
},
|
|
383
406
|
{
|
|
384
407
|
name: "NotebookEdit",
|
|
385
408
|
description: "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file). Use for editing interactive documents that combine code, text, and visualizations.",
|
package/src/config/index.js
CHANGED
|
@@ -397,6 +397,14 @@ const webFetchBodyPreviewMax = Number.parseInt(process.env.WEB_FETCH_BODY_PREVIE
|
|
|
397
397
|
const webSearchRetryEnabled = process.env.WEB_SEARCH_RETRY_ENABLED !== "false"; // default true
|
|
398
398
|
const webSearchMaxRetries = Number.parseInt(process.env.WEB_SEARCH_MAX_RETRIES ?? "2", 10);
|
|
399
399
|
|
|
400
|
+
// TinyFish AI Browser Automation configuration
|
|
401
|
+
const tinyfishApiKey = process.env.TINYFISH_API_KEY?.trim() || null;
|
|
402
|
+
const tinyfishEndpoint = process.env.TINYFISH_ENDPOINT?.trim() || "https://agent.tinyfish.ai/v1/automation/run-sse";
|
|
403
|
+
const tinyfishBrowserProfile = process.env.TINYFISH_BROWSER_PROFILE?.trim() || "lite";
|
|
404
|
+
const tinyfishTimeoutMs = parseInt(process.env.TINYFISH_TIMEOUT_MS ?? "120000", 10);
|
|
405
|
+
const tinyfishProxyEnabled = process.env.TINYFISH_PROXY_ENABLED === "true";
|
|
406
|
+
const tinyfishProxyCountry = process.env.TINYFISH_PROXY_COUNTRY?.trim() || "US";
|
|
407
|
+
|
|
400
408
|
const policyMaxSteps = Number.parseInt(process.env.POLICY_MAX_STEPS ?? "8", 10);
|
|
401
409
|
const policyMaxToolCalls = Number.parseInt(process.env.POLICY_MAX_TOOL_CALLS ?? "12", 10);
|
|
402
410
|
const policyToolLoopThreshold = Number.parseInt(process.env.POLICY_TOOL_LOOP_THRESHOLD ?? "10", 10);
|
|
@@ -660,6 +668,14 @@ var config = {
|
|
|
660
668
|
retryEnabled: webSearchRetryEnabled,
|
|
661
669
|
maxRetries: Number.isNaN(webSearchMaxRetries) ? 2 : webSearchMaxRetries,
|
|
662
670
|
},
|
|
671
|
+
tinyfish: {
|
|
672
|
+
apiKey: tinyfishApiKey,
|
|
673
|
+
endpoint: tinyfishEndpoint,
|
|
674
|
+
browserProfile: tinyfishBrowserProfile,
|
|
675
|
+
timeoutMs: Number.isNaN(tinyfishTimeoutMs) ? 120000 : tinyfishTimeoutMs,
|
|
676
|
+
proxyEnabled: tinyfishProxyEnabled,
|
|
677
|
+
proxyCountry: tinyfishProxyCountry,
|
|
678
|
+
},
|
|
663
679
|
policy: {
|
|
664
680
|
maxStepsPerTurn: Number.isNaN(policyMaxSteps) ? 8 : policyMaxSteps,
|
|
665
681
|
maxToolCallsPerTurn: Number.isNaN(policyMaxToolCalls) ? 12 : policyMaxToolCalls,
|
|
@@ -938,6 +954,10 @@ function reloadConfig() {
|
|
|
938
954
|
config.modelProvider.fallbackProvider = (process.env.FALLBACK_PROVIDER ?? "databricks").toLowerCase();
|
|
939
955
|
config.modelProvider.suggestionModeModel = (process.env.SUGGESTION_MODE_MODEL ?? "default").trim();
|
|
940
956
|
|
|
957
|
+
// TinyFish config reload
|
|
958
|
+
config.tinyfish.apiKey = process.env.TINYFISH_API_KEY?.trim() || null;
|
|
959
|
+
config.tinyfish.browserProfile = process.env.TINYFISH_BROWSER_PROFILE?.trim() || "lite";
|
|
960
|
+
|
|
941
961
|
config.toon.enabled = process.env.TOON_ENABLED === "true";
|
|
942
962
|
const newToonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
|
|
943
963
|
config.toon.minBytes = Number.isNaN(newToonMinBytes) ? 4096 : newToonMinBytes;
|
|
@@ -1358,7 +1358,7 @@ function sanitizePayload(payload) {
|
|
|
1358
1358
|
clean.tools = selectedTools.length > 0 ? selectedTools : undefined;
|
|
1359
1359
|
}
|
|
1360
1360
|
|
|
1361
|
-
clean.stream = payload
|
|
1361
|
+
clean.stream = payload?.stream ?? false;
|
|
1362
1362
|
|
|
1363
1363
|
if (
|
|
1364
1364
|
config.modelProvider?.type === "azure-anthropic" &&
|
|
@@ -2248,7 +2248,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2248
2248
|
const serverSideToolCalls = [];
|
|
2249
2249
|
const clientSideToolCalls = [];
|
|
2250
2250
|
|
|
2251
|
-
const SERVER_SIDE_TOOLS = new Set(["task", "web_search", "web_fetch", "websearch", "webfetch"]);
|
|
2251
|
+
const SERVER_SIDE_TOOLS = new Set(["task", "web_search", "web_fetch", "websearch", "webfetch", "web_agent"]);
|
|
2252
2252
|
|
|
2253
2253
|
for (const call of toolCalls) {
|
|
2254
2254
|
const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
|
package/src/server.js
CHANGED
|
@@ -78,18 +78,8 @@ function createApp() {
|
|
|
78
78
|
// Metrics collection
|
|
79
79
|
app.use(metricsMiddleware);
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
level: 6, // Balanced compression level
|
|
84
|
-
threshold: 1024, // Only compress responses > 1KB
|
|
85
|
-
filter: (req, res) => {
|
|
86
|
-
// Don't compress event streams
|
|
87
|
-
if (res.getHeader('Content-Type') === 'text/event-stream') {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
return compression.filter(req, res);
|
|
91
|
-
}
|
|
92
|
-
}));
|
|
81
|
+
// Note: If using a tunnel (ngrok, Cloudflare Tunnel) and seeing BrotliDecompressionError,
|
|
82
|
+
// start ngrok with: ngrok http 8081 --request-header-remove "Accept-Encoding"
|
|
93
83
|
|
|
94
84
|
app.use(express.json({ limit: config.server.jsonLimit }));
|
|
95
85
|
app.use(sessionMiddleware);
|
package/src/tools/index.js
CHANGED
|
@@ -30,6 +30,10 @@ const TOOL_ALIASES = {
|
|
|
30
30
|
WebSearch: "web_search",
|
|
31
31
|
web_fetch: "web_fetch",
|
|
32
32
|
webfetch: "web_fetch",
|
|
33
|
+
web_agent: "web_agent",
|
|
34
|
+
webagent: "web_agent",
|
|
35
|
+
WebAgent: "web_agent",
|
|
36
|
+
tinyfish: "web_agent",
|
|
33
37
|
task: "fs_write",
|
|
34
38
|
write: "fs_write",
|
|
35
39
|
filewrite: "fs_write",
|
package/src/tools/lazy-loader.js
CHANGED
|
@@ -57,6 +57,11 @@ const TOOL_CATEGORIES = {
|
|
|
57
57
|
loader: () => require('./tasks').registerTaskTools,
|
|
58
58
|
priority: 2,
|
|
59
59
|
},
|
|
60
|
+
tinyfish: {
|
|
61
|
+
keywords: ['tinyfish', 'web_agent', 'automate', 'scrape', 'extract', 'crawl', 'browser'],
|
|
62
|
+
loader: () => require('./tinyfish').registerTinyFishTools,
|
|
63
|
+
priority: 2,
|
|
64
|
+
},
|
|
60
65
|
tests: {
|
|
61
66
|
keywords: ['test', 'jest', 'mocha', 'pytest', 'unittest', 'spec', 'coverage', 'assert'],
|
|
62
67
|
loader: () => require('./tests').registerTestTools,
|
|
@@ -277,6 +282,8 @@ function loadCategoryForTool(toolName) {
|
|
|
277
282
|
'workspace_mcp_servers': 'mcp',
|
|
278
283
|
|
|
279
284
|
// Agent task
|
|
285
|
+
// TinyFish (web agent)
|
|
286
|
+
'web_agent': 'tinyfish',
|
|
280
287
|
'agent_task': 'agentTask',
|
|
281
288
|
};
|
|
282
289
|
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
const { URL } = require("url");
|
|
2
|
+
const { Agent } = require("undici");
|
|
3
|
+
const config = require("../config");
|
|
4
|
+
const logger = require("../logger");
|
|
5
|
+
const { registerTool } = require(".");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Dedicated HTTP agent for TinyFish SSE streams.
|
|
9
|
+
* The default webAgent in web-client.js has a 30s bodyTimeout which is too
|
|
10
|
+
* short for browser-automation tasks that can take up to 120s.
|
|
11
|
+
*/
|
|
12
|
+
const sseAgent = new Agent({
|
|
13
|
+
connections: 10,
|
|
14
|
+
pipelining: 1,
|
|
15
|
+
keepAliveTimeout: 60000,
|
|
16
|
+
connectTimeout: 15000,
|
|
17
|
+
bodyTimeout: 0, // no body timeout — we manage timeout via AbortController
|
|
18
|
+
headersTimeout: 15000,
|
|
19
|
+
maxRedirections: 3,
|
|
20
|
+
strictContentLength: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Argument normalisers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function normalizeUrl(args) {
|
|
28
|
+
const raw = args.url ?? args.uri ?? args.href ?? args.target_url;
|
|
29
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
30
|
+
throw new Error("web_agent requires a non-empty url string.");
|
|
31
|
+
}
|
|
32
|
+
// Validate URL
|
|
33
|
+
try {
|
|
34
|
+
new URL(raw.trim());
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`web_agent received an invalid URL: ${raw}`);
|
|
37
|
+
}
|
|
38
|
+
return raw.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeGoal(args) {
|
|
42
|
+
const goal = args.goal ?? args.task ?? args.prompt ?? args.instruction;
|
|
43
|
+
if (typeof goal !== "string" || goal.trim().length === 0) {
|
|
44
|
+
throw new Error("web_agent requires a non-empty goal string.");
|
|
45
|
+
}
|
|
46
|
+
return goal.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveBrowserProfile(args) {
|
|
50
|
+
const profile = args.browser_profile ?? args.browserProfile ?? config.tinyfish.browserProfile;
|
|
51
|
+
if (profile === "stealth") return "stealth";
|
|
52
|
+
return "lite";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// SSE stream consumer
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function consumeSSEStream(response, timeoutMs) {
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let buffer = "";
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
68
|
+
const err = new Error(`TinyFish SSE stream timed out after ${timeoutMs}ms`);
|
|
69
|
+
err.code = "ETIMEDOUT";
|
|
70
|
+
err.status = 504;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { done, value } = await reader.read();
|
|
75
|
+
if (done) break;
|
|
76
|
+
|
|
77
|
+
buffer += decoder.decode(value, { stream: true });
|
|
78
|
+
|
|
79
|
+
// SSE events are separated by double newlines
|
|
80
|
+
const parts = buffer.split("\n\n");
|
|
81
|
+
// Keep the last (possibly incomplete) chunk in the buffer
|
|
82
|
+
buffer = parts.pop() || "";
|
|
83
|
+
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
// Extract the data: line(s)
|
|
86
|
+
const lines = part.split("\n");
|
|
87
|
+
let dataStr = "";
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (line.startsWith("data: ")) {
|
|
90
|
+
dataStr += line.slice(6);
|
|
91
|
+
} else if (line.startsWith("data:")) {
|
|
92
|
+
dataStr += line.slice(5);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!dataStr) continue;
|
|
97
|
+
|
|
98
|
+
let event;
|
|
99
|
+
try {
|
|
100
|
+
event = JSON.parse(dataStr);
|
|
101
|
+
} catch {
|
|
102
|
+
// Not valid JSON — skip this SSE frame
|
|
103
|
+
logger.debug({ raw: dataStr.slice(0, 200) }, "TinyFish: non-JSON SSE frame, skipping");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.debug(
|
|
108
|
+
{ type: event.type, status: event.status },
|
|
109
|
+
"TinyFish SSE event"
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (event.type === "COMPLETE" || event.type === "complete") {
|
|
113
|
+
const status = (event.status ?? "").toUpperCase();
|
|
114
|
+
if (status === "COMPLETED" || status === "SUCCESS") {
|
|
115
|
+
return event.resultJson ?? event.result ?? event.data ?? event;
|
|
116
|
+
}
|
|
117
|
+
// Task failed
|
|
118
|
+
const errMsg = event.error ?? event.message ?? "TinyFish task failed";
|
|
119
|
+
const err = new Error(typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg));
|
|
120
|
+
err.code = "TINYFISH_TASK_FAILED";
|
|
121
|
+
err.status = 502;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Stream ended without a COMPLETE event
|
|
128
|
+
const err = new Error("TinyFish SSE stream ended without a COMPLETE event");
|
|
129
|
+
err.code = "TINYFISH_INCOMPLETE";
|
|
130
|
+
err.status = 502;
|
|
131
|
+
throw err;
|
|
132
|
+
} finally {
|
|
133
|
+
reader.releaseLock();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Core API call
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async function callTinyFishAPI({ url, goal, browserProfile, proxyConfig, timeoutMs }) {
|
|
142
|
+
const endpoint = config.tinyfish.endpoint;
|
|
143
|
+
const apiKey = config.tinyfish.apiKey;
|
|
144
|
+
|
|
145
|
+
if (!apiKey) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
status: 503,
|
|
149
|
+
content: JSON.stringify({
|
|
150
|
+
error: "tinyfish_not_configured",
|
|
151
|
+
message:
|
|
152
|
+
"TinyFish API key is not configured. Set TINYFISH_API_KEY in your .env file. Get a key from https://tinyfish.ai",
|
|
153
|
+
}, null, 2),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const body = {
|
|
158
|
+
url,
|
|
159
|
+
goal,
|
|
160
|
+
browserProfile,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (proxyConfig) {
|
|
164
|
+
body.proxy = proxyConfig;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(endpoint, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
"X-API-Key": apiKey,
|
|
176
|
+
Accept: "text/event-stream",
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
dispatcher: sseAgent,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Handle non-2xx responses before attempting SSE parse
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const text = await response.text().catch(() => "");
|
|
186
|
+
const shouldRetry = response.status === 429 || response.status >= 500;
|
|
187
|
+
|
|
188
|
+
if (shouldRetry) {
|
|
189
|
+
// Retry once with 2s backoff
|
|
190
|
+
logger.warn(
|
|
191
|
+
{ status: response.status, body: text.slice(0, 200) },
|
|
192
|
+
"TinyFish API error, retrying once"
|
|
193
|
+
);
|
|
194
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
195
|
+
|
|
196
|
+
const retryController = new AbortController();
|
|
197
|
+
const retryTimeout = setTimeout(() => retryController.abort(), timeoutMs);
|
|
198
|
+
try {
|
|
199
|
+
const retryResponse = await fetch(endpoint, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: {
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
"X-API-Key": apiKey,
|
|
204
|
+
Accept: "text/event-stream",
|
|
205
|
+
},
|
|
206
|
+
body: JSON.stringify(body),
|
|
207
|
+
signal: retryController.signal,
|
|
208
|
+
dispatcher: sseAgent,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!retryResponse.ok) {
|
|
212
|
+
const retryText = await retryResponse.text().catch(() => "");
|
|
213
|
+
const err = new Error(
|
|
214
|
+
`TinyFish API error (${retryResponse.status}): ${retryResponse.statusText}`
|
|
215
|
+
);
|
|
216
|
+
err.status = retryResponse.status;
|
|
217
|
+
err.body = retryText;
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const result = await consumeSSEStream(retryResponse, timeoutMs);
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
status: 200,
|
|
225
|
+
result,
|
|
226
|
+
};
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(retryTimeout);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const err = new Error(
|
|
233
|
+
`TinyFish API error (${response.status}): ${response.statusText}`
|
|
234
|
+
);
|
|
235
|
+
err.status = response.status;
|
|
236
|
+
err.body = text;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const result = await consumeSSEStream(response, timeoutMs);
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
status: 200,
|
|
244
|
+
result,
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error.name === "AbortError") {
|
|
248
|
+
const err = new Error(`TinyFish request timed out after ${timeoutMs}ms`);
|
|
249
|
+
err.code = "ETIMEDOUT";
|
|
250
|
+
err.status = 504;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Tool registration
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function registerTinyFishTool() {
|
|
264
|
+
registerTool(
|
|
265
|
+
"web_agent",
|
|
266
|
+
async ({ args = {} }) => {
|
|
267
|
+
const url = normalizeUrl(args);
|
|
268
|
+
const goal = normalizeGoal(args);
|
|
269
|
+
const browserProfile = resolveBrowserProfile(args);
|
|
270
|
+
const timeoutMs = config.tinyfish.timeoutMs;
|
|
271
|
+
|
|
272
|
+
// Build proxy config if enabled
|
|
273
|
+
let proxyConfig = null;
|
|
274
|
+
if (config.tinyfish.proxyEnabled) {
|
|
275
|
+
proxyConfig = {
|
|
276
|
+
enabled: true,
|
|
277
|
+
country: config.tinyfish.proxyCountry,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const response = await callTinyFishAPI({
|
|
283
|
+
url,
|
|
284
|
+
goal,
|
|
285
|
+
browserProfile,
|
|
286
|
+
proxyConfig,
|
|
287
|
+
timeoutMs,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Guard clause: not configured
|
|
291
|
+
if (!response.ok && response.status === 503) {
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const resultStr =
|
|
296
|
+
typeof response.result === "string"
|
|
297
|
+
? response.result
|
|
298
|
+
: JSON.stringify(response.result, null, 2);
|
|
299
|
+
|
|
300
|
+
logger.debug(
|
|
301
|
+
{
|
|
302
|
+
url,
|
|
303
|
+
goal: goal.slice(0, 100),
|
|
304
|
+
browserProfile,
|
|
305
|
+
resultLength: resultStr.length,
|
|
306
|
+
},
|
|
307
|
+
"TinyFish web_agent completed"
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
status: 200,
|
|
313
|
+
content: resultStr,
|
|
314
|
+
metadata: {
|
|
315
|
+
url,
|
|
316
|
+
goal,
|
|
317
|
+
browserProfile,
|
|
318
|
+
resultLength: resultStr.length,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
} catch (err) {
|
|
322
|
+
logger.error(
|
|
323
|
+
{ err, url, goal: goal.slice(0, 100) },
|
|
324
|
+
"web_agent request failed"
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
status: err.status ?? 500,
|
|
329
|
+
content: JSON.stringify(
|
|
330
|
+
{
|
|
331
|
+
error: err.code ?? "web_agent_failed",
|
|
332
|
+
message: err.message,
|
|
333
|
+
url,
|
|
334
|
+
...(err.status ? { http_status: err.status } : {}),
|
|
335
|
+
},
|
|
336
|
+
null,
|
|
337
|
+
2
|
|
338
|
+
),
|
|
339
|
+
metadata: {
|
|
340
|
+
url,
|
|
341
|
+
goal,
|
|
342
|
+
error_code: err.code,
|
|
343
|
+
...(err.status ? { http_status: err.status } : {}),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{ category: "tinyfish" }
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function registerTinyFishTools() {
|
|
353
|
+
registerTinyFishTool();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
registerTinyFishTools,
|
|
358
|
+
};
|
package/src/tools/truncate.js
CHANGED
|
@@ -8,6 +8,7 @@ const TRUNCATION_LIMITS = {
|
|
|
8
8
|
Glob: { maxChars: 8000, strategy: 'head' },
|
|
9
9
|
WebFetch: { maxChars: 16000, strategy: 'head' },
|
|
10
10
|
WebSearch: { maxChars: 12000, strategy: 'head' },
|
|
11
|
+
WebAgent: { maxChars: 16000, strategy: 'head' },
|
|
11
12
|
LSP: { maxChars: 8000, strategy: 'head' },
|
|
12
13
|
Edit: { maxChars: 8000, strategy: 'middle' },
|
|
13
14
|
Write: { maxChars: 8000, strategy: 'middle' },
|
package/.github/FUNDING.yml
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# These are supported funding model platforms
|
|
2
|
-
|
|
3
|
-
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
4
|
-
patreon: # Replace with a single Patreon username
|
|
5
|
-
open_collective: # Replace with a single Open Collective username
|
|
6
|
-
ko_fi: # Replace with a single Ko-fi username
|
|
7
|
-
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
8
|
-
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
9
|
-
liberapay: # Replace with a single Liberapay username
|
|
10
|
-
issuehunt: # Replace with a single IssueHunt username
|
|
11
|
-
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
12
|
-
polar: # Replace with a single Polar username
|
|
13
|
-
buy_me_a_coffee: srinivasveera
|
|
14
|
-
thanks_dev: # Replace with a single thanks.dev username
|
|
15
|
-
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|