@este.systems/dsc 0.1.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/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/dsc.mjs +29 -0
- package/dist/agent.js +259 -0
- package/dist/agent.js.map +1 -0
- package/dist/api.js +333 -0
- package/dist/api.js.map +1 -0
- package/dist/approval.js +79 -0
- package/dist/approval.js.map +1 -0
- package/dist/audit.js +26 -0
- package/dist/audit.js.map +1 -0
- package/dist/compact.js +100 -0
- package/dist/compact.js.map +1 -0
- package/dist/history.js +212 -0
- package/dist/history.js.map +1 -0
- package/dist/index.js +830 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.js +543 -0
- package/dist/markdown.js.map +1 -0
- package/dist/prompt.js +44 -0
- package/dist/prompt.js.map +1 -0
- package/dist/repl_history.js +55 -0
- package/dist/repl_history.js.map +1 -0
- package/dist/search.js +215 -0
- package/dist/search.js.map +1 -0
- package/dist/tools.js +670 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui.js +165 -0
- package/dist/ui.js.map +1 -0
- package/package.json +57 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { confirmBash, confirmEdit, confirmFetch, confirmWrite } from "./approval.js";
|
|
5
|
+
import * as audit from "./audit.js";
|
|
6
|
+
import { search as runSearchProvider, formatResults, getProvider, SearchError } from "./search.js";
|
|
7
|
+
export const READ_ONLY_TOOLS = new Set(["read_file", "grep", "glob", "web_search"]);
|
|
8
|
+
export const TOOL_SCHEMAS = [
|
|
9
|
+
{
|
|
10
|
+
type: "function",
|
|
11
|
+
function: {
|
|
12
|
+
name: "read_file",
|
|
13
|
+
description: "Read a file from the local filesystem. Returns up to 2000 lines by default with 1-based line numbers prefixed; pass offset/limit to page through larger files. Lines longer than 2000 chars are truncated.",
|
|
14
|
+
parameters: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
path: { type: "string", description: "Absolute or relative path to the file." },
|
|
18
|
+
offset: { type: "integer", description: "1-based line number to start reading from." },
|
|
19
|
+
limit: { type: "integer", description: "Maximum number of lines to return." },
|
|
20
|
+
},
|
|
21
|
+
required: ["path"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: "function",
|
|
27
|
+
function: {
|
|
28
|
+
name: "write_file",
|
|
29
|
+
description: "Create a new file or fully overwrite an existing one. Use only when creating new files or rewriting in full; prefer edit_file otherwise.",
|
|
30
|
+
parameters: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
path: { type: "string", description: "Path to the file." },
|
|
34
|
+
content: { type: "string", description: "Complete file contents." },
|
|
35
|
+
},
|
|
36
|
+
required: ["path", "content"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
type: "function",
|
|
42
|
+
function: {
|
|
43
|
+
name: "edit_file",
|
|
44
|
+
description: "Replace an exact substring in an existing file. old_string must appear exactly once unless replace_all is true.",
|
|
45
|
+
parameters: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
path: { type: "string", description: "Path to the file." },
|
|
49
|
+
old_string: { type: "string", description: "Exact text to replace." },
|
|
50
|
+
new_string: { type: "string", description: "Replacement text." },
|
|
51
|
+
replace_all: {
|
|
52
|
+
type: "boolean",
|
|
53
|
+
description: "If true, replace every occurrence (uniqueness not required).",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ["path", "old_string", "new_string"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
type: "function",
|
|
62
|
+
function: {
|
|
63
|
+
name: "bash",
|
|
64
|
+
description: "Run a shell command via /bin/sh. Output is captured and returned (truncated if very long). Long-running interactive commands are not supported.",
|
|
65
|
+
parameters: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
command: { type: "string", description: "The command to run." },
|
|
69
|
+
description: { type: "string", description: "Short description of why." },
|
|
70
|
+
timeout_ms: { type: "integer", description: "Timeout in milliseconds (default 60000)." },
|
|
71
|
+
},
|
|
72
|
+
required: ["command"],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "function",
|
|
78
|
+
function: {
|
|
79
|
+
name: "grep",
|
|
80
|
+
description: "Search file contents for a regex pattern. Uses ripgrep (rg) when available, falls back to grep -rn. Output is line-limited; narrow scope with path/glob if it overflows.",
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
pattern: { type: "string", description: "Regex pattern (rg/grep -E syntax)." },
|
|
85
|
+
path: { type: "string", description: "File or directory to search (default: cwd)." },
|
|
86
|
+
glob: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "Optional glob filter, e.g. '*.ts' or '!**/node_modules/**'. Passed as --glob to rg or --include to grep.",
|
|
89
|
+
},
|
|
90
|
+
case_insensitive: { type: "boolean", description: "Case-insensitive match." },
|
|
91
|
+
},
|
|
92
|
+
required: ["pattern"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: "function",
|
|
98
|
+
function: {
|
|
99
|
+
name: "glob",
|
|
100
|
+
description: "List filesystem paths matching a glob pattern (e.g. 'src/**/*.ts'). Returns up to 500 paths.",
|
|
101
|
+
parameters: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
pattern: { type: "string", description: "Glob pattern." },
|
|
105
|
+
path: { type: "string", description: "Base directory (default: cwd)." },
|
|
106
|
+
},
|
|
107
|
+
required: ["pattern"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: "function",
|
|
113
|
+
function: {
|
|
114
|
+
name: "web_fetch",
|
|
115
|
+
description: "Fetch a URL via HTTP(S) GET. HTML responses are stripped to readable text. Response is size-capped.",
|
|
116
|
+
parameters: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
url: { type: "string", description: "Full URL (http or https)." },
|
|
120
|
+
},
|
|
121
|
+
required: ["url"],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: "function",
|
|
127
|
+
function: {
|
|
128
|
+
name: "web_search",
|
|
129
|
+
description: "Search the web for a query. Returns up to 20 results as numbered title/url/snippet entries. Pair with web_fetch to read the full content of any specific URL the search returns.",
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
query: { type: "string", description: "Search query." },
|
|
134
|
+
count: {
|
|
135
|
+
type: "integer",
|
|
136
|
+
description: "How many results to return (1-20, default 5).",
|
|
137
|
+
},
|
|
138
|
+
freshness: {
|
|
139
|
+
type: "string",
|
|
140
|
+
enum: ["day", "week", "month", "year"],
|
|
141
|
+
description: "Limit to results from the last day/week/month/year.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
required: ["query"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
function resolvePath(ctx, p) {
|
|
150
|
+
return path.isAbsolute(p) ? p : path.resolve(ctx.cwd, p);
|
|
151
|
+
}
|
|
152
|
+
async function exists(p) {
|
|
153
|
+
try {
|
|
154
|
+
await fs.access(p);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function withLineNumbers(text, offset = 1) {
|
|
162
|
+
const lines = text.split("\n");
|
|
163
|
+
const width = String(offset + lines.length - 1).length;
|
|
164
|
+
return lines.map((l, i) => `${String(offset + i).padStart(width, " ")}\t${l}`).join("\n");
|
|
165
|
+
}
|
|
166
|
+
export async function executeTool(name, argsJson, ctx, signal) {
|
|
167
|
+
let args;
|
|
168
|
+
try {
|
|
169
|
+
args = JSON.parse(argsJson || "{}");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
const result = {
|
|
173
|
+
content: `error: invalid arguments JSON: ${argsJson.slice(0, 200)}`,
|
|
174
|
+
audit: { error: "invalid_arguments_json" },
|
|
175
|
+
};
|
|
176
|
+
void writeAudit(name, ctx, result);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
let result;
|
|
180
|
+
switch (name) {
|
|
181
|
+
case "read_file":
|
|
182
|
+
result = await readFile(args, ctx);
|
|
183
|
+
break;
|
|
184
|
+
case "write_file":
|
|
185
|
+
result = await writeFile(args, ctx);
|
|
186
|
+
break;
|
|
187
|
+
case "edit_file":
|
|
188
|
+
result = await editFile(args, ctx);
|
|
189
|
+
break;
|
|
190
|
+
case "bash":
|
|
191
|
+
result = await runBash(args, ctx, signal);
|
|
192
|
+
break;
|
|
193
|
+
case "grep":
|
|
194
|
+
result = await runGrep(args, ctx, signal);
|
|
195
|
+
break;
|
|
196
|
+
case "glob":
|
|
197
|
+
result = await runGlob(args, ctx);
|
|
198
|
+
break;
|
|
199
|
+
case "web_fetch":
|
|
200
|
+
result = await runWebFetch(args, ctx, signal);
|
|
201
|
+
break;
|
|
202
|
+
case "web_search":
|
|
203
|
+
result = await runWebSearch(args, signal);
|
|
204
|
+
break;
|
|
205
|
+
default:
|
|
206
|
+
result = { content: `error: unknown tool '${name}'`, audit: { error: "unknown_tool" } };
|
|
207
|
+
}
|
|
208
|
+
void writeAudit(name, ctx, result);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
function writeAudit(name, ctx, result) {
|
|
212
|
+
return audit.record({
|
|
213
|
+
session: ctx.sessionId,
|
|
214
|
+
cwd: ctx.cwd,
|
|
215
|
+
tool: name,
|
|
216
|
+
approved: !result.rejected,
|
|
217
|
+
...(result.audit ?? {}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const READ_DEFAULT_LIMIT = 2000;
|
|
221
|
+
const READ_MAX_LINE_LEN = 2000;
|
|
222
|
+
async function readFile(args, ctx) {
|
|
223
|
+
const p = String(args.path ?? "");
|
|
224
|
+
if (!p)
|
|
225
|
+
return { content: "error: missing 'path'", audit: { error: "missing_path" } };
|
|
226
|
+
const abs = resolvePath(ctx, p);
|
|
227
|
+
if (!(await exists(abs))) {
|
|
228
|
+
return { content: `error: file does not exist: ${abs}`, audit: { path: abs, error: "missing" } };
|
|
229
|
+
}
|
|
230
|
+
// Catch the common case where the agent passes a directory path. Without
|
|
231
|
+
// this, fs.readFile throws EISDIR and the agent has to bounce through a
|
|
232
|
+
// `bash ls` to figure out what's there. Point it at the right tool instead.
|
|
233
|
+
let stat;
|
|
234
|
+
try {
|
|
235
|
+
stat = await fs.stat(abs);
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
return { content: `error: ${e.message}`, audit: { path: abs, error: "stat_failed" } };
|
|
239
|
+
}
|
|
240
|
+
if (stat.isDirectory()) {
|
|
241
|
+
return {
|
|
242
|
+
content: `error: '${abs}' is a directory, not a file. ` +
|
|
243
|
+
`Use the glob tool to list it: glob({"pattern": "*", "path": "${abs}"}) ` +
|
|
244
|
+
`for top-level entries, or "**/*" for everything recursively.`,
|
|
245
|
+
audit: { path: abs, error: "is_directory" },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
let text;
|
|
249
|
+
try {
|
|
250
|
+
text = await fs.readFile(abs, "utf8");
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return { content: `error: ${e.message}`, audit: { path: abs, error: "read_failed" } };
|
|
254
|
+
}
|
|
255
|
+
const offset = Number(args.offset) > 0 ? Math.floor(Number(args.offset)) : 1;
|
|
256
|
+
const limitProvided = Number(args.limit) > 0;
|
|
257
|
+
const limit = limitProvided ? Math.floor(Number(args.limit)) : READ_DEFAULT_LIMIT;
|
|
258
|
+
const allLines = text.split("\n");
|
|
259
|
+
const totalLines = allLines.length;
|
|
260
|
+
const start = Math.min(offset - 1, totalLines);
|
|
261
|
+
const end = Math.min(start + limit, totalLines);
|
|
262
|
+
const slice = allLines.slice(start, end).map((l) => l.length > READ_MAX_LINE_LEN ? l.slice(0, READ_MAX_LINE_LEN) + "…(truncated long line)" : l);
|
|
263
|
+
let body = withLineNumbers(slice.join("\n"), offset);
|
|
264
|
+
if (end < totalLines) {
|
|
265
|
+
body += `\n…(showing lines ${offset}–${end} of ${totalLines}; pass offset/limit to read more)`;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
content: body,
|
|
269
|
+
audit: { path: abs, lines_returned: end - start, total_lines: totalLines },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function writeFile(args, ctx) {
|
|
273
|
+
const p = String(args.path ?? "");
|
|
274
|
+
const content = String(args.content ?? "");
|
|
275
|
+
if (!p)
|
|
276
|
+
return { content: "error: missing 'path'", audit: { error: "missing_path" } };
|
|
277
|
+
const abs = resolvePath(ctx, p);
|
|
278
|
+
const existed = await exists(abs);
|
|
279
|
+
let oldContent = "";
|
|
280
|
+
if (existed) {
|
|
281
|
+
try {
|
|
282
|
+
oldContent = await fs.readFile(abs, "utf8");
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// fall through; treat as new
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!ctx.yolo) {
|
|
289
|
+
const ok = await confirmWrite(abs, oldContent, content, existed);
|
|
290
|
+
if (!ok) {
|
|
291
|
+
return {
|
|
292
|
+
content: "rejected by user",
|
|
293
|
+
rejected: true,
|
|
294
|
+
audit: { path: abs, size: content.length, existed },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
299
|
+
await fs.writeFile(abs, content, "utf8");
|
|
300
|
+
ctx.filesTouched.add(abs);
|
|
301
|
+
return {
|
|
302
|
+
content: `ok: ${existed ? "overwrote" : "created"} ${abs} (${content.length} chars)`,
|
|
303
|
+
audit: { path: abs, size: content.length, existed },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async function editFile(args, ctx) {
|
|
307
|
+
const p = String(args.path ?? "");
|
|
308
|
+
const oldString = String(args.old_string ?? "");
|
|
309
|
+
const newString = String(args.new_string ?? "");
|
|
310
|
+
const replaceAll = Boolean(args.replace_all);
|
|
311
|
+
if (!p)
|
|
312
|
+
return { content: "error: missing 'path'", audit: { error: "missing_path" } };
|
|
313
|
+
if (oldString === "") {
|
|
314
|
+
return { content: "error: old_string must not be empty", audit: { path: p, error: "empty_old_string" } };
|
|
315
|
+
}
|
|
316
|
+
const abs = resolvePath(ctx, p);
|
|
317
|
+
if (!(await exists(abs))) {
|
|
318
|
+
return { content: `error: file does not exist: ${abs}`, audit: { path: abs, error: "missing" } };
|
|
319
|
+
}
|
|
320
|
+
let current;
|
|
321
|
+
try {
|
|
322
|
+
current = await fs.readFile(abs, "utf8");
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
return { content: `error: ${e.message}`, audit: { path: abs, error: "read_failed" } };
|
|
326
|
+
}
|
|
327
|
+
const occurrences = current.split(oldString).length - 1;
|
|
328
|
+
if (occurrences === 0) {
|
|
329
|
+
return {
|
|
330
|
+
content: `error: old_string not found in ${abs}`,
|
|
331
|
+
audit: { path: abs, error: "not_found" },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (occurrences > 1 && !replaceAll) {
|
|
335
|
+
return {
|
|
336
|
+
content: `error: old_string is not unique in ${abs} (matches ${occurrences} times). Pass replace_all=true or include more surrounding context.`,
|
|
337
|
+
audit: { path: abs, error: "not_unique", occurrences },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const updated = replaceAll
|
|
341
|
+
? current.split(oldString).join(newString)
|
|
342
|
+
: current.replace(oldString, newString);
|
|
343
|
+
if (!ctx.yolo) {
|
|
344
|
+
const ok = await confirmEdit(abs, current, updated);
|
|
345
|
+
if (!ok) {
|
|
346
|
+
return {
|
|
347
|
+
content: "rejected by user",
|
|
348
|
+
rejected: true,
|
|
349
|
+
audit: { path: abs, replacements: occurrences },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
await fs.writeFile(abs, updated, "utf8");
|
|
354
|
+
ctx.filesTouched.add(abs);
|
|
355
|
+
return {
|
|
356
|
+
content: `ok: edited ${abs} (${occurrences} replacement${occurrences === 1 ? "" : "s"})`,
|
|
357
|
+
audit: { path: abs, replacements: occurrences },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
async function runBash(args, ctx, signal) {
|
|
361
|
+
const command = String(args.command ?? "");
|
|
362
|
+
if (!command)
|
|
363
|
+
return { content: "error: missing 'command'", audit: { error: "missing_command" } };
|
|
364
|
+
const timeoutMs = Number(args.timeout_ms) > 0 ? Math.floor(Number(args.timeout_ms)) : 60_000;
|
|
365
|
+
if (!ctx.yolo) {
|
|
366
|
+
const ok = await confirmBash(command, String(args.description ?? ""));
|
|
367
|
+
if (!ok) {
|
|
368
|
+
return {
|
|
369
|
+
content: "rejected by user",
|
|
370
|
+
rejected: true,
|
|
371
|
+
audit: { command, timeout_ms: timeoutMs },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
const child = spawn("/bin/sh", ["-c", command], { cwd: ctx.cwd });
|
|
377
|
+
let stdout = "";
|
|
378
|
+
let stderr = "";
|
|
379
|
+
let timedOut = false;
|
|
380
|
+
let interrupted = false;
|
|
381
|
+
const timer = setTimeout(() => {
|
|
382
|
+
timedOut = true;
|
|
383
|
+
child.kill("SIGKILL");
|
|
384
|
+
}, timeoutMs);
|
|
385
|
+
const onAbort = () => {
|
|
386
|
+
interrupted = true;
|
|
387
|
+
child.kill("SIGTERM");
|
|
388
|
+
};
|
|
389
|
+
if (signal) {
|
|
390
|
+
if (signal.aborted)
|
|
391
|
+
onAbort();
|
|
392
|
+
else
|
|
393
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
394
|
+
}
|
|
395
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
396
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
397
|
+
child.on("close", (code) => {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
signal?.removeEventListener("abort", onAbort);
|
|
400
|
+
const MAX = 16_000;
|
|
401
|
+
const trim = (s) => (s.length > MAX ? s.slice(0, MAX) + `\n…(truncated, ${s.length - MAX} more chars)` : s);
|
|
402
|
+
const parts = [];
|
|
403
|
+
const exitDesc = interrupted
|
|
404
|
+
? "killed (interrupted)"
|
|
405
|
+
: timedOut
|
|
406
|
+
? "killed (timeout)"
|
|
407
|
+
: String(code);
|
|
408
|
+
parts.push(`exit_code: ${exitDesc}`);
|
|
409
|
+
if (stdout)
|
|
410
|
+
parts.push(`stdout:\n${trim(stdout)}`);
|
|
411
|
+
if (stderr)
|
|
412
|
+
parts.push(`stderr:\n${trim(stderr)}`);
|
|
413
|
+
if (!stdout && !stderr)
|
|
414
|
+
parts.push("(no output)");
|
|
415
|
+
resolve({
|
|
416
|
+
content: parts.join("\n"),
|
|
417
|
+
audit: {
|
|
418
|
+
command,
|
|
419
|
+
exit: interrupted ? "interrupted" : timedOut ? "timeout" : (code ?? null),
|
|
420
|
+
stdout_bytes: stdout.length,
|
|
421
|
+
stderr_bytes: stderr.length,
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
child.on("error", (e) => {
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
signal?.removeEventListener("abort", onAbort);
|
|
428
|
+
resolve({ content: `error: ${e.message}`, audit: { command, error: "spawn_failed" } });
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
let _hasRg;
|
|
433
|
+
function hasRipgrep() {
|
|
434
|
+
if (_hasRg !== undefined)
|
|
435
|
+
return _hasRg;
|
|
436
|
+
const r = spawnSync("which", ["rg"], { stdio: "ignore" });
|
|
437
|
+
_hasRg = r.status === 0;
|
|
438
|
+
return _hasRg;
|
|
439
|
+
}
|
|
440
|
+
const GREP_MAX_OUTPUT = 16_000;
|
|
441
|
+
async function runGrep(args, ctx, signal) {
|
|
442
|
+
const pattern = String(args.pattern ?? "");
|
|
443
|
+
if (!pattern)
|
|
444
|
+
return { content: "error: missing 'pattern'" };
|
|
445
|
+
const searchPath = args.path ? resolvePath(ctx, String(args.path)) : ctx.cwd;
|
|
446
|
+
const ci = Boolean(args.case_insensitive);
|
|
447
|
+
const glob = args.glob ? String(args.glob) : null;
|
|
448
|
+
let cmd;
|
|
449
|
+
let cmdArgs;
|
|
450
|
+
if (hasRipgrep()) {
|
|
451
|
+
cmd = "rg";
|
|
452
|
+
cmdArgs = ["--no-heading", "--line-number", "--max-count=200", "--color=never"];
|
|
453
|
+
if (ci)
|
|
454
|
+
cmdArgs.push("-i");
|
|
455
|
+
if (glob)
|
|
456
|
+
cmdArgs.push("--glob", glob);
|
|
457
|
+
cmdArgs.push("--", pattern, searchPath);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
cmd = "grep";
|
|
461
|
+
cmdArgs = ["-rn", "-E"];
|
|
462
|
+
if (ci)
|
|
463
|
+
cmdArgs.push("-i");
|
|
464
|
+
if (glob)
|
|
465
|
+
cmdArgs.push(`--include=${glob}`);
|
|
466
|
+
cmdArgs.push("-e", pattern, searchPath);
|
|
467
|
+
}
|
|
468
|
+
return new Promise((resolve) => {
|
|
469
|
+
const child = spawn(cmd, cmdArgs, { cwd: ctx.cwd });
|
|
470
|
+
let stdout = "";
|
|
471
|
+
let stderr = "";
|
|
472
|
+
let interrupted = false;
|
|
473
|
+
const onAbort = () => {
|
|
474
|
+
interrupted = true;
|
|
475
|
+
child.kill("SIGTERM");
|
|
476
|
+
};
|
|
477
|
+
if (signal) {
|
|
478
|
+
if (signal.aborted)
|
|
479
|
+
onAbort();
|
|
480
|
+
else
|
|
481
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
482
|
+
}
|
|
483
|
+
child.stdout.on("data", (d) => {
|
|
484
|
+
stdout += d.toString();
|
|
485
|
+
if (stdout.length > GREP_MAX_OUTPUT * 2)
|
|
486
|
+
child.kill("SIGTERM");
|
|
487
|
+
});
|
|
488
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
489
|
+
child.on("close", (code) => {
|
|
490
|
+
signal?.removeEventListener("abort", onAbort);
|
|
491
|
+
const auditBase = { pattern, path: searchPath, glob, case_insensitive: ci, engine: cmd };
|
|
492
|
+
if (interrupted) {
|
|
493
|
+
return resolve({ content: "interrupted", audit: { ...auditBase, exit: "interrupted" } });
|
|
494
|
+
}
|
|
495
|
+
// grep/rg exit 1 when no matches — treat as a clean "no matches" result.
|
|
496
|
+
if (code === 1 && !stdout) {
|
|
497
|
+
return resolve({ content: "(no matches)", audit: { ...auditBase, matches: 0 } });
|
|
498
|
+
}
|
|
499
|
+
if (code !== 0 && code !== null && !stdout) {
|
|
500
|
+
return resolve({
|
|
501
|
+
content: `error: ${cmd} exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`,
|
|
502
|
+
audit: { ...auditBase, error: `exit_${code}` },
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
let out = stdout.trim();
|
|
506
|
+
const matches = out ? out.split("\n").length : 0;
|
|
507
|
+
if (out.length > GREP_MAX_OUTPUT) {
|
|
508
|
+
out = out.slice(0, GREP_MAX_OUTPUT) + `\n…(truncated; narrow the search with path or glob)`;
|
|
509
|
+
}
|
|
510
|
+
resolve({ content: out || "(no matches)", audit: { ...auditBase, matches } });
|
|
511
|
+
});
|
|
512
|
+
child.on("error", (e) => {
|
|
513
|
+
signal?.removeEventListener("abort", onAbort);
|
|
514
|
+
resolve({ content: `error: ${e.message}`, audit: { pattern, error: "spawn_failed" } });
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
const GLOB_LIMIT = 500;
|
|
519
|
+
async function runGlob(args, ctx) {
|
|
520
|
+
const pattern = String(args.pattern ?? "");
|
|
521
|
+
if (!pattern)
|
|
522
|
+
return { content: "error: missing 'pattern'", audit: { error: "missing_pattern" } };
|
|
523
|
+
const cwd = args.path ? resolvePath(ctx, String(args.path)) : ctx.cwd;
|
|
524
|
+
const matches = [];
|
|
525
|
+
try {
|
|
526
|
+
// fs.promises.glob is available in Node 22+.
|
|
527
|
+
const fsAny = fs;
|
|
528
|
+
if (typeof fsAny.glob !== "function") {
|
|
529
|
+
return {
|
|
530
|
+
content: "error: fs.glob unavailable; need Node 22+. Use bash with `find` instead.",
|
|
531
|
+
audit: { error: "fs_glob_unavailable" },
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
for await (const p of fsAny.glob(pattern, { cwd })) {
|
|
535
|
+
matches.push(p);
|
|
536
|
+
if (matches.length >= GLOB_LIMIT)
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch (e) {
|
|
541
|
+
return { content: `error: ${e.message}`, audit: { pattern, base: cwd, error: "glob_failed" } };
|
|
542
|
+
}
|
|
543
|
+
if (!matches.length) {
|
|
544
|
+
return { content: "(no matches)", audit: { pattern, base: cwd, matches: 0 } };
|
|
545
|
+
}
|
|
546
|
+
matches.sort();
|
|
547
|
+
let out = matches.join("\n");
|
|
548
|
+
if (matches.length === GLOB_LIMIT)
|
|
549
|
+
out += `\n…(reached ${GLOB_LIMIT}-path cap; narrow the pattern)`;
|
|
550
|
+
return { content: out, audit: { pattern, base: cwd, matches: matches.length } };
|
|
551
|
+
}
|
|
552
|
+
const FETCH_MAX = 50_000;
|
|
553
|
+
async function runWebFetch(args, ctx, signal) {
|
|
554
|
+
const url = String(args.url ?? "");
|
|
555
|
+
if (!url)
|
|
556
|
+
return { content: "error: missing 'url'", audit: { error: "missing_url" } };
|
|
557
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
558
|
+
return { content: "error: url must start with http:// or https://", audit: { url, error: "bad_scheme" } };
|
|
559
|
+
}
|
|
560
|
+
if (!ctx.yolo) {
|
|
561
|
+
const ok = await confirmFetch(url);
|
|
562
|
+
if (!ok)
|
|
563
|
+
return { content: "rejected by user", rejected: true, audit: { url } };
|
|
564
|
+
}
|
|
565
|
+
let res;
|
|
566
|
+
try {
|
|
567
|
+
res = await fetch(url, {
|
|
568
|
+
headers: { "User-Agent": "dsc/0.1", Accept: "text/html,text/plain,*/*" },
|
|
569
|
+
signal,
|
|
570
|
+
redirect: "follow",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
if (e.name === "AbortError") {
|
|
575
|
+
return { content: "interrupted", audit: { url, error: "interrupted" } };
|
|
576
|
+
}
|
|
577
|
+
return { content: `error: ${e.message}`, audit: { url, error: "fetch_failed" } };
|
|
578
|
+
}
|
|
579
|
+
if (!res.ok) {
|
|
580
|
+
return {
|
|
581
|
+
content: `error: HTTP ${res.status} ${res.statusText}`,
|
|
582
|
+
audit: { url, status: res.status },
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
586
|
+
let text;
|
|
587
|
+
try {
|
|
588
|
+
text = await res.text();
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
return { content: `error: ${e.message}`, audit: { url, status: res.status, error: "read_failed" } };
|
|
592
|
+
}
|
|
593
|
+
const rawBytes = text.length;
|
|
594
|
+
if (/html|xml/i.test(ct))
|
|
595
|
+
text = stripHtml(text);
|
|
596
|
+
if (text.length > FETCH_MAX) {
|
|
597
|
+
text = text.slice(0, FETCH_MAX) + `\n…(truncated, ${text.length - FETCH_MAX} more chars)`;
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
content: text || "(empty body)",
|
|
601
|
+
audit: { url, status: res.status, content_type: ct, bytes: rawBytes },
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function stripHtml(html) {
|
|
605
|
+
return html
|
|
606
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
607
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
608
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
609
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
610
|
+
.replace(/<\/?(p|div|li|h[1-6]|tr)\b[^>]*>/gi, "\n")
|
|
611
|
+
.replace(/<[^>]+>/g, "")
|
|
612
|
+
.replace(/ /g, " ")
|
|
613
|
+
.replace(/&/g, "&")
|
|
614
|
+
.replace(/</g, "<")
|
|
615
|
+
.replace(/>/g, ">")
|
|
616
|
+
.replace(/"/g, '"')
|
|
617
|
+
.replace(/'/g, "'")
|
|
618
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
619
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
620
|
+
.trim();
|
|
621
|
+
}
|
|
622
|
+
async function runWebSearch(args, signal) {
|
|
623
|
+
const query = String(args.query ?? "").trim();
|
|
624
|
+
if (!query)
|
|
625
|
+
return { content: "error: missing 'query'", audit: { error: "missing_query" } };
|
|
626
|
+
const count = Number(args.count) > 0 ? Math.min(20, Math.floor(Number(args.count))) : 5;
|
|
627
|
+
const freshness = args.freshness === "day" ||
|
|
628
|
+
args.freshness === "week" ||
|
|
629
|
+
args.freshness === "month" ||
|
|
630
|
+
args.freshness === "year"
|
|
631
|
+
? args.freshness
|
|
632
|
+
: undefined;
|
|
633
|
+
const provider = getProvider();
|
|
634
|
+
try {
|
|
635
|
+
const results = await runSearchProvider({ query, count, freshness, signal });
|
|
636
|
+
return {
|
|
637
|
+
content: formatResults(results),
|
|
638
|
+
audit: {
|
|
639
|
+
provider,
|
|
640
|
+
query: query.slice(0, 200),
|
|
641
|
+
count,
|
|
642
|
+
freshness: freshness ?? null,
|
|
643
|
+
results: results.length,
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
if (e.name === "AbortError") {
|
|
649
|
+
return {
|
|
650
|
+
content: "interrupted",
|
|
651
|
+
audit: { provider, query: query.slice(0, 200), error: "interrupted" },
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (e instanceof SearchError) {
|
|
655
|
+
return {
|
|
656
|
+
content: `error: ${e.message}`,
|
|
657
|
+
audit: {
|
|
658
|
+
provider,
|
|
659
|
+
query: query.slice(0, 200),
|
|
660
|
+
error: e.status ? `http_${e.status}` : "search_failed",
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
content: `error: ${e.message}`,
|
|
666
|
+
audit: { provider, query: query.slice(0, 200), error: "search_failed" },
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
//# sourceMappingURL=tools.js.map
|