min-agent 0.5.1 → 0.6.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.
Files changed (169) hide show
  1. package/README.md +18 -18
  2. package/dist/cli.js +538 -12
  3. package/docs/API.md +6 -6
  4. package/package.json +6 -6
  5. package/skills/self-config/SKILL.md +4 -2
  6. package/skills/self-config/reference.md +4 -1
  7. package/dist/agent.js +0 -1388
  8. package/dist/assistant-stream.js +0 -131
  9. package/dist/cli/commands/chat.js +0 -13
  10. package/dist/cli/commands/ctx.js +0 -7
  11. package/dist/cli/commands/exec.js +0 -35
  12. package/dist/cli/commands/history.js +0 -58
  13. package/dist/cli/commands/index.js +0 -249
  14. package/dist/cli/commands/init.js +0 -18
  15. package/dist/cli/commands/mcp.js +0 -173
  16. package/dist/cli/commands/memory.js +0 -87
  17. package/dist/cli/commands/models.js +0 -21
  18. package/dist/cli/commands/permission.js +0 -12
  19. package/dist/cli/commands/rules.js +0 -33
  20. package/dist/cli/commands/sandbox.js +0 -13
  21. package/dist/cli/commands/serve.js +0 -9
  22. package/dist/cli/commands/setup.js +0 -56
  23. package/dist/cli/commands/shared.js +0 -25
  24. package/dist/cli/commands/skills.js +0 -119
  25. package/dist/cli/commands/think.js +0 -12
  26. package/dist/cli/commands/update.js +0 -7
  27. package/dist/cli/commands/write-config.js +0 -52
  28. package/dist/cli/errors.js +0 -36
  29. package/dist/cli/exec-prompt.js +0 -26
  30. package/dist/cli/option-helpers.js +0 -65
  31. package/dist/cli/program.js +0 -223
  32. package/dist/cli/setup/detect.js +0 -17
  33. package/dist/cli/setup/flags.js +0 -12
  34. package/dist/cli/setup/flow.js +0 -108
  35. package/dist/cli/setup/provider-form.js +0 -102
  36. package/dist/cli/setup/ui.js +0 -534
  37. package/dist/clipboard.js +0 -142
  38. package/dist/code-mode.js +0 -184
  39. package/dist/compaction.js +0 -644
  40. package/dist/config.js +0 -363
  41. package/dist/confirm.js +0 -172
  42. package/dist/context-window.js +0 -256
  43. package/dist/ctx-cli.js +0 -30
  44. package/dist/ctx.js +0 -80
  45. package/dist/doom-loop.js +0 -278
  46. package/dist/fetch-timeout.js +0 -152
  47. package/dist/http-approvals.js +0 -60
  48. package/dist/http.js +0 -119
  49. package/dist/instructions.js +0 -196
  50. package/dist/logger.js +0 -95
  51. package/dist/markdown.js +0 -259
  52. package/dist/mcp.js +0 -1036
  53. package/dist/memory-cli.js +0 -33
  54. package/dist/memory.js +0 -295
  55. package/dist/model-catalog.js +0 -285
  56. package/dist/ollama-model.js +0 -234
  57. package/dist/ollama-openai-bridge.js +0 -383
  58. package/dist/output.js +0 -76
  59. package/dist/paste-handler.js +0 -41
  60. package/dist/permission-cli.js +0 -40
  61. package/dist/plugins.js +0 -131
  62. package/dist/pricing.js +0 -119
  63. package/dist/provider.js +0 -63
  64. package/dist/question-format.js +0 -60
  65. package/dist/reasoning-stream.js +0 -158
  66. package/dist/sandbox-cli.js +0 -79
  67. package/dist/sandbox.js +0 -403
  68. package/dist/save-throttle.js +0 -45
  69. package/dist/scope.js +0 -23
  70. package/dist/serve/common.js +0 -425
  71. package/dist/serve/routes-chat.js +0 -367
  72. package/dist/serve/routes-mcp.js +0 -212
  73. package/dist/serve/routes-memory.js +0 -95
  74. package/dist/serve/routes-meta.js +0 -268
  75. package/dist/serve/routes-sessions.js +0 -61
  76. package/dist/serve/routes-skills.js +0 -70
  77. package/dist/serve.js +0 -138
  78. package/dist/sessions.js +0 -265
  79. package/dist/skills.js +0 -599
  80. package/dist/structured-output.js +0 -29
  81. package/dist/synthetic.js +0 -7
  82. package/dist/think-cli.js +0 -36
  83. package/dist/thinking-wire.js +0 -239
  84. package/dist/thinking.js +0 -166
  85. package/dist/title-gen.js +0 -55
  86. package/dist/token-display.js +0 -39
  87. package/dist/tool-display.js +0 -178
  88. package/dist/tool-output.js +0 -126
  89. package/dist/tools/apply_patch.js +0 -265
  90. package/dist/tools/atomic-file.js +0 -35
  91. package/dist/tools/backend.js +0 -61
  92. package/dist/tools/bash.js +0 -231
  93. package/dist/tools/code_search.js +0 -98
  94. package/dist/tools/edit.js +0 -68
  95. package/dist/tools/explore.js +0 -232
  96. package/dist/tools/glob.js +0 -40
  97. package/dist/tools/grep.js +0 -174
  98. package/dist/tools/index.js +0 -34
  99. package/dist/tools/question.js +0 -54
  100. package/dist/tools/read.js +0 -109
  101. package/dist/tools/search-searxng.js +0 -223
  102. package/dist/tools/search-serper.js +0 -189
  103. package/dist/tools/task.js +0 -165
  104. package/dist/tools/todo.js +0 -213
  105. package/dist/tools/web_fetch.js +0 -195
  106. package/dist/tools/web_search.js +0 -228
  107. package/dist/tools/write.js +0 -55
  108. package/dist/tui/App.js +0 -138
  109. package/dist/tui/ConfirmBar.js +0 -66
  110. package/dist/tui/CtxPicker.js +0 -68
  111. package/dist/tui/InputBar.js +0 -618
  112. package/dist/tui/MessageList.js +0 -718
  113. package/dist/tui/ModelPicker.js +0 -113
  114. package/dist/tui/QuestionBar.js +0 -136
  115. package/dist/tui/SessionPicker.js +0 -79
  116. package/dist/tui/Spinner.js +0 -6
  117. package/dist/tui/StatusBar.js +0 -22
  118. package/dist/tui/ThinkPicker.js +0 -75
  119. package/dist/tui/agent-runner.js +0 -223
  120. package/dist/tui/bracketed-paste.js +0 -37
  121. package/dist/tui/caret-pos.js +0 -179
  122. package/dist/tui/caret.js +0 -69
  123. package/dist/tui/click-count.js +0 -13
  124. package/dist/tui/diff-view.js +0 -61
  125. package/dist/tui/drag-state.js +0 -49
  126. package/dist/tui/hydrate.js +0 -129
  127. package/dist/tui/index.js +0 -264
  128. package/dist/tui/input-history.js +0 -125
  129. package/dist/tui/layout.js +0 -105
  130. package/dist/tui/mouse.js +0 -46
  131. package/dist/tui/overlay-input.js +0 -12
  132. package/dist/tui/paste-draft.js +0 -173
  133. package/dist/tui/prompt-queue.js +0 -24
  134. package/dist/tui/selection.js +0 -232
  135. package/dist/tui/session-switch.js +0 -28
  136. package/dist/tui/slash-commands.js +0 -129
  137. package/dist/tui/slash-handler.js +0 -615
  138. package/dist/tui/text-width.js +0 -113
  139. package/dist/tui/theme.js +0 -12
  140. package/dist/tui/token-info.js +0 -7
  141. package/dist/tui/tool-children.js +0 -19
  142. package/dist/tui/types.js +0 -1
  143. package/dist/tui/undo-stack.js +0 -14
  144. package/dist/tui/use-sgr-mouse.js +0 -29
  145. package/dist/tui-chat.js +0 -478
  146. package/dist/updater.js +0 -116
  147. package/dist/xml-search.js +0 -194
  148. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +0 -1510
  149. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +0 -2105
  150. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +0 -1595
  151. package/docs/superpowers/plans/2026-08-16-input-caret.md +0 -782
  152. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +0 -873
  153. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +0 -631
  154. package/docs/superpowers/plans/2026-08-23-cli-setup.md +0 -501
  155. package/docs/superpowers/plans/2026-08-23-input-paste-attachments.md +0 -475
  156. package/docs/superpowers/plans/2026-08-23-thinking-wire-profile.md +0 -450
  157. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +0 -183
  158. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +0 -220
  159. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +0 -196
  160. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +0 -63
  161. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +0 -116
  162. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +0 -47
  163. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +0 -37
  164. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +0 -68
  165. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +0 -273
  166. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +0 -165
  167. package/docs/superpowers/specs/2026-08-23-cli-setup-design.md +0 -282
  168. package/docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md +0 -174
  169. package/docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md +0 -140
@@ -1,174 +0,0 @@
1
- import { tool, jsonSchema } from "ai";
2
- import { readdir, readFile, stat } from "fs/promises";
3
- import { spawn } from "child_process";
4
- import path from "path";
5
- import { truncateToolOutput } from "../tool-output.js";
6
- import { pathAccessError } from "../sandbox.js";
7
- const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".out", "coverage", ".min-agent"]);
8
- const MAX_RESULTS = 500;
9
- const MAX_FILE_BYTES = 5 * 1024 * 1024;
10
- const MAX_RG_OUTPUT_BYTES = 8 * 1024 * 1024;
11
- const WALK_CONCURRENCY = 8;
12
- /** Convert a simple glob like "*.ts" into an anchored regex for file names. */
13
- function globToRegex(glob) {
14
- const escaped = glob
15
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
16
- .replace(/\*/g, ".*")
17
- .replace(/\?/g, ".");
18
- return new RegExp(`^${escaped}$`);
19
- }
20
- /** Fast path: shell out to ripgrep when available. Resolves null if rg is missing or fails. */
21
- function rgSearch(pattern, root, includeRegex, ignore) {
22
- const args = [
23
- "--no-heading",
24
- "--line-number",
25
- "-i",
26
- "-e",
27
- pattern,
28
- "--max-count",
29
- "1",
30
- "-g",
31
- "!*.lock",
32
- "--max-filesize",
33
- String(MAX_FILE_BYTES),
34
- ];
35
- for (const dir of ignore)
36
- args.push("-g", `!${dir}/**`);
37
- if (includeRegex) {
38
- const glob = includeRegex.source.replace(/^\^/, "").replace(/\$$/, "").replace(/\\\./g, ".").replace(/\.\*/g, "*");
39
- args.push("-g", glob);
40
- }
41
- args.push(root);
42
- return new Promise((resolve) => {
43
- const child = spawn("rg", args, { stdio: ["ignore", "pipe", "pipe"] });
44
- const chunks = [];
45
- let total = 0;
46
- let settled = false;
47
- const finish = (value) => {
48
- if (settled)
49
- return;
50
- settled = true;
51
- clearTimeout(timer);
52
- resolve(value);
53
- };
54
- const timer = setTimeout(() => {
55
- child.kill("SIGKILL");
56
- finish(null);
57
- }, 15000);
58
- child.stdout.on("data", (chunk) => {
59
- total += chunk.byteLength;
60
- if (total > MAX_RG_OUTPUT_BYTES) {
61
- child.kill("SIGKILL");
62
- finish(null);
63
- return;
64
- }
65
- chunks.push(chunk);
66
- });
67
- child.on("error", () => finish(null));
68
- child.on("close", (code) => {
69
- if (code === 1)
70
- finish([]);
71
- else if (code === 0)
72
- finish(Buffer.concat(chunks).toString("utf-8").split("\n").filter(Boolean).slice(0, MAX_RESULTS));
73
- else
74
- finish(null);
75
- });
76
- });
77
- }
78
- async function walk(dir, regex, includeRegex, results) {
79
- const dirQueue = [dir];
80
- const worker = async () => {
81
- while (dirQueue.length > 0 && results.length < MAX_RESULTS) {
82
- const current = dirQueue.pop();
83
- let entries;
84
- try {
85
- entries = await readdir(current, { withFileTypes: true });
86
- }
87
- catch {
88
- continue;
89
- }
90
- await Promise.all(entries.map(async (entry) => {
91
- const full = path.join(current, entry.name);
92
- if (entry.isDirectory()) {
93
- if (!IGNORED_DIRS.has(entry.name) && results.length < MAX_RESULTS)
94
- dirQueue.push(full);
95
- return;
96
- }
97
- if (!entry.isFile())
98
- return;
99
- if (includeRegex && !includeRegex.test(entry.name))
100
- return;
101
- try {
102
- const info = await stat(full);
103
- if (info.size > MAX_FILE_BYTES)
104
- return;
105
- const content = await readFile(full, "utf-8");
106
- if (content.includes("\0"))
107
- return;
108
- const lines = content.split("\n");
109
- for (let i = 0; i < lines.length && results.length < MAX_RESULTS; i++) {
110
- if (regex.test(lines[i])) {
111
- results.push(`${path.relative(process.cwd(), full)}:${i + 1}:${lines[i]}`);
112
- }
113
- }
114
- }
115
- catch {
116
- // unreadable file — skip
117
- }
118
- }));
119
- }
120
- };
121
- await Promise.all(Array.from({ length: WALK_CONCURRENCY }, worker));
122
- }
123
- export const grepTool = tool({
124
- description: "Search for a pattern in files using regex. Returns matching lines with file paths and line numbers. Use this to find code references, usages, and definitions.",
125
- inputSchema: jsonSchema({
126
- type: "object",
127
- properties: {
128
- pattern: { type: "string", description: "The regex pattern to search for" },
129
- path: { type: "string", description: "File or directory path to search in (defaults to current directory)" },
130
- include: { type: "string", description: "File pattern to include (e.g. '*.ts')" },
131
- },
132
- required: ["pattern"],
133
- }),
134
- execute: async ({ pattern, path: searchPath, include }) => {
135
- try {
136
- const regex = new RegExp(pattern);
137
- const includeRegex = include ? globToRegex(include) : null;
138
- const root = path.resolve(process.cwd(), searchPath ?? ".");
139
- const denied = pathAccessError(root, "read");
140
- if (denied)
141
- return `Error: ${denied}`;
142
- const results = [];
143
- const info = await stat(root).catch(() => null);
144
- if (!info)
145
- return `Error: path not found: ${searchPath ?? "."}`;
146
- if (info.isFile()) {
147
- try {
148
- const content = await readFile(root, "utf-8");
149
- if (!content.includes("\0")) {
150
- content.split("\n").forEach((line, i) => {
151
- if (regex.test(line))
152
- results.push(`${root}:${i + 1}:${line}`);
153
- });
154
- }
155
- }
156
- catch { }
157
- }
158
- else {
159
- const rgResults = await rgSearch(pattern, root, includeRegex, [...IGNORED_DIRS]);
160
- if (rgResults !== null) {
161
- results.push(...rgResults);
162
- }
163
- else {
164
- await walk(root, regex, includeRegex, results);
165
- }
166
- }
167
- const text = results.length > 0 ? results.join("\n") : "No matches found";
168
- return truncateToolOutput(text, { direction: "head" }).content;
169
- }
170
- catch (err) {
171
- return `Error: ${err.message}`;
172
- }
173
- },
174
- });
@@ -1,34 +0,0 @@
1
- import { bashTool } from "./bash.js";
2
- import { readTool } from "./read.js";
3
- import { writeTool } from "./write.js";
4
- import { editTool } from "./edit.js";
5
- import { applyPatchTool } from "./apply_patch.js";
6
- import { globTool } from "./glob.js";
7
- import { grepTool } from "./grep.js";
8
- import { webSearchTool } from "./web_search.js";
9
- import { webFetchTool } from "./web_fetch.js";
10
- import { questionTool } from "./question.js";
11
- import { codeSearchTool } from "./code_search.js";
12
- /**
13
- * Builtin tools shared by main runs and sub-agents. `todo` is deliberately
14
- * absent: it must be bound to a per-run TaskState, so callers register their
15
- * own instance via createTodoTool.
16
- */
17
- export function createTools() {
18
- const tools = {
19
- bash: bashTool,
20
- read: readTool,
21
- write: writeTool,
22
- edit: editTool,
23
- apply_patch: applyPatchTool,
24
- glob: globTool,
25
- grep: grepTool,
26
- search_web: webSearchTool,
27
- web_fetch: webFetchTool,
28
- question: questionTool,
29
- };
30
- if (process.env.EXA_API_KEY) {
31
- tools.codesearch = codeSearchTool;
32
- }
33
- return tools;
34
- }
@@ -1,54 +0,0 @@
1
- import { tool, jsonSchema } from "ai";
2
- import { askQuestion } from "../confirm.js";
3
- import { normalizeQuestionOptions, normalizeQuestionPrompt } from "../question-format.js";
4
- export const questionTool = tool({
5
- description: `Ask the user a question to get clarification before proceeding. Use this when:
6
- - The task is ambiguous and you need more information
7
- - There are multiple valid approaches and you want the user to choose
8
- - You need confirmation before a potentially destructive action
9
- - You're unsure about a requirement or preference
10
-
11
- Provide a clear, specific question. Optional choices should be an array of strings. Object items with label/description are also accepted.`,
12
- inputSchema: jsonSchema({
13
- type: "object",
14
- properties: {
15
- question: { type: "string", description: "The question to ask the user" },
16
- options: {
17
- type: "array",
18
- items: {
19
- anyOf: [
20
- { type: "string" },
21
- {
22
- type: "object",
23
- properties: {
24
- label: { type: "string" },
25
- description: { type: "string" },
26
- key: { type: "string" },
27
- },
28
- },
29
- ],
30
- },
31
- description: "Optional list of choices. Prefer plain strings; {label, description} objects are also accepted.",
32
- },
33
- },
34
- required: ["question"],
35
- }),
36
- execute: async ({ question, options }) => {
37
- const prompt = normalizeQuestionPrompt(question);
38
- if (!prompt.trim())
39
- return "Error: question is required.";
40
- const choices = normalizeQuestionOptions(options);
41
- const answer = await askQuestion(prompt, choices.length > 0 ? choices : undefined);
42
- if (answer === null)
43
- return "User cancelled the question";
44
- if (choices.length > 0) {
45
- const exact = choices.find((c) => c === answer);
46
- if (exact)
47
- return `User chose: ${exact}`;
48
- const idx = parseInt(answer, 10) - 1;
49
- if (Number.isInteger(idx) && idx >= 0 && idx < choices.length)
50
- return `User chose: ${choices[idx]}`;
51
- }
52
- return `User answered: ${answer}`;
53
- },
54
- });
@@ -1,109 +0,0 @@
1
- import { tool, jsonSchema } from "ai";
2
- import { createReadStream } from "fs";
3
- import { stat, readFile } from "fs/promises";
4
- import { createInterface } from "readline";
5
- import path from "path";
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
- }
55
- export const readTool = tool({
56
- description: "Read the contents of a file. Returns the file content as text. Use this to understand code, check configurations, etc.",
57
- inputSchema: jsonSchema({
58
- type: "object",
59
- properties: {
60
- filePath: { type: "string", description: "Path to the file to read (relative to cwd or absolute)" },
61
- startLine: { type: "number", description: "Start line number (1-indexed)" },
62
- endLine: { type: "number", description: "End line number (1-indexed, inclusive)" },
63
- },
64
- required: ["filePath"],
65
- }),
66
- execute: async ({ filePath, startLine, endLine }) => {
67
- const denied = pathAccessError(filePath, "read");
68
- if (denied)
69
- return `Error: ${denied}`;
70
- const resolved = path.resolve(process.cwd(), filePath);
71
- try {
72
- const info = await stat(resolved);
73
- if (info.isDirectory())
74
- return `Error: ${filePath} is a directory, not a file`;
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
- }
83
- let result;
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}`;
94
- }
95
- else {
96
- result = await readFile(resolved, "utf-8");
97
- }
98
- // Context-aware: discover nearby instruction files
99
- const nearbyInstructions = getActiveInstructionTracker().resolveForFile(resolved);
100
- if (nearbyInstructions.length > 0) {
101
- result += "\n\n<system-reminder>\n" + nearbyInstructions.join("\n\n") + "\n</system-reminder>";
102
- }
103
- return truncateToolOutput(result, { direction: "head" }).content;
104
- }
105
- catch (err) {
106
- return `Error reading file: ${err.message}`;
107
- }
108
- },
109
- });
@@ -1,223 +0,0 @@
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
- }