jaitus 0.3.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 +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
// ─── Tools the agent can use ────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Each tool is a schema the model sees plus an executor that runs locally. Two rules
|
|
4
|
+
// hold everywhere:
|
|
5
|
+
//
|
|
6
|
+
// 1. Every path is resolved and checked to be inside the working directory. The model
|
|
7
|
+
// is not an attacker, but it is a text generator that will happily emit
|
|
8
|
+
// "../../.ssh/id_rsa" if a file it read suggests it. Containment is the CLI's job,
|
|
9
|
+
// not the model's.
|
|
10
|
+
// 2. `needsPermission` decides what gets asked about. Reading is free; changing the
|
|
11
|
+
// user's disk or running a command is not.
|
|
12
|
+
//
|
|
13
|
+
// Results are returned as strings because that is what a tool_result block carries, and
|
|
14
|
+
// they are truncated — a 4MB file pasted into the transcript costs real money and pushes
|
|
15
|
+
// the actual task out of context.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs/promises';
|
|
18
|
+
import fsSync from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { loadIgnore, IGNORE_FILE } from '../project.js';
|
|
22
|
+
import { searchWeb, formatResults } from './websearch.js';
|
|
23
|
+
|
|
24
|
+
const MAX_OUTPUT = 60_000; // characters returned to the model per tool call
|
|
25
|
+
const MAX_READ_LINES = 2_000;
|
|
26
|
+
|
|
27
|
+
export class ToolError extends Error {}
|
|
28
|
+
|
|
29
|
+
function truncate(text, limit = MAX_OUTPUT) {
|
|
30
|
+
const s = String(text ?? '');
|
|
31
|
+
if (s.length <= limit) return s;
|
|
32
|
+
return `${s.slice(0, limit)}\n\n[truncated — ${s.length - limit} more characters]`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a user/model-supplied path inside the working directory.
|
|
37
|
+
* Rejects anything that escapes it, including via a symlink whose real path leads out.
|
|
38
|
+
*/
|
|
39
|
+
function safePath(cwd, p) {
|
|
40
|
+
if (typeof p !== 'string' || !p.trim()) throw new ToolError('A path is required.');
|
|
41
|
+
const resolved = path.resolve(cwd, p);
|
|
42
|
+
const rel = path.relative(cwd, resolved);
|
|
43
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
44
|
+
throw new ToolError(`Refused: ${p} is outside the working directory.`);
|
|
45
|
+
}
|
|
46
|
+
// A symlink can still point out of the tree; check where it really lands.
|
|
47
|
+
try {
|
|
48
|
+
const real = fsSync.realpathSync(resolved);
|
|
49
|
+
const realRel = path.relative(fsSync.realpathSync(cwd), real);
|
|
50
|
+
if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
|
|
51
|
+
throw new ToolError(`Refused: ${p} resolves outside the working directory.`);
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e instanceof ToolError) throw e;
|
|
55
|
+
// Does not exist yet — fine for a write, and the containment check above already
|
|
56
|
+
// passed on the literal path.
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Paths handed to the model are always forward-slashed. path.relative() returns
|
|
62
|
+
// backslashes on Windows, and the model will echo those straight into source files,
|
|
63
|
+
// shell commands and further tool calls, where they are either wrong or eaten as escapes.
|
|
64
|
+
const posix = (p) => p.split(path.sep).join('/');
|
|
65
|
+
|
|
66
|
+
const IGNORED_DIRS = new Set([
|
|
67
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.cache', 'coverage',
|
|
68
|
+
'__pycache__', '.venv', 'venv', '.idea', '.vscode', 'vendor', 'target',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
// .jaitusignore is read once per working directory rather than per tool call: a session
|
|
72
|
+
// makes hundreds of these and re-reading the file each time would be pure syscall.
|
|
73
|
+
const ignoreCache = new Map();
|
|
74
|
+
function ignoreFor(cwd) {
|
|
75
|
+
if (!ignoreCache.has(cwd)) ignoreCache.set(cwd, loadIgnore(cwd));
|
|
76
|
+
return ignoreCache.get(cwd);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Exported so a long-running session picks up an edit to the file. */
|
|
80
|
+
export function forgetIgnoreCache() {
|
|
81
|
+
ignoreCache.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An ignored path is refused for reads and writes alike, not merely hidden from
|
|
86
|
+
* listings. Hiding it only would mean the agent cannot find `secrets/` by browsing but
|
|
87
|
+
* will happily open it the moment a filename appears in a stack trace — which is the
|
|
88
|
+
* case the file was written for.
|
|
89
|
+
*/
|
|
90
|
+
function refuseIfIgnored(cwd, p) {
|
|
91
|
+
const rel = posix(path.relative(cwd, path.resolve(cwd, p)));
|
|
92
|
+
if (ignoreFor(cwd).matches(rel)) {
|
|
93
|
+
throw new ToolError(`Refused: ${p} is listed in ${IGNORE_FILE}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function walk(dir, cwd, out, depth, maxEntries) {
|
|
98
|
+
if (out.length >= maxEntries || depth < 0) return;
|
|
99
|
+
const ignore = ignoreFor(cwd);
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
103
|
+
} catch { return; }
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
if (out.length >= maxEntries) return;
|
|
106
|
+
if (e.name.startsWith('.') && e.name !== '.env.example') continue;
|
|
107
|
+
const rel = posix(path.relative(cwd, path.join(dir, e.name)));
|
|
108
|
+
if (ignore.matches(rel)) continue;
|
|
109
|
+
if (e.isDirectory()) {
|
|
110
|
+
if (IGNORED_DIRS.has(e.name)) continue;
|
|
111
|
+
out.push(`${rel}/`);
|
|
112
|
+
await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries);
|
|
113
|
+
} else {
|
|
114
|
+
out.push(rel);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const TOOLS = [
|
|
120
|
+
{
|
|
121
|
+
name: 'read_file',
|
|
122
|
+
description:
|
|
123
|
+
'Read a text file from the project. Returns the contents with line numbers so they can be referred to precisely. Prefer reading before editing.',
|
|
124
|
+
needsPermission: false,
|
|
125
|
+
input_schema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
path: { type: 'string', description: 'Path relative to the project root.' },
|
|
129
|
+
offset: { type: 'integer', description: 'First line to return (1-based).' },
|
|
130
|
+
limit: { type: 'integer', description: 'How many lines to return.' },
|
|
131
|
+
},
|
|
132
|
+
required: ['path'],
|
|
133
|
+
},
|
|
134
|
+
summarise: (i) => `read ${i.path}`,
|
|
135
|
+
async run(input, { cwd }) {
|
|
136
|
+
const file = safePath(cwd, input.path);
|
|
137
|
+
refuseIfIgnored(cwd, input.path);
|
|
138
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
139
|
+
if (!stat) throw new ToolError(`No such file: ${input.path}`);
|
|
140
|
+
if (stat.isDirectory()) throw new ToolError(`${input.path} is a directory — use list_files.`);
|
|
141
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
142
|
+
const lines = raw.split('\n');
|
|
143
|
+
const start = Math.max(0, (input.offset || 1) - 1);
|
|
144
|
+
const end = Math.min(lines.length, start + (input.limit || MAX_READ_LINES));
|
|
145
|
+
const body = lines.slice(start, end)
|
|
146
|
+
.map((l, idx) => `${String(start + idx + 1).padStart(5)} ${l}`)
|
|
147
|
+
.join('\n');
|
|
148
|
+
const more = end < lines.length ? `\n\n[${lines.length - end} more lines]` : '';
|
|
149
|
+
return truncate(body + more);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
name: 'write_file',
|
|
155
|
+
description:
|
|
156
|
+
'Create a file, or completely replace one. For a small change to an existing file prefer edit_file — it is safer and cheaper.',
|
|
157
|
+
needsPermission: true,
|
|
158
|
+
input_schema: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
path: { type: 'string' },
|
|
162
|
+
content: { type: 'string' },
|
|
163
|
+
},
|
|
164
|
+
required: ['path', 'content'],
|
|
165
|
+
},
|
|
166
|
+
summarise: (i) => `write ${i.path} (${String(i.content || '').split('\n').length} lines)`,
|
|
167
|
+
async run(input, { cwd }) {
|
|
168
|
+
const file = safePath(cwd, input.path);
|
|
169
|
+
refuseIfIgnored(cwd, input.path);
|
|
170
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
171
|
+
const existed = await fs.stat(file).then(() => true).catch(() => false);
|
|
172
|
+
await fs.writeFile(file, String(input.content ?? ''), 'utf8');
|
|
173
|
+
return `${existed ? 'Replaced' : 'Created'} ${input.path}`;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
{
|
|
178
|
+
name: 'edit_file',
|
|
179
|
+
description:
|
|
180
|
+
'Replace an exact string in a file. `old` must appear EXACTLY once unless replace_all is set — include enough surrounding context to make it unique. '
|
|
181
|
+
+ 'Pass `edits` instead of old/new to make several replacements in one file atomically: either all of them apply or none do. '
|
|
182
|
+
+ 'This is the preferred way to change existing code.',
|
|
183
|
+
needsPermission: true,
|
|
184
|
+
input_schema: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
path: { type: 'string' },
|
|
188
|
+
old: { type: 'string', description: 'The exact text to replace.' },
|
|
189
|
+
new: { type: 'string', description: 'What to replace it with.' },
|
|
190
|
+
replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring exactly one. Use for a rename.' },
|
|
191
|
+
edits: {
|
|
192
|
+
type: 'array',
|
|
193
|
+
description: 'Several replacements applied in order to the same file. Use this instead of old/new for a multi-part change.',
|
|
194
|
+
items: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
old: { type: 'string' },
|
|
198
|
+
new: { type: 'string' },
|
|
199
|
+
replace_all: { type: 'boolean' },
|
|
200
|
+
},
|
|
201
|
+
required: ['old', 'new'],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
required: ['path'],
|
|
206
|
+
},
|
|
207
|
+
summarise: (i) => (Array.isArray(i.edits) && i.edits.length ? `edit ${i.path} (${i.edits.length} changes)` : `edit ${i.path}`),
|
|
208
|
+
async run(input, { cwd }) {
|
|
209
|
+
const file = safePath(cwd, input.path);
|
|
210
|
+
refuseIfIgnored(cwd, input.path);
|
|
211
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => null);
|
|
212
|
+
if (raw === null) throw new ToolError(`No such file: ${input.path}`);
|
|
213
|
+
|
|
214
|
+
const edits = Array.isArray(input.edits) && input.edits.length
|
|
215
|
+
? input.edits
|
|
216
|
+
: [{ old: input.old, new: input.new, replace_all: input.replace_all }];
|
|
217
|
+
if (!edits.length || edits.some((e) => typeof e?.old !== 'string' || typeof e?.new !== 'string')) {
|
|
218
|
+
throw new ToolError('Give old and new, or a list of edits each with old and new.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Applied to a working copy and written once. A partial multi-edit — three of five
|
|
222
|
+
// replacements landing before the fourth fails to match — leaves a file in a state
|
|
223
|
+
// neither the model nor the user asked for, and the model cannot tell from the
|
|
224
|
+
// error which ones took.
|
|
225
|
+
let next = raw;
|
|
226
|
+
let changed = 0;
|
|
227
|
+
for (const [index, edit] of edits.entries()) {
|
|
228
|
+
const where = edits.length > 1 ? ` (edit ${index + 1} of ${edits.length})` : '';
|
|
229
|
+
if (edit.old === edit.new) throw new ToolError(`old and new are identical${where} — nothing to do.`);
|
|
230
|
+
const occurrences = next.split(edit.old).length - 1;
|
|
231
|
+
if (occurrences === 0) {
|
|
232
|
+
throw new ToolError(
|
|
233
|
+
`That exact text is not in ${input.path}${where}. Read the file again — whitespace and indentation must match exactly. Nothing was changed.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (occurrences > 1 && !edit.replace_all) {
|
|
237
|
+
throw new ToolError(
|
|
238
|
+
`That text appears ${occurrences} times in ${input.path}${where}. Include more surrounding context, or set replace_all. Nothing was changed.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
next = edit.replace_all ? next.split(edit.old).join(edit.new) : next.replace(edit.old, edit.new);
|
|
242
|
+
changed += occurrences;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await fs.writeFile(file, next, 'utf8');
|
|
246
|
+
const before = raw.split('\n').length;
|
|
247
|
+
const after = next.split('\n').length;
|
|
248
|
+
const delta = after - before;
|
|
249
|
+
return `Edited ${input.path}: ${changed} replacement${changed === 1 ? '' : 's'}`
|
|
250
|
+
+ (delta ? `, ${delta > 0 ? '+' : ''}${delta} line${Math.abs(delta) === 1 ? '' : 's'}` : '');
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
name: 'glob',
|
|
256
|
+
description:
|
|
257
|
+
'Find files by name pattern — `**/*.test.js`, `src/**/*.tsx`, `*.md`. Returns paths, newest first. '
|
|
258
|
+
+ 'This is the fast way to answer "which files are there"; use search to look inside them and list_files to browse a directory.',
|
|
259
|
+
needsPermission: false,
|
|
260
|
+
input_schema: {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
pattern: { type: 'string', description: 'Glob pattern. `*` stays within one path segment, `**` crosses them.' },
|
|
264
|
+
path: { type: 'string', description: 'Directory to search from. Defaults to the project root.' },
|
|
265
|
+
limit: { type: 'integer', description: 'Maximum paths to return. Default 200.' },
|
|
266
|
+
},
|
|
267
|
+
required: ['pattern'],
|
|
268
|
+
},
|
|
269
|
+
summarise: (i) => `glob ${i.pattern}`,
|
|
270
|
+
async run(input, { cwd }) {
|
|
271
|
+
const root = safePath(cwd, input.path || '.');
|
|
272
|
+
const re = globToRegExp(String(input.pattern || ''));
|
|
273
|
+
const limit = Math.min(Math.max(Number(input.limit) || 200, 1), 2000);
|
|
274
|
+
const found = [];
|
|
275
|
+
await walk(root, cwd, found, 12, 20_000);
|
|
276
|
+
const matched = found.filter((rel) => !rel.endsWith('/') && re.test(rel));
|
|
277
|
+
if (!matched.length) {
|
|
278
|
+
return `No file matches ${input.pattern}. Note that * does not cross a / — use ** for that, as in **/*.ts.`;
|
|
279
|
+
}
|
|
280
|
+
// Newest first: when a pattern matches many files, the ones touched most recently
|
|
281
|
+
// are almost always the ones the question is about.
|
|
282
|
+
const withTimes = await Promise.all(matched.map(async (rel) => {
|
|
283
|
+
const stat = await fs.stat(path.resolve(cwd, rel)).catch(() => null);
|
|
284
|
+
return { rel, mtime: stat ? stat.mtimeMs : 0 };
|
|
285
|
+
}));
|
|
286
|
+
withTimes.sort((a, b) => b.mtime - a.mtime);
|
|
287
|
+
const shown = withTimes.slice(0, limit).map((f) => f.rel);
|
|
288
|
+
const more = withTimes.length > shown.length ? `\n\n[${withTimes.length - shown.length} more]` : '';
|
|
289
|
+
return truncate(`${shown.join('\n')}${more}`);
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
{
|
|
294
|
+
name: 'list_files',
|
|
295
|
+
description: 'List files and directories in the project. Build output and dependency directories are skipped.',
|
|
296
|
+
needsPermission: false,
|
|
297
|
+
input_schema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: {
|
|
300
|
+
path: { type: 'string', description: 'Directory to list. Defaults to the project root.' },
|
|
301
|
+
depth: { type: 'integer', description: 'How deep to recurse. Default 2.' },
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
summarise: (i) => `list ${i.path || '.'}`,
|
|
305
|
+
async run(input, { cwd }) {
|
|
306
|
+
const dir = safePath(cwd, input.path || '.');
|
|
307
|
+
const found = [];
|
|
308
|
+
await walk(dir, cwd, found, input.depth ?? 2, 600);
|
|
309
|
+
if (!found.length) return 'No files found.';
|
|
310
|
+
return truncate(found.sort().join('\n'));
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
{
|
|
315
|
+
name: 'search',
|
|
316
|
+
description:
|
|
317
|
+
'Search file contents with a regular expression. Returns matching lines with their file and line number. Use this to find where something is defined or used.',
|
|
318
|
+
needsPermission: false,
|
|
319
|
+
input_schema: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
properties: {
|
|
322
|
+
pattern: { type: 'string', description: 'JavaScript regular expression.' },
|
|
323
|
+
path: { type: 'string', description: 'Directory to search. Defaults to the project root.' },
|
|
324
|
+
glob: { type: 'string', description: 'Only search files whose name ends with this, e.g. ".ts".' },
|
|
325
|
+
},
|
|
326
|
+
required: ['pattern'],
|
|
327
|
+
},
|
|
328
|
+
summarise: (i) => `search /${i.pattern}/`,
|
|
329
|
+
async run(input, { cwd }) {
|
|
330
|
+
let re;
|
|
331
|
+
try {
|
|
332
|
+
re = new RegExp(input.pattern, 'i');
|
|
333
|
+
} catch (e) {
|
|
334
|
+
throw new ToolError(`Not a valid regular expression: ${e.message}`);
|
|
335
|
+
}
|
|
336
|
+
const dir = safePath(cwd, input.path || '.');
|
|
337
|
+
const files = [];
|
|
338
|
+
await walk(dir, cwd, files, 8, 4000);
|
|
339
|
+
const hits = [];
|
|
340
|
+
for (const rel of files) {
|
|
341
|
+
if (rel.endsWith('/')) continue;
|
|
342
|
+
if (input.glob && !rel.endsWith(input.glob)) continue;
|
|
343
|
+
if (hits.length >= 200) break;
|
|
344
|
+
let text;
|
|
345
|
+
try {
|
|
346
|
+
const stat = await fs.stat(path.join(cwd, rel));
|
|
347
|
+
if (stat.size > 2_000_000) continue; // not a source file
|
|
348
|
+
text = await fs.readFile(path.join(cwd, rel), 'utf8');
|
|
349
|
+
} catch { continue; }
|
|
350
|
+
text.split('\n').forEach((line, i) => {
|
|
351
|
+
if (hits.length >= 200) return;
|
|
352
|
+
if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 240)}`);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return hits.length ? truncate(hits.join('\n')) : 'No matches.';
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
{
|
|
360
|
+
name: 'run_command',
|
|
361
|
+
description:
|
|
362
|
+
'Run a shell command in the project directory — tests, a build, git, a linter. Returns stdout, stderr and the exit code. Never run something interactive; it will hang.',
|
|
363
|
+
needsPermission: true,
|
|
364
|
+
input_schema: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
command: { type: 'string' },
|
|
368
|
+
timeout_ms: { type: 'integer', description: 'Give up after this long. Default 120000.' },
|
|
369
|
+
},
|
|
370
|
+
required: ['command'],
|
|
371
|
+
},
|
|
372
|
+
summarise: (i) => i.command,
|
|
373
|
+
run(input, { cwd }) {
|
|
374
|
+
const command = String(input.command || '').trim();
|
|
375
|
+
if (!command) throw new ToolError('No command given.');
|
|
376
|
+
const timeout = Math.min(Math.max(Number(input.timeout_ms) || 120_000, 1000), 600_000);
|
|
377
|
+
|
|
378
|
+
return new Promise((resolve) => {
|
|
379
|
+
// `shell: true` rather than spawning cmd.exe with an argument array.
|
|
380
|
+
//
|
|
381
|
+
// On Windows, spawn('cmd.exe', ['/c', 'node -e "console.log(1)"']) escapes the
|
|
382
|
+
// inner quotes in a way cmd does not understand. The command then runs as
|
|
383
|
+
// something else entirely and exits 0 with NO OUTPUT — which an agent reads as
|
|
384
|
+
// "the tests passed and printed nothing". A silently wrong success is far worse
|
|
385
|
+
// than an error, and this is exactly how a coding agent ends up reporting a
|
|
386
|
+
// green build that never ran.
|
|
387
|
+
const child = spawn(command, {
|
|
388
|
+
cwd,
|
|
389
|
+
shell: true,
|
|
390
|
+
// No TTY: anything that would prompt gets EOF and exits instead of hanging
|
|
391
|
+
// forever with nobody able to type into it.
|
|
392
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
let stdout = '';
|
|
396
|
+
let stderr = '';
|
|
397
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
398
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
399
|
+
|
|
400
|
+
const timer = setTimeout(() => {
|
|
401
|
+
child.kill('SIGKILL');
|
|
402
|
+
stderr += `\n[killed after ${timeout}ms]`;
|
|
403
|
+
}, timeout);
|
|
404
|
+
|
|
405
|
+
child.on('error', (e) => {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
resolve(`Could not run the command: ${e.message}`);
|
|
408
|
+
});
|
|
409
|
+
child.on('close', (code) => {
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
const parts = [];
|
|
412
|
+
if (stdout.trim()) parts.push(stdout.trim());
|
|
413
|
+
if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
|
|
414
|
+
parts.push(`exit code: ${code}`);
|
|
415
|
+
resolve(truncate(parts.join('\n\n')));
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
{
|
|
422
|
+
name: 'web_search',
|
|
423
|
+
description:
|
|
424
|
+
'Search the public web for current information: library versions, documentation, error messages, news, API changes. Returns organic results with title, URL and a snippet. Use web_fetch on a promising URL to read the full page.',
|
|
425
|
+
needsPermission: false,
|
|
426
|
+
input_schema: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
properties: {
|
|
429
|
+
query: { type: 'string', description: 'The search query.' },
|
|
430
|
+
},
|
|
431
|
+
required: ['query'],
|
|
432
|
+
},
|
|
433
|
+
summarise: (i) => `search: ${i.query}`,
|
|
434
|
+
async run(input) {
|
|
435
|
+
const query = String(input.query || '').trim();
|
|
436
|
+
if (!query) throw new ToolError('No query given.');
|
|
437
|
+
let settings = null;
|
|
438
|
+
try { settings = (await import('../config.js')).loadSettings(); } catch { /* defaults */ }
|
|
439
|
+
try {
|
|
440
|
+
const found = await searchWeb(query, { settings });
|
|
441
|
+
return formatResults(found);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
throw new ToolError(
|
|
444
|
+
`${error.message} Try a different wording, or fetch a known URL directly with web_fetch.`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
{
|
|
451
|
+
name: 'web_fetch',
|
|
452
|
+
description:
|
|
453
|
+
'Fetch a web page and return its readable text with scripts, styles and navigation stripped. For documentation, GitHub files (use raw.githubusercontent.com), API references, and any URL that came from web_search. Prefer this over run_command + curl — it is faster and returns clean text.',
|
|
454
|
+
needsPermission: false,
|
|
455
|
+
input_schema: {
|
|
456
|
+
type: 'object',
|
|
457
|
+
properties: {
|
|
458
|
+
url: { type: 'string', description: 'The http(s) URL to fetch.' },
|
|
459
|
+
max_chars: { type: 'integer', description: 'Maximum characters of text to return. Default 20000.' },
|
|
460
|
+
},
|
|
461
|
+
required: ['url'],
|
|
462
|
+
},
|
|
463
|
+
summarise: (i) => `fetch ${String(i.url).slice(0, 80)}`,
|
|
464
|
+
async run(input) {
|
|
465
|
+
const raw = String(input.url || '').trim();
|
|
466
|
+
if (!/^https?:\/\//i.test(raw)) throw new ToolError('Give a full http(s) URL.');
|
|
467
|
+
const limit = Math.min(Math.max(Number(input.max_chars) || 20_000, 1000), 50_000);
|
|
468
|
+
|
|
469
|
+
const cached = fetchCache.get(raw);
|
|
470
|
+
if (cached && Date.now() - cached.at < FETCH_CACHE_MS) {
|
|
471
|
+
return truncate(cached.text.slice(0, limit));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let res;
|
|
475
|
+
try {
|
|
476
|
+
res = await fetch(raw, {
|
|
477
|
+
headers: {
|
|
478
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 jaitus-cli',
|
|
479
|
+
'accept': 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8',
|
|
480
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
481
|
+
},
|
|
482
|
+
redirect: 'follow',
|
|
483
|
+
signal: AbortSignal.timeout(20_000),
|
|
484
|
+
});
|
|
485
|
+
} catch (e) {
|
|
486
|
+
throw new ToolError(explainFetchFailure(e, raw));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!res.ok) {
|
|
490
|
+
const hint = res.status === 403 || res.status === 429
|
|
491
|
+
? ' The site is refusing automated requests — try a documentation mirror, the raw file (raw.githubusercontent.com for GitHub), or an official API.'
|
|
492
|
+
: res.status === 404 ? ' Check the URL; search for the page rather than guessing its path.'
|
|
493
|
+
: '';
|
|
494
|
+
throw new ToolError(`HTTP ${res.status} ${res.statusText || ''} for ${raw}.${hint}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ctype = res.headers.get('content-type') || '';
|
|
498
|
+
if (/^application\/pdf/i.test(ctype)) {
|
|
499
|
+
throw new ToolError('That URL is a PDF, which this tool cannot read. Look for an HTML version, or download it with run_command and use a local tool.');
|
|
500
|
+
}
|
|
501
|
+
if (/^(image|audio|video)\//i.test(ctype)) {
|
|
502
|
+
throw new ToolError(`That URL is ${ctype.split(';')[0]}, not a document. Use read_image for a local image file.`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let text;
|
|
506
|
+
if (/json|xml|csv|javascript|text\/plain|markdown/i.test(ctype)) {
|
|
507
|
+
text = await res.text();
|
|
508
|
+
} else {
|
|
509
|
+
text = readableText(await res.text());
|
|
510
|
+
if (!text.trim()) {
|
|
511
|
+
throw new ToolError(
|
|
512
|
+
'The page returned no readable text — it is almost certainly rendered by JavaScript. '
|
|
513
|
+
+ 'Try the site\'s API, its raw markdown (many docs sites serve /llms.txt or a .md path), or a different source.',
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// A short-lived cache, because an agent re-reads the same documentation page
|
|
519
|
+
// several times inside one task and each read is a second of latency it did not
|
|
520
|
+
// need to spend.
|
|
521
|
+
fetchCache.set(raw, { at: Date.now(), text });
|
|
522
|
+
if (fetchCache.size > 40) fetchCache.delete(fetchCache.keys().next().value);
|
|
523
|
+
return truncate(text.slice(0, limit));
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
{
|
|
528
|
+
name: 'todo_write',
|
|
529
|
+
description:
|
|
530
|
+
'Maintain a visible task list for the current request. Call it whenever work has multiple steps: once to plan, and again after each step completes. Users see the list live in the interface, so keep statuses truthful — never mark a step done before its work is finished. Replace the whole list on every call; omit clear to keep it.',
|
|
531
|
+
needsPermission: false,
|
|
532
|
+
input_schema: {
|
|
533
|
+
type: 'object',
|
|
534
|
+
properties: {
|
|
535
|
+
todos: {
|
|
536
|
+
type: 'array',
|
|
537
|
+
description: 'The complete list, in order.',
|
|
538
|
+
items: {
|
|
539
|
+
type: 'object',
|
|
540
|
+
properties: {
|
|
541
|
+
task: { type: 'string', description: 'One step, phrased as an outcome.' },
|
|
542
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: 'Where this step stands.' },
|
|
543
|
+
},
|
|
544
|
+
required: ['task', 'status'],
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
clear: { type: 'boolean', description: 'true to empty the list (for example when everything is done and the request is complete).' },
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
summarise: (i) => `update task list (${Array.isArray(i.todos) ? i.todos.length : 0} items)`,
|
|
551
|
+
async run(input) {
|
|
552
|
+
if (input.clear) { todoState.splice(0, todoState.length); return 'Task list cleared.'; }
|
|
553
|
+
if (!Array.isArray(input.todos)) throw new ToolError('Give a todos array (or clear: true).');
|
|
554
|
+
const cleaned = input.todos.map((t) => ({
|
|
555
|
+
task: String(t?.task || '').trim(),
|
|
556
|
+
status: ['pending', 'in_progress', 'completed'].includes(t?.status) ? t.status : 'pending',
|
|
557
|
+
})).filter((t) => t.task);
|
|
558
|
+
if (!cleaned.length) throw new ToolError('The list has no usable items.');
|
|
559
|
+
todoState.splice(0, todoState.length, ...cleaned);
|
|
560
|
+
return `Task list updated (${cleaned.filter((t) => t.status === 'completed').length}/${cleaned.length} done).`;
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
{
|
|
565
|
+
name: 'read_image',
|
|
566
|
+
description:
|
|
567
|
+
'Read an image file (png, jpg, jpeg, gif, webp) so you can actually see it: screenshots, UI mockups, diagrams, photos. Returns the image itself to your vision, not a text description. Max ~3 MB after base64.',
|
|
568
|
+
needsPermission: false,
|
|
569
|
+
input_schema: {
|
|
570
|
+
type: 'object',
|
|
571
|
+
properties: {
|
|
572
|
+
path: { type: 'string', description: 'Path to the image, relative to the project root.' },
|
|
573
|
+
},
|
|
574
|
+
required: ['path'],
|
|
575
|
+
},
|
|
576
|
+
summarise: (i) => `view ${i.path}`,
|
|
577
|
+
async run() { return '(image attached to the conversation above)'; },
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
{
|
|
581
|
+
name: 'background_run',
|
|
582
|
+
description:
|
|
583
|
+
'Start a long-running shell command (dev server, build watcher, test suite) in the background. Output streams to a log file; poll it with background_read instead of blocking the session on one long command.',
|
|
584
|
+
needsPermission: true,
|
|
585
|
+
input_schema: {
|
|
586
|
+
type: 'object',
|
|
587
|
+
properties: {
|
|
588
|
+
command: { type: 'string', description: 'The shell command to run detached.' },
|
|
589
|
+
},
|
|
590
|
+
required: ['command'],
|
|
591
|
+
},
|
|
592
|
+
summarise: (i) => `bg ${String(i.command || '').slice(0, 60)}`,
|
|
593
|
+
async run(input, { cwd }) {
|
|
594
|
+
if (!input.command?.trim()) throw new ToolError('Give a command to run.');
|
|
595
|
+
const { startBackground } = await import('../background.js');
|
|
596
|
+
const job = startBackground(input.command, cwd);
|
|
597
|
+
return `Background job ${job.id} started (pid ${job.pid ?? '?'}). Read its output with background_read, stop it with background_stop.`;
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
{
|
|
602
|
+
name: 'background_list',
|
|
603
|
+
description: 'List running and finished background shell jobs started this session.',
|
|
604
|
+
needsPermission: false,
|
|
605
|
+
input_schema: { type: 'object', properties: {} },
|
|
606
|
+
summarise: () => 'list background jobs',
|
|
607
|
+
async run() {
|
|
608
|
+
const { listBackground } = await import('../background.js');
|
|
609
|
+
const all = listBackground();
|
|
610
|
+
if (!all.length) return 'No background jobs this session.';
|
|
611
|
+
return all.map((j) => `${j.running ? '…' : j.exit === 0 ? '✓' : '✗'} ${j.id} ${j.command} (pid ${j.pid ?? '?'})`).join('\n');
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
{
|
|
616
|
+
name: 'background_read',
|
|
617
|
+
description: 'Read the tail of a background job log without blocking. Poll this to follow a dev server or build.',
|
|
618
|
+
needsPermission: false,
|
|
619
|
+
input_schema: {
|
|
620
|
+
type: 'object',
|
|
621
|
+
properties: {
|
|
622
|
+
id: { type: 'string', description: 'Job id (or its unique prefix) from background_run or background_list.' },
|
|
623
|
+
tail: { type: 'integer', description: 'How many trailing lines to return (default 40).' },
|
|
624
|
+
},
|
|
625
|
+
required: ['id'],
|
|
626
|
+
},
|
|
627
|
+
summarise: (i) => `read bg ${i.id}`,
|
|
628
|
+
async run(input) {
|
|
629
|
+
const { readBackground } = await import('../background.js');
|
|
630
|
+
const job = readBackground(input.id, input.tail || 40);
|
|
631
|
+
if (!job) throw new ToolError(`No background job ${input.id}. List them with background_list.`);
|
|
632
|
+
return `${job.running ? 'Running' : `Finished (exit ${job.exit})`} — ${job.command}\n\n${job.tail}`;
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
{
|
|
637
|
+
name: 'background_stop',
|
|
638
|
+
description: 'Stop a running background shell job.',
|
|
639
|
+
needsPermission: false,
|
|
640
|
+
input_schema: {
|
|
641
|
+
type: 'object',
|
|
642
|
+
properties: {
|
|
643
|
+
id: { type: 'string', description: 'Job id (or its unique prefix).' },
|
|
644
|
+
},
|
|
645
|
+
required: ['id'],
|
|
646
|
+
},
|
|
647
|
+
summarise: (i) => `stop bg ${i.id}`,
|
|
648
|
+
async run(input) {
|
|
649
|
+
const { stopBackground } = await import('../background.js');
|
|
650
|
+
if (!stopBackground(input.id)) throw new ToolError(`No background job ${input.id}.`);
|
|
651
|
+
return `Stopped ${input.id}.`;
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
/** The live task list todo_write maintains; the session renders it in the panel. */
|
|
657
|
+
export const todoState = [];
|
|
658
|
+
|
|
659
|
+
/** The three image sizes the API accepts, newest API first. */
|
|
660
|
+
export const IMAGE_MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Turn an image file into an API content block, or null when it is not a usable
|
|
664
|
+
* image (wrong kind, too big, unreadable). The caller attaches the block to the
|
|
665
|
+
* next user message so the model actually receives the pixels.
|
|
666
|
+
*/
|
|
667
|
+
export async function imageBlock(p) {
|
|
668
|
+
const ext = path.extname(p).toLowerCase();
|
|
669
|
+
const mime = IMAGE_MIME[ext];
|
|
670
|
+
if (!mime) return null;
|
|
671
|
+
try {
|
|
672
|
+
const buf = await fs.readFile(p);
|
|
673
|
+
if (buf.length > 3_500_000) return null;
|
|
674
|
+
return { type: 'image', source: { type: 'base64', media_type: mime, data: buf.toString('base64') } };
|
|
675
|
+
} catch { return null; }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* read_image's bridge to the loop: same containment rules as every other file tool,
|
|
680
|
+
* then the pixel payload. Null (with the reason) when the image cannot be seen.
|
|
681
|
+
*/
|
|
682
|
+
export async function readImageForModel(cwd, p) {
|
|
683
|
+
const file = safePath(cwd, p);
|
|
684
|
+
refuseIfIgnored(cwd, p);
|
|
685
|
+
const block = await imageBlock(file);
|
|
686
|
+
if (!block) {
|
|
687
|
+
const ext = path.extname(String(p)).toLowerCase();
|
|
688
|
+
if (!IMAGE_MIME[ext]) throw new ToolError(`Not an image format the model can see (${ext || 'no extension'}). Supported: png, jpg, jpeg, gif, webp.`);
|
|
689
|
+
throw new ToolError('Image too large or unreadable (max ~3 MB). Compress or screenshot it smaller.');
|
|
690
|
+
}
|
|
691
|
+
return block;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const BROWSER_HEADERS = {
|
|
695
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
|
696
|
+
'accept': 'text/html',
|
|
697
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* A glob pattern as a regular expression.
|
|
702
|
+
*
|
|
703
|
+
* `*` stops at a path separator and `**` crosses them, which is the convention every
|
|
704
|
+
* tool a developer already uses follows. Getting that distinction wrong in either
|
|
705
|
+
* direction is the difference between `src/*.ts` meaning "the TypeScript files in src"
|
|
706
|
+
* and "every TypeScript file in the project".
|
|
707
|
+
*/
|
|
708
|
+
function globToRegExp(pattern) {
|
|
709
|
+
let out = '';
|
|
710
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
711
|
+
const ch = pattern[i];
|
|
712
|
+
if (ch === '*') {
|
|
713
|
+
if (pattern[i + 1] === '*') {
|
|
714
|
+
i += 1;
|
|
715
|
+
// `**/` may match nothing at all, so `**/*.ts` finds `index.ts` at the root.
|
|
716
|
+
if (pattern[i + 1] === '/') { i += 1; out += '(?:.*/)?'; } else out += '.*';
|
|
717
|
+
} else out += '[^/]*';
|
|
718
|
+
} else if (ch === '?') out += '[^/]';
|
|
719
|
+
else if (ch === '{') out += '(?:';
|
|
720
|
+
else if (ch === '}') out += ')';
|
|
721
|
+
else if (ch === ',') out += '|';
|
|
722
|
+
else out += ch.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
723
|
+
}
|
|
724
|
+
return new RegExp(`^${out}$`, process.platform === 'win32' ? 'i' : '');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const fetchCache = new Map();
|
|
728
|
+
const FETCH_CACHE_MS = 15 * 60 * 1000;
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Say what actually went wrong.
|
|
732
|
+
*
|
|
733
|
+
* `fetch()` reports every network failure as the string "fetch failed" and hides the
|
|
734
|
+
* reason on `error.cause`. An agent told "Fetch failed: fetch failed" has no way to
|
|
735
|
+
* choose between retrying, trying a different host, or telling the user their DNS is
|
|
736
|
+
* broken — and neither does the person reading the transcript.
|
|
737
|
+
*/
|
|
738
|
+
function explainFetchFailure(error, url) {
|
|
739
|
+
const code = error?.cause?.code || error?.code || '';
|
|
740
|
+
const host = (() => { try { return new URL(url).host; } catch { return url; } })();
|
|
741
|
+
const reasons = {
|
|
742
|
+
ENOTFOUND: `${host} does not resolve on this network — check the spelling, or DNS is blocking it.`,
|
|
743
|
+
EAI_AGAIN: `DNS lookup for ${host} timed out — the network or resolver is unavailable.`,
|
|
744
|
+
ECONNREFUSED: `${host} refused the connection.`,
|
|
745
|
+
ECONNRESET: `${host} closed the connection mid-response; retrying once is reasonable.`,
|
|
746
|
+
UND_ERR_CONNECT_TIMEOUT: `Could not connect to ${host} before the timeout — it may be unreachable from this network.`,
|
|
747
|
+
UND_ERR_HEADERS_TIMEOUT: `${host} accepted the connection but never sent headers.`,
|
|
748
|
+
CERT_HAS_EXPIRED: `${host} has an expired TLS certificate.`,
|
|
749
|
+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: `${host}'s TLS certificate could not be verified — a proxy may be intercepting it.`,
|
|
750
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: `${host} presents a self-signed certificate.`,
|
|
751
|
+
};
|
|
752
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return `Fetching ${host} timed out after 20s.`;
|
|
753
|
+
return reasons[code] || `Could not fetch ${host}: ${error?.message || error}${code ? ` (${code})` : ''}.`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function isNewsQuery(query) {
|
|
757
|
+
return /\b(news|headlines?|breaking|today|latest|current events?)\b/i.test(query);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Google News exposes a public RSS search feed with dated, news-only results. */
|
|
761
|
+
async function searchGoogleNews(query) {
|
|
762
|
+
const endpoint = isHeadlineRequest(query)
|
|
763
|
+
? 'https://news.google.com/rss?hl=en-IN&gl=IN&ceid=IN:en'
|
|
764
|
+
: `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-IN&gl=IN&ceid=IN:en`;
|
|
765
|
+
const res = await fetch(endpoint, {
|
|
766
|
+
headers: { ...BROWSER_HEADERS, accept: 'application/rss+xml, application/xml;q=0.9, text/xml;q=0.8' },
|
|
767
|
+
signal: AbortSignal.timeout(15_000),
|
|
768
|
+
});
|
|
769
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
770
|
+
const xml = await res.text();
|
|
771
|
+
const results = [];
|
|
772
|
+
for (const item of xml.match(/<item>[\s\S]*?<\/item>/g) || []) {
|
|
773
|
+
if (results.length >= 8) break;
|
|
774
|
+
const title = xmlText(item, 'title');
|
|
775
|
+
const url = xmlText(item, 'link');
|
|
776
|
+
const snippet = xmlText(item, 'description').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
777
|
+
if (title && /^https?:\/\//.test(url)) results.push({ title, url, snippet });
|
|
778
|
+
}
|
|
779
|
+
return results;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function isHeadlineRequest(query) {
|
|
783
|
+
const remaining = query
|
|
784
|
+
.toLowerCase()
|
|
785
|
+
.replace(/\b(top|latest|today|breaking|news|headlines?|current|events?|world|global|updates?)\b/g, '')
|
|
786
|
+
.replace(/[^a-z0-9]+/g, ' ').trim();
|
|
787
|
+
return remaining.length === 0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function xmlText(source, name) {
|
|
791
|
+
const match = new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i').exec(source);
|
|
792
|
+
if (!match) return '';
|
|
793
|
+
return match[1]
|
|
794
|
+
.replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, '$1')
|
|
795
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
796
|
+
.replace(/"/g, '"').replace(/'/g, "'").trim();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Bing: results live in <li class="b_algo"> blocks; links are bing.com/ck redirects whose u=a1<base64url> parameter carries the real URL. */
|
|
800
|
+
async function searchBing(query) {
|
|
801
|
+
const res = await fetch(`https://www.bing.com/search?q=${encodeURIComponent(query)}&count=10`, {
|
|
802
|
+
headers: BROWSER_HEADERS, signal: AbortSignal.timeout(15_000),
|
|
803
|
+
});
|
|
804
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
805
|
+
const html = await res.text();
|
|
806
|
+
const results = [];
|
|
807
|
+
for (const block of html.split('<li class="b_algo').slice(1)) {
|
|
808
|
+
if (results.length >= 8) break;
|
|
809
|
+
const hrefMatch = /<h2[^>]*><a[^>]+href="([^"]+)"/.exec(block);
|
|
810
|
+
const h2 = /<h2[^>]*>([\s\S]*?)<\/h2>/.exec(block);
|
|
811
|
+
if (!hrefMatch || !h2) continue;
|
|
812
|
+
const url = unwrapBingHref(hrefMatch[1]);
|
|
813
|
+
const title = stripTags(h2[1]).trim();
|
|
814
|
+
const snippetMatch = /<p[^>]*>([\s\S]*?)<\/p>/.exec(block);
|
|
815
|
+
const snippet = snippetMatch ? stripTags(snippetMatch[1]).trim() : '';
|
|
816
|
+
if (url && title) results.push({ url, title, snippet });
|
|
817
|
+
}
|
|
818
|
+
return results;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Bing wraps outbound links in a click-tracker; the target is base64url after u=a1. */
|
|
822
|
+
function unwrapBingHref(href) {
|
|
823
|
+
const clean = href.replace(/&/g, '&');
|
|
824
|
+
if (!clean.includes('bing.com/ck/')) return clean;
|
|
825
|
+
const u = /[?&]u=a1([^&]+)/.exec(clean);
|
|
826
|
+
if (!u) return clean;
|
|
827
|
+
let b64 = u[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
828
|
+
while (b64.length % 4) b64 += '=';
|
|
829
|
+
try {
|
|
830
|
+
const decoded = Buffer.from(b64, 'base64').toString('utf8');
|
|
831
|
+
return /^https?:\/\//.test(decoded) ? decoded : clean;
|
|
832
|
+
} catch { return clean; }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Mojeek: independent index, friendly to scrapers, plain hrefs in <a class="ob". */
|
|
836
|
+
async function searchMojeek(query) {
|
|
837
|
+
const res = await fetch(`https://www.mojeek.com/search?q=${encodeURIComponent(query)}`, {
|
|
838
|
+
headers: BROWSER_HEADERS, signal: AbortSignal.timeout(15_000),
|
|
839
|
+
});
|
|
840
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
841
|
+
const html = await res.text();
|
|
842
|
+
const results = [];
|
|
843
|
+
const re = /<a[^>]+class="ob"[^>]+href="(https?:[^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
844
|
+
let m;
|
|
845
|
+
while ((m = re.exec(html)) && results.length < 8) {
|
|
846
|
+
const title = stripTags(m[2]).trim();
|
|
847
|
+
if (title) results.push({ url: m[1].replace(/&/g, '&'), title, snippet: '' });
|
|
848
|
+
}
|
|
849
|
+
return results;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** DuckDuckGo's HTML endpoint — unreachable on some networks, so it is the last resort. */
|
|
853
|
+
async function searchDuckDuckGo(query) {
|
|
854
|
+
const res = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
|
|
855
|
+
headers: BROWSER_HEADERS, signal: AbortSignal.timeout(15_000),
|
|
856
|
+
});
|
|
857
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
858
|
+
const html = await res.text();
|
|
859
|
+
const results = [];
|
|
860
|
+
const re = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
861
|
+
let m;
|
|
862
|
+
while ((m = re.exec(html)) && results.length < 8) {
|
|
863
|
+
let href = m[1].replace(/&/g, '&');
|
|
864
|
+
const uddg = /[?&]uddg=([^&]+)/.exec(href);
|
|
865
|
+
if (uddg) href = decodeURIComponent(uddg[1]);
|
|
866
|
+
if (href.startsWith('//')) href = `https:${href}`;
|
|
867
|
+
const title = stripTags(m[2]).trim();
|
|
868
|
+
if (title && /^https?:\/\//.test(href)) results.push({ url: href, title, snippet: '' });
|
|
869
|
+
}
|
|
870
|
+
const snippets = [];
|
|
871
|
+
const sre = /<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
872
|
+
while ((m = sre.exec(html)) && snippets.length < 8) snippets.push(stripTags(m[1]).trim());
|
|
873
|
+
results.forEach((r, i) => { r.snippet = snippets[i] || ''; });
|
|
874
|
+
return results;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Strip tags and entities, then collapse whitespace — the text-only view of HTML. */
|
|
878
|
+
function stripTags(s) {
|
|
879
|
+
return String(s)
|
|
880
|
+
.replace(/<[^>]*>/g, '')
|
|
881
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
882
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
|
883
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
884
|
+
.replace(/\s+/g, ' ');
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Crude but effective reader mode: drop scripts/styles/nav/headers/footers and known
|
|
889
|
+
* boilerplate, keep headings and paragraphs with their text, in document order.
|
|
890
|
+
*/
|
|
891
|
+
function readableText(html) {
|
|
892
|
+
return String(html)
|
|
893
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
894
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
895
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
|
896
|
+
.replace(/<template[\s\S]*?<\/template>/gi, ' ')
|
|
897
|
+
.replace(/<(nav|header|footer|aside|form|svg)[\s\S]*?<\/\1>/gi, ' ')
|
|
898
|
+
.replace(/<!--[^]*?-->/g, ' ')
|
|
899
|
+
.replace(/<h([1-6])[^>]*>/gi, '\n\n')
|
|
900
|
+
.replace(/<\/(h[1-6])>/gi, '\n')
|
|
901
|
+
.replace(/<(p|div|section|article|li|br|tr)[^>]*>/gi, '\n')
|
|
902
|
+
.replace(/<(td|th)[^>]*>/gi, ' | ')
|
|
903
|
+
.replace(/<[^>]*>/g, ' ')
|
|
904
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
905
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
|
906
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
907
|
+
.split('\n')
|
|
908
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
909
|
+
.filter((l) => l.length > 0)
|
|
910
|
+
.join('\n')
|
|
911
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** The tool list in the shape the Messages API expects. */
|
|
915
|
+
export const toolSchemas = () =>
|
|
916
|
+
TOOLS.map((t) => ({
|
|
917
|
+
name: t.name,
|
|
918
|
+
description: t.description,
|
|
919
|
+
input_schema: t.input_schema,
|
|
920
|
+
}));
|
|
921
|
+
|
|
922
|
+
/** Tools by name, for dispatch in the loop. */
|
|
923
|
+
export const byName = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
|