min-agent 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/tools/read.js
CHANGED
|
@@ -1,10 +1,57 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import {
|
|
2
|
+
import { createReadStream } from "fs";
|
|
3
|
+
import { stat, readFile } from "fs/promises";
|
|
4
|
+
import { createInterface } from "readline";
|
|
3
5
|
import path from "path";
|
|
4
|
-
import { truncateToolOutput } from "../tool-output.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { TOOL_OUTPUT_MAX_BYTES, truncateToolOutput } from "../tool-output.js";
|
|
7
|
+
import { getActiveInstructionTracker } from "../instructions.js";
|
|
8
|
+
import { pathAccessError } from "../sandbox.js";
|
|
9
|
+
/** Files above this size are streamed instead of loaded into memory. */
|
|
10
|
+
const STREAM_THRESHOLD_BYTES = 20 * 1024 * 1024;
|
|
11
|
+
/** Stream lines [start, end] (1-indexed, inclusive) without loading the whole file. */
|
|
12
|
+
function readLineRange(filePath, start, end) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const lines = [];
|
|
15
|
+
const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
|
|
16
|
+
let i = 0;
|
|
17
|
+
rl.on("line", (line) => {
|
|
18
|
+
i++;
|
|
19
|
+
if (i >= start && i <= end)
|
|
20
|
+
lines.push(line);
|
|
21
|
+
if (i > end)
|
|
22
|
+
rl.close();
|
|
23
|
+
});
|
|
24
|
+
rl.on("close", () => resolve(lines.join("\n")));
|
|
25
|
+
rl.on("error", reject);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Stream the first maxBytes of a file, cut on a UTF-8 safe boundary. */
|
|
29
|
+
function readStreamHead(filePath, maxBytes) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let total = 0;
|
|
33
|
+
let truncated = false;
|
|
34
|
+
let totalLines = 0;
|
|
35
|
+
const stream = createReadStream(filePath);
|
|
36
|
+
stream.on("data", (chunk) => {
|
|
37
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
38
|
+
total += buf.byteLength;
|
|
39
|
+
if (total > maxBytes) {
|
|
40
|
+
const keep = buf.byteLength - (total - maxBytes);
|
|
41
|
+
chunks.push(buf.subarray(0, keep));
|
|
42
|
+
truncated = true;
|
|
43
|
+
stream.destroy();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(buf);
|
|
47
|
+
});
|
|
48
|
+
stream.on("close", () => {
|
|
49
|
+
totalLines = Buffer.concat(chunks).toString("utf-8").split("\n").length;
|
|
50
|
+
resolve({ text: Buffer.concat(chunks).toString("utf-8"), truncated, totalLines });
|
|
51
|
+
});
|
|
52
|
+
stream.on("error", reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
8
55
|
export const readTool = tool({
|
|
9
56
|
description: "Read the contents of a file. Returns the file content as text. Use this to understand code, check configurations, etc.",
|
|
10
57
|
inputSchema: jsonSchema({
|
|
@@ -17,24 +64,39 @@ export const readTool = tool({
|
|
|
17
64
|
required: ["filePath"],
|
|
18
65
|
}),
|
|
19
66
|
execute: async ({ filePath, startLine, endLine }) => {
|
|
67
|
+
const denied = pathAccessError(filePath, "read");
|
|
68
|
+
if (denied)
|
|
69
|
+
return `Error: ${denied}`;
|
|
20
70
|
const resolved = path.resolve(process.cwd(), filePath);
|
|
21
71
|
try {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
72
|
+
const info = await stat(resolved);
|
|
73
|
+
if (info.isDirectory())
|
|
24
74
|
return `Error: ${filePath} is a directory, not a file`;
|
|
25
|
-
|
|
75
|
+
if (info.size > STREAM_THRESHOLD_BYTES && !startLine && !endLine) {
|
|
76
|
+
const { text, truncated, totalLines } = await readStreamHead(resolved, TOOL_OUTPUT_MAX_BYTES);
|
|
77
|
+
let result = text;
|
|
78
|
+
if (truncated) {
|
|
79
|
+
result += `\n\n...(file too large: ${info.size} bytes, ${totalLines}+ lines — use startLine/endLine to read specific ranges)...`;
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
26
83
|
let result;
|
|
27
|
-
if (startLine || endLine) {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
84
|
+
if (startLine != null || endLine != null) {
|
|
85
|
+
const start = startLine ?? 1;
|
|
86
|
+
const end = endLine ?? Number.MAX_SAFE_INTEGER;
|
|
87
|
+
if (start < 1)
|
|
88
|
+
return `Error: startLine must be >= 1`;
|
|
89
|
+
if (endLine != null && end < start)
|
|
90
|
+
return `Error: endLine (${end}) must be >= startLine (${start})`;
|
|
91
|
+
result = await readLineRange(resolved, start, end);
|
|
92
|
+
if (!result)
|
|
93
|
+
return `Error: no lines found in range ${start}-${end}`;
|
|
32
94
|
}
|
|
33
95
|
else {
|
|
34
|
-
result =
|
|
96
|
+
result = await readFile(resolved, "utf-8");
|
|
35
97
|
}
|
|
36
98
|
// Context-aware: discover nearby instruction files
|
|
37
|
-
const nearbyInstructions =
|
|
99
|
+
const nearbyInstructions = getActiveInstructionTracker().resolveForFile(resolved);
|
|
38
100
|
if (nearbyInstructions.length > 0) {
|
|
39
101
|
result += "\n\n<system-reminder>\n" + nearbyInstructions.join("\n\n") + "\n</system-reminder>";
|
|
40
102
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { truncateToolOutput } from "../tool-output.js";
|
|
3
|
+
import { configHint, resolveBackendBase } from "./backend.js";
|
|
4
|
+
import { LANGUAGE_RE, MAX_BODY_BYTES, VALID_TIME_RANGES, clamp, collapse, dedupeByUrl, emptyResponse, fetchJsonOutcome, formatResults, isString, resolveMaxResults, } from "./web_search.js";
|
|
5
|
+
/** SearXNG is used when explicitly configured (webSearchURL / MIN_AGENT_SEARXNG_URL). */
|
|
6
|
+
export const SEARXNG_BACKEND = {
|
|
7
|
+
envName: "MIN_AGENT_SEARXNG_URL",
|
|
8
|
+
configKey: "webSearchURL",
|
|
9
|
+
fallback: "https://searxng.xc.lonae.com",
|
|
10
|
+
};
|
|
11
|
+
const SEARCH_TIMEOUT_MS = 20000;
|
|
12
|
+
const DEFAULT_FALLBACK_ENGINES = "google,bing";
|
|
13
|
+
export function searxngConfigured() {
|
|
14
|
+
const env = process.env[SEARXNG_BACKEND.envName];
|
|
15
|
+
if (env?.trim())
|
|
16
|
+
return true;
|
|
17
|
+
const configured = loadConfig().webSearchURL;
|
|
18
|
+
return !!configured?.trim();
|
|
19
|
+
}
|
|
20
|
+
function asResult(r) {
|
|
21
|
+
if (typeof r !== "object" || r === null)
|
|
22
|
+
return null;
|
|
23
|
+
const o = r;
|
|
24
|
+
if (!isString(o.url))
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
title: isString(o.title) ? o.title : "",
|
|
28
|
+
url: o.url,
|
|
29
|
+
content: isString(o.content) ? o.content : "",
|
|
30
|
+
engine: isString(o.engine) ? o.engine : "",
|
|
31
|
+
score: typeof o.score === "number" ? o.score : 0,
|
|
32
|
+
publishedDate: isString(o.publishedDate) ? o.publishedDate : "",
|
|
33
|
+
imgSrc: isString(o.img_src) ? o.img_src : isString(o.thumbnail) ? o.thumbnail : "",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseResponse(data) {
|
|
37
|
+
if (typeof data !== "object" || data === null)
|
|
38
|
+
return emptyResponse();
|
|
39
|
+
const o = data;
|
|
40
|
+
const results = (Array.isArray(o.results) ? o.results : [])
|
|
41
|
+
.map(asResult)
|
|
42
|
+
.filter((r) => r !== null);
|
|
43
|
+
// Some engines return answers as objects ({ answer, url }) rather than plain strings.
|
|
44
|
+
const answers = (Array.isArray(o.answers) ? o.answers : [])
|
|
45
|
+
.map((a) => {
|
|
46
|
+
if (isString(a))
|
|
47
|
+
return a;
|
|
48
|
+
if (typeof a === "object" && a !== null) {
|
|
49
|
+
const rec = a;
|
|
50
|
+
if (isString(rec.answer))
|
|
51
|
+
return rec.answer;
|
|
52
|
+
if (isString(rec.content))
|
|
53
|
+
return rec.content;
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
})
|
|
57
|
+
.filter((a) => a !== "");
|
|
58
|
+
const suggestions = Array.isArray(o.suggestions) ? o.suggestions.filter(isString) : [];
|
|
59
|
+
const corrections = Array.isArray(o.corrections) ? o.corrections.filter(isString) : [];
|
|
60
|
+
const infoboxes = (Array.isArray(o.infoboxes) ? o.infoboxes : [])
|
|
61
|
+
.map((b) => {
|
|
62
|
+
if (typeof b !== "object" || b === null)
|
|
63
|
+
return null;
|
|
64
|
+
const box = b;
|
|
65
|
+
return {
|
|
66
|
+
infobox: isString(box.infobox) ? box.infobox : "",
|
|
67
|
+
content: isString(box.content) ? box.content : "",
|
|
68
|
+
};
|
|
69
|
+
})
|
|
70
|
+
.filter((b) => b !== null && (b.infobox !== "" || b.content !== ""));
|
|
71
|
+
const unresponsive = (Array.isArray(o.unresponsive_engines) ? o.unresponsive_engines : [])
|
|
72
|
+
.map((e) => {
|
|
73
|
+
if (!Array.isArray(e) || !isString(e[0]))
|
|
74
|
+
return null;
|
|
75
|
+
return [e[0], isString(e[1]) ? e[1] : ""];
|
|
76
|
+
})
|
|
77
|
+
.filter((e) => e !== null);
|
|
78
|
+
return { results, answers, suggestions, corrections, infoboxes, unresponsive_engines: unresponsive };
|
|
79
|
+
}
|
|
80
|
+
async function searchOnce(base, params) {
|
|
81
|
+
return fetchJsonOutcome(`${base}/search?${params}`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS) }, parseResponse);
|
|
82
|
+
}
|
|
83
|
+
function renderFailure(o) {
|
|
84
|
+
const hint = configHint(SEARXNG_BACKEND);
|
|
85
|
+
switch (o.kind) {
|
|
86
|
+
case "network":
|
|
87
|
+
return `Search error: could not reach the search backend (${o.message}). The instance may be down or the network may be unavailable — this is not a problem with the query. ${hint}`;
|
|
88
|
+
case "invalid-json":
|
|
89
|
+
if (o.bodyTooLarge) {
|
|
90
|
+
return `Search error: the search backend response exceeded ${MAX_BODY_BYTES / 1024 / 1024} MB and could not be parsed. Narrow the query or request fewer engines.`;
|
|
91
|
+
}
|
|
92
|
+
return `Search error: the search backend returned a non-JSON response${o.sample ? ` (starts with: ${o.sample})` : ""}. The instance most likely has the JSON output format disabled. ${hint}`;
|
|
93
|
+
case "http": {
|
|
94
|
+
const detail = o.backendMessage ? ` Backend message: ${o.backendMessage}.` : "";
|
|
95
|
+
if (o.status === 429) {
|
|
96
|
+
return `Search error: the search backend is rate-limiting (HTTP 429).${detail} Wait a few seconds before retrying, or use a different instance. ${hint}`;
|
|
97
|
+
}
|
|
98
|
+
if (o.status === 401 || o.status === 403) {
|
|
99
|
+
return `Search error: the search backend rejected the request (HTTP ${o.status}).${detail} The instance likely requires authentication or has the JSON output format disabled. ${hint}`;
|
|
100
|
+
}
|
|
101
|
+
if (o.status === 400 || o.status === 422) {
|
|
102
|
+
return `Search error: the search backend rejected these parameters (HTTP ${o.status}).${detail} Fix the arguments and retry; retrying with the same values will fail again.`;
|
|
103
|
+
}
|
|
104
|
+
if (o.status >= 500) {
|
|
105
|
+
return `Search error: the search backend failed with HTTP ${o.status}.${detail} It may be temporarily overloaded — retry once, then try a different instance. ${hint}`;
|
|
106
|
+
}
|
|
107
|
+
return `Search error: HTTP ${o.status} from the search backend.${detail} ${hint}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function fallbackEngines() {
|
|
112
|
+
const env = process.env.MIN_AGENT_SEARXNG_FALLBACK_ENGINES;
|
|
113
|
+
if (env !== undefined)
|
|
114
|
+
return env.trim();
|
|
115
|
+
const configured = loadConfig().webSearchFallbackEngines;
|
|
116
|
+
if (configured !== undefined)
|
|
117
|
+
return configured.trim();
|
|
118
|
+
return DEFAULT_FALLBACK_ENGINES;
|
|
119
|
+
}
|
|
120
|
+
/** Validate/normalize the model-supplied arguments, collecting notes for anything ignored. */
|
|
121
|
+
function buildParams(input, notes) {
|
|
122
|
+
const params = new URLSearchParams({ q: input.query, format: "json", categories: "general" });
|
|
123
|
+
const lang = (input.language ?? "").trim();
|
|
124
|
+
if (lang) {
|
|
125
|
+
if (LANGUAGE_RE.test(lang))
|
|
126
|
+
params.set("language", lang.replace("_", "-"));
|
|
127
|
+
else
|
|
128
|
+
notes.push(`ignored invalid language "${lang}"; use a code like "zh", "en" or "zh-CN"`);
|
|
129
|
+
}
|
|
130
|
+
const range = (input.time_range ?? "").trim().toLowerCase();
|
|
131
|
+
if (range) {
|
|
132
|
+
if (VALID_TIME_RANGES.has(range))
|
|
133
|
+
params.set("time_range", range);
|
|
134
|
+
else
|
|
135
|
+
notes.push(`ignored invalid time_range "${range}"; valid values: day, week, month, year`);
|
|
136
|
+
}
|
|
137
|
+
return params;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* SearXNG reports the instance-wide unresponsive list regardless of which
|
|
141
|
+
* engines a request used, so narrow it to the requested set when there is one
|
|
142
|
+
* and say so otherwise.
|
|
143
|
+
*/
|
|
144
|
+
function unresponsiveHint(engines, requested) {
|
|
145
|
+
let relevant = engines;
|
|
146
|
+
if (requested && requested.length > 0) {
|
|
147
|
+
const wanted = new Set(requested.map((e) => e.toLowerCase()));
|
|
148
|
+
relevant = engines.filter(([name]) => wanted.has(name.toLowerCase()));
|
|
149
|
+
}
|
|
150
|
+
if (relevant.length === 0)
|
|
151
|
+
return "";
|
|
152
|
+
const detail = relevant.map(([name, reason]) => (reason ? `${name} (${reason})` : name)).join(", ");
|
|
153
|
+
const scope = requested
|
|
154
|
+
? "The requested engines are unavailable"
|
|
155
|
+
: "The backend reports these engines as unavailable (instance-wide list, may include engines this query did not use)";
|
|
156
|
+
return `${scope}: ${detail}. Try a different query, or pass "engines" explicitly (e.g. "google,bing").`;
|
|
157
|
+
}
|
|
158
|
+
export async function runSearxng(input, query, notes) {
|
|
159
|
+
const resolved = resolveBackendBase(SEARXNG_BACKEND, loadConfig().webSearchURL);
|
|
160
|
+
if (!resolved.ok)
|
|
161
|
+
return `Search error: ${resolved.error}. ${configHint(SEARXNG_BACKEND)}`;
|
|
162
|
+
const maxResults = resolveMaxResults(input.max_results, notes);
|
|
163
|
+
const params = buildParams({ ...input, query }, notes);
|
|
164
|
+
let outcome = await searchOnce(resolved.base, params);
|
|
165
|
+
// The default engine set on a given instance can be entirely blocked
|
|
166
|
+
// (CAPTCHA / rate limits), which looks identical to "no such page exists".
|
|
167
|
+
// A single retry against known-good engines distinguishes the two. The
|
|
168
|
+
// unresponsive list is instance-wide and therefore not a reliable trigger.
|
|
169
|
+
let usedFallback = null;
|
|
170
|
+
if (outcome.kind === "ok" && outcome.data.results.length === 0) {
|
|
171
|
+
const fallback = fallbackEngines();
|
|
172
|
+
if (fallback) {
|
|
173
|
+
const retryParams = new URLSearchParams(params);
|
|
174
|
+
retryParams.set("engines", fallback);
|
|
175
|
+
const retry = await searchOnce(resolved.base, retryParams);
|
|
176
|
+
if (retry.kind === "ok" && retry.data.results.length > 0) {
|
|
177
|
+
outcome = retry;
|
|
178
|
+
usedFallback = fallback;
|
|
179
|
+
}
|
|
180
|
+
else if (retry.kind !== "ok") {
|
|
181
|
+
notes.push(`the fallback engines (${fallback}) also failed: ${renderFailure(retry).replace(/^Search error: /, "")}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (outcome.kind !== "ok")
|
|
186
|
+
return renderFailure(outcome);
|
|
187
|
+
const data = outcome.data;
|
|
188
|
+
const results = dedupeByUrl(data.results).slice(0, maxResults);
|
|
189
|
+
const noteBlock = notes.map((n) => `Note: ${n}`).join("\n");
|
|
190
|
+
if (results.length === 0) {
|
|
191
|
+
const parts = [`No search results found for "${query}".`];
|
|
192
|
+
if (noteBlock)
|
|
193
|
+
parts.push(noteBlock);
|
|
194
|
+
const hint = unresponsiveHint(data.unresponsive_engines, usedFallback ? usedFallback.split(",") : null);
|
|
195
|
+
if (hint)
|
|
196
|
+
parts.push(hint);
|
|
197
|
+
if (data.corrections.length > 0)
|
|
198
|
+
parts.push(`Did you mean: ${data.corrections.join(", ")}`);
|
|
199
|
+
if (data.suggestions.length > 0)
|
|
200
|
+
parts.push(`Related queries: ${data.suggestions.slice(0, 8).join(", ")}`);
|
|
201
|
+
if (!hint && data.corrections.length === 0 && data.suggestions.length === 0) {
|
|
202
|
+
parts.push("Try a different query or a broader wording.");
|
|
203
|
+
}
|
|
204
|
+
return parts.join("\n\n");
|
|
205
|
+
}
|
|
206
|
+
// Direct answers and infoboxes carry the highest information density, so
|
|
207
|
+
// they precede the result list.
|
|
208
|
+
const sections = [];
|
|
209
|
+
if (noteBlock)
|
|
210
|
+
sections.push(noteBlock);
|
|
211
|
+
for (const a of data.answers)
|
|
212
|
+
sections.push(`Answer: ${collapse(a)}`);
|
|
213
|
+
for (const b of data.infoboxes) {
|
|
214
|
+
sections.push(`Infobox${b.infobox ? ` (${collapse(b.infobox)})` : ""}: ${clamp(collapse(b.content), 600)}`);
|
|
215
|
+
}
|
|
216
|
+
sections.push(`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}"${usedFallback ? ` via fallback engines ${usedFallback}` : ""}:`);
|
|
217
|
+
sections.push(formatResults(results));
|
|
218
|
+
if (data.corrections.length > 0)
|
|
219
|
+
sections.push(`Did you mean: ${data.corrections.join(", ")}`);
|
|
220
|
+
if (data.suggestions.length > 0)
|
|
221
|
+
sections.push(`Related queries: ${data.suggestions.slice(0, 8).join(", ")}`);
|
|
222
|
+
return truncateToolOutput(sections.join("\n\n"), { direction: "head" }).content;
|
|
223
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { truncateToolOutput } from "../tool-output.js";
|
|
3
|
+
import { LANGUAGE_RE, MAX_BODY_BYTES, VALID_TIME_RANGES, clamp, collapse, dedupeByUrl, emptyResponse, fetchJsonOutcome, formatResults, hostOf, isString, resolveMaxResults, } from "./web_search.js";
|
|
4
|
+
const SERPER_KEY_ENV = "MIN_AGENT_SERPER_API_KEY";
|
|
5
|
+
const SERPER_API = "https://google.serper.dev/search";
|
|
6
|
+
const SERPER_TIMEOUT_MS = 35000;
|
|
7
|
+
export const DEFAULT_SERPER_API_KEY = "7913c19ae7a80320771b843ddffa152c9162c9f2";
|
|
8
|
+
/** Google's tbs time filter for each supported time_range value. */
|
|
9
|
+
const TBS_BY_RANGE = {
|
|
10
|
+
day: "qdr:d",
|
|
11
|
+
week: "qdr:w",
|
|
12
|
+
month: "qdr:m",
|
|
13
|
+
year: "qdr:y",
|
|
14
|
+
};
|
|
15
|
+
function serperKey() {
|
|
16
|
+
const env = process.env[SERPER_KEY_ENV];
|
|
17
|
+
if (env?.trim())
|
|
18
|
+
return env.trim();
|
|
19
|
+
const configured = loadConfig().serperApiKey;
|
|
20
|
+
if (configured?.trim())
|
|
21
|
+
return configured.trim();
|
|
22
|
+
return DEFAULT_SERPER_API_KEY.trim();
|
|
23
|
+
}
|
|
24
|
+
function serperHint() {
|
|
25
|
+
return `Set your Serper API key via "serperApiKey" in ~/.min-agent/config.json or the ${SERPER_KEY_ENV} env var, or configure a SearXNG instance via "webSearchURL" / MIN_AGENT_SEARXNG_URL.`;
|
|
26
|
+
}
|
|
27
|
+
function asSerperResult(r) {
|
|
28
|
+
if (typeof r !== "object" || r === null)
|
|
29
|
+
return null;
|
|
30
|
+
const o = r;
|
|
31
|
+
if (!isString(o.link) || o.link.trim() === "")
|
|
32
|
+
return null;
|
|
33
|
+
return {
|
|
34
|
+
title: isString(o.title) ? o.title : "",
|
|
35
|
+
url: o.link,
|
|
36
|
+
content: isString(o.snippet) ? o.snippet : "",
|
|
37
|
+
engine: hostOf(o.link),
|
|
38
|
+
score: 0,
|
|
39
|
+
publishedDate: isString(o.date) ? o.date : "",
|
|
40
|
+
imgSrc: "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function emptySerper() {
|
|
44
|
+
return { ...emptyResponse(), questions: [] };
|
|
45
|
+
}
|
|
46
|
+
function stringList(v, key) {
|
|
47
|
+
return (Array.isArray(v) ? v : [])
|
|
48
|
+
.map((item) => {
|
|
49
|
+
if (typeof item === "object" && item !== null) {
|
|
50
|
+
const rec = item;
|
|
51
|
+
if (isString(rec[key]))
|
|
52
|
+
return rec[key];
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
})
|
|
56
|
+
.filter((s) => s.trim() !== "");
|
|
57
|
+
}
|
|
58
|
+
function parseSerper(data) {
|
|
59
|
+
if (typeof data !== "object" || data === null)
|
|
60
|
+
return emptySerper();
|
|
61
|
+
const o = data;
|
|
62
|
+
const results = (Array.isArray(o.organic) ? o.organic : [])
|
|
63
|
+
.map(asSerperResult)
|
|
64
|
+
.filter((r) => r !== null);
|
|
65
|
+
const answers = [];
|
|
66
|
+
const ab = o.answerBox;
|
|
67
|
+
if (typeof ab === "object" && ab !== null) {
|
|
68
|
+
const rec = ab;
|
|
69
|
+
for (const key of ["answer", "snippet", "title"]) {
|
|
70
|
+
if (isString(rec[key]) && rec[key].trim()) {
|
|
71
|
+
answers.push(collapse(rec[key]));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const suggestions = stringList(o.relatedSearches, "query");
|
|
77
|
+
const questions = stringList(o.peopleAlsoAsk, "question");
|
|
78
|
+
const infoboxes = [];
|
|
79
|
+
const kg = o.knowledgeGraph;
|
|
80
|
+
if (typeof kg === "object" && kg !== null) {
|
|
81
|
+
const rec = kg;
|
|
82
|
+
const title = isString(rec.title) ? rec.title : "";
|
|
83
|
+
const description = isString(rec.description) ? rec.description : "";
|
|
84
|
+
if (title || description)
|
|
85
|
+
infoboxes.push({ infobox: title, content: description });
|
|
86
|
+
}
|
|
87
|
+
return { results, answers, suggestions, corrections: [], infoboxes, unresponsive_engines: [], questions };
|
|
88
|
+
}
|
|
89
|
+
async function serperSearch(payload, apiKey) {
|
|
90
|
+
return fetchJsonOutcome(SERPER_API, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"X-API-KEY": apiKey,
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Accept: "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(payload),
|
|
98
|
+
signal: AbortSignal.timeout(SERPER_TIMEOUT_MS),
|
|
99
|
+
}, parseSerper);
|
|
100
|
+
}
|
|
101
|
+
function renderSerperFailure(o) {
|
|
102
|
+
const hint = serperHint();
|
|
103
|
+
switch (o.kind) {
|
|
104
|
+
case "network":
|
|
105
|
+
return `Search error: could not reach the Serper search API (${o.message}). This is not a problem with the query.`;
|
|
106
|
+
case "invalid-json":
|
|
107
|
+
if (o.bodyTooLarge) {
|
|
108
|
+
return `Search error: the search API response exceeded ${MAX_BODY_BYTES / 1024 / 1024} MB and could not be parsed. Narrow the query or request fewer results.`;
|
|
109
|
+
}
|
|
110
|
+
return `Search error: the search API returned a non-JSON response${o.sample ? ` (starts with: ${o.sample})` : ""}.`;
|
|
111
|
+
case "http": {
|
|
112
|
+
const detail = o.backendMessage ? ` Backend message: ${o.backendMessage}.` : "";
|
|
113
|
+
if (o.status === 429) {
|
|
114
|
+
return `Search error: the search API is rate-limiting (HTTP 429).${detail} Wait a few seconds before retrying.`;
|
|
115
|
+
}
|
|
116
|
+
if (o.status === 401 || o.status === 403) {
|
|
117
|
+
return `Search error: the search API rejected the API key (HTTP ${o.status}).${detail} ${hint}`;
|
|
118
|
+
}
|
|
119
|
+
if (o.status === 400 || o.status === 422) {
|
|
120
|
+
return `Search error: the search API rejected these parameters (HTTP ${o.status}).${detail} Fix the arguments and retry; retrying with the same values will fail again.`;
|
|
121
|
+
}
|
|
122
|
+
if (o.status >= 500) {
|
|
123
|
+
return `Search error: the search API failed with HTTP ${o.status}.${detail} It may be temporarily overloaded — retry once.`;
|
|
124
|
+
}
|
|
125
|
+
return `Search error: HTTP ${o.status} from the search API.${detail}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function buildSerperPayload(input, notes, maxResults) {
|
|
130
|
+
const payload = { q: input.query, num: maxResults };
|
|
131
|
+
const lang = (input.language ?? "").trim().toLowerCase();
|
|
132
|
+
if (lang) {
|
|
133
|
+
if (LANGUAGE_RE.test(lang) && lang !== "all") {
|
|
134
|
+
payload.hl = lang.replace("_", "-");
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
notes.push(`ignored invalid language "${input.language}"; use a code like "zh", "en" or "zh-CN"`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const range = (input.time_range ?? "").trim().toLowerCase();
|
|
141
|
+
if (range) {
|
|
142
|
+
if (VALID_TIME_RANGES.has(range))
|
|
143
|
+
payload.tbs = TBS_BY_RANGE[range];
|
|
144
|
+
else
|
|
145
|
+
notes.push(`ignored invalid time_range "${range}"; valid values: day, week, month, year`);
|
|
146
|
+
}
|
|
147
|
+
return payload;
|
|
148
|
+
}
|
|
149
|
+
export async function runSerper(input, query, notes) {
|
|
150
|
+
const apiKey = serperKey();
|
|
151
|
+
if (!apiKey)
|
|
152
|
+
return `Search error: no Serper API key configured. ${serperHint()}`;
|
|
153
|
+
const maxResults = resolveMaxResults(input.max_results, notes);
|
|
154
|
+
const payload = buildSerperPayload(input, notes, maxResults);
|
|
155
|
+
const outcome = await serperSearch(payload, apiKey);
|
|
156
|
+
if (outcome.kind !== "ok")
|
|
157
|
+
return renderSerperFailure(outcome);
|
|
158
|
+
const data = outcome.data;
|
|
159
|
+
const results = dedupeByUrl(data.results).slice(0, maxResults);
|
|
160
|
+
const noteBlock = notes.map((n) => `Note: ${n}`).join("\n");
|
|
161
|
+
if (results.length === 0) {
|
|
162
|
+
const parts = [`No search results found for "${query}".`];
|
|
163
|
+
if (noteBlock)
|
|
164
|
+
parts.push(noteBlock);
|
|
165
|
+
if (data.questions.length > 0)
|
|
166
|
+
parts.push(`Related questions: ${data.questions.slice(0, 5).join(" · ")}`);
|
|
167
|
+
if (data.suggestions.length > 0)
|
|
168
|
+
parts.push(`Related searches: ${data.suggestions.slice(0, 8).join(" · ")}`);
|
|
169
|
+
if (data.questions.length === 0 && data.suggestions.length === 0) {
|
|
170
|
+
parts.push("Try a different query or a broader wording.");
|
|
171
|
+
}
|
|
172
|
+
return parts.join("\n\n");
|
|
173
|
+
}
|
|
174
|
+
const sections = [];
|
|
175
|
+
if (noteBlock)
|
|
176
|
+
sections.push(noteBlock);
|
|
177
|
+
for (const a of data.answers)
|
|
178
|
+
sections.push(`Answer: ${collapse(a)}`);
|
|
179
|
+
for (const b of data.infoboxes) {
|
|
180
|
+
sections.push(`Infobox${b.infobox ? ` (${collapse(b.infobox)})` : ""}: ${clamp(collapse(b.content), 600)}`);
|
|
181
|
+
}
|
|
182
|
+
sections.push(`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}":`);
|
|
183
|
+
sections.push(formatResults(results));
|
|
184
|
+
if (data.questions.length > 0)
|
|
185
|
+
sections.push(`Related questions: ${data.questions.slice(0, 5).join(" · ")}`);
|
|
186
|
+
if (data.suggestions.length > 0)
|
|
187
|
+
sections.push(`Related searches: ${data.suggestions.slice(0, 8).join(" · ")}`);
|
|
188
|
+
return truncateToolOutput(sections.join("\n\n"), { direction: "head" }).content;
|
|
189
|
+
}
|