lynkr 9.9.1 → 9.10.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 +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +4 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +2 -2
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +275 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/difficulty-classifier.js +52 -10
- package/src/routing/index.js +35 -3
- package/src/routing/intent-score.js +20 -2
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +0 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
package/src/tools/web.js
DELETED
|
@@ -1,415 +0,0 @@
|
|
|
1
|
-
const { URL } = require("url");
|
|
2
|
-
const config = require("../config");
|
|
3
|
-
const logger = require("../logger");
|
|
4
|
-
const { registerTool } = require(".");
|
|
5
|
-
const { withRetry } = require("../clients/retry");
|
|
6
|
-
const { fetchWithAgent } = require("./web-client");
|
|
7
|
-
|
|
8
|
-
const DEFAULT_MAX_RESULTS = 5;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Extract readable text from HTML
|
|
12
|
-
* Removes scripts, styles, and extracts meaningful content
|
|
13
|
-
*/
|
|
14
|
-
function extractTextFromHtml(html) {
|
|
15
|
-
if (typeof html !== "string") return "";
|
|
16
|
-
|
|
17
|
-
let text = html;
|
|
18
|
-
|
|
19
|
-
// Remove script and style tags with their content
|
|
20
|
-
text = text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ");
|
|
21
|
-
text = text.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ");
|
|
22
|
-
|
|
23
|
-
// Remove HTML comments
|
|
24
|
-
text = text.replace(/<!--[\s\S]*?-->/g, " ");
|
|
25
|
-
|
|
26
|
-
// Convert common block elements to newlines
|
|
27
|
-
text = text.replace(/<\/(div|p|br|h[1-6]|li|tr|section|article|header|footer|nav)>/gi, "\n");
|
|
28
|
-
|
|
29
|
-
// Remove all remaining HTML tags
|
|
30
|
-
text = text.replace(/<[^>]+>/g, " ");
|
|
31
|
-
|
|
32
|
-
// Decode common HTML entities
|
|
33
|
-
text = text
|
|
34
|
-
.replace(/ /g, " ")
|
|
35
|
-
.replace(/&/g, "&")
|
|
36
|
-
.replace(/</g, "<")
|
|
37
|
-
.replace(/>/g, ">")
|
|
38
|
-
.replace(/"/g, '"')
|
|
39
|
-
.replace(/'/g, "'")
|
|
40
|
-
.replace(/'/g, "'");
|
|
41
|
-
|
|
42
|
-
// Normalize whitespace
|
|
43
|
-
text = text.replace(/\r\n/g, "\n");
|
|
44
|
-
text = text.replace(/\r/g, "\n");
|
|
45
|
-
text = text.replace(/[ \t]+/g, " ");
|
|
46
|
-
text = text.replace(/\n\s+/g, "\n");
|
|
47
|
-
text = text.replace(/\n{3,}/g, "\n\n");
|
|
48
|
-
|
|
49
|
-
return text.trim();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function normaliseQuery(args = {}) {
|
|
53
|
-
const query = args.query ?? args.q ?? args.prompt ?? args.search ?? args.input;
|
|
54
|
-
if (typeof query !== "string" || query.trim().length === 0) {
|
|
55
|
-
throw new Error("web_search requires a non-empty query string.");
|
|
56
|
-
}
|
|
57
|
-
return query.trim();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function resolveLimit(args = {}) {
|
|
61
|
-
const raw = args.limit ?? args.top_k ?? args.max_results;
|
|
62
|
-
if (raw === undefined) return DEFAULT_MAX_RESULTS;
|
|
63
|
-
const parsed = Number.parseInt(raw, 10);
|
|
64
|
-
if (Number.isNaN(parsed) || parsed <= 0) return DEFAULT_MAX_RESULTS;
|
|
65
|
-
return Math.min(parsed, 20);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function buildAllowedHosts() {
|
|
69
|
-
if (config.webSearch.allowAllHosts) {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
const configured = config.webSearch.allowedHosts ?? [];
|
|
73
|
-
const hosts = new Set();
|
|
74
|
-
if (config.webSearch.endpoint) {
|
|
75
|
-
try {
|
|
76
|
-
const endpointHost = new URL(config.webSearch.endpoint).hostname.toLowerCase();
|
|
77
|
-
hosts.add(endpointHost);
|
|
78
|
-
} catch {
|
|
79
|
-
// ignore parse errors; config already validated
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
configured.forEach((host) => hosts.add(host));
|
|
83
|
-
return hosts;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function buildSearchUrl({ query, limit }) {
|
|
87
|
-
const endpoint = new URL(config.webSearch.endpoint);
|
|
88
|
-
endpoint.searchParams.set("q", query);
|
|
89
|
-
endpoint.searchParams.set("format", "json");
|
|
90
|
-
endpoint.searchParams.set("per_page", String(limit));
|
|
91
|
-
return endpoint;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function performSearch({ query, limit, timeoutMs }) {
|
|
95
|
-
const url = buildSearchUrl({ query, limit });
|
|
96
|
-
|
|
97
|
-
// Wrap fetch in retry logic if enabled
|
|
98
|
-
const fetchFn = async () => {
|
|
99
|
-
const controller = new AbortController();
|
|
100
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
const response = await fetchWithAgent(url, {
|
|
104
|
-
method: "GET",
|
|
105
|
-
signal: controller.signal,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const text = await response.text();
|
|
109
|
-
let json;
|
|
110
|
-
try {
|
|
111
|
-
json = JSON.parse(text);
|
|
112
|
-
} catch {
|
|
113
|
-
json = null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (!response.ok) {
|
|
117
|
-
const error = new Error(`Web search provider error (${response.status}): ${response.statusText}`);
|
|
118
|
-
error.status = response.status;
|
|
119
|
-
error.body = text;
|
|
120
|
-
error.code = response.status === 429 ? "RATE_LIMITED" :
|
|
121
|
-
response.status >= 500 ? "SERVER_ERROR" : "REQUEST_ERROR";
|
|
122
|
-
throw error;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return json ?? { results: [], raw: text };
|
|
126
|
-
} catch (error) {
|
|
127
|
-
if (error.name === "AbortError") {
|
|
128
|
-
const timeoutError = new Error(`Web search timeout after ${timeoutMs}ms`);
|
|
129
|
-
timeoutError.code = "ETIMEDOUT";
|
|
130
|
-
throw timeoutError;
|
|
131
|
-
}
|
|
132
|
-
throw error;
|
|
133
|
-
} finally {
|
|
134
|
-
clearTimeout(timeout);
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
// Apply retry logic if enabled
|
|
139
|
-
if (config.webSearch.retryEnabled) {
|
|
140
|
-
return withRetry(fetchFn, {
|
|
141
|
-
maxRetries: config.webSearch.maxRetries,
|
|
142
|
-
initialDelay: 500,
|
|
143
|
-
maxDelay: 5000,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return fetchFn();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function summariseResult(item) {
|
|
151
|
-
if (!item) return null;
|
|
152
|
-
return {
|
|
153
|
-
title: item.title ?? item.name ?? null,
|
|
154
|
-
url: item.url ?? item.link ?? null,
|
|
155
|
-
snippet: item.snippet ?? item.content ?? item.summary ?? item.excerpt ?? null,
|
|
156
|
-
score: item.score ?? item.rank ?? null,
|
|
157
|
-
source: item.source ?? null,
|
|
158
|
-
metadata: item.metadata ?? null,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function formatSearchResponse(payload, { query, limit }) {
|
|
163
|
-
const results = Array.isArray(payload?.results) ? payload.results : [];
|
|
164
|
-
const payloadCount =
|
|
165
|
-
typeof payload?.number_of_results === "number" && payload.number_of_results > 0
|
|
166
|
-
? payload.number_of_results
|
|
167
|
-
: null;
|
|
168
|
-
const effectiveCount = payloadCount ?? results.length;
|
|
169
|
-
const numberOfResults = effectiveCount > 0 ? effectiveCount : undefined;
|
|
170
|
-
const metadata = {
|
|
171
|
-
...(payload?.metadata ?? {}),
|
|
172
|
-
raw_number_of_results: payloadCount,
|
|
173
|
-
engines: payload?.engines ?? null,
|
|
174
|
-
categories: payload?.categories ?? null,
|
|
175
|
-
};
|
|
176
|
-
if (numberOfResults !== undefined) {
|
|
177
|
-
metadata.number_of_results = numberOfResults;
|
|
178
|
-
}
|
|
179
|
-
return {
|
|
180
|
-
query,
|
|
181
|
-
limit,
|
|
182
|
-
number_of_results: numberOfResults,
|
|
183
|
-
results: results.map(summariseResult).filter(Boolean),
|
|
184
|
-
metadata,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function buildAllowedFetchHosts() {
|
|
189
|
-
return config.webSearch.allowAllHosts ? null : buildAllowedHosts();
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function parseUrl(rawUrl) {
|
|
193
|
-
try {
|
|
194
|
-
return new URL(rawUrl);
|
|
195
|
-
} catch (err) {
|
|
196
|
-
err.code = "invalid_url";
|
|
197
|
-
throw err;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function ensureHostAllowed(url, allowedHosts) {
|
|
202
|
-
if (allowedHosts === null) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const host = url.hostname.toLowerCase();
|
|
206
|
-
if (!allowedHosts.has(host)) {
|
|
207
|
-
const error = new Error(`Host ${host} is not in the allowlist.`);
|
|
208
|
-
error.code = "host_not_allowed";
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async function fetchDocument(url, timeoutMs) {
|
|
214
|
-
// Wrap fetch in retry logic
|
|
215
|
-
const fetchFn = async () => {
|
|
216
|
-
const controller = new AbortController();
|
|
217
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
const response = await fetchWithAgent(url.toString(), { signal: controller.signal });
|
|
221
|
-
const text = await response.text();
|
|
222
|
-
|
|
223
|
-
if (!response.ok) {
|
|
224
|
-
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
225
|
-
error.status = response.status;
|
|
226
|
-
error.code = response.status === 429 ? "RATE_LIMITED" :
|
|
227
|
-
response.status >= 500 ? "SERVER_ERROR" : "REQUEST_ERROR";
|
|
228
|
-
throw error;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
status: response.status,
|
|
233
|
-
headers: Object.fromEntries(response.headers.entries()),
|
|
234
|
-
body: text,
|
|
235
|
-
contentType: response.headers.get("content-type") || "",
|
|
236
|
-
};
|
|
237
|
-
} catch (error) {
|
|
238
|
-
if (error.name === "AbortError") {
|
|
239
|
-
const timeoutError = new Error(`Web fetch timeout after ${timeoutMs}ms`);
|
|
240
|
-
timeoutError.code = "ETIMEDOUT";
|
|
241
|
-
throw timeoutError;
|
|
242
|
-
}
|
|
243
|
-
throw error;
|
|
244
|
-
} finally {
|
|
245
|
-
clearTimeout(timeout);
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
// Apply retry logic if enabled
|
|
250
|
-
if (config.webSearch.retryEnabled) {
|
|
251
|
-
return withRetry(fetchFn, {
|
|
252
|
-
maxRetries: config.webSearch.maxRetries,
|
|
253
|
-
initialDelay: 500,
|
|
254
|
-
maxDelay: 5000,
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return fetchFn();
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function registerWebSearchTool() {
|
|
262
|
-
registerTool(
|
|
263
|
-
"web_search",
|
|
264
|
-
async ({ args = {} }) => {
|
|
265
|
-
const query = normaliseQuery(args);
|
|
266
|
-
const limit = resolveLimit(args);
|
|
267
|
-
const timeoutMs = config.webSearch.timeoutMs;
|
|
268
|
-
|
|
269
|
-
try {
|
|
270
|
-
const payload = await performSearch({ query, limit, timeoutMs });
|
|
271
|
-
const formatted = formatSearchResponse(payload, { query, limit });
|
|
272
|
-
const resultCount = formatted.results.length;
|
|
273
|
-
logger.debug(
|
|
274
|
-
{
|
|
275
|
-
query,
|
|
276
|
-
limit,
|
|
277
|
-
result_count: resultCount,
|
|
278
|
-
number_of_results: formatted.number_of_results,
|
|
279
|
-
engines: payload?.engines ?? null,
|
|
280
|
-
categories: payload?.categories ?? null,
|
|
281
|
-
sample_result: formatted.results[0] ?? null,
|
|
282
|
-
},
|
|
283
|
-
"Web search results summarised",
|
|
284
|
-
);
|
|
285
|
-
return {
|
|
286
|
-
ok: true,
|
|
287
|
-
status: 200,
|
|
288
|
-
content: JSON.stringify(formatted, null, 2),
|
|
289
|
-
metadata: {
|
|
290
|
-
query,
|
|
291
|
-
limit,
|
|
292
|
-
result_count: resultCount,
|
|
293
|
-
...(formatted.number_of_results !== undefined
|
|
294
|
-
? { number_of_results: formatted.number_of_results }
|
|
295
|
-
: {}),
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
} catch (err) {
|
|
299
|
-
logger.error({ err }, "Web search request failed");
|
|
300
|
-
return {
|
|
301
|
-
ok: false,
|
|
302
|
-
status: err.status ?? 500,
|
|
303
|
-
content: JSON.stringify(
|
|
304
|
-
{
|
|
305
|
-
error: err.code ?? "web_search_failed",
|
|
306
|
-
message: err.message,
|
|
307
|
-
status: err.status ?? 500,
|
|
308
|
-
},
|
|
309
|
-
null,
|
|
310
|
-
2,
|
|
311
|
-
),
|
|
312
|
-
metadata: {
|
|
313
|
-
query,
|
|
314
|
-
limit,
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
{ category: "web" },
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function registerWebFetchTool() {
|
|
324
|
-
registerTool(
|
|
325
|
-
"web_fetch",
|
|
326
|
-
async ({ args = {} }) => {
|
|
327
|
-
const rawUrl = args.url ?? args.uri ?? args.href;
|
|
328
|
-
if (typeof rawUrl !== "string" || rawUrl.trim().length === 0) {
|
|
329
|
-
throw new Error("web_fetch requires a url string.");
|
|
330
|
-
}
|
|
331
|
-
const url = parseUrl(rawUrl.trim());
|
|
332
|
-
const allowedHosts = buildAllowedFetchHosts();
|
|
333
|
-
ensureHostAllowed(url, allowedHosts);
|
|
334
|
-
|
|
335
|
-
const timeoutMs = config.webSearch.timeoutMs;
|
|
336
|
-
try {
|
|
337
|
-
const document = await fetchDocument(url, timeoutMs);
|
|
338
|
-
|
|
339
|
-
// Extract text content from HTML if content type indicates HTML
|
|
340
|
-
const isHtml = document.contentType.toLowerCase().includes("text/html");
|
|
341
|
-
const rawBody = document.body;
|
|
342
|
-
const bodyPreview = rawBody.slice(0, config.webSearch.bodyPreviewMax);
|
|
343
|
-
|
|
344
|
-
// For HTML, provide both raw preview and extracted text
|
|
345
|
-
const result = {
|
|
346
|
-
url: url.toString(),
|
|
347
|
-
status: document.status,
|
|
348
|
-
content_type: document.contentType,
|
|
349
|
-
headers: document.headers,
|
|
350
|
-
body_preview: bodyPreview,
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
if (isHtml) {
|
|
354
|
-
const extractedText = extractTextFromHtml(rawBody);
|
|
355
|
-
result.text_content = extractedText.slice(0, config.webSearch.bodyPreviewMax);
|
|
356
|
-
result.text_length = extractedText.length;
|
|
357
|
-
logger.debug({
|
|
358
|
-
url: url.toString(),
|
|
359
|
-
originalLength: rawBody.length,
|
|
360
|
-
extractedLength: extractedText.length,
|
|
361
|
-
}, "Extracted text from HTML");
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
return {
|
|
365
|
-
ok: document.status >= 200 && document.status < 400,
|
|
366
|
-
status: document.status,
|
|
367
|
-
content: JSON.stringify(result, null, 2),
|
|
368
|
-
metadata: {
|
|
369
|
-
url: url.toString(),
|
|
370
|
-
status: document.status,
|
|
371
|
-
content_type: document.contentType,
|
|
372
|
-
is_html: isHtml,
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
} catch (err) {
|
|
376
|
-
logger.error({
|
|
377
|
-
err,
|
|
378
|
-
url: url.toString(),
|
|
379
|
-
code: err.code,
|
|
380
|
-
status: err.status
|
|
381
|
-
}, "web_fetch failed");
|
|
382
|
-
|
|
383
|
-
return {
|
|
384
|
-
ok: false,
|
|
385
|
-
status: err.status ?? 500,
|
|
386
|
-
content: JSON.stringify(
|
|
387
|
-
{
|
|
388
|
-
error: err.code ?? "web_fetch_failed",
|
|
389
|
-
message: err.message,
|
|
390
|
-
url: url.toString(),
|
|
391
|
-
...(err.status ? { http_status: err.status } : {}),
|
|
392
|
-
},
|
|
393
|
-
null,
|
|
394
|
-
2,
|
|
395
|
-
),
|
|
396
|
-
metadata: {
|
|
397
|
-
url: url.toString(),
|
|
398
|
-
error_code: err.code,
|
|
399
|
-
...(err.status ? { http_status: err.status } : {}),
|
|
400
|
-
},
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
},
|
|
404
|
-
{ category: "web" },
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function registerWebTools() {
|
|
409
|
-
registerWebSearchTool();
|
|
410
|
-
registerWebFetchTool();
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
module.exports = {
|
|
414
|
-
registerWebTools,
|
|
415
|
-
};
|
package/src/tools/workspace.js
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
const path = require("path");
|
|
2
|
-
const {
|
|
3
|
-
readFile,
|
|
4
|
-
writeFile,
|
|
5
|
-
applyFilePatch,
|
|
6
|
-
resolveWorkspacePath,
|
|
7
|
-
expandTilde,
|
|
8
|
-
isExternalPath,
|
|
9
|
-
readExternalFile,
|
|
10
|
-
fileExists,
|
|
11
|
-
workspaceRoot,
|
|
12
|
-
} = require("../workspace");
|
|
13
|
-
const { recordEdit } = require("../edits");
|
|
14
|
-
const { registerTool } = require(".");
|
|
15
|
-
const logger = require("../logger");
|
|
16
|
-
|
|
17
|
-
function validateString(value, field) {
|
|
18
|
-
if (typeof value !== "string" || value.trim().length === 0) {
|
|
19
|
-
throw new Error(`${field} must be a non-empty string`);
|
|
20
|
-
}
|
|
21
|
-
return value;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function normalizeEncoding(value) {
|
|
25
|
-
if (!value) return "utf8";
|
|
26
|
-
const encoding = value.toLowerCase();
|
|
27
|
-
if (!["utf8", "utf-8"].includes(encoding)) {
|
|
28
|
-
throw new Error(`Unsupported encoding: ${value}`);
|
|
29
|
-
}
|
|
30
|
-
return "utf8";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function registerWorkspaceTools() {
|
|
34
|
-
registerTool(
|
|
35
|
-
"fs_read",
|
|
36
|
-
async ({ args = {} }) => {
|
|
37
|
-
const targetPath = validateString(args.path ?? args.file ?? args.file_path, "path");
|
|
38
|
-
const encoding = normalizeEncoding(args.encoding);
|
|
39
|
-
|
|
40
|
-
// Check if path is outside workspace
|
|
41
|
-
if (isExternalPath(targetPath)) {
|
|
42
|
-
if (args.user_approved !== true) {
|
|
43
|
-
const expanded = expandTilde(targetPath);
|
|
44
|
-
const resolved = path.resolve(expanded);
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
status: 403,
|
|
48
|
-
content: JSON.stringify({
|
|
49
|
-
error: "external_path_requires_approval",
|
|
50
|
-
message: `The file "${targetPath}" resolves to "${resolved}" which is outside the workspace. You MUST ask the user for permission before reading this file. If the user approves, call this tool again with the same path and set user_approved to true.`,
|
|
51
|
-
resolved_path: resolved,
|
|
52
|
-
}),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
// User approved — read external file
|
|
56
|
-
const { content, resolvedPath } = await readExternalFile(targetPath, encoding);
|
|
57
|
-
return {
|
|
58
|
-
ok: true,
|
|
59
|
-
status: 200,
|
|
60
|
-
content,
|
|
61
|
-
metadata: { path: targetPath, encoding, resolved_path: resolvedPath },
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Normal workspace read (unchanged)
|
|
66
|
-
const content = await readFile(targetPath, encoding);
|
|
67
|
-
return {
|
|
68
|
-
ok: true,
|
|
69
|
-
status: 200,
|
|
70
|
-
content,
|
|
71
|
-
metadata: {
|
|
72
|
-
path: targetPath,
|
|
73
|
-
encoding,
|
|
74
|
-
resolved_path: resolveWorkspacePath(targetPath),
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
},
|
|
78
|
-
{ category: "workspace" },
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
registerTool(
|
|
82
|
-
"fs_write",
|
|
83
|
-
async ({ args = {} }, context = {}) => {
|
|
84
|
-
const relativePath = validateString(
|
|
85
|
-
args.path ??
|
|
86
|
-
args.file ??
|
|
87
|
-
args.file_path ??
|
|
88
|
-
args.filePath ??
|
|
89
|
-
args.filename ??
|
|
90
|
-
args.name,
|
|
91
|
-
"path",
|
|
92
|
-
);
|
|
93
|
-
const encoding = normalizeEncoding(args.encoding);
|
|
94
|
-
const content =
|
|
95
|
-
typeof args.content === "string"
|
|
96
|
-
? args.content
|
|
97
|
-
: typeof args.contents === "string"
|
|
98
|
-
? args.contents
|
|
99
|
-
: "";
|
|
100
|
-
const createParents = args.create_parents !== false;
|
|
101
|
-
|
|
102
|
-
const writeResult = await writeFile(relativePath, content, {
|
|
103
|
-
encoding,
|
|
104
|
-
createParents,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
recordEdit({
|
|
109
|
-
sessionId: context.session?.id ?? context.sessionId ?? null,
|
|
110
|
-
filePath: relativePath,
|
|
111
|
-
source: "fs_write",
|
|
112
|
-
beforeContent:
|
|
113
|
-
typeof writeResult.previousContent === "string"
|
|
114
|
-
? writeResult.previousContent
|
|
115
|
-
: writeResult.previousContent ?? null,
|
|
116
|
-
afterContent: content,
|
|
117
|
-
metadata: {
|
|
118
|
-
encoding,
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
} catch (err) {
|
|
122
|
-
logger.warn({ err }, "Failed to record fs_write edit");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
ok: true,
|
|
127
|
-
status: 200,
|
|
128
|
-
content: JSON.stringify(
|
|
129
|
-
{
|
|
130
|
-
path: relativePath,
|
|
131
|
-
bytes: Buffer.byteLength(content, encoding),
|
|
132
|
-
resolved_path: resolveWorkspacePath(relativePath),
|
|
133
|
-
},
|
|
134
|
-
null,
|
|
135
|
-
2,
|
|
136
|
-
),
|
|
137
|
-
metadata: {
|
|
138
|
-
path: relativePath,
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
},
|
|
142
|
-
{ category: "workspace" },
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
registerTool(
|
|
146
|
-
"edit_patch",
|
|
147
|
-
async ({ args = {} }, context = {}) => {
|
|
148
|
-
const relativePath = validateString(args.path ?? args.file, "path");
|
|
149
|
-
const patch = validateString(args.patch, "patch");
|
|
150
|
-
const encoding = normalizeEncoding(args.encoding);
|
|
151
|
-
|
|
152
|
-
const exists = await fileExists(relativePath);
|
|
153
|
-
if (!exists) {
|
|
154
|
-
throw new Error("Cannot apply patch to non-existent file.");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const patchResult = await applyFilePatch(relativePath, patch, { encoding });
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
recordEdit({
|
|
161
|
-
sessionId: context.session?.id ?? context.sessionId ?? null,
|
|
162
|
-
filePath: relativePath,
|
|
163
|
-
source: "edit_patch",
|
|
164
|
-
beforeContent:
|
|
165
|
-
typeof patchResult.previousContent === "string"
|
|
166
|
-
? patchResult.previousContent
|
|
167
|
-
: patchResult.previousContent ?? null,
|
|
168
|
-
afterContent:
|
|
169
|
-
typeof patchResult.nextContent === "string"
|
|
170
|
-
? patchResult.nextContent
|
|
171
|
-
: patchResult.nextContent ?? null,
|
|
172
|
-
metadata: {
|
|
173
|
-
encoding,
|
|
174
|
-
patchLength: patch.length,
|
|
175
|
-
},
|
|
176
|
-
});
|
|
177
|
-
} catch (err) {
|
|
178
|
-
logger.warn({ err }, "Failed to record edit_patch edit");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
ok: true,
|
|
183
|
-
status: 200,
|
|
184
|
-
content: JSON.stringify(
|
|
185
|
-
{
|
|
186
|
-
path: relativePath,
|
|
187
|
-
resolved_path: resolveWorkspacePath(relativePath),
|
|
188
|
-
},
|
|
189
|
-
null,
|
|
190
|
-
2,
|
|
191
|
-
),
|
|
192
|
-
metadata: {
|
|
193
|
-
path: relativePath,
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
},
|
|
197
|
-
{ category: "workspace" },
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
module.exports = {
|
|
202
|
-
workspaceRoot,
|
|
203
|
-
registerWorkspaceTools,
|
|
204
|
-
};
|