@alisio/core 0.1.0-alpha.3
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 +25 -0
- package/dist/application.d.ts +192 -0
- package/dist/application.js +764 -0
- package/dist/config.d.ts +420 -0
- package/dist/config.js +416 -0
- package/dist/core/compaction.d.ts +48 -0
- package/dist/core/compaction.js +206 -0
- package/dist/core/contracts.d.ts +141 -0
- package/dist/core/contracts.js +1 -0
- package/dist/core/registry.d.ts +8 -0
- package/dist/core/registry.js +54 -0
- package/dist/core/runner.d.ts +121 -0
- package/dist/core/runner.js +543 -0
- package/dist/extensions/registry.d.ts +28 -0
- package/dist/extensions/registry.js +58 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +27 -0
- package/dist/integrations/herdr.d.ts +34 -0
- package/dist/integrations/herdr.js +174 -0
- package/dist/mcp/connector.d.ts +63 -0
- package/dist/mcp/connector.js +384 -0
- package/dist/plugins/host.d.ts +164 -0
- package/dist/plugins/host.js +572 -0
- package/dist/providers/openai-compatible.d.ts +14 -0
- package/dist/providers/openai-compatible.js +228 -0
- package/dist/providers/registry.d.ts +35 -0
- package/dist/providers/registry.js +74 -0
- package/dist/providers/routing.d.ts +11 -0
- package/dist/providers/routing.js +41 -0
- package/dist/providers/settings.d.ts +30 -0
- package/dist/providers/settings.js +80 -0
- package/dist/resources/context.d.ts +39 -0
- package/dist/resources/context.js +164 -0
- package/dist/resources/prompts.d.ts +77 -0
- package/dist/resources/prompts.js +182 -0
- package/dist/resources/skills.d.ts +82 -0
- package/dist/resources/skills.js +214 -0
- package/dist/runtime/fs.d.ts +9 -0
- package/dist/runtime/fs.js +73 -0
- package/dist/runtime/modules.d.ts +14 -0
- package/dist/runtime/modules.js +76 -0
- package/dist/runtime/paths.d.ts +4 -0
- package/dist/runtime/paths.js +42 -0
- package/dist/runtime/process.d.ts +16 -0
- package/dist/runtime/process.js +104 -0
- package/dist/runtime/sqlite.d.ts +3 -0
- package/dist/runtime/sqlite.js +85 -0
- package/dist/runtime/store.d.ts +37 -0
- package/dist/runtime/store.js +250 -0
- package/dist/sessions/children.d.ts +55 -0
- package/dist/sessions/children.js +231 -0
- package/dist/startup/index.d.ts +41 -0
- package/dist/startup/index.js +105 -0
- package/dist/startup/mascot.d.ts +6 -0
- package/dist/startup/mascot.js +30 -0
- package/dist/startup/screen.d.ts +18 -0
- package/dist/startup/screen.js +116 -0
- package/dist/startup/text.d.ts +19 -0
- package/dist/startup/text.js +68 -0
- package/dist/tools/execute.d.ts +16 -0
- package/dist/tools/execute.js +96 -0
- package/dist/tools/search.d.ts +69 -0
- package/dist/tools/search.js +232 -0
- package/dist/tools/standard.d.ts +22 -0
- package/dist/tools/standard.js +408 -0
- package/dist/tools/webfetch.d.ts +26 -0
- package/dist/tools/webfetch.js +119 -0
- package/dist/trust.d.ts +34 -0
- package/dist/trust.js +113 -0
- package/package.json +62 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A long-running, widely mirrored public SearXNG instance (see https://searx.space for others).
|
|
3
|
+
* Best-effort only: a public instance has no SLA and routinely rate-limits or bot-blocks
|
|
4
|
+
* automated requests (verified empirically — see the module doc comment above).
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_SEARXNG_URL = "https://searx.be";
|
|
7
|
+
const PRIVATE_IPV4 = [
|
|
8
|
+
/^127\./,
|
|
9
|
+
/^10\./,
|
|
10
|
+
/^192\.168\./,
|
|
11
|
+
/^172\.(1[6-9]|2\d|3[0-1])\./,
|
|
12
|
+
/^169\.254\./,
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Heuristic SSRF guard: a user-configured `searxngUrl` could point anywhere, unlike the fixed
|
|
16
|
+
* built-in providers' hardcoded hosts. This is not exhaustive (no DNS-rebinding protection), but
|
|
17
|
+
* it rejects the obvious cases and requires HTTPS for anything that is not clearly local/private.
|
|
18
|
+
*/
|
|
19
|
+
export function isLoopbackOrPrivateHost(hostname) {
|
|
20
|
+
const h = hostname.toLowerCase();
|
|
21
|
+
if (h === "localhost" || h === "::1" || h === "[::1]")
|
|
22
|
+
return true;
|
|
23
|
+
if (PRIVATE_IPV4.some((re) => re.test(h)))
|
|
24
|
+
return true;
|
|
25
|
+
if (h.startsWith("fc") || h.startsWith("fd"))
|
|
26
|
+
return true; // IPv6 unique local addresses
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
export function validateSearxngUrl(raw) {
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error(`websearch.searxngUrl is not a valid URL: ${raw}`);
|
|
36
|
+
}
|
|
37
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
38
|
+
throw new Error("websearch.searxngUrl must be http or https");
|
|
39
|
+
if (url.protocol === "http:" && !isLoopbackOrPrivateHost(url.hostname))
|
|
40
|
+
throw new Error("websearch.searxngUrl must use https unless it targets a loopback or private host");
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
43
|
+
const asText = (value) => (typeof value === "string" ? value : "");
|
|
44
|
+
// Some public search backends (DuckDuckGo's Instant Answer API among them) vary their response
|
|
45
|
+
// content-type by User-Agent; a browser-like one gets consistent JSON instead of a JSONP-flavored
|
|
46
|
+
// content-type for an unrecognized client.
|
|
47
|
+
const DEFAULT_UA = "Mozilla/5.0 (compatible; AlisioAgent/1.0; +https://github.com/GustavoGutierrez/alisio)";
|
|
48
|
+
async function getJson(url, options) {
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
signal: options.signal,
|
|
51
|
+
headers: { "User-Agent": DEFAULT_UA, ...options.headers },
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
throw new Error(`${url.hostname} search request failed: HTTP ${res.status}`);
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A public SearXNG instance under bot protection answers 200 with an HTML challenge/captcha
|
|
61
|
+
// page instead of JSON (some APIs, like DuckDuckGo's, also report a non-JSON content-type on
|
|
62
|
+
// an otherwise-valid JSON body, so content-type alone cannot tell the two apart). A plain
|
|
63
|
+
// `res.json()` call would fail here with a confusing "Unexpected token '<'"; this gives the
|
|
64
|
+
// actual, actionable cause instead.
|
|
65
|
+
throw new Error(`${url.hostname} did not return valid JSON; it may be showing a bot-check/captcha page ` +
|
|
66
|
+
"instead of results. Public instances vary in how aggressively they block automated " +
|
|
67
|
+
"requests — try another instance or self-host one (see websearch.searxngUrl, e.g. " +
|
|
68
|
+
'"http://localhost:8080").');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function searxngProvider(baseUrl) {
|
|
72
|
+
const validated = validateSearxngUrl(baseUrl);
|
|
73
|
+
return {
|
|
74
|
+
id: `searxng:${validated.host}`,
|
|
75
|
+
async search(query, options) {
|
|
76
|
+
const url = new URL("/search", validated);
|
|
77
|
+
url.searchParams.set("q", query);
|
|
78
|
+
url.searchParams.set("format", "json");
|
|
79
|
+
const body = (await getJson(url, { signal: options?.signal }));
|
|
80
|
+
return (body.results ?? [])
|
|
81
|
+
.slice(0, 10)
|
|
82
|
+
.map((r) => ({ title: asText(r.title), url: asText(r.url), snippet: asText(r.content) }));
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* DuckDuckGo's free, keyless Instant Answer API. It only returns a result for a direct
|
|
88
|
+
* factual/infobox-style query (Wikipedia-style); an ordinary "search the web for X" query
|
|
89
|
+
* routinely returns nothing here even though results exist on the web — this is NOT a general
|
|
90
|
+
* search engine. Callers must not treat an empty result as "nothing found on the web".
|
|
91
|
+
*/
|
|
92
|
+
export function duckDuckGoInstantProvider() {
|
|
93
|
+
return {
|
|
94
|
+
id: "duckduckgo-instant",
|
|
95
|
+
async search(query, options) {
|
|
96
|
+
const url = new URL("https://api.duckduckgo.com/");
|
|
97
|
+
url.searchParams.set("q", query);
|
|
98
|
+
url.searchParams.set("format", "json");
|
|
99
|
+
url.searchParams.set("no_html", "1");
|
|
100
|
+
url.searchParams.set("skip_disambig", "1");
|
|
101
|
+
const body = (await getJson(url, { signal: options?.signal }));
|
|
102
|
+
const results = [];
|
|
103
|
+
if (asText(body.AbstractText))
|
|
104
|
+
results.push({
|
|
105
|
+
title: asText(body.Heading) || query,
|
|
106
|
+
url: asText(body.AbstractURL),
|
|
107
|
+
snippet: asText(body.AbstractText),
|
|
108
|
+
});
|
|
109
|
+
for (const topic of body.RelatedTopics ?? []) {
|
|
110
|
+
const text = asText(topic.Text);
|
|
111
|
+
const link = asText(topic.FirstURL);
|
|
112
|
+
if (text && link)
|
|
113
|
+
results.push({ title: text.split(" - ")[0] || text, url: link, snippet: text });
|
|
114
|
+
}
|
|
115
|
+
return results.slice(0, 10);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function tavilyProvider(apiKey) {
|
|
120
|
+
return {
|
|
121
|
+
id: "tavily",
|
|
122
|
+
async search(query, options) {
|
|
123
|
+
const res = await fetch("https://api.tavily.com/search", {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Content-Type": "application/json", "User-Agent": DEFAULT_UA },
|
|
126
|
+
body: JSON.stringify({ api_key: apiKey, query, max_results: 10 }),
|
|
127
|
+
signal: options?.signal,
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
throw new Error(`Tavily search request failed: HTTP ${res.status}`);
|
|
131
|
+
const body = (await res.json());
|
|
132
|
+
return (body.results ?? []).map((r) => ({
|
|
133
|
+
title: asText(r.title),
|
|
134
|
+
url: asText(r.url),
|
|
135
|
+
snippet: asText(r.content),
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function braveProvider(apiKey) {
|
|
141
|
+
return {
|
|
142
|
+
id: "brave",
|
|
143
|
+
async search(query, options) {
|
|
144
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
145
|
+
url.searchParams.set("q", query);
|
|
146
|
+
const body = (await getJson(url, {
|
|
147
|
+
signal: options?.signal,
|
|
148
|
+
headers: { "X-Subscription-Token": apiKey, Accept: "application/json" },
|
|
149
|
+
}));
|
|
150
|
+
return (body.web?.results ?? []).map((r) => ({
|
|
151
|
+
title: asText(r.title),
|
|
152
|
+
url: asText(r.url),
|
|
153
|
+
snippet: asText(r.description),
|
|
154
|
+
}));
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function serpApiProvider(apiKey) {
|
|
159
|
+
return {
|
|
160
|
+
id: "serpapi",
|
|
161
|
+
async search(query, options) {
|
|
162
|
+
const url = new URL("https://serpapi.com/search.json");
|
|
163
|
+
url.searchParams.set("q", query);
|
|
164
|
+
url.searchParams.set("api_key", apiKey);
|
|
165
|
+
const body = (await getJson(url, { signal: options?.signal }));
|
|
166
|
+
return (body.organic_results ?? []).map((r) => ({
|
|
167
|
+
title: asText(r.title),
|
|
168
|
+
url: asText(r.link),
|
|
169
|
+
snippet: asText(r.snippet),
|
|
170
|
+
}));
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function resolveBuiltin(config) {
|
|
175
|
+
const provider = config?.provider;
|
|
176
|
+
if (!provider || provider === "searxng") {
|
|
177
|
+
const url = config?.searxngUrl ?? DEFAULT_SEARXNG_URL;
|
|
178
|
+
return {
|
|
179
|
+
provider: searxngProvider(url),
|
|
180
|
+
source: config?.searxngUrl ? `searxng (${url})` : `default public SearXNG instance (${url})`,
|
|
181
|
+
...(config?.searxngUrl
|
|
182
|
+
? {}
|
|
183
|
+
: {
|
|
184
|
+
limitation: "Using a public SearXNG instance. In practice, most public instances rate-limit " +
|
|
185
|
+
"or bot-block a fresh automated request (verified while building this tool: " +
|
|
186
|
+
"nearly every instance tried returned 429 or a captcha page on the first call) — " +
|
|
187
|
+
"treat this as a best-effort starting point, not a reliable default. Self-host " +
|
|
188
|
+
"(e.g. `docker run searxng/searxng`) and set websearch.searxngUrl (for example " +
|
|
189
|
+
'"http://localhost:8080") for search that actually works consistently.',
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (provider === "duckduckgo-instant")
|
|
194
|
+
return {
|
|
195
|
+
provider: duckDuckGoInstantProvider(),
|
|
196
|
+
source: "duckduckgo-instant",
|
|
197
|
+
limitation: "DuckDuckGo's Instant Answer API only answers direct factual/infobox-style queries, not " +
|
|
198
|
+
"general web search. An empty result here does not mean nothing exists on the web.",
|
|
199
|
+
};
|
|
200
|
+
if (provider === "native")
|
|
201
|
+
throw new Error('websearch.provider is "native": search runs server-side through the model provider; ' +
|
|
202
|
+
"this tool should never be registered or called in that mode.");
|
|
203
|
+
const envVar = config?.apiKeyEnv ?? `${provider.toUpperCase()}_API_KEY`;
|
|
204
|
+
const key = process.env[envVar];
|
|
205
|
+
if (!key)
|
|
206
|
+
throw new Error(`websearch.provider is "${provider}" but ${envVar} is not set in the environment`);
|
|
207
|
+
if (provider === "tavily")
|
|
208
|
+
return { provider: tavilyProvider(key), source: "tavily" };
|
|
209
|
+
if (provider === "brave")
|
|
210
|
+
return { provider: braveProvider(key), source: "brave" };
|
|
211
|
+
if (provider === "serpapi")
|
|
212
|
+
return { provider: serpApiProvider(key), source: "serpapi" };
|
|
213
|
+
throw new Error(`Unknown websearch.provider: ${provider}`);
|
|
214
|
+
}
|
|
215
|
+
export async function searchWithFallback(query, config, resolveExtension, signal) {
|
|
216
|
+
const extension = resolveExtension?.();
|
|
217
|
+
if (extension) {
|
|
218
|
+
try {
|
|
219
|
+
const results = await extension.provider.search(query, { signal });
|
|
220
|
+
return { provider: extension.provider, source: `plugin:${extension.plugin}`, results };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const diagnostic = `Plugin websearch provider (${extension.plugin}) failed, using the built-in chain instead: ${error instanceof Error ? error.message : String(error)}`;
|
|
224
|
+
const builtin = resolveBuiltin(config);
|
|
225
|
+
const results = await builtin.provider.search(query, { signal });
|
|
226
|
+
return { ...builtin, results, diagnostic };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const builtin = resolveBuiltin(config);
|
|
230
|
+
const results = await builtin.provider.search(query, { signal });
|
|
231
|
+
return { ...builtin, results };
|
|
232
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AskQuestionsRequest, type AskQuestionsResult, type JsonSchema, type SearchProvider } from "@alisio/sdk";
|
|
2
|
+
import type { Policy } from "../core/contracts.ts";
|
|
3
|
+
import type { ToolRegistry } from "../core/registry.ts";
|
|
4
|
+
import type { ProjectContext } from "../resources/context.ts";
|
|
5
|
+
import type { Skills } from "../resources/skills.ts";
|
|
6
|
+
import { type WebsearchConfig } from "./search.ts";
|
|
7
|
+
export declare const objectSchema: (properties: Record<string, unknown>, required?: string[]) => JsonSchema;
|
|
8
|
+
export declare const hash: (text: string) => string;
|
|
9
|
+
export declare function registerStandard(registry: ToolRegistry, workspace: string, skills: Skills, context: ProjectContext, ui?: {
|
|
10
|
+
interactive(): boolean;
|
|
11
|
+
askQuestions(request: AskQuestionsRequest): Promise<AskQuestionsResult>;
|
|
12
|
+
},
|
|
13
|
+
/** Live policy accessor for `execute`'s nested tool calls (bound once the runner exists). */
|
|
14
|
+
execCtx?: {
|
|
15
|
+
policy(): Policy;
|
|
16
|
+
}, websearchCtx?: {
|
|
17
|
+
config?: WebsearchConfig;
|
|
18
|
+
resolveExtension?: () => {
|
|
19
|
+
provider: SearchProvider;
|
|
20
|
+
plugin: string;
|
|
21
|
+
} | undefined;
|
|
22
|
+
}): void;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, relative } from "node:path";
|
|
4
|
+
import { textResult, } from "@alisio/sdk";
|
|
5
|
+
import { fileSize, readHead, readText } from "../runtime/fs.js";
|
|
6
|
+
import { safePath } from "../runtime/paths.js";
|
|
7
|
+
import { runProcess } from "../runtime/process.js";
|
|
8
|
+
import { EXECUTE_TIMEOUT_MS, MAX_NESTED_CALLS, runExecute } from "./execute.js";
|
|
9
|
+
import { searchWithFallback } from "./search.js";
|
|
10
|
+
import { DEFAULT_TIMEOUT_SECONDS, MAX_RESPONSE_BYTES, MAX_TIMEOUT_SECONDS, webfetch, } from "./webfetch.js";
|
|
11
|
+
const str = { type: "string" }, integer = { type: "integer", minimum: 0 };
|
|
12
|
+
export const objectSchema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
13
|
+
export const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
14
|
+
async function existing(path) {
|
|
15
|
+
const size = await fileSize(path);
|
|
16
|
+
if (size === undefined)
|
|
17
|
+
return;
|
|
18
|
+
if (size > 1_048_576)
|
|
19
|
+
throw new Error("Editable files are limited to 1 MiB");
|
|
20
|
+
return readText(path);
|
|
21
|
+
}
|
|
22
|
+
async function atomicWrite(root, path, content, expected) {
|
|
23
|
+
path = await safePath(root, path);
|
|
24
|
+
await mkdir(dirname(path), { recursive: true });
|
|
25
|
+
const before = await existing(path);
|
|
26
|
+
if (before === undefined ? expected !== null : hash(before) !== expected)
|
|
27
|
+
throw new Error("File precondition failed: reread the file and use its current sha256");
|
|
28
|
+
const temporary = `${path}.alisio-${crypto.randomUUID()}.tmp`;
|
|
29
|
+
try {
|
|
30
|
+
const mode = before === undefined ? 0o644 : (await stat(path)).mode & 0o777;
|
|
31
|
+
await writeFile(temporary, content, { mode });
|
|
32
|
+
await safePath(root, path);
|
|
33
|
+
const now = await existing(path);
|
|
34
|
+
if (now !== before)
|
|
35
|
+
throw new Error("File changed while preparing write");
|
|
36
|
+
await rename(temporary, path);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await rm(temporary, { force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function registerStandard(registry, workspace, skills, context, ui,
|
|
43
|
+
/** Live policy accessor for `execute`'s nested tool calls (bound once the runner exists). */
|
|
44
|
+
execCtx, websearchCtx) {
|
|
45
|
+
const register = (tool) => registry.register(tool);
|
|
46
|
+
const filePaths = (input) => typeof input.path === "string" ? [input.path] : [];
|
|
47
|
+
register({
|
|
48
|
+
name: "read_file",
|
|
49
|
+
effect: "read",
|
|
50
|
+
description: "Read UTF-8 text by line; returns sha256 for safe edits. Max scan 1 MiB.",
|
|
51
|
+
inputSchema: objectSchema({
|
|
52
|
+
path: str,
|
|
53
|
+
startLine: { type: "integer", minimum: 1 },
|
|
54
|
+
limit: { type: "integer", minimum: 1, maximum: 1000 },
|
|
55
|
+
}, ["path"]),
|
|
56
|
+
paths: filePaths,
|
|
57
|
+
async execute(i, c) {
|
|
58
|
+
const path = await safePath(c.workspace, String(i.path));
|
|
59
|
+
const size = await fileSize(path);
|
|
60
|
+
if (size === undefined)
|
|
61
|
+
throw new Error(`File not found: ${String(i.path)}`);
|
|
62
|
+
const raw = await readHead(path, 1_048_576);
|
|
63
|
+
if (raw.includes("\0"))
|
|
64
|
+
throw new Error("Binary file; text reader refused");
|
|
65
|
+
const start = Number(i.startLine ?? 1), limit = Number(i.limit ?? 200), lines = raw.split("\n");
|
|
66
|
+
const content = lines
|
|
67
|
+
.slice(start - 1, start - 1 + limit)
|
|
68
|
+
.map((line, n) => `${start + n}: ${line}`)
|
|
69
|
+
.join("\n");
|
|
70
|
+
return textResult(JSON.stringify({
|
|
71
|
+
path: i.path,
|
|
72
|
+
sha256: size <= 1_048_576 ? hash(raw) : null,
|
|
73
|
+
startLine: start,
|
|
74
|
+
content: content.slice(0, 28_000),
|
|
75
|
+
truncated: size > 1_048_576 || start - 1 + limit < lines.length || content.length > 28_000,
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
register({
|
|
80
|
+
name: "write_file",
|
|
81
|
+
effect: "write",
|
|
82
|
+
description: "Create or replace a file. expectedHash is null only for a new file, otherwise use read_file sha256.",
|
|
83
|
+
inputSchema: objectSchema({ path: str, content: str, expectedHash: { type: ["string", "null"] } }, ["path", "content", "expectedHash"]),
|
|
84
|
+
paths: filePaths,
|
|
85
|
+
async execute(i, c) {
|
|
86
|
+
const content = String(i.content);
|
|
87
|
+
if (Buffer.byteLength(content) > 1_048_576)
|
|
88
|
+
throw new Error("Write exceeds 1 MiB");
|
|
89
|
+
await atomicWrite(c.workspace, String(i.path), content, i.expectedHash);
|
|
90
|
+
return textResult(JSON.stringify({ path: i.path, sha256: hash(content) }));
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
register({
|
|
94
|
+
name: "edit_file",
|
|
95
|
+
effect: "write",
|
|
96
|
+
description: "Replace exactly one occurrence. Requires expectedHash from read_file; preserves untouched bytes and line endings.",
|
|
97
|
+
inputSchema: objectSchema({ path: str, oldText: { type: "string", minLength: 1 }, newText: str, expectedHash: str }, ["path", "oldText", "newText", "expectedHash"]),
|
|
98
|
+
paths: filePaths,
|
|
99
|
+
async execute(i, c) {
|
|
100
|
+
const path = await safePath(c.workspace, String(i.path)), before = await existing(path);
|
|
101
|
+
if (before === undefined)
|
|
102
|
+
throw new Error("File not found");
|
|
103
|
+
const needle = String(i.oldText), index = before.indexOf(needle);
|
|
104
|
+
if (index < 0 || before.indexOf(needle, index + 1) >= 0)
|
|
105
|
+
throw new Error("Edit must match exactly one occurrence");
|
|
106
|
+
const after = before.slice(0, index) + String(i.newText) + before.slice(index + needle.length);
|
|
107
|
+
await atomicWrite(c.workspace, path, after, String(i.expectedHash));
|
|
108
|
+
return textResult(JSON.stringify({ path: i.path, sha256: hash(after) }));
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
register({
|
|
112
|
+
name: "list_files",
|
|
113
|
+
effect: "read",
|
|
114
|
+
description: "List gitignore-aware files using ripgrep, paginated. Hidden files are excluded.",
|
|
115
|
+
inputSchema: objectSchema({
|
|
116
|
+
path: str,
|
|
117
|
+
offset: integer,
|
|
118
|
+
limit: { type: "integer", minimum: 1, maximum: 500 },
|
|
119
|
+
}),
|
|
120
|
+
paths: filePaths,
|
|
121
|
+
async execute(i, c) {
|
|
122
|
+
const path = await safePath(c.workspace, String(i.path ?? "."));
|
|
123
|
+
const result = await runProcess("rg", ["--files", "--glob", "!.git", "--glob", "!node_modules", "--", path], { cwd: c.workspace, signal: c.signal, maxBytes: 200_000 });
|
|
124
|
+
if (result.exitCode > 1 && !result.truncated)
|
|
125
|
+
throw new Error(result.stderr);
|
|
126
|
+
const all = result.stdout
|
|
127
|
+
.trim()
|
|
128
|
+
.split("\n")
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((p) => relative(c.workspace, p))
|
|
131
|
+
.sort(), offset = Number(i.offset ?? 0), limit = Number(i.limit ?? 100);
|
|
132
|
+
return textResult(JSON.stringify({
|
|
133
|
+
files: all.slice(offset, offset + limit),
|
|
134
|
+
nextOffset: offset + limit < all.length ? offset + limit : null,
|
|
135
|
+
truncated: result.truncated,
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
register({
|
|
140
|
+
name: "search_text",
|
|
141
|
+
effect: "read",
|
|
142
|
+
description: "Search text using ripgrep regex, JSON results and bounded output; exit code 1 means no matches.",
|
|
143
|
+
inputSchema: objectSchema({ pattern: str, path: str, literal: { type: "boolean" } }, [
|
|
144
|
+
"pattern",
|
|
145
|
+
]),
|
|
146
|
+
paths: filePaths,
|
|
147
|
+
async execute(i, c) {
|
|
148
|
+
const path = await safePath(c.workspace, String(i.path ?? "."));
|
|
149
|
+
const r = await runProcess("rg", [
|
|
150
|
+
"--json",
|
|
151
|
+
"--max-count",
|
|
152
|
+
"100",
|
|
153
|
+
"--glob",
|
|
154
|
+
"!.git",
|
|
155
|
+
"--glob",
|
|
156
|
+
"!node_modules",
|
|
157
|
+
...(i.literal ? ["--fixed-strings"] : []),
|
|
158
|
+
"--",
|
|
159
|
+
String(i.pattern),
|
|
160
|
+
path,
|
|
161
|
+
], { cwd: c.workspace, signal: c.signal });
|
|
162
|
+
if (r.exitCode > 1 && !r.truncated)
|
|
163
|
+
throw new Error(r.stderr);
|
|
164
|
+
return textResult(JSON.stringify(r));
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
register({
|
|
168
|
+
name: "run_process",
|
|
169
|
+
effect: "process",
|
|
170
|
+
description: "Run an executable with an argument array. Not sandboxed; may access outside workspace.",
|
|
171
|
+
inputSchema: objectSchema({
|
|
172
|
+
command: str,
|
|
173
|
+
args: { type: "array", items: str },
|
|
174
|
+
timeoutMs: { type: "integer", minimum: 1, maximum: 120000 },
|
|
175
|
+
}, ["command", "args"]),
|
|
176
|
+
async execute(i, c) {
|
|
177
|
+
return textResult(JSON.stringify(await runProcess(String(i.command), i.args, {
|
|
178
|
+
cwd: c.workspace,
|
|
179
|
+
signal: c.signal,
|
|
180
|
+
timeoutMs: Number(i.timeoutMs ?? 30000),
|
|
181
|
+
onData: c.emit,
|
|
182
|
+
})));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
register({
|
|
186
|
+
name: "shell",
|
|
187
|
+
effect: "process",
|
|
188
|
+
description: "Run an arbitrary shell command. Explicit process capability required; not sandboxed.",
|
|
189
|
+
inputSchema: objectSchema({ command: str }, ["command"]),
|
|
190
|
+
async execute(i, c) {
|
|
191
|
+
return textResult(JSON.stringify(await runProcess(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32"
|
|
192
|
+
? ["/d", "/s", "/c", String(i.command)]
|
|
193
|
+
: ["-c", String(i.command)], { cwd: c.workspace, signal: c.signal, onData: c.emit })));
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
for (const [name, args] of [
|
|
197
|
+
["git_status", ["status", "--porcelain=v1"]],
|
|
198
|
+
["git_diff", ["diff", "--no-ext-diff", "--no-textconv"]],
|
|
199
|
+
])
|
|
200
|
+
register({
|
|
201
|
+
name,
|
|
202
|
+
effect: "read",
|
|
203
|
+
description: `Read ${name} without mutating the repository.`,
|
|
204
|
+
inputSchema: objectSchema({}),
|
|
205
|
+
async execute(_i, c) {
|
|
206
|
+
const r = await runProcess("git", ["--no-pager", ...args], {
|
|
207
|
+
cwd: c.workspace,
|
|
208
|
+
signal: c.signal,
|
|
209
|
+
});
|
|
210
|
+
if (r.exitCode !== 0)
|
|
211
|
+
throw new Error(r.stderr);
|
|
212
|
+
return textResult(JSON.stringify(r));
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
register({
|
|
216
|
+
name: "skill_load",
|
|
217
|
+
effect: "read",
|
|
218
|
+
description: "Load a skill's full instructions on demand.",
|
|
219
|
+
inputSchema: objectSchema({ name: str }, ["name"]),
|
|
220
|
+
async execute(i) {
|
|
221
|
+
return textResult(await skills.load(String(i.name)));
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
register({
|
|
225
|
+
name: "skill_search",
|
|
226
|
+
effect: "read",
|
|
227
|
+
description: "Search available skill metadata.",
|
|
228
|
+
inputSchema: objectSchema({ query: str }, ["query"]),
|
|
229
|
+
async execute(i) {
|
|
230
|
+
const q = String(i.query).toLowerCase();
|
|
231
|
+
return textResult(JSON.stringify([...skills.items.values()]
|
|
232
|
+
.filter((s) => `${s.name} ${s.description}`.toLowerCase().includes(q))
|
|
233
|
+
.slice(0, 30)));
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
register({
|
|
237
|
+
name: "skill_resource",
|
|
238
|
+
effect: "read",
|
|
239
|
+
description: "Read a supporting file relative to a skill root.",
|
|
240
|
+
inputSchema: objectSchema({ name: str, path: str }, ["name", "path"]),
|
|
241
|
+
async execute(i) {
|
|
242
|
+
return textResult(await skills.resource(String(i.name), String(i.path)));
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
register({
|
|
246
|
+
name: "context_explain",
|
|
247
|
+
effect: "read",
|
|
248
|
+
description: "Inspect scoped AGENTS.md for a target file.",
|
|
249
|
+
inputSchema: objectSchema({ path: str }, ["path"]),
|
|
250
|
+
paths: filePaths,
|
|
251
|
+
async execute(i) {
|
|
252
|
+
return textResult(JSON.stringify(await context.explain(String(i.path))));
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
register({
|
|
256
|
+
name: "ask_user_question",
|
|
257
|
+
effect: "read",
|
|
258
|
+
description: "Ask the user one or more multiple-choice questions when there is a real fork in the " +
|
|
259
|
+
"approach and their preference genuinely changes what you do next. Each question needs 2-4 " +
|
|
260
|
+
"concrete options; mark at most one `recommended` when you have a clear opinion, but it is " +
|
|
261
|
+
"only a suggestion, never forced on the user. Prefer a single round: only ask a follow-up " +
|
|
262
|
+
"batch afterward if the answers create a genuinely new fork. Only usable in an interactive " +
|
|
263
|
+
"session; if it is not, this call fails and you should ask the question in plain text instead " +
|
|
264
|
+
"and wait for the user's normal reply.",
|
|
265
|
+
inputSchema: objectSchema({
|
|
266
|
+
questions: {
|
|
267
|
+
type: "array",
|
|
268
|
+
minItems: 1,
|
|
269
|
+
maxItems: 4,
|
|
270
|
+
items: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
header: { type: "string", minLength: 1, maxLength: 40 },
|
|
274
|
+
question: { type: "string", minLength: 1 },
|
|
275
|
+
multiSelect: { type: "boolean" },
|
|
276
|
+
options: {
|
|
277
|
+
type: "array",
|
|
278
|
+
minItems: 2,
|
|
279
|
+
maxItems: 4,
|
|
280
|
+
items: objectSchema({
|
|
281
|
+
label: { type: "string", minLength: 1 },
|
|
282
|
+
description: str,
|
|
283
|
+
recommended: { type: "boolean" },
|
|
284
|
+
}, ["label"]),
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
required: ["header", "question", "options"],
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
}, ["questions"]),
|
|
292
|
+
async execute(i, c) {
|
|
293
|
+
if (!ui?.interactive())
|
|
294
|
+
return textResult(JSON.stringify({
|
|
295
|
+
error: "ask_user_question is unavailable: this session is not interactive (headless or " +
|
|
296
|
+
"--json). Ask the question in plain text in your reply instead and wait for the " +
|
|
297
|
+
"user's normal answer; never rely on this tool here.",
|
|
298
|
+
}), true);
|
|
299
|
+
const specs = i.questions;
|
|
300
|
+
for (const spec of specs) {
|
|
301
|
+
const recommended = spec.options.filter((o) => o.recommended).length;
|
|
302
|
+
if (recommended > 1)
|
|
303
|
+
throw new Error(`At most one option may be recommended (question "${spec.header}")`);
|
|
304
|
+
const labels = new Set(spec.options.map((o) => o.label));
|
|
305
|
+
if (labels.size !== spec.options.length)
|
|
306
|
+
throw new Error(`Option labels must be unique within a question ("${spec.header}")`);
|
|
307
|
+
}
|
|
308
|
+
const questions = specs.map((spec, index) => ({
|
|
309
|
+
id: `q${index}`,
|
|
310
|
+
header: spec.header,
|
|
311
|
+
question: spec.question,
|
|
312
|
+
multiSelect: spec.multiSelect,
|
|
313
|
+
options: spec.options.map((o) => ({
|
|
314
|
+
value: o.label,
|
|
315
|
+
label: o.label,
|
|
316
|
+
...(o.description ? { description: o.description } : {}),
|
|
317
|
+
...(o.recommended ? { recommended: true } : {}),
|
|
318
|
+
})),
|
|
319
|
+
}));
|
|
320
|
+
const result = await ui.askQuestions({
|
|
321
|
+
questions,
|
|
322
|
+
...(c.session ? { session: c.session } : {}),
|
|
323
|
+
...(c.label ? { label: c.label } : {}),
|
|
324
|
+
signal: c.signal,
|
|
325
|
+
});
|
|
326
|
+
const answers = specs.map((spec, index) => {
|
|
327
|
+
const value = result[`q${index}`];
|
|
328
|
+
if (value === undefined)
|
|
329
|
+
return { header: spec.header, skipped: true, selected: [] };
|
|
330
|
+
return {
|
|
331
|
+
header: spec.header,
|
|
332
|
+
skipped: false,
|
|
333
|
+
selected: Array.isArray(value) ? value : [value],
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
return textResult(JSON.stringify({ answers }));
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
register({
|
|
340
|
+
name: "webfetch",
|
|
341
|
+
effect: "external",
|
|
342
|
+
description: "Fetch a URL as text, markdown or HTML (default markdown). Follows redirects; refuses " +
|
|
343
|
+
`binary/image responses; capped at ${MAX_RESPONSE_BYTES} bytes. Long pages are truncated ` +
|
|
344
|
+
"in the result with a `fullTextPath` you can read in full with read_file.",
|
|
345
|
+
inputSchema: objectSchema({
|
|
346
|
+
url: str,
|
|
347
|
+
format: { type: "string", enum: ["markdown", "text", "html"] },
|
|
348
|
+
timeout: {
|
|
349
|
+
type: "integer",
|
|
350
|
+
minimum: 1,
|
|
351
|
+
maximum: MAX_TIMEOUT_SECONDS,
|
|
352
|
+
description: "Seconds, default 30, capped at 120",
|
|
353
|
+
},
|
|
354
|
+
}, ["url"]),
|
|
355
|
+
async execute(i, c) {
|
|
356
|
+
const result = await webfetch(c.workspace, String(i.url), i.format ?? "markdown", Number(i.timeout ?? DEFAULT_TIMEOUT_SECONDS), c.signal);
|
|
357
|
+
return textResult(JSON.stringify(result));
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
// In "native" mode the provider answers search server-side (see application.ts); registering
|
|
361
|
+
// this tool too would just confuse the model with a second, redundant search path.
|
|
362
|
+
if (websearchCtx?.config?.provider !== "native")
|
|
363
|
+
register({
|
|
364
|
+
name: "websearch",
|
|
365
|
+
effect: "external",
|
|
366
|
+
description: "Search the web and return {title, url, snippet} results. Uses a configured provider " +
|
|
367
|
+
"(websearch.provider in config), or a public SearXNG instance by default; check the " +
|
|
368
|
+
"result's `limitation` field, since some providers (e.g. duckduckgo-instant) only answer " +
|
|
369
|
+
"direct factual queries, not general search.",
|
|
370
|
+
inputSchema: objectSchema({ query: { type: "string", minLength: 1 } }, ["query"]),
|
|
371
|
+
async execute(i, c) {
|
|
372
|
+
const outcome = await searchWithFallback(String(i.query), websearchCtx?.config, websearchCtx?.resolveExtension, c.signal);
|
|
373
|
+
return textResult(JSON.stringify({
|
|
374
|
+
results: outcome.results,
|
|
375
|
+
source: outcome.source,
|
|
376
|
+
...(outcome.limitation ? { limitation: outcome.limitation } : {}),
|
|
377
|
+
...(outcome.diagnostic ? { diagnostic: outcome.diagnostic } : {}),
|
|
378
|
+
}));
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
register({
|
|
382
|
+
name: "execute",
|
|
383
|
+
effect: "process",
|
|
384
|
+
description: "Run a short JavaScript snippet (Code Mode) that can call other tools via " +
|
|
385
|
+
"`await callTool(name, input)` and return one final JSON-serializable value, so " +
|
|
386
|
+
"intermediate tool results never re-enter your context — only the return value does. " +
|
|
387
|
+
"The snippet runs in an isolated context with no filesystem, network, imports or timers " +
|
|
388
|
+
"of its own; every nested callTool still goes through this session's normal permission " +
|
|
389
|
+
`gate (an effect not already allowed is denied, never newly approved). Bounded to ` +
|
|
390
|
+
`${EXECUTE_TIMEOUT_MS}ms wall-clock and ${MAX_NESTED_CALLS} nested tool calls. This is a ` +
|
|
391
|
+
"correctness sandbox, not an OS security boundary. Prefer plain sequential tool calls " +
|
|
392
|
+
"unless you are combining several read-only calls or post-processing their output.",
|
|
393
|
+
inputSchema: objectSchema({ code: { type: "string", minLength: 1 } }, ["code"]),
|
|
394
|
+
async execute(i, c) {
|
|
395
|
+
const policy = execCtx?.policy() ?? { write: false, process: false, external: false };
|
|
396
|
+
const result = await runExecute(String(i.code), {
|
|
397
|
+
registry,
|
|
398
|
+
policy,
|
|
399
|
+
workspace: c.workspace,
|
|
400
|
+
signal: c.signal,
|
|
401
|
+
emit: c.emit,
|
|
402
|
+
...(c.session ? { session: c.session } : {}),
|
|
403
|
+
...(c.label ? { label: c.label } : {}),
|
|
404
|
+
});
|
|
405
|
+
return textResult(JSON.stringify({ result: result ?? null }));
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|